Skip to content

feat: enter a client with a key bind instead of a screen edge - #482

Open
khac88 wants to merge 1 commit into
feschber:mainfrom
khac88:feat/enter-bind
Open

feat: enter a client with a key bind instead of a screen edge#482
khac88 wants to merge 1 commit into
feschber:mainfrom
khac88:feat/enter-bind

Conversation

@khac88

@khac88 khac88 commented Aug 10, 2026

Copy link
Copy Markdown

Closes #260.

Adds optional [enter_binds]: key combinations that begin capture at a position
without the pointer having to travel into the corresponding screen edge.

[enter_binds]
right = [ "KeyLeftCtrl", "KeyLeftAlt", "KeyRight" ]
top = [ "KeyLeftCtrl", "KeyLeftAlt", "KeyUp" ]

Motivation from the issue: people who navigate by keyboard don't want to reach for
the mouse just to change machines, edges get crossed by accident, and games that
capture the pointer make the edge unreachable.

Approach

A bind produces exactly the capture an edge crossing produces — the backend calls
the same start_capture() and emits the same CaptureEvent::Begin — so everything
downstream (CaptureTask, service, enter_hook, release_bind) works unchanged and
there is no second notion of "entered".

Consequences that fall out of that, rather than being special-cased:

  • a bind only fires while capture is inactive; release_bind is still what returns
    control to the local machine
  • a bind only fires for a position that currently has an active client, so it can
    never enter a client an edge crossing could not

Binds are keyed by position, not by client, for the same reason: crossing an edge
enters every client at that edge, so a per-client bind would have been a per-position
bind wearing a disguise — with the added problem that the frontend's save_config
round-trip has no representation for it and would have had to reconstruct it.

The matching itself lives in one small, backend-independent type
(input-capture/src/enter_bind.rs) that both backends drive, with unit tests
covering: firing on the last key down, key order independence, never firing on
release, ignoring positions without a client, extra held keys not blocking the bind,
and the two ways stale held-keys could otherwise fire a bind (below).

Capture::set_enter_binds has a default no-op implementation, so the Linux backends
are untouched and keep entering by edge only — the input capture portal doesn't
deliver input while capture is inactive, as noted in #260.

Notes for review

  • Held keys are forgotten whenever capture begins, by either route. Keys stop being
    observed for the duration of a capture, so without this a key held when capture
    began (and released during it, unseen) would still count as held afterwards, and
    could complete a bind on its own later.
  • The bind chord is consumed locally and not forwarded, so the remote starts with no
    keys held. That mirrors release_capture, which deliberately releases everything
    before handing control back — clean slate in both directions. Carrying held
    modifiers across the switch instead would be a separate change.
  • On macOS the idle branch decodes keys through the existing get_events() rather
    than reading the CGEvent separately, so modifier bookkeeping stays continuous
    across the idle → capturing transition instead of a modifier held at capture start
    being re-reported as a fresh press.
  • A key event carries no meaningful pointer location, so the macOS path queries the
    window server for the real cursor position instead of reading it off the triggering
    event; start_capture now takes a CGPoint for that reason. Windows uses
    GetCursorPos and seeds both ENTRY_POINT and PREV_POS so the first motion
    delta after entering is ~0 rather than a jump.

Testing

  • cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings
    and cargo test --workspace --all-features are clean on macOS, and cargo check /
    cargo clippy are clean for x86_64-pc-windows-msvc.

  • macOS verified end-to-end against a Windows peer over Tailscale: pressing the bind
    from the idle state enters the client exactly as an edge crossing does —

    input_capture::macos] entering client @ top: enter-bind pressed
    lan_mouse::service]   entering client 0 ...
    lan_mouse::connect]   client (0) connected @ …:4242
    lan_mouse::capture]   client 0 acknowledged the connection!
    lan_mouse::capture]   releasing capture: left remote client device region
    

    Confirmed on real hardware too: pressing the bind physically on the Mac hands input
    to the Windows machine — moving the trackpad afterwards moves the remote pointer.
    Entering by edge afterwards is unaffected and release_bind still hands control
    back.

    Note the pointer lands wherever it was left on the remote rather than at a matching
    position, since Enter carries no coordinates — same as an edge crossing, and
    orthogonal to this PR (cf. the CursorPos proposal in feat(cursor-sync): CursorPos protocol + cursor warps without prior Bounds #429).

  • Windows: implemented and compile/clippy-checked for x86_64-pc-windows-msvc, but
    not exercised at runtime — the hook path mirrors the macOS one and shares the tested
    matcher, so a confirmation from someone running the Windows capture backend would be
    welcome.

Possible follow-ups (deliberately not in this PR)

Adds optional `[enter_binds]`: key combinations that begin capture at a
position without the pointer having to travel into the corresponding
screen edge.

A bind produces exactly the capture an edge crossing produces — the
backend calls the same `start_capture()` and emits the same
`CaptureEvent::Begin` — so everything downstream (CaptureTask, service,
enter_hook, release_bind) works unchanged and there is no second notion
of "entered". Consequently a bind only fires while capture is inactive,
and only for a position that currently has an active client.

Binds are keyed by position rather than by client because entering is
position-based: crossing an edge enters every client at that edge.

The matching lives in one backend-independent type
(input-capture/src/enter_bind.rs) that both the macOS and Windows
backends drive, with unit tests. `Capture::set_enter_binds` defaults to
a no-op, so the Linux backends are untouched — the input capture portal
does not deliver input while capture is inactive.

Closes feschber#260
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.

Feature: Add a keyboard shortcut to change to different screens

1 participant