Skip to content

Add Linux background drag and held-button tools - #1871

Merged
r33drichards merged 14 commits into
mainfrom
r33d/linux-background-x11-drag-tools
Jun 11, 2026
Merged

Add Linux background drag and held-button tools#1871
r33drichards merged 14 commits into
mainfrom
r33d/linux-background-x11-drag-tools

Conversation

@r33drichards

@r33drichards r33drichards commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

This pull request adds support for a "pressed" (held-button) visual state to the cursor overlay system, and refactors the Linux overlay backend to support multiple simultaneous overlays (multi-cursor) via keying. The changes include new overlay commands, new rendering logic for the pressed state, and significant refactoring to manage multiple cursors in the Linux backend.

New cursor pressed state and visual feedback:

  • Added a pressed field to RenderStateCore and a new SetPressed command to OverlayCommand, allowing the overlay to visually indicate when a button is held. The rendering code now draws an enlarged bloom and ring effect when pressed. [1] [2] [3] [4] [5] [6]

New overlay commands and improvements:

  • Added a SnapTo command to OverlayCommand for immediately moving the cursor to a given position, optionally updating heading. [1] [2]

Linux overlay backend refactor for multi-cursor:

  • Refactored the Linux overlay backend to manage multiple overlays using a RenderMap keyed by CursorKey, supporting per-cursor state, commands, and removal. Functions like send_command, is_enabled, and current_position now have per-cursor variants. [1] [2] [3] [4] [5] [6] [7]

X11 overlay improvements:

  • Changed X11 input region handling to use shape_rectangles with an empty rectangle list instead of an empty pixmap, ensuring proper click-through behavior.

Dependency updates:

  • Added x11 and evdev crates to Linux overlay dependencies for future input and X11 integration. [1] [2]

Summary by CodeRabbit

  • New Features

    • Added multi-cursor mouse state tracking to support simultaneous cursor operations
    • Introduced cursor repositioning commands with visual button-press indicators
    • Added separate mouse button down, drag, and up operations per cursor
  • Bug Fixes

    • Enhanced window target resolution for more accurate mouse event delivery
  • Infrastructure

    • Added X11 library dependencies for improved platform integration

@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Preview Jun 11, 2026 8:01am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a448237-bce5-4340-b473-97f65899a5d5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Resolves X11 pointer targets to the deepest child window and translates root/local coordinates; converts synthetic click/drag/motion primitives to use resolved targets and correct button state. Migrates overlay to keyed multi-cursor render map and adds per-cursor held-mouse state plus three new tools for button-down, drag, and button-up workflows.

Changes

Mouse Event Targeting and Held-Mouse Tools

Layer / File(s) Summary
Event target resolution foundation
libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs
Introduces EventTarget, deepest_child_at_point, resolve_event_target, and button_state_mask; updates click/drag/motion/button primitives to emit events to resolved windows with computed root/event coordinates and proper state.
Keyed multi-cursor overlay runtime
libs/cua-driver/rust/crates/platform-linux/src/overlay.rs
Replace single-cursor channel with keyed OverlayMsg + RenderMap; add per-cursor arrival one-shots, keyed send/is_enabled/current_position/animate helpers, and per-frame rendering across all cursor keys.
Cursor overlay API and pressed state
libs/cua-driver/rust/crates/cursor-overlay/src/lib.rs, libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs
Add OverlayCommand::SnapTo and SetPressed; add pressed: bool to RenderStateCore, handle the commands, and render pressed visuals (larger bloom, center fill, outer ring).
ToolState mouse-hold store and helpers
libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
Add mouse_hold: Mutex<HashMap<String, MouseHoldState>> and MouseHoldState struct; add shared helpers for parsing mouse buttons, cursor-key resolution, held-state JSON, and overlay glide/snap helpers.
Tool updates, multi-cursor support, and held-mouse tools
libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
Migrate many tools to accept session/cursor_id and use cursor-keyed overlay commands; add MouseButtonDownTool, MouseDragTool, and MouseButtonUpTool; update Drag/Click tools to use shared parsing and pressed state; register new tools and add session-end cleanup in build_registry.
Platform build deps
libs/cua-driver/rust/crates/platform-linux/Cargo.toml, nix/cua-driver/package.nix
Add x11 (xlib,xinput,xtest) and evdev crates; bump cargoHash and add pkg-config plus X11 libs (libx11, libxi, libxtst, libxext) to Nix Linux build inputs.

🎯 4 (Complex) | ⏱️ ~60 minutes

  • trycua/cua#1746: Overlapping Nix/Cargo packaging changes; related to CUA Nix dependency updates for Linux/X11.

"A rabbit hops through windows deep,
Resolves each target where small children sleep,
Clicks held close, drags drawn slow and true,
Multi-cursors dance — a gentle hop anew." 🐰🖱️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly and clearly summarizes the main changes: adding new Linux tools for background mouse drag and held-button interactions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch r33d/linux-background-x11-drag-tools

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Linux visual regression artifacts

Matrix jobs now run independently. Download visual artifacts from this workflow run.
Each background-GUI job uploads a .gif of the interaction plus two annotated PNGs (<app>.png raw, <app>-atspi.png with AT-SPI element boxes); the cua-driver-linux-som-overlays artifact adds <app>-som.png cua Set-of-Marks overlays:

  • cua-driver-linux-cursor-click-gif
  • cua-driver-linux-background-terminal-gif
  • cua-driver-linux-parallel-drag-xserver
  • cua-driver-linux-background-gui-chromium
  • cua-driver-linux-background-gui-tk
  • cua-driver-linux-background-gui-gtk3-gedit
  • cua-driver-linux-background-gui-gtk3-mousepad
  • cua-driver-linux-background-gui-gtk3-scite
  • cua-driver-linux-background-gui-gtk4-characters
  • cua-driver-linux-background-gui-qt5-manuskript
  • cua-driver-linux-background-gui-qt5-klog
  • cua-driver-linux-background-gui-qt5-openambit
  • cua-driver-linux-background-gui-qt6-kate
  • cua-driver-linux-background-gui-qt6-kcalc
  • cua-driver-linux-background-gui-qt6-okular
  • cua-driver-linux-background-gui-qt6-qownnotes
  • cua-driver-linux-background-gui-electron-zettlr
  • cua-driver-linux-background-gui-electron-joplin
  • cua-driver-linux-background-gui-electron-logseq
  • cua-driver-linux-som-overlays

Open workflow run and download artifacts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs (1)

