diff --git a/Justfile b/Justfile index 22956fd..dff9449 100644 --- a/Justfile +++ b/Justfile @@ -4,32 +4,50 @@ default: @just --list -# Create the venv and install Python + JS deps. Idempotent. -# Run once. flox activate handles PATH; this sets up the venv itself. -setup: +# Per-platform setup recipes. `setup` auto-picks the right one; use the +# explicit recipe when you want to override (e.g. cross-checking on a CI box). + +# Linux/GNOME dev: [dev,uinput] (evdev-binary is Linux-only). +setup-linux: uv venv --python 3.12 --allow-existing uv pip install -e ".[dev,uinput]" cd client && npm install +# macOS dev: [dev] + [macos] (PyObjC Quartz covers scroll, pointer, click, +# and held-button drag for the trackpad). +setup-macos: + uv venv --python 3.12 --allow-existing + uv pip install -e ".[dev,macos]" + cd client && npm install + +# Dispatch: picks setup-linux on Linux, setup-macos on macOS. flox users +# don't need this -- flox activate handles its own venv. +setup: + @if [ "$(uname)" = Darwin ]; then \ + just setup-macos; \ + else \ + just setup-linux; \ + fi + # Run the daemon against the layouts directory, serving the built client. run-daemon: - .venv/bin/deckd --layouts-dir layouts --client-dist client/dist --verbose + deckd --layouts-dir layouts --client-dist client/dist --verbose # Run the daemon on the LAN without a built client (use dev-client-lan for HMR). run-daemon-lan: - .venv/bin/deckd --host 0.0.0.0 --layouts-dir layouts --verbose + deckd --host 0.0.0.0 --layouts-dir layouts --verbose # Run the daemon under a supervisor that restarts it when daemon/**/*.py # changes. Layout YAML hot-reload is built into the daemon itself; this is # only useful when editing Python. dev-daemon: - .venv/bin/deckd-dev --verbose + deckd-dev --verbose # Same, but bind the daemon to all interfaces so a phone on the LAN # (or Tailscale) can reach it. deckd-dev forwards unknown args to the # child, so --host and --verbose end up on the deckd process. dev-daemon-lan: - .venv/bin/deckd-dev --host 0.0.0.0 --verbose + deckd-dev --host 0.0.0.0 --verbose # Vite dev server on the LAN. Vite proxies /ws and /health to the local # daemon (see vite.config.ts), so the client is same-origin at :5173. @@ -75,12 +93,12 @@ install-focus-extension: # Print active app/window changes for Spike #2. watch-focus: - .venv/bin/python -u scripts/watch_focus.py + python -u scripts/watch_focus.py # Single snapshot of the active app/window. watch-focus-once: - .venv/bin/python -u scripts/watch_focus.py --once + python -u scripts/watch_focus.py --once # Hit /health. status: - .venv/bin/deckctl status + deckctl status diff --git a/README.md b/README.md index f5ed207..a5ff00a 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,38 @@ just run-daemon Open `http://127.0.0.1:8765` in any browser. You should see the active layout's buttons filling the main area, an always-on jogstrip pinned to the right edge, and a chrome bottom strip with the app name, a connection status dot, and a `trackpad` button. Drag or flick vertically on the right-side jogstrip to emit `REL_WHEEL_HI_RES` deltas through uinput (log-only when uinput is unavailable). Tap the `trackpad` button to swap the button grid for a full-area trackpad surface — see the [Trackpad mode](#trackpad-mode) section for the gesture list. +### macOS + +The daemon runs on macOS via `daemon/deckd/platform_macos.py` — focus + key injection work out of the box, and the jogstrip + trackpad need `pyobjc-framework-Quartz` (pulled in via the `[macos]` extra). The GNOME Shell focus extension is Linux-only. + +Setup (no `uv`/uinput bits): + +```sh +just setup # auto-picks setup-macos on Mac, setup-linux elsewhere +just build-client +just dev-daemon # listens on http://127.0.0.1:8765, auto-restarts on Python edits +``` + +`just dev-daemon` wraps the daemon in the `deckd-dev` supervisor so Python edits hot-reload (YAML hot-reloads either way). For a one-shot `deckd` invocation use `just run-daemon`. + +To force a specific platform's setup (e.g. on a CI box): `just setup-linux` or `just setup-macos`. + +First time you focus a non-default window, **System Events** will pop a TCC prompt asking you to allow the controlling terminal/iTerm/whatever wraps Python. Accept it once and the focus watcher runs forever after. The focus backend uses the process name as `app_id`, so layouts match by process name — `firefox`, `Terminal`, `kitty`, `code` etc. work as-is. The GNOME-specific per-app YAMLs (`org.gnome.Console`, `foot`, `konsole`…) won't match on macOS unless you rename them to the Mac process name. + +What works / doesn't on macOS: + +| capability | macOS | +| ----------------------------------- | -------------------------------- | +| focus detection | yes (osascript + System Events) | +| `key:` action (printable + combos) | yes (osascript `keystroke`) | +| `key:` action (non-printable) | partial (HID-code map covers the common ones — arrows, esc, tab, enter, F-keys) | +| `shell:` / `terminal:` actions | yes | +| `dbus:` action | no (macOS D-Bus exists but GNOME services don't) | +| trackpad pointer + clicks + drag | yes (PyObjC Quartz `CGEventCreateMouseEvent`) | +| jogstrip scroll | yes (PyObjC Quartz `CGEventCreateScrollWheelEvent` — pulled in via the `[macos]` extra) | + +When the layout doesn't switch as expected, run `python scripts/check_focus_macos.py` for a one-shot diagnostic: it prints what `osascript` reports for the frontmost app, whether the auto-ignore rule would hold, and which layout `resolve_layout` would pick. Saves reading the daemon log for the common cases (TCC denied, stale daemon, wrong app_id). + ### Phone/tablet testing The phone must load the web client from a daemon address it can reach. Build the client, run the daemon on all interfaces, then open the desktop's LAN IP from the phone: @@ -324,6 +356,12 @@ A directory of YAML files in `layouts/` — one per app, plus a `default.yaml` f - `key: "ctrl+t"` — fire the keystroke through uinput as a single combo. - `dbus: "service:path org.Interface.Method arg1 arg2"` — call a D-Bus method via `dbus-fast`. The bus is inferred from the interface name (`org.freedesktop.login1.*`, `systemd1.*`, `timedate1.*`, `locale1.*`, etc. → system bus; everything else → session bus). Errors are logged, not surfaced to the client. With the `service:path` prefix omitted, the daemon derives them from the first two / three segments of the interface name. +### Per-platform overlay + +The daemon also loads a sibling directory next to `--layouts-dir` whose name is suffixed with the current platform: `layouts.macos/` on macOS, `layouts.linux/` on Linux. A missing overlay is fine (the most common case). Overlay entries load first and **replace** any base entry with the same `id` — so `layouts.macos/firefox.yaml` overrides `layouts/firefox.yaml` on Mac without you touching the shared base. The watcher also watches the overlay dir, so edits reload live. Pass `--no-overlay` to skip the overlay even when it exists (debugging, cross-platform checkout debugging, etc.). + +This is how `layouts.macos/firefox.yaml` carries the `super+t` / `super+[` / `super+]` shortcuts without forking the rest of `firefox.yaml` for every Linux user who pulls the repo. + ## What works today - **Wire protocol** in both directions: `LayoutMessage` (with `jogstrip_enabled` + optional `error`) and `press` / `jog` / `jog_end` / `pad` / `pad_tap` / `pad_drag` events. diff --git a/client/vite.config.ts b/client/vite.config.ts index 0ffdc34..46e5bf1 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ server: { port: 5173, // Match any tailnet hostname so `..ts.net` isn't rejected. - allowedHosts: ["lute", ".ts.net"], + allowedHosts: ["lute", "jonodot-1", ".ts.net"], ...(https ? { https } : {}), // Same-origin proxy so the client never has to know the daemon's URL: // /ws, /health, /reload all appear at the Vite origin and are forwarded diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index f04d942..fc4b847 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -4,6 +4,7 @@ import asyncio import logging import signal +import sys from pathlib import Path from .input import LoggingKeySink, LoggingScrollSink, ScrollController, UinputSink @@ -27,6 +28,17 @@ async def _run(server: Server) -> None: await server.stop() +def _overlay_dir_for(layouts_dir: Path) -> Path: + """Pick a per-platform overlay dir next to ``layouts_dir``. + + Convention: ``.linux`` or ``.macos``. The path is + always returned; whether it actually exists is the caller's + concern (``Server`` treats a missing overlay as a no-op). + """ + suffix = {"darwin": "macos"}.get(sys.platform, "linux") + return layouts_dir.parent / f"{layouts_dir.name}.{suffix}" + + def main() -> None: parser = argparse.ArgumentParser(prog="deckd") parser.add_argument("--host", default="127.0.0.1") @@ -37,6 +49,11 @@ def main() -> None: default=Path("layouts"), help="Directory of per-app YAML layouts (one file per app + default.yaml)", ) + parser.add_argument( + "--no-overlay", + action="store_true", + help="Skip the per-platform overlay (layouts.) even if present", + ) parser.add_argument( "--no-focus", action="store_true", @@ -72,17 +89,35 @@ def main() -> None: ) try: - sink = UinputSink() + if sys.platform == "darwin": + from .platform_macos import MacKeySink, MacScrollSink + + sink = MacKeySink() + scroll_sink = MacScrollSink() + key_sink = sink + else: + sink = UinputSink() + scroll_sink = sink + key_sink = sink except Exception as exc: logging.getLogger("deckd").warning( - "uinput unavailable; falling back to logging only: %s", exc + "platform sink unavailable; falling back to logging only: %s", exc ) sink = None - - scroll_sink = sink if sink is not None else LoggingScrollSink() - key_sink = sink if sink is not None else LoggingKeySink() + scroll_sink = LoggingScrollSink() + key_sink = LoggingKeySink() focus_backend = None if args.no_focus else default_backend() + if focus_backend is not None: + logging.getLogger("deckd").info( + "focus backend: %s (sys.platform=%s)", + type(focus_backend).__name__, + sys.platform, + ) + + overlay_dir = None if args.no_overlay else _overlay_dir_for(args.layouts_dir) + if overlay_dir is not None and overlay_dir.is_dir(): + logging.getLogger("deckd").info("loading layouts overlay from %s", overlay_dir) def dbus_bus_factory(bus_type): from dbus_fast.aio import MessageBus @@ -101,6 +136,7 @@ def dbus_bus_factory(bus_type): key_sink=key_sink, dbus_bus_factory=dbus_bus_factory, focus_backend=focus_backend, + overlay_dir=overlay_dir, ) if args.client_dist is not None: diff --git a/daemon/deckd/input.py b/daemon/deckd/input.py index a7656ca..a6608e0 100644 --- a/daemon/deckd/input.py +++ b/daemon/deckd/input.py @@ -127,6 +127,40 @@ def parse_key_combo(key_string: str) -> list[int]: return keycodes +def name_from_keycode(keycode: int) -> str | None: + """Reverse of :data:`MODIFIER_MAP` + :data:`_SINGLE_KEY_MAP`. + + Returns the canonical (lowercase) name for an evdev keycode, or ``None`` + if the code isn't in either table. Used by backends (e.g. macOS) that + receive a keycode list and need to translate to their own event names. + + When a code has multiple names (e.g. ``[`` and ``leftbrace`` both map to + 26), the single-character form is preferred — macOS's ``keystroke`` + translates printable literals directly, so ``"["`` round-trips through + AppleScript without needing an HID-code lookup. + """ + candidates = _NAMES_BY_KEYCODE.get(keycode) + if not candidates: + return None + for name in candidates: + if len(name) == 1: + return name + return candidates[0] + + +# Multiple names can share a keycode (``[`` and ``leftbrace`` both map +# to 26, ``super`` and ``meta`` to 125). Preserve them all in insertion +# order so callers can see every alias; ``name_from_keycode`` then +# prefers the single-char form because AppleScript's ``keystroke ""`` +# takes printable literals directly without an HID-code lookup. +_NAMES_BY_KEYCODE: dict[int, list[str]] = {} +for _name, _kc in ( + *MODIFIER_MAP.items(), + *_SINGLE_KEY_MAP.items(), +): + _NAMES_BY_KEYCODE.setdefault(_kc, []).append(_name) + + # --------------------------------------------------------------------------- # Fallback / logging sinks # --------------------------------------------------------------------------- diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 1e7e996..9414ce4 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path import yaml @@ -7,6 +8,8 @@ from .platform import AppInfo +log = logging.getLogger("deckd.layouts") + class Widget(BaseModel): model_config = ConfigDict(extra="forbid") @@ -120,17 +123,40 @@ def resolve_layout(store: LayoutStore, app: AppInfo) -> Layout: return store.default() -def load_layouts(layouts_dir: Path) -> LayoutStore: - """Load every ``*.yaml`` / ``*.yml`` file in ``layouts_dir``. +def load_layouts( + layouts_dir: Path, overlay_dir: Path | None = None +) -> LayoutStore: + """Load every ``*.yaml`` / ``*.yml`` file in ``layouts_dir`` plus an + optional platform overlay. + + The overlay is loaded first; same-id base entries are then dropped. + Effect: if the overlay defines a layout with the same id as a base + layout (typically because both name their file ``.yaml``), the + overlay wins. The overlay can also add layouts for apps the base + doesn't cover. A missing ``layouts_dir`` is fatal; a missing overlay + is fine (no overlay is the most common case). - A missing directory is a fatal startup error (matches the existing - single-file behavior: bad config = ``SystemExit``). Non-yaml files in - the directory are ignored. + Resolution semantics stay first-match-wins within the combined list, + so loading the overlay first means its entries shadow base entries + that match the same focused-app identity -- which is the intuitive + "platform overrides shared" semantic. """ if not layouts_dir.is_dir(): raise SystemExit(f"layouts directory not found: {layouts_dir}") layouts: list[Layout] = [] + + if overlay_dir is not None and overlay_dir.is_dir(): + for path in sorted(overlay_dir.glob("*.y*ml")): + if path.suffix not in {".yaml", ".yml"}: + continue + try: + layout = load_layout(path) + except SystemExit as exc: + raise SystemExit(f"{exc}") from None + layouts.append(layout) + + overlay_ids = {l.id for l in layouts if l.id} for path in sorted(layouts_dir.glob("*.y*ml")): if path.suffix not in {".yaml", ".yml"}: continue @@ -138,7 +164,11 @@ def load_layouts(layouts_dir: Path) -> LayoutStore: layout = load_layout(path) except SystemExit as exc: raise SystemExit(f"{exc}") from None + if layout.id and layout.id in overlay_ids: + log.info("layout %r overridden by overlay %s", layout.id, path) + continue layouts.append(layout) + return LayoutStore(layouts) diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py index 508f39c..bdce0cf 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -4,6 +4,7 @@ import ast import json import os +import sys from collections.abc import AsyncIterator from dataclasses import dataclass @@ -70,6 +71,10 @@ async def get_active_app(self) -> AppInfo: def default_backend() -> PlatformBackend: + if sys.platform == "darwin": + from .platform_macos import MacFocusBackend + + return MacFocusBackend() if os.environ.get("XDG_SESSION_TYPE") == "x11": return X11FocusBackend() return GnomeShellFocusBackend() diff --git a/daemon/deckd/platform_macos.py b/daemon/deckd/platform_macos.py new file mode 100644 index 0000000..7e412fd --- /dev/null +++ b/daemon/deckd/platform_macos.py @@ -0,0 +1,342 @@ +"""macOS backend: focus detection + key/pointer/scroll sinks. + +No new native deps, no kernel extensions. Keys + focus shell out to +``osascript`` (AppleScript); scroll + pointer + click go through +PyObjC ``Quartz`` so we can do held-button drags the trackpad's +tap-and-a-half gesture needs. cliclick was an earlier choice for +pointer / click but couldn't model a held button across multiple +moves -- Quartz's ``LeftMouseDragged`` is. + +Capability matrix (sketch): + + +-----------------------------+--------+------------------------------+ + | capability | works? | how | + +-----------------------------+--------+------------------------------+ + | focus detection | yes | osascript + System Events | + | key injection (printable) | yes | osascript ``keystroke`` | + | key injection (non-print) | partial| osascript ``key code`` (map) | + | combo modifiers | yes | ``using {command down}`` | + | mouse click (left/right) | yes | PyObjC Quartz CG mouse event | + | mouse relative motion | yes | PyObjC Quartz CG mouse event | + | mouse drag (held-button) | yes | PyObjC Quartz LeftMouseDragged | + | high-res wheel scroll | yes | PyObjC ``Quartz.CGEvent- | + | | | CreateScrollWheelEvent`` | + +-----------------------------+--------+------------------------------+ + +The Linux ``UinputSink`` covers the same wire protocol but emits Linux +evdev events. +""" + +from __future__ import annotations + +import logging +import subprocess +from collections.abc import Sequence +from types import ModuleType + +from .input import MODIFIER_MAP, KeySink, ScrollSink, name_from_keycode +from .platform import AppInfo, PlatformBackend, _run + +log = logging.getLogger("deckd.platform_macos") + + +def _load_quartz() -> tuple[ModuleType | None, bool]: + """Lazy import of ``pyobjc-framework-Quartz``. Returns ``(module, available)``. + + Both sinks (pointer / click and scroll) need the same module; + centralising the try / except avoids the duplicated boilerplate and + gives one place to log the install hint. + """ + try: + import Quartz # type: ignore + + return Quartz, True + except ImportError: + return None, False + +# --------------------------------------------------------------------------- +# Focus +# --------------------------------------------------------------------------- + +# AppleScript returns three pipe-delimited fields. Pipe is rare enough in +# process names / window titles that this is fine as a sketch. +_FOCUS_SCRIPT = """ +tell application "System Events" + set frontProc to first process whose frontmost is true + set procName to name of frontProc + try + set winTitle to name of front window of frontProc + on error + set winTitle to "" + end try + return procName & "|||" & winTitle +end tell +""" + + +class MacFocusBackend(PlatformBackend): + """Read the frontmost app's process name + front window title via + System Events. ``app_id`` is the process name; ``wm_class`` is left + None (the Mac concept doesn't map 1:1). + """ + + async def get_active_app(self) -> AppInfo: + out = await _run("osascript", "-e", _FOCUS_SCRIPT) + proc_name, _, title = out.partition("|||") + return AppInfo( + app_id=proc_name.strip() or None, + wm_class=None, + title=title.strip() or None, + ) + + +# --------------------------------------------------------------------------- +# Key sink (osascript keystroke / key code) +# --------------------------------------------------------------------------- + +# AppleScript modifier clauses. ``super``/``meta`` map to Command (the Mac +# "super" key); ``alt`` maps to Option; ``ctrl`` maps to Control. +_MOD_CLAUSE: dict[str, str] = { + "ctrl": "control", + "shift": "shift", + "alt": "option", + "super": "command", + "meta": "command", +} + +# AppleScript ``key code`` numbers (HID usage IDs) for the non-printable +# keys we care about. Letters and digits are sent as ``keystroke "x"`` +# instead -- easier and locale-correct. +_MAC_KEY_CODE: dict[str, int] = { + "esc": 53, "escape": 53, + "tab": 48, + "enter": 36, "return": 36, + "space": 49, + "backspace": 51, + "delete": 117, "del": 117, + "home": 115, + "end": 119, + "pageup": 116, "pgup": 116, + "pagedown": 121, "pgdn": 121, + "up": 126, + "down": 125, + "left": 123, + "right": 124, + "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, + "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111, + "capslock": 57, +} + + +def _build_keystroke_script(keycodes: Sequence[int]) -> str | None: + """Translate an evdev keycode list into a single AppleScript line. + + Returns ``None`` when the translation can't be expressed (e.g. an + unknown keycode); the caller should log and skip. + """ + if not keycodes: + return None + + mods: list[str] = [] + main_name: str | None = None + for kc in keycodes: + name = name_from_keycode(kc) + if name is None: + log.warning("[mac key] unknown evdev keycode %s", kc) + return None + if name in MODIFIER_MAP: + clause = _MOD_CLAUSE.get(name) + if clause is None: + return None + mods.append(clause) + else: + # ``parse_key_combo`` always puts the main key last, so the last + # non-modifier keycode wins. + main_name = name + + if main_name is None: + log.warning("[mac key] no main key in combo %s", keycodes) + return None + + mod_clause = "" + if mods: + mod_clause = " using {" + ", ".join(f"{m} down" for m in mods) + "}" + + # Printable single-char -> ``keystroke "x"``; everything else -> ``key code N``. + if len(main_name) == 1 and main_name.isprintable() and main_name not in _MAC_KEY_CODE: + return f'tell application "System Events" to keystroke "{main_name}"{mod_clause}' + + mac_code = _MAC_KEY_CODE.get(main_name) + if mac_code is None: + log.warning("[mac key] no macOS mapping for key %r", main_name) + return None + return f'tell application "System Events" to key code {mac_code}{mod_clause}' + + +class MacKeySink(KeySink): + """Emit keys via osascript. Pointer + click + drag via PyObjC Quartz. + + Quartz is needed (over the simpler cliclick path) because the + trackpad's tap-and-a-half gesture requires a held-button drag -- + cliclick only knows ``click`` (down+up in one shot) and ``move``, + so it can't model the "press, move with button held, then release" + sequence the wire protocol demands. Quartz's CGEvent lets us split + those into ``LeftMouseDown`` / ``LeftMouseDragged`` / ``LeftMouseUp``. + """ + + def __init__(self) -> None: + self._Q, self._has_quartz = _load_quartz() + if not self._has_quartz: + log.warning( + "[mac key] PyObjC Quartz not available; " + "trackpad pointer / clicks will log only " + "(install with: pip install pyobjc-framework-Quartz)" + ) + # Track the held-button state so emit_pointer emits + # ``LeftMouseDragged`` (not ``MouseMoved``) while the user is + # mid-drag. The server drives this via pad_drag start/end. + self._dragging_left = False + + # -- key ----------------------------------------------------------------- + + def emit_key(self, keycodes: list[int]) -> None: + script = _build_keystroke_script(keycodes) + if script is None: + return + # Fire and forget: osascript is too slow to block the event loop on + # the first call (TCC prompt can take seconds). The key reaches the + # focused app a few ms later; the wire protocol doesn't await. + subprocess.Popen( + ["osascript", "-e", script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + log.debug("[mac key] %s", script) + + # -- pointer / click (Quartz) ------------------------------------------- + + def emit_pointer(self, dx: int, dy: int) -> None: + if not self._has_quartz: + log.info("[mac pointer log] dx=%s dy=%s (no Quartz)", dx, dy) + return + if dx == 0 and dy == 0: + return + Q = self._Q + # ``LeftMouseDragged`` keeps the left button logically held in the + # event stream; ``MouseMoved`` is plain cursor motion with no + # button. Picking the right one is what makes the tap-and-a-half + # drag-lock feel native (selections / windows actually drag). + event_type = Q.kCGEventLeftMouseDragged if self._dragging_left else Q.kCGEventMouseMoved + # Compute the new absolute cursor position rather than setting the + # delta fields. ``CGEventCreateMouseEvent`` with a (0, 0) cursor + # position is read by Quartz as "warp the cursor to (0, 0)"; the + # deltaX / deltaY fields are then ignored (or applied in addition + # to the warp), so the cursor snaps to a screen corner on every + # event instead of moving relative to where it was. Posting at + # ``current + delta`` is the reliable path. + # + # Quartz mouse-event coordinates use top-left origin with Y down, + # same as CSS / iOS / Windows -- so dy from the wire (screen-down) + # maps directly to positive Y on the cursor. + current = _cursor_pos(Q) + new_pos = Q.CGPoint(current.x + dx, current.y + dy) + event = Q.CGEventCreateMouseEvent( + None, event_type, new_pos, Q.kCGMouseButtonLeft + ) + Q.CGEventPost(Q.kCGHIDEventTap, event) + log.debug("[mac pointer] dx=%s dy=%s -> (%.1f, %.1f)", dx, dy, new_pos.x, new_pos.y) + + def emit_click(self, button: str, pressed: bool) -> None: + if not self._has_quartz: + log.info("[mac click log] button=%s pressed=%s (no Quartz)", button, pressed) + return + Q = self._Q + if button == "left": + down_type, up_type = Q.kCGEventLeftMouseDown, Q.kCGEventLeftMouseUp + button_code = Q.kCGMouseButtonLeft + elif button == "right": + down_type, up_type = Q.kCGEventRightMouseDown, Q.kCGEventRightMouseUp + button_code = Q.kCGMouseButtonRight + else: + log.warning("[mac click] unknown button %r", button) + return + event_type = down_type if pressed else up_type + # Use the current cursor position, not (0, 0). Same trap as + # emit_pointer: a (0, 0) position warps the cursor to the top-left + # before the down / up registers, so taps feel like the cursor + # snapped away. The user's last emit_pointer already placed the + # cursor where they want the click; reuse that. + current = _cursor_pos(Q) + event = Q.CGEventCreateMouseEvent(None, event_type, current, button_code) + Q.CGEventPost(Q.kCGHIDEventTap, event) + if button == "left": + self._dragging_left = pressed + log.debug("[mac click] button=%s pressed=%s", button, pressed) + + def close(self) -> None: + pass + + +def _cursor_pos(Q: ModuleType): + """Read the current cursor position. Wraps the CGEventCreate + CGEventGetLocation + pair so emit_pointer / emit_click don't repeat the dance.""" + return Q.CGEventGetLocation(Q.CGEventCreate(None)) + + +# --------------------------------------------------------------------------- +# Scroll sink +# --------------------------------------------------------------------------- + + +class MacScrollSink(ScrollSink): + """Synthetic-wheel sink via PyObjC + Quartz. + + macOS doesn't expose wheel injection through AppleScript. Quartz's + ``CGEventCreateScrollWheelEvent`` is the only path that reaches the + focused window's scroll view the same way a real trackpad or mouse + wheel does. Requires ``pyobjc-framework-Quartz``; the sink falls + back to log-only if it's not importable. + + The wire protocol emits ``REL_WHEEL_HI_RES`` deltas (1/120 of a + wheel detent). We accumulate them and emit one ``kCGScrollEventUnitLine`` + event per detent, matching the Linux ``UinputSink`` behaviour. + """ + + # 1 detent (line) == 120 REL_WHEEL_HI_RES units, the same ratio Linux uses. + DETENT = 120 + + def __init__(self) -> None: + self._Quartz, self._available = _load_quartz() + if self._available: + log.info("[mac scroll] Quartz loaded; wheel events will be injected") + else: + log.warning( + "[mac scroll] PyObjC Quartz not available; " + "jogstrip will log only " + "(install with: pip install pyobjc-framework-Quartz)" + ) + self._wheel_remainder = 0 + + def emit_scroll(self, delta: int) -> None: + if delta == 0: + return + if not self._available: + log.info("[mac scroll log] delta=%s (no Quartz)", delta) + return + self._wheel_remainder += delta + detents = self._wheel_remainder // self.DETENT + if detents == 0: + return + self._wheel_remainder -= int(detents) * self.DETENT + Q = self._Quartz + event = Q.CGEventCreateScrollWheelEvent( + None, + Q.kCGScrollEventUnitLine, + 1, # one wheel axis (vertical) + int(detents), + ) + Q.CGEventPost(Q.kCGHIDEventTap, event) + log.debug("[mac scroll] line events=%s (delta=%s)", int(detents), delta) + + def close(self) -> None: + pass diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 6762b66..22855ce 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -110,14 +110,16 @@ def __init__( key_sink: "KeySink | None" = None, dbus_bus_factory: "Callable[[BusTypeT], MessageBus] | None" = None, focus_backend: "PlatformBackend | None" = None, + overlay_dir: Path | None = None, ) -> None: self.layouts_dir = layouts_dir + self.overlay_dir = overlay_dir self.host = host self.port = port self.app = web.Application() self._setup_routes() self._sessions: set[Session] = set() - self.layouts: LayoutStore = load_layouts(layouts_dir) + self.layouts: LayoutStore = load_layouts(layouts_dir, overlay_dir) self._current_app_id: str = DEFAULT_APP_ID self._current_layout: Layout = self.layouts.default() self.scroll = scroll if scroll is not None else ScrollController() @@ -143,7 +145,7 @@ def current_error(self) -> str | None: return self._current_error def reload_layouts(self) -> None: - """Re-read every layout YAML in ``layouts_dir``. + """Re-read every layout YAML in ``layouts_dir`` (and overlay_dir). On success: rebuild the store, keep the current app_id if it still resolves, else fall back to default, and clear any prior error. @@ -154,7 +156,7 @@ def reload_layouts(self) -> None: the grid. Callers should not have to catch anything. """ try: - new_store = load_layouts(self.layouts_dir) + new_store = load_layouts(self.layouts_dir, self.overlay_dir) except SystemExit as exc: self._current_error = str(exc) log.error("layout reload failed (keeping last-good): %s", exc) @@ -167,7 +169,11 @@ def reload_layouts(self) -> None: new_layout = self.layouts.default() self._current_layout = new_layout self._current_error = None - log.info("reloaded layouts from %s", self.layouts_dir) + log.info( + "reloaded layouts from %s%s", + self.layouts_dir, + f" + {self.overlay_dir}" if self.overlay_dir else "", + ) async def _push_to_all(self) -> None: """Push the current layout to every live session. @@ -193,14 +199,16 @@ def _is_deckd_window(self, app: "AppInfo") -> bool: The defining signal is the daemon's own port appearing in the focused window's title (the deckd client is served at that port, so browsers that surface the URL in the title reveal it). The - page-title fallback ("deckd") covers browsers whose window title - is only the page's ````. + page-title fallback is an exact match on the client's ``<title>`` + (``"deckd"``), which avoids false positives on any tab whose + title merely contains "deckd" as a substring (e.g. a GitHub tab + for the deckd repo). """ - title = app.title or "" + title = (app.title or "").strip() port = self.port if port and port > 0 and str(port) in title: return True - return "deckd" in title.lower() + return title.lower() == "deckd" async def _on_focus(self, app: "AppInfo") -> None: if self._is_deckd_window(app): @@ -251,7 +259,7 @@ def start_focus_watcher(self) -> asyncio.Task[None] | None: async def run_layouts_watcher(self) -> None: """Long-running task: reload layouts when a YAML file in the layouts - directory is created, edited, or removed. + directory (or its platform overlay) is created, edited, or removed. Layouts are user configuration, not just a dev-only artifact — watching them is on by default so a user can iterate on their YAML @@ -264,8 +272,11 @@ async def run_layouts_watcher(self) -> None: except ImportError: log.warning("watchfiles not installed; layouts hot-reload disabled") return + watch_paths = [self.layouts_dir] + if self.overlay_dir is not None and self.overlay_dir.is_dir(): + watch_paths.append(self.overlay_dir) yaml_suffixes = {".yaml", ".yml"} - async for changes in awatch(self.layouts_dir): + async for changes in awatch(*watch_paths): if not any(Path(p).suffix in yaml_suffixes for _, p in changes): continue log.info("layouts dir changed -> reload") diff --git a/layouts.macos/default.yaml b/layouts.macos/default.yaml new file mode 100644 index 0000000..46817ce --- /dev/null +++ b/layouts.macos/default.yaml @@ -0,0 +1,66 @@ +# Mac fallback layout. Used when no per-app layout matches. Same `id` as +# `layouts/default.yaml` so the overlay-replaces-base loader picks this +# one on Mac without touching the shared base. +# +# Designed for "works for max apps": the buttons below are useful in a +# long tail of Mac apps (browsers, terminals, editors, chat clients, etc.) +# and the launchers work regardless of which app is focused. +match: + - default +widgets: + - id: new-tab + kind: button + label: New tab + icon: plus + grid: [0, 0, 1, 1] + action: + key: "super+t" + - id: close-tab + kind: button + label: Close + icon: close + grid: [1, 0, 1, 1] + action: + key: "super+w" + - id: find + kind: button + label: Find + icon: search + grid: [2, 0, 1, 1] + action: + key: "super+f" + - id: new-window + kind: button + label: New window + icon: window + grid: [3, 0, 1, 1] + action: + key: "super+n" + - id: open-url + kind: button + label: Open example.com + icon: globe + grid: [0, 1, 1, 1] + action: + shell: "open https://example.com" + - id: launch-iterm + kind: button + label: iTerm + icon: terminal + grid: [1, 1, 1, 1] + action: + shell: "open -a iTerm" + - id: launch-terminal + kind: button + label: Terminal + icon: terminal + grid: [2, 1, 1, 1] + action: + shell: "open -a Terminal" + - id: system-settings + kind: button + label: Settings + icon: cog + grid: [3, 1, 1, 1] + action: + shell: "open -a \"System Settings\"" diff --git a/layouts.macos/firefox.yaml b/layouts.macos/firefox.yaml new file mode 100644 index 0000000..862f63c --- /dev/null +++ b/layouts.macos/firefox.yaml @@ -0,0 +1,65 @@ +# Firefox on macOS. Same match token as the Linux firefox.yaml (the Mac +# process is reported as `firefox`), so this file sorts first (the `-` is +# ASCII 0x2D, before `.`) and wins on the Mac. On a Linux checkout delete +# this file or restructure into per-platform layouts dirs. +match: + - firefox +widgets: + - id: new-tab + kind: button + label: New tab + icon: plus + grid: [0, 0, 1, 1] + action: + key: "super+t" + - id: new-window + kind: button + label: New window + icon: window + grid: [1, 0, 1, 1] + action: + key: "super+n" + - id: back + kind: button + label: Back + icon: arrow-left + color: "#1e3a8a" + grid: [2, 0, 1, 1] + action: + key: "super+[" + - id: forward + kind: button + label: Forward + icon: arrow-right + color: "#1e3a8a" + grid: [3, 0, 1, 1] + action: + key: "super+]" + - id: reload + kind: button + label: Reload + icon: refresh + grid: [0, 1, 1, 1] + action: + key: "super+r" + - id: focus-url + kind: button + label: URL bar + icon: link + grid: [1, 1, 1, 1] + action: + key: "super+l" + - id: find + kind: button + label: Find + icon: search + grid: [2, 1, 1, 1] + action: + key: "super+f" + - id: close-tab + kind: button + label: Close tab + icon: close + grid: [3, 1, 1, 1] + action: + key: "super+w" diff --git a/pyproject.toml b/pyproject.toml index 3e01929..059a6b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,11 @@ dev = [ "pytest>=8", "pytest-asyncio>=0.23", ] +macos = [ + # PyObjC Quartz for synthetic wheel-event injection via CGEventCreateScrollWheelEvent. + # Needed on macOS for the jogstrip to actually scroll the focused window. + "pyobjc-framework-Quartz>=10.0", +] [project.scripts] deckd = "deckd.__main__:main" diff --git a/scripts/check_focus_macos.py b/scripts/check_focus_macos.py new file mode 100644 index 0000000..63a884a --- /dev/null +++ b/scripts/check_focus_macos.py @@ -0,0 +1,92 @@ +"""One-shot diagnostic for the macOS focus watcher. + +Prints: + - what osascript reports for the frontmost process (and its window title) + - whether the auto-ignore rule says "this is the deckd client" + - which layout ``resolve_layout`` would pick for that focused app + +Useful when the daemon's layout doesn't switch as expected: it answers +"is osascript failing?", "is auto-ignore wrongly holding?", and +"is there no matching layout?" without having to read daemon logs. + +Requires the flox environment (or any venv with ``pip install -e .``). +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + + +async def _probe(port: int, layouts_dir: Path) -> int: + from deckd.layouts import LayoutStore, load_layouts, resolve_layout + from deckd.platform import AppInfo + from deckd.platform_macos import MacFocusBackend + + overlay = layouts_dir.parent / f"{layouts_dir.name}.macos" + if not overlay.is_dir(): + overlay = None + + backend = MacFocusBackend() + try: + app = await backend.get_active_app() + except Exception as exc: + print(f"ERROR: focus query failed ({exc.__class__.__name__}): {exc}") + print("On macOS this is almost always a TCC permission issue --") + print("grant Automation permission to the parent shell (Terminal / iTerm / cmux)") + print("under System Settings -> Privacy & Security -> Automation, then re-run.") + return 2 + + print(f"osascript sees: app_id={app.app_id!r} title={app.title!r}") + + store: LayoutStore = load_layouts(layouts_dir, overlay) + resolved = resolve_layout(store, app) + + # Reproduce the auto-ignore check exactly as Server._is_deckd_window does. + title = (app.title or "").strip() + port_match = bool(port) and str(port) in title + exact_match = title.lower() == "deckd" + print( + f"auto-ignore: port_in_title={port_match} " + f"exact_title_match={exact_match} -> holds={port_match or exact_match}" + ) + print(f"would resolve to layout: {resolved.id!r}") + + if ( + resolved.id == "default" + and app.app_id + and app.app_id.lower() in {"firefox", "safari", "chrome"} + ): + print(f"hint: {app.app_id!r} has no specific layout in this checkout,") + print(" so the default fallback is correct.") + + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--port", + type=int, + default=8765, + help="daemon port (matched against the focused window's title by the auto-ignore check)", + ) + parser.add_argument( + "--layouts-dir", + type=Path, + default=Path("layouts"), + help="base layouts directory (overlay is auto-discovered as <name>.macos next to it)", + ) + args = parser.parse_args() + + try: + rc = asyncio.run(_probe(args.port, args.layouts_dir)) + except KeyboardInterrupt: + rc = 130 + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/tests/test_deckd_window.py b/tests/test_deckd_window.py new file mode 100644 index 0000000..b768097 --- /dev/null +++ b/tests/test_deckd_window.py @@ -0,0 +1,119 @@ +"""Tests for ``Server._is_deckd_window`` exact-match behaviour. + +The function decides whether a focus event represents the deckd client +browser gaining focus -- when true, the server holds the current layout +instead of switching away. Two checks: + 1. the daemon's own port appears in the window title + 2. the title is exactly "deckd" (the client's ``<title>``) + +The second check used to be ``"deckd" in title.lower()`` which +false-positived on any tab whose title merely contained "deckd" as a +substring (e.g. a GitHub tab for the deckd repo). These tests pin the +new exact-match contract. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from deckd.platform import AppInfo + + +@dataclass +class _Stub: + """Minimal stand-in for Server -- _is_deckd_window only reads self.port.""" + + port: int + + +def _app(*, app_id: str | None = "firefox", title: str | None = None) -> AppInfo: + """AppInfo with wm_class=None (matches the macOS backend's output + shape). title / app_id defaults can be overridden per-test.""" + return AppInfo(app_id=app_id, wm_class=None, title=title) + + +def _is_deckd(server: _Stub, app: AppInfo) -> bool: + # Late import so test collection doesn't require a fully wired Server. + from deckd.server import Server + + return Server._is_deckd_window(server, app) + + +# --------------------------------------------------------------------------- +# Port-in-title branch +# --------------------------------------------------------------------------- + + +def test_port_in_title_holds() -> None: + """A title carrying the daemon's own port (some browsers surface the + URL in the title bar) holds -- the user is looking at the deckd + client.""" + assert _is_deckd(_Stub(port=8765), _app(title="127.0.0.1:8765")) + + +def test_other_port_in_title_does_not_hold() -> None: + """A different port in the title is a different app -- no hold.""" + assert not _is_deckd( + _Stub(port=8765), _app(title="localhost:8080") + ) + + +def test_no_title_does_not_hold() -> None: + """No title, no signal -- resolve the focus event normally.""" + assert not _is_deckd(_Stub(port=8765), _app(title=None)) + + +# --------------------------------------------------------------------------- +# Exact-title branch (the regression magnet) +# --------------------------------------------------------------------------- + + +def test_exact_deckd_title_holds() -> None: + """The deckd client's ``<title>deckd`` is an exact match.""" + assert _is_deckd(_Stub(port=8765), _app(title="deckd")) + + +def test_exact_deckd_title_with_whitespace_holds() -> None: + """Some terminals / window managers add surrounding whitespace; + the implementation strips before comparing.""" + assert _is_deckd( + _Stub(port=8765), _app(title=" deckd ") + ) + + +def test_exact_deckd_case_insensitive() -> None: + """Page titles can vary in case; lower() comparison covers that.""" + assert _is_deckd(_Stub(port=8765), _app(title="DECKD")) + + +def test_deckd_substring_does_not_hold() -> None: + """Regression: a tab titled "deckd on GitHub" or "deckd-deckd/issues" + must NOT hold -- the user is reading something else and the + previous substring match falsely froze the layout.""" + assert not _is_deckd( + _Stub(port=8765), _app(title="deckd on GitHub") + ) + assert not _is_deckd( + _Stub(port=8765), _app(title="jonocodes/deckd") + ) + assert not _is_deckd( + _Stub(port=8765), _app(title="deckded") + ) + + +def test_empty_title_does_not_hold() -> None: + """An empty string is not the title 'deckd'.""" + assert not _is_deckd(_Stub(port=8765), _app(title="")) + + +# --------------------------------------------------------------------------- +# Port-zero edge case (server hasn't bound yet / explicit override) +# --------------------------------------------------------------------------- + + +def test_port_zero_disables_port_branch() -> None: + """A non-positive port disables the port-in-title branch (the + fallback in __main__ uses 0 when the server isn't bound yet). Only + the exact-title branch can match.""" + assert not _is_deckd(_Stub(port=0), _app(title="1234")) + # ... but the exact-title branch still works. + assert _is_deckd(_Stub(port=0), _app(title="deckd")) diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 0000000..48cffb0 --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,63 @@ +"""Tests for ``deckd.input`` helpers. + +Focused on ``name_from_keycode`` -- the reverse lookup the macOS sink +uses to translate the wire's keycode list back to names for AppleScript. +A regression in this table (e.g. ``leftbrace`` winning over ``[``) +silently breaks ``super+[`` / ``super+]`` shortcuts, so we pin the +canonical form here. +""" +from __future__ import annotations + +from deckd.input import name_from_keycode + + +# --------------------------------------------------------------------------- +# Single-char aliases win over the longer evdev names +# --------------------------------------------------------------------------- + + +def test_name_from_keycode_prefers_single_char_alias() -> None: + """``[`` and ``]`` / ``-`` / ``=`` map to the same code as their + long evdev names; the single-char form must win so the macOS sink + can emit ``keystroke "["`` instead of failing the HID-code lookup. + """ + assert name_from_keycode(26) == "[" + assert name_from_keycode(27) == "]" + assert name_from_keycode(12) == "-" + assert name_from_keycode(13) == "=" + + +def test_name_from_keycode_returns_long_name_when_no_single_char() -> None: + """For keys without a single-char alias (arrows, function keys, etc.), + the long evdev name is what callers get.""" + assert name_from_keycode(103) == "up" + assert name_from_keycode(108) == "down" + assert name_from_keycode(105) == "left" + assert name_from_keycode(106) == "right" + assert name_from_keycode(59) == "f1" + assert name_from_keycode(88) == "f12" + + +def test_name_from_keycode_handles_modifiers() -> None: + """Modifiers are also reverse-lookupable -- the macOS sink translates + them to AppleScript ``command down`` / ``control down`` / etc.""" + assert name_from_keycode(29) == "ctrl" + assert name_from_keycode(42) == "shift" + assert name_from_keycode(56) == "alt" + assert name_from_keycode(125) in {"super", "meta"} + + +def test_name_from_keycode_returns_none_for_unknown() -> None: + """Codes outside the table return None -- the sink treats that as + "skip this key" rather than crashing.""" + assert name_from_keycode(999) is None + assert name_from_keycode(-1) is None + + +def test_name_from_keycode_handles_letters_and_digits() -> None: + """The common-printable cases are single chars already; this is just + a sanity check that they round-trip.""" + assert name_from_keycode(20) == "t" + assert name_from_keycode(30) == "a" + assert name_from_keycode(11) == "0" + assert name_from_keycode(2) == "1" diff --git a/tests/test_layouts.py b/tests/test_layouts.py index 561f44e..52cfe6f 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -297,3 +297,162 @@ def test_layout_with_jogstrip_false_parses(tmp_path: Path) -> None: _write(tmp_path, "default.yaml", body) store = load_layouts(tmp_path) assert store["default"].jogstrip is False + + +# --------------------------------------------------------------------------- +# Platform overlay +# +# The daemon accepts an optional ``overlay_dir`` next to the base layouts +# dir. Overlay entries load first so they shadow base entries with the +# same id; this is the "platform overrides shared" semantic. +# --------------------------------------------------------------------------- + + +def test_overlay_dir_is_optional(tmp_path: Path) -> None: + _write(tmp_path, "default.yaml", DEFAULT_LAYOUT) + # No overlay_dir arg at all -> unchanged single-dir behavior. + store = load_layouts(tmp_path) + assert "default" in store + + +def test_missing_overlay_dir_is_fine(tmp_path: Path) -> None: + _write(tmp_path, "default.yaml", DEFAULT_LAYOUT) + overlay = tmp_path / "does-not-exist" + store = load_layouts(tmp_path, overlay) + assert "default" in store + + +def test_overlay_replaces_same_id_base_entry(tmp_path: Path) -> None: + base = tmp_path / "base" + overlay = tmp_path / "overlay" + base.mkdir() + overlay.mkdir() + _write(base, "firefox.yaml", FIREFOX_LAYOUT) # id=firefox, key=alt+Left + _write( + overlay, + "firefox.yaml", + """ +match: + - firefox +widgets: + - id: new-tab + kind: button + label: New tab + grid: [0, 0, 1, 1] + action: + key: "super+t" +""", + ) + _write(base, "default.yaml", DEFAULT_LAYOUT) + + store = load_layouts(base, overlay) + firefox_layouts = [l for l in store.layouts if l.id == "firefox"] + # Overlay replaces base entirely -- no duplicate id left in the store. + assert len(firefox_layouts) == 1 + # And it's the overlay's action that survives. + assert firefox_layouts[0].widgets[0].action.key == "super+t" + + +def test_overlay_wins_on_match_conflict_with_different_filename(tmp_path: Path) -> None: + """When an overlay entry matches the same app as a base entry but has + a different filename (so different ``id``), first-match-wins within + the combined list resolves it: overlay entries load first, so the + overlay wins.""" + base = tmp_path / "base" + overlay = tmp_path / "overlay" + base.mkdir() + overlay.mkdir() + _write(base, "firefox.yaml", FIREFOX_LAYOUT) # id=firefox, match=[firefox] + _write( + overlay, + "macos-firefox.yaml", + """ +match: + - firefox +widgets: + - id: new-tab + kind: button + label: New tab + grid: [0, 0, 1, 1] + action: + key: "super+t" +""", + ) + + store = load_layouts(base, overlay) + app = AppInfo(app_id="firefox", wm_class="firefox") + layout = resolve_layout(store, app) + assert layout.widgets[0].action.key == "super+t" + + +def test_overlay_can_add_new_layouts(tmp_path: Path) -> None: + """An overlay file for an app the base doesn't cover is additive.""" + base = tmp_path / "base" + overlay = tmp_path / "overlay" + base.mkdir() + overlay.mkdir() + _write(base, "default.yaml", DEFAULT_LAYOUT) + _write(overlay, "safari.yaml", """ +match: + - Safari +widgets: + - id: new-tab + kind: button + label: New tab + grid: [0, 0, 1, 1] + action: + key: "super+t" +""") + + store = load_layouts(base, overlay) + assert "Safari" in store + assert "default" in store + + +# --------------------------------------------------------------------------- +# Overlay directory discovery (__main__._overlay_dir_for) +# +# The daemon auto-discovers ``.`` next to +# the base dir. The suffix is ``macos`` on Darwin, ``linux`` elsewhere. +# Pure path math, tested by monkeypatching sys.platform. +# --------------------------------------------------------------------------- + + +def test_overlay_dir_for_darwin(monkeypatch, tmp_path: Path) -> None: + """On macOS the overlay path is ``.macos``.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "darwin") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "layouts" + assert _overlay_dir_for(base) == tmp_path / "layouts.macos" + + +def test_overlay_dir_for_linux(monkeypatch, tmp_path: Path) -> None: + """On Linux (or any non-darwin sys.platform) the suffix is ``linux``.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "linux") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "layouts" + assert _overlay_dir_for(base) == tmp_path / "layouts.linux" + + +def test_overlay_dir_for_unknown_platform_defaults_to_linux(monkeypatch, tmp_path: Path) -> None: + """An unmapped sys.platform (e.g. ``freebsd``) falls back to ``linux`` + suffix -- unknown platforms behave like Linux for overlay purposes.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "freebsd") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "layouts" + assert _overlay_dir_for(base) == tmp_path / "layouts.linux" + + +def test_overlay_dir_preserves_arbitrary_base_name(monkeypatch, tmp_path: Path) -> None: + """The base dir can be named anything; only ``.`` is + computed. Useful for users who keep their layouts in + ``~/.config/deckd/layouts`` or similar.""" + monkeypatch.setattr("deckd.__main__.sys.platform", "darwin") + from deckd.__main__ import _overlay_dir_for + + base = tmp_path / "my-configs" / "deckd-layouts" + base.parent.mkdir() + assert _overlay_dir_for(base) == base.parent / "deckd-layouts.macos" diff --git a/tests/test_platform_macos.py b/tests/test_platform_macos.py new file mode 100644 index 0000000..3980fe1 --- /dev/null +++ b/tests/test_platform_macos.py @@ -0,0 +1,155 @@ +"""Tests for ``deckd.platform_macos._build_keystroke_script``. + +This is the pure-Python translation that turns an evdev keycode list +(modifiers + main key) into a one-line AppleScript. The actual +``osascript`` call is fire-and-forget so we can't integration-test it +cleanly; the string output is what we assert on. + +Runs on any platform -- the function doesn't import Quartz / osascript. +""" +from __future__ import annotations + +import pytest + +from deckd.input import parse_key_combo +from deckd.platform_macos import _build_keystroke_script + + +# --------------------------------------------------------------------------- +# Printables: keystroke "" +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "combo,expected", + [ + ("t", 'tell application "System Events" to keystroke "t"'), + ("a", 'tell application "System Events" to keystroke "a"'), + ("0", 'tell application "System Events" to keystroke "0"'), + ("=", 'tell application "System Events" to keystroke "="'), + ("-", 'tell application "System Events" to keystroke "-"'), + ("[", 'tell application "System Events" to keystroke "["'), + ("]", 'tell application "System Events" to keystroke "]"'), + ], +) +def test_printable_char_emits_keystroke_literal(combo: str, expected: str) -> None: + """Single-character printables go out as ``keystroke ""`` -- + locale-correct, no HID-code lookup needed. This is the path that + fixed the ``super+[`` shortcut regression.""" + assert _build_keystroke_script(parse_key_combo(combo)) == expected + + +# --------------------------------------------------------------------------- +# Combos: using { down} +# --------------------------------------------------------------------------- + + +def test_super_modifier_maps_to_command() -> None: + """``super`` is the Mac "Command" key -- the modifier clause uses + ``command down``.""" + script = _build_keystroke_script(parse_key_combo("super+t")) + assert script == ( + 'tell application "System Events" to keystroke "t" ' + "using {command down}" + ) + + +def test_ctrl_maps_to_control() -> None: + script = _build_keystroke_script(parse_key_combo("ctrl+a")) + assert 'using {control down}' in script + assert 'keystroke "a"' in script + + +def test_alt_maps_to_option() -> None: + script = _build_keystroke_script(parse_key_combo("alt+Tab")) + assert 'using {option down}' in script + assert "key code 48" in script # Tab's HID code + + +def test_meta_and_super_both_become_command() -> None: + """``meta`` is an alias for ``super`` on Linux; both should yield + ``command down`` on macOS.""" + a = _build_keystroke_script(parse_key_combo("super+a")) + b = _build_keystroke_script(parse_key_combo("meta+a")) + assert a == b + + +def test_multiple_modifiers_combine_in_order() -> None: + """Modifiers are emitted in the order they appear in the input -- + the wire protocol always puts them first so the test combo matches + the canonical order, but the clause should still emit all of them.""" + script = _build_keystroke_script(parse_key_combo("ctrl+shift+Right")) + assert script == ( + 'tell application "System Events" to key code 124 ' + "using {control down, shift down}" + ) + + +def test_super_plus_bracket_uses_literal_keystroke() -> None: + """Regression test: ``super+[`` used to log + ``no macOS mapping for key 'leftbrace'`` because the keycode + reverse-lookup returned the long alias. Now ``name_from_keycode`` + prefers the single-char form and the script is a clean + ``keystroke "["``.""" + script = _build_keystroke_script(parse_key_combo("super+[")) + assert script == ( + 'tell application "System Events" to keystroke "[" ' + "using {command down}" + ) + + +# --------------------------------------------------------------------------- +# Non-printables: key code +# --------------------------------------------------------------------------- + + +def test_function_key_uses_hid_code() -> None: + """F1 -> key code 122 (HID usage ID); no modifiers.""" + assert _build_keystroke_script(parse_key_combo("F1")) == ( + 'tell application "System Events" to key code 122' + ) + + +def test_arrow_uses_hid_code() -> None: + assert _build_keystroke_script(parse_key_combo("Up")) == ( + 'tell application "System Events" to key code 126' + ) + + +def test_return_uses_hid_code() -> None: + assert _build_keystroke_script(parse_key_combo("Return")) == ( + 'tell application "System Events" to key code 36' + ) + + +# --------------------------------------------------------------------------- +# Failure modes +# --------------------------------------------------------------------------- + + +def test_empty_keycodes_returns_none() -> None: + """An empty list (e.g. parsed combo with no known keys) yields + None -- the sink logs and skips rather than emitting ``key code`` + with nothing.""" + assert _build_keystroke_script([]) is None + + +def test_unknown_keycode_returns_none() -> None: + """A keycode not in either forward table is untranslatable; the + sink logs and skips rather than crashing.""" + assert _build_keystroke_script([29, 999]) is None + + +def test_unknown_main_key_returns_none() -> None: + """If the main key isn't printable AND isn't in the HID map, we + bail out -- we don't want to emit garbage key codes.""" + # ``foo`` is not in MODIFIER_MAP or _SINGLE_KEY_MAP. + assert _build_keystroke_script([20, 9999]) is None + + +def test_only_modifiers_returns_none() -> None: + """A combo that ends up modifier-only (e.g. a parse that lost the + main key) returns None -- the sink shouldn't emit ``keystroke`` + with no character.""" + # Pass a keycode that's only in MODIFIER_MAP, twice. + assert _build_keystroke_script([29, 29]) is None