Merge approved PhotoCraft PR #4985: feat(ui): add Shift+right-click brush mode picker

This commit is contained in:
FredApps
2026-10-29 03:28:59 +01:00
13 changed files with 449 additions and 5 deletions
+158
View File
@@ -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<BlendMode>, &'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::<egui::Modifiers>(key));
return mods;
}
let held = ctx.data(|d| d.get_temp::<egui::Modifiers>(key));
if released {
ctx.data_mut(|d| d.remove::<egui::Modifiers>(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::<bool>(key)).unwrap_or(false) {
if !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|d| d.remove::<bool>(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::<bool>(key)).unwrap_or(false);
if blocked {
if matches!(ev, ToolEvent::Up { .. }) {
ctx.data_mut(|d| d.remove::<bool>(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<BlendMode>) {
// 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;
}
}
+1
View File
@@ -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);
+27
View File
@@ -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;
@@ -65,6 +65,179 @@ fn strokes(h: &Harness<'static, PhotocraftApp>) -> Vec<serde_json::Value> {
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);
+1
View File
@@ -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
+3
View File
@@ -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);
}
+5
View File
@@ -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::<Vec<_>>(),
})),
"brushPicker": app.ui.brush_picker.map(|pos| json!({
"pos": pos,
// The picker's content size once its corner grip was dragged (null: the default).
+68
View File
@@ -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::<Vec<_>>()));
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());
+1
View File
@@ -13,6 +13,7 @@ macro_rules! tl {
$crate::i18n::t($s)
};
}
mod brush_mode_menu;
pub mod canvas;
pub mod control;
pub mod dialogs;
+1 -3
View File
@@ -26,9 +26,7 @@ pub(super) fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option<Bl
/// 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).
pub(super) 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<BlendMode>, &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();
+4
View File
@@ -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,
+4 -1
View File
@@ -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). |
+3 -1
View File
@@ -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).