194-249: ⚠️ Potential issue | 🟠 Major

Keep the same press child window for the entire drag/hold gesture.

send_drag() resolves press_target for ButtonPress, but later MotionNotifyEvent and ButtonReleaseEvent re-resolve event from the current coordinates (resolve_event_target(... ix, iy) / resolve_event_target(... to_x, to_y)), which can split one gesture across sibling child windows. On real X11 input, a ButtonPress establishes an implicit pointer grab so subsequent MotionNotify/ButtonRelease are delivered to the window that received the press until all buttons are released. Persist the resolved press child window (and translate subsequent coordinates into that window’s coordinate space) and reuse it for all motion/release posts; ensure the held-button tool path stores/reuses the same resolved child target as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs` around lines 194
- 249, send_drag() currently re-resolves the target on each motion/release which
can deliver parts of a single press-drag-release to different child windows;
instead capture and persist the initial press_target from
resolve_event_target(...) and reuse its window/child and coordinate space for
all MotionNotifyEvent and ButtonReleaseEvent postings (use press_target.window
as event, press_target.child, and compute event_x/event_y by translating root
coordinates into press_target.local_x/local_y space using
press_target.root_x/root_y), do not call resolve_event_target for
ix/iy/to_x/to_y after the initial press, and ensure any held-button tool path
state stores and reuses this same press_target rather than re-resolving.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs`:
- Around line 46-56: deepest_child_at_point currently descends into every child
returned by conn.query_tree() based only on geometry, but query_tree can include
unmapped/unviewable windows; before recursing into a child (where
conn.get_geometry and the point_in_rect check occur) fetch the child's window
attributes via conn.get_window_attributes(*child)?.reply()? and check
attrs.map_state == MapState::VIEWABLE (use the MapState enum already in scope)
and skip the child if not viewable so hit testing only targets windows a real
pointer can reach.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs`:
- Around line 1581-1582: The reads for x/y in mouse_button_down and mouse_drag
currently use args.f64_or("x", 0.0) and args.f64_or("y", 0.0) which silently
default to (0,0); change them to require and validate coordinates the same way
DragTool does: use the mandatory extractor (e.g., args.f64("x") / args.f64("y")
or equivalent) and propagate a descriptive error when missing or wrong-typed,
matching the coordinate validation logic in DragTool/MouseDragTool so the call
fails fast instead of injecting actions at the origin.
- Around line 1568-1605: The code races on the mouse_hold lifecycle because
callers snapshot and release the mutex, await overlay/X11 work, then re-acquire
and write state; wrap the entire down/drag/up transition in a single async-aware
lock to serialize transitions: introduce an async mutex or reservation (e.g., an
async_mutex field on the same state struct) and acquire it at the start of the
mouse-button handlers that use mouse_hold (the block around the current checks
that read/clone/write mouse_hold and the subsequent awaits and send_button_down
call), hold that lock until after the stored state is set (i.e., until after
MouseHoldState is written back), and apply the same pattern to the other
handlers mentioned (the code around lines 1648-1703 and 1747-1787) so only one
lifecycle transition can be in flight at a time.
- Around line 1761-1772: The release path is re-transforming stored
hold.x/hold.y because you use unwrap_or(hold.x) and then always apply
zoom/resize; change the logic to detect whether the request actually supplied
coordinates (use args.has("x") or args.opt_f64 returning Some) and only run the
zoom_registry.get(...).zoom_to_window(...) or resize_registry.ratio(...)
conversion when coordinates were provided in this request; if neither "x" nor
"y" were supplied, leave x/y as the already-window-space hold.x/hold.y and skip
the zoom/resize branches (adjust the code around args.opt_f64("x")/opt_f64("y"),
the from_zoom check, and resize_registry.ratio(hold.pid) accordingly).

---

Outside diff comments:
In `@libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs`:
- Around line 194-249: send_drag() currently re-resolves the target on each
motion/release which can deliver parts of a single press-drag-release to
different child windows; instead capture and persist the initial press_target
from resolve_event_target(...) and reuse its window/child and coordinate space
for all MotionNotifyEvent and ButtonReleaseEvent postings (use
press_target.window as event, press_target.child, and compute event_x/event_y by
translating root coordinates into press_target.local_x/local_y space using
press_target.root_x/root_y), do not call resolve_event_target for
ix/iy/to_x/to_y after the initial press, and ensure any held-button tool path
state stores and reuses this same press_target rather than re-resolving.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5fce2f3b-3486-43cc-9609-d9249340d14c

📥 Commits

Reviewing files that changed from the base of the PR and between 2925b49 and 9129522.

📒 Files selected for processing (2)
  • libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs
  • libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs

Comment on lines +46 to +56
let tree = conn.query_tree(window)?.reply()?;
for child in tree.children.iter().rev() {
let Ok(geom) = conn.get_geometry(*child)?.reply() else {
continue;
};
if !point_in_rect(local_x, local_y, &geom) {
continue;
}
let child_x = local_x - geom.x as i32;
let child_y = local_y - geom.y as i32;
return deepest_child_at_point(conn, *child, child_x, child_y);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For X11 hit-testing: does QueryTree include unmapped children, and is checking GetWindowAttributes.map_state == MapState::VIEWABLE the correct way to exclude hidden child windows from synthetic pointer targeting?

💡 Result:

Yes, XQueryTree includes unmapped children [1]. The function returns all child windows of the specified window regardless of their mapping state [1]. Checking XGetWindowAttributes.map_state == IsViewable is the correct and necessary way to identify whether a window is currently viewable on screen [1][2][3]. When performing synthetic pointer targeting (hit-testing), simply checking if a window is present in the tree is insufficient because: 1. XQueryTree returns both mapped and unmapped windows [1]. 2. A window may be "mapped" but still not visible to the user (IsUnviewable) if one of its ancestors is unmapped [2][4][3]. The IsViewable state specifically confirms that a window is mapped and that all of its ancestors are also mapped, meaning it is a candidate for receiving user input [4][3]. Note that you should also consider checking the override_redirect attribute if you intend to exclude specific types of windows (like pop-ups or menus) that might otherwise interfere with your targeting logic [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and show the relevant section around the reported lines
FILE="libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "=== File: $FILE (lines 1-120) ==="
nl -ba "$FILE" | sed -n '1,140p'

# Search for deepest_child_at_point definition and for query_tree usage
echo "=== Search: deepest_child_at_point ==="
rg -n "deepest_child_at_point" "$FILE" -S || true

echo "=== Search: query_tree / get_geometry / get_window_attributes ==="
rg -n "(query_tree|get_geometry|get_window_attributes|MapState)" "$FILE" -S || true

Repository: trycua/cua

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and show the relevant section around the reported lines
FILE="libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "=== File: $FILE (lines 1-200) ==="
nl -ba "$FILE" | sed -n '1,220p'

# Search for deepest_child_at_point definition and for query_tree usage
echo "=== Search: deepest_child_at_point ==="
rg -n "deepest_child_at_point" "$FILE" -S || true

echo "=== Search: query_tree / get_geometry / get_window_attributes / MapState ==="
rg -n "(query_tree|get_geometry|get_window_attributes|MapState)" "$FILE" -S || true

Repository: trycua/cua

Length of output: 204


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "=== File: $FILE (lines 1-220) ==="
cat -n "$FILE" | sed -n '1,220p'

echo "=== Search: deepest_child_at_point ==="
rg -n "deepest_child_at_point" "$FILE" -S || true

echo "=== Search: query_tree / get_geometry / get_window_attributes / MapState ==="
rg -n "(query_tree|get_geometry|get_window_attributes|MapState)" "$FILE" -S || true

echo "=== Search: MapState usage in entire platform-linux input module ==="
rg -n "MapState" "libs/cua-driver/rust/crates/platform-linux/src/input" -S || true

Repository: trycua/cua

Length of output: 9685


🌐 Web query:

x11rb GetWindowAttributesReply map_state MapState::VIEWABLE

💡 Result:

In the x11rb library, GetWindowAttributesReply is a structure returned by the GetWindowAttributes request, which provides the current attributes of an X11 window [1][2][3]. The map_state field within this structure is an enumeration of type MapState that indicates whether the window is currently mapped and viewable [1][4]. The MapState enumeration includes the variant MapState::VIEWABLE, which signifies that the window is viewable—meaning it is mapped and all of its ancestors are also mapped [4]. In practice, when working with x11rb, you typically access this field after sending a GetWindowAttributes request and receiving the reply [5]: let attr = conn.get_window_attributes(window)?.reply?; if attr.map_state == MapState::VIEWABLE { // The window is currently viewable } The other variants for MapState include MapState::UNMAPPED (the window is not mapped) and MapState::UNVIEWABLE (the window is mapped, but at least one of its ancestors is not mapped) [4][6].

Citations:


Filter non-viewable child windows during hit testing

deepest_child_at_point() descends into every query_tree() child that matches the geometry, but query_tree() includes unmapped/unviewable windows. Without checking GetWindowAttributes.map_state, hit testing can target a window that a real pointer can’t reach. Skip children unless attrs.map_state == MapState::VIEWABLE before descending.

Suggested fix
     let tree = conn.query_tree(window)?.reply()?;
     for child in tree.children.iter().rev() {
         let Ok(geom) = conn.get_geometry(*child)?.reply() else {
             continue;
         };
+        let Ok(attrs) = conn.get_window_attributes(*child)?.reply() else {
+            continue;
+        };
+        if attrs.map_state != MapState::VIEWABLE {
+            continue;
+        }
         if !point_in_rect(local_x, local_y, &geom) {
             continue;
         }
         let child_x = local_x - geom.x as i32;
         let child_y = local_y - geom.y as i32;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let tree = conn.query_tree(window)?.reply()?;
for child in tree.children.iter().rev() {
let Ok(geom) = conn.get_geometry(*child)?.reply() else {
continue;
};
if !point_in_rect(local_x, local_y, &geom) {
continue;
}
let child_x = local_x - geom.x as i32;
let child_y = local_y - geom.y as i32;
return deepest_child_at_point(conn, *child, child_x, child_y);
let tree = conn.query_tree(window)?.reply()?;
for child in tree.children.iter().rev() {
let Ok(geom) = conn.get_geometry(*child)?.reply() else {
continue;
};
let Ok(attrs) = conn.get_window_attributes(*child)?.reply() else {
continue;
};
if attrs.map_state != MapState::VIEWABLE {
continue;
}
if !point_in_rect(local_x, local_y, &geom) {
continue;
}
let child_x = local_x - geom.x as i32;
let child_y = local_y - geom.y as i32;
return deepest_child_at_point(conn, *child, child_x, child_y);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs` around lines 46
- 56, deepest_child_at_point currently descends into every child returned by
conn.query_tree() based only on geometry, but query_tree can include
unmapped/unviewable windows; before recursing into a child (where
conn.get_geometry and the point_in_rect check occur) fetch the child's window
attributes via conn.get_window_attributes(*child)?.reply()? and check
attrs.map_state == MapState::VIEWABLE (use the MapState enum already in scope)
and skip the child if not viewable so hit testing only targets windows a real
pointer can reach.

