Tools: remember tool options between launches (#2814) (#2822)

* Tools: remember tool options between launches (#2814)

The options-bar settings in ToolOptions (Move > Auto-Select, marquee
Feather, tolerance, gradient style, crop ratio, eyedropper sample, ...)
only went through serde for Tool Presets, so every launch started from
the defaults. Photoshop keeps each tool's options between sessions.

Preferences gain `toolOptions` (shell-owned JSON, like brushPicker): the
fields that differ from their defaults, saved once the pointer is up and
restored at launch field by field. An unknown key is ignored; a field
that doesn't read back (wrong type, a number out of f32 range) keeps its
default with a log warning.

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

* Merge upstream/main into fix/2814-persist-tool-options; regenerate docs/scorecard.md

The branch adds a preference, so the prefs audit count in docs/scorecard.md changes
(CI's xtask scorecard staleness test).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 12:20:17 +02:00 committed by GitHub
parent ac19db1046
commit 203b5796b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 2 deletions

View File

@ -947,6 +947,10 @@ pub struct Preferences {
/// the footer slider's card scale, saved as the user changes them and restored at launch.
/// JSON owned by the shell.
pub brush_picker: Value,
/// The tools' options-bar settings (Move › Auto-Select, marquee Feather, tolerance, …) that
/// differ from their defaults, saved as the user changes them and restored at launch, as
/// Photoshop remembers each tool's options between sessions. JSON owned by the shell.
pub tool_options: Value,
/// File › Scripts › Script Events Manager: event → script bindings.
pub script_events: crate::automate_cmds::ScriptEvents,
}

View File

@ -166,6 +166,7 @@ mod titlebar;
pub mod tone;
mod tool_cursor;
pub mod tool_feedback;
pub(crate) mod tool_memory;
pub mod transform_tex;
pub mod transform_tool;
mod type_font_preview;
@ -1609,6 +1610,8 @@ impl eframe::App for PhotocraftApp {
// remembered once the pointer is up. Here, after every panel, rather than in the
// picker's own code: the control channel can set it while the picker is closed.
brush_picker::persist(self, &ctx);
// Tool options are remembered between launches the same way (#2814).
tool_memory::persist(self, &ctx);
self.automation_input = false;
native_menu::sync(self, &ctx);
if screen_picker::busy(&ctx) {

View File

@ -48,6 +48,8 @@ pub struct Runtime {
pub(crate) checker_key: Option<[[u8; 3]; 2]>,
/// Style last sent to the GPU canvas.
pub(crate) gpu_style: Option<crate::gpu_canvas::CanvasStyle>,
/// Tool options as last remembered in the preferences (see `tool_memory`).
pub(crate) tool_options: Option<crate::state::ToolOptions>,
}
/// The GPU canvas colours from Preferences › Transparency & Gamut.
@ -185,6 +187,7 @@ pub fn load(app: &mut PhotocraftApp) {
}
crate::dock::restore(app);
crate::brush_picker::restore(app);
crate::tool_memory::restore(app);
app.sync_recent();
app.prefs_rt.saved_rev = app.session.prefs.rev();
app.prefs_rt.saved_value = Some(app.session.prefs_value());
@ -2539,6 +2542,33 @@ mod tests {
assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && app3.ui.brush_picker_list.show_tip);
}
#[test]
fn tool_options_survive_a_restart() {
let (mut app, store) = app_with_store();
let ctx = egui::Context::default();
tick(&mut app, &ctx);
app.ui.tool_options.move_auto_select = false;
app.ui.tool_options.feather = 12.0;
crate::tool_memory::persist(&mut app, &ctx);
tick(&mut app, &ctx);
// Only what differs from the defaults is stored.
assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0}));
let saved = store.lock().unwrap().clone().unwrap();
let (mut app2, _) = app_with_saved(Some(saved));
tick(&mut app2, &ctx);
assert!(!app2.ui.tool_options.move_auto_select);
assert_eq!(app2.ui.tool_options.feather, 12.0);
assert_eq!(app2.ui.tool_options.tolerance, crate::state::ToolOptions::default().tolerance);
// A hand-edited file: unreadable fields keep their defaults, the good ones still load.
for bad in [json!("junk"), json!({"move_auto_select": "no", "feather": 1e300, "tolerance": 5.0, "unknown": 1})] {
let (mut app3, _) = app_with_saved(Some(json!({"toolOptions": bad}).to_string()));
tick(&mut app3, &ctx);
let o = &app3.ui.tool_options;
assert!(o.move_auto_select && o.feather == 0.0, "{bad}");
assert_eq!(o.tolerance, if bad.is_object() { 5.0 } else { 32.0 });
}
}
#[test]
fn show_tooltips_preferences_turn_tooltips_off() {
let (mut app, _) = app_with_store();

View File

@ -0,0 +1,76 @@
//! Tool options remembered between launches (#2814), as Photoshop keeps each tool's options-bar
//! settings across sessions.
//!
//! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults,
//! so a default changed in a later release still reaches users who never touched that option.
//! Saved once the pointer is up (not on every slider step), restored at launch. A hand-edited or
//! old file can't break the options: an unknown key is ignored and a field that doesn't read back
//! (wrong type, a number too large for its field) keeps its default.
use serde_json::{Map, Value};
use crate::PhotocraftApp;
use crate::state::ToolOptions;
/// The options that differ from the defaults, as JSON (`Null` when none do).
fn changed(o: &ToolOptions) -> Value {
let (Ok(Value::Object(now)), Ok(Value::Object(def))) = (serde_json::to_value(o), serde_json::to_value(ToolOptions::default())) else {
return Value::Null;
};
let diff: Map<String, Value> = now.into_iter().filter(|(k, v)| def.get(k) != Some(v)).collect();
if diff.is_empty() { Value::Null } else { Value::Object(diff) }
}
/// Remember the tool options in the preferences once the user lets go of the pointer. Cheap
/// when nothing changed: one struct compare per frame. Run every frame, so options set by 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()) || app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) {
return;
}
app.prefs_rt.tool_options = Some(app.ui.tool_options.clone());
let now = changed(&app.ui.tool_options);
if app.session.prefs().tool_options != now {
app.session.prefs.edit(|p| p.tool_options = now);
}
}
/// Restore the remembered tool options at launch, field by field over the current ones.
pub fn restore(app: &mut PhotocraftApp) {
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)
{
for (k, v) in saved {
let Some(old) = cur.get(k).cloned() else { continue };
cur.insert(k.clone(), v.clone());
if !reads_back(&cur, k) {
log::warn!("Preferences: ignoring the remembered tool option `{k}`: {v}");
cur.insert(k.clone(), old);
}
}
if let Ok(o) = serde_json::from_value(Value::Object(cur)) {
app.ui.tool_options = o;
}
} else if !saved.is_null() {
log::warn!("Preferences: the remembered tool options are not an object; using the defaults");
}
app.prefs_rt.tool_options = Some(app.ui.tool_options.clone());
}
/// Does `opts` deserialize, with field `k` coming back as a value? A number out of an `f32`'s
/// range reads as infinity, which writes back as `null`.
fn reads_back(opts: &Map<String, Value>, k: &str) -> bool {
let Ok(o) = serde_json::from_value::<ToolOptions>(Value::Object(opts.clone())) else { return false };
let Ok(Value::Object(back)) = serde_json::to_value(&o) else { return false };
back.get(k).is_some_and(|v| nulls(v) <= opts.get(k).map_or(0, nulls))
}
fn nulls(v: &Value) -> usize {
match v {
Value::Null => 1,
Value::Array(a) => a.iter().map(nulls).sum(),
Value::Object(o) => o.values().map(nulls).sum(),
_ => 0,
}
}

View File

@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 23 | 16 | 4 | |
| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 37 of 152 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 37 of 153 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@ -176,7 +176,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 37 of 152** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 37 of 153** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.