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..6fb354b36c --- /dev/null +++ b/crates/ui-egui/src/brush_mode_menu.rs @@ -0,0 +1,158 @@ +//! 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 +} + +/// 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::unique("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; + // 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(); + 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 3896b84cf6..236630971f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -980,6 +980,7 @@ fn handle_canvas_tool_stroke( && 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/canvas/paint_mouse.rs b/crates/ui-egui/src/canvas/paint_mouse.rs index dc64d34312..b4b4382971 100644 --- a/crates/ui-egui/src/canvas/paint_mouse.rs +++ b/crates/ui-egui/src/canvas/paint_mouse.rs @@ -114,6 +114,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 physical_right = app.services.right_button_down.as_ref().is_some_and(|f| f()); let (mods, mut right_down) = response.ctx.input(|i| (i.modifiers, i.pointer.secondary_down())); right_down |= physical_right; @@ -137,6 +141,22 @@ 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); + // 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() + && 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 && right_started; @@ -170,6 +190,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; diff --git a/crates/ui-egui/src/canvas/paint_mouse/tests.rs b/crates/ui-egui/src/canvas/paint_mouse/tests.rs index 4b54a1ce98..4ea56d1321 100644 --- a/crates/ui-egui/src/canvas/paint_mouse/tests.rs +++ b/crates/ui-egui/src/canvas/paint_mouse/tests.rs @@ -65,6 +65,179 @@ fn strokes(h: &Harness<'static, PhotocraftApp>) -> Vec { 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 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 + // 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); diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 82a1e2089f..96bd81ee6a 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -10,6 +10,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 diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index f3e2756071..7e83dc631a 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -1110,6 +1110,9 @@ fn pointer(app: &mut PhotocraftApp, ctx: &egui::Context, p: &Value) -> Outcome { mods: modifiers_from(&parsed.modifiers), }; for sample in parsed.events { + if crate::brush_mode_menu::blocks_control(app, ctx, sample.ev) { + continue; + } if !pointer_event_intercepted(app, ctx, &gesture, &sample) { forward_pointer_event(app, &gesture, sample); } diff --git a/crates/ui-egui/src/control/inspect.rs b/crates/ui-egui/src/control/inspect.rs index 12b34034b4..171fe49ef9 100644 --- a/crates/ui-egui/src/control/inspect.rs +++ b/crates/ui-egui/src/control/inspect.rs @@ -41,6 +41,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). diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1db2ec45ad..89c8166e9f 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -8,6 +8,74 @@ fn call(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Valu } } +#[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 ui_set_turns_the_stroke_trace_on_and_off_at_runtime() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22d3565ffa..c7b651c1d8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ macro_rules! tl { $crate::i18n::t($s) }; } +mod brush_mode_menu; pub mod canvas; pub mod control; pub mod dialogs; diff --git a/crates/ui-egui/src/panels/brush_controls.rs b/crates/ui-egui/src/panels/brush_controls.rs index d6a96112a7..335f9b83bc 100644 --- a/crates/ui-egui/src/panels/brush_controls.rs +++ b/crates/ui-egui/src/panels/brush_controls.rs @@ -26,9 +26,7 @@ pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[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, tl!("Mode"), &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 eac69a2263..bb9df8945d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -273,6 +273,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, @@ -508,6 +511,7 @@ impl Default for UiState { filter_mask_target: None, 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 23d0b422c3..691e5c1beb 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-25 · **Change:** minor (Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Brush Shift+right-click Mode picker and Escape dismissal; Art History Brush row added as partial, 8 of 10 styles, TOOL-213-20, #1610; hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **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 @@ -76,6 +76,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 | @@ -130,7 +131,9 @@ tablets against Photoshop side by side. | 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 | Hue Jitter uses the same themed amount slider as other jitter controls (#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. | | 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | | 2026-10-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. No brush-feel estimate changed. | | 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | +| 2026-10-23 | minor | Escape also consumes a held right-button release after a rapid second press, so the Brush Mode picker stays dismissed without painting or changing history (#4929). | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e13f64fedc..c3db027130 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -77,11 +77,13 @@ 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 + - `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 - 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).