From 95d79dcd83b855fac2cb716bf0f99ba04283a849 Mon Sep 17 00:00:00 2001 From: RickiNano <81099017+RickiNano@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:18:57 +0100 Subject: [PATCH] Zoom tool: Zoom In / Zoom Out mode buttons (#12066) The Zoom bar starts with a Zoom In / Zoom Out pair (Zoom In by default), kept as the `zoom_in` tool option. In Zoom Out mode a click steps out and Alt+click steps in, the cursor is the "-" magnifier ("+" with Alt), and a drag with Scrubby Zoom off steps out once at the press point with no zoom rectangle, as Alt+drag does in Zoom In mode. Reset Tool leaves the mode; temporary zoom keys still decide their own direction. The zoom-out glyph is Lucide's, next to the zoom-in one already vendored. --- ATTRIBUTION.md | 4 +- assets/icons/zoom-out.svg | 15 + crates/ui-egui/src/canvas.rs | 12 +- crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/panels/options_bar.rs | 7 + crates/ui-egui/src/state/tool_options.rs | 5 + crates/ui-egui/src/tools/tool_reset.rs | 14 +- crates/ui-egui/src/tools/zoom_tool.rs | 40 ++- .../tools/zoom_tool/zoom_out_mode_tests.rs | 262 ++++++++++++++++++ 9 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 assets/icons/zoom-out.svg create mode 100644 crates/ui-egui/src/tools/zoom_tool/zoom_out_mode_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 423ee458e6..391f851335 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -42,8 +42,8 @@ next to them. | `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | -| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (117 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in,zoom-out}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/zoom-out.svg b/assets/icons/zoom-out.svg new file mode 100644 index 0000000000..70abec44b4 --- /dev/null +++ b/assets/icons/zoom-out.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a954865efc..2d6ac3ae89 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -872,7 +872,7 @@ fn handle_dialog_canvas_input(app: &mut PhotocraftApp, input: DialogCanvasInput< } let b = crate::dialogs::free_buttons(input.ctx, input.rect); let pointer = input.ctx.input(|i| i.pointer.latest_pos()); - crate::zoom_tool::drag(app, input.ctx, input.view, input.xf, &b, pointer); + crate::zoom_tool::drag(app, input.ctx, input.view, input.xf, &b, pointer, &zoom_out); if b.clicked && let Some(p) = pointer { @@ -1197,7 +1197,7 @@ fn handle_canvas_tool_navigation( } // Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below. // Like the Hand, the selected Zoom yields to an open Free Transform box (#4799). - if tool == Tool::Zoom && !transform_owns_pointer && crate::zoom_tool::drag(app, ctx, view, xf, buttons, response.interact_pointer_pos()) { + if tool == Tool::Zoom && !transform_owns_pointer && crate::zoom_tool::drag(app, ctx, view, xf, buttons, response.interact_pointer_pos(), zoom_out) { (buttons.started, buttons.dragged, buttons.stopped) = (false, false, false); } } @@ -2325,13 +2325,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: app.tool_override = (temporary == Some(crate::hold_keys::Temporary::Move)).then_some(Tool::Move); // `~` held: a Brush or Pencil stroke that starts now erases (`tool_event`'s `Down`). app.held_erase = crate::hold_keys::erase_held(app, &ctx); - // Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool), held or latched in - // Window › Modifier Keys (the way around a desktop that takes Alt+click, `alt_grab`). + // Zoom direction: the temporary zoom key decides, else the Zoom tool's Zoom In / Zoom Out + // mode, turned around by ⌥ held or latched in Window › Modifier Keys (the way around a + // desktop that takes Alt+click, `alt_grab`). let sticky_alt = app.ui.shell.sticky_alt; + let out_mode = !app.ui.tool_options.zoom_in; let zoom_out = |alt: bool| match temporary { Some(crate::hold_keys::Temporary::ZoomOut) => true, Some(crate::hold_keys::Temporary::ZoomIn) => false, - _ => alt || sticky_alt, + _ => (alt || sticky_alt) != out_mode, }; if under_dialog { diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index c29e2192fa..feaa9987bc 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -191,4 +191,5 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), ("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")), + ("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")), ]; diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 948e608fdb..b08232c966 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -767,6 +767,13 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens, } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), Tool::Zoom => { + // Zoom In / Zoom Out: a pair, exactly one pressed; Zoom Out makes a click step out. + for (zoom_in, icon, tip) in [(true, "zoom-in", tl!("Zoom In")), (false, "zoom-out", tl!("Zoom Out"))] { + if icons::button(ui, icon, 24.0, app.ui.tool_options.zoom_in == zoom_in, tip).clicked() { + app.ui.tool_options.zoom_in = zoom_in; + } + } + widgets::vline(ui, 22.0); // Resize Windows to Fit is Preferences › Zoom Resizes Windows itself, as in // Photoshop: one setting, shown in both places (zoom_window_fit.rs). let mut fit = app.session.prefs().general.zoom_resizes_windows; diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index 5290558d61..e054c641d3 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -242,6 +242,10 @@ pub struct ToolOptions { /// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle). #[serde(default = "yes")] pub zoom_scrubby: bool, + /// Zoom tool › Zoom In / Zoom Out: which way a click steps (⌥ turns it around). Reset Tool + /// leaves it, as in Photoshop. + #[serde(default = "yes")] + pub zoom_in: bool, /// Hand tool › Scroll All Windows: a Hand drag pans every shown document view, not only the /// one dragged in (`scroll_all_windows.rs`). Shift held while dragging does the same. #[serde(default)] @@ -545,6 +549,7 @@ impl Default for ToolOptions { cr_tolerance: 30.0, cr_anti_alias: true, zoom_scrubby: true, + zoom_in: true, hand_scroll_all: false, pencil_auto_erase: false, brush_clear: false, diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index fa746e9cf8..557984f608 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -132,7 +132,11 @@ fn is_selection_tool(tool: Tool) -> bool { ) } -/// Put `tool`'s fields of `o` back to their defaults. +/// Options a tool preset stores but Reset Tool leaves, as in Photoshop: the Zoom tool keeps Zoom +/// Out selected (#12066). +const KEPT_BY_RESET: &[&str] = &["zoom_in"]; + +/// Put `tool`'s fields of `o` back to their defaults, except those [`KEPT_BY_RESET`]. pub fn reset_options(o: &mut ToolOptions, tool: Tool) { let keys = option_keys(tool); if keys.is_empty() { @@ -141,7 +145,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { let (Ok(Value::Object(mut cur)), Ok(Value::Object(d))) = (serde_json::to_value(&*o), serde_json::to_value(ToolOptions::default())) else { return; }; - for k in keys { + for k in keys.iter().filter(|k| !KEPT_BY_RESET.contains(k)) { if let Some(v) = d.get(*k) { cur.insert((*k).to_string(), v.clone()); } @@ -167,8 +171,8 @@ pub fn sample_all_key(tool: Tool) -> Option { m.into_iter().find(|(k, v)| b.get(k) != Some(v)).map(|(k, _)| k) } -/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and -/// what a tool preset made with + stores (#7356). +/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets (but +/// for [`KEPT_BY_RESET`]) and what a tool preset made with + stores (#7356). pub fn option_keys(tool: Tool) -> &'static [&'static str] { // `reset!` names each field once; one that is misspelt or gone fails to compile. macro_rules! reset { @@ -226,7 +230,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => { reset!(type_font, type_style, type_size, type_aa, type_align, type_direction, type_style_defaults) } - Tool::Zoom => reset!(zoom_scrubby), + Tool::Zoom => reset!(zoom_scrubby, zoom_in), Tool::Hand => reset!(hand_scroll_all), Tool::SpotHealing => reset!(spot_type), Tool::Healing => reset!(clone_aligned, clone_sample), diff --git a/crates/ui-egui/src/tools/zoom_tool.rs b/crates/ui-egui/src/tools/zoom_tool.rs index 553e9ad781..e2b86f3b1b 100644 --- a/crates/ui-egui/src/tools/zoom_tool.rs +++ b/crates/ui-egui/src/tools/zoom_tool.rs @@ -2,8 +2,9 @@ //! //! With Scrubby Zoom on (the default, as in Photoshop) press-and-drag zooms continuously: right //! zooms in, left zooms out, anchored at the point where the button went down. With it off, the -//! drag draws a rectangle and the release zooms to fit it. A click (no drag) still steps the zoom -//! in, or out with ⌥ (handled by the canvas). +//! drag draws a rectangle and the release zooms to fit it, unless it zooms out (Zoom Out mode, or +//! ⌥ in Zoom In mode): then the release steps out once at the press point. A click (no drag) +//! steps the zoom in, or out in Zoom Out mode, ⌥ turning either around (handled by the canvas). use egui::{Pos2, Rect, vec2}; @@ -25,6 +26,8 @@ struct ZoomDrag { zoom0: f32, /// Scrubby Zoom off: a zoom rectangle, its current corner. rect_to: Option, + /// The release steps out (Zoom Out mode, or ⌥ in Zoom In mode): no rectangle is shown. + out: bool, } fn id() -> egui::Id { @@ -51,18 +54,29 @@ pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], } /// Route the canvas buttons for the Zoom tool. Returns true when it consumed the drag (the -/// canvas then handles only the click). -pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, pointer: Option) -> bool { +/// canvas then handles only the click). `zoom_out` is the canvas's direction for ⌥ held or not +/// (temporary zoom key, Zoom In / Zoom Out mode, latched ⌥). +pub fn drag( + app: &PhotocraftApp, + ctx: &egui::Context, + view: &mut View, + xf: &ViewXform, + b: &Buttons, + pointer: Option, + zoom_out: &dyn Fn(bool) -> bool, +) -> bool { let scrubby = app.ui.tool_options.zoom_scrubby; + let out = zoom_out(ctx.input(|i| i.modifiers.alt)); if b.started { let anchor = ctx.input(|i| i.pointer.press_origin()).filter(|p| xf.rect.contains(*p)).or(pointer); if let Some(anchor) = anchor { - let z = ZoomDrag { anchor, doc: xf.to_doc(anchor), zoom0: view.zoom, rect_to: (!scrubby).then_some(anchor) }; + let z = ZoomDrag { anchor, doc: xf.to_doc(anchor), zoom0: view.zoom, rect_to: (!scrubby).then_some(anchor), out }; ctx.data_mut(|d| d.insert_temp(id(), z)); } } let Some(mut z) = ctx.data(|d| d.get_temp::(id())) else { return b.started || b.dragged || b.stopped }; if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { + z.out = out; match z.rect_to.as_mut() { Some(to) => *to = p, None => { @@ -79,7 +93,6 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View if b.stopped { ctx.data_mut(|d| d.remove::(id())); if let Some(to) = z.rect_to { - let out = crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).alt; zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, out, app.ppp); } } @@ -87,7 +100,7 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View } /// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom -/// at the press point; ⌥ steps out). +/// at the press point; `out` steps out there). fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool, ppp: f32) { if out || r.width() < 4.0 || r.height() < 4.0 { let doc = xf.to_doc(anchor); @@ -103,12 +116,19 @@ fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: boo /// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants. pub fn draw(ctx: &egui::Context, painter: &egui::Painter) { - let Some(z) = ctx.data(|d| d.get_temp::(id())) else { return }; - let Some(to) = z.rect_to else { return }; - let r = Rect::from_two_pos(z.anchor, to); + let Some(r) = shown_rect(ctx) else { return }; let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5)); crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true); } +/// The zoom rectangle on screen, if any: none for a drag that steps out at its release. +fn shown_rect(ctx: &egui::Context) -> Option { + let z = ctx.data(|d| d.get_temp::(id()))?; + let to = z.rect_to.filter(|_| !z.out)?; + Some(Rect::from_two_pos(z.anchor, to)) +} + #[cfg(test)] mod tests; +#[cfg(test)] +mod zoom_out_mode_tests; diff --git a/crates/ui-egui/src/tools/zoom_tool/zoom_out_mode_tests.rs b/crates/ui-egui/src/tools/zoom_tool/zoom_out_mode_tests.rs new file mode 100644 index 0000000000..60b4ea789b --- /dev/null +++ b/crates/ui-egui/src/tools/zoom_tool/zoom_out_mode_tests.rs @@ -0,0 +1,262 @@ +//! Zoom tool › Zoom In / Zoom Out (#12066): a pair of toggles on the options bar. In Zoom Out mode +//! a click steps out and ⌥+click steps in, the cursor is the "-" magnifier ("+" with ⌥), and a +//! drag with Scrubby Zoom off steps out once at the press point, as ⌥+drag does in Zoom In mode. + +use super::*; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::state::{Tool, ToolOptions}; + +const DOC: [u32; 2] = [400, 300]; + +fn harness(tool: Tool, zoom_in: bool, scrubby: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": DOC[0], "height": DOC[1]})).unwrap(); + // No animation, the clicked point stays under the pointer, and the view is never clamped: + // every step lands on the first frame where the tests can compare it. + app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + app.ui.tool_options.zoom_in = zoom_in; + app.ui.tool_options.zoom_scrubby = scrubby; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.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 = [200.0, 150.0]; + v.fit_pending = false; + v.fill_pending = false; + h.run_steps(2); + h +} + +fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + ViewXform::active(h.state()).unwrap() +} + +/// The platform's modifiers for `Cmd` (⌘ on the Mac, Ctrl elsewhere), as egui-winit reports them. +fn platform(m: Modifiers) -> Modifiers { + let mut out = Modifiers { alt: m.alt, shift: m.shift, ctrl: m.ctrl, ..Default::default() }; + if m.command { + out.command = true; + if cfg!(target_os = "macos") { + out.mac_cmd = true; + } else { + out.ctrl = true; + } + } + out +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, m: Modifiers) { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: platform(m) }); + h.step(); +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, m: Modifiers) { + h.event(Event::ModifiersChanged(platform(m))); + h.event(Event::PointerMoved(p)); + h.step(); + button(h, p, true, m); + button(h, p, false, m); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// The view after one click at `p` with `m`, from zoom 1 centred on the document. +fn view_after_click(zoom_in: bool, m: Modifiers) -> (f32, [f32; 2]) { + let mut h = harness(Tool::Zoom, zoom_in, true); + let p = h.state().last_canvas_rect.center() + vec2(90.0, -45.0); + click(&mut h, p, m); + let v = &h.state().ui.views[0]; + (v.zoom, v.center) +} + +#[test] +fn zoom_out_mode_click_steps_out_and_alt_click_steps_in() { + let mut h = harness(Tool::Zoom, false, true); + let p = h.state().last_canvas_rect.center() + vec2(90.0, -45.0); + let doc = xf(&h).to_doc(p); + + click(&mut h, p, Modifiers::NONE); + let out = crate::zoom_levels::step(1.0, -1, DOC); + assert!(out < 1.0); + assert_eq!(h.state().current_zoom(), out, "a click steps one stop out"); + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5, "about the clicked point"); + + click(&mut h, p, Modifiers::ALT); + assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(out, 1, DOC), "⌥+click steps one stop in"); + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5); + + assert_eq!(h.state().session.documents()[0].history.past_len(), 0, "zooming adds no history state"); +} + +#[test] +fn zoom_out_mode_click_is_zoom_in_mode_alt_click_and_the_reverse() { + assert_eq!(view_after_click(false, Modifiers::NONE), view_after_click(true, Modifiers::ALT)); + assert_eq!(view_after_click(false, Modifiers::ALT), view_after_click(true, Modifiers::NONE)); + // Zoom In mode keeps today's click: in, and out with ⌥. + assert_eq!(view_after_click(true, Modifiers::NONE).0, crate::zoom_levels::step(1.0, 1, DOC)); + assert_eq!(view_after_click(true, Modifiers::ALT).0, crate::zoom_levels::step(1.0, -1, DOC)); +} + +/// A cursor as the platform gets it: the icon, and the bitmap with its size where one is drawn. +type Cursor = (egui::CursorIcon, Option<([u16; 2], std::sync::Arc<[u8]>)>); + +/// The canvas cursor over the image with ⌥ held or not. +fn cursor(h: &mut Harness<'static, PhotocraftApp>, alt: bool) -> Cursor { + let p = h.state().last_canvas_rect.center() + vec2(alt as u8 as f32, 0.0); + h.event(Event::ModifiersChanged(if alt { Modifiers::ALT } else { Modifiers::NONE })); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + let o = &h.output().platform_output; + (o.cursor_icon, o.cursor_image.clone().map(|i| (i.size, i.rgba))) +} + +#[test] +fn zoom_out_mode_cursor_is_the_minus_magnifier_and_plus_with_alt() { + let mut zoom_in = harness(Tool::Zoom, true, true); + let (plus, minus) = (cursor(&mut zoom_in, false), cursor(&mut zoom_in, true)); + assert_ne!(plus, minus, "the two magnifiers differ"); + let mut zoom_out = harness(Tool::Zoom, false, true); + assert_eq!(cursor(&mut zoom_out, false), minus, "Zoom Out mode shows \"-\""); + assert_eq!(cursor(&mut zoom_out, true), plus, "\"+\" while ⌥ is held"); +} + +/// Scrubby Zoom off: a drag from a point to 120 x 80 points away, with `m` held. Returns the +/// view at the release, and whether a zoom rectangle was on screen while dragging. +fn rect_drag(zoom_in: bool, m: Modifiers) -> ((f32, [f32; 2]), bool) { + let mut h = harness(Tool::Zoom, zoom_in, false); + let a = h.state().last_canvas_rect.center() + vec2(-80.0, -50.0); + let doc = xf(&h).to_doc(a); + h.event(Event::ModifiersChanged(platform(m))); + h.event(Event::PointerMoved(a)); + h.step(); + button(&mut h, a, true, m); + let mut shown = false; + for t in [0.25, 0.5, 1.0] { + h.event(Event::PointerMoved(a + vec2(120.0, 80.0) * t)); + h.step(); + shown |= shown_rect(&h.ctx).is_some(); + assert_eq!(h.state().current_zoom(), 1.0, "no zoom while dragging"); + } + button(&mut h, a + vec2(120.0, 80.0), false, m); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + if !zoom_in || m.alt { + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(a) < 0.5, "anchored at the press point"); + } + let v = &h.state().ui.views[0]; + ((v.zoom, v.center), shown) +} + +#[test] +fn zoom_out_mode_drag_without_scrubby_zoom_steps_out_once_like_alt_drag() { + let (out_mode, out_shown) = rect_drag(false, Modifiers::NONE); + let (alt_drag, alt_shown) = rect_drag(true, Modifiers::ALT); + assert_eq!(out_mode.0, crate::zoom_levels::step(1.0, -1, DOC), "one stop out"); + assert_eq!(out_mode, alt_drag, "the same view as ⌥+drag in Zoom In mode"); + assert!(!out_shown && !alt_shown, "no zoom rectangle for a drag that steps out"); + // Zoom In mode keeps its zoom rectangle. + let (rect, rect_shown) = rect_drag(true, Modifiers::NONE); + assert!(rect_shown); + assert!(rect.0 > 1.0, "the rectangle zooms in: {}", rect.0); +} + +#[test] +fn temporary_zoom_keys_ignore_the_zoom_out_mode() { + let mut h = harness(Tool::Brush, false, true); + let p = h.state().last_canvas_rect.center(); + let key = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool, m: Modifiers| { + let m = platform(m); + h.event(Event::ModifiersChanged(m)); + h.event(Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: m }); + h.step(); + }; + key(&mut h, true, Modifiers::COMMAND); + click(&mut h, p, Modifiers::COMMAND); + key(&mut h, false, Modifiers::COMMAND); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let z = h.state().current_zoom(); + assert_eq!(z, crate::zoom_levels::step(1.0, 1, DOC), "⌘Space still zooms in"); + key(&mut h, true, Modifiers::COMMAND | Modifiers::ALT); + click(&mut h, p, Modifiers::COMMAND | Modifiers::ALT); + key(&mut h, false, Modifiers::COMMAND | Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(z, -1, DOC), "⌘⌥Space still zooms out"); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert!(!h.state().ui.tool_options.zoom_in); +} + +fn options_bar() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Zoom; + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), crate::theme::ThemeKind::ALL[0]); + ready = true; + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn left(h: &Harness<'static, PhotocraftApp>, label: &str) -> f64 { + h.get_by_label(label).accesskit_node().bounding_box().unwrap_or_else(|| panic!("{label} is laid out")).x0 +} + +#[test] +fn options_bar_has_the_zoom_in_and_zoom_out_pair_before_the_checkboxes() { + let mut h = options_bar(); + assert!(h.state().ui.tool_options.zoom_in, "Zoom In is pressed by default"); + let (zin, zout, fit) = (left(&h, "Zoom In"), left(&h, "Zoom Out"), left(&h, "Resize Windows to Fit")); + assert!(zin < zout && zout < fit, "Zoom In {zin}, Zoom Out {zout}, Resize Windows to Fit {fit}"); + + // Exactly one is pressed: clicking either selects it, clicking it again keeps it. + for (label, zoom_in) in [("Zoom Out", false), ("Zoom Out", false), ("Zoom In", true), ("Zoom In", true), ("Zoom Out", false)] { + h.get_by_label(label).click(); + h.run_steps(2); + assert_eq!(h.state().ui.tool_options.zoom_in, zoom_in, "after clicking {label}"); + } +} + +#[test] +fn zoom_mode_survives_reset_tool_and_defaults_to_zoom_in() { + let mut o = ToolOptions { zoom_in: false, zoom_scrubby: false, ..ToolOptions::default() }; + crate::tool_reset::reset_options(&mut o, Tool::Zoom); + assert!(o.zoom_scrubby, "Reset Tool resets Scrubby Zoom"); + assert!(!o.zoom_in, "but leaves Zoom Out selected"); + + assert!(ToolOptions::default().zoom_in); + let mut saved = serde_json::to_value(ToolOptions { zoom_in: false, ..ToolOptions::default() }).unwrap(); + assert!(!serde_json::from_value::(saved.clone()).unwrap().zoom_in, "the mode is saved"); + // Options saved before the mode existed load as Zoom In; a bad value is an error, not a panic. + saved.as_object_mut().unwrap().remove("zoom_in"); + assert!(serde_json::from_value::(saved.clone()).unwrap().zoom_in); + saved["zoom_in"] = json!("out"); + assert!(serde_json::from_value::(saved).is_err()); +}