Comment on lines +1568 to +1605
if self.state.mouse_hold.lock().unwrap().is_some() {
let held = self.state.mouse_hold.lock().unwrap().clone();
return ToolResult::error("A mouse button is already held. Call mouse_button_up first.")
.with_structured(mouse_hold_json(held.as_ref()));
}

let pid = match args.require_u32("pid") { Ok(v) => v, Err(e) => return e };
let xid = match args.opt_u64("window_id") {
Some(v) => v,
None => return ToolResult::error("window_id is required on Linux."),
};
let button_name = args.str_or("button", "left");
let button = parse_mouse_button(button_name.as_str());
let mut x = args.f64_or("x", 0.0);
let mut y = args.f64_or("y", 0.0);
if args.bool_or("from_zoom", false) {
match self.state.zoom_registry.get(pid) {
Some(ctx) => { let (wx, wy) = ctx.zoom_to_window(x, y); x = wx; y = wy; }
None => return ToolResult::error(format!("from_zoom=true but no zoom context for pid {pid}. Call zoom first.")),
}
} else if let Some(ratio) = self.state.resize_registry.ratio(pid) {
x *= ratio;
y *= ratio;
}

crate::overlay::send_command(cursor_overlay::OverlayCommand::PinAbove(xid));
if let Ok(Ok((sx, sy))) = tokio::task::spawn_blocking(move || window_local_to_screen(xid, x, y)).await {
overlay_glide_to(sx, sy).await;
crate::overlay::send_command(cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy });
}

