Brush: remember the last selected brush across restarts (split from #2516) (#3472)

* Brush: remember the last selected brush across restarts (split from #2516)

The persistence half of #2516 on its own, so it can merge and be maintained
apart from the brush-outline half. The tool's brush (settings, tip
references, and the name of the preset it was picked from) is saved once the
pointer is up and restored at launch: options bar, painting cursor and
Brushes panel highlight all start where the session left off.

Review fix from #2516 -- no per-frame work:
- `ToolState::brush_rev` is bumped by every brush/preset setter
  (`tools.setBrush`, preset save/delete/rename/setCurrent/deleteGroup,
  `brush.defineFromSelection`, `brush.presets.importAbr`, the store's tip
  re-linking, and the shell's tool-switch/reset paths); `remember_brush`
  serialises only when it moved since `brush_saved_rev`, so an unchanged
  brush costs a counter compare per frame, not a clone and a JSON dump of
  the whole brush (sampled tip previews included).
- Restoring counts as saved: the launch value is never rewritten.

Bitmaps stay out of the preferences document: remembered tips and patterns
are references (a loaded stored tile drops its full bitmap), and a sampled
bitmap too big to embed is skipped, keeping the previous value so a preview
never enters the prefs file. A malformed remembered brush falls back to the
default.

docs/scorecard.md regenerated with `cargo xtask scorecard` (34 of 154).

Tests: engine 1267 lib (5 new: revision gating, rejected changes, picked
preset, restore round trip, no embedded bitmaps), ui-egui 1982 lib; clippy,
layers, wasm green.

* rustfmt the StoredTile line and regenerate the scorecard after the main merge

* engine tests: destructive Levels keep the signed float overshoot at 32 bits (#2592)

* Scorecard: regenerate after merging upstream/main

* Export As: compare batch paths without the Windows separator

* font fix ?

* fix(ui): only Filter menu dialogs get the in-dialog preview window

#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* cargo    fmt --check max_width = 160 fix

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 12:48:41 -04:00
1 parent d0fc974aae
commit 9a4d925b34
16 files changed
+224 -12

No files matched your search

+5 -5
View File
@@ -298,13 +298,14 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
@@ -350,9 +351,8 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
+2 -1
View File
@@ -1638,7 +1638,8 @@ mod tests {
#[test]
fn destructive_levels_dont_clip_in_32_bit() {
// Input 15..230, gamma 1.3, output 10..245: integer documents stop at the output range,
// while float documents preserve the curve's signed and HDR values beyond it.
// while float documents preserve the curve's signed and HDR values beyond it (the
// compose-side `levels_dont_clip_in_32_bit` pins the same curve, #2592).
let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245});
for depth in [8, 16, 32] {
let mut s = crate::Session::new();
+70 -1
View File
@@ -10,7 +10,10 @@ use photocraft_doc::LayerContent;
use photocraft_geom::Rect;
use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke};
use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect};
use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, PresetCaptures, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke};
use photocraft_paint::tile::PREVIEW_SIDE;
use photocraft_paint::{
BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke,
};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -773,6 +776,7 @@ fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new(), captures });
// The saved brush is the current brush now (Photoshop selects the brush it just saved).
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name, "count": s.tools.presets.len() }))
}
@@ -786,6 +790,8 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) {
s.tools.current_preset = None;
// The remembered brush names the preset it was picked from: the name went away.
s.tools.brush_changed();
}
s.brush_presets_changed();
Ok(json!({ "count": s.tools.presets.len() }))
@@ -823,6 +829,7 @@ fn presets_set_current(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no brush preset named `{name}`")));
}
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name }))
}
@@ -889,6 +896,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new(), captures: Default::default() });
s.tools.brush = brush;
s.tools.current_preset = Some(name.clone());
s.tools.brush_changed();
Ok(json!({ "name": name, "width": tw, "height": th }))
}
@@ -948,9 +956,70 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
None => s.tools.brush_gesture = None,
}
// The one accepted brush change everyone (UI sliders, control channel, presets) lands here.
s.tools.brush_changed();
Ok(brush_json(&s.tools.brush))
}
/// Remember the current brush in `Preferences::last_brush` (see [`remembered_brush`]). Called
/// every frame while the pointer is up, so it is change-driven: `ToolState::brush_rev` is compared
/// with the revision already saved and an unchanged brush clones and serialises nothing.
pub fn remember_brush(s: &mut Session) {
if s.tools.brush_rev == s.tools.brush_saved_rev {
return;
}
let Some(now) = remembered_brush(s) else { return };
if s.prefs().last_brush != now {
s.prefs.edit(|p| p.last_brush = now);
}
s.tools.brush_saved_rev = s.tools.brush_rev;
}
/// The current brush as JSON for `Preferences::last_brush`: settings plus tip references, no full
/// 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<Value> {
let b = &s.tools.brush;
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()? }))
}
/// 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);
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.
s.tools.brush_saved_rev = s.tools.brush_rev;
}
/// A tip for remembering: a loaded stored tip drops its full bitmap (it serialises as a reference
/// then); a sampled bitmap bigger than a preview would enter the prefs document whole.
fn ref_tip(t: &TipShape) -> Option<TipShape> {
match t {
TipShape::Stored(r) if r.full.is_some() => Some(TipShape::Stored(StoredTile { full: None, ..r.clone() })),
TipShape::Sampled(g) if g.width > PREVIEW_SIDE || g.height > PREVIEW_SIDE => None,
t => Some(t.clone()),
}
}
fn ref_pattern(p: &Pattern) -> Option<Pattern> {
match p {
Pattern::Stored(r) if r.full.is_some() => Some(Pattern::Stored(StoredTile { full: None, ..r.clone() })),
Pattern::Tile(g) if g.width > PREVIEW_SIDE || g.height > PREVIEW_SIDE => None,
p => Some(p.clone()),
}
}
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
+76
View File
@@ -910,6 +910,82 @@ fn an_offer_that_does_not_match_the_commit_is_ignored() {
assert!(r.get("fromLive").is_none());
}
#[test]
fn last_brush_is_remembered_when_the_revision_moves_only() {
let mut s = session(8, 8);
restore_brush(&mut s);
// A restore is already in the preferences: nothing to write, no clone or serialise.
s.prefs.edit(|p| p.last_brush = json!("sentinel"));
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush, json!("sentinel"), "an unchanged brush must not serialise");
s.execute("tools.setBrush", json!({"size": 23})).unwrap();
assert!(s.tools.brush_rev > s.tools.brush_saved_rev);
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush["brush"]["size"].as_f64(), Some(23.0));
assert_eq!(s.tools.brush_saved_rev, s.tools.brush_rev, "the revision is marked saved");
// Revision unchanged: even a hand-written value stays (the gate is a counter compare).
s.prefs.edit(|p| p.last_brush = json!("sentinel"));
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush, json!("sentinel"));
}
#[test]
fn rejected_brush_changes_do_not_move_the_revision() {
let mut s = session(8, 8);
let rev = s.tools.brush_rev;
assert!(s.execute("tools.setBrush", json!({"size": "big"})).is_err());
assert_eq!(s.tools.brush_rev, rev, "a rejected brush leaves no change behind");
s.execute("tools.setBrush", json!({"size": 31})).unwrap();
assert!(s.tools.brush_rev > rev);
}
#[test]
fn the_revision_tracks_the_picked_preset_too() {
let mut s = session(8, 8);
s.execute("tools.setBrush", json!({"preset": "Hard Round"})).unwrap();
let rev = s.tools.brush_rev;
s.execute("brush.presets.setCurrent", json!({"name": "Soft Round"})).unwrap();
assert!(s.tools.brush_rev > rev, "the remembered preset name follows the picked preset");
remember_brush(&mut s);
assert_eq!(s.prefs().last_brush["preset"].as_str(), Some("Soft Round"));
}
#[test]
fn the_remembered_brush_restores_at_launch() {
let mut s = session(8, 8);
s.execute("tools.setBrush", json!({"preset": "Hard Round", "size": 44, "hardness": 0.3})).unwrap();
remember_brush(&mut s);
let stored = s.prefs().last_brush.clone();
assert_eq!(stored["preset"].as_str(), Some("Hard Round"));
let mut t = Session::new();
assert_ne!(t.tools.brush.size, 44.0, "a fresh session starts on the default brush");
t.prefs.edit(|p| p.last_brush = stored);
restore_brush(&mut t);
assert_eq!(t.tools.brush.size, 44.0);
assert_eq!(t.tools.brush.hardness, 0.3);
assert_eq!(t.tools.current_preset.as_deref(), Some("Hard Round"));
assert_eq!(t.tools.brush_saved_rev, t.tools.brush_rev, "the restored value is already stored");
// A malformed remembered brush keeps the default (never an error, never a panic).
let mut bad = Session::new();
bad.prefs.edit(|p| p.last_brush = json!({"brush": {"size": "huge"}}));
restore_brush(&mut bad);
assert_eq!(bad.tools.brush.size, Session::new().tools.brush.size);
}
#[test]
fn remembered_brushes_never_embed_large_bitmaps() {
use std::sync::Arc;
let big = GrayTile::from_fn(PREVIEW_SIDE + 1, 8, |_, _| 1.0);
assert!(ref_tip(&TipShape::Sampled(big)).is_none(), "a big sampled tip cannot be embedded");
let small = GrayTile::from_fn(8, 8, |_, _| 1.0);
assert!(ref_tip(&TipShape::Sampled(small.clone())).is_some(), "small tiles embed");
let stored =
StoredTile { key: "k".into(), width: 300, height: 200, preview: Arc::new(small), full: Some(Arc::new(GrayTile::from_fn(300, 200, |_, _| 1.0))) };
let t = ref_tip(&TipShape::Stored(stored)).expect("stored tips are remembered as references");
assert!(matches!(t, TipShape::Stored(r) if r.full.is_none()), "a loaded full bitmap is dropped");
assert!(ref_pattern(&Pattern::Tile(GrayTile::from_fn(PREVIEW_SIDE + 1, 4, |_, _| 1.0))).is_none());
}
/// Photoshop's Clear painting mode (#3439): a Brush or Pencil stroke in `"mode": "clear"` erases
/// with that brush, and History still names the tool rather than the Eraser.
#[test]
+2
View File
@@ -119,6 +119,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
pr.builtin = false;
if was_current {
s.tools.current_preset = Some(new.clone());
s.tools.brush_changed();
}
s.brush_presets_changed();
}
@@ -433,6 +434,7 @@ fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
s.tools.presets.retain(|x| !in_folder(x, &g, &f));
if s.tools.current_preset.as_ref().is_some_and(|c| !s.tools.presets.iter().any(|x| x.name.eq_ignore_ascii_case(c))) {
s.tools.current_preset = None;
s.tools.brush_changed();
}
s.brush_presets_changed();
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
+16
View File
@@ -299,6 +299,13 @@ pub struct ToolState {
/// preset stays "the current brush" so `brush.presets.update` can overwrite it); cleared when
/// the brush is reset or the preset is gone.
pub current_preset: Option<String>,
/// Bumped whenever `brush` or `current_preset` changes ([`ToolState::brush_changed`]). The
/// last-brush persistence watches it instead of comparing the brush itself, so an unchanged
/// brush costs a counter compare per frame, not a clone and serialise.
pub brush_rev: u64,
/// The revision whose value is in `Preferences::last_brush` (a restored one counts as saved):
/// [`brush_rev`](Self::brush_rev) == `brush_saved_rev` means there is nothing new to write.
pub brush_saved_rev: u64,
/// A layer mask is the edit target ([`ToolState::target_mask`]).
pub mask_targeted: bool,
/// The foreground/background pair of the edit target that isn't current: the layer pixels'
@@ -333,6 +340,13 @@ impl ToolState {
/// background (reveal-paint first).
pub const MASK_COLORS: [[f32; 4]; 2] = [[1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]];
/// Note that `brush` or `current_preset` changed. Every setter of either (commands and the
/// shell's tool-switch paths) calls this; the last-brush persistence only serialises when the
/// revision has moved since the stored value ([`Self::brush_saved_rev`]).
pub fn brush_changed(&mut self) {
self.brush_rev = self.brush_rev.wrapping_add(1);
}
/// Switch the edit target between layer pixels and a layer mask. Like Photoshop, the mask and
/// the pixels keep separate app-wide foreground/background pairs: changing the target puts
/// the current pair aside and brings back the other one, so colours picked while a mask is
@@ -365,6 +379,8 @@ impl Default for ToolState {
mixer: Default::default(),
brush_gesture: None,
current_preset: None,
brush_rev: 0,
brush_saved_rev: 0,
mask_targeted: false,
other_colors: Self::MASK_COLORS,
cmyk_picks: Vec::new(),
+4
View File
@@ -1007,6 +1007,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 brush the tool was last using (settings and tip references, never the full bitmaps;
/// shaped by `brush_cmds::remembered_brush`), restored at launch so the tool starts where it
/// was left. Written only when `ToolState::brush_rev` moves.
pub last_brush: 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.
+1
View File
@@ -106,6 +106,7 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
let mut b = pr.brush.clone().picked_over(&s.tools.brush);
s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?;
s.tools.brush = b;
s.tools.brush_changed();
}
// Distinct folder paths the file brought (its Photoshop folders, nested inside `group`).
let folders: std::collections::BTreeSet<&[String]> =
+16 -2
View File
@@ -1125,6 +1125,11 @@ impl Session {
}
self.brush_presets_changed();
self.sync_preset_store();
// A brush remembered from the previous run (or picked before the store loaded) may be
// painting with previews; load the full tips now (a missing one keeps its preview).
let mut brush = std::mem::take(&mut self.tools.brush);
let _ = self.load_brush_tips(&mut brush);
self.tools.brush = brush;
if let Some(st) = self.preset_store.as_mut() {
warnings.extend(st.take_warnings());
}
@@ -1139,7 +1144,11 @@ impl Session {
if st.synced_rev != Some(rev) {
st.sync(&mut self.tools.presets);
st.synced_rev = Some(rev);
link_stored_tips(st, &mut self.tools.brush);
if link_stored_tips(st, &mut self.tools.brush) {
// An embedded tip became a store reference: what the last-brush
// persistence would write changed without a command touching the brush.
self.tools.brush_changed();
}
}
st.sync_actions(&self.actions.list, actions_rev);
}
@@ -1172,7 +1181,7 @@ impl Session {
/// After a sync, an embedded bitmap of the tool's brush that the store now holds (the brush was
/// just saved as a preset) becomes the stored tile, keeping its bitmap: the brush then matches its
/// preset (Brushes panel highlight) like a preset picked from the library.
fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) {
fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) -> bool {
let link = |g: &GrayTile| -> Option<StoredTile> {
if !g.is_valid() {
return None;
@@ -1180,24 +1189,29 @@ fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) {
let h = tip_hash(g);
st.tips.contains_key(&h).then(|| StoredTile { key: h, width: g.width, height: g.height, preview: Arc::new(g.preview(PREVIEW_SIDE)), full: None })
};
let mut linked = false;
if let TipShape::Sampled(g) = &b.tip
&& let Some(r) = link(g)
&& let TipShape::Sampled(g) = std::mem::take(&mut b.tip)
{
b.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r });
linked = true;
}
if let TipShape::Sampled(g) = &b.dual_brush.tip
&& let Some(r) = link(g)
&& let TipShape::Sampled(g) = std::mem::take(&mut b.dual_brush.tip)
{
b.dual_brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r });
linked = true;
}
if let Pattern::Tile(g) = &b.texture.pattern
&& let Some(r) = link(g)
&& let Pattern::Tile(g) = std::mem::take(&mut b.texture.pattern)
{
b.texture.pattern = Pattern::Stored(StoredTile { full: Some(Arc::new(g)), ..r });
linked = true;
}
linked
}
#[cfg(test)]
+1 -1
View File
@@ -739,7 +739,7 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() {
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(written.borrow().len(), 2);
assert_eq!(written.borrow()[0], "/out/boards.jpg");
// The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows).
// The sibling path is built with `Path::join`, so Windows turns the separator into `\`.
let second = written.borrow()[1].replace('\\', "/");
assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow());
let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::<Vec<_>>();
+21
View File
@@ -0,0 +1,21 @@
//! The brush the tool was last using, remembered across restarts (`Preferences::last_brush`) so
//! the next launch starts with the same brush, options bar and painting cursor outline.
//!
//! Saved once the pointer is up (a slider drag saves once, like the Brush Preset picker's view),
//! and change-driven: `remember_brush` watches `ToolState::brush_rev`, so an unchanged brush
//! costs a counter compare per frame, not a clone and a JSON serialisation of the whole brush.
use crate::PhotocraftApp;
/// Remember the current brush once the pointer is up.
pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
if !ctx.input(|i| i.pointer.any_down()) {
photocraft_engine::brush_cmds::remember_brush(&mut app.session);
}
}
/// Restore the remembered brush at launch; stored tips load when the preset store attaches
/// (`Session::attach_preset_store`).
pub fn restore(app: &mut PhotocraftApp) {
photocraft_engine::brush_cmds::restore_brush(&mut app.session);
}
+5
View File
@@ -105,6 +105,7 @@ pub mod icons;
pub mod jobs_ui;
pub mod kys_import;
pub mod lasso_ui;
pub(crate) mod last_brush;
pub mod layer_menu_ui;
pub mod layer_pick_ui;
pub mod layer_picker;
@@ -1861,6 +1862,10 @@ 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);
// The tool's brush (settings and tip references) is remembered the same way. Like the
// picker's view, in the frame loop, so the control channel can set it with the panels
// closed; the engine's revision counter keeps an unchanged brush free.
last_brush::persist(self, &ctx);
// Tool options are remembered between launches the same way (#2814).
tool_memory::persist(self, &ctx);
// So are View › Extras, Show and Snap To (#3762).
+1
View File
@@ -37,6 +37,7 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) {
// The picked preset was the outgoing tool's brush: forget it, so Update Brush can't overwrite
// that preset with this tool's brush.
app.session.tools.current_preset = None;
app.session.tools.brush_changed();
app.ui.brush_tool = tool;
}
+1
View File
@@ -220,6 +220,7 @@ pub fn load(app: &mut PhotocraftApp) {
}
crate::dock::restore(app);
crate::brush_picker::restore(app);
crate::last_brush::restore(app);
sync_rulers(app);
crate::tool_memory::restore(app);
crate::view_cmds::restore(app);
+1
View File
@@ -92,6 +92,7 @@ fn reset_session_brush(app: &mut PhotocraftApp) {
app.session.tools.brush = crate::paint_mouse::fresh_brush(&app.session.tools.brush);
app.session.tools.brush_gesture = None;
app.session.tools.current_preset = None;
app.session.tools.brush_changed();
}
fn is_selection_tool(tool: Tool) -> bool {
+2 -2
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) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 |
| [Tools](#tools) | 28 | 14 | 1 | |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (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 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: 26 of 156** (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 157** (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`.