From 3d90d8edfe34b10af747d1874fd672a083b06fbe Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:33:28 +0200 Subject: [PATCH] Keep View toggles such as Rulers in the preferences Fixes #379 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 74 ++++++++++++++++++----- crates/engine/src/lib.rs | 18 ++++++ crates/engine/src/view_behaviour_tests.rs | 24 ++++++++ crates/ui-egui/src/lib.rs | 2 + 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5df..c89a4925 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -19,6 +19,48 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul Ok(json!({"on": *slot})) } +/// One of a view's on/off settings. +type Slot = fn(&mut ViewState) -> &mut bool; + +/// The View menu's toggles the preferences keep: files opened or made later start with them. +const KEPT: &[(&str, Slot)] = &[ + ("view.pixelGrid", |v| &mut v.pixel_grid), + ("view.layoutGrids", |v| &mut v.layout_grids), + ("view.rulers", |v| &mut v.rulers), + ("view.slices", |v| &mut v.show_slices), + ("view.comments", |v| &mut v.comments), + ("view.multiplayerCursors", |v| &mut v.multiplayer_cursors), + ("view.outlines", |v| &mut v.outlines), + ("view.outlinesSelection", |v| &mut v.outlines_selection), + ("view.outlinesHiddenLayers", |v| &mut v.outline_hidden), + ("view.outlinesObjectBounds", |v| &mut v.outline_bounds), + ("view.maskOutlines", |v| &mut v.mask_outlines), + ("view.frameOutlines", |v| &mut v.frame_outlines), + ("view.proofSrgb", |v| &mut v.proof_srgb), + ("view.snapGeometry", |v| &mut v.snap_geometry), + ("view.snapObjects", |v| &mut v.snap_objects), + ("view.snapPixel", |v| &mut v.snap_pixel), +]; + +/// A View toggle the preferences keep: the file's view changes and the preferences remember it. +fn kept(s: &mut Session, p: &Value, id: &str) -> Result { + let Some((_, f)) = KEPT.iter().find(|(k, _)| *k == id) else { return Err(bad(id, "not a View toggle")) }; + let r = flag(s, p, *f)?; + if let Some(on) = r.get("on").and_then(Value::as_bool) { + s.prefs.view.insert(id.to_string(), on); + } + Ok(r) +} + +/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are). +pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) { + for (id, f) in KEPT { + if let Some(on) = prefs.view.get(*id) { + *f(v) = *on; + } + } +} + /// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command /// that isn't one. pub fn checked(s: &Session, id: &str) -> Option { @@ -227,46 +269,46 @@ pub fn specs() -> Vec { spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)), spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)), - spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)), - spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)), - spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)), - spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), - spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), + spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| kept(s, p, "view.pixelGrid")), + spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| kept(s, p, "view.layoutGrids")), + spec!("view.rulers", "Rulers", "{on?}", false, |s, p| kept(s, p, "view.rulers")), + spec!("view.slices", "Slices", "{on?}", false, |s, p| kept(s, p, "view.slices")), + spec!("view.comments", "Comments", "{on?}", false, |s, p| kept(s, p, "view.comments")), spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { - flag(s, p, |v| &mut v.multiplayer_cursors) + kept(s, p, "view.multiplayerCursors") }), spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), - spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), + spec!("view.outlines", "Outlines", "{on?}", false, |s, p| kept(s, p, "view.outlines")), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { - flag(s, p, |v| &mut v.outlines_selection) + kept(s, p, "view.outlinesSelection") }), spec!( "view.outlinesHiddenLayers", "Include hidden layers in outlines", "{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there", false, - |s, p| { flag(s, p, |v| &mut v.outline_hidden) } + |s, p| { kept(s, p, "view.outlinesHiddenLayers") } ), spec!( "view.outlinesObjectBounds", "Include object bounds in outlines", "{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer", false, - |s, p| { flag(s, p, |v| &mut v.outline_bounds) } + |s, p| { kept(s, p, "view.outlinesObjectBounds") } ), - spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), + spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| kept(s, p, "view.maskOutlines")), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), - spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), + spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| kept(s, p, "view.frameOutlines")), spec!( "view.proofSrgb", "Soft-proof sRGB", "{on?} mark the colours of a Display P3 file that sRGB screens and files can't show", false, - |s, p| { flag(s, p, |v| &mut v.proof_srgb) } + |s, p| { kept(s, p, "view.proofSrgb") } ), - spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), - spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)), - spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)), + spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| kept(s, p, "view.snapGeometry")), + spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| kept(s, p, "view.snapObjects")), + spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| kept(s, p, "view.snapPixel")), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| { s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..d3e480a6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -328,6 +328,8 @@ pub struct DocState { pub guide: Option<(NodeId, usize)>, /// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved). pub(crate) rotation_origin: Option, + /// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]). + view_from_prefs: bool, } /// A version shown on the canvas in place of the document: read-only until it's restored or the @@ -383,6 +385,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + view_from_prefs: false, }; st.zoom_to_fit(); st @@ -599,6 +602,10 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id: + /// files opened or made later start with them. Missing: the toggle's default. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub view: BTreeMap, } impl Default for Prefs { @@ -627,6 +634,7 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + view: BTreeMap::new(), } } } @@ -764,6 +772,14 @@ impl Session { s } + /// Files new to the session start with the View toggles the preferences keep. + pub fn apply_view_prefs(&mut self) { + for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) { + cmd::view::apply_kept(&self.prefs, &mut ds.view); + ds.view_from_prefs = true; + } + } + pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } @@ -805,6 +821,7 @@ impl Session { return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into())); } branches::sync(self); + self.apply_view_prefs(); if textpreview::COMMANDS.contains(&id) { // A live preview is no edit: no undo step, normalisation or rollback here. let r = guard::run(id, || (spec.run)(self, params)); @@ -828,6 +845,7 @@ impl Session { match &result { Ok(_) => { self.after_command(spec, before); + self.apply_view_prefs(); if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index c22cd1f5..c160723f 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -725,3 +725,27 @@ fn alt_measures_between_layers() { assert_eq!(measured(&mut s, p, m), want, "{why}"); } } + +#[test] +fn view_toggles_are_kept_for_the_next_launch() { + let mut s = Session::new(); + // (command, turned to, ViewState field, derivation) + let rows: Vec<(&str, bool, fn(&ViewState) -> bool, &str)> = vec![ + ("view.rulers", true, |v| v.rulers, "rulers shown stay shown"), + ("view.pixelGrid", false, |v| v.pixel_grid, "a hidden pixel grid stays hidden"), + ("view.layoutGrids", false, |v| v.layout_grids, "hidden layout grids stay hidden"), + ("view.snapPixel", false, |v| v.snap_pixel, "snapping to the pixel grid off stays off"), + ]; + for (cmd, on, _, _) in &rows { + ex(&mut s, cmd, json!({"on": on})); + } + // The app writes its preferences as JSON and reads them back at the next launch. + let saved = serde_json::to_value(&s.prefs).unwrap(); + let mut next = Session::empty(); + next.prefs = serde_json::from_value(saved).unwrap(); + ex(&mut next, "file.new", json!({})); + let view = &next.ds().unwrap().view; + for (cmd, on, get, why) in rows { + assert_eq!(get(view), on, "{cmd}: {why}"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..7b18e072 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -942,6 +942,8 @@ impl FigApp { } self.background_done(&ctx); } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui);