From 67b7f1208ee0e10b70c11804d5cb184081790844 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:07:49 +0900 Subject: [PATCH 1/4] feat(ui): add Shift+right-click brush mode picker --- crates/ui-egui/src/brush_mode_menu.rs | 130 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/control.rs | 79 +++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/paint_mouse.rs | 140 ++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/state.rs | 4 + docs/brush-parity.md | 4 +- docs/control-protocol.md | 2 + 9 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/brush_mode_menu.rs diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs new file mode 100644 index 0000000000..09c5c1cd0d --- /dev/null +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -0,0 +1,130 @@ +//! Brush-only Shift+right-click Mode picker. Shares the options bar's painting modes. + +use egui::{Context, Id}; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::{PhotocraftApp, state::Tool, theme::Tokens}; + +/// The options bar and canvas picker use the same order, including painting-only Clear. +pub(crate) fn choices() -> Vec<(Option, &'static str)> { + let mut choices: Vec<_> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); + let at = choices.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(choices.len(), |i| i + 1); + choices.insert(at, (None, "Clear")); + choices +} + +pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool { + tool == Tool::Brush && mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd +} + +pub(crate) fn menu_id() -> Id { + Id::new("canvas-brush-mode-menu") +} + +pub(crate) fn open(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) { + if !pos.iter().all(|v| v.is_finite()) { + return; + } + app.ui.brush_picker = None; + app.ui.layer_menu = None; + app.ui.canvas_tool_menu = None; + app.ui.brush_mode_menu = Some(pos); + crate::canvas_popup::mark_open(ctx, menu_id()); +} + +/// A press outside dismisses the popup, and consumes that entire canvas gesture through Up. +pub(crate) fn blocks_canvas(app: &PhotocraftApp, ctx: &Context) -> bool { + let key = menu_id().with("dismiss-pointer"); + if ctx.data(|d| d.get_temp::(key)).unwrap_or(false) { + if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(key)); + } + return true; + } + if app.ui.brush_mode_menu.is_some() { + if let Some(rect) = ctx.memory(|m| m.area_rect(menu_id())) + && crate::canvas_popup::outside_pressed(ctx, menu_id(), rect) + { + ctx.data_mut(|d| d.insert_temp(key, true)); + } + return true; + } + false +} + +/// Document-coordinate control pointers dismiss; menu rows are chosen with screen-space ui.click. +pub(crate) fn blocks_control(app: &mut PhotocraftApp, ctx: &Context, ev: crate::canvas::ToolEvent) -> bool { + use crate::canvas::ToolEvent; + let key = menu_id().with("control-dismiss"); + let blocked = ctx.data(|d| d.get_temp::(key)).unwrap_or(false); + if blocked { + if matches!(ev, ToolEvent::Up { .. }) { + ctx.data_mut(|d| d.remove::(key)); + } + return true; + } + if app.ui.brush_mode_menu.is_some() { + if app.ui.tool != Tool::Brush { + app.ui.brush_mode_menu = None; + return false; + } + if matches!(ev, ToolEvent::Down { .. }) { + app.ui.brush_mode_menu = None; + ctx.data_mut(|d| d.insert_temp(key, true)); + } + return true; + } + false +} + +fn choose(app: &mut PhotocraftApp, mode: Option) { + // Clear is the existing painting-only UI flag; keep the underlying blend mode just as the + // options bar does. Journal the choice through the same brush command. + let selected = mode.unwrap_or(app.session.tools.brush.mode); + match app.run("tools.setBrush", json!({"brush": {"mode": selected}})) { + Ok(_) => app.ui.tool_options.brush_clear = mode.is_none(), + Err(e) => app.ui.status = e, + } + app.ui.brush_mode_menu = None; +} + +pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) { + let Some([x, y]) = app.ui.brush_mode_menu else { return }; + if app.ui.tool != Tool::Brush || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.brush_mode_menu = None; + return; + } + let choices = choices(); + let current = if app.ui.tool_options.brush_clear { None } else { Some(app.session.tools.brush.mode) }; + let screen = crate::work_area::visible_rect(ctx); + let id = menu_id(); + let margin = ctx.global_style().spacing.menu_margin.sum() + egui::Vec2::splat(2.0); + let height = (choices.len() as f32 * 22.0).min((screen.height() - margin.y).max(0.0)); + let width = (screen.width() - margin.x).clamp(0.0, 190.0); + let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(width + margin.x, height + margin.y), |r| r.size()); + let pos = egui::pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top())); + let mut picked = None; + let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos).constrain_to(screen).show(ctx, |ui| { + let t = Tokens::get(ctx); + egui::Frame::menu(ui.style()).fill(t.card).stroke(egui::Stroke::new(1.0, t.card_border)).show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 0.0; + crate::widgets::menu_scroll(ui, |ui| { + for (mode, label) in choices { + let selected = mode == current; + let color = if selected && t.bevel { t.accent_text } else { t.text }; + let row = egui::Button::selectable(selected, egui::RichText::new(tl!(label)).color(color)).min_size(egui::vec2(width, 22.0)); + if ui.add(row).clicked() { + picked = Some(mode); + } + } + }); + }); + }); + if let Some(mode) = picked { + choose(app, mode); + } else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) { + app.ui.brush_mode_menu = None; + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 231b4612ca..935d4bcbc9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1821,6 +1821,7 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::layer_transfer::finish(app, ui.ctx()); // Once a frame, not per document view: the right-click or options-bar Brush Preset picker. crate::paint_mouse::show_picker(app, ui.ctx()); + crate::brush_mode_menu::show(app, ui.ctx()); } /// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the @@ -3270,6 +3271,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: && app.drag.is_none() && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) + && !crate::brush_mode_menu::blocks_canvas(app, &ctx) && let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)) { let d = xf.to_doc(p); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e797b27cc0..2fde81369f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -976,6 +976,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let space = p.get("modifiers").unwrap_or(p).get("space").and_then(Value::as_bool).unwrap_or(false); crate::crop_ui::set_space(app, space); for (ev, x, y, pr, pen) in events { + if crate::brush_mode_menu::blocks_control(app, ctx, ev) { + continue; + } // With the Color Picker on top the image is its eyedropper, as for the mouse. if crate::color_picker_ui::top(app).is_some() { if !matches!(ev, ToolEvent::Up { .. }) { @@ -1254,6 +1257,11 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "camTransform": app.ui.cam_transform, "layerMenu": app.ui.layer_menu, "layerPicker": app.ui.layer_picker, + "brushModeMenu": app.ui.brush_mode_menu.map(|pos| json!({ + "pos": pos, + "mode": if app.ui.tool_options.brush_clear { "Clear" } else { app.session.tools.brush.mode.label() }, + "choices": crate::brush_mode_menu::choices().iter().map(|(_, label)| *label).collect::>(), + })), "brushPicker": app.ui.brush_picker.map(|pos| json!({ "pos": pos, // The picker's content size once its corner grip was dragged (null: the default). @@ -1348,6 +1356,77 @@ mod tests { } } + #[test] + fn shift_right_brush_does_not_open_presets_or_paint() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.ui.tool = crate::state::Tool::Brush; + let ctx = egui::Context::default(); + let before = app.session.journal.len(); + call( + &mut app, + &ctx, + "ui.pointer", + json!({"button": "right", "shift": true, + "events": [{"kind": "down", "x": 20, "y": 20}, {"kind": "up", "x": 20, "y": 20}]}), + ); + assert!(app.ui.brush_picker.is_none(), "Shift+right opens Mode, not Brush Presets"); + assert!(app.drag.is_none()); + assert_eq!(app.session.journal.len(), before, "opening a menu does not paint or journal"); + } + + #[test] + fn brush_mode_control_routes_modifiers_and_reports_choices_without_history() { + let ctx = egui::Context::default(); + for (tool, modifiers, expected) in [ + ("brush", json!({"shift": true}), "mode"), + ("brush", json!({}), "erase"), + ("brush", json!({"shift": true, "alt": true}), "resize"), + ("brush", json!({"shift": true, "ctrl": true}), if cfg!(target_os = "macos") { "erase" } else { "layer" }), + ("brush", json!({"shift": true, "command": true}), "layer"), + ("pencil", json!({"shift": true}), "preset"), + ] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + call( + &mut app, + &ctx, + "ui.pointer", + json!({"tool": tool, "button": "right", "modifiers": modifiers, + "events": [{"kind": "down", "x": 20, "y": 20}]}), + ); + assert_eq!(app.ui.brush_mode_menu.is_some(), expected == "mode", "{tool}: {modifiers}"); + match expected { + "mode" => { + assert!(app.drag.is_none() && !app.secondary_erase); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + let inspect = inspect(&app, &ctx); + assert_eq!( + inspect["brushModeMenu"]["choices"], + json!(crate::brush_mode_menu::choices().iter().map(|(_, label)| *label).collect::>()) + ); + let journal = app.session.journal.len(); + call( + &mut app, + &ctx, + "ui.pointer", + json!({"events": [ + {"kind": "down", "x": 10, "y": 10}, {"kind": "move", "x": 30, "y": 30}, {"kind": "up", "x": 30, "y": 30}]}), + ); + assert!(app.ui.brush_mode_menu.is_none() && app.drag.is_none()); + assert_eq!(app.session.journal.len(), journal, "control dismissal cannot paint"); + } + "preset" => assert!(app.ui.brush_picker.is_some()), + "layer" => assert!(app.ui.layer_menu.is_some()), + "resize" => assert!(app.brush_resize.is_some()), + "erase" => assert!(app.drag.as_ref().is_some_and(|d| d.erase)), + _ => panic!("unknown fixture route: {expected}"), + } + } + } + #[test] fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6bffa8133f..faf077828c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ mod blend_keys; pub(crate) mod blend_preview; mod brand; pub(crate) mod brush_cursor; +mod brush_mode_menu; pub mod brush_panel; pub mod brush_picker; pub mod brush_preset_dialog; diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index c49055e13a..9bb2022410 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -99,6 +99,10 @@ pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { /// brush with Alt held (#297), erases (Erase preference) or opens the Brush Preset picker. Arms /// `secondary_erase` or `brush_resize_armed` for this frame's `Down`. pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons { + if crate::brush_mode_menu::blocks_canvas(app, &response.ctx) { + app.secondary_erase = false; + return Buttons::default(); + } let (mods, right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); // Alt+right-drag resizes the brush (brush_resize.rs); its events reach `tool_event` like a // left drag's, and nothing paints. A resize whose release was missed ends here. @@ -111,6 +115,21 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) app.brush_resize_armed = resize_start; // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); + let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, mods)); + if mode_menu && !resizing && app.drag.is_none() { + app.secondary_erase = false; + if response.secondary_clicked() + && let Some(p) = response.interact_pointer_pos() + { + crate::brush_mode_menu::open(app, &response.ctx, [p.x, p.y]); + } + return Buttons { + started: response.drag_started_by(PointerButton::Primary), + dragged: response.dragged_by(PointerButton::Primary), + stopped: response.drag_stopped_by(PointerButton::Primary), + clicked: response.clicked(), + }; + } let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu; let right_stroke = erase && app.drag.is_some(); let right_start = erase && response.drag_started_by(PointerButton::Secondary); @@ -144,6 +163,13 @@ pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: boo app.brush_resize_armed = down && app.drag.is_none(); return true; } + if crate::brush_mode_menu::is_gesture(tool, mods) && app.drag.is_none() && app.brush_resize.is_none() { + app.secondary_erase = false; + if down { + crate::brush_mode_menu::open(app, ctx, at); + } + return false; + } if right_erases(app, tool) { app.secondary_erase = down; return true; @@ -278,6 +304,120 @@ mod tests { h.state().session.journal.iter().filter(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).collect() } + fn mode_right_click(h: &mut Harness<'static, PhotocraftApp>, mods: Modifiers) { + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(mods)); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: mods }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: mods }); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + } + + #[test] + fn shift_right_mode_picker_selects_journals_and_clear_erases() { + use egui_kittest::kittest::Queryable; + let mut h = harness(Some("erase")); + let history = h.state().session.active().unwrap().history.past_len(); + let journal = h.state().session.journal.len(); + mode_right_click(&mut h, Modifiers::SHIFT); + assert!(h.state().ui.brush_mode_menu.is_some()); + assert!(h.state().ui.brush_picker.is_none() && h.state().drag.is_none()); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + h.get_by_label("Multiply").click(); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Multiply); + assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Multiply"}}))); + let (a, b) = drag(&mut h, PointerButton::Primary); + let mid = a + (b - a) * 0.5; + assert!(alpha_at(&h, mid) > 0.9); + mode_right_click(&mut h, Modifiers::SHIFT); + h.get_by_label("Clear").click(); + h.run_steps(2); + assert!(h.state().ui.tool_options.brush_clear); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Multiply, "Clear keeps the underlying mode, as the options bar does"); + drag(&mut h, PointerButton::Primary); + assert_eq!(alpha_at(&h, mid), 0.0); + assert!(strokes(&h).last().unwrap()["erase"].as_bool().unwrap()); + } + + #[test] + fn mode_picker_outside_dismissal_does_not_paint_or_erase() { + for button in [PointerButton::Primary, PointerButton::Secondary] { + let mut h = harness(Some("erase")); + drag(&mut h, PointerButton::Primary); + let journal = h.state().session.journal.len(); + let history = h.state().session.active().unwrap().history.past_len(); + mode_right_click(&mut h, Modifiers::SHIFT); + // A full outside drag must only dismiss, including its release click. + drag(&mut h, button); + assert!(h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.journal.len(), journal, "dismissal must not paint/erase with {button:?}"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + } + } + + #[test] + fn mode_control_can_pick_a_row_with_ui_click() { + use crate::control::{ControlRequest, Outcome, handle}; + use egui_kittest::kittest::Queryable; + let mut h = harness(Some("erase")); + let ctx = h.ctx.clone(); + let before = h.state().session.journal.len(); + let (req, _) = ControlRequest::new( + "ui.pointer", + json!({"button": "right", "shift": true, + "events": [{"kind": "down", "x": 200, "y": 150}, {"kind": "up", "x": 200, "y": 150}]}), + ); + assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::Done(_))); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_some()); + assert_eq!(h.state().session.journal.len(), before); + let at = h.get_by_label("Darken").rect().center(); + let (req, _) = ControlRequest::new("ui.click", json!({"x": at.x, "y": at.y})); + assert!(matches!(handle(h.state_mut(), &ctx, &req), Outcome::AfterInput)); + // The app injects these queued control events into its next frame's input. + let events = std::mem::take(&mut h.state_mut().synthetic); + for event in events { + h.event(event); + } + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.tools.brush.mode, photocraft_doc::BlendMode::Darken); + assert_eq!(h.state().session.journal.last().unwrap(), &("tools.setBrush".into(), json!({"brush": {"mode": "Darken"}}))); + } + + #[test] + fn shift_right_mode_picker_cancels_and_other_gestures_keep_their_routes() { + let mut h = harness(None); + let journal = h.state().session.journal.len(); + mode_right_click(&mut h, Modifiers::SHIFT); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + mode_right_click(&mut h, Modifiers::SHIFT); + let outside = h.state().last_canvas_rect.min + vec2(2.0, 2.0); + h.event(egui::Event::PointerMoved(outside)); + press(&mut h, outside, PointerButton::Secondary, true); + press(&mut h, outside, PointerButton::Secondary, false); + assert!(h.state().ui.brush_mode_menu.is_none()); + mode_right_click(&mut h, Modifiers::SHIFT); + h.state_mut().ui.tool = Tool::Pencil; + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none()); + mode_right_click(&mut h, Modifiers::SHIFT); + assert!(h.state().ui.brush_mode_menu.is_none() && h.state().ui.brush_picker.is_some()); + h.state_mut().ui.brush_picker = None; + h.state_mut().ui.tool = Tool::Brush; + mode_right_click(&mut h, Modifiers::NONE); + assert!(h.state().ui.brush_picker.is_some() && h.state().ui.brush_mode_menu.is_none()); + assert_eq!(h.state().session.journal.len(), journal, "opening/cancelling never changes brush or pixels"); + } + #[test] fn left_drag_paints() { let mut h = harness(None); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2ab7fbf794..7e19f9deb2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3656,9 +3656,7 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C /// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while /// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061). fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) { - let mut opts: Vec<(Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); - let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); - opts.insert(at, (None, "Clear")); + let opts = crate::brush_mode_menu::choices(); let mut mode = if *clear || held { None } else { Some(b.mode) }; if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() { *clear = mode.is_none(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b82c7cb023..b7a30c13ad 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1105,6 +1105,9 @@ pub struct UiState { /// its screen position (points). #[serde(default)] pub brush_picker: Option<[f32; 2]>, + /// Brush Mode menu opened by Shift+right-click: screen position in points. + #[serde(default)] + pub brush_mode_menu: Option<[f32; 2]>, /// The Brush Preset picker's preset list: search, collapsed groups, view, a rename in progress. #[serde(default = "crate::brush_picker::list_state")] pub brush_picker_list: crate::brush_panel::BrushesPanelState, @@ -1281,6 +1284,7 @@ impl Default for UiState { vector_mask_target: false, properties_layer: None, brush_picker: None, + brush_mode_menu: None, brush_picker_list: crate::brush_picker::list_state(), brush_picker_size: None, layer_menu: None, diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 39efd36371..2f14634bf8 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Brush Shift+right-click Mode picker) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -73,6 +73,7 @@ tablets against Photoshop side by side. | ⇧-click draws a straight line from the last point | done | | | Hold `~` with the Brush or Pencil to erase with its brush: Clear mode while held (rebindable under Tools › Temporary) | done | `hold_keys.rs`, #3439 | | Clear painting mode in the Brush and Pencil Mode menus | done | `paint_mode_dropdown` in `panels.rs`, `"mode": "clear"`, #3439 | +| Shift+right-click with Brush opens its Mode menu, including Clear; Alt and Command/Ctrl keep their existing gestures | done | #4929; shared options-bar choices, `tools.setBrush`, canvas/control interaction regressions and synthetic offscreen checks | | Right-click opens the Brush Preset picker | done | [`context-menu-parity.md`](context-menu-parity.md); XP-Pen right-click not (#1980), macOS issue #2360 | | Precise / normal / full-size tip cursors, crosshair in the tip | partial | Caps Lock precise cursor not audited; round Pencil cursor requested (#2662, #1057) | | Brush choice kept per tool (Brush, Eraser, Blur each their own) | partial | #2542 | @@ -119,3 +120,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Brush-only Shift+right-click Mode menu shares options-bar choices, including Clear; preserves existing right-click gestures and consumes outside dismissal without painting (#4929). No brush-feel estimate changed. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e571e94f8b..cf62b4f041 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,6 +73,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing +With Brush, Shift+right-click (without Alt, Ctrl or Command) opens the Mode menu even when right-click painting is set to Erase. `ui.inspect.brushModeMenu` reports its screen position, current mode and choices. Pick a row with `ui.click`; the choice uses `tools.setBrush`. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document. + - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). From 4636a1f67cc565eca6b1158a6c55e75613c1fb0b Mon Sep 17 00:00:00 2001 From: haranishi Date: Thu, 22 Oct 2026 13:36:02 +0900 Subject: [PATCH 2/4] fix(ui): decide the brush Mode menu gesture on the right-button press egui reports secondary_clicked on release, so letting go of Shift a moment before the right button turned Shift+right-click into a plain right-click. With the Erase preference that erased a dab instead of opening the Mode menu. Remember the modifiers held when the button went down and use them for the gesture (the control path already decides on the press), and add a test that releases Shift in a frame of its own before the button. --- crates/ui-egui/src/brush_mode_menu.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/paint_mouse.rs | 27 ++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs index 09c5c1cd0d..b8cd5b9268 100644 --- a/crates/ui-egui/src/brush_mode_menu.rs +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -18,6 +18,30 @@ pub(crate) fn is_gesture(tool: Tool, mods: egui::Modifiers) -> bool { tool == Tool::Brush && mods.shift && !mods.alt && !mods.ctrl && !mods.command && !mods.mac_cmd } +/// The modifiers that decide a right-button gesture: the ones held when the button went down. +/// egui reports `secondary_clicked` on release, so letting go of Shift a moment before the button +/// must not turn Shift+right-click into a plain right-click (an erase dab, with the Erase +/// preference). The control path decides on the press too. +pub(crate) fn gesture_mods(ctx: &Context, mods: egui::Modifiers) -> egui::Modifiers { + let key = menu_id().with("press-mods"); + let (pressed, down, released) = ctx.input(|i| { + (i.pointer.button_pressed(egui::PointerButton::Secondary), i.pointer.secondary_down(), i.pointer.button_released(egui::PointerButton::Secondary)) + }); + if pressed { + ctx.data_mut(|d| d.insert_temp(key, mods)); + return mods; + } + if !down && !released { + ctx.data_mut(|d| d.remove::(key)); + return mods; + } + let held = ctx.data(|d| d.get_temp::(key)); + if released { + ctx.data_mut(|d| d.remove::(key)); + } + held.unwrap_or(mods) +} + pub(crate) fn menu_id() -> Id { Id::new("canvas-brush-mode-menu") } diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 07f8de9cba..a15fa25d41 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -139,7 +139,8 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) app.brush_resize_armed = resize_start || (physical_right && resizing); // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307). let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods); - let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, mods)); + // Decided by the modifiers held when the button went down, not by those of the release frame that reports the click. + let mode_menu = crate::brush_mode_menu::is_gesture(tool, crate::workspace_ui::sticky_mods(app, crate::brush_mode_menu::gesture_mods(&response.ctx, mods))); if mode_menu && !resizing && app.drag.is_none() { app.secondary_erase = false; if response.secondary_clicked() @@ -442,6 +443,30 @@ mod tests { assert_eq!(h.state().session.journal.len(), journal, "opening/cancelling never changes brush or pixels"); } + #[test] + fn shift_released_before_the_right_button_still_opens_the_mode_picker() { + // egui reports `secondary_clicked` on release. With Erase on, a click that lost Shift a + // frame early must open the menu, not erase a dab. + let mut h = harness(Some("erase")); + let history = h.state().session.active().unwrap().history.past_len(); + let journal = h.state().session.journal.len(); + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT)); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + // Shift goes up in a frame of its own while the right button is still down. + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_some(), "the press decided the gesture"); + assert!(h.state().drag.is_none() && !h.state().secondary_erase); + assert_eq!(h.state().session.journal.len(), journal, "no erase dab"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + } + #[test] fn left_drag_paints() { let mut h = harness(None); From 9c47ee6dd50e52d0b8085a6220254c3199020c41 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:51:45 +0900 Subject: [PATCH 3/4] fix(ui): consume the right-button release after dismissing brush modes --- crates/ui-egui/src/brush_mode_menu.rs | 4 +++ crates/ui-egui/src/paint_mouse.rs | 35 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/ui-egui/src/brush_mode_menu.rs b/crates/ui-egui/src/brush_mode_menu.rs index b8cd5b9268..e222fd8b63 100644 --- a/crates/ui-egui/src/brush_mode_menu.rs +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -117,6 +117,10 @@ pub(crate) fn show(app: &mut PhotocraftApp, ctx: &Context) { let Some([x, y]) = app.ui.brush_mode_menu else { return }; if app.ui.tool != Tool::Brush || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.brush_mode_menu = None; + // Escape also consumes the release of a right-button gesture still in flight. + if app.ui.tool == Tool::Brush && ctx.input(|i| i.pointer.secondary_down()) { + ctx.data_mut(|d| d.insert_temp(menu_id().with("dismiss-pointer"), true)); + } return; } let choices = choices(); diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index a15fa25d41..e01f0183a9 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -443,6 +443,41 @@ mod tests { assert_eq!(h.state().session.journal.len(), journal, "opening/cancelling never changes brush or pixels"); } + #[test] + fn escape_dismissal_consumes_a_held_secondary_release() { + for release_shift in [false, true] { + let mut h = harness(Some("erase")); + let journal = h.state().session.journal.len(); + let history = h.state().session.active().unwrap().history.past_len(); + let p = h.state().last_canvas_rect.center(); + h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT)); + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + // A quick second press can arrive in the frame that opens the menu for the first + // click's release, leaving the secondary button held while the picker is visible. + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::SHIFT }); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + assert!(h.state().ui.brush_mode_menu.is_some()); + assert!(h.ctx.input(|i| i.pointer.secondary_down())); + h.key_press(egui::Key::Escape); + h.run_steps(1); + assert!(h.state().ui.brush_mode_menu.is_none()); + let mods = if release_shift { Modifiers::NONE } else { Modifiers::SHIFT }; + h.event(egui::Event::ModifiersChanged(mods)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed: false, modifiers: mods }); + h.run_steps(2); + assert!(h.state().ui.brush_mode_menu.is_none(), "Escape must consume the pending release"); + assert!(h.state().ui.brush_picker.is_none() && h.state().drag.is_none() && !h.state().secondary_erase); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + mode_right_click(&mut h, Modifiers::SHIFT); + assert!(h.state().ui.brush_mode_menu.is_some(), "the next gesture must still work"); + } + } + #[test] fn shift_released_before_the_right_button_still_opens_the_mode_picker() { // egui reports `secondary_clicked` on release. With Erase on, a click that lost Shift a From 1dc98f80f60e993f3edf20af815391b6a7e7add7 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara <223786623+haranishi@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:15 +0900 Subject: [PATCH 4/4] docs(control): associate brush mode gesture with ui.pointer Signed-off-by: Ikki Nishihara <223786623+haranishi@users.noreply.github.com> --- docs/control-protocol.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15cc6a2aaf..767df529b5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -77,12 +77,12 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). With the Crop tool in its default mode (Use Classic Mode off) a drag inside the box moves the image and one outside turns it, the view panning and turning with it; a drag's `move`/`up` points are read as the view showed the document at its `down`, so an `up` where the last `move` was changes nothing more. `command: true` with a painting, retouching, eraser, gradient, eyedropper or other non-selection tool makes the gesture the Move tool's, as holding ⌘ (Ctrl off the Mac) does in the app: a drag moves the active layer (the selected pixels when there is a selection), with `alt: true` as well it duplicates the layer first, and a click picks the layer under the point; the tool is unchanged afterwards. The Hand, Rotate View, Zoom, Crop, Slice, Path Selection, Pen, shape, Type and selection tools keep `command` for themselves. `ctrl: true` with `alt: true` and a brush tool is the on-canvas brush resize instead (in the app, Ctrl+Alt-drag off the Mac). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen, or with a Type tool on text being edited or on committed type, it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with Zoom or Hand it opens the view menu there (Zoom In and Zoom Out step about the pressed point; `view.fillScreen` is its Fill Screen row); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. For Brush Shift+right-click, see the Mode menu exception below. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. `button` is `left` (default), `right`, `secondary` or `middle`, and an event's `kind` is `down`, `move` (default) or `up`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. A `down`/`up` at the same point with the Rectangle, Ellipse, Triangle or Polygon tool opens Create Rectangle / Ellipse / Triangle / Polygon (Photoshop's click-to-size dialog); set its `width` and `height` (document pixels), `fromCenter`, `radii` [tl, tr, br, bl] (Rectangle) or `sides` and `starRatio` (percent, Polygon) with `ui.dialog.set`, then `ui.dialog.confirm` runs `shape.create` at the clicked point. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - Image › Adjustments › Curves on top, no eyedropper armed (#7325): a `down` with `command: true` adds a point on the curve the dialog shows (its Channel popup), at the pixel's tone (RGB: Rec. 709 luma; Gray: the mean) and at the curve's current output there, so the preview does not change; with `shift: true` as well, one point on each of Red, Green and Blue at that channel's value. It samples the merged image like the eyedroppers, never moves an existing point (a point already within 2 levels is selected instead, a 16-point curve takes no more), and the tool is not driven. Nothing is added with a pencil-drawn curve, in Lab or CMYK, or with a mask, saved channel or Quick Mask targeted. - Levels or Curves adjustment layer, Properties eyedropper armed (`layerPicker` in `ui.inspect`: `{layer, point, tool}`, set by the panel's Set Black/Neutral Gray/White Point buttons): a `down` sets that point from the image below the layer as one `layer.setAdjustment {"layer", "eyedropper": {"point", "at": [x, y], "size"}}` (send that command directly to skip the UI) and the tool is not driven; Esc, another tool or another layer disarms it. + - With Brush, Shift+right-click (without Alt, Ctrl or Command) opens the Mode menu even when right-click painting is set to Erase. `ui.inspect.brushModeMenu` reports its screen position, current mode and choices. Pick a row with `ui.click`; the choice uses `tools.setBrush`. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing -With Brush, Shift+right-click (without Alt, Ctrl or Command) opens the Mode menu even when right-click painting is set to Erase. `ui.inspect.brushModeMenu` reports its screen position, current mode and choices. Pick a row with `ui.click`; the choice uses `tools.setBrush`. Clear has the same painting-only erase meaning as the options bar. Escape, an outside press or a tool change closes the menu without changing the brush or document. - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`. `{"key": "AltLeft"}` is a tap on Alt, which puts the keyboard on the in-window menu bar (then `ArrowRight`, `ArrowDown`, `Enter`, `Escape` or a menu's letter drive it); `{"key": "F", "alt": true}` opens the menu with that mnemonic (#4988) - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default