Skip to content

Phase 4: radial Actions Ring overlay#3

Merged
ChristopherLandaverde merged 34 commits intomainfrom
phase4-ring-prototype
Apr 27, 2026
Merged

Phase 4: radial Actions Ring overlay#3
ChristopherLandaverde merged 34 commits intomainfrom
phase4-ring-prototype

Conversation

@ChristopherLandaverde
Copy link
Copy Markdown
Owner

Summary

  • Adds the radial Actions Ring overlay (PyQt6, X11-only v1).
  • Replaces Binding.action with polymorphic Binding.target = "ring:NAME" | "action:NAME". Legacy action = "..." form preserved with a one-line deprecation log.
  • Extends device.read_loop to emit both key-down and key-up via InputEvent.pressed.
  • New overlay/ package: pure geometry (wedge_index, is_in_dead_zone, shifted_center_for_screen), RingWidget (frameless top-level, paint), CursorPoller (8ms QTimer), RingController state machine.
  • Listener splits into command-only path (no Qt, Phase 2 backward compat) and Qt-driven path (worker thread + Qt.QueuedConnection to a _MainBridge slot for safe GUI dispatch).
  • [ring] is an optional extra; PyQt6 missing + ring binding configured is caught at check-config.
  • CI runs widget tests under xvfb-run.
  • New diagnostic helper scripts/dump-keys.py for mapping evdev codes on Logitech hardware.

Spec: docs/superpowers/specs/2026-04-26-phase4-actions-ring-design.md
Plan: docs/superpowers/plans/2026-04-26-phase4-actions-ring.md

Caveats found during hardware test (documented in commit messages + example config)

  • BTN_TASK is advertised by the kernel on the dev MX hardware but never fires; mapped real codes via scripts/dump-keys.py.
  • WA_TranslucentBackground interacts badly with Mutter (rendered as invisible black square) — disabled in v1; transparency moves to a future theming phase.
  • All thumb codes (BTN_SIDE/EXTRA/BACK/FORWARD) dual-fire with browser back/forward in any focused app since v1 does not grab the device. Acceptable trade-off documented.

Test plan

  • Phase 2's 35 baseline tests still pass (regression-free schema migration)
  • 72 new tests (config, geometry, controller, widget under qtbot, listener dispatch, Qt smoke)
  • Manual hardware test on real Logitech MX (event25): ring opens, follows cursor, fires segment on release-outside-deadzone, cancels on release-in-deadzone
  • CI updated for xvfb-run so widget tests run headless

🤖 Generated with Claude Code

ChristopherLandaverde and others added 30 commits April 26, 2026 20:46
Brainstorm output for the radial Actions Ring overlay (PyQt6, X11-only v1).
Covers trigger model (hold-to-show / release-to-fire), variable segment
count per ring (3-12, default 8), polymorphic binding targets
(action:NAME | ring:NAME) with backward compat for the legacy
action = "..." form, and a worker-thread evdev listener feeding a Qt main
thread that owns the overlay widget.

Phase 3 concerns (per-app profiles, new action types) and Wayland support
explicitly deferred. Icons accepted in schema, not rendered in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 bite-sized tasks following TDD per-cycle commits, mirroring Phase 2's
discipline. Covers schema migration (polymorphic targets + legacy shim),
read_loop key-up extension, three pure-geometry units, RingWidget +
CursorPoller + RingController, and the worker-thread/Qt-main-thread split
in cli/listen.py.

PyQt6 stays an optional [ring] extra; command-only configs run on the
existing non-Qt path. Phase 2's 35 tests must remain green at every commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code quality reviewer flagged the local `import math as _m` inside
paintEvent — Python caches module lookups but it's a smell. Move to
module top and use math.radians for the degree→radian conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g in dispatch_event

Quality reviewer flagged an unused InputEvent import and an unnecessarily
defensive getattr() chain on run_action's result (ActionResult always
has .ok and .detail; the None/getattr guards added nothing). 106 tests
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests

Phase 4 introduces requires_display tests (RingWidget, CursorPoller, Qt
listener smoke). The conftest hook skips them when DISPLAY is unset;
in CI we provide one via xvfb-run. Pulls in [ring] extra for PyQt6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 4 ring controller never opened on real hardware. Diagnosis: the
worker-thread evdev signal was connected to a free Python function, which
PyQt6 routed via DirectConnection. That meant ring_controller.open() —
and the QWidget calls inside it — ran on the worker thread. Qt silently
no-ops most GUI calls from a non-main thread, so the ring widget never
appeared even though dispatch was reaching the controller.

