diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 3bbf36b3c2..acb56b46aa 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -279,6 +279,33 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// Opens the dialog for `command` at the settings last confirmed in it (Photoshop's ⌥ plus the +/// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they +/// don't fit the current colour mode. +pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { + let id = open(app, command)?; + let kind = command.strip_prefix(PREFIX).unwrap_or_default(); + let mode = curve_mode(app, kind); + let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) }; + let Ok(adjustment) = photocraft_engine::adjust_params::from_params(kind, &last, None, mode) else { return Some(id) }; + if let (Some(dialog), Value::Object(values)) = (app.ui.dialog_mut(id), photocraft_engine::adjust_params::to_params(&adjustment)) { + dialog.fields.retain(|k, _| k.starts_with("__")); + dialog.fields.extend(values); + } + Some(id) +} + +/// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a +/// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`). +pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { + if fields.contains_key("__smartFilter") { + return; + } + let Some(command) = fields.get("__command").and_then(Value::as_str).filter(|c| has_dialog(c)) else { return }; + let values = crate::filter_dialog::params_of(fields); + app.session.prefs.edit(|prefs| prefs.dialogs.insert(command.into(), values)); +} + /// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask /// selection — both ways of selecting a mask must give the same dropdown (#5335). fn isolated(app: &PhotocraftApp) -> Option { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4a7f5634f8..3191d64392 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -855,7 +855,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { app.ui.view.guide_layout = params; } if result.is_ok() { - crate::filter_dialog::remember(app, &cmd, &d.fields); + if crate::adjust_dialog::owns(&d.fields) { + crate::adjust_dialog::remember(app, &d.fields); + } else { + crate::filter_dialog::remember(app, &cmd, &d.fields); + } // A step dialog of a playing action: the action continues after it. crate::actions::resume(app, &d.fields)?; } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e9721b765d..68c64cffb0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1293,8 +1293,19 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { } if let Some(id) = clicked { let ctx = ui.ctx().clone(); - let id = alt_click(id, ctx.input(|i| i.modifiers.alt)); - if let Err(e) = invoke(app, &ctx, &id, json!({})) { + let alt = ctx.input(|i| i.modifiers.alt); + // ⌥ while choosing an Image › Adjustments item opens it at its last settings (#8900). + let r = if alt && crate::adjust_dialog::has_dialog(&id) { + let opened = crate::adjust_dialog::open_last(app, &id).map(|_| Value::Null).ok_or_else(|| "no document".to_string()); + // Edit › Search's recents, as `invoke` notes a plain click. + if opened.is_ok() { + crate::palette::note_recent(&ctx, &id); + } + opened + } else { + invoke(app, &ctx, &alt_click(id, alt), json!({})) + }; + if let Err(e) = r { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 0f47282213..e7d8b3c36d 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -239,7 +239,15 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc let by_position = physical_key .filter(|p| POSITIONAL.contains(p)) .and_then(|p| table.iter().find(|(_, sc)| sc.modifiers.command && sc.logical_key == p && key_matches(sc, p, *modifiers))); - by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab)) + let bound = + by_position.or_else(|| table.iter().find(|(_, sc)| key_matches(sc, *key, *modifiers))).map(|(id, _)| (id.clone(), *key == Key::Tab, false)); + // Photoshop: ⌥ added to an adjustment's own key opens it at its last settings (⌥⌘L, + // ⌥⌘M, #8900), unless that key is bound to something else. + bound.or_else(|| { + let plain = egui::Modifiers { alt: false, ..*modifiers }; + let last = modifiers.alt.then(|| table.iter().find(|(id, sc)| crate::adjust_dialog::has_dialog(id) && key_matches(sc, *key, plain))).flatten(); + last.map(|(id, _)| (id.clone(), false, true)) + }) } _ => None, }; @@ -250,7 +258,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc i.events.remove(at); Some(hit) }); - let Some((id, tab)) = next else { break }; + let Some((id, tab, last)) = next else { break }; if tab { // egui already read this Tab as "focus the next widget" (Photoshop's Tab hides the // panels instead, #1313): the shortcut took it, so cancel that move. @@ -258,7 +266,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc } let id = clip_key_command(app, delete_key_command(app, id)); let owner = key_owner(app, ctx); - dispatch(app, ctx, &id); + dispatch_as(app, ctx, &id, last); ran = true; if key_owner(app, ctx) != owner { break; @@ -345,6 +353,11 @@ fn label(id: &str) -> String { /// Run a shortcut's command like its menu item (dialogs included), or say why it can't run. pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) { + dispatch_as(app, ctx, id, false); +} + +/// [`dispatch`]; with `last`, an adjustment dialog opens at its last confirmed settings. +fn dispatch_as(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, last: bool) { let action = id.strip_prefix(crate::actions::SHORTCUT_PREFIX); let command_id = if action.is_some() { "actions.play" } else { id }; let outcome = if !crate::menus::is_enabled(app, command_id) { @@ -364,7 +377,11 @@ pub fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) { if v.get("failed").is_some_and(|f| f.is_object()) { Err(app.ui.status.clone()) } else { Ok(v) } }) } else if crate::adjust_dialog::has_dialog(id) { - crate::adjust_dialog::open(app, id); + if last { + crate::adjust_dialog::open_last(app, id); + } else { + crate::adjust_dialog::open(app, id); + } Ok(serde_json::Value::Null) } else { crate::menus::invoke(app, ctx, id, json!({})) diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index df66185db1..b602a5a2b0 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -846,3 +846,117 @@ fn plain_comma_and_semicolon_bindings_follow_the_typed_character_on_azerty() { tap(&mut h, egui::Key::Comma, egui::Key::M); assert_eq!(logged(&h), ["tools.decreaseBrushHardness"]); } + +/// #8900: ⌥ added to an adjustment's shortcut opens its dialog with the settings last confirmed +/// there (Photoshop's ⌥⌘L for Levels, ⌥⌘M for Curves); the plain shortcut still opens at the +/// defaults, Cancel remembers nothing, each adjustment remembers its own, and a key assigned to +/// another command in Edit › Keyboard Shortcuts stays that command's. +#[test] +fn alt_adjustment_shortcut_reopens_the_last_confirmed_settings() { + use photocraft_doc::ColorMode; + use photocraft_engine::adjust_params::{default_for, from_params, to_params}; + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + let top = |h: &Harness<'_, PhotocraftApp>| h.state().ui.dialogs.last().map(|d| (d.id, d.fields.clone())).expect("a dialog"); + let set = |h: &mut Harness<'_, PhotocraftApp>, id: u64, values: serde_json::Value| { + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.extend(values.as_object().unwrap().clone()); + h.run_steps(2); + }; + let levels = to_params(&from_params("levels", &json!({"inBlack": 30, "gamma": 1.3}), None, ColorMode::Rgb).unwrap()); + let level_defaults = to_params(&default_for("levels", ColorMode::Rgb).unwrap()); + assert_ne!(levels["inBlack"], level_defaults["inBlack"]); + + press(&mut h, "Cmd+L"); + let (id, _) = top(&h); + set(&mut h, id, json!({"inBlack": 30, "gamma": 1.3})); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + + press(&mut h, "Cmd+L"); + let (id, fields) = top(&h); + assert_eq!((&fields["inBlack"], &fields["gamma"]), (&level_defaults["inBlack"], &level_defaults["gamma"]), "⌘L opens at the defaults"); + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + + press(&mut h, "Cmd+Alt+L"); + assert_eq!(logged(&h), ["image.adjustments.levels", "image.adjustments.levels", "image.adjustments.levels"]); + let (id, fields) = top(&h); + assert_eq!((&fields["inBlack"], &fields["gamma"]), (&levels["inBlack"], &levels["gamma"]), "⌥⌘L opens at the last OK'd settings"); + set(&mut h, id, json!({"inBlack": 50})); + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + press(&mut h, "Cmd+Alt+L"); + let (id, fields) = top(&h); + assert_eq!(fields["inBlack"], levels["inBlack"], "Cancel remembers nothing"); + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + + // Curves has nothing of its own yet: its defaults, not Levels' settings. + let curve_defaults = to_params(&default_for("curves", ColorMode::Rgb).unwrap()); + press(&mut h, "Cmd+Alt+M"); + let (id, fields) = top(&h); + assert_eq!(fields["__adjust"], json!("curves")); + assert_eq!(fields["points"], curve_defaults["points"], "⌥⌘M with no Curves confirmed yet"); + let curve = json!({"points": [[0, 0], [100, 150], [255, 255]]}); + set(&mut h, id, curve.clone()); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + press(&mut h, "Cmd+Alt+M"); + let (id, fields) = top(&h); + let curves = to_params(&from_params("curves", &curve, None, ColorMode::Rgb).unwrap()); + assert_eq!(fields["points"], curves["points"], "⌥⌘M opens at the last OK'd curve"); + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + take_log(&h.ctx); + + h.state_mut().session.edit_prefs(|p| { + p.shortcuts.insert("image.adjustments.desaturate".into(), "Cmd+Alt+L".into()); + }); + press(&mut h, "Cmd+Alt+L"); + assert_eq!(logged(&h), ["image.adjustments.desaturate"], "a key assigned to another command is that command's"); + assert!(h.state().ui.dialogs.is_empty()); +} + +/// #8900: in Photoshop ⌥ held while choosing an Image › Adjustments item opens it at its last +/// settings too; a plain click still opens it at the defaults. +#[test] +fn alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings() { + use photocraft_doc::ColorMode; + use photocraft_engine::adjust_params::{default_for, from_params, to_params}; + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| { + p.dialogs.insert("image.adjustments.levels".into(), json!({"inBlack": 30, "gamma": 1.3})); + }); + let levels = to_params(&from_params("levels", &json!({"inBlack": 30, "gamma": 1.3}), None, ColorMode::Rgb).unwrap()); + let defaults = to_params(&default_for("levels", ColorMode::Rgb).unwrap()); + let choose = |h: &mut Harness<'_, PhotocraftApp>, m: Modifiers| { + h.get_by_label("Image").click(); + h.run_steps(3); + // The Image menu's row, not the Adjustments panel's tab in the dock on the right. + let adjustments = h.get_all_by_label_contains("Adjustments").map(|n| n.rect()).min_by(|a, b| a.left().total_cmp(&b.left())).unwrap().center(); + h.hover_at(adjustments); + h.run_steps(30); + let at = h.get_by_label_contains("Levels…").rect().center(); + h.hover_at(at); + h.run_steps(2); + h.event(egui::Event::ModifiersChanged(m)); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: m }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: m }); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + let d = h.state().ui.dialogs.last().expect("Levels opened").clone(); + assert_eq!(d.fields["__adjust"], json!("levels")); + crate::dialogs::cancel(h.state_mut(), d.id).unwrap(); + h.run_steps(2); + (d.fields["inBlack"].clone(), d.fields["gamma"].clone()) + }; + assert_eq!(choose(&mut h, Modifiers::NONE), (defaults["inBlack"].clone(), defaults["gamma"].clone()), "a click opens at the defaults"); + assert_eq!(choose(&mut h, Modifiers::ALT), (levels["inBlack"].clone(), levels["gamma"].clone()), "⌥-click opens at the last settings"); + // The plain click noted Levels already: put another command in front of it first. + crate::palette::note_recent(&h.ctx, "file.new"); + choose(&mut h, Modifiers::ALT); + assert_eq!(crate::palette::recent_ids(&h.ctx).first().map(String::as_str), Some("image.adjustments.levels"), "Edit › Search's most recent"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f1ed065ced..73bc39a53b 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-22 · **Change:** minor (Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; 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; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; 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; selection-modifier and ⌥-wheel rows match their tests) · **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, @@ -207,6 +207,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | +| ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | ## Panels and workspace @@ -293,3 +294,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-16 | minor | View › Extras (⌘H) hides the Move tool's transform controls and their handles stop responding; ⌘H again restores them, and Show Transform Controls keeps its setting (#6699) | | 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) | | 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | +| 2026-10-23 | minor | ⌥ + an adjustment's shortcut (⌥⌘L Levels, ⌥⌘M Curves) opens its dialog at the settings of its last OK, as in Photoshop (#8900); no percentage changed |