From 94ee52088d128eba131490edb04c3bc596e7630b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 12:35:49 -0400 Subject: [PATCH] LR-BEHAV-HEADLESS: ui.clickWidget right-clicks with button "right" `ui.click` took `button`, `ui.clickWidget` ignored it: MCP `click {widget, button: "right"}` passes it on, and silently left-clicked. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 5 +++-- crates/ui-egui/src/headless.rs | 16 ++++++++++++++++ docs/control-protocol.md | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e2d01f5e..592a58de 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -201,10 +201,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest app.synthetic.push(egui::Event::PointerMoved(at)); } else if req.method == "ui.clickWidget" { let n = p.get("count").and_then(Value::as_u64).unwrap_or(1); + let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; app.synthetic.push(egui::Event::PointerMoved(at)); for _ in 0..n { - app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); - app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m }); } } else { let to = egui::pos2( diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8b5f4256..324f35e6 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -662,6 +662,22 @@ mod tests { assert_eq!(r["ok"], false, "an unknown action is refused: {r}"); } + /// `ui.clickWidget` right-clicks with `button: "right"`, as `ui.click` does (MCP `click` + /// passes it on with a widget): a filmstrip photo's context menu opens. + #[test] + fn click_widget_right_clicks() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| w.starts_with("film:")).map(|(w, _)| w.clone()).expect("a filmstrip photo"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.clickWidget", json!({"id": cell, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); + } + #[test] fn demo_grid_snapshot_has_ui_pixels() { let t0 = Instant::now(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b0b123fa..c0317b58 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,7 +32,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | | `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | -| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | +| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | | `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press |