From 6f1984d12659098a86920b11d2b2b8e8a979ef65 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 18:59:22 +0200 Subject: [PATCH 1/2] Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212) Tool options and the selected tool were already remembered (tool_memory, #2814), but each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that owned the session brush restarted as the Brush, so after quitting with the Eraser its brush became the Brush's. - Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and on exit, restored at launch with stored tips as references (loaded on swap). - brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow, smoothing). - tool_memory::sanitize clamps restored tool options to their options-bar ranges. - Reset Tool / Reset All Tools clear the persisted state through the same path. - Scorecard UI-217-1 note updated (stays partial: font recents). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_cmds.rs | 36 +++++-- crates/engine/src/brush_cmds/tests.rs | 14 +++ crates/engine/src/prefs.rs | 6 ++ crates/ui-egui/src/paint_mouse.rs | 8 +- crates/ui-egui/src/prefs_ui.rs | 146 ++++++++++++++++++++++++++ crates/ui-egui/src/shape_stroke_ui.rs | 2 +- crates/ui-egui/src/tool_memory.rs | 130 ++++++++++++++++++++++- docs/scorecard.md | 6 +- scorecard/ui.toml | 2 +- 9 files changed, 333 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index b76f8c836d..dc8c39876e 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -993,23 +993,43 @@ pub fn remember_brush(s: &mut Session) { /// bitmap. `None` while a sampled bitmap too big to embed is current (only before the store /// externalizes it; see [`ref_tip`]), so the previous remembered brush stands. fn remembered_brush(s: &Session) -> Option { - let b = &s.tools.brush; + Some(json!({ "preset": s.tools.current_preset.clone(), "brush": remembered_settings(&s.tools.brush)? })) +} + +/// A brush as JSON for the preferences (the last brush, each painting tool's own brush): its +/// settings with stored tips as references, never their full bitmaps. `None` while a sampled +/// bitmap too big to embed is in it (only before the preset store externalizes it). +pub fn remembered_settings(b: &BrushSettings) -> Option { let (tip, dual, pattern) = (ref_tip(&b.tip)?, ref_tip(&b.dual_brush.tip)?, ref_pattern(&b.texture.pattern)?); let mut c = b.clone(); (c.tip, c.dual_brush.tip, c.texture.pattern) = (tip, dual, pattern); - Some(json!({ "preset": s.tools.current_preset.clone(), "brush": serde_json::to_value(&c).ok()? })) + serde_json::to_value(&c).ok() +} + +/// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is +/// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The +/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness, +/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else +/// they paint with their previews until `Session::attach_preset_store`). +pub fn restored_settings(s: &Session, v: &Value) -> Option { + if !v.is_object() { + return None; + } + let mut b = merge_brush(&BrushSettings::default(), v, "brush.restore").ok()?; + validate_brush(&b, "brush.restore").ok()?; + b.size = b.size.clamp(1.0, photocraft_paint::MAX_BRUSH_SIZE); + for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] { + *f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) }; + } + let _ = s.load_brush_tips(&mut b); + Some(b) } /// Restore `Preferences::last_brush` into the session at launch (a missing or malformed value /// keeps the default brush). Stored tips load when the preset store attaches. pub fn restore_brush(s: &mut Session) { let v = s.prefs().last_brush.clone(); - let Some(patch) = v.get("brush").filter(|b| b.is_object()) else { return }; - let Ok(mut b) = merge_brush(&BrushSettings::default(), patch, "brush.restore") else { return }; - if validate_brush(&b, "brush.restore").is_err() { - return; - } - let _ = s.load_brush_tips(&mut b); + let Some(b) = v.get("brush").and_then(|b| restored_settings(s, b)) else { return }; s.tools.brush = b; s.tools.current_preset = v.get("preset").and_then(Value::as_str).map(str::to_string); // What was just restored is exactly what the preferences hold: nothing new to write. diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index eaf69281bc..9a2028b0cb 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -1021,6 +1021,20 @@ fn the_remembered_brush_restores_at_launch() { assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size); } +/// #6212: a remembered brush reads back clamped to its controls' ranges; one that fails the +/// `tools.setBrush` checks, or isn't a brush at all, doesn't read back. +#[test] +fn restored_brush_settings_are_clamped_or_rejected() { + let s = Session::new(); + let b = restored_settings(&s, &json!({"size": -4.0, "opacity": 7.0, "flow": -1.0, "hardness": 0.25, "smoothing": {"amount": 9.0}})).unwrap(); + assert_eq!((b.size, b.opacity, b.flow, b.hardness, b.smoothing.amount), (1.0, 1.0, 0.0, 0.25, 1.0)); + let round = restored_settings(&s, &remembered_settings(&b).unwrap()).unwrap(); + assert_eq!(round, b, "a remembered brush reads back as it was"); + for bad in [json!({"size": 1e30}), json!({"size": "big"}), json!(null), json!([1]), json!("brush")] { + assert!(restored_settings(&s, &bad).is_none(), "{bad}"); + } +} + #[test] fn remembered_brushes_never_embed_large_bitmaps() { use std::sync::Arc; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c732daeefd..9142abce86 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -969,6 +969,12 @@ pub struct Preferences { /// 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, + /// Each painting tool's own brush (the Brush, the Eraser, the retouching tools…; see the + /// shell's `paint_mouse::sync_tool_brush`), saved as the user changes them and restored at + /// launch, as Photoshop keeps every tool's brush between sessions (#6212): `{"owner": tool, + /// "brushes": {tool: brush}}`, where `owner` is the tool whose brush `last_brush` holds and + /// the brushes are shaped by `brush_cmds::remembered_settings`. JSON owned by the shell. + pub tool_brushes: Value, /// 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, diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index a45c016c59..ee7ba5fe90 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -30,7 +30,13 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { let current = app.session.tools.brush.clone(); save_brush(app, app.ui.brush_tool, current.clone()); app.session.tools.brush = match app.ui.tool_brushes.iter().find(|(t, _)| *t == tool) { - Some((_, b)) => session_fields(¤t, b), + Some((_, b)) => { + // A brush remembered from the previous run holds its stored tips as references: + // load their full bitmaps (a missing one keeps painting with its preview). + let mut b = session_fields(¤t, b); + let _ = app.session.load_brush_tips(&mut b); + b + } // A tool seen for the first time takes what the brush has, at Photoshop's smoothing. None => BrushSettings { smoothing: first_smoothing(), ..current }, }; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d194803020..aa6da016ec 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -53,6 +53,8 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + /// The tool owning the session brush and the other tools' brushes, as last remembered. + pub(crate) tool_brushes: Option, } /// The GPU canvas colours from Preferences › Transparency & Gamut. @@ -3269,6 +3271,150 @@ mod tests { } } + /// #6212: a remembered value its options-bar field could never hold is clamped to the + /// field's range (or takes the default) rather than reaching the tool. + #[test] + fn remembered_tool_options_are_clamped_to_their_fields() { + let ctx = egui::Context::default(); + let hostile = json!({"toolOptions": { + "feather": 1e30, "tolerance": -5.0, "polygon_sides": 0, "eyedropper_size": 7, + "crop_overlay_orientation": 9, "fill_opacity": 0.0, "magnetic_width": 1e9, + "pattern_stamp_angle": -720.0, "marquee_width": 1e7, "shape_stroke": {"miter_limit": -1.0, "opacity": 5.0}, + }}); + let (mut app, _) = app_with_saved(Some(hostile.to_string())); + tick(&mut app, &ctx); + let o = &app.ui.tool_options; + let d = crate::state::ToolOptions::default(); + assert_eq!((o.feather, o.tolerance, o.polygon_sides, o.eyedropper_size), (1000.0, 0.0, 3, d.eyedropper_size)); + assert_eq!((o.crop_overlay_orientation, o.fill_opacity, o.magnetic_width), (d.crop_overlay_orientation, 1.0, 256.0)); + assert_eq!((o.pattern_stamp_angle, o.marquee_width), (-180.0, 999.0)); + assert_eq!(o.shape_stroke, d.shape_stroke, "an invalid stroke style takes the default"); + // A good value is left alone, and in-range defaults never change (nothing to save). + let (mut app2, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.5}}).to_string())); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions { feather: 12.5, ..d.clone() }); + let mut def = d.clone(); + crate::tool_memory::sanitize(&mut def); + assert_eq!(def, d); + } + + /// #6212: every painting tool keeps its own brush across a restart, and the session brush + /// comes back as the brush of the tool that had it. + #[test] + fn tool_brushes_survive_a_restart() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + let set_size = |app: &mut PhotocraftApp, size: f32| { + app.session.tools.brush.size = size; + app.session.tools.brush.opacity = size / 100.0; + app.session.tools.brush_changed(); + }; + set_size(&mut app, 77.0); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + set_size(&mut app, 33.0); + app.ui.tool = Tool::CloneStamp; + crate::paint_mouse::sync_tool_brush(&mut app); + set_size(&mut app, 12.0); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolBrushes"]["owner"], json!("Eraser")); + assert_eq!(saved["toolBrushes"]["brushes"]["Brush"]["size"], json!(77.0)); + assert_eq!(saved["toolBrushes"]["brushes"]["CloneStamp"]["size"], json!(12.0)); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_none(), "the owner's brush is the last brush"); + + let (mut app2, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut app2, &ctx); + assert_eq!((app2.ui.tool, app2.ui.brush_tool), (Tool::Eraser, Tool::Eraser)); + assert_eq!(app2.session.tools.brush.size, 33.0, "the Eraser's brush is the session brush"); + for (tool, size) in [(Tool::Brush, 77.0), (Tool::CloneStamp, 12.0), (Tool::Eraser, 33.0)] { + app2.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app2); + assert_eq!(app2.session.tools.brush.size, size, "{tool:?}"); + assert_eq!(app2.session.tools.brush.opacity, size / 100.0, "{tool:?}"); + } + } + + /// #6212: a damaged or hand-edited `toolBrushes` never panics: what reads back loads (clamped + /// to the options bar's ranges), the rest is skipped and those tools start from the brush. + #[test] + fn remembered_tool_brushes_survive_damage() { + use crate::state::Tool; + let ctx = egui::Context::default(); + for bad in [json!("junk"), json!(42), json!({"owner": [1], "brushes": "x"}), json!({"brushes": {"Eraser": 5}})] { + let (mut app, _) = app_with_saved(Some(json!({"toolBrushes": bad}).to_string())); + tick(&mut app, &ctx); + assert_eq!(app.ui.brush_tool, Tool::Brush, "{bad}"); + assert!(app.ui.tool_brushes.is_empty(), "{bad}"); + } + let hostile = json!({"toolBrushes": {"owner": "Move", "brushes": { + "Eraser": {"size": 1e30}, "Bogus": {"size": 5.0}, "Move": {"size": 5.0}, + "Smudge": {"size": -3.0, "opacity": 50.0, "flow": -2.0, "hardness": 1e30}, + "Brush": {"size": 9.0}, + }}}); + let (mut app, _) = app_with_saved(Some(hostile.to_string())); + tick(&mut app, &ctx); + assert_eq!(app.ui.brush_tool, Tool::Brush, "the Move tool has no brush"); + let tools: Vec = app.ui.tool_brushes.iter().map(|(t, _)| *t).collect(); + assert_eq!(tools, vec![Tool::Smudge], "too large, unknown, brushless and the owner's own are skipped"); + let b = &app.ui.tool_brushes[0].1; + assert_eq!((b.size, b.opacity, b.flow, b.hardness), (1.0, 1.0, 0.0, 1.0)); + // A whole preferences file that isn't JSON: defaults, no panic. + let (mut app, _) = app_with_saved(Some("{not json".into())); + tick(&mut app, &ctx); + assert!(app.ui.tool_brushes.is_empty()); + assert_eq!(app.ui.tool_options, crate::state::ToolOptions::default()); + } + + /// #6212: Reset Tool forgets that tool's remembered options and brush; Reset All Tools + /// forgets every tool's. + #[test] + fn reset_tools_clear_the_remembered_options_and_brushes() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + app.ui.tool_options.feather = 12.0; + app.ui.tool_options.strength = 80.0; + app.session.tools.brush.size = 77.0; + app.session.tools.brush_changed(); + for tool in [Tool::Eraser, Tool::Smudge, Tool::Brush] { + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + } + app.ui.tool = Tool::Smudge; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolOptions"]["strength"], json!(80.0)); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_some() && saved["toolBrushes"]["brushes"].get("Brush").is_some()); + + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert!(saved["toolOptions"].get("strength").is_none(), "the Smudge's Strength is forgotten"); + assert_eq!(saved["toolOptions"]["feather"], json!(12.0), "the marquee's Feather is not the Smudge's"); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_some()); + + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert!(saved["toolOptions"].is_null(), "{saved}"); + assert!(saved["toolBrushes"]["brushes"].as_object().is_none_or(|m| m.is_empty()), "{saved}"); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); + assert!(app2.ui.tool_brushes.is_empty()); + } + #[test] fn view_show_items_survive_a_restart() { let (mut app, store) = app_with_store(); diff --git a/crates/ui-egui/src/shape_stroke_ui.rs b/crates/ui-egui/src/shape_stroke_ui.rs index a44e321dfc..e568403c43 100644 --- a/crates/ui-egui/src/shape_stroke_ui.rs +++ b/crates/ui-egui/src/shape_stroke_ui.rs @@ -74,7 +74,7 @@ impl StrokeOptions { }; } - fn valid(&self) -> bool { + pub(crate) fn valid(&self) -> bool { self.stroke(3.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)).validate().is_ok() } } diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 4c0f61b817..80fefe8ad7 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -4,13 +4,23 @@ //! 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. +//! (wrong type, a number too large for its field) keeps its default, and numbers are clamped to +//! the ranges their options-bar fields allow ([`sanitize`]). +//! +//! Each painting tool's own brush (`UiState::tool_brushes`, swapped by +//! `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. -use serde_json::{Map, Value}; +use photocraft_engine::BrushSettings; +use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::state::{Tool, ToolOptions}; +/// The tool whose brush the session brush is, and the other painting tools' brushes. +pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; @@ -58,6 +68,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; } @@ -68,6 +79,68 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// 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. +fn persist_brushes(app: &mut PhotocraftApp) { + let (owner, brushes) = (app.ui.brush_tool, &app.ui.tool_brushes); + if app.prefs_rt.tool_brushes.as_ref().is_some_and(|(o, b)| *o == owner && b == brushes) { + return; + } + let old = app.session.prefs().tool_brushes.get("brushes").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut saved = Map::new(); + for (tool, brush) in brushes { + // The owner's brush is the session brush, remembered as `last_brush`. + if *tool == owner { + continue; + } + let Ok(Value::String(key)) = serde_json::to_value(tool) else { continue }; + if let Some(v) = photocraft_engine::brush_cmds::remembered_settings(brush).or_else(|| old.get(&key).cloned()) { + saved.insert(key, v); + } + } + // Nothing to remember while the Brush owns the session brush and no other tool has its own. + let now = match serde_json::to_value(owner) { + Ok(o) if !saved.is_empty() || owner != Tool::Brush => json!({"owner": o, "brushes": saved}), + _ => Value::Null, + }; + app.prefs_rt.tool_brushes = Some((owner, brushes.clone())); + if app.session.prefs().tool_brushes != now { + app.session.prefs.edit(|p| p.tool_brushes = now); + } +} + +/// Restore the painting tools' own brushes and which tool the session brush (`last_brush`) +/// belongs to. Unknown tools, tools without a brush and brushes that don't read back are skipped +/// with a warning: such a tool starts from the current brush, as on a first launch. +fn restore_brushes(app: &mut PhotocraftApp) { + let saved = app.session.prefs().tool_brushes.clone(); + if !saved.is_null() && !saved.is_object() { + log::warn!("Preferences: the remembered tool brushes are not an object; using the defaults"); + } + let tool = |v: Value| serde_json::from_value::(v).ok().filter(|t| crate::paint_mouse::has_brush_picker(*t)); + if let Some(v) = saved.get("owner") { + match tool(v.clone()) { + Some(owner) => app.ui.brush_tool = owner, + None => log::warn!("Preferences: ignoring the remembered brush owner: {v}"), + } + } + let owner = app.ui.brush_tool; + let mut brushes: Vec<(Tool, BrushSettings)> = Vec::new(); + for (k, v) in saved.get("brushes").and_then(Value::as_object).into_iter().flatten() { + let Some(t) = tool(Value::String(k.clone())).filter(|t| *t != owner) else { + log::warn!("Preferences: ignoring the remembered brush of `{k}`"); + continue; + }; + match photocraft_engine::brush_cmds::restored_settings(&app.session, v) { + Some(b) => brushes.push((t, b)), + None => log::warn!("Preferences: ignoring the remembered brush of `{k}`: it doesn't read back"), + } + } + app.ui.tool_brushes = brushes; + app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); +} + /// Flush the final choice on normal exit, even if no further frame ran after the tool changed. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { persist(app, &egui::Context::default()); @@ -99,13 +172,64 @@ pub fn restore(app: &mut PhotocraftApp) { cur.insert(k.clone(), old); } } - if let Ok(o) = serde_json::from_value(Value::Object(cur)) { + if let Ok(mut o) = serde_json::from_value(Value::Object(cur)) { + sanitize(&mut o); 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()); + restore_brushes(app); +} + +/// Bring remembered options into the ranges their options-bar fields allow, so a hand-edited or +/// damaged file can't start a tool with a value its own controls could never set (a 1e30 px +/// feather, a 0-sided polygon). A NaN (JSON can't hold one, but it costs nothing to rule out) +/// takes the default. +pub(crate) fn sanitize(o: &mut ToolOptions) { + let d = ToolOptions::default(); + let fit = |v: &mut f32, lo: f32, hi: f32, def: f32| *v = if v.is_nan() { def } else { v.clamp(lo, hi) }; + fit(&mut o.tolerance, 0.0, 255.0, d.tolerance); + fit(&mut o.feather, 0.0, 1000.0, d.feather); + fit(&mut o.fill_opacity, 1.0, 100.0, d.fill_opacity); + fit(&mut o.type_size, 0.1, 1296.0, d.type_size); + fit(&mut o.pattern_stamp_scale, 1.0, 1000.0, d.pattern_stamp_scale); + fit(&mut o.pattern_stamp_angle, -180.0, 180.0, d.pattern_stamp_angle); + fit(&mut o.patch_structure, 1.0, 7.0, d.patch_structure); + fit(&mut o.patch_color, 0.0, 10.0, d.patch_color); + fit(&mut o.cam_structure, 1.0, 7.0, d.cam_structure); + fit(&mut o.cam_color, 0.0, 10.0, d.cam_color); + fit(&mut o.exposure, 1.0, 100.0, d.exposure); + fit(&mut o.strength, 1.0, 100.0, d.strength); + fit(&mut o.stroke_width, 0.0, 288.0, d.stroke_width); + fit(&mut o.corner_radius, 0.0, 10000.0, d.corner_radius); + fit(&mut o.line_weight, 1.0, 1000.0, d.line_weight); + fit(&mut o.curve_fit, 0.5, 10.0, d.curve_fit); + // The marquee's Width and Height are a ratio or a size in px, as its Style says. + let (lo, hi) = if o.marquee_style == "fixedSize" { (1.0, 300_000.0) } else { (0.001, 999.0) }; + fit(&mut o.marquee_width, lo, hi, d.marquee_width); + fit(&mut o.marquee_height, lo, hi, d.marquee_height); + fit(&mut o.crop_custom_ratio_width, 0.0, f32::MAX, d.crop_custom_ratio_width); + fit(&mut o.crop_custom_ratio_height, 0.0, f32::MAX, d.crop_custom_ratio_height); + fit(&mut o.magic_eraser_opacity, 1.0, 100.0, d.magic_eraser_opacity); + fit(&mut o.bg_tolerance, 0.0, 100.0, d.bg_tolerance); + fit(&mut o.cr_tolerance, 0.0, 100.0, d.cr_tolerance); + fit(&mut o.magnetic_width, 1.0, 256.0, d.magnetic_width); + fit(&mut o.magnetic_contrast, 1.0, 100.0, d.magnetic_contrast); + fit(&mut o.magnetic_frequency, 0.0, 100.0, d.magnetic_frequency); + fit(&mut o.red_eye_pupil_size, 1.0, 100.0, d.red_eye_pupil_size); + fit(&mut o.red_eye_darken, 0.0, 100.0, d.red_eye_darken); + o.polygon_sides = o.polygon_sides.clamp(3, 100); + if o.crop_overlay_orientation > 3 { + o.crop_overlay_orientation = d.crop_overlay_orientation; + } + if !photocraft_engine::sample_cmds::SAMPLE_SIZES.contains(&o.eyedropper_size) { + o.eyedropper_size = d.eyedropper_size; + } + if !o.shape_stroke.valid() { + o.shape_stroke = d.shape_stroke; + } } /// Does `opts` deserialize, with field `k` coming back as a value? A number out of an `f32`'s diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..0903ae2d6f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 167 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -177,13 +177,13 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 166** (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: 26 of 167** (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.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `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`. | 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) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options | +| 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-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/scorecard/ui.toml b/scorecard/ui.toml index 4ac0ce6386..77074e62b5 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 = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options" +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)" [[item]] id = "UI-217-2" From 2a4a15f3ab92ea28978a5c7dc1da6cbf652d9cb0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 20 Oct 2026 19:40:18 +0200 Subject: [PATCH 2/2] Regenerate docs/scorecard.md Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f8..8987002cbf 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 170 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -179,13 +179,13 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 25 of 169** (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: 25 of 170** (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.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `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`. | 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) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options | +| 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-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-* |