From 25c38a0b69b7bff31badcdbe2ef9f2ff6aba1c63 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 05:52:40 +0200 Subject: [PATCH] Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop (fixes #6434) --- crates/ui-egui/src/hold_keys.rs | 25 +++++++++++- crates/ui-egui/src/hold_keys/tests.rs | 56 ++++++++++++++++++++++++++- docs/ui-parity.md | 5 ++- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index 3f2845cae8..1da497021e 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -16,6 +16,8 @@ //! selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the //! selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path //! Selection, shape, Pen and Type tools keep ⌘ for themselves. +//! - With the Hand tool, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ (Alt) Zoom Out, ⌘⌥ too +//! ([`hand_zoom_held`], #6434). //! - `~` (the backtick key) with the Brush or Pencil: a stroke started while it is held erases, //! with that tool's own brush ([`erase_held`], #3439). //! @@ -118,6 +120,23 @@ fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool { i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none() } +/// The Hand tool's own temporary zoom (#6434): ⌘ held is Zoom In, ⌥ (with or without ⌘) Zoom Out, +/// as in Photoshop. Not while a Free Transform keeps ⌘ for its handles, and not from the middle of +/// a Hand drag: a modifier pressed while panning must not stop the pan. +fn hand_zoom_held(app: &PhotocraftApp, i: &InputState) -> Option { + let panning = i.pointer.primary_down() && !i.pointer.primary_pressed(); + if app.ui.tool != Tool::Hand || app.ui.transform.is_some() || panning { + return None; + } + if i.modifiers.alt { + Some(Temporary::ZoomOut) + } else if i.modifiers.command { + Some(Temporary::ZoomIn) + } else { + None + } +} + /// Is `id` a held temporary tool rather than a command? pub fn is_temporary(id: &str) -> bool { photocraft_engine::prefs::TEMPORARY_TOOLS.iter().any(|t| t.0 == id) @@ -159,6 +178,7 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option }; let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None; let mut cmd_move = false; + let mut hand_zoom = None; let mut alt = false; ctx.input(|i| { for t in Temporary::BOUND { @@ -170,13 +190,14 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option } } cmd_move = cmd_move_held(app, i); + hand_zoom = hand_zoom_held(app, i); alt = i.modifiers.alt; }); // ⌥ turns a held Zoom In around, as it does the Zoom tool: ⌘⌥Space zooms out (and wins the // tie with ⌥Space), unless ⌥ is part of the Zoom In key itself. let held = best.map(|(_, t, sc)| if t == Temporary::ZoomIn && alt && !sc.modifiers.alt { Temporary::ZoomOut } else { t }); - // A bound key (⌘Space is Zoom In) wins over ⌘ alone. - held.or(cmd_move.then_some(Temporary::Move)) + // A bound key (⌘Space is Zoom In) wins over ⌘ or ⌥ alone. + held.or(cmd_move.then_some(Temporary::Move)).or(hand_zoom) } /// The binding id of the Brush and Pencil erase key (`prefs::TEMPORARY_TOOLS`). diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index cfe89d4642..9e3dbb6041 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -643,13 +643,67 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() { let mut h = harness(tool); h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND))); h.run_steps(1); - assert_eq!(held_tool(h.state(), &h.ctx), None, "{tool:?}"); + // The Hand's ⌘ is Zoom In (#6434), never the Move tool. + assert_eq!(held_tool(h.state(), &h.ctx), (tool == Tool::Hand).then_some(Temporary::ZoomIn), "{tool:?}"); } for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::CloneStamp, Tool::Eyedropper, Tool::MagneticLasso, Tool::QuickSelection] { assert!(cmd_moves(tool), "{tool:?}"); } } +/// With the Hand tool, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ Zoom Out, as in Photoshop; the +/// Hand is back when the key comes up (#6434). +#[test] +fn cmd_and_alt_with_the_hand_tool_zoom_in_and_out_while_held() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + hold_mods(&mut h, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + let z2 = h.state().ui.views[0].zoom; + assert!(z2 < z1, "{z1} -> {z2}"); + hold_mods(&mut h, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut), "⌥ turns ⌘'s Zoom In around"); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + // Released: a drag pans again. + let (center, zoom) = (h.state().ui.views[0].center, h.state().ui.views[0].zoom); + drag_with(&mut h, Modifiers::NONE, 200.0, 150.0, 260.0, 190.0); + assert_ne!(h.state().ui.views[0].center, center, "the Hand pans"); + assert_eq!(h.state().ui.views[0].zoom, zoom); + assert_eq!(h.state().ui.tool, Tool::Hand); +} + +/// ⌘ pressed in the middle of a Hand drag doesn't stop the pan, and an open Free Transform keeps ⌘ +/// for its handles. +#[test] +fn cmd_mid_pan_or_during_free_transform_is_not_the_hand_tools_zoom() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + press_at(&mut h, 200.0, 150.0); + hold_mods(&mut h, Modifiers::COMMAND); + let center = h.state().ui.views[0].center; + move_to(&mut h, 300.0, 150.0); + assert_ne!(h.state().ui.views[0].center, center, "the drag kept panning after ⌘ went down"); + release(&mut h, 300.0, 150.0, Modifiers::COMMAND); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(h.state().ui.views[0].zoom, z0, "no zoom"); + + with_layer(&mut h); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.transform.is_some()); + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), None); + hold_mods(&mut h, Modifiers::NONE); +} + /// The params of the last brush stroke (`paint.stroke`, or the Pencil's `paint.pencil`). fn last_stroke(app: &PhotocraftApp) -> serde_json::Value { let journal = &app.session.journal; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..d9b517ef37 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Hand tool ⌘ / ⌥ zoom in and out while held, #6434; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -166,7 +166,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| -| Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | +| Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held; with the Hand tool ⌘ zooms in and ⌥ out while held (#6434) | done | `hold_keys.rs` | | Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | @@ -257,3 +257,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 2026-10-16 | minor | Hand tool: ⌘ held zooms in and ⌥ zooms out, as in Photoshop (#6434) |