From c052e3f5ecb996d44e33fa97afba5df458877a04 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 22:54:36 +0100 Subject: [PATCH 1/2] Background Eraser: Alt+click picks the foreground colour (#11906) Holding Alt with the Background Eraser now turns it into the Eyedropper for that press, as with the Brush, Pencil, Gradient and Paint Bucket: a click or drag sets the foreground colour, adds no history state and changes no pixel, and the cursor shows the pipette. Control+Alt stays the brush-resize gesture. Co-Authored-By: Claude Opus 5.5 --- .../src/background_eraser_alt_click_tests.rs | 175 ++++++++++++++++++ crates/ui-egui/src/canvas/cursors.rs | 8 +- 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/background_eraser_alt_click_tests.rs diff --git a/crates/ui-egui/src/background_eraser_alt_click_tests.rs b/crates/ui-egui/src/background_eraser_alt_click_tests.rs new file mode 100644 index 0000000000..30047b42cc --- /dev/null +++ b/crates/ui-egui/src/background_eraser_alt_click_tests.rs @@ -0,0 +1,175 @@ +//! ⌥ (Alt) with the Background Eraser is the Eyedropper, as with the Brush (#11906): a click or +//! drag sets the foreground colour, adds no history state and changes no pixel, and the cursor +//! is the pipette while ⌥ is held. Control+⌥ stays the brush-resize gesture. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [100.0, 50.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { + h.event(egui::Event::ModifiersChanged(m)); + h.run_steps(1); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, m: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m }); + h.run_steps(1); +} + +/// A drag between document points with `m` held from before the press until after the +/// release; `from == to` is a click. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m: Modifiers) { + let p = screen(h, from[0], from[1]); + mods(h, m); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(h, p, true, m); + if from != to { + for i in 1..=8 { + let t = i as f32 / 8.0; + let q = screen(h, from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t); + h.event(egui::Event::PointerMoved(q)); + h.run_steps(1); + } + } + button(h, screen(h, to[0], to[1]), false, m); + h.run_steps(2); + mods(h, Modifiers::NONE); +} + +/// Red on the left, green on the right of an unlocked layer, Background Eraser active, blue +/// foreground and white background colour. +fn background_eraser() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("tools.setColors", json!({"foreground": BLUE, "background": [1.0, 1.0, 1.0, 1.0]})).unwrap(); + app.sync_views(); + let mut h = harness(app); + h.state_mut().ui.tool = Tool::BackgroundEraser; + h.run_steps(1); + h +} + +/// The document and the number of history states, to show nothing changed. +fn snapshot(h: &Harness<'static, PhotocraftApp>) -> (std::sync::Arc, usize) { + let st = h.state().session.active().unwrap(); + (st.doc.clone(), st.history.past_len()) +} + +fn unchanged(h: &Harness<'static, PhotocraftApp>, before: &(std::sync::Arc, usize), what: &str) { + let (doc, steps) = snapshot(h); + assert!(std::sync::Arc::ptr_eq(&doc, &before.0), "{what}: no pixel changed"); + assert_eq!(steps, before.1, "{what}: no history state"); +} + +#[test] +fn alt_click_sets_the_foreground_without_erasing() { + let mut h = background_eraser(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, GREEN, "⌥-click picked green"); + assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "the background colour stays"); + unchanged(&h, &before, "⌥-click"); + assert!(!h.state().alt_sampling, "the release ends the sampling"); +} + +#[test] +fn alt_drag_follows_the_pointer_without_erasing() { + let mut h = background_eraser(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag ends on red"); + unchanged(&h, &before, "⌥-drag"); +} + +/// The same press without ⌥ still erases: the harness drives the real tool. +#[test] +fn a_click_without_alt_still_erases() { + let mut h = background_eraser(); + let (_, steps) = snapshot(&h); + drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::NONE); + assert_eq!(h.state().session.tools.foreground, BLUE); + assert_eq!(snapshot(&h).1, steps + 1, "the stroke added a history state"); +} + +/// While ⌥ is held the cursor is the Eyedropper's pipette (`pipette_cursor`), where the +/// Background Eraser otherwise shows its own tip; Control+⌥ keeps the tip. +#[test] +fn alt_shows_the_eyedropper_cursor() { + let mut h = background_eraser(); + let p = screen(&h, 150.0, 50.0); + let pipette = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers| { + mods(h, m); + h.hover_at(p); + h.run_steps(2); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-icon-cursor")); + let shown = h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer)); + (shown, h.output().platform_output.cursor_icon) + }; + assert_eq!(pipette(&mut h, Modifiers::ALT), (true, egui::CursorIcon::None), "⌥: the pipette replaces the pointer"); + assert!(!pipette(&mut h, Modifiers::NONE).0, "without ⌥: the eraser's own tip"); + assert!(!pipette(&mut h, Modifiers::ALT | Modifiers::CTRL).0, "Control+⌥ is not the Eyedropper"); + assert!(pipette(&mut h, Modifiers::ALT).0, "⌥ again: the pipette again"); +} + +/// Control+⌥-drag stays the brush-resize gesture: it samples nothing and erases nothing. +#[test] +fn ctrl_alt_drag_resizes_instead_of_sampling() { + assert!(crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT)); + assert!(!crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT | Modifiers::CTRL)); + let mut h = background_eraser(); + let before = snapshot(&h); + drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT | Modifiers::CTRL); + assert_eq!(h.state().session.tools.foreground, BLUE, "Control+⌥ sampled nothing"); + unchanged(&h, &before, "Control+⌥-drag"); +} diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index e692c4fe07..e4f0336b1d 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -112,10 +112,10 @@ pub(super) fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, const RING_GREY: Color32 = Color32::from_gray(127); /// Tools on which holding ⌥ (Alt) switches to the Eyedropper: a click or drag sets the -/// foreground colour, as with the Eyedropper itself (#417). +/// foreground colour, as with the Eyedropper itself (#417, the Background Eraser #11906). pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool { // Control+Alt is the brush-resize drag (`brush_resize`), not sampling. - mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket) + mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket | Tool::BackgroundEraser) } /// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the @@ -289,3 +289,7 @@ pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool { #[cfg(test)] #[path = "../eyedropper_mask_sample_tests.rs"] mod eyedropper_mask_sample_tests; + +#[cfg(test)] +#[path = "../background_eraser_alt_click_tests.rs"] +mod background_eraser_alt_click_tests; From d61aa40aa48bc16bf634fc379f4db7d14f9ea94e Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 7 Nov 2026 23:06:00 +0100 Subject: [PATCH 2/2] Background Eraser Alt tests: right-drag, held drag, erase and resize checks Adds an Alt+right-drag test (resizes, samples nothing), checks the foreground while an Alt-drag is still held, that a plain click clears the pixel, and that Control+Alt-drag changes the brush size. Co-Authored-By: Claude Opus 5.5 --- .../src/background_eraser_alt_click_tests.rs | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/background_eraser_alt_click_tests.rs b/crates/ui-egui/src/background_eraser_alt_click_tests.rs index 30047b42cc..aa7adbcc0c 100644 --- a/crates/ui-egui/src/background_eraser_alt_click_tests.rs +++ b/crates/ui-egui/src/background_eraser_alt_click_tests.rs @@ -55,19 +55,19 @@ fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) { h.run_steps(1); } -fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, m: Modifiers) { - h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m }); +fn button(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, p: Pos2, down: bool, m: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: b, pressed: down, modifiers: m }); h.run_steps(1); } -/// A drag between document points with `m` held from before the press until after the -/// release; `from == to` is a click. -fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m: Modifiers) { +/// Press `b` at document point `from` with `m` held from before the press, and move to `to`; +/// the button stays down. +fn press_and_move(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, from: [f32; 2], to: [f32; 2], m: Modifiers) { let p = screen(h, from[0], from[1]); mods(h, m); h.event(egui::Event::PointerMoved(p)); h.run_steps(1); - button(h, p, true, m); + button(h, b, p, true, m); if from != to { for i in 1..=8 { let t = i as f32 / 8.0; @@ -76,11 +76,22 @@ fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m h.run_steps(1); } } - button(h, screen(h, to[0], to[1]), false, m); +} + +/// Release `b` at document point `at`, then let go of the modifiers. +fn release(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, at: [f32; 2], m: Modifiers) { + button(h, b, screen(h, at[0], at[1]), false, m); h.run_steps(2); mods(h, Modifiers::NONE); } +/// A drag between document points with `m` held from before the press until after the +/// release; `from == to` is a click. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m: Modifiers) { + press_and_move(h, PointerButton::Primary, from, to, m); + release(h, PointerButton::Primary, to, m); +} + /// Red on the left, green on the right of an unlocked layer, Background Eraser active, blue /// foreground and white background colour. fn background_eraser() -> Harness<'static, PhotocraftApp> { @@ -127,7 +138,9 @@ fn alt_click_sets_the_foreground_without_erasing() { fn alt_drag_follows_the_pointer_without_erasing() { let mut h = background_eraser(); let before = snapshot(&h); - drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + press_and_move(&mut h, PointerButton::Primary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag samples while the button is held"); + release(&mut h, PointerButton::Primary, [40.0, 50.0], Modifiers::ALT); assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag ends on red"); unchanged(&h, &before, "⌥-drag"); } @@ -140,6 +153,9 @@ fn a_click_without_alt_still_erases() { drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::NONE); assert_eq!(h.state().session.tools.foreground, BLUE); assert_eq!(snapshot(&h).1, steps + 1, "the stroke added a history state"); + let st = h.state().session.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert_eq!(layer.rgba(150, 50)[3], 0.0, "the clicked pixel was erased"); } /// While ⌥ is held the cursor is the Eyedropper's pipette (`pipette_cursor`), where the @@ -168,8 +184,24 @@ fn ctrl_alt_drag_resizes_instead_of_sampling() { assert!(crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT)); assert!(!crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT | Modifiers::CTRL)); let mut h = background_eraser(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap(); let before = snapshot(&h); drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT | Modifiers::CTRL); assert_eq!(h.state().session.tools.foreground, BLUE, "Control+⌥ sampled nothing"); unchanged(&h, &before, "Control+⌥-drag"); + assert_ne!(h.state().session.tools.brush.size, 40.0, "Control+⌥-drag resized the brush"); +} + +/// ⌥+right-drag (not measured in the reference application) keeps today's meaning, the brush +/// resize: it samples nothing and erases nothing. +#[test] +fn alt_right_drag_resizes_instead_of_sampling() { + let mut h = background_eraser(); + h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap(); + let before = snapshot(&h); + press_and_move(&mut h, PointerButton::Secondary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT); + release(&mut h, PointerButton::Secondary, [40.0, 50.0], Modifiers::ALT); + assert_eq!(h.state().session.tools.foreground, BLUE, "⌥+right-drag sampled nothing"); + unchanged(&h, &before, "⌥+right-drag"); + assert_ne!(h.state().session.tools.brush.size, 40.0, "⌥+right-drag resized the brush"); }