mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 22:24:37 +00:00
Merge PR #6213: Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212)
# Conflicts: # docs/scorecard.md
This commit is contained in:
commit
b5f48b3d7b
8 files changed
+330
-14
No files matched your search
@@ -1036,23 +1036,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<Value> {
|
||||
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<Value> {
|
||||
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<BrushSettings> {
|
||||
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.
|
||||
|
||||
@@ -1187,6 +1187,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;
|
||||
|
||||
@@ -993,6 +993,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,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -53,6 +53,8 @@ pub struct Runtime {
|
||||
/// Tool options as last remembered in the preferences (see `tool_memory`).
|
||||
pub(crate) tool_options: Option<crate::state::ToolOptions>,
|
||||
pub(crate) selected_tool: Option<crate::state::Tool>,
|
||||
/// The tool owning the session brush and the other tools' brushes, as last remembered.
|
||||
pub(crate) tool_brushes: Option<crate::tool_memory::ToolBrushes>,
|
||||
}
|
||||
|
||||
/// The GPU canvas colours from Preferences › Transparency & Gamut.
|
||||
@@ -3557,6 +3559,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<Tool> = 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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Tool>(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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user