let xi = x as i32;
let yi = y as i32;
let result = tokio::task::spawn_blocking(move || crate::input::send_button_down(xid, xi, yi, button)).await;
match result {
Ok(Ok(())) => {
let hold = MouseHoldState { pid, xid, button, x, y };
*self.state.mouse_hold.lock().unwrap() = Some(hold.clone());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Serialize held-mouse lifecycle transitions.

These three tools all snapshot mouse_hold, drop the mutex, await overlay/X11 work, and only then write the new state back. Two overlapping requests can therefore both observe None, send duplicate ButtonPress events, or race a drag against a release and leave the stored hold out of sync with the actual injected button state. Please guard the whole down/drag/up lifecycle with a single async mutex or equivalent reservation so only one transition can be in flight at a time.

Also applies to: 1648-1703, 1747-1787

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs` around lines
1568 - 1605, The code races on the mouse_hold lifecycle because callers snapshot
and release the mutex, await overlay/X11 work, then re-acquire and write state;
wrap the entire down/drag/up transition in a single async-aware lock to
serialize transitions: introduce an async mutex or reservation (e.g., an
async_mutex field on the same state struct) and acquire it at the start of the
mouse-button handlers that use mouse_hold (the block around the current checks
that read/clone/write mouse_hold and the subsequent awaits and send_button_down
call), hold that lock until after the stored state is set (i.e., until after
MouseHoldState is written back), and apply the same pattern to the other
handlers mentioned (the code around lines 1648-1703 and 1747-1787) so only one
lifecycle transition can be in flight at a time.

Comment thread libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
Comment on lines +1761 to +1772
let mut x = args.opt_f64("x").unwrap_or(hold.x);
let mut y = args.opt_f64("y").unwrap_or(hold.y);
if args.bool_or("from_zoom", false) {
match self.state.zoom_registry.get(hold.pid) {
Some(ctx) => { let (wx, wy) = ctx.zoom_to_window(x, y); x = wx; y = wy; }
None => return ToolResult::error(format!("from_zoom=true but no zoom context for pid {}. Call zoom first.", hold.pid))
.with_structured(mouse_hold_json(Some(&hold))),
}
} else if let Some(ratio) = self.state.resize_registry.ratio(hold.pid) {
x *= ratio;
y *= ratio;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't re-translate stored hold coordinates on mouse_button_up.

When x/y are omitted, this path starts from hold.x/hold.y and then applies from_zoom/resize conversion again. Those fields were already stored in window coordinates by mouse_button_down/mouse_drag, so releases can land on the wrong child widget in resized or zoomed flows. Only transform coordinates that were actually supplied in this request.

Suggested fix
-        let mut x = args.opt_f64("x").unwrap_or(hold.x);
-        let mut y = args.opt_f64("y").unwrap_or(hold.y);
+        let raw_x = args.opt_f64("x");
+        let raw_y = args.opt_f64("y");
+        let mut x = raw_x.unwrap_or(hold.x);
+        let mut y = raw_y.unwrap_or(hold.y);
         if args.bool_or("from_zoom", false) {
             match self.state.zoom_registry.get(hold.pid) {
-                Some(ctx) => { let (wx, wy) = ctx.zoom_to_window(x, y); x = wx; y = wy; }
+                Some(ctx) => {
+                    if let Some(px) = raw_x {
+                        x = ctx.origin_x + px * ctx.scale_inv;
+                    }
+                    if let Some(py) = raw_y {
+                        y = ctx.origin_y + py * ctx.scale_inv;
+                    }
+                }
                 None => return ToolResult::error(format!("from_zoom=true but no zoom context for pid {}. Call zoom first.", hold.pid))
                     .with_structured(mouse_hold_json(Some(&hold))),
             }
         } else if let Some(ratio) = self.state.resize_registry.ratio(hold.pid) {
-            x *= ratio;
-            y *= ratio;
+            if let Some(px) = raw_x {
+                x = px * ratio;
+            }
+            if let Some(py) = raw_y {
+                y = py * ratio;
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut x = args.opt_f64("x").unwrap_or(hold.x);
let mut y = args.opt_f64("y").unwrap_or(hold.y);
if args.bool_or("from_zoom", false) {
match self.state.zoom_registry.get(hold.pid) {
Some(ctx) => { let (wx, wy) = ctx.zoom_to_window(x, y); x = wx; y = wy; }
None => return ToolResult::error(format!("from_zoom=true but no zoom context for pid {}. Call zoom first.", hold.pid))
.with_structured(mouse_hold_json(Some(&hold))),
}
} else if let Some(ratio) = self.state.resize_registry.ratio(hold.pid) {
x *= ratio;
y *= ratio;
}
let raw_x = args.opt_f64("x");
let raw_y = args.opt_f64("y");
let mut x = raw_x.unwrap_or(hold.x);
let mut y = raw_y.unwrap_or(hold.y);
if args.bool_or("from_zoom", false) {
match self.state.zoom_registry.get(hold.pid) {
Some(ctx) => {
if let Some(px) = raw_x {
x = ctx.origin_x + px * ctx.scale_inv;
}
if let Some(py) = raw_y {
y = ctx.origin_y + py * ctx.scale_inv;
}
}
None => return ToolResult::error(format!("from_zoom=true but no zoom context for pid {}. Call zoom first.", hold.pid))
.with_structured(mouse_hold_json(Some(&hold))),
}
} else if let Some(ratio) = self.state.resize_registry.ratio(hold.pid) {
if let Some(px) = raw_x {
x = px * ratio;
}
if let Some(py) = raw_y {
y = py * ratio;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs` around lines
1761 - 1772, The release path is re-transforming stored hold.x/hold.y because
you use unwrap_or(hold.x) and then always apply zoom/resize; change the logic to
detect whether the request actually supplied coordinates (use args.has("x") or
args.opt_f64 returning Some) and only run the
zoom_registry.get(...).zoom_to_window(...) or resize_registry.ratio(...)
conversion when coordinates were provided in this request; if neither "x" nor
"y" were supplied, leave x/y as the already-window-space hold.x/hold.y and skip
the zoom/resize branches (adjust the code around args.opt_f64("x")/opt_f64("y"),
the from_zoom check, and resize_registry.ratio(hold.pid) accordingly).

* Start Linux multi-cursor plumbing

* Key Linux overlay state by cursor

* Fix Linux keyed overlay build

* Restore Linux click tool arg helpers

* Restore Linux double-click arg helpers

* Restore Linux right-click arg helpers

* Import Linux tool arg helpers once

* Tighten Linux multi-cursor tool validation

* Show Linux cursor state during drags

* Animate Linux drag overlay in motion engine

* Add Linux MPX parallel drag tool

* Fix x11 feature flags for MPX tool

* Fix MPX tool FFI usage

* Use XTEST slave devices for Linux MPX drags

* Initialize Xlib threading for Linux MPX drags

* Fail MPX drags on Xtigervnc and clean up masters

* Detect Xtigervnc by process for MPX guard

* Fix MPX drag delivery: overlay click-through, warp sync, flat accel

Three fixes that turn the Linux parallel_mouse_drag uinput/MPX pipeline
from "raw events only" into real app-visible same-window drags:

- The cursor overlay's click-through was a no-op: ShapeMask with a None
  pixmap *resets* the input shape to the full window, so the fullscreen
  overlay swallowed every pointer event before it reached any app. Set
  an empty input region via ShapeRectangles with no rects instead.

- XIWarpPointer was only XFlush'd, so the uinput button press (separate
  kernel pipeline) raced ahead of the queued warp and landed at the
  master's spawn position (screen center), starting an implicit grab on
  the desktop. XSync the warp so the press lands at the warped spot.

- Relative uinput deltas went through libinput's adaptive accel profile
  and drag endpoints drifted a few pixels off-target. Pin the slave's
  accel profile to flat after attaching it to its master.

Also moves the drag path off the fake single-pointer/XTEST approach to
per-session MPX master pointers fed by per-session uinput slaves with
concurrent step scheduling (carried over from earlier session work).

Verified in the Xorg+x11vnc desktop-workspace guest: an XI2 test app
receives interleaved cooked XI_ButtonPress/XI_Motion/XI_ButtonRelease
for both masters with pixel-exact endpoints across repeated calls and
fresh MCP sessions, and paints all concurrent strokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Track parallel drags with the agent cursor overlay

The drag loop now snaps each session's overlay cursor to the
interpolated step position (heading along the motion vector), so the
gesture is visible on screen instead of the cursor teleporting from
start to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restore the active window after parallel drags

Click-to-focus WMs grab buttons for XIAllMasterDevices, so an MPX
drag's press activates the dragged window exactly like a user click —
parallel agent drags were stealing focus from whatever the user had
active.

Snapshot the focus state before the drags and hand it back afterwards,
WM-agnostically:

- EWMH path: re-activate the previous _NET_ACTIVE_WINDOW via client
  message (source=pager) so the WM's own bookkeeping stays consistent.
  The WM finishes its click handling on the release replay, which can
  land after a one-shot request, so settle briefly then verify/retry.
- Fallback for bare X / non-EWMH WMs: save and restore the core input
  focus directly, under a scoped ignore-errors handler since the saved
  window may be gone by then.

Also drops the XISetClientPointer/XISetFocus calls before drags — they
were debugging leftovers, not needed for cooked event delivery, and
pointing master keyboard focus at the target fights the restore.

Verified under openbox: delivery unchanged, active window returns to
the previously-focused terminal after every call. xfwm4 4.18 has a
broken MPX path — one foreign-master press and it permanently ignores
_NET_ACTIVE_WINDOW requests (ours and xdotool's alike, until restarted)
— so the desktop-workspace image should ship a different WM; the
driver logs a warning when the WM refuses the re-activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stamp focus-restore activation with real server time

_NET_ACTIVE_WINDOW requests carrying CurrentTime(0) lose to the WM's
focus-stealing prevention whenever newer input exists, so fetch the
server time via the standard PropertyNotify round-trip and stamp the
re-activation request with it. With concurrent live user input the WM
may still legitimately refuse (that IS focus-stealing prevention); the
driver retries briefly and logs a warning if the WM declines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clean up drag masters per call and resync wedged WM focus state

Two changes that keep non-MPX-aware WMs healthy across parallel drags:

- Remove the per-session master pairs as soon as the gesture completes
  instead of leaving them attached until session end. One-shot MCP
  sessions never end, so masters used to accumulate without bound, and
  lingering foreign master keyboards multiply the core focus-event
  noise WMs have to digest.

- The focus restore now requires the active window to hold stable for
  consecutive checks, and escalates to a core-focus bounce when plain
  re-activation doesn't take. Verified mechanism: after an MPX click, a
  core-protocol WM (xfwm4, openbox) can record the dragged window as
  active while the core focus never actually moved there; its
  XSetInputFocus for our re-activation request is then a no-op, no
  FocusIn ever arrives, and its bookkeeping stays wedged — even real
  user clicks stop updating the titlebar. Setting the core focus onto
  the window the WM believes active and then re-activating produces the
  focus transition its state machine is waiting for.

With this, two consecutive parallel_mouse_drag calls against an
unfocused window under openbox leave the user's terminal focused
(titlebar, _NET_ACTIVE_WINDOW, and core focus all agree), with no
leftover master devices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Shield parallel drags from the WM with a device-specific grab + replay

Prevents the focus steal at the source instead of restoring focus after
the fact: the window manager now never sees the drag's button press.

Before each press, install a device-specific XI2 synchronous passive
button grab (XIGrabButton) for the drag's master pointer on the target
window. Per the X server's grab semantics (dix/events.c, dix/grabs.c):
this grab is newer than the WM's click-to-focus grab on the same window
so it's matched first, and being device-specific it never BadAccess-
conflicts with the WM's core/all-master grabs. The press freezes the
device and is delivered to us; XIAllowEvents(XIReplayDevice) then replays
it, which re-checks passive grabs only *below* the grab window and
delivers the event normally to the app — so the app gets the full drag
(press, implicit-grab motion, release) while the WM is blind to it.

Shields are removed after release. Failures degrade gracefully: if a
shield can't install or a frozen press isn't seen before a 1s timeout,
the drag still runs and the existing focus save/restore catches any
leak.

Verified on xfwm4 (the wedge-prone WM): _NET_ACTIVE_WINDOW polled at
50ms across two consecutive two-cursor drag calls never left the
focused terminal — no mid-drag flicker — the app received all presses
and releases at pixel-exact coordinates, masters were cleaned up, and
the WM stayed healthy (activation still toggles both ways afterwards).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Replay shield presses per-device to fix dropped concurrent presses

The first shield cut focus-stealing but dropped ~1 in 8 presses under
concurrent drags: with both masters' presses frozen on their grabs and
replayed back to back, the X server delivered only one. Diagnostics
confirmed both XIReplayDevice calls returned success yet only one press
reached the app — a server-side race when multiple devices are frozen on
the same window and replayed together (XSync between replays narrowed it
but didn't close it).

Fix: replay each frozen press immediately after emitting it, before the
next press, so only one device is ever frozen at a time. The few-ms
stagger this adds to the presses is invisible — the concurrency that
matters is the motion phase, which is unchanged.

Verified on xfwm4: 40/40 presses and releases delivered across 20
consecutive two-cursor calls, focus never left the terminal, WM stayed
healthy, no leftover masters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(nix): update cua-driver cargoHash for new dependency set

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(nix): add pkg-config and X11 libs for the x11 crate build

The MPX multi-cursor plumbing pulls in the raw-Xlib x11 crate, whose
build.rs locates libX11/libXi/libXtst via pkg-config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
if vendor.to_ascii_lowercase().contains("tigervnc") {
bail!(
"parallel_mouse_drag is not supported on this X server ('{vendor}'). \
Xtigervnc exposes only its built-in VNC/XTEST devices, so Linux uinput/libinput \

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot maybe just try checking for this directly? instead of looking for magic strings

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Instead of scanning all /proc entries and matching "Xtigervnc" as a substring in cmdline, the function now reads the X server's PID directly from /tmp/.X{N}-lock (standard X11 lock file), then checks the process executable via /proc/{PID}/exe (with /proc/{PID}/comm as fallback). Committed in the latest push.

codex and others added 3 commits June 10, 2026 23:07
A scenario that drives cua-driver through its Linux multi-cursor
parallel_mouse_drag path end to end, in the style of the other
linux-*-gif NixOS tests, and records a GIF artifact.

The scenario (mcp-parallel-drag-test.py) pilots the driver over MCP
through two parallel_mouse_drag calls — two per-session master pointers
drawing concurrent crossing strokes into one window that does NOT hold
focus — and the test asserts the three guarantees the feature rests on:

- concurrent cooked delivery: an XI2 paint app (compiled in-tree) logs
  every window-targeted XI2 event with its device id; we assert four
  presses from two distinct master devices plus motion;
- no focus steal: the shield grab keeps the WM blind to the presses, so
  the separate control window stays active throughout;
- a GIF of the two crossing strokes is produced and copied out.

Unlike the other Linux visual tests this one needs a REAL Xorg (dummy
video driver + libinput) rather than Xvfb, because the MPX path attaches
uinput slave devices to per-session masters and only a real Xorg with
libinput enumerates uinput devices as X input devices — the same reason
the old Xvfb/Xtigervnc setups couldn't host it. The test loads the
uinput kernel module and launches Xorg with an explicit module path.

Wired into flake checks and the nix-build CI matrix (+ the visual
artifact comment list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mandatory-shield change uses .with_context() on the shield-grab
install, but anyhow::Context wasn't in scope, so the crate didn't
compile. Add it to the use list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Background "X X" multi-cursor drag — mandatory shield, verified live

Two MPX master pointers drawing two X shapes concurrently into the same unfocused window, with the control terminal holding focus the whole time. Built from this branch (0e4f49ca mandatory-shield + 069ff56e the anyhow::Context import it needed to compile) and run on the dev Xorg/xfwm4 instance:

two agent cursors drawing two X shapes with no focus steal

Results:

  • 4 presses + 4 releases delivered as cooked, window-targeted XI2 events from two distinct master devices (genuine concurrent multi-cursor input).
  • _NET_ACTIVE_WINDOW polled at 50 ms across both drag calls — 240/240 samples on the control terminal, 0 on any other window: no focus steal, no mid-drag flicker (the shield grab kept xfwm4 blind to the presses).
  • Both X shapes painted pixel-exact (blue = agent-1, green = agent-2).

The mandatory-shield change behaved as intended: the shield installed/replayed successfully so the drag ran; had it failed, the drag would now abort with an error rather than silently fall back to focus-restore.

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Bonus: multi-cursor writes "Cua", then the C becomes an illuminated drop cap

For fun — the two MPX agent cursors collaboratively hand-write Cua into a background window (focus stays on the terminal, as always), then it cross-fades into a stylish drop-cap wordmark: a gold-on-navy illuminated initial C with "ua" set in Palatino, finished with a gold rule.

two cursors write Cua, then it transforms into an illuminated drop-cap C

The writing half is real parallel_mouse_drag output (two per-session master pointers, round-nib strokes); the drop-cap finale is rendered with ImageMagick (URW P052 / Palatino) and stitched on with a crossfade.

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Background multi-cursor writing on two windows at once — focus never moves

The clearest demo of the background effect yet: two paint windows side by side, both having "Cua" hand-written concurrently by the agent cursors, while a small "hello world!" terminal sits focused on top in the center. The terminal keeps focus the entire time — the drawing happens on the windows around/behind it without ever stealing it (shield grab).

Serif — 12 cursors (6 per window) tracing serif letterforms, then fading to the Cua logo:
two background windows writing serif Cua with a focused hello-world terminal on top

Cursive — 8 cursors (4 per window) tracing a connected script:
two background windows writing cursive Cua with a focused hello-world terminal on top

Both letterforms are generated parametrically (arc/Bézier sampling → dense short parallel_mouse_drag segments, since each drag is a straight chord), one parallel_mouse_drag call per step driving every cursor on both windows together. The fade-to-logo is composited with ImageMagick. Throughout: xdotool getactivewindow stays on hello — verified background, no focus steal.

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
#1871)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Same cursive demo, but recorded through cua-driver's own native screen capture

Re-did the two-window cursive demo capturing each frame via the driver's native get_window_state (capture_mode vision) on the root window — i.e. cua-driver screenshotting its own desktop over MCP — instead of an external screenshot tool. Frames come back as base64 PNG in the tool result; here driven at full resolution (set_config max_image_dimension: 0):

cursive Cua written on two background windows, captured via cua-driver native screenshot, focused hello-world terminal on top

Per step the writer issues one parallel_mouse_drag (8 cursors across both windows) then one get_window_state to grab the frame — so every frame is a genuine driver capture of the live composite (both windows + the focused hello world! terminal + the agent cursors). Focus stays on hello throughout; fade-to-logo composited after.

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
…ing mp4 (PR #1871)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Cursive demo recorded with cua-driver's native video capture (start_recording → mp4)

Per the video-capture feature discussion: this one was recorded by the driver itself. A single MCP session calls start_recording with record_video: true, draws the cursive "Cua" on both background windows (8 cursors), then stop_recording — and the driver's Linux video backend captures the whole display to an H.264 mp4 via its ffmpeg x11grab pipeline (recording.mp4, 1920×1080 @ 30 fps). The clip below is that mp4, cropped to the two-window region, sped up 2× and cross-faded to the logo:

cursive Cua on two background windows, recorded via cua-driver start_recording, focused hello-world terminal on top

Source mp4 (the post-processed clip): https://raw.githubusercontent.com/trycua/cua/r33d/assets-linux-multi-cursor/cua-cursive-2win-video.mp4

Notes:

  • start_recording/stop_recording must be issued in the same MCP session — supergateway spawns a fresh cua-driver mcp child per session, and the video backend lives in the process that started it, so a stop from a different session is a no-op and the mp4 never gets its moov atom finalized.
  • Linux/Windows video uses an ffmpeg subprocess (x11grab/gdigrab); macOS uses native ScreenCaptureKit (no ffmpeg). I had to apt-get install ffmpeg in the workspace guest first — it ships only ImageMagick.

🤖 Generated with Claude Code

start_recording's video capture shells out to the ffmpeg binary on
Linux/Windows (macOS records natively). When ffmpeg is absent, video
silently fails. Add an install_ffmpeg MCP tool that resolves it:

- Detects the platform package manager (apt-get/dnf/yum/zypper/pacman/
  apk/snap on Linux, brew on macOS, winget/choco on Windows) and, on
  Linux, prefixes sudo -n when not root.
- Two-step / confirmed: without `confirm` it only REPORTS the exact
  command it would run (read-only preview); `confirm: true` runs it.
  Marked destructive + open_world so conforming MCP clients also gate it
  behind a human approval. No-op if ffmpeg is already on PATH.
- start_recording's video-failed message now points at install_ffmpeg
  when find_ffmpeg() is None.

ffmpeg is still only ever invoked as a separate process, never linked —
this just installs the same user-provided binary find_ffmpeg looks for.

Verified end to end on Linux: with ffmpeg removed, start_recording
reports the hint; install_ffmpeg (no confirm) previews
`apt-get install -y ffmpeg`; confirm:true installs it; video capture
then works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r33drichards pushed a commit that referenced this pull request Jun 11, 2026
…ing (PR #1871)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Both demos, recorded natively with the install_ffmpegstart_recording flow

End-to-end through the new tooling: install_ffmpeg (confirm-gated) put ffmpeg on the box, then a single MCP session per demo ran start_recording(record_video:true) → draw on both background windows → stop_recording, so the driver's own ffmpeg x11grab backend captured each to an H.264 mp4 (1920×1080 @ 30fps). Both cropped to the two-window region, sped up, and cross-faded to the logo. Focus stays on the centered hello world! terminal throughout.

Serif (6 strokes/window, 12 cursors):
serif Cua on two background windows, recorded via cua-driver start_recording

Cursive (connected script, 8 cursors):
cursive Cua on two background windows, recorded via cua-driver start_recording

Source mp4s: serif · cursive

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

r33drichards commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Full-screen capture, as WebM (VP9, 30 fps) — continuous motion

Re-encoded the native start_recording mp4s as WebM (VP9, 30 fps, full screen — whole desktop, no crop). Smooth playback instead of the GIF's choppy frame rate, and I dropped the idle/frozen frames (ffmpeg mpdecimate) so the cursor motion is continuous rather than stuttering through the inter-stroke pauses — which also makes it shorter (cursive ~8s, serif ~13s):

~0.5 MB each.

Why the earlier GIFs looked smoother/faster: they were sped up (2–3×) and built from one frame per drawing step, so they never showed the real-time gaps between parallel_mouse_drag calls (MCP round-trip + per-call shield work, ×12 cursors). The WebM is a true real-time recording; mpdecimate collapses those frozen gaps back into continuous motion.

Heads-up on GitHub rendering: a .webm referenced by a raw URL shows as a link (click to play), not an inline player — GitHub only embeds an inline <video> for files uploaded as a comment attachment (drag-and-drop), which I can't do over the API. Click the links, or drag either webm into a comment to embed it.

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
…ous motion (PR #1871)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Cursive demo — full-screen WebM (VP9, 30 fps), 1× and 2×

Continuous-motion (idle frames dropped via mpdecimate), whole desktop, focused hello world! terminal on top:

~0.3–0.5 MB each. (Raw .webm links open/play in-browser; drag one into a PR comment to embed it as an inline <video>.)

🤖 Generated with Claude Code

…el_mouse_drag

parallel_mouse_drag pressed and released on every call, so drawing a curve
as many short calls looked like a chain of clicks rather than a drag.

Now each drag item carries a full waypoint PATH: the master presses once at
path[0], glides through every point (arc-length interpolated over the whole
duration), and releases once at the end — a single continuous held drag, so
a curved stroke is smooth instead of stamped.

The path is given either as a straight from→to segment (back-compatible) or
as a function `fn` = y(x) sampled over [x_from, x_to] in window-local pixels
(e.g. fn:"x", fn:"300+150*sin(x/40)", fn:"560-0.0011*(x-400)^2"). Expressions
are evaluated with the meval crate (+ - * / ^, sin/cos/tan, sqrt, abs, exp,
ln, pi, e). Each cursor can follow its own function, all concurrently.

- VirtualPointerDrag now holds `path: Vec<(i32,i32)>` (screen coords) instead
  of from/to; the scheduler interpolates position by arc-length fraction.
- The tool builds the path once per item, translating window-local → screen
  via a single origin lookup (translate is a pure offset).
- steps defaults to a path-length-scaled count for a smooth glide.

Verified on Linux: three cursors drew a line, a sine, and a parabola in one
call as smooth continuous strokes, focus held on another window.

NOTE: adds the `meval` dependency — nix package.nix cargoHash needs regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

"cua driver" — written by the cursors using the new held-path drags

Using the function/path drag feature (744cd2f): each pen presses once, glides through its whole stroke, and releases once — so the letters are smooth continuous strokes, not the chain-of-clicks from before. Multiple cursors write concurrently into a background window while the hello terminal keeps focus. Recorded natively (start_recording → VP9 WebM, full screen, continuous):

https://raw.githubusercontent.com/trycua/cua/r33d/assets-linux-multi-cursor/cua-driver-write.webm

Each lowercase letter is a few stroke polylines fed as path: [[x,y],...] to parallel_mouse_drag; the driver glides each as one held drag. (~0.3 MB; raw-link → click to play, or drag into a comment to embed.)

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Just "cua" — smooth multi-cursor held-path drags

Back to the short wordmark: 4 cursors write "cua" concurrently into a background window (focus stays on the hello terminal), each stroke a single continuous held drag via the new path API, recorded natively (start_recording → full-screen VP9 WebM):

https://raw.githubusercontent.com/trycua/cua/r33d/assets-linux-multi-cursor/cua-write.webm

~0.26 MB. (raw link → click to play, or drag into a comment to embed.)

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
…#1871)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Cursive "Cua" — original letterforms, both windows, fixed transition

Addresses the three issues:

  • Parity — back to the original connected cursive script (the nice bezier letterforms), now drawn as a single held path per arc so it's smooth instead of clicky.
  • Both windows — draws on Paint A and Paint B concurrently (4 arcs × 2 windows = 8 cursors), hello terminal focused in the center.
  • Transition timing — the fade was starting during the drawing because mpdecimate drops the static finished frames; now the finished "Cua" is held ~1.5s before the fade to the logo.

Native start_recording → full-screen VP9 WebM, continuous motion:

https://raw.githubusercontent.com/trycua/cua/r33d/assets-linux-multi-cursor/cua-cursive-2win.webm

~0.33 MB. (raw link → click to play, or drag into a comment to embed.)

🤖 Generated with Claude Code

r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r33drichards pushed a commit that referenced this pull request Jun 11, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@r33drichards

Copy link
Copy Markdown
Collaborator Author

Pushing it: cursive "Cua" on 16 windows at once

16 paint windows in a 4×4 grid, each getting the full connected cursive "Cua" written by its own cursor — 16 MPX master pointers drawing concurrently — while the hello world! terminal stays focused in the center. One parallel_mouse_drag call, 16 held-path drags.

https://raw.githubusercontent.com/trycua/cua/r33d/assets-linux-multi-cursor/cua-16windows.webm

Plus the earlier ask — a plain real-time, no-fade "cua": https://raw.githubusercontent.com/trycua/cua/r33d/assets-linux-multi-cursor/cua-cursive-realtime.webm

Notes: 16 cursors = 16 masters + 16 uinput slaves created in the one call (~7s of that is device setup, trimmed from the clip; the draw itself is real-time). One cursor per window here (the whole cursive as a single held stroke); 4-per-window × 16 = 64 masters would exceed X's device limits, so per-window is the sweet spot for going wide.

🤖 Generated with Claude Code

codex and others added 6 commits June 11, 2026 06:16
…argoHash

- Add path_tests: path_cumulative / point_on_path (arc-length glide) and
  sample_function (y=f(x) sampling: linear, affine, trig, invalid-expr,
  non-finite drop). Factor the fn sampling out of the tool into
  input::sample_function so it's unit-testable without X.
- Reset nix cargoHash to "" so CI prints the correct got: hash for the
  new meval/nom/fnv dependency set (will be filled in next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The function-path drag feature added the meval crate (+ nom, fnv) to
Cargo.lock, which changed the vendored dependency set; the nix cua-driver
build (and every nix integration job that depends on it) failed with a
cargoHash mismatch. Set the new fetchCargoVendor hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ulated VM)

The cargoHash fix turned the nix suite green except this one test, which
fails because a hand-launched real Xorg (dummy+libinput) — required so the
MPX uinput slaves enumerate as X input devices — does not start within the
timeout in the emulated GHA nixos-test VM (uinput loads fine; the 'Start a
real Xorg' subtest times out).

The parallel_mouse_drag / held-path / fn feature is covered by unit tests
(platform-linux/src/input/mod.rs). Remove this scenario from the flake
checks + CI matrix + artifact list; keep the .nix file (documented) for
local / real-X manual runs. Can be brought back to CI via services.xserver
if a reliable real-X env is wired up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rver

Replaces the hand-launched-Xorg linux-parallel-drag-gif.nix (which timed
out because a self-launched Xorg can't get a VT/seat in the emulated
nixos-test VM) with a version that lets NixOS bring Xorg up properly:

  - services.xserver with the `dummy` video driver + a 1280x1024 virtual
    screen, libinput input backend, and an icewm window manager
  - lightdm + services.displayManager.autoLogin a normal user into the
    icewm session; the session runs `xhost +local:` so the root-run
    cua-driver / test clients can connect to :0
  - boot.kernelModules uinput + udev rule so MPX slaves enumerate as X
    input devices under libinput

Same proof as before: two per-session master pointers draw concurrent,
window-targeted XI2 events into one unfocused window (assert 2 distinct
devices + >=4 motions), the shield grab keeps focus on a separate control
window, and a GIF artifact is produced. First-run diagnostics dump the
display-manager journal / Xorg log if X fails to come up.

Wired into flake checks and the nix-build matrix (+ visual-artifact list).
The old GIF scenario file is kept for local/real-X manual runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first CI run booted fine — wait_for_x() confirmed /tmp/.X11-unix/X0 and
the graphical target, lightdm autologged-in the user, and the session's
`xhost +local:` ran — but root's xdpyinfo on :0 still timed out for 60s. The
server's auth ACL only lists the autologin user, and xhost +local: did not
reliably grant the other uid. Disable access control outright with the X
`-ac` flag (fine for a throwaway single-user test VM); keep xhost as a
fallback. Also extend the bring-up diagnostics to cover the xdpyinfo step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both prior CI runs failed at the same line — the X bring-up subtest's
`DISPLAY=:0 xdpyinfo` probe timed out — not because root couldn't reach :0
but because xdpyinfo was never in systemPackages, so the command was
not-found and thus always nonzero. The dumped Xorg log shows X itself is
healthy (XINPUT enumerates devices). Add xorg.xdpyinfo so the probe runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@r33drichards
r33drichards merged commit 10a2e71 into main Jun 11, 2026
31 checks passed
r33drichards added a commit that referenced this pull request Jun 12, 2026
Patch: Linux background drag + held-button tools with MPX parallel drags,
function-path held glides, focus-shield grab, and install_ffmpeg (#1871);
Linux agent cursor + typing in background terminals, XTEST keyboard
injection (#1789). Changelog gains 0.5.3 and backfills the missing 0.5.2
entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r33drichards added a commit that referenced this pull request Jun 12, 2026
The MPX multi-cursor work (#1871) pulled in the raw-Xlib x11 crate,
whose build.rs locates libX11/libXi/libXtst via pkg-config. The nix
build got these inputs in #1871, but the CD workflow's linux-x86_64
job had no system deps step, so the v0.5.3 release build failed on
the x11 crate's build script. Install the same dev libs via apt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants