From 3537343622d387ad215da0de422c55fe5e0b57a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:38:06 +0200 Subject: [PATCH 1/4] Preferences: Units & Increments with no document open set new documents' units As in InDesign, Preferences with no document open has Units & Increments: the horizontal and vertical ruler units, the ruler origin and the cursor-key and kerning increments. They are kept with the application preferences (`prefs.set {defaultUnits}`) and new documents start with them; New Document opens in those units, and its [Default] card uses them. A document keeps its own Units & Increments once made, and Preferences with a document open still edits that document's. `file.new` without a preset or `units` uses the default units; units picked in New Document set both rulers unless they are the default horizontal ones, which keep the default vertical units. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/file.rs | 16 ++++++-- crates/engine/src/cmd/prefs.rs | 48 ++++++++++++++++++++++- crates/engine/src/lib.rs | 56 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 62 ++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 13 ++++++- crates/ui-egui/src/new_document.rs | 11 +++++- 7 files changed, 196 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..264336ad8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Preferences › Units & Increments with no document open, as in InDesign: the ruler units (horizontal and vertical), the ruler origin and the cursor-key and kerning increments set there are what new documents start with, and New Document opens in those units. They are kept with the application preferences (`prefs.set {defaultUnits}`); a document keeps its own once made. Reported by a reader who works in inches and had to change every file. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index da38b8132..f658d4b92 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -221,10 +221,11 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +/// Without a preset or `units`, it is in the default units (Preferences with no document). fn new_document_settings(s: &Session, p: &Value) -> Result { let mut nd = match str_param(p, "preset") { Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), + None => s.default_new_document(), }; apply_settings(&mut nd, p); check_size("file.new", &nd)?; @@ -235,12 +236,19 @@ fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = Document::new(&nd); + let mut d = Document::new(&nd); + s.prefs.default_units.apply(&mut d.settings); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } impl Session { + /// What New Document starts with: the factory settings, in the default units (Preferences + /// with no document open). + pub fn default_new_document(&self) -> NewDocument { + NewDocument { units: self.prefs.default_units.horizontal_units, ..NewDocument::default() } + } + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while /// the New Document window is open. It replaces the previous preview document in its tab and /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses @@ -259,7 +267,9 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(Document::new(&nd), None); + let mut d = Document::new(&nd); + self.prefs.default_units.apply(&mut d.settings); + let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 989494507..781d3ef13 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -50,12 +50,14 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], recentFonts?: [family], showFontNamesInEnglish?, smartTextReflow?, autocorrect?, autocorrectList?: [[word, correction]], showAddedText?, showNoteAnchors?, cjkFeatures?: bool|null (CJK-only menu items and dialogs shown; null follows the interface language), linkTextFiles? (Create Links When Placing Text Files), recoveryMinutes?, recentFilesMax?: 0–100, defaultUnits?: {horizontalUnits?, verticalUnits?, rulerOrigin?, keyboardIncrement? (pt), kerningIncrement? (1/1000 em)} (Units & Increments of new documents)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; let new = merge("prefs.set", cur, p, None)?; - s.prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + let prefs: crate::Prefs = serde_json::from_value(new).map_err(|e| bad("prefs.set", e.to_string()))?; + prefs.default_units.check().map_err(|e| bad("prefs.set", e))?; + s.prefs = prefs; s.prefs.trim_recent_files(); serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string())) } @@ -141,6 +143,48 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas); } + + /// Units & Increments set with no document open are what new documents start with, as in + /// InDesign: a reader who works in inches sets them once, not in every file. + #[test] + fn default_units_are_what_new_documents_start_with() { + use designcraft_geom::Unit; + let mut s = Session::new(); + let r = s + .execute( + "prefs.set", + &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "inches", "keyboardIncrement": 0.5, "rulerOrigin": "page"}}), + ) + .unwrap(); + assert_eq!(r["defaultUnits"]["horizontalUnits"], "inches"); + assert_eq!(r["defaultUnits"]["kerningIncrement"], 20.0, "the rest kept"); + s.execute("file.new", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Inches)); + assert_eq!((st.keyboard_increment, st.ruler_origin), (0.5, designcraft_doc::RulerOrigin::Page)); + assert!(!s.doc().unwrap().is_dirty(), "made so, not edited"); + // Inches across, millimetres down: New Document's units (the horizontal ones) keep the + // vertical preference; other units picked there set both rulers. + s.execute("prefs.set", &json!({"defaultUnits": {"verticalUnits": "millimeters"}})).unwrap(); + s.execute("file.new", &json!({"units": "inches"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Inches, Unit::Millimeters)); + s.execute("file.new", &json!({"units": "points"})).unwrap(); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); + // A preset keeps its own units. + s.execute("file.new", &json!({"preset": "A4"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, Unit::Millimeters); + // New Document opens with them; bad values are refused and change nothing. + assert_eq!(s.default_new_document().units, Unit::Inches); + for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { + assert!(s.execute("prefs.set", &json!({"defaultUnits": bad})).is_err(), "{bad}"); + } + assert_eq!(s.prefs.default_units.keyboard_increment, 0.5); + // Kept with the application preferences. + let saved: crate::Prefs = serde_json::from_slice(&serde_json::to_vec(&s.prefs).unwrap()).unwrap(); + assert_eq!(saved.default_units, s.prefs.default_units); + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d16dc9dd1..9c5c64a35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -243,6 +243,61 @@ pub struct Prefs { pub recent_files: Vec, /// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list). pub recent_files_max: u32, + /// Preferences › Units & Increments set with no document open: what new documents start + /// with, as InDesign's application defaults. A document keeps its own once made. + pub default_units: DefaultUnits, +} + +/// Units & Increments for new documents (Preferences with no document open), in the keys +/// `document.preferences` uses. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DefaultUnits { + pub horizontal_units: designcraft_geom::Unit, + pub vertical_units: designcraft_geom::Unit, + pub ruler_origin: designcraft_doc::RulerOrigin, + /// Cursor Key, in points. + pub keyboard_increment: f64, + /// Kerning/Tracking, in 1/1000 em. + pub kerning_increment: f64, +} + +impl Default for DefaultUnits { + /// A new document's own settings. + fn default() -> Self { + let d = designcraft_doc::DocSettings::default(); + DefaultUnits { + horizontal_units: designcraft_geom::Unit::Picas, + vertical_units: designcraft_geom::Unit::Picas, + ruler_origin: d.ruler_origin, + keyboard_increment: d.keyboard_increment, + kerning_increment: d.kerning_increment, + } + } +} + +impl DefaultUnits { + /// Gives a new document made in `units` these defaults. Its vertical ruler follows the + /// preference while the document keeps the default horizontal units (units picked in New + /// Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings) { + if settings.horizontal_units == self.horizontal_units { + settings.vertical_units = self.vertical_units; + } + settings.ruler_origin = self.ruler_origin; + settings.keyboard_increment = self.keyboard_increment; + settings.kerning_increment = self.kerning_increment; + } + + /// Refuses increments that aren't finite and positive (at most 1000). + pub fn check(&self) -> std::result::Result<(), String> { + for (name, v) in [("keyboardIncrement", self.keyboard_increment), ("kerningIncrement", self.kerning_increment)] { + if !(v.is_finite() && v > 0.0 && v <= 1000.0) { + return Err(format!("`defaultUnits.{name}` must be more than 0 and at most 1000")); + } + } + Ok(()) + } } /// The most recent files Preferences lets File › Open Recent keep. @@ -317,6 +372,7 @@ impl Default for Prefs { link_text_files: false, recent_files: Vec::new(), recent_files_max: 20, + default_units: DefaultUnits::default(), } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..502b8e0e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1562,10 +1562,11 @@ fn confirm_document_setup(app: &mut DesignApp, d: &Dialog) -> Result Result { } app.run("view.snapPreferences", snap)?; } + if d.b("defaults") { + // No document open: the Units & Increments new documents start with. + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { + du["keyboardIncrement"] = json!(v.clamp(0.001, 1000.0)); + } + if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { + du["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); + } + app.run("prefs.set", json!({"defaultUnits": du}))?; + return Ok(Value::Null); + } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; let mut doc = json!({"horizontalUnits": horizontal}); // Fields can come from `ui.dialog.open`; rulers it doesn't name keep their units. @@ -7218,6 +7237,43 @@ mod tests { } } + /// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them + /// for new documents, and New Document opens in them (Lollo's inches, set once). + #[test] + fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); + } + #[test] fn preferences_ok_sets_only_the_ruler_units_it_is_given() { // `ui.dialog.open` takes any fields, so the vertical units can be missing. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb43f8cb9..6f514dd9f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1024,7 +1024,9 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + // In the default units (Preferences with no document open). + let fields = crate::new_document::defaults_for(&app.session.default_new_document()); + app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", fields)); Ok(Value::Null) } "app.openDialog" => app.pick_and_open("open"), @@ -1394,6 +1396,15 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Map { /// The fields a new New Document dialog starts with: the factory settings on the Recent tab. pub(crate) fn defaults() -> Value { - let mut f = fields_for(&NewDocument::default()); + defaults_for(&NewDocument::default()) +} + +/// The fields New Document starts with for `nd` (the session's default new document, in the +/// default units), on the Recent tab. +pub(crate) fn defaults_for(nd: &NewDocument) -> Value { + let mut f = fields_for(nd); f.insert("tab".into(), json!("recent")); f.insert("preset".into(), json!(DEFAULT_CARD)); f.insert("name".into(), json!("")); @@ -325,7 +331,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec { Card { key, label, nd, custom } }; // Recent and Saved both start with the factory settings, as in InDesign. - let default = Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: NewDocument::default(), custom: false }; + let default = + Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: app.session.default_new_document(), custom: false }; match tab { "recent" => std::iter::once(default) .chain(app.ui.recent_new_documents.iter().enumerate().map(|(i, nd)| named(format!("recent:{i}"), nd.clone()))) From 6f8a0bf09eb42a34bff3512b71d0a677b5ae8590 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 12:43:28 +0200 Subject: [PATCH 2/4] Default units: a preset keeps both its units; bad increments are refused A document made from a preset keeps the preset's units on both rulers; the default origin and increments still apply. Preferences with no document open passes the increments it reads to `prefs.set` unclamped: a Cursor Key of 0, or text that isn't a measure or number, is refused and the dialog stays open with the reason; a Kerning/Tracking of 0.5 is kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 4 ++-- crates/engine/src/cmd/prefs.rs | 9 ++++++-- crates/engine/src/lib.rs | 10 ++++----- crates/ui-egui/src/dialogs.rs | 41 ++++++++++++++++++++++++++++------ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f658d4b92..5b5cb2827 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -237,7 +237,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result { s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); let mut d = Document::new(&nd); - s.prefs.default_units.apply(&mut d.settings); + s.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } @@ -268,7 +268,7 @@ impl Session { } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); let mut d = Document::new(&nd); - self.prefs.default_units.apply(&mut d.settings); + self.prefs.default_units.apply(&mut d.settings, p.get("preset").is_some_and(|v| !v.is_null())); let st = DocState::new(d, None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 781d3ef13..a6b53d605 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -172,9 +172,14 @@ mod tests { s.execute("file.new", &json!({"units": "points"})).unwrap(); let st = s.doc().unwrap().doc.settings.clone(); assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Points, Unit::Points)); - // A preset keeps its own units. + // A preset keeps its own units, both of them, even when the default horizontal units are + // the preset's. + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "millimeters", "verticalUnits": "inches"}})).unwrap(); s.execute("file.new", &json!({"preset": "A4"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, Unit::Millimeters); + let st = s.doc().unwrap().doc.settings.clone(); + assert_eq!((st.horizontal_units, st.vertical_units), (Unit::Millimeters, Unit::Millimeters)); + assert_eq!(st.keyboard_increment, 0.5, "the increments still apply"); + s.execute("prefs.set", &json!({"defaultUnits": {"horizontalUnits": "inches", "verticalUnits": "millimeters"}})).unwrap(); // New Document opens with them; bad values are refused and change nothing. assert_eq!(s.default_new_document().units, Unit::Inches); for bad in [json!({"horizontalUnits": "furlongs"}), json!({"keyboardIncrement": 0}), json!({"kerningIncrement": -5})] { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9c5c64a35..0dcd348f3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -277,11 +277,11 @@ impl Default for DefaultUnits { } impl DefaultUnits { - /// Gives a new document made in `units` these defaults. Its vertical ruler follows the - /// preference while the document keeps the default horizontal units (units picked in New - /// Document set both rulers). - pub fn apply(&self, settings: &mut designcraft_doc::DocSettings) { - if settings.horizontal_units == self.horizontal_units { + /// Gives a new document these defaults. Unless it was made from a preset (whose units are its + /// own), its vertical ruler follows the preference while it keeps the default horizontal + /// units (units picked in New Document set both rulers). + pub fn apply(&self, settings: &mut designcraft_doc::DocSettings, preset: bool) { + if !preset && settings.horizontal_units == self.horizontal_units { settings.vertical_units = self.vertical_units; } settings.ruler_origin = self.ruler_origin; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 502b8e0e7..eb11c1367 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3389,20 +3389,32 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("view.snapPreferences", snap)?; } if d.b("defaults") { - // No document open: the Units & Increments new documents start with. + // No document open: the Units & Increments new documents start with. A value that + // doesn't read, or that `prefs.set` refuses, keeps the dialog open to be fixed. let mut du = json!({}); for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { du[k] = v.clone(); } } - if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { - du["keyboardIncrement"] = json!(v.clamp(0.001, 1000.0)); + let mut read = || -> Result<(), String> { + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(()) + }; + let r = read().and_then(|()| app.run("prefs.set", json!({"defaultUnits": du}))); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e.clone()); } - if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { - du["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); - } - app.run("prefs.set", json!({"defaultUnits": du}))?; return Ok(Value::Null); } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; @@ -7261,6 +7273,21 @@ mod tests { let du = app.session.prefs.default_units; assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved value; a Kerning/Tracking of 0.5 is kept as it is. + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches"})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); // New Document opens in inches, and the document it makes has inch rulers. crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); let nd = app.ui.dialog.clone().expect("New Document"); From dc973c5395287a8cd874545f6194b601937a13e9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 17:19:54 +0200 Subject: [PATCH 3/4] Preferences with no document: the default units are saved with the other preferences Units & Increments for new documents go in the same prefs.set as the rest, which checks everything before it changes anything, so a refused increment keeps the dialog open with no preference saved. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 74 +++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9cde3798f..fac5ca40d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3361,7 +3361,23 @@ pub fn confirm(app: &mut DesignApp) -> Result { if d.b("cjkFeatures") != d.b("cjkFeatures.initial") { prefs["cjkFeatures"] = json!(d.b("cjkFeatures")); } - app.run("prefs.set", prefs)?; + // No document open: the Units & Increments new documents start with, saved with the + // rest in one `prefs.set` (which checks everything before it changes anything). A value + // that doesn't read, or that it refuses, keeps the dialog open to be fixed, with no + // preference changed. + let saved = default_units_of(&d).and_then(|du| { + if let Some(du) = du { + prefs["defaultUnits"] = du; + } + app.run("prefs.set", prefs) + }); + if let Err(e) = saved { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.status(e.clone()); + app.ui.dialog = Some(d); + return Err(e); + } app.ui.dynamic_spelling = d.b("dynamicSpelling"); if d.fields.contains_key("userWords") { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); @@ -3396,32 +3412,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("view.snapPreferences", snap)?; } if d.b("defaults") { - // No document open: the Units & Increments new documents start with. A value that - // doesn't read, or that `prefs.set` refuses, keeps the dialog open to be fixed. - let mut du = json!({}); - for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { - if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { - du[k] = v.clone(); - } - } - let mut read = || -> Result<(), String> { - if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { - du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); - } - if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { - let n = d.n("kerningIncrement").filter(|v| v.is_finite()); - du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); - } - Ok(()) - }; - let r = read().and_then(|()| app.run("prefs.set", json!({"defaultUnits": du}))); - if let Err(e) = &r { - let mut d = d.clone(); - d.fields.insert("status".into(), json!(e)); - app.status(e.clone()); - app.ui.dialog = Some(d); - return Err(e.clone()); - } + // The default units went with the preferences above. return Ok(Value::Null); } let Some(horizontal) = d.fields.get("horizontalUnits") else { return Ok(Value::Null) }; @@ -5170,6 +5161,29 @@ fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cjk: bool, sectio }); } +/// Preferences with no document open: the Units & Increments new documents start with, as +/// `prefs.set`'s `defaultUnits` (None with a document open). An increment that doesn't read is an +/// error naming the field. +fn default_units_of(d: &Dialog) -> Result, String> { + if !d.b("defaults") { + return Ok(None); + } + let mut du = json!({}); + for k in ["horizontalUnits", "verticalUnits", "rulerOrigin"] { + if let Some(v) = d.fields.get(k).filter(|v| v.is_string()) { + du[k] = v.clone(); + } + } + if let Some(t) = d.fields.get("keyboardIncrement").and_then(Value::as_str) { + du["keyboardIncrement"] = json!(parse_measure(t, Unit::Points).map_err(|_| format!("Cursor Key: `{t}` is not a measurement"))?); + } + if let Some(v) = d.fields.get("kerningIncrement").filter(|v| !v.is_null()) { + let n = d.n("kerningIncrement").filter(|v| v.is_finite()); + du["kerningIncrement"] = json!(n.ok_or_else(|| format!("Kerning/Tracking: `{}` is not a number", v.as_str().unwrap_or_default()))?); + } + Ok(Some(du)) +} + /// "Paragraph Style n" / "Character Style n": the first name no style has. fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; @@ -7281,9 +7295,10 @@ mod tests { assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); assert!(app.session.active().is_none(), "no document made"); // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and - // the saved value; a Kerning/Tracking of 0.5 is kept as it is. + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { - let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches"})); + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); for (k, v) in bad.as_object().unwrap() { d.fields.insert(k.clone(), v.clone()); } @@ -7291,6 +7306,7 @@ mod tests { assert!(confirm(&mut app).is_err(), "{bad}"); assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); } app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); confirm(&mut app).unwrap(); From 9ffc1ae379bdfe5e000b7a60ab92343a74d46fab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:26:17 +0100 Subject: [PATCH 4/4] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 54 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 54 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a2265c288..858f08a22 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,60 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them - /// for new documents, and New Document opens in them (Lollo's inches, set once). - #[test] - fn preferences_with_no_document_set_the_units_of_new_documents() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - crate::menus::activate(&mut app, "app.preferences", &Value::Null); - let mut d = app.ui.dialog.take().expect("Preferences"); - assert!(d.b("defaults"), "for new documents"); - let ctx = dialog_context("en"); - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); - app.ui.dialog = Some(d.clone()); - let mut labels = vec![]; - for _ in 0..3 { - labels = dialog_frame(&mut app, &ctx, screen, vec![]); - } - assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); - d.fields.insert("horizontalUnits".into(), json!("inches")); - d.fields.insert("verticalUnits".into(), json!("inches")); - d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); - app.ui.dialog = Some(d); - confirm(&mut app).unwrap(); - let du = app.session.prefs.default_units; - assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); - assert!(app.session.active().is_none(), "no document made"); - // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and - // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. - let scale = app.session.prefs.scale_strokes; - for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { - let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); - for (k, v) in bad.as_object().unwrap() { - d.fields.insert(k.clone(), v.clone()); - } - app.ui.dialog = Some(d); - assert!(confirm(&mut app).is_err(), "{bad}"); - assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); - assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); - assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); - } - app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); - confirm(&mut app).unwrap(); - assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); - // New Document opens in inches, and the document it makes has inch rulers. - crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); - let nd = app.ui.dialog.clone().expect("New Document"); - assert_eq!(nd.fields["units"], json!("inches")); - assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); - confirm(&mut app).unwrap(); - let st = &app.session.doc().unwrap().doc.settings; - assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); - // With the document open, Preferences edits that document's units, not the defaults. - crate::menus::activate(&mut app, "app.preferences", &Value::Null); - assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..7b0c8b5ba 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,57 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// With no document open, Preferences has Units & Increments, as InDesign's: OK keeps them +/// for new documents, and New Document opens in them (Lollo's inches, set once). +#[test] +fn preferences_with_no_document_set_the_units_of_new_documents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + let mut d = app.ui.dialog.take().expect("Preferences"); + assert!(d.b("defaults"), "for new documents"); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + app.ui.dialog = Some(d.clone()); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + assert!(labels.iter().any(|(t, _, _)| t == "Units & Increments"), "listed with no document: {labels:?}"); + d.fields.insert("horizontalUnits".into(), json!("inches")); + d.fields.insert("verticalUnits".into(), json!("inches")); + d.fields.insert("keyboardIncrement".into(), json!("0.5 pt")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let du = app.session.prefs.default_units; + assert_eq!((du.horizontal_units, du.vertical_units, du.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + assert!(app.session.active().is_none(), "no document made"); + // A Cursor Key of 0 or text that isn't a measure is refused, keeping the dialog open and + // the saved values, the other preferences' too; a Kerning/Tracking of 0.5 is kept as it is. + let scale = app.session.prefs.scale_strokes; + for bad in [json!({"keyboardIncrement": "0 pt"}), json!({"keyboardIncrement": "wide"}), json!({"kerningIncrement": "lots"})] { + let mut d = Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "scaleStrokes": !scale})); + for (k, v) in bad.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + app.ui.dialog = Some(d); + assert!(confirm(&mut app).is_err(), "{bad}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "preferences"), "still open: {bad}"); + assert_eq!(app.session.prefs.default_units.keyboard_increment, 0.5, "{bad}"); + assert_eq!(app.session.prefs.scale_strokes, scale, "no other preference saved: {bad}"); + } + app.ui.dialog = Some(Dialog::new("preferences", json!({"defaults": true, "horizontalUnits": "inches", "kerningIncrement": "0.5"}))); + confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.default_units.kerning_increment, 0.5, "not rounded up to 1"); + // New Document opens in inches, and the document it makes has inch rulers. + crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null); + let nd = app.ui.dialog.clone().expect("New Document"); + assert_eq!(nd.fields["units"], json!("inches")); + assert_eq!(nd.s("width"), designcraft_geom::format_measure(612.0, Unit::Inches)); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.settings; + assert_eq!((st.horizontal_units, st.vertical_units, st.keyboard_increment), (Unit::Inches, Unit::Inches, 0.5)); + // With the document open, Preferences edits that document's units, not the defaults. + crate::menus::activate(&mut app, "app.preferences", &Value::Null); + assert!(!app.ui.dialog.as_ref().unwrap().b("defaults")); +}