mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
* 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:
16 files changed
+224
-12
No files matched your search
@@ -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(())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]> =
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<_>>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user