feat(ui): hold Alt+Space to zoom out, as in Photoshop (#2877)

* feat(ui): Alt turns a held temporary Zoom In into Zoom Out

Alt reverses the Zoom tool's direction, but a held Zoom In (Cmd+Space)
ignored it: with Zoom Out unbound or rebound, Cmd+Alt+Space zoomed in.
held_tool now reports Zoom Out whenever Zoom In is the held binding and
Alt is down, unless Alt is part of the Zoom In key itself (a rebound
Alt+Z still zooms in). The default keys don't change here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): hold Alt+Space to zoom out, as in Photoshop

Photoshop zooms out with Alt+Space (Option+Space on the Mac), usually
pressed as Space first, then Alt, then click. PhotoCraft's Zoom Out
(hold) default was Cmd+Alt+Space only, so Space then Alt still panned.

Zoom Out (hold) now defaults to Alt+Space. Holding Cmd+Alt+Space
matches both Cmd+Space and Alt+Space equally; the previous commit's
Alt rule resolves that to Zoom Out, so it keeps zooming out. Saved
overrides are kept. Holding Space first also avoids the Mac's Spotlight
shortcut and the Alt+Space window menu on many Linux desktops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(contributors): credit FinnNk by GitHub profile name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FinnNkandClaude Opus 5.5 authored and GitHub committed 2026-10-10 18:56:04 -04:00
1 parent 6911075037
commit 0c4016a4db
6 files changed
+97 -12

No files matched your search

+3
View File
@@ -90,3 +90,6 @@ sync_github_name = false
[people.vangel76]
sync_github_name = true
[people.FinnNk]
sync_github_name = true
+1 -1
View File
@@ -1364,7 +1364,7 @@ fn capitalize(s: &str) -> String {
pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
("tools.temporary.hand", "Hand Tool (hold)", "Space"),
("tools.temporary.zoomIn", "Zoom In (hold, drag to scrub)", "Cmd+Space"),
("tools.temporary.zoomOut", "Zoom Out (hold)", "Cmd+Alt+Space"),
("tools.temporary.zoomOut", "Zoom Out (hold)", "Alt+Space"),
];
/// Every bindable id with its default: commands, then the temporary tools.
+1 -1
View File
@@ -2633,7 +2633,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// A Magnetic Lasso border left behind by a tool or document switch is dropped.
crate::magnetic_lasso_ui::frame(app);
// Held keys (hold_keys.rs): Space repositions a crop frame, marquee, lasso or shape being
// drawn; otherwise Space is the Hand and ⌘Space / ⌘⌥Space the Zoom tool while held.
// drawn; otherwise Space is the Hand and ⌘Space / ⌥Space the Zoom tool while held.
let reposition = crate::hold_keys::reposition_held(app, &ctx);
crate::crop_ui::set_space(app, reposition);
// ⌘ (Ctrl) held: a temporary Straighten with the Crop tool.
+16 -7
View File
@@ -2,7 +2,11 @@
//!
//! - Space: the Hand tool while held.
//! - ⌘Space (Ctrl+Space off the Mac): Zoom In while held; a drag is a scrubby zoom.
//! - ⌘⌥Space: Zoom Out while held.
//! - ⌥Space (Alt+Space off the Mac): Zoom Out while held. ⌥ added to a held Zoom In (⌘⌥Space)
//! zooms out too, as ⌥ does with the Zoom tool.
//! - The keys can be pressed in any order. Photoshop users usually hold Space first: on the Mac
//! that keeps ⌘Space from opening Spotlight, and many Linux desktops take ⌥Space for the window
//! menu when ⌥ comes first.
//! - Space while a marquee, lasso or shape is being dragged repositions it; releasing Space
//! goes back to sizing it (the Crop tool does the same for its frame, `crop_ui`).
//! - ⌘ (Ctrl off the Mac): the Move tool while held, with the painting, retouching, eraser,
@@ -129,8 +133,8 @@ fn held(i: &InputState, sc: &KeyboardShortcut) -> bool {
&& (!want.mac_cmd || m.mac_cmd)
}
/// The temporary tool whose key is held now, the most specific binding first (⌘⌥Space before
/// ⌘Space before Space). Never while a text field has the keyboard.
/// The temporary tool whose key is held now, the most specific binding first (⌘Space before
/// Space). Never while a text field has the keyboard.
pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary> {
if ctx.text_edit_focused() {
return None;
@@ -139,21 +143,26 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
let m = sc.modifiers;
m.alt as u8 + m.shift as u8 + (m.command || m.mac_cmd) as u8 + (m.ctrl && !m.command) as u8
};
let mut best: Option<(u8, Temporary)> = None;
let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None;
let mut cmd_move = false;
let mut alt = false;
ctx.input(|i| {
for t in Temporary::BOUND {
if let Some(sc) = binding(app, t)
&& held(i, &sc)
&& best.is_none_or(|(n, _)| count(&sc) > n)
&& best.is_none_or(|(n, ..)| count(&sc) > n)
{
best = Some((count(&sc), t));
best = Some((count(&sc), t, sc));
}
}
cmd_move = cmd_move_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.
best.map(|(_, t)| t).or(cmd_move.then_some(Temporary::Move))
held.or(cmd_move.then_some(Temporary::Move))
}
/// Is the reposition key (the Hand key, any modifiers) down?
+75 -2
View File
@@ -1,5 +1,5 @@
//! #249 through the real canvas and key handling: Space repositions a marquee being drawn, Space
//! and ⌘Space / ⌘⌥Space are temporary Hand / Zoom tools that give the tool back, D / X and the
//! and ⌘Space / ⌥Space are temporary Hand / Zoom tools that give the tool back, D / X and the
//! fill keys are commands whose Keyboard Shortcuts overrides replace the defaults.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
@@ -218,6 +218,79 @@ fn temporary_zoom_drag_is_a_scrubby_zoom() {
assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted");
}
/// Changes only the held modifiers, as pressing or releasing ⌥ / ⌘ does with Space already down.
fn hold_mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) {
h.event(Event::ModifiersChanged(platform(m)));
h.run_steps(1);
}
#[test]
fn space_then_alt_zooms_out_as_in_photoshop() {
// Photoshop users hold Space first, then add ⌥ and click.
let mut h = harness(Tool::Brush);
h.state_mut().run("tools.setColors", json!({"foreground": "#ff0000", "background": "#00ff00"})).unwrap();
let colors = fg_bg(&h);
let z0 = h.state().ui.views[0].zoom;
key(&mut h, Key::Space, true, Modifiers::NONE);
assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Hand));
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 z1 = h.state().ui.views[0].zoom;
assert!(z1 < z0, "{z0} -> {z1}");
// Letting go of ⌥ with Space still down is the Hand again.
hold_mods(&mut h, Modifiers::NONE);
assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::Hand));
key(&mut h, Key::Space, false, Modifiers::NONE);
h.run_steps(2);
assert_eq!(held_tool(h.state(), &h.ctx), None);
assert_eq!(h.state().ui.tool, Tool::Brush, "the brush is back");
assert_eq!(fg_bg(&h), colors, "the ⌥-click didn't reach the brush's eyedropper");
assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted");
}
#[test]
fn space_then_cmd_zooms_in_and_adding_alt_zooms_out() {
let mut h = harness(Tool::RectMarquee);
let z0 = h.state().ui.views[0].zoom;
key(&mut h, Key::Space, true, Modifiers::NONE);
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::COMMAND | Modifiers::ALT);
assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut));
click(&mut h, 200.0, 150.0, Modifiers::COMMAND | Modifiers::ALT);
assert!(h.state().ui.views[0].zoom < z1);
hold_mods(&mut h, Modifiers::NONE);
key(&mut h, Key::Space, false, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().ui.tool, Tool::RectMarquee);
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "no click reached the marquee");
}
#[test]
fn alt_turns_a_held_zoom_in_around_unless_the_key_includes_alt() {
let mut h = harness(Tool::Brush);
// With Zoom Out unbound, ⌘⌥Space still zooms out: ⌥ turns the held Zoom In around, as it
// does the Zoom tool.
h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomOut": ""}})).unwrap();
key(&mut h, Key::Space, true, Modifiers::COMMAND | Modifiers::ALT);
assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut));
key(&mut h, Key::Space, false, Modifiers::COMMAND | Modifiers::ALT);
hold_mods(&mut h, Modifiers::NONE);
// An ⌥ that is part of the Zoom In key doesn't.
h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomIn": "Alt+Z"}})).unwrap();
let z0 = h.state().ui.views[0].zoom;
key(&mut h, Key::Z, true, Modifiers::ALT);
assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn));
click(&mut h, 200.0, 150.0, Modifiers::ALT);
key(&mut h, Key::Z, false, Modifiers::ALT);
hold_mods(&mut h, Modifiers::NONE);
assert!(h.state().ui.views[0].zoom > z0);
}
#[test]
fn rebound_temporary_zoom_uses_the_new_key_only() {
let mut h = harness(Tool::RectMarquee);
@@ -316,7 +389,7 @@ fn keyboard_shortcuts_dialog_lists_the_keys_under_tools() {
assert_eq!(find("edit.fillBackgroundPreserve"), Some((tools(), Some("Cmd+Shift+Backspace".into()))));
let temp = vec!["Tools".to_string(), "Temporary".to_string()];
assert_eq!(find("tools.temporary.hand"), Some((temp.clone(), Some("Space".into()))));
assert_eq!(find("tools.temporary.zoomOut"), Some((temp, Some("Cmd+Alt+Space".into()))));
assert_eq!(find("tools.temporary.zoomOut"), Some((temp, Some("Alt+Space".into()))));
assert_eq!(crate::prefs_ui::default_shortcut("tools.temporary.zoomIn").as_deref(), Some("Cmd+Space"));
// One contiguous Tools section (the dialog prints a header where the top level changes).
let first = items.iter().position(|i| i.2.first().map(String::as_str) == Some("Tools")).unwrap();
+1 -1
View File
@@ -176,7 +176,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca
| `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key |
| `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.<id>` or `menus.colors.<id>` reset removes the override and returns `null`, including when it is already absent |
| `edit.preferences.<section>` | the section's values; in the app (no params) it opens the Preferences dialog on that section |
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Cmd+Alt+Space); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once |
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once |
| `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` |
| `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt |