Skip to content

eventtap: setProperty choose CGEventField setter based on value type#3859

Open
mogenson wants to merge 2 commits intoHammerspoon:masterfrom
mogenson:eventtap_event_set_hidden_property
Open

eventtap: setProperty choose CGEventField setter based on value type#3859
mogenson wants to merge 2 commits intoHammerspoon:masterfrom
mogenson:eventtap_event_set_hidden_property

Conversation

@mogenson
Copy link
Copy Markdown
Contributor

eventtap event's setProperty() method uses CGEventSetDoubleValueField for a defined list of CGEventField properties, and CGEventSetIntegerValueField for all others.

Keep the list of double type CGEventField properties, but make a change to check the type of the value parameter for the all others case.

If the value is a Lua integer, use CGEventSetIntegerValueField. If the value is a Lua number (aka double), use the CGEventSetDoubleValueField.

The motivation for this change is to use setProperty() to set private / hidden event fields. These event field constants are not defined in any public headers from Apple so it doesn't make sense to add a mapping from string to constant value in the hs.eventtap.event.types table. However, a spoon or Hammerspoon config can set these event fields with setProperty(property, value) by passing an integer for the property and integer or number for the value.

Also add a doc note clarifying which CGEvent API is used depending on the Lua value type passed.

eventtap event's setProperty() method uses CGEventSetDoubleValueField
for a defined list of CGEventField properties, and
CGEventSetIntegerValueField for all others.

Keep the list of double type CGEventField properties, but make a change
to check the type of the value parameter for the all others case.

If the value is a Lua integer, use CGEventSetIntegerValueField. If the
value is a Lua number (aka double), use the CGEventSetDoubleValueField.

The motivation for this change is to use setProperty() to set private /
hidden event fields. These event field constants are not defined in any
public headers from Apple so it doesn't make sense to add a mapping from
string to constant value in the hs.eventtap.event.types table.
However, a spoon or Hammerspoon config can set these event fields with
setProperty(property, value) by passing an integer for the property and
integer or number for the value.

Also add a doc note clarifying which CGEvent API is used depending
on the Lua value type passed.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request does not contain a valid label. Please add one of the following labels: ['pr-fix', 'pr-change', 'pr-feature', 'pr-maintenance']

@mogenson
Copy link
Copy Markdown
Contributor Author

Some people have discovered a very cool way to switch spaces without the animation by posting a swipe gesture with a very fast velocity: https://github.com/joshuarli/iss

It would be great if we could use this technique in Hammerspoon. However, it requires setting some undocumented / hidden CGEventFields. I'm not sure about the best way to go about this. The fast swipe gesture only works on spaces on the same screen, so it's not a suitable replacement for hs.spaces.gotoSpace(). The CGEventField values are not defined any any public Apple header and are subject to change / break so I'm hesitant to manually define them in the hs.eventtap.event.types table. I settled on making a small modification to hs.eventtap.event:setProperty() to select either the CGEventSetIntegerValueField or CGEventSetDoubleValueField setter based on the Lua type of the value parameter.

With this change we can create a fast swipe gesture to switch spaces without an animation by doing the following:

local FLT_TRUE_MIN = 2 ^ -149
local kCGEventGestureHIDType = 110
local kCGEventGesturePhase = 132
local kCGEventGestureScrollY = 119
local kCGEventGestureSwipeMotion = 123
local kCGEventGestureSwipeProgress = 124
local kCGEventGestureSwipeVelocityX = 129
local kCGEventGestureSwipeVelocityY = 130
local kCGEventGestureZoomDeltaX = 139
local kCGEventScrollGestureFlagBits = 135
local kCGGestureMotionHorizontal = 1
local kCGSEventDockControl = 30
local kCGSEventGesture = 29
local kCGSEventTypeField = 55
local kCGSGesturePhaseBegan = 1
local kCGSGesturePhaseEnded = 4
local kIOHIDEventTypeDockSwipe = 23