Fix: introduce _MainBridge(QObject) with a @pyqtSlot decorator, parented
to the main thread, and connect with explicit Qt.QueuedConnection. Also
move QCursor.pos() out of the worker thread (queried in the main-thread
slot now). Also adds INFO logs for ring open/close so the next time
something silently fails, the listener log shows whether dispatch is
even reaching the controller.

Verified end-to-end on real Logitech MX hardware (event25): ring opens
on key-down, follows cursor, fires the highlighted segment on key-up,
cancels on release-in-deadzone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…isibility

On real hardware (Pop!_OS / GNOME / Mutter), enabling WA_TranslucentBackground
combined with translucent BG colors caused the entire ring widget to render
as an invisible black square — paintEvent ran, geometry was right, but
nothing was visible on screen.

Disable WA_TranslucentBackground in v1 and ship with opaque colors:
- BG_COLOR = rgb(40, 40, 40)
- ACTIVE_BG_COLOR = rgb(80, 80, 80)
- DEAD_ZONE_COLOR = rgb(20, 20, 20) — extracted as a named constant
  instead of an inline magic QColor inside paintEvent

Window-level fade-in via setWindowOpacity stays — that uses the WM composite
path and works regardless of WA_TranslucentBackground.

Theming + opt-in transparency was already deferred to a polish phase in
spec §2 / §10. Updated test_widget_can_be_constructed_and_shown to assert
the new attribute state and explain why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Helper for diagnosing which evdev codes a given input device emits when
buttons are pressed. Used during the Phase 4 hardware test to confirm that
this MX hardware (event25, "Logitech USB Receiver Mouse") does NOT emit
BTN_TASK on the gesture button — it only exposes BTN_LEFT/RIGHT/MIDDLE,
BTN_BACK/SIDE/EXTRA/FORWARD. That clears the Phase 2 carry-forward note
about BTN_TASK and explains why ring bindings on this MX must use one of
the thumb codes (with the dual-fire trade-off vs browser-back) until
device grabbing lands in a later phase.

Run: ./scripts/dump-keys.py /dev/input/eventNN

Also adds venv/ to .gitignore (the project venv is .venv/; venv/ was
created accidentally).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChristopherLandaverde and others added 4 commits April 26, 2026 23:45
Hardware test on real MX revealed BTN_TASK is advertised by the kernel
but never fires on at least some MX models — Logitech routes the gesture
button through proprietary HID reports the kernel doesn't translate.
Example config now points users at scripts/dump-keys.py to find out
what their MX actually emits, with a note on BTN_SIDE/BTN_EXTRA fallback
and the dual-fire trade-off vs browser back/forward.

Also flags gnome-screenshot as a non-portable default — the example
keeps it (most common Linux desktop) but tells users to swap for
flameshot/scrot/notify-send when it's not installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earned during the Phase 4 hardware test ("let's do brazil colors"). Ships
two named themes in a `_THEMES` dict at module load:

- dark (default) — opaque grays, the production v1 palette
- brazil — bandeira green / yellow dead zone / blue active wedge

Picked via LOGITECHMOUSE_THEME=<name>; unknown names fall back to dark
without raising. Themes are baked at import time (intentional — no per-event
overhead). Full TOML-driven theming stays a polish-phase item per spec §2/§10;
this is a tasteful escape hatch users can grab without editing code.

3 new requires_display tests verify default, brazil swap, and unknown
fallback. Full suite: 110 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…endering

Without WA_TranslucentBackground, the 368x368 widget rectangle was being
drawn opaque — the colored ring filled the inscribed circle, but the four
corner triangles showed up as a black/system-bg square framing the ring.

Setting QRegion.Ellipse as the widget mask tells the WM only the circular
area is part of the widget; the corners aren't drawn at all and the
desktop shows through. Pairs cleanly with the WA_TranslucentBackground=
off decision from a2c7ec5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PyQt6 6.11 (which the Ubuntu runner pulls in) ships Qt 6.11; the xcb
platform plugin has required libxcb-cursor since Qt 6.5. Without it,
QApplication() aborts the process with no error message — which is what
the pytest (3.12) job hit after the 38 non-Qt tests passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ChristopherLandaverde ChristopherLandaverde merged commit 0f8ce57 into main Apr 27, 2026
2 checks passed
@ChristopherLandaverde ChristopherLandaverde deleted the phase4-ring-prototype branch April 27, 2026 09:15
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.

1 participant