Skip to content

feat(capture): wall-press auto-release + Bounds protocol foundation#420

Open
jondkinney wants to merge 12 commits into
feschber:mainfrom
jondkinney:split/01-cursor-and-walls
Open

feat(capture): wall-press auto-release + Bounds protocol foundation#420
jondkinney wants to merge 12 commits into
feschber:mainfrom
jondkinney:split/01-cursor-and-walls

Conversation

@jondkinney
Copy link
Copy Markdown
Contributor

@jondkinney jondkinney commented May 6, 2026

Wall-press auto-release feature plus the Bounds proto event the cursor-sync work builds on.

Summary

  • Cross-platform wall-press auto-release fallback (cursor releases when pushed past a configurable threshold)
  • Bounds(width, height) proto event for receivers to advertise their display dimensions
  • All emulation backends report display_bounds and warp the cursor on Enter
  • Capture caches peer bounds for use as the wall-press upper clamp

Test plan

  • Push cursor against the far edge of the peer screen; auto-release fires after threshold
  • Verify on macOS, Windows, and Linux (wlroots/X11)

Related PRs

This PR is part of an effort to split #418 into focused, independently-reviewable pieces.

The split stack (each builds on the previous; review in order):

  1. feat(capture): wall-press auto-release + Bounds protocol foundation #420 — wall-press auto-release + Bounds protocol foundation
  2. feat(cursor-sync): CursorPos protocol + cursor warps without prior Bounds #429 — CursorPos protocol + cursor warps without prior Bounds
  3. feat(capture): suppress cross-machine crossings while host screen is locked #430 — host-lock crossing suppression
  4. feat: peer version exchange via Hello proto event #431 — peer version exchange
  5. fix: hostname resolution via OS resolver + multi-homed DTLS listener #432 — multi-homed DTLS listener + OS-resolver DNS
  6. feat(discovery): mDNS-SD primary-IP hints for service-order-aware dialing #433 — mDNS-SD primary-IP hints
  7. macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish #434 — macOS QoL bundle + UI polish
  8. feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes #435 — per-pair scroll/sensitivity + scroll & version-exchange fixes
  9. feat(gui): cross-platform GUI singleton via dedicated socket #436 — cross-platform GUI singleton

Plus:


CleanShot 2026-05-07 at 09 39 37@2x

jondkinney and others added 11 commits May 6, 2026 16:01
Adds a host-side fallback that releases capture when the user
sweeps the cursor against the host-adjacent edge of the guest
and keeps pushing past a configurable threshold. Solves the
"two locked screens" case where the peer's capture backend
can't fire CaptureBegin (and therefore can't send Leave back),
leaving the host stuck capturing indefinitely until the
release-bind chord is pressed.

Algorithm lives in InputCapture::poll_next so every backend
(macOS, libei, layer-shell, x11, windows, dummy) gets it for
free — they only need to emit standard motion events through
the existing Stream interface, which they already do. The
wrapper tracks:

  virtual_pos: signed position along the entry axis, clamped at
    0 from below. No upper clamp — the wrapper can't know the
    guest's far-edge extent without protocol-level cooperation,
    and any proxy is wrong for some user's setup.
  wall_pressure: motion that overshoots the host-adjacent edge
    and would have driven virtual_pos negative. Fires
    CaptureEvent::AutoRelease when the threshold is reached;
    the capture loop then runs the same teardown path as the
    release-bind chord.

State resets on Begin (entry to capture), AutoRelease (we
self-released), and external release (chord, peer Leave,
connection error, EnterOnly fallback).

Surface:
- New FrontendRequest::SetReleaseThreshold + FrontendEvent::
  ReleaseThreshold IPC pair.
- New release_threshold_px field on the daemon config (0 = off,
  serialized to config.toml).
- New AdwPreferencesGroup with a 0–500px slider in the GTK
  window. Default 0 (disabled) so existing users see no
  behavior change until they opt in.
- New CaptureEvent::AutoRelease variant + handling in
  src/capture.rs's handle_capture_event (short-circuit to
  release_capture, which already synthesizes key-ups and sends
  Leave to the peer).