function fast_switch_space(direction_right)
  -- begin gesture
  local ev = hs.eventtap.event.newEvent()
  ev:setProperty(kCGSEventTypeField, kCGSEventDockControl)
  ev:setProperty(kCGEventGestureHIDType, kIOHIDEventTypeDockSwipe)
  ev:setProperty(kCGEventGesturePhase, kCGSGesturePhaseBegan)
  ev:setProperty(kCGEventScrollGestureFlagBits, direction_right and 1 or 0)
  ev:setProperty(kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal)
  ev:setProperty(kCGEventGestureScrollY, 0.0)             -- must be a double value
  ev:setProperty(kCGEventGestureZoomDeltaX, FLT_TRUE_MIN) -- must be a double value
  ev:post()

  hs.eventtap.event.newEvent():setProperty(kCGSEventTypeField, kCGSEventGesture):post()

  -- end gesture
  ev = hs.eventtap.event.newEvent()
  ev:setProperty(kCGSEventTypeField, kCGSEventDockControl)
  ev:setProperty(kCGEventGestureHIDType, kIOHIDEventTypeDockSwipe)
  ev:setProperty(kCGEventGesturePhase, kCGSGesturePhaseEnded)
  ev:setProperty(kCGEventGestureSwipeProgress, direction_right and 2.0 or -2.0) -- double value
  ev:setProperty(kCGEventScrollGestureFlagBits, direction_right and 1 or 0)
  ev:setProperty(kCGEventGestureSwipeMotion, kCGGestureMotionHorizontal)
  ev:setProperty(kCGEventGestureScrollY, 0.0)                                        -- must be a double value
  ev:setProperty(kCGEventGestureSwipeVelocityX, direction_right and 400.0 or -400.0) -- must be a double value
  ev:setProperty(kCGEventGestureSwipeVelocityY, 0.0)                                 -- must be a double value
  ev:setProperty(kCGEventGestureZoomDeltaX, FLT_TRUE_MIN)                            -- must be a double value
  ev:post()

  hs.eventtap.event.newEvent():setProperty(kCGSEventTypeField, kCGSEventGesture):post()
end

As far as I know, this change preserves previous behavior because all the existing "(N)" CGEventField properties still explicitly call CGEventSetDoubleValueField (even if the value passed is an integer type). And, if any spoon or user was passing a number (double) value to any of the "(I)" CGEventField properties, this would have failed the luaL_checkinteger() getter (so hopefully spoons were not doing that)!

However, let me know if all CGEventField types should be defined in libeventtap_event.m (even private ones) or if there should be a new Lua API method for setting raw event properties.

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 30, 2026

Codecov Report

❌ Patch coverage is 52.63158% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 27.42%. Comparing base (08e93f6) to head (8ba08f8).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3859      +/-   ##
==========================================
+ Coverage   27.39%   27.42%   +0.03%     
==========================================
  Files         191      191              
  Lines       51537    51552      +15     
==========================================
+ Hits        14119    14140      +21     
+ Misses      37418    37412       -6     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@asmagill
Copy link
Copy Markdown
Member

asmagill commented Apr 1, 2026

Generally yes, even private properties are added to the appropriate tables (in this case, in libeventtap_event.m) -- it encourages experimentation and someone might find a sequence or set of values to perform something that we haven't thought of yet.

@ArhanChaudhary
Copy link
Copy Markdown

Related: #3850 (Although I do see that it cannot replace hs.spaces.gotoSpace)

Create constants for some of the undocumented CGEventField types found
in a WebKit test:
https://github.com/WebKit/webkit/blob/main/Tools/TestRunnerShared/spi/CoreGraphicsTestSPI.h
Add a mapping from string to value to the eventtap.event.properties
table. Use CGEventSetDoubleValueField to set an event property for
CGEventFields that use a double type.
@mogenson mogenson force-pushed the eventtap_event_set_hidden_property branch from 38ec451 to 8ba08f8 Compare April 3, 2026 13:07
@mogenson
Copy link
Copy Markdown
Contributor Author

mogenson commented Apr 3, 2026

Generally yes, even private properties are added to the appropriate tables (in this case, in libeventtap_event.m) -- it encourages experimentation and someone might find a sequence or set of values to perform something that we haven't thought of yet.

Sure, I've added a second commit to first define the private properties as static const CGEventFields, then add them to the hs.eventtap.event.properties table.

You can now amend the Lua example for fast space switching to use the properties table:

local props = hs.eventtap.event.properties

