diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ee61af791c..8d4d11ade8 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1296,6 +1296,10 @@ pub struct Preferences { /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, + /// The tools used last, most recent first, restored at launch so each toolbar slot shows the + /// tool it was left on and each tool key brings it back, as in Photoshop (#3569). Tool-name + /// strings owned and validated by the shell; null in older files. + pub group_tools: Value, /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To /// targets that differ from their defaults, saved as the user toggles them and restored at /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. diff --git a/crates/ui-egui/src/input/shortcuts.rs b/crates/ui-egui/src/input/shortcuts.rs index 0abd096530..87bfd36ade 100644 --- a/crates/ui-egui/src/input/shortcuts.rs +++ b/crates/ui-egui/src/input/shortcuts.rs @@ -485,9 +485,9 @@ fn preflight_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if crate::canvas::cancel_mixer_on_context_change(app, ctx) { return false; } - // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings - // it back later (#2608). - app.ui.remember_group_tool(); + // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key and its + // toolbar slot bring it back later (#2608). A temporary tool is no choice, so it isn't noted. + app.ui.remember_group_tool(crate::tool_memory::selected(app)); crate::canvas::polygon_cancel_stale(app); // Camera Raw is modal like Photoshop's filter dialog: no application shortcut (Save, Undo, // tools) runs beneath it, and it handles its own keys (Y, U, O, S). Unlike the other dialogs @@ -924,7 +924,7 @@ fn select_tool_by_key(app: &mut PhotocraftApp, ctx: &egui::Context, pressed: &im app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), + None => app.ui.group_tools.iter().copied().find(|last| group.contains(last)).unwrap_or(group[0]), }; crate::tool_memory::ended_by_tool(app); // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. diff --git a/crates/ui-egui/src/input/shortcuts/tests.rs b/crates/ui-egui/src/input/shortcuts/tests.rs index cfb3203f10..fd68edff51 100644 --- a/crates/ui-egui/src/input/shortcuts/tests.rs +++ b/crates/ui-egui/src/input/shortcuts/tests.rs @@ -72,6 +72,20 @@ fn shift_key_tool_switch_is_the_default() { assert_ne!(app.ui.tool, Tool::Type); } +/// A tool with a shortcut of its own leaves its letter (#4086) but stays in the history: the letter +/// falls back on the last tool it still picks, not on its group's first. +#[test] +fn a_tool_key_passes_over_a_remapped_tool_to_the_last_one_it_picks() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.polygonLasso", "value": "F6"})).unwrap(); + app.ui.remember_group_tool(Tool::MagneticLasso); + app.ui.remember_group_tool(Tool::PolygonLasso); + press_key(&mut app, &ctx, Key::L, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MagneticLasso); +} + /// #3109: on a German layout Ö / # (physical `;` / `\`) step the brush size and ⇧Ö / ⇧# /// (⇧# types `'`) its hardness, as Photoshop's German shortcuts. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9fd21b2f20..0d342a0feb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -112,7 +112,8 @@ use title_bar::title_x; pub use title_bar::{sync_window_title, system_title_bar, title_bar, window_title}; #[cfg(test)] use toolbar::{ - STUDIO_CHIP, TOOL_SECTIONS, chips_height, color_chips, next_slot_tool, tool_flyout_drag_open, tool_flyout_release, toolbar_icon_size, visible_sections, + STUDIO_CHIP, TOOL_SECTIONS, chips_height, color_chips, next_slot_tool, slot_tool, tool_flyout_drag_open, tool_flyout_release, toolbar_icon_size, + visible_sections, }; pub use toolbar::{paint_toolbar_flyout_indicator, toolbar}; diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs index a81ca7653a..74a2e6bb98 100644 --- a/crates/ui-egui/src/panels/toolbar.rs +++ b/crates/ui-egui/src/panels/toolbar.rs @@ -35,13 +35,13 @@ pub(crate) const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[&[Tool::Hand, Tool::RotateView], &[Tool::Zoom]], ]; -/// The tool a slot shows: the current tool if it belongs to the slot, else the last one used. -fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool { +/// The tool a slot shows: the current tool if it belongs to the slot, else the last one used +/// (`group_tools`, most recent first), else the slot's first tool. +pub(super) fn slot_tool(current: Tool, group_tools: &[Tool], slot: &[Tool]) -> Tool { if slot.contains(¤t) { - ui.data_mut(|d| d.insert_temp(key, current)); return current; } - ui.data(|d| d.get_temp::(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0]) + group_tools.iter().chain(slot).copied().find(|t| slot.contains(t)).unwrap_or(current) } /// Next tool in a toolbar flyout slot after `from`, wrapping (Photoshop Alt/Option-click). An @@ -61,7 +61,7 @@ pub(crate) fn toolbar_position(tool: Tool) -> usize { /// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A /// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is /// dropped, and so is a section with no slots. Each slot carries its index in `TOOL_SECTIONS`, -/// so its remembered tool and flyout stay put when other slots are hidden. +/// so its flyout stays put when other slots are hidden. pub(super) fn visible_sections(hidden: &[String]) -> Vec)>> { let hidden: Vec = hidden.iter().map(String::as_str).filter_map(Tool::from_name).collect(); let mut index = 0usize; @@ -226,7 +226,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for (slot_index, slot) in row.iter() { let key = egui::Id::unique(("tool-slot", *slot_index)); - let tool = slot_tool(ui, app.ui.tool, slot, key); + let tool = slot_tool(app.ui.tool, &app.ui.group_tools, slot); let sel = slot.contains(&app.ui.tool); let tip = match crate::tool_shortcuts::shortcut(app, tool) { Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), diff --git a/crates/ui-egui/src/panels/type_flyout_tests.rs b/crates/ui-egui/src/panels/type_flyout_tests.rs index 9e13f66169..1243992b66 100644 --- a/crates/ui-egui/src/panels/type_flyout_tests.rs +++ b/crates/ui-egui/src/panels/type_flyout_tests.rs @@ -450,6 +450,67 @@ fn next_slot_tool_wraps_around_the_flyout() { assert_eq!(next_slot_tool(&[], Tool::Brush), Tool::Brush); } +#[test] +fn slot_tool_is_the_current_tool_else_the_slots_last_else_its_first() { + let lasso = [Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso]; + assert_eq!(slot_tool(Tool::Move, &[], &lasso), Tool::Lasso); + let used = [Tool::Brush, Tool::PolygonLasso, Tool::Lasso]; + assert_eq!(slot_tool(Tool::Move, &used, &lasso), Tool::PolygonLasso); + assert_eq!(slot_tool(Tool::MagneticLasso, &used, &lasso), Tool::MagneticLasso); + // Polygonal Lasso hidden in Edit › Toolbar: the slot falls back on the next one used. + assert_eq!(slot_tool(Tool::Move, &used, &[Tool::Lasso, Tool::MagneticLasso]), Tool::Lasso); + // Slots don't line up with keys: Blur's slot and the two single marquees have no key at all, + // and the Hand slot has two. + let used = [Tool::Sharpen, Tool::SingleRowMarquee, Tool::RotateView]; + assert_eq!(slot_tool(Tool::Move, &used, &[Tool::Blur, Tool::Sharpen, Tool::Smudge]), Tool::Sharpen); + let marquee = [Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee]; + assert_eq!(slot_tool(Tool::Move, &used, &marquee), Tool::SingleRowMarquee); + assert_eq!(slot_tool(Tool::Move, &used, &[Tool::Hand, Tool::RotateView]), Tool::RotateView); + // Empty slot: no panic, the current tool stays. + assert_eq!(slot_tool(Tool::Brush, &used, &[]), Tool::Brush); +} + +/// #3569: after a restart a slot is still on the tool it was left on, whichever tool is selected: +/// a click picks that tool, not the slot's first. +#[test] +fn slots_come_back_on_their_last_tool_after_a_restart() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.prefs.edit(|p| p.group_tools = json!(["PolygonLasso", "PaintBucket", "Pencil", "Sharpen", "SingleRowMarquee"])); + crate::tool_memory::restore(&mut app); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let slots = [ + (Tool::Brush, Tool::Pencil), + (Tool::Gradient, Tool::PaintBucket), + (Tool::Lasso, Tool::PolygonLasso), + (Tool::Blur, Tool::Sharpen), + (Tool::RectMarquee, Tool::SingleRowMarquee), + ]; + for (i, (first, last)) in slots.into_iter().enumerate() { + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&first)).unwrap(); + let at = buttons[index].center(); + let t = 1.0 + i as f64; + frame(&mut app, &ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, t + 0.05, vec![pointer(at, false)]); + frame(&mut app, &ctx, t + 0.1, vec![]); + assert_eq!(app.ui.tool, last); + } +} + /// Edit › Toolbar hidden tools shrink the Alt-click cycle to the remaining flyout members. #[test] fn visible_sections_drop_hidden_tools_from_alt_click_slots() { diff --git a/crates/ui-egui/src/prefs/prefs_ui.rs b/crates/ui-egui/src/prefs/prefs_ui.rs index e37efea17f..36cd3e74c5 100644 --- a/crates/ui-egui/src/prefs/prefs_ui.rs +++ b/crates/ui-egui/src/prefs/prefs_ui.rs @@ -53,6 +53,7 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + pub(crate) group_tools: Option>, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, } diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index dabde320b8..3d62afaa43 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -1139,6 +1139,125 @@ fn selected_tool_excludes_temporary_switches() { assert_eq!(stored(&store)["selectedTool"], json!("PolygonLasso")); } +/// #3569: a restart keeps the last tool of every group, not only the selected tool's. +#[test] +fn group_tools_survive_a_restart() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + for tool in [Tool::Brush, Tool::Pencil, Tool::PaintBucket, Tool::PolygonLasso, Tool::Pencil, Tool::Zoom] { + app.ui.tool = tool; + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + } + assert_eq!(stored(&store)["groupTools"], json!(["Zoom", "Pencil", "PolygonLasso", "PaintBucket", "Brush", "Move"]), "most recent first, each once"); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool, Tool::Zoom); + assert_eq!(app2.ui.group_tools, app.ui.group_tools); + assert_eq!(app2.ui.group_tool('B'), Some(Tool::Pencil), "the group's latest tool"); + assert_eq!(app2.ui.group_tool('G'), Some(Tool::PaintBucket)); + assert_eq!(app2.ui.group_tool('L'), Some(Tool::PolygonLasso)); +} + +#[test] +fn group_tools_are_flushed_on_exit_without_another_frame() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + app.ui.tool = Tool::Pencil; + crate::tool_memory::persist(&mut app, &egui::Context::default()); + app.ui.tool = Tool::PaintBucket; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.group_tool('B'), Some(Tool::Pencil)); + assert_eq!(app2.ui.group_tool('G'), Some(Tool::PaintBucket)); +} + +/// A frame as far as the tools go: the key handling, then what is remembered. +fn tool_frame(app: &mut PhotocraftApp, ctx: &egui::Context, events: Vec) { + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + crate::shortcuts::handle(app, ctx); + ctx.end_pass().textures_delta.clear(); + crate::tool_memory::persist(app, ctx); + tick(app, ctx); +} + +/// A tool picked during a drag that ends on the tool it began with: the selected tool never +/// changed, but that tool's group did. +#[test] +fn group_tools_are_saved_when_the_selected_tool_stays_the_same() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let pointer = |pressed| egui::Event::PointerButton { pos: egui::Pos2::ZERO, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + app.ui.tool = Tool::Brush; + tool_frame(&mut app, &ctx, vec![]); + tool_frame(&mut app, &ctx, vec![pointer(true)]); + app.ui.tool = Tool::PaintBucket; + tool_frame(&mut app, &ctx, vec![]); + app.ui.tool = Tool::Brush; + tool_frame(&mut app, &ctx, vec![]); + tool_frame(&mut app, &ctx, vec![pointer(false)]); + assert_eq!(stored(&store)["selectedTool"], json!("Brush")); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.group_tool('G'), Some(Tool::PaintBucket)); +} + +/// The pen's eraser end and a held tool letter don't change what a group shows or brings back, +/// neither during the session nor when quitting while they are in use. +#[test] +fn group_tools_exclude_temporary_switches() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + for tool in [Tool::MagicEraser, Tool::Brush] { + app.ui.tool = tool; + tool_frame(&mut app, &ctx, vec![]); + } + app.stylus.tool_before_eraser = Some(Tool::Brush); + app.ui.tool = Tool::Eraser; + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.group_tool('E'), Some(Tool::MagicEraser), "the pen's eraser end"); + app.stylus.tool_before_eraser = None; + app.ui.tool = Tool::Brush; + let press = egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + tool_frame(&mut app, &ctx, vec![press]); + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(app.ui.tool, Tool::Zoom, "Z is still down"); + assert_eq!(app.ui.group_tool('Z'), None, "a held letter"); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["groupTools"], json!(["Brush", "MagicEraser", "Move"])); +} + +/// A selected tool this version doesn't know starts on the default tool. The saved list stays as +/// it is, names this version doesn't know included, until the next tool is picked. +#[test] +fn a_launch_alone_saves_no_group_tools() { + use crate::state::Tool; + let saved = json!({"selectedTool": "FutureTool", "groupTools": ["Pencil", "FutureTool", "PaintBucket"]}); + let (mut app, store) = app_with_saved(Some(saved.to_string())); + let ctx = egui::Context::default(); + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(stored(&store)["groupTools"], saved["groupTools"]); + app.ui.tool = Tool::Brush; + tool_frame(&mut app, &ctx, vec![]); + assert_eq!(stored(&store)["groupTools"], json!(["Brush", "Move", "Pencil", "PaintBucket"])); +} + +/// A damaged or hand-edited `groupTools` costs no other preference, and what reads back counts. +#[test] +fn damaged_group_tools_keep_what_reads_back() { + use crate::state::Tool; + for bad in [json!(null), json!("Pencil"), json!(42), json!({"B": "Pencil"})] { + let (app, _) = app_with_saved(Some(json!({"groupTools": bad, "toolOptions": {"feather": 12.0}}).to_string())); + assert_eq!(app.ui.group_tools, [Tool::Move], "{bad}"); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + let saved = json!({"groupTools": ["FutureTool", "Pencil", 7, null, {}, "PaintBucket", "Pencil"]}); + let (app, _) = app_with_saved(Some(saved.to_string())); + assert_eq!(app.ui.group_tools, [Tool::Move, Tool::Pencil, Tool::PaintBucket]); +} + #[test] fn tool_options_survive_a_restart() { let (mut app, store) = app_with_store(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a0389e58ad..6a6f1aeca0 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -227,8 +227,11 @@ pub struct DocWindow { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct UiState { pub tool: Tool, - /// The last tool used in each shortcut group (the tools sharing a key, [`Tool::key`]), at most - /// one per group: the group's key brings it back, as in Photoshop (#2608). + /// The tools used so far, most recent first and each one once. The first tool of a shortcut + /// group (the tools sharing a key, [`Tool::key`]) is the one its key brings back, and the first + /// tool of a toolbar slot is the one the slot shows, as in Photoshop (#2608, #3569). One list + /// answers both because they don't line up: the Marquee slot holds tools with and without a + /// key, the Hand slot two keys. Kept between launches in `Preferences::group_tools`. #[serde(default)] pub group_tools: Vec, /// A tool letter still held (spring-loaded tool, #2672): the tool to give back on release. @@ -675,16 +678,16 @@ impl Default for UiState { } impl UiState { - /// Records the current tool as the last one used in its shortcut group. - pub fn remember_group_tool(&mut self) { - let tool = self.tool; - if !self.group_tools.contains(&tool) { - self.group_tools.retain(|t| t.key() != tool.key()); - self.group_tools.push(tool); + /// Records `tool` as the last one used in its shortcut group and toolbar slot. + pub fn remember_group_tool(&mut self, tool: Tool) { + if self.group_tools.first() != Some(&tool) { + self.group_tools.retain(|t| *t != tool); + self.group_tools.insert(0, tool); } } /// The last tool used in the shortcut group of `key`, if any. + #[cfg(test)] pub fn group_tool(&self, key: char) -> Option { self.group_tools.iter().copied().find(|t| t.key() == key) } diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 08259200fe..b70974d0b2 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -11,6 +11,10 @@ //! `paint_mouse::sync_tool_brush`) is kept the same way in `Preferences::tool_brushes` (#6212), //! with the tool that owns the session brush (that brush itself is `Preferences::last_brush`). //! Reset Tool and Reset All Tools change both, so the next save forgets what they reset. +//! +//! The tools used last (`UiState::group_tools`) are kept the same way in +//! `Preferences::group_tools` (#3569), so a tool picked from a flyout stays on its toolbar slot +//! and its key after a restart. A name this version doesn't know is skipped, as with the options. use photocraft_engine::BrushSettings; use serde_json::{Map, Value, json}; @@ -43,7 +47,7 @@ pub(crate) fn ended_by_tool(app: &mut PhotocraftApp) { /// Temporary canvas overrides do not change `ui.tool`. Spring-loaded letters and the pen's /// eraser end do, so remember the tool they will return to instead. A different explicit choice /// (or beginning a type edit) owns the tool and stands, just as in `spring_tools::tick`. -fn selected(app: &PhotocraftApp) -> Tool { +pub(crate) fn selected(app: &PhotocraftApp) -> Tool { let tool = if app.ui.tool == Tool::Eraser { app.stylus.tool_before_eraser.unwrap_or(app.ui.tool) } else { app.ui.tool }; if let Some(s) = &app.ui.spring && tool == s.tool @@ -63,8 +67,9 @@ fn changed(o: &ToolOptions) -> Value { if diff.is_empty() { Value::Null } else { Value::Object(diff) } } -/// Remember the selected tool and its options once the user lets go of the pointer. Run every -/// frame, so choices made through the control channel or a tool preset are remembered too. +/// Remember the selected tool, the tools used last and the options once the user lets go of the +/// pointer. Run every frame, so choices made through the control channel or a tool preset are +/// remembered too. pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { if ctx.input(|i| i.pointer.any_down()) { return; @@ -80,6 +85,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_group_tools(app, ctx, tool); persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; @@ -91,6 +97,24 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Remember the tools used last when the list changes, whether or not the selected tool did: a +/// tool picked during a drag that ends on the tool it began with changes only the list. +fn persist_group_tools(app: &mut PhotocraftApp, ctx: &egui::Context, tool: Tool) { + // Noted here as well: no frame follows a tool picked right before quitting. + app.ui.remember_group_tool(tool); + if app.prefs_rt.group_tools.as_ref() == Some(&app.ui.group_tools) { + return; + } + app.prefs_rt.group_tools = Some(app.ui.group_tools.clone()); + if let Ok(now) = serde_json::to_value(&app.ui.group_tools) + && app.session.prefs().group_tools != now + { + app.session.prefs.edit(|p| p.group_tools = now); + // As for the selected tool: prefs_ui::tick ran before the panels. + ctx.request_repaint(); + } +} + /// Remember the painting tools' own brushes when they change (a compare per frame otherwise: /// tip bitmaps are shared, so equal brushes compare by pointer). A brush that can't be remembered /// yet (a sampled tip too big to embed, before the preset store takes it) keeps what was saved. @@ -174,19 +198,35 @@ pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { crate::prefs_ui::save_preferences(app) } +/// Restore the tools used last, keeping their order. Anything that isn't a tool is skipped with a +/// warning: the rest of the list still holds. +fn restore_group_tools(app: &mut PhotocraftApp) { + let saved = app.session.prefs().group_tools.clone(); + if !saved.is_null() && !saved.is_array() { + log::warn!("Preferences: the remembered group tools are not a list; using the defaults"); + } + for v in saved.as_array().into_iter().flatten().rev() { + match serde_json::from_value::(v.clone()) { + Ok(tool) => app.ui.remember_group_tool(tool), + Err(_) => log::warn!("Preferences: ignoring the remembered group tool {v}"), + } + } +} + /// Restore the selected tool before the first frame and its options field by field. pub fn restore(app: &mut PhotocraftApp) { + restore_group_tools(app); let saved = app.session.prefs().selected_tool.clone(); if !saved.is_null() { match serde_json::from_value::(saved) { - Ok(tool) => { - app.ui.tool = tool; - app.ui.remember_group_tool(); - } + Ok(tool) => app.ui.tool = tool, Err(err) => log::warn!("Preferences: ignoring the remembered selected tool: {err}"), } } + // Here rather than on the first frame, so a launch alone has nothing to save. + app.ui.remember_group_tool(app.ui.tool); app.prefs_rt.selected_tool = Some(app.ui.tool); + app.prefs_rt.group_tools = Some(app.ui.group_tools.clone()); let saved = app.session.prefs().tool_options.clone(); if let Some(saved) = saved.as_object() && let Ok(Value::Object(mut cur)) = serde_json::to_value(&app.ui.tool_options) diff --git a/docs/scorecard.md b/docs/scorecard.md index a3158616b2..c199c30e90 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -215,7 +215,7 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace. | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list) | +| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. The last tool of each toolbar slot and tool key persists too (Preferences.groupTools, #3569). Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list) | | UI-217-2 | Window size and dock width remembered across restarts | done | [#217](https://github.com/storytold/photocraft/issues/217) | eframe persistence (ui.ron): persist_window for the window, egui memory for the dock panel width; window_and_panel_geometry_survive_a_restart asserts the round trip (apps/photocraft/src/main.rs; b07a2e5b, #587) | | UI-217-3 | File > Open starts in the last-used folder | done | [#217](https://github.com/storytold/photocraft/issues/217) | file_dialog.rs last_used_dir: open dialogs ask with initial_dir (the most recent file's folder) and apps services.rs passes it to rfd set_directory; open_starts_in_the_last_used_folder + last_used_dir_takes_the_most_recent_parent | | UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b84fd81419..f80e1c5e18 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-11-06 · **Change:** minor (Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + 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; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; 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; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (each toolbar slot and tool key keeps its last tool across launches, #3569; Image › Adjustments › Selective Color keeps each colour's own values and applies all nine, #11463; History Options snapshot options, #3634; Channels panel menu: New Channel…, Duplicate Channel, Delete Channel, New Spot Channel…, Merge Spot Channel, Channel Options…, Split Channels, Merge Channels…, #11451; a Color Picker for another dialog's colour or the Crop shield shows the picked colour there before OK, #9886; Edit › Transform › Distort side handles move their edge, Perspective side handles skew, #10085; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + 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; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; 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; Properties › Transform shows a shape's path geometry, without its stroke) · **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, @@ -51,7 +51,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Letter shortcuts, ⇧+letter cycles the group, "Use Shift Key for Tool Switch" (on by default, as in Photoshop) | done | `shortcuts.rs` `select_tool_by_key` | | Alt/Option-click a toolbar button cycles its flyout slot (including tools with no letter key) | done | `toolbar.rs` `next_slot_tool`; `type_flyout_tests.rs` `alt_click_cycles_the_tool_flyout_slot` | -| Group remembers the last tool used | partial | L always resets to Lasso (#2608) | +| Group remembers the last tool used: its toolbar slot shows it and its key brings it back, also after quitting and relaunching; temporary tool switches excluded | done | `state.rs` `group_tools`, `tool_memory.rs`; `shortcuts::tests::tool_key_brings_back_the_groups_last_used_tool`, `prefs_ui::tests::group_tools_survive_a_restart`, `group_tools_exclude_temporary_switches`, `type_flyout_tests::slots_come_back_on_their_last_tool_after_a_restart` | | Press-and-hold or click-drag opens the flyout | done | `toolbar.rs`; right-click, long-press, press-drag | | Edit Toolbar customization | done | `edit.toolbar` | | Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | @@ -364,4 +364,5 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-28 | minor | A Color Picker for another dialog's colour field (Fill, Stroke, filters, guides, channels, Preferences…) or the Crop shield's custom colour shows the colour there before OK; Cancel restores it (#9886); no percentage changed | | 2026-10-16 | minor | Hand tool: ⌘ held zooms in and ⌥ zooms out, as in Photoshop (#6434) | | 2026-11-03 | minor | Tools › Overscroll off centres an image smaller than the window on that axis, so zooming out around the pointer keeps it in the middle instead of where the zoom left it (#10488, #4799); this overrides #1718's Photoshop 25.4 measurement on the strength of two Photoshop users' reports, and Overscroll on keeps the free view; no percentage changed | +| 2026-11-04 | minor | Each toolbar slot and tool key keeps its last tool across launches (`Preferences.groupTools`, #3569); the group row, stale since #2625, is done; no percentage changed | | 2026-10-31 | minor | Ctrl + Alt + wheel over the canvas zooms like ⌥ + wheel (#10488): ×1.1 a notch around the pointer with Animated Zoom off, eased with it on, and it follows the ⌥ swap when General › Zoom with Scroll Wheel is on. Windows and Linux report a held Ctrl as ctrl AND command (`wheel_nav::alt_gesture`), macOS ⌘ + ⌥ is left alone, plain ⌘/Ctrl + wheel still scrolls sideways; the combination was a dead gesture, so nothing existing was replaced. The centred-zoom half of the report is still open: `canvas.rs:1737` re-clamps the view centre each frame only when Tools › Overscroll is off (it is on by default), so the drift the reporter saw is more likely the deliberate nearest-image-point anchor (`view_xform.rs:239`, #3720) or Zoom Clicked Point to Center, which needs its own repro; no percentage changed | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 6f2e163eb7..98c4d2d344 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -10,7 +10,7 @@ id = "UI-217-1" target = "Tool options, last Export As / Save for Web / Fill settings and font recents persist" status = "partial" issue = 217 -note = "Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list)" +note = "Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. The last tool of each toolbar slot and tool key persists too (Preferences.groupTools, #3569). Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list)" [[item]] id = "UI-217-2"