Known limitation: the wrapper has no way to know where the
guest's cursor actually is (the guest doesn't tell us). On
re-entry into a peer mid-session, virtual_pos resets to 0 but
the guest's cursor may still be in the middle of its screen
from the prior session, causing the threshold to fire from
the wrong reference point. A protocol-level Bounds event +
cursor-warp on Enter is needed for full correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new ProtoEvent variant carrying the receiving device's
display geometry (in pixels). Sent by the emulation side
right after acknowledging an Enter so the capturing peer can
model the guest cursor's position along the entry axis.

Wire format: 1-byte EventType discriminator (Bounds = 11)
followed by big-endian u32 width and big-endian u32 height
— 9 bytes total, well under MAX_EVENT_SIZE (21).

This commit only adds the protocol wiring. Senders and the
host-side cache come in subsequent commits. Old peers that
don't recognize EventType=11 will skip the datagram per the
forward-compat fix in the previous commit, so deployment is
incremental: the emulation side can start sending Bounds
without breaking older capturing peers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `display_bounds(pos)` and `warp_cursor(pos)` to the
InputEmulation trait and implement them across every backend:

  - macOS: CGDisplay APIs for bounds, CGWarpMouseCursorPosition for warp
  - x11: RandR for bounds, XWarpPointer for warp
  - wlroots: wl_output extents + virtual_pointer.motion_absolute
  - libei: region walking + ei_pointer.emit_motion_absolute
  - Windows: GetSystemMetrics + SetCursorPos
  - xdg_desktop_portal: no-op fallback (the protocol exposes neither
    bounds nor a warp primitive)

These are the prerequisites for the protocol-based wall-press
auto-release: emulation hosts now have a common API to report their
display extents to peers and to warp the cursor on Enter so the
host's modeled virtual_pos = 0 matches the guest's actual cursor.
Wire the new emulation-side capabilities into the daemon's
listener task. When a peer's Enter arrives:

  1. Reply Ack (existing behavior).
  2. Reply Bounds(width, height) using the cached display
     geometry from the active emulation backend.
  3. Warp the local cursor to the entry edge of the displayed
     position (0 for Left, width-1 for Right, etc., centered
     along the orthogonal axis).

The warp is the structural fix for the "cursor jumps back to
where it was" symptom: previously, on re-entry into a peer
mid-session, the cursor stayed wherever the prior capture
session left it, breaking the host's wall-press model
(virtual_pos=0 in the host's mind didn't match the guest's
actual cursor column). With the warp, the host's model is
synchronized with the guest's reality on every Enter.

EmulationProxy gains:
  - Cached display_bounds (Rc<Cell<Option<(u32, u32)>>>),
    refreshed each time the underlying InputEmulation is
    (re)created. Read by the listener task.
  - warp_cursor(x, y) fire-and-forget. Drops if emulation
    isn't currently active (no live backend to receive it).

ProxyRequest::Warp(x, y) carries the request to EmulationTask,
which dispatches to InputEmulation::warp_cursor.

If the active backend doesn't implement display_bounds — every
non-macOS backend right now — the listener skips the Bounds
reply and the warp call. The capturing peer falls back to its
existing "no upper clamp / virtual_pos = 0 on Begin" heuristic,
which is degraded but functional. Adding display_bounds /
warp_cursor to other backends unlocks correct behavior
incrementally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InputCapture now keeps a per-position HashMap of peer display
geometry, populated when ProtoEvent::Bounds arrives from the
peer (handled in src/capture.rs's recv arm). track_wall_press
uses the cached entry-axis extent as the upper clamp for
virtual_pos:

  self.virtual_pos = proposed.clamp(0.0, peer_extent);

Eliminates the runaway-virtual_pos bug from the heuristic
fallback: when the user obliviously over-pushes their physical
mouse past the guest's actual far edge, the modeled position
clamps at the real width instead of climbing fictionally to
infinity. Now the user's "walk back" cost is bounded by the
guest's actual screen width.

When the peer hasn't sent Bounds yet (older peer running
without the protocol extension, or in the brief pre-Ack
window of a fresh connection), peer_extent returns INFINITY
and the model degrades to the prior heuristic.