function fast_switch_space(direction_right)
  -- begin gesture
  local ev = hs.eventtap.event.newEvent()
  ev:setProperty(props.eventTypeField, kCGSEventDockControl)
  ev:setProperty(props.eventGestureHIDType, kIOHIDEventTypeDockSwipe)
  ev:setProperty(props.eventGesturePhase, kCGSGesturePhaseBegan)
  ev:setProperty(props.eventScrollGestureFlagBits, direction_right and 1 or 0)
  ev:setProperty(props.eventGestureSwipeMotion, kCGGestureMotionHorizontal)
  ev:setProperty(props.eventGestureScrollY, 0.0)             -- must be a double value
  ev:setProperty(props.eventGestureZoomDeltaX, FLT_TRUE_MIN) -- must be a double value
  ev:post()

  hs.eventtap.event.newEvent():setProperty(props.eventTypeField, kCGSEventGesture):post()

  -- end gesture
  ev = hs.eventtap.event.newEvent()
  ev:setProperty(props.eventTypeField, kCGSEventDockControl)
  ev:setProperty(props.eventGestureHIDType, kIOHIDEventTypeDockSwipe)
  ev:setProperty(props.eventGesturePhase, kCGSGesturePhaseEnded)
  ev:setProperty(props.eventGestureSwipeProgress, direction_right and 2.0 or -2.0) -- double value
  ev:setProperty(props.eventScrollGestureFlagBits, direction_right and 1 or 0)
  ev:setProperty(props.eventGestureSwipeMotion, kCGGestureMotionHorizontal)
  ev:setProperty(props.eventGestureScrollY, 0.0)                                        -- must be a double value
  ev:setProperty(props.eventGestureSwipeVelocityX, direction_right and 400.0 or -400.0) -- must be a double value
  ev:setProperty(props.eventGestureSwipeVelocityY, 0.0)                                 -- must be a double value
  ev:setProperty(props.eventGestureZoomDeltaX, FLT_TRUE_MIN)                            -- must be a double value
  ev:post()

  hs.eventtap.event.newEvent():setProperty(props.eventTypeField, kCGSEventGesture):post()
end

If this approach is correct, I can squash these two commits before merging.

