View: remember Extras, Show and Snap To across restarts (#3762) (#3787)

* View: remember Extras, Show and Snap To across restarts (#3762)

View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and
the View › Snap To targets reset to their defaults on every launch. They
are now saved in Preferences (`viewExtras`) as they change and restored at
startup, as Photoshop keeps them between sessions.

Only items that differ from their defaults are stored, so a default
changed in a later release still reaches users who never touched it. A
hand-edited file can't break them: unknown keys and non-boolean values
keep their defaults.

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

* View: rustfmt the view-extras restart test (#3762)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JGF
2026-10-12 07:47:33 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 53d814f643
commit edc1b1e774
4 changed files with 140 additions and 0 deletions
+4
View File
@@ -994,6 +994,10 @@ 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,
/// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To
/// targets that differ from their defaults, saved as the user toggles them and restored at
/// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell.
pub view_extras: Value,
/// File › Scripts › Script Events Manager: event → script bindings.
pub script_events: crate::automate_cmds::ScriptEvents,
}
+2
View File
@@ -1725,6 +1725,8 @@ impl eframe::App for PhotocraftApp {
brush_picker::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).
view_cmds::persist(self, &ctx);
self.automation_input = false;
shape_stroke_ui::finish_frame(self, &ctx);
native_menu::sync(self, &ctx);
+34
View File
@@ -218,6 +218,7 @@ pub fn load(app: &mut PhotocraftApp) {
crate::brush_picker::restore(app);
sync_rulers(app);
crate::tool_memory::restore(app);
crate::view_cmds::restore(app);
app.sync_recent();
app.prefs_rt.saved_rev = app.session.prefs.rev();
app.prefs_rt.saved_value = Some(app.session.prefs_value());
@@ -2649,6 +2650,39 @@ mod tests {
}
}
#[test]
fn view_show_items_survive_a_restart() {
let (mut app, store) = app_with_store();
let ctx = egui::Context::default();
tick(&mut app, &ctx);
// Untouched: nothing is stored, so later default changes still reach the user.
crate::view_cmds::persist(&mut app, &ctx);
tick(&mut app, &ctx);
assert!(stored(&store).get("viewExtras").is_none_or(Value::is_null));
// The menu commands, as a user toggles them (#3762: Pixel Grid off).
for id in ["view.show.pixelGrid", "view.show.grid", "view.snapTo.layers", "view.extras"] {
crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap();
}
crate::view_cmds::persist(&mut app, &ctx);
tick(&mut app, &ctx);
assert_eq!(stored(&store)["viewExtras"], json!({"extras": false, "grid": true, "show": {"pixelGrid": false}, "snapTo": {"layers": false}}));
// A restart gets them back.
let saved = store.lock().unwrap().clone().unwrap();
let (mut app2, _) = app_with_saved(Some(saved));
tick(&mut app2, &ctx);
let v = &app2.ui.view;
assert!(!v.extras && !v.show.pixel_grid && !v.snap_to.layers && app2.ui.extras.grid);
assert!(v.show.selection_edges && v.snap_to.guides && app2.ui.extras.guides, "the rest keep their defaults");
// A hand-edited file: unknown keys and non-booleans keep the defaults, good ones still load.
for bad in [json!("junk"), json!({"extras": 0, "show": {"pixelGrid": "no", "mesh": true, "bogus": true}, "snapTo": [1]})] {
let (mut app3, _) = app_with_saved(Some(json!({"viewExtras": bad}).to_string()));
tick(&mut app3, &ctx);
let v = &app3.ui.view;
assert!(v.extras && v.show.pixel_grid && v.snap_to.layers, "{bad}");
assert_eq!(v.show.mesh, bad.is_object());
}
}
#[test]
fn show_tooltips_preferences_turn_tooltips_off() {
let (mut app, _) = app_with_store();
+100
View File
@@ -214,6 +214,106 @@ fn snap_slot<'a>(s: &'a mut SnapTo, key: &str) -> Option<&'a mut bool> {
})
}
/// Every [`show_slot`] key.
const SHOW_KEYS: [&str; 13] = [
"layerEdges",
"selectionEdges",
"targetPath",
"pixelGrid",
"smartGuides",
"brushPreview",
"slices",
"notes",
"mesh",
"editPins",
"count",
"canvasGuides",
"artboardGuides",
];
/// Every [`snap_slot`] key.
const SNAP_KEYS: [&str; 5] = ["guides", "grid", "layers", "slices", "documentBounds"];
/// The flags of `now` that differ from `def`, by key (`None` when none do).
fn changed_flags<T: Copy>(now: T, def: T, keys: &[&str], slot: impl for<'a> Fn(&'a mut T, &str) -> Option<&'a mut bool>) -> Option<Value> {
let (mut now, mut def) = (now, def);
let diff: Map<String, Value> = keys
.iter()
.filter_map(|k| {
let v = *slot(&mut now, k)?;
(Some(v) != slot(&mut def, k).copied()).then(|| ((*k).to_string(), Value::Bool(v)))
})
.collect();
(!diff.is_empty()).then_some(Value::Object(diff))
}
/// View › Extras, Show and Snap To as remembered across restarts (`Preferences::view_extras`):
/// only what differs from the defaults, so a default changed in a later release still reaches
/// users who never touched that item. `Null` when nothing differs.
fn remembered(app: &PhotocraftApp) -> Value {
let (v, e, def) = (&app.ui.view, &app.ui.extras, crate::state::Extras::default());
let mut m = Map::new();
if v.extras != ViewOptions::default().extras {
m.insert("extras".into(), Value::Bool(v.extras));
}
// View › Show › Grid and Guides live in `UiState::extras` beside the rulers and snapping.
if e.grid != def.grid {
m.insert("grid".into(), Value::Bool(e.grid));
}
if e.guides != def.guides {
m.insert("guides".into(), Value::Bool(e.guides));
}
if let Some(show) = changed_flags(v.show, Show::default(), &SHOW_KEYS, show_slot) {
m.insert("show".into(), show);
}
if let Some(snap) = changed_flags(v.snap_to, SnapTo::default(), &SNAP_KEYS, snap_slot) {
m.insert("snapTo".into(), snap);
}
if m.is_empty() { Value::Null } else { Value::Object(m) }
}
/// Remember View › Extras, Show and Snap To in the preferences once the user lets go of the
/// pointer (#3762). Cheap: a small JSON compare per frame. Run every frame, so items set by the
/// control channel or the Show Extras Options dialog are remembered too.
pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_down()) {
return;
}
let now = remembered(app);
if app.session.prefs().view_extras != now {
app.session.prefs.edit(|p| p.view_extras = now);
}
}
/// Restore the remembered View › Extras, Show and Snap To items at launch. A hand-edited file
/// can't break them: an unknown key or a value that isn't a boolean keeps its default.
pub fn restore(app: &mut PhotocraftApp) {
let saved = app.session.prefs().view_extras.clone();
if !saved.is_null() && !saved.is_object() {
log::warn!("Preferences: the remembered View › Show items are not an object; using the defaults");
return;
}
let flag = |k: &str| saved.get(k).and_then(Value::as_bool);
if let Some(on) = flag("extras") {
app.ui.view.extras = on;
}
if let Some(on) = flag("grid") {
app.ui.extras.grid = on;
}
if let Some(on) = flag("guides") {
app.ui.extras.guides = on;
}
for (k, v) in saved.get("show").and_then(Value::as_object).into_iter().flatten() {
if let (Some(slot), Some(on)) = (show_slot(&mut app.ui.view.show, k), v.as_bool()) {
*slot = on;
}
}
for (k, v) in saved.get("snapTo").and_then(Value::as_object).into_iter().flatten() {
if let (Some(slot), Some(on)) = (snap_slot(&mut app.ui.view.snap_to, k), v.as_bool()) {
*slot = on;
}
}
}
/// Window › <panel>: (panel, dock tab) for panels that live as tabs of a dock card.
fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> {
let pro = app.ui.theme.is_pro();