Cache lifecycle:
  - Insert on ProtoEvent::Bounds.
  - Drop on CaptureRequest::Destroy(handle) so re-adding the
    same peer later starts fresh.

Combined with the previous commit (emulation warps cursor on
Enter), the host's virtual_pos = 0 at Begin now matches the
guest's actual cursor at column 0 (or width-1, etc.) on every
re-entry. The "cursor was in the middle, 200px back fires
release prematurely" bug is fixed structurally rather than
papered over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The label "Auto-Release" reads as a global app preference; the
description's "forwarded mouse capture" was ambiguous about which
machine does the forwarding. Rename the group to
"Outgoing Auto-Release" so the scope mirrors the surrounding
"Outgoing Connections" / "Incoming Connections" groups, and lead
the description with "When this machine is capturing input for a
peer …" so a user scanning the window can tell at a glance that
this setting only matters when the local machine is the host.
GtkScale's default behavior treats a vertical scroll event as
+/- increment, which means the threshold creeps any time the
user is scrolling the window and the cursor passes over the
slider — easy to do given the slider sits in the middle of the
preferences pane.

Add an EventControllerScroll to the slider in CAPTURE phase that
returns Propagation::Stop unconditionally. The scale's own scroll
controller never sees the event, so the value doesn't change.

Trade-off: scrolling doesn't pass through to the parent
GtkScrolledWindow while the cursor is on the slider — the wheel
becomes inert there. Acceptable: prior behavior was actively
destructive (silent state corruption); this is just "no scroll
in this small region." If users start complaining about the gap,
the next step is to forward dy to the ancestor scrolled window's
vadjustment manually before returning Stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old text described the mechanism ("releases capture automatically once
the cursor pushes past the host-adjacent edge") without explaining
when the user would actually need it. With the new peer-Leave deadline
gate (34605a7), wall-press only fires when the peer can't deliver a
Leave — i.e. when the peer's screen is locked or its capture backend
is otherwise suppressed. New text leads with that framing and trims
two sentences to two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture-phase scroll handler used to return Propagation::Stop to
suppress GtkScale's default scroll-to-adjust behavior, but Stop also
killed propagation to the parent — so the main window wouldn't
scroll when the cursor was over the slider. Frustrating because the
slider sits in the middle of the preferences pane and "I just want
to scroll past this" is the common interaction.

Same capture-phase handler now walks up to the ancestor
ScrolledWindow and bumps its vadjustment by `dy * step_increment`
(or 40px when step_increment is unset). Mimics what native scroll
passthrough would have done — slider value stays fixed, parent
scrolls smoothly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows clippy flags `loop { let Some(...) = get_msg() else { break } }`
as while-let-loop. Rewrite to `while let Some(msg) = get_msg() { … }`.
The inner `break` for `RequestType::Exit` still breaks the surrounding
while-let, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Proxy trait import needed by the wlroots backend's
`output.id()` call (introduced when the emulation side started
binding wl_output for display_bounds), and applies cargo fmt for
this split's own files.
`test windows-latest`'s only failure was the post-job cache-save
step in `Swatinem/rust-cache@v2` (job step 27); `cargo test`
itself (step 12) succeeded with all tests passing. CI rerun on
upstream requires admin rights, so an empty commit is the only
way to retrigger from this side.
@jondkinney
Copy link
Copy Markdown
Contributor Author

jondkinney commented May 7, 2026

@feschber this is the new base of the work that I split out. Sorry for the noise of closing the last stack of them. That happened automatically when I was trying to rename the branches to be in a good review order and be as atomic as possible.

This particular PR is made against main, but starting with #429 you can review each of the PRs against the prior using the Review-only focused diff (just this PR's commits, vs. note at the top of the description of each PR, which shows a separate comparison against the prior PR.

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