CGEventRef event = *(CGEventRef*)luaL_checkudata(L, 1, EVENT_USERDATA_TAG);
CGEventField field = (CGEventField)(luaL_checkinteger(L, 2));
if ((field == kCGMouseEventPressure) || // These fields use a double (floating point number)
if ((field == kCGEventGestureScrollY) || // These fields use a double (floating point number)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really a very small list of properties - with just these added, and still no way to explicitly specify that you want to set (or get) the property as a double, eventually someone will have to come back to this list and add more. Let's not throw out the code to dynamically detect whether you want to set the value as an integer or double. In fact that code is always correct, since CG seems to internally cast values from int to double and vice versa when you get or set them with the wrong function.

Copy link
Copy Markdown

@tbodt tbodt Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact that code is always correct, since CG seems to internally cast values from int to double and vice versa when you get or set them with the wrong function.

My bad - turns out when you get or set a float property using ints, it just gets or sets 0. I'm now convinced it's really best to add special getPropertyFloat and setPropertyFloat functions.

(Using floats to get or set an int property works fine, though.)

}

// Undocumented event fields
static const CGEventField kCGEventGestureHIDType = (CGEventField)110;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static const CGEventField kCGEventGestureSwipeVelocityY = (CGEventField)130;
static const CGEventField kCGEventGestureZoomDeltaX = (CGEventField)139;
static const CGEventField kCGEventScrollGestureFlagBits = (CGEventField)135;
static const CGEventField kCGSEventTypeField = (CGEventField)55;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this it's the same as setType

@tbodt
Copy link
Copy Markdown

tbodt commented Apr 14, 2026

Today I did a little bit of experimentation with this technique (deleting things to see if it still works) and discovered

  • You don't need the empty gesture events in between the dock control events.
  • eventGestureZoomDeltaX and eventGestureScrollY and eventGestureZoomDeltaX and eventScrollGestureFlagBits don't need to be set, the defaults are fine. In fact setting them seems to...cause other properties to get set. Type confusion in WindowServer?! At least on my machine.
  • In particular, don't eventScrollGestureFlagBits because it collides with eventGestureSwipeProgress. (this confused me for a while - one popular program using this event tap technique sets eventScrollGestureFlagBits to a bit-banged floating point value in order to actually set eventGestureSwipeProgress.)

So the important properties are:

  • gesturePhase (int) - began, changed, ended, canceled. Real trackpad swipes will send a begin event once the gesture is detected, then changed events when the fingers move, then an ended or canceled event when fingers disappear.
  • gestureSwipeMotion (int) - whether this swipe gesture is vertical or horizontal. Easy to understand, just gets used to distinguish between space switching and mission control.
  • gestureSwipePositionX, gestureSwipePositionY (double) - relative change finger position since the last event, presumably gestureSwipeProgress is an accumulation of this. Both are set on all events regardless of gestureSwipeMotion value.
  • gestureSwipeProgress (double) - total distance traveled by the fingers so far during this gesture, on the axis given by gestureSwipeMotion, in units that confuse me (on my 13" MBP 1.5 is a full screen width but it's 1.2 on my 16" MBP?). On gesturePhase == changed, Dock sets the animation progress to this value. And on gesturePhase == ended, Dock decides whether the animation should go to the right or the left based on whether this is greater than or less than 0. (If exactly zero it goes to the left!)
  • gestureSwipeVelocityX, gestureSwipeVelocityY (double) - velocity of the fingers at the moment they disappeared. Only set on an ended or canceled event. Curiously they are always just set to the same value on real trackpad events. This is used to calculate the curve for the animation

The gestureSwipePositionX/Y values don't seem to matter for synthetic dockcontrol events, only gestureSwipeProgress - and if we're sending a one-shot synthesized swipe and not trying to create a cool animation, there's no need to specify gesturePhase. So I wrote these functions to create a nice but fully powerful interface:

function postDockControl(phase, opts)
	local ev = hs.eventtap.event.newEvent()
	ev:setType(types.dockControl)
	ev:setProperty(props.eventGestureHIDType, kIOHIDEventTypeDockSwipe)
	ev:setProperty(props.eventGesturePhase, phase)
	ev:setProperty(props.eventGestureSwipeMotion, opts.motion or kCGGestureMotionHorizontal)  
	ev:setProperty(props.eventGestureSwipeProgress, opts.distance or 0)
	ev:setProperty(props.eventGestureSwipeVelocityX, opts.velocity or 0)
	ev:setProperty(props.eventGestureSwipeVelocityY, opts.velocity or 0)
	ev:post()
end

function postDockGesture(opts)
	postDockControl(kCGSGesturePhaseBegan, opts)
	if not opts.cancel then
		postDockControl(kCGSGesturePhaseEnded, opts)
	else
		postDockControl(kCGSGesturePhaseCanceled, opts)
	end
end

directions = {left = -1, right = 1}
function switchSpaces(direction, speed)
	if direction < 0 then -- switch left
		postDockGesture{distance=-1, velocity=-speed}
	elseif direction > 0 then -- switch right
		postDockGesture{distance=1, velocity=speed}
	end
end

-- Usage examples
-- just switch spaces, default animation:
switchSpaces(directions.left)
-- with no animation:
switchSpaces(directions.left, 1000)
-- really funny animation where it starts by going the wrong way and the view goes off the list of spaces and becomes black before springing back and landing on the correct space
switchSpaces(directions.left, -1000)
-- get the screen stuck halfway between spaces, if you want:
postDockControl(kCGSGesturePhaseBegan, {}); postDockControl(kCGSGesturePhaseChanged, {distance=0.5})

@tbodt
Copy link
Copy Markdown

tbodt commented Apr 14, 2026

Oh and check out this super fun thing you can do

local tap = hs.eventtap.new({types.dockControl}, function(ev)
    if ev:getProperty(props.eventSourceUnixProcessID) ~= 0 then return end
    local phase = ev:getProperty(props.eventGesturePhase)
    if phase == gesturePhases.canceled then
        ev:setProperty(props.eventGesturePhase, gesturePhases.ended)
        phase = gesturePhases.ended
    end
    if phase == gesturePhases.ended then
        ev:setProperty(props.eventGestureSwipeVelocityX, ev:getProperty(props.eventGestureSwipeVelocityX) * 10000)
        ev:setProperty(props.eventGestureSwipeVelocityY, ev:getProperty(props.eventGestureSwipeVelocityY) * 10000)
        return true, {ev}
    end
end)
tap:start()

now all your trackpad swipes are amplified dramatically! never wait for an animation to finish again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants