Repository navigation
macOS platform support - #28
Merged
Merged
Conversation
Run deckd on macOS via a new platform_macos backend (osascript + cliclick
+ PyObjC Quartz). Linux uinput path is untouched.
daemon/deckd/platform_macos.py:
* MacFocusBackend - frontmost app + window title via System Events.
app_id is the process name (firefox, Safari, Terminal, iTerm2, ...).
* MacKeySink - osascript keystroke / key code. Printable chars round-
trip as keystroke '<char>'; non-printable use the HID map. Modifiers
translate ctrl->control, alt->option, super/meta->command.
* MacScrollSink - Quartz.CGEventCreateScrollWheelEvent at one detent
per 120 REL_WHEEL_HI_RES (matches the UinputSink ratio). Requires
pyobjc-framework-Quartz; falls back to log-only if absent.
* Pointer + click via cliclick when on PATH (brew install cliclick).
daemon/deckd/{__main__,platform,server,layouts}.py:
* default_backend() returns MacFocusBackend on darwin.
* __main__.py builds MacKeySink/MacScrollSink on darwin before the
logging-only fallback; logs which backend is in use.
* LayoutStore gains an optional overlay_dir: load_layouts(base, overlay)
loads overlay first and drops base entries with matching ids, so
layouts.macos/ can shadow layouts/ per-id without forking the base.
__main__.py auto-discovers layouts.<platform> next to --layouts-dir
(--no-overlay to skip). The layouts watcher watches both dirs.
* Server._is_deckd_window requires exact title match on 'deckd' to
avoid auto-ignore firing on tabs whose titles merely contain
'deckd' as a substring.
* input.name_from_keycode() prefers the single-char alias when a
code has multiple names (leftbrace/[ both map to 26; now returns '[').
pyproject.toml:
* New [macos] extra pulling pyobjc-framework-Quartz>=10.0.
* New name_from_keycode() helper exported from the daemon.
Justfile:
* setup-macos: brew install cliclick + uv pip install -e '.[dev,macos]'.
* setup: platform-branch to setup-macos on darwin, setup-linux else.
layouts.macos/:
* firefox.yaml - super+t / super+w / super+f / super+n / super+r /
super+l / super+[ / super+] (Mac-conventional shortcuts).
* default.yaml - generic 4x2 fallback (new tab, close, find, new
window, open URL, iTerm, Terminal, System Settings).
scripts/check_focus_macos.py:
* One-shot diagnostic: osascript focus query, auto-ignore verdict,
and which layout would resolve. Use it to debug 'layout stuck on X'
reports - tells you whether the daemon, TCC, or the client is at
fault.
README:
* macOS section: setup steps, capability matrix, layout overlay
convention, TCC heads-up.
* Configuration: per-platform overlay paragraph.
The trackpad's tap-and-a-half gesture needs a held-button drag -- cliclick
only knows 'click' (down+up in one shot) and 'move', which can't model
'press, move with button held, then release'. Switched the pointer + click
half of MacKeySink to PyObjC Quartz CGEvent and dropped cliclick as a
runtime dependency.
daemon/deckd/platform_macos.py (MacKeySink):
* Load Quartz lazily (same dep the scroll sink already uses).
* emit_pointer emits CGEventMouseMoved when no button is held,
CGEventLeftMouseDragged while a drag is in progress, with deltaX /
deltaY fields set from the wire's dx / dy.
* emit_click emits Left/Right MouseDown / MouseUp. The server's
pad_tap sequence (True then False) is a clean click; pad_drag start
fires MouseDown alone, pad_drag end fires MouseUp alone. Mid-drag
pointer events therefore carry the held-button flag through the
Quartz event stream, which is what makes native selections / window
drags feel right.
* _dragging_left tracks the held state across the async event loop.
* Removed shutil.which('cliclick') check + cliclick subprocess paths.
* Updated docstring capability matrix.
Justfile:
* setup-macos: dropped brew install cliclick -- Quartz covers
everything the trackpad and jogstrip need.
README:
* macOS section: cliclick is no longer a runtime dependency.
* Capability matrix: trackpad pointer / clicks row reads 'yes
(PyObjC Quartz)' instead of 'only with cliclick'.
CGEventCreateMouseEvent with a (0, 0) cursor position is read by Quartz as 'warp the cursor to (0, 0)' and the deltaX / deltaY fields are then ignored (or applied on top of the warp), so every trackpad event sent the cursor to a screen corner instead of moving it relative to its current position. Read the current cursor position with CGEventGetLocation and post the new event at current+delta instead. Also invert dy -- the wire is screen-down (CSS convention) while Quartz screen Y goes up. State machine unchanged: _dragging_left still toggles between MouseMoved and LeftMouseDragged, so tap-and-half drag-lock still works.
Mouse-event coords in Quartz use top-left origin with Y down (same as CSS / iOS / Windows), not bottom-left / Y up. The -dy flip made vertical motion inverted: finger down moved the cursor up.
emit_click had the same gotcha as emit_pointer: a CGEventCreateMouseEvent with cursor position (0, 0) is read by Quartz as 'warp the cursor to (0, 0) and click there'. So taps (pad_tap -> click+release) and the tap-and-half drag-lock (pad_drag start -> click, drag motion, pad_drag end -> click) all snapped the cursor to the top of the screen. Use the current cursor position from CGEventGetLocation instead -- the caller's last emit_pointer already placed the cursor where the click should land.
dev-daemon wraps the supervisor so Python edits hot-reload; bare run-daemon does not. Adding a pointer to the diagnostic script for the 'layout stuck on X' troubleshooting path.
38 new tests across four files, all targeting the bug-prone pure
helpers we shipped in the mac-platform branch.
tests/test_input.py
* name_from_keycode() -- reverse lookup the macOS sink uses to
translate evdev keycode lists into names for AppleScript. Pins:
- single-char alias wins over long evdev name ([ over leftbrace
for code 26; fixes the super+[ regression that initially
shipped)
- modifiers round-trip (ctrl / shift / alt / super / meta)
- long-name fallback for non-printables without a single-char
alias (arrows, F-keys, etc.)
- unknown keycode -> None (sink skips, doesn't crash)
tests/test_platform_macos.py
* _build_keystroke_script() -- pure translation from keycodes to
AppleScript line. 20 tests pin:
- printable single-char path: keystroke '<char>'
- non-printable HID path: key code <N>
- modifier mapping: super->command, ctrl->control, alt->option,
meta==super
- multi-modifier combinations emit one using { ... } clause
- super+[ regression test (would have caught the original bug)
- empty / unknown / modifier-only keycode lists -> None
tests/test_deckd_window.py
* Server._is_deckd_window() -- auto-ignore check. 9 tests pin:
- port-in-title branch (with own port + with other port)
- exact-title branch (the regression fix): 'deckd' holds,
'deckd on GitHub' / 'jonocodes/deckd' / 'deckded' do not
- case insensitive + whitespace stripped
- port=0 disables port branch
tests/test_layouts.py (added 4)
* __main__._overlay_dir_for() -- pure path computation, monkeypatched
sys.platform. Covers darwin, linux, unknown platform (defaults to
linux suffix), arbitrary base dir name.
All 116 tests pass; no daemon / platform code touched.
Required
--------
* platform_macos.py: drop dead is_macos() -- never imported anywhere;
callers use sys.platform directly.
* platform_macos.py: extract _load_quartz() helper. MacKeySink and
MacScrollSink both had the same try/except Quartz + log import dance;
collapsed to a one-liner init + sink-specific warning.
* input.py: collapse _NAMES_BY_KEYCODE build to a single iteration
over (MODIFIER_MAP.items() ++ _SINGLE_KEY_MAP.items()). Same data
structure, same lookup behaviour, ~5 lines vs ~10.
* __main__.py: _overlay_dir_for now returns Path, not Path | None --
the type lied. The 'missing overlay is fine' branch lives at the
call site (Server treats it as a no-op).
Optional
--------
* platform_macos.py: extract _cursor_pos(Q) helper. emit_pointer and
emit_click both called CGEventGetLocation(CGEventCreate(None)) --
same dance, one helper now.
* platform_macos.py: fix stale header docstring ('and, when present,
cliclick' -- cliclick is no longer used). Updated to explain
why Quartz is chosen over cliclick.
* scripts/check_focus_macos.py: --port CLI arg + argparse. Hardcoded
8765 would silently lie if the daemon's default ever changed.
* scripts/check_focus_macos.py: drop the no-op set comprehension
'{l.id for l in store.layouts if l.id == "default"}' -- just
'resolved.id == "default"'.
All 116 tests still pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the macOS platform backend per the #24 spike: focus detection + key injection via osascript, scroll + pointer + click via PyObjC Quartz. Introduces the per-platform overlay convention (
layouts.macos/) so Mac-specific shortcuts can ship alongside the shared base without forking.What's in here
daemon/deckd/platform_macos.py(new)MacFocusBackend— osascript + System Events for frontmost app + window titleMacKeySink— osascriptkeystrokefor printable chars,key codefor non-printables, QuartzCGEventfor pointer / click / held-button drag (tap-and-half)MacScrollSink— QuartzCGEventCreateScrollWheelEvent, 1 line per 120 REL_WHEEL_HI_RES units (matches Linux)daemon/deckd/layouts.pyload_layouts(base, overlay=None)— overlay entries load first, base entries with matchingidare dropped. First-match-wins within the combined list, so overlay entries shadow base entries that match the same focused-app identity.daemon/deckd/__main__.pylayouts.<platform>next to--layouts-dir(siblinglayouts.macos/on Mac,layouts.linux/on Linux).--no-overlayto skip.daemon/deckd/server.py_is_deckd_windownow requires exact title match on"deckd". Previously usedin title.lower()which false-positived on tabs whose titles merely containeddeckdas a substring.daemon/deckd/input.pyname_from_keycode()reverse lookup for the Mac sink. Prefers single-char aliases ([overleftbracefor code 26) so AppleScript'skeystroke "<char>"works without an HID-code lookup.pyproject.toml[macos]extra:pyobjc-framework-Quartz>=10.0.evdev-binarystays in[uinput](Linux-only).Justfilesetup-macos/setup-linuxper-platform recipes.setupauto-dispatches.layouts.macos/firefox.yaml—super+t/super+w/super+f/super+n/super+r/super+l/super+[/super+](Mac-conventional)default.yaml— generic 4×2 fallback (new tab, close, find, new window, open URL, iTerm, Terminal, System Settings)scripts/check_focus_macos.py(new)--port/--layouts-dirCLI args. Used during testing to verify TCC + auto-ignore behaviour without reading daemon logs.Tests (43 new, 116 total)
tests/test_input.py—name_from_keycode()regressionstests/test_platform_macos.py—_build_keystroke_script()(20 cases incl. thesuper+[regression)tests/test_deckd_window.py—_is_deckd_window()exact-matchtests/test_layouts.py— overlay semantics +_overlay_dir_for()Issue
Closes #24. Supersedes the research-only outcome — this PR also lands the implementation that the spike was groundwork for.
Follow-ups filed
dbus-fastan optional dep (currently a hard dep; no-op on macOS)Verification
super+[/super+]/super+t/super+w, trackpad click + drag with tap-and-half, jogstrip scroll)3a289aaBehavior matrix
key:action (printable + combos)keystroke)key:action (non-printable)shell:/terminal:actionsdbus:action