Browse / github.com/storytold/wordcraft / patch
File › Options › Defaults: edit the styles and page setup new documents start from
File › Options › Defaults: edit the styles and page setup new documents start from ## What A File › Options › Defaults page where the defaults for new documents are edited directly, with no document as the source: - **Styles**: pick a style, edit it with the same fields as the Modify Style dialog (font, size, colour, bold, italic, spacing) and see a rendered sample; adopt it, import it from the open document, or restore the selected/all styles. - **Page setup**: paper size (including custom), orientation, margins, mirror margins and sheet colour, with a proportionally scaled preview; adopt, import from the document, or restore. - **View**: the same switches as View › Show,
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- lucasmelo-mike01 (imported from pull request #4456, open)
- Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3- Size
- +1421 −15 in 17 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c74a6e84a51205f31250e1fc
How it went
No other operator has reported using it yet.
Diff
raw · apps/wordcraft/src/main.rs crates/doc/src/defaults.rs crates/doc/src/lib.rs crates/doc/src/section.rs crates/engine/src/cmd/defaults.rs crates/engine/src/cmd/mod.rs crates/engine/src/cmd/options.rs crates/engine/src/cmd/page.rs crates/engine/src/cmd/para.rs crates/engine/src/session.rs crates/engine/src/session/documents.rs crates/engine/src/tests.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/options.rs crates/ui-egui/src/previews.rs crates/ui-egui/src/window_ui.rs
@@ -466,9 +466,10 @@ fn main() -> eframe::Result { app.set_startup_update_check(true); load_prefs(&mut app); load_user_dictionary(&mut app);- // A region chosen in File › Options replaces the system's for the first blank- // document too (#838).- if !sample && app.session.prefs.region.is_some() {+ // The window's first document is made once the preferences are in: the person's+ // defaults (styles, page setup) and, when chosen, their region's paper (#838) must+ // both be in place.+ if !sample { let doc = app.session.blank_document(); app.session.set_document(doc); }new file mode 100644@@ -0,0 +1,222 @@+//! Defaults for new documents (File › Options › Defaults): the styles and page setup a person+//! adopts from a document. They live in the app's preferences (`UiState::defaults`, saved in+//! `ui.json`) and are applied by [`Document::apply_defaults`] when a blank document is made —+//! never to a document being opened, which keeps its own styles and page.++use serde::{Deserialize, Serialize};++use crate::Document;+use crate::props::Rgb;+use crate::section::{MAX_PAGE_SIDE, MIN_PAGE_SIDE, SectionProps};+use crate::styles::{Style, StyleSheet};++/// The page setup adopted for new documents.+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct PageDefaults {+ pub page_w: f32,+ pub page_h: f32,+ pub landscape: bool,+ pub margin_top: f32,+ pub margin_bottom: f32,+ pub margin_left: f32,+ pub margin_right: f32,+ /// Mirror margins (a document-wide setting, like the page colour).+ pub mirror: bool,+ /// The sheet's own colour; `None` leaves the theme's paper.+ pub page_color: Option<Rgb>,+}++impl Default for PageDefaults {+ fn default() -> Self {+ let s = SectionProps::default();+ PageDefaults {+ page_w: s.page_w,+ page_h: s.page_h,+ landscape: s.landscape,+ margin_top: s.margin_top,+ margin_bottom: s.margin_bottom,+ margin_left: s.margin_left,+ margin_right: s.margin_right,+ mirror: false,+ page_color: None,+ }+ }+}++impl PageDefaults {+ /// The page setup a document's section has (its size, orientation and margins), with the+ /// document's mirror margins and page colour.+ pub fn from_section(s: &SectionProps, mirror: bool, page_color: Option<Rgb>) -> Self {+ PageDefaults {+ page_w: s.page_w,+ page_h: s.page_h,+ landscape: s.landscape,+ margin_top: s.margin_top,+ margin_bottom: s.margin_bottom,+ margin_left: s.margin_left,+ margin_right: s.margin_right,+ mirror,+ page_color,+ }+ }++ /// Keep the values inside the page model's limits: a hand-edited or older `ui.json` must not+ /// put an impossible page into every new document.+ pub fn sanitize(&mut self) {+ let side = |v: f32, fallback: f32| if v.is_finite() { v.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE) } else { fallback };+ let default = SectionProps::default();+ self.page_w = side(self.page_w, default.page_w);+ self.page_h = side(self.page_h, default.page_h);+ self.margin_top = self.margin_top.clamp(0.0, self.page_h);+ self.margin_bottom = self.margin_bottom.clamp(0.0, self.page_h);+ self.margin_left = self.margin_left.clamp(0.0, self.page_w);+ self.margin_right = self.margin_right.clamp(0.0, self.page_w);+ // Margins that leave no text room are brought back to the app's margins.+ if self.margin_left + self.margin_right > self.page_w - 18.0 || self.margin_top + self.margin_bottom > self.page_h - 18.0 {+ let d = SectionProps::default();+ self.margin_top = d.margin_top;+ self.margin_bottom = d.margin_bottom;+ self.margin_left = d.margin_left;+ self.margin_right = d.margin_right;+ }+ }++ fn apply(&self, doc: &mut Document) {+ let mut p = *self;+ p.sanitize();+ doc.last_section.page_w = p.page_w;+ doc.last_section.page_h = p.page_h;+ doc.last_section.landscape = p.landscape;+ doc.last_section.margin_top = p.margin_top;+ doc.last_section.margin_bottom = p.margin_bottom;+ doc.last_section.margin_left = p.margin_left;+ doc.last_section.margin_right = p.margin_right;+ // The header/footer distances stay on the page.+ doc.last_section.header = doc.last_section.header.clamp(0.0, p.page_h);+ doc.last_section.footer = doc.last_section.footer.clamp(0.0, p.page_h);+ doc.settings.mirror_margins = p.mirror;+ doc.settings.page_color = p.page_color;+ }+}++/// Defaults for new documents: a whole style sheet (every style adopted), individual style+/// definitions, and the page setup. Empty means the app's own built-in defaults.+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct DocumentDefaults {+ /// A whole sheet adopted from a document (`adopt all styles`); replaces the built-in sheet.+ pub sheet: Option<StyleSheet>,+ /// Style definitions adopted one by one, merged over the sheet (or the built-in sheet).+ pub styles: Vec<Style>,+ /// The page setup for new documents; `None` keeps the app's regional paper.+ pub page: Option<PageDefaults>,+}++impl DocumentDefaults {+ pub fn is_empty(&self) -> bool {+ self.sheet.is_none() && self.styles.is_empty() && self.page.is_none()+ }++ /// Keep the defaults inside sane limits (a hand-edited or older `ui.json`).+ pub fn sanitize(&mut self) {+ const MAX_STYLES: usize = 5000;+ self.styles.retain(|s| !s.id.is_empty() && s.id.chars().count() <= 200);+ self.styles.truncate(MAX_STYLES);+ if let Some(sheet) = &mut self.sheet {+ sheet.styles.truncate(MAX_STYLES);+ }+ if let Some(page) = &mut self.page {+ page.sanitize();+ }+ }++ /// The style sheet these defaults stand for: the adopted sheet (all styles), or the built-in+ /// sheet with the adopted styles merged over it. The UI edits and previews this.+ pub fn styles_sheet(&self) -> StyleSheet {+ let mut sheet = self.sheet.clone().unwrap_or_default();+ if self.sheet.is_none() {+ for style in &self.styles {+ match sheet.styles.iter_mut().find(|s| s.id == style.id) {+ Some(slot) => *slot = style.clone(),+ None => sheet.styles.push(style.clone()),+ }+ }+ }+ sheet+ }++ /// The ids this default defines, in order (the sheet's, or the adopted ones).+ pub fn style_ids(&self) -> Vec<String> {+ match &self.sheet {+ Some(sheet) => sheet.styles.iter().map(|s| s.id.clone()).collect(),+ None => self.styles.iter().map(|s| s.id.clone()).collect(),+ }+ }+}++impl Document {+ /// Start this (new) document from `defaults`: the adopted styles and page setup. An empty+ /// default leaves the document as it is. Existing documents are never passed here.+ pub fn apply_defaults(&mut self, defaults: &DocumentDefaults) {+ let mut d = defaults.clone();+ d.sanitize();+ if let Some(sheet) = d.sheet {+ self.styles = sheet;+ }+ for style in d.styles {+ match self.styles.styles.iter_mut().find(|s| s.id == style.id) {+ Some(slot) => *slot = style,+ None => self.styles.styles.push(style),+ }+ }+ if let Some(page) = d.page {+ page.apply(self);+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn defaults_replace_styles_and_page_of_a_new_document() {+ let mut d = Document::new();+ let mut style = d.styles.styles.iter().find(|s| s.id == "Normal").cloned().unwrap();+ style.chr.size = Some(15.0);+ let mut page = PageDefaults::from_section(&d.last_section, false, Some(Rgb(0x18, 0x18, 0x18)));+ page.margin_top = 40.0;+ page.page_h = 600.0 * 72.0 / 2.54;+ let defaults = DocumentDefaults { sheet: None, styles: vec![style], page: Some(page) };+ d.apply_defaults(&defaults);+ let normal = d.styles.styles.iter().find(|s| s.id == "Normal").unwrap();+ assert_eq!(normal.chr.size, Some(15.0));+ assert_eq!(d.last_section.margin_top, 40.0);+ assert_eq!(d.last_section.page_h, page.page_h);+ assert_eq!(d.settings.page_color, Some(Rgb(0x18, 0x18, 0x18)));+ // Other styles keep the built-in sheet.+ assert!(d.styles.styles.iter().any(|s| s.id == "Heading1"));+ }++ #[test]+ fn an_empty_default_leaves_a_document_alone() {+ let mut d = Document::new();+ let before = d.clone();+ d.apply_defaults(&DocumentDefaults::default());+ assert_eq!(d.styles, before.styles);+ assert_eq!(d.last_section, before.last_section);+ }++ #[test]+ fn junk_defaults_are_brought_back_into_limits() {+ let mut d = Document::new();+ let page = PageDefaults { page_w: f32::NAN, page_h: 1e30, margin_left: -5.0, margin_right: 1e9, ..Default::default() };+ let defaults = DocumentDefaults { sheet: None, styles: vec![], page: Some(page) };+ d.apply_defaults(&defaults);+ assert!(d.last_section.page_w.is_finite() && d.last_section.page_w >= MIN_PAGE_SIDE);+ assert_eq!(d.last_section.page_h, MAX_PAGE_SIDE);+ assert!(d.last_section.margin_left >= 0.0);+ assert!(d.last_section.margin_left + d.last_section.margin_right < d.last_section.page_w);+ }+}@@ -19,6 +19,7 @@ pub mod cluster; pub mod collate; pub mod connector; pub mod control;+pub mod defaults; pub mod edit; pub mod effects; pub mod encoding;@@ -717,6 +717,15 @@ impl DocGrid { } } +/// The smallest page side WordCraft accepts, points (0.1", as Word allows).+pub const MIN_PAGE_SIDE: f32 = 7.2;++/// The largest page side WordCraft accepts, points: 600 cm, LibreOffice's limit for a custom page+/// size (Word itself stops at 22 in / 1584 pt). The page stays this size through layout, saving+/// and export; only the PDF writer, capped by the PDF format at 200 in, and Word itself are+/// narrower.+pub const MAX_PAGE_SIDE: f32 = 17_008.0;+ /// Page setup and section-level settings. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]new file mode 100644@@ -0,0 +1,446 @@+//! Defaults for new documents (File › Options › Defaults): adopting the current document's styles+//! or page setup as the starting point of every new document. The values are kept in the app's+//! preferences (`UiState::defaults` in `ui.json`) and used by `Session::blank_document`; an+//! existing document is never touched.++use serde_json::{Value, json};+use wordcraft_doc::defaults::PageDefaults;+use wordcraft_doc::props::Rgb;+use wordcraft_doc::section::SectionProps;++use super::page;+use crate::{CmdError, CmdResult, CommandSpec, Session, p};++pub fn specs() -> Vec<CommandSpec> {+ vec![+ CommandSpec::new("defaults.state", "Defaults", "File › Options › Defaults", state)+ .params(r#"{} → {"styles": {"all": bool, "ids": [string]}, "page": {…} | null}"#)+ .pure(),+ CommandSpec::new("defaults.saveStyles", "Set as Default", "File › Options › Defaults", save_styles)+ .params(r#"{"what": "all" | [style id]}"#),+ CommandSpec::new("defaults.savePage", "Set as Default", "File › Options › Defaults", save_page)+ .params(r#"{}"#)+ .pure(),+ CommandSpec::new("defaults.savePageValues", "Set as Default", "File › Options › Defaults", save_page_values)+ .params(r#"{"pageW"?: pt, "pageH"?: pt, "landscape"?: bool, "marginTop"?: pt, "marginBottom"?: pt, "marginLeft"?: pt, "marginRight"?: pt, "mirror"?: bool, "color"?: "RRGGBB" | null}"#),+ CommandSpec::new("defaults.saveStyle", "Set as Default in the App", "File › Options › Defaults", save_style)+ .params(r#"{"style": string, "name"?: string, "next"?: string, "chr"?: CharProps, "para"?: ParaProps, "apply"?: bool (also apply it to the open document, for the Modify Style dialog's button)}"#),+ CommandSpec::new("defaults.resetStyle", "Reset", "File › Options › Defaults", reset_style).params(r#"{"style": string}"#),+ CommandSpec::new("defaults.reset", "Reset", "File › Options › Defaults", reset)+ .params(r#"{"what": "all" | "view" | "styles" | "page"}"#),+ ]+}++/// What the defaults hold, as agents and the Options dialog read it: the adopted styles (all, or+/// their ids) and the page setup.+fn state(s: &mut Session, _: &Value) -> CmdResult {+ Ok(describe(&s.defaults, &s.view))+}++fn describe(d: &wordcraft_doc::defaults::DocumentDefaults, view: &crate::ViewState) -> Value {+ let page = d.page.map(|p| {+ json!({+ "pageW": p.page_w,+ "pageH": p.page_h,+ "landscape": p.landscape,+ "marginTop": p.margin_top,+ "marginBottom": p.margin_bottom,+ "marginLeft": p.margin_left,+ "marginRight": p.margin_right,+ "mirror": p.mirror,+ "color": p.page_color.map(|c| c.hex()),+ })+ });+ json!({+ "styles": {"all": d.sheet.is_some(), "ids": d.style_ids()},+ "page": page,+ "view": {"ruler": view.ruler, "gridlines": view.gridlines, "tableGridlines": view.table_gridlines, "marks": view.marks},+ })+}++fn save_styles(s: &mut Session, v: &Value) -> CmdResult {+ let what = v.get("what").ok_or_else(|| CmdError::Params("`what` (\"all\" or style ids) is required".into()))?;+ match what {+ Value::String(name) if name == "all" => {+ // The whole sheet, as the document has it (built-in and custom styles).+ s.defaults.sheet = Some(s.doc.styles.clone());+ s.defaults.styles.clear();+ }+ Value::Array(ids) => {+ if ids.is_empty() {+ return Err(CmdError::Params("`what` lists no styles".into()));+ }+ if ids.len() > 500 {+ return Err(CmdError::Params("`what` lists more than 500 styles".into()));+ }+ // Validate the whole list first: a bad id must not leave the earlier ones adopted.+ let mut found = Vec::with_capacity(ids.len());+ for id in ids {+ let Some(id) = id.as_str() else {+ return Err(CmdError::Params("`what` must hold style ids".into()));+ };+ let Some(style) = s.doc.styles.styles.iter().find(|st| st.id == id).cloned() else {+ return Err(CmdError::Params(format!("unknown style `{id}`")));+ };+ found.push(style);+ }+ for style in found {+ let id = style.id.clone();+ // With a whole sheet adopted, the sheet is the source of truth: update it there,+ // so the two lists never disagree.+ if let Some(sheet) = &mut s.defaults.sheet {+ match sheet.styles.iter_mut().find(|st| st.id == id) {+ Some(slot) => *slot = style,+ None => sheet.styles.push(style),+ }+ } else {+ match s.defaults.styles.iter_mut().find(|st| st.id == id) {+ Some(slot) => *slot = style,+ None => s.defaults.styles.push(style),+ }+ }+ }+ }+ _ => return Err(CmdError::Params("`what` must be \"all\" or a list of style ids".into())),+ }+ s.defaults.sanitize();+ Ok(describe(&s.defaults, &s.view))+}++/// Adopt the current document's page setup: the caret section's size, orientation and margins,+/// with the document's mirror margins and page colour.+fn save_page(s: &mut Session, _: &Value) -> CmdResult {+ let sect = page::sect(s);+ s.defaults.page = Some(PageDefaults::from_section(§, s.doc.settings.mirror_margins, s.doc.settings.page_color));+ s.defaults.sanitize();+ Ok(describe(&s.defaults, &s.view))+}++/// Adopt page setup values that are not (yet) the document's: the Page Setup dialog's Set as+/// Default, which must not change the open document.+fn save_page_values(s: &mut Session, v: &Value) -> CmdResult {+ // Start from the defaults already adopted (what the editor shows), not from the open+ // document: fields the caller leaves out keep their defaults, not another document's page.+ let mut sect = match s.defaults.page {+ Some(p) => SectionProps {+ page_w: p.page_w,+ page_h: p.page_h,+ landscape: p.landscape,+ margin_top: p.margin_top,+ margin_bottom: p.margin_bottom,+ margin_left: p.margin_left,+ margin_right: p.margin_right,+ ..SectionProps::default()+ },+ None => page::sect(s),+ };+ for (key, slot) in [+ ("pageW", &mut sect.page_w),+ ("pageH", &mut sect.page_h),+ ("marginTop", &mut sect.margin_top),+ ("marginBottom", &mut sect.margin_bottom),+ ("marginLeft", &mut sect.margin_left),+ ("marginRight", &mut sect.margin_right),+ ] {+ if let Some(n) = v.get(key) {+ if n.is_null() {+ continue;+ }+ *slot = p::req_f32(v, key)?;+ }+ }+ if let Some(land) = v.get("landscape").filter(|b| !b.is_null()) {+ let Some(land) = land.as_bool() else { return Err(CmdError::Params("`landscape` must be true or false".into())) };+ sect.landscape = land;+ }+ let mirror = match v.get("mirror") {+ None | Some(Value::Null) => s.defaults.page.map(|p| p.mirror).unwrap_or(s.doc.settings.mirror_margins),+ Some(Value::Bool(b)) => *b,+ Some(_) => return Err(CmdError::Params("`mirror` must be true or false".into())),+ };+ let color = match v.get("color") {+ None => s.defaults.page.map(|p| p.page_color).unwrap_or(s.doc.settings.page_color),+ Some(Value::Null) => None,+ Some(Value::String(hex)) => Some(Rgb::parse(hex).ok_or_else(|| CmdError::Params("`color` is not a colour (RRGGBB)".into()))?),+ Some(_) => return Err(CmdError::Params("`color` must be a string or null".into())),+ };+ // The same rules as Layout › Page Setup: an impossible page is refused, not stored.+ sect.margin_top = sect.margin_top.max(0.0);+ sect.margin_bottom = sect.margin_bottom.max(0.0);+ sect.margin_left = sect.margin_left.max(0.0);+ sect.margin_right = sect.margin_right.max(0.0);+ page::validate_section(&mut sect)?;+ s.defaults.page = Some(PageDefaults::from_section(§, mirror, color));+ s.defaults.sanitize();+ Ok(describe(&s.defaults, &s.view))+}++/// Adopt the definition the Modify Style dialog shows for one style as the default for new+/// documents. With `apply`, the same definition goes to the open document's style too (the+/// dialog's "Set as Default in the App"), so the two never disagree.+fn save_style(s: &mut Session, v: &Value) -> CmdResult {+ let id = p::req_str(v, "style")?;+ let chr: Option<wordcraft_doc::props::CharProps> =+ v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;+ let para: Option<wordcraft_doc::props::ParaProps> =+ v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;+ let name = p::str(v, "name");+ let next = p::str(v, "next");+ let apply = p::bool(v, "apply").unwrap_or(false);++ // The definition to adopt: the document's style when it has one (its `basedOn`, next style+ // and kind ride along), else the current default, else the built-in one.+ let base = s+ .doc+ .styles+ .get(id)+ .cloned()+ .or_else(|| s.defaults.styles_sheet().get(id).cloned())+ .ok_or_else(|| CmdError::Params(format!("unknown style `{id}`")))?;+ let mut adopted = base.clone();+ if let Some(c) = &chr {+ adopted.chr.overlay(c);+ }+ if let Some(pp) = ¶ {+ adopted.para.overlay(pp);+ }+ if let Some(n) = next {+ adopted.next = Some(n.to_string());+ }+ if let Some(n) = name.filter(|n| !n.trim().is_empty()) {+ adopted.name = n.to_string();+ }+ adopted.id = id.to_string();++ // Apply to the open document first: it is the operation that can fail (an unknown style),+ // and the defaults then describe exactly what the document has.+ if apply {+ super::para::overlay_style(s, id, chr.as_ref(), para.as_ref(), name, next)?;+ }+ store_default_style(&mut s.defaults, adopted);+ s.defaults.sanitize();+ Ok(describe(&s.defaults, &s.view))+}++/// Put `style` into the defaults: in the adopted sheet when there is one (it is the source of+/// truth), otherwise among the individually adopted styles.+fn store_default_style(d: &mut wordcraft_doc::defaults::DocumentDefaults, style: wordcraft_doc::styles::Style) {+ if let Some(sheet) = &mut d.sheet {+ match sheet.styles.iter_mut().find(|s| s.id == style.id) {+ Some(slot) => *slot = style,+ None => sheet.styles.push(style),+ }+ } else {+ match d.styles.iter_mut().find(|s| s.id == style.id) {+ Some(slot) => *slot = style,+ None => d.styles.push(style),+ }+ }+}++/// Restore one style in the defaults to the application's own definition (a custom style is+/// simply removed from the defaults).+fn reset_style(s: &mut Session, v: &Value) -> CmdResult {+ let id = p::req_str(v, "style")?;+ let builtin = wordcraft_doc::styles::StyleSheet::builtin().get(id).cloned();+ if let Some(sheet) = &mut s.defaults.sheet {+ match builtin {+ Some(b) => match sheet.styles.iter_mut().find(|s| s.id == id) {+ Some(slot) => *slot = b,+ None => sheet.styles.push(b),+ },+ None => sheet.styles.retain(|s| s.id != id),+ }+ } else {+ s.defaults.styles.retain(|s| s.id != id);+ }+ Ok(describe(&s.defaults, &s.view))+}++fn reset(s: &mut Session, v: &Value) -> CmdResult {+ let what = p::str(v, "what").unwrap_or("all");+ match what {+ "all" | "view" => {+ let d = crate::ViewState::default();+ s.view.ruler = d.ruler;+ s.view.gridlines = d.gridlines;+ s.view.table_gridlines = d.table_gridlines;+ s.view.marks = d.marks;+ }+ "styles" | "page" => {}+ _ => return Err(CmdError::Params("`what` must be all, view, styles or page".into())),+ }+ match what {+ "all" => {+ s.defaults.sheet = None;+ s.defaults.styles.clear();+ s.defaults.page = None;+ }+ "styles" => {+ s.defaults.sheet = None;+ s.defaults.styles.clear();+ }+ "page" => s.defaults.page = None,+ _ => {}+ }+ Ok(describe(&s.defaults, &s.view))+}++#[cfg(test)]+mod tests {+ use super::*;++ fn session() -> Session {+ Session::new(wordcraft_doc::Document::from_text("one"))+ }++ #[test]+ fn adopting_styles_and_page_changes_only_the_defaults() {+ let mut s = session();+ let before = s.doc.clone();+ // One style, by id.+ s.run("defaults.saveStyles", &json!({"what": ["Normal"]})).unwrap();+ assert_eq!(s.defaults.style_ids(), ["Normal"]);+ assert!(s.defaults.sheet.is_none());+ // The whole sheet.+ s.run("defaults.saveStyles", &json!({"what": "all"})).unwrap();+ assert!(s.defaults.sheet.is_some());+ assert_eq!(s.defaults.style_ids().len(), s.doc.styles.styles.len());+ // The page setup, from the document.+ s.run("defaults.savePage", &json!({})).unwrap();+ assert!(s.defaults.page.is_some());+ // None of it touched the open document.+ assert_eq!(s.doc.styles, before.styles);+ assert_eq!(s.doc.last_section, before.last_section);+ // An unknown style is refused.+ assert!(s.run("defaults.saveStyles", &json!({"what": ["NoSuchStyle"]})).is_err());+ }++ #[test]+ fn page_values_from_the_dialog_are_checked() {+ let mut s = session();+ s.run(+ "defaults.savePageValues",+ &json!({"pageW": 600.0, "pageH": 800.0, "marginTop": 40.0, "marginBottom": 40.0, "marginLeft": 40.0, "marginRight": 40.0}),+ )+ .unwrap();+ let p = s.defaults.page.expect("page defaults");+ assert_eq!((p.page_w, p.page_h, p.margin_top), (600.0, 800.0, 40.0));+ // Margins that leave no text room are refused, and the defaults stay as they were.+ assert!(s.run("defaults.savePageValues", &json!({"marginLeft": 590.0, "marginRight": 590.0})).is_err());+ assert_eq!(s.defaults.page.map(|p| p.margin_left), Some(40.0));+ // A colour goes with the page setup.+ s.run("defaults.savePageValues", &json!({"color": "181818"})).unwrap();+ assert_eq!(s.defaults.page.unwrap().page_color, Some(Rgb(0x18, 0x18, 0x18)));+ }++ #[test]+ fn a_new_document_starts_from_the_defaults() {+ let mut s = session();+ // Change the Normal style and the page, adopt both, then make a new document.+ if let Some(normal) = s.doc.styles.styles.iter_mut().find(|st| st.id == "Normal") {+ normal.chr.size = Some(16.0);+ }+ s.run("defaults.saveStyles", &json!({"what": ["Normal"]})).unwrap();+ s.run(+ "defaults.savePageValues",+ &json!({"pageW": 500.0, "pageH": 700.0, "marginTop": 30.0, "marginBottom": 30.0, "marginLeft": 30.0, "marginRight": 30.0, "color": "181818"}),+ )+ .unwrap();+ s.run("file.new", &json!({})).unwrap();+ let normal = s.doc.styles.styles.iter().find(|st| st.id == "Normal").expect("Normal");+ assert_eq!(normal.chr.size, Some(16.0), "the adopted style starts the new document");+ assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h), (500.0, 700.0));+ assert_eq!(s.doc.settings.page_color, Some(Rgb(0x18, 0x18, 0x18)));+ // A heading not adopted keeps the built-in definition.+ let h1 = s.doc.styles.styles.iter().find(|st| st.id == "Heading1").expect("Heading 1");+ let builtin = wordcraft_doc::Document::new();+ assert_eq!(h1.chr.size, builtin.styles.styles.iter().find(|st| st.id == "Heading1").unwrap().chr.size);+ }++ #[test]+ fn opening_a_document_keeps_its_own_styles_and_page() {+ let dir = std::env::temp_dir().join(format!("wc-defaults-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("own.docx");+ // A document with its own Normal size and its own page.+ let mut own = session();+ if let Some(normal) = own.doc.styles.styles.iter_mut().find(|st| st.id == "Normal") {+ normal.chr.size = Some(13.0);+ }+ own.doc.last_section.page_w = 612.0;+ own.run("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();+ // The person now adopts different defaults.+ let mut s = session();+ s.run("defaults.saveStyles", &json!({"what": "all"})).unwrap();+ s.run(+ "defaults.savePageValues",+ &json!({"pageW": 400.0, "pageH": 400.0, "marginTop": 13.0, "marginBottom": 13.0, "marginLeft": 13.0, "marginRight": 13.0}),+ )+ .unwrap();+ s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap();+ let normal = s.doc.styles.styles.iter().find(|st| st.id == "Normal").expect("Normal");+ assert_eq!(normal.chr.size, Some(13.0), "the file's own style, not the new defaults");+ assert_eq!(s.doc.last_section.page_w, 612.0, "the file's own page");+ let _ = std::fs::remove_dir_all(&dir);+ }++ /// The Modify Style dialog's definition for one style becomes the default: only that style+ /// is adopted, and a new document starts with it (the others keep the built-in sheet).+ #[test]+ fn a_style_saved_from_the_dialog_is_the_new_default() {+ let mut s = session();+ let chr = json!({"font": "Georgia", "size": 15.0, "bold": false, "italic": false, "color": {"Rgb": [0x11, 0x22, 0x33]}});+ s.run("defaults.saveStyle", &json!({"style": "Normal", "chr": chr, "para": {"spaceBefore": 6.0, "spaceAfter": 4.0}})).unwrap();+ assert_eq!(s.defaults.style_ids(), ["Normal"], "only the edited style is adopted");+ s.run("file.new", &json!({})).unwrap();+ let normal = s.doc.styles.get("Normal").expect("Normal");+ assert_eq!(normal.chr.font.as_deref(), Some("Georgia"));+ assert_eq!(normal.chr.size, Some(15.0));+ assert_eq!(normal.para.space_before, Some(6.0));+ assert_eq!(normal.para.space_after, Some(4.0));+ // Heading 1 keeps the application's own definition.+ let builtin = wordcraft_doc::styles::StyleSheet::builtin();+ assert_eq!(s.doc.styles.get("Heading1"), builtin.get("Heading1"));+ // Restoring the style drops the adoption.+ s.run("defaults.resetStyle", &json!({"style": "Normal"})).unwrap();+ assert!(s.defaults.styles.is_empty());+ }++ /// The dialog's "Set as Default in the App" applies the definition to the open document and+ /// adopts it for new documents, leaving the other styles alone.+ #[test]+ fn the_dialogs_button_applies_and_adopts_together() {+ let mut s = session();+ let other = s.doc.styles.get("Heading1").cloned();+ s.run("defaults.saveStyle", &json!({"style": "Normal", "apply": true, "chr": {"font": "Georgia", "size": 15.0}})).unwrap();+ assert_eq!(s.doc.styles.get("Normal").unwrap().chr.font.as_deref(), Some("Georgia"), "the document's style changed");+ assert_eq!(s.doc.styles.get("Heading1").cloned(), other, "the other styles didn't");+ assert_eq!(s.defaults.styles.len(), 1);+ assert_eq!(s.defaults.styles[0].chr.size, Some(15.0));+ // An unknown style is refused and nothing is adopted.+ let before = s.defaults.clone();+ assert!(s.run("defaults.saveStyle", &json!({"style": "NoSuchStyle", "apply": true})).is_err());+ assert_eq!(s.defaults, before);+ // A colour the model can't parse is refused the same way (the dialog only sends colours+ // it managed to parse).+ assert!(s.run("defaults.saveStyle", &json!({"style": "Normal", "chr": {"color": "not-a-colour"}})).is_err());+ assert_eq!(s.defaults, before);+ }++ #[test]+ fn reset_restores_the_app_defaults() {+ let mut s = session();+ s.view.ruler = false;+ s.view.gridlines = true;+ s.run("defaults.saveStyles", &json!({"what": "all"})).unwrap();+ s.run("defaults.savePage", &json!({})).unwrap();+ let d = s.run("defaults.reset", &json!({"what": "all"})).unwrap();+ assert!(s.defaults.is_empty());+ assert!(s.view.ruler && !s.view.gridlines && s.view.table_gridlines && !s.view.marks);+ assert_eq!(d["view"]["ruler"], true);+ // A fresh blank document again has the app's own styles and page.+ let doc = s.blank_document();+ assert_eq!(doc.styles, wordcraft_doc::Document::new().styles);+ }+}@@ -8,6 +8,7 @@ pub mod citations; pub mod column; pub mod connectors; pub mod controls;+pub mod defaults; pub mod design; pub mod draw; pub mod edit;@@ -76,6 +77,7 @@ pub fn registry() -> Registry { v.extend(insert::specs()); v.extend(equation::specs()); v.extend(page::specs());+ v.extend(defaults::specs()); v.extend(table::specs()); v.extend(table_style::specs()); v.extend(table_draw::specs());@@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p}; /// Options panes, in the order the page lists them (the `pane` param of `file.options`).-pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"];+pub const PANES: [&str; 9] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents", "defaults"]; /// Longest initials kept, in characters. pub const MAX_INITIALS_CHARS: usize = 9;@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> { vec![ CommandSpec::new("file.options", "Options", "File", options) .params(- r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,+ r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents|defaults", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, ) .pure(), CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {@@ -408,7 +408,7 @@ pub fn columns_preset_at(s: &Session) -> Option<&'static str> { /// Checks a whole section handed to `layout.pageSetup` by the rules of Size, Margins, Columns, /// Line Numbers, Footnotes and Page Borders: a non-finite number or margins that leave no room /// for text is an error; the rest is clamped to the ranges those commands use.-fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> {+pub(crate) fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> { let bad = |what: &str| CmdError::Params(format!("section: {what} must be a finite number")); let finite = [x.page_w, x.page_h, x.margin_top, x.margin_bottom, x.margin_left, x.margin_right, x.header, x.footer, x.gutter, x.columns.space]; if finite.iter().any(|n| !n.is_finite()) {@@ -974,22 +974,37 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult { v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?; let next = p::str(v, "next").map(str::to_string); let name = p::str(v, "name").map(str::to_string);- let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;+ overlay_style(s, &id, chr.as_ref(), para.as_ref(), name.as_deref(), next.as_deref())?;+ sel_result(s)+}++/// Apply the Modify Style dialog's properties to a style of the open document: an overlay, so+/// properties the dialog doesn't show (list numbering, borders, the paragraph after…) stay.+/// Shared with the defaults, which adopt the same definition for new documents.+pub(crate) fn overlay_style(+ s: &mut Session,+ id: &str,+ chr: Option<&CharProps>,+ para: Option<&ParaProps>,+ name: Option<&str>,+ next: Option<&str>,+) -> Result<(), CmdError> {+ let st = s.doc.styles.get_mut(id).ok_or_else(|| CmdError::Params("no such style".into()))?; if let Some(c) = chr {- st.chr.overlay(&c);+ st.chr.overlay(c); } if let Some(pp) = para {- st.para.overlay(&pp);+ st.para.overlay(pp); } if let Some(n) = next {- st.next = Some(n);+ st.next = Some(n.to_string()); } if let Some(n) = name.filter(|n| !n.trim().is_empty()) {- st.name = n;+ st.name = n.to_string(); } // Every paragraph's layout depends on styles: bump all revisions. touch_all(s);- sel_result(s)+ Ok(()) } /// Update Style to Match Selection: the style takes on how the selection looks — its paragraph@@ -451,6 +451,9 @@ pub struct Session { pub doc: Document, pub sel: Selection, pub view: ViewState,+ /// Defaults for new documents (File › Options › Defaults): the styles and page setup the+ /// person adopted. Persisted by the host in `ui.json`; applied only to blank documents.+ pub defaults: wordcraft_doc::defaults::DocumentDefaults, /// Formatting picked with a collapsed caret, applied to the next typed text. pub pending: Option<CharProps>, /// Last highlighter choice for the shortcut; palette state, outside document undo.@@ -895,6 +898,7 @@ impl Session { doc, sel, view,+ defaults: wordcraft_doc::defaults::DocumentDefaults::default(), pending, last_highlight: wordcraft_doc::props::Highlight::Yellow, path,@@ -1607,8 +1611,12 @@ impl Session { /// A new blank document (the Normal template) on the region's paper: Letter in the US and /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round.+ /// A blank document for File › New (and the window's first document): the app's paper for the+ /// region, then the person's defaults for new documents (styles, page setup, page colour). pub fn blank_document(&self) -> Document {- blank_document(&self.region())+ let mut d = blank_document(&self.region());+ d.apply_defaults(&self.defaults);+ d } /// Replace the document (open/new).@@ -1675,6 +1683,8 @@ impl Session { doc, sel, view: _,+ // App preferences for future documents, not part of this one's edit state.+ defaults: _, pending: _, last_highlight: _, path: _,@@ -196,6 +196,8 @@ impl Session { doc: _, sel: _, view: _,+ // App preferences for future documents, shared by every open document.+ defaults: _, pending: _, path: _, file_name: _,@@ -21,6 +21,49 @@ fn text(s: &Session) -> String { s.doc.plain_text(StoryRef::Body) } +/// File › Options › Defaults: the style and the page setup adopted for new documents are what a+/// new document starts from, and the open document is never touched.+#[test]+fn adopted_defaults_start_every_new_document() {+ let mut s = s();+ run(&mut s, "defaults.saveStyle", json!({"style": "Normal", "chr": {"font": "Courier New", "size": 11.0}}));+ run(&mut s, "defaults.savePageValues", json!({"pageW": 420.0, "pageH": 595.0, "marginTop": 42.0}));+ let state = run(&mut s, "defaults.state", json!({}));+ assert_eq!(state["styles"]["ids"], json!(["Normal"]));+ assert_eq!(state["page"]["pageW"], json!(420.0));++ let d = s.blank_document();+ assert_eq!((d.last_section.page_w, d.last_section.page_h), (420.0, 595.0), "the adopted page");+ assert_eq!(d.last_section.margin_top, 42.0, "the adopted margin");+ let normal = d.styles.get("Normal").expect("Normal is a built-in");+ assert_eq!((normal.chr.font.as_deref(), normal.chr.size), (Some("Courier New"), Some(11.0)), "the adopted style");+ // A document already open keeps its own page and styles.+ assert_ne!((s.doc.last_section.page_w, s.doc.last_section.page_h), (420.0, 595.0));++ // Reset takes the app's own page back.+ run(&mut s, "defaults.reset", json!({"what": "page"}));+ let d = s.blank_document();+ assert_ne!((d.last_section.page_w, d.last_section.page_h), (420.0, 595.0), "the app's paper again");+}++/// A `defaults.saveStyles` naming an unknown style changes nothing: the ids before the bad one+/// are not adopted either, and a page update keeps the values it does not name.+#[test]+fn a_failed_save_styles_changes_nothing() {+ let mut s = s();+ run(&mut s, "defaults.savePageValues", json!({"pageW": 420.0, "pageH": 595.0, "marginTop": 30.0}));+ let before = run(&mut s, "defaults.state", json!({}));+ let r = s.run("defaults.saveStyles", &json!({"what": ["Normal", "NoSuchStyle"]}));+ assert!(r.is_err(), "the unknown style is refused");+ assert_eq!(run(&mut s, "defaults.state", json!({})), before, "nothing was adopted");++ // A page update builds on what is already adopted (not on another document's page).+ run(&mut s, "defaults.savePageValues", json!({"marginTop": 42.0}));+ let state = run(&mut s, "defaults.state", json!({}));+ assert_eq!(state["page"]["pageW"], json!(420.0), "the size it did not name");+ assert_eq!(state["page"]["marginTop"], json!(42.0), "the margin it did name");+}+ #[test] fn doc_extension_dispatches_to_docbin() { // Garbage bytes with a .doc name must produce an error through the Word 97-2003@@ -1774,7 +1774,7 @@ fn run_as_one_step(app: &mut WordApp, commands: Vec<(&str, Value)>) -> Result<() /// Page Setup's Paper group (#1433): a Paper size list (the named sizes and Custom size) and /// the width and height. The fields follow the orientation (the wide side is the width in /// landscape), and a typed size that is wider than tall turns the orientation to landscape.-fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, custom: &mut bool) {+pub(crate) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, custom: &mut bool) { if (*landscape && paper.0 < paper.1) || (!*landscape && paper.0 > paper.1) { *paper = (paper.1, paper.0); }@@ -1817,6 +1817,64 @@ fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, c }); } +/// The property fields the Modify Style dialog and the Defaults pane's style editor share: what+/// a style says for itself, resolved for display (as the dialog has always shown it).+pub(crate) struct StyleFields<'a> {+ pub name: &'a mut String,+ pub font: &'a mut String,+ pub size: &'a mut f32,+ pub bold: &'a mut bool,+ pub italic: &'a mut bool,+ pub color: &'a mut String,+ pub before: &'a mut f32,+ pub after: &'a mut f32,+}++/// The style's properties: name, font, size, colour, spacing and emphasis. Shared by Modify Style+/// and File › Options › Defaults, so both edit a style in exactly the same terms. `name_editable`+/// is false where renaming would break the `basedOn` links of the default styles.+pub(crate) fn style_fields(app: &mut WordApp, ui: &mut Ui, f: StyleFields<'_>, name_editable: bool, display_name: Option<&str>) {+ egui::Grid::new("style_fields").num_columns(2).show(ui, |ui| {+ let name_label = ui.label(tl!("Name:"));+ if name_editable {+ ui.text_edit_singleline(f.name).labelled_by(name_label.id);+ } else {+ // A default style keeps its name: other styles are based on that id.+ ui.label(egui::RichText::new(display_name.unwrap_or(f.name.as_str())).weak());+ }+ ui.end_row();+ let font_label = ui.label(tl!("Font:"));+ let shown = app.previews.font_shown(f.font);+ let fams = app.previews.families();+ let prev = app.previews.font_preview_fn();+ if let Some(face) = crate::widgets::font_combo(ui, "ms_font", 220.0, &shown, &fams, Some(&*prev)) {+ if face == crate::widgets::ADD_FONT {+ let _ = app.run("font.add", json!({}));+ } else {+ *f.font = app.previews.font_stored(&face);+ }+ }+ let _ = font_label;+ ui.end_row();+ let size_label = ui.label(tl!("Size:"));+ ui.add(egui::DragValue::new(f.size).range(1.0..=1638.0).speed(0.5)).labelled_by(size_label.id);+ ui.end_row();+ let color_label = ui.label(tl!("Color:"));+ ui.text_edit_singleline(f.color).labelled_by(color_label.id);+ ui.end_row();+ ui.label(tl!("Space before/after:"));+ ui.horizontal(|ui| {+ ui.add(egui::DragValue::new(f.before).range(0.0..=1584.0)).accessible_name(tl!("Space before"));+ ui.add(egui::DragValue::new(f.after).range(0.0..=1584.0)).accessible_name(tl!("Space after"));+ });+ ui.end_row();+ });+ ui.horizontal(|ui| {+ ui.checkbox(f.bold, tl!("Bold"));+ ui.checkbox(f.italic, tl!("Italic"));+ });+}+ /// Cancel key for dialogs: Escape everywhere, plus ⌘. (Command+Period) on macOS. pub(crate) fn cancel_key_pressed(ui: &Ui) -> bool { ui.input(|i| i.key_pressed(egui::Key::Escape) || (cfg!(target_os = "macos") && i.modifiers.command && i.key_pressed(egui::Key::Period)))@@ -314,6 +314,10 @@ pub struct UiState { /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved. #[serde(skip)] pub nav_collapsed: std::collections::HashSet<wordcraft_doc::Path>,+ /// Defaults for new documents (File › Options › Defaults): styles and page setup. The session+ /// owns them while running (see [`WordApp::prefs`]); they are never applied to an open+ /// document.+ pub defaults: wordcraft_doc::defaults::DocumentDefaults, pub show_discord: bool, /// User name (File › Options) for comments and tracked changes; empty keeps the default. pub author: String,@@ -437,6 +441,7 @@ impl Default for UiState { pane_places: Default::default(), pane_order: Vec::new(), nav_collapsed: Default::default(),+ defaults: Default::default(), show_discord: true, author: String::new(), window: None,@@ -958,6 +963,7 @@ impl WordApp { ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations; ui.dark_page = self.session.view.dark_mode; ui.nav_pane = self.session.view.nav_pane;+ ui.defaults = self.session.defaults.clone(); ui.styles_pane_dock = self.session.view.styles_pane_dock; ui.check_spelling = self.session.view.proofing; ui.keyboard = self.session.keymap.clone();@@ -1007,6 +1013,10 @@ impl WordApp { self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations; self.session.view.dark_mode = self.ui.dark_page; self.session.view.nav_pane = self.ui.nav_pane;+ // Defaults for new documents: cleaned up, then owned by the session.+ let mut defaults = std::mem::take(&mut self.ui.defaults);+ defaults.sanitize();+ self.session.defaults = defaults; self.session.view.styles_pane_dock = self.ui.styles_pane_dock; self.session.view.proofing = self.ui.check_spelling; // Keys for commands this version doesn't have are dropped.@@ -130,6 +130,7 @@ fn pane_label(id: &str) -> &'static str { "accessibility" => "Accessibility", "advanced" => "Advanced", "agents" => "Agents",+ "defaults" => "Defaults", _ => "General", } }@@ -175,6 +176,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) { "accessibility" => accessibility(app, ui), "advanced" => advanced(app, ui), "agents" => agents(ui),+ "defaults" => defaults(app, ui), _ => general(app, ui), } });@@ -642,6 +644,462 @@ fn templates_folder(app: &mut WordApp, ui: &mut Ui) { note(ui, "File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here."); } +/// A "Saved" note under an action for a moment after it ran (the dialog's own feedback that the+/// defaults were written, without a new message that would have to be translated everywhere).+fn flash(ui: &mut Ui, action: &str) {+ // The time is read before the data lock: `Context::input` takes the context's own lock, and+ // calling it inside `data_mut` deadlocks.+ let now = ui.input(|i| i.time);+ ui.data_mut(|d| d.insert_temp(egui::Id::unique(("defaults_saved", action)), now));+}++fn flashed(ui: &Ui, action: &str) -> bool {+ let now = ui.input(|i| i.time);+ ui.data(|d| d.get_temp::<f64>(egui::Id::unique(("defaults_saved", action)))).is_some_and(|t| now - t < 2.0)+}++fn saved_note(ui: &mut Ui, action: &str) {+ if flashed(ui, action) {+ ui.label(RichText::new(tl!("Saved")).small().weak());+ ui.ctx().request_repaint();+ }+}++/// A Reset button that asks once (OK/Cancel) before throwing the person's defaults away: a stray+/// click on a small button shouldn't lose them.+fn confirm_reset(ui: &mut Ui, key: &str) -> bool {+ let id = egui::Id::unique(("defaults_reset", key));+ if !ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(false) {+ if ui.button(tl!("Reset")).clicked() {+ ui.data_mut(|d| d.insert_temp(id, true));+ }+ return false;+ }+ let mut go = false;+ ui.label(tl!("Reset"));+ if ui.button(tl!("OK")).clicked() {+ go = true;+ }+ if ui.button(tl!("Cancel")).clicked() {+ ui.data_mut(|d| d.insert_temp(id, false));+ }+ if go {+ ui.data_mut(|d| d.insert_temp(id, false));+ }+ go+}++/// File › Options › Defaults: what a new document starts from. The view switches are the person's+/// preferences (saved with the app); the styles and the page setup are adopted from a document —+/// or reset — and only ever applied to documents created afterwards.+/// The style being edited in the Defaults pane: the resolved properties the Modify Style dialog+/// shows, kept apart from any document.+#[derive(Clone)]+struct StyleDraft {+ /// Style id (the model's key; `basedOn` links use it).+ id: String,+ /// The style's own name (not the localized label): what is saved.+ name: String,+ /// The label shown in the pane (localized for built-in styles).+ display: String,+ font: String,+ size: f32,+ bold: bool,+ italic: bool,+ color: String,+ before: f32,+ after: f32,+}++/// The page setup being edited in the Defaults pane, in the measurement unit the dialogs show.+#[derive(Clone, Debug)]+struct PageDraft {+ paper: (f32, f32),+ custom: bool,+ landscape: bool,+ top: f32,+ bottom: f32,+ left: f32,+ right: f32,+ mirror: bool,+ /// `RRGGBB`, or empty for the theme's paper.+ color: String,+}++fn style_draft_id() -> egui::Id {+ egui::Id::unique("defaults_style_draft")+}++fn page_draft_id() -> egui::Id {+ egui::Id::unique("defaults_page_draft")+}++impl PageDraft {+ /// The app's own page setup, for the editor's first show and a reset.+ fn default_page() -> Self {+ page_draft_from(&wordcraft_doc::SectionProps::default(), false, None)+ }+}++fn color_text(value: &str) -> wordcraft_doc::props::TextColor {+ match wordcraft_doc::Rgb::parse(value) {+ Some(c) => wordcraft_doc::props::TextColor::Rgb(c),+ None => wordcraft_doc::props::TextColor::Auto,+ }+}++/// A style's properties as the Modify Style dialog shows them: resolved through its `basedOn`+/// chain, so an edited Normal doesn't hide what Heading 1 inherits.+fn draft_from_style(sheet: &wordcraft_doc::styles::StyleSheet, id: &str) -> Option<StyleDraft> {+ let st = sheet.get(id)?;+ let character = st.kind == wordcraft_doc::StyleKind::Character;+ let rc = sheet.resolve_char(+ if character { None } else { Some(id) },+ &wordcraft_doc::CharProps { style: character.then(|| id.to_string()), ..Default::default() },+ );+ let rp = sheet.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.to_string()), ..Default::default() });+ Some(StyleDraft {+ id: id.to_string(),+ name: st.name.clone(),+ display: crate::i18n::style_name(st),+ font: rc.font,+ size: rc.size,+ bold: rc.bold,+ italic: rc.italic,+ color: match rc.color {+ wordcraft_doc::props::TextColor::Rgb(c) => c.hex(),+ wordcraft_doc::props::TextColor::Auto => "auto".into(),+ },+ before: rp.space_before,+ after: rp.space_after,+ })+}++/// The style the draft describes, for the sample: the default's own definition with the edited+/// properties laid over it (the preview never touches the document).+fn draft_style(sheet: &wordcraft_doc::styles::StyleSheet, draft: &StyleDraft) -> wordcraft_doc::styles::Style {+ let mut st = sheet.get(&draft.id).cloned().unwrap_or_default();+ st.id = draft.id.clone();+ st.name = draft.name.clone();+ st.chr.font = Some(draft.font.clone());+ st.chr.size = Some(draft.size);+ st.chr.bold = Some(draft.bold);+ st.chr.italic = Some(draft.italic);+ st.chr.color = Some(color_text(&draft.color));+ st.para.space_before = Some(draft.before);+ st.para.space_after = Some(draft.after);+ st+}++fn page_draft_from_defaults(p: &wordcraft_doc::defaults::PageDefaults) -> PageDraft {+ page_draft_from(+ &wordcraft_doc::SectionProps {+ page_w: p.page_w,+ page_h: p.page_h,+ landscape: p.landscape,+ margin_top: p.margin_top,+ margin_bottom: p.margin_bottom,+ margin_left: p.margin_left,+ margin_right: p.margin_right,+ ..Default::default()+ },+ p.mirror,+ p.page_color,+ )+}++fn page_draft_from(sect: &wordcraft_doc::SectionProps, mirror: bool, color: Option<wordcraft_doc::Rgb>) -> PageDraft {+ let ppu = crate::options::unit().pt_per_unit();+ let paper = (sect.page_w / ppu, sect.page_h / ppu);+ PageDraft {+ paper,+ custom: true,+ landscape: sect.landscape,+ top: sect.margin_top / ppu,+ bottom: sect.margin_bottom / ppu,+ left: sect.margin_left / ppu,+ right: sect.margin_right / ppu,+ mirror,+ color: color.map(|c| c.hex()).unwrap_or_default(),+ }+}++/// File › Options › Defaults: what a new document starts from, edited right here — the view+/// switches, the default styles (each one shown and editable like the Modify Style dialog) and+/// the default page setup. Only the adopted defaults change: the open document is never touched.+fn defaults(app: &mut WordApp, ui: &mut Ui) {+ section(ui, "Defaults");++ // A. View preferences: the same switches as View › Show, saved with the app's preferences.+ ui.label(RichText::new(tl!("View")).font(semibold(13.5)));+ let mut ruler = app.session.view.ruler;+ let mut gridlines = app.session.view.gridlines;+ let mut table_gridlines = app.session.view.table_gridlines;+ let mut marks = app.session.view.marks;+ ui.horizontal_wrapped(|ui| {+ if ui.checkbox(&mut ruler, tl!("Ruler")).changed() {+ let _ = app.run("view.ruler", json!({"value": ruler}));+ }+ if ui.checkbox(&mut gridlines, tl!("Gridlines")).changed() {+ let _ = app.run("view.gridlines", json!({"value": gridlines}));+ }+ if ui.checkbox(&mut table_gridlines, tl!("View Gridlines")).changed() {+ let _ = app.run("table.viewGridlines", json!({"value": table_gridlines}));+ }+ if ui.checkbox(&mut marks, tl!("Show/Hide ¶")).changed() {+ let _ = app.run("view.marks", json!({"value": marks}));+ }+ if confirm_reset(ui, "view") {+ let _ = app.run("defaults.reset", json!({"what": "view"}));+ }+ });++ // B. Default styles: pick one, edit it, see it, adopt it — without a document as the source.+ ui.add_space(12.0);+ ui.label(RichText::new(tl!("Styles")).font(semibold(13.5)));+ let sheet = app.session.defaults.styles_sheet();+ let ids: Vec<String> = sheet+ .styles+ .iter()+ .filter(|s| !s.hidden && matches!(s.kind, wordcraft_doc::StyleKind::Paragraph | wordcraft_doc::StyleKind::Character))+ .map(|s| s.id.clone())+ .collect();+ if ids.is_empty() {+ return;+ }+ let Some(mut draft) = ui+ .data(|d| d.get_temp::<StyleDraft>(style_draft_id()))+ .filter(|d| ids.contains(&d.id))+ .or_else(|| draft_from_style(&sheet, "Normal").or_else(|| draft_from_style(&sheet, &ids[0])))+ else {+ return;+ };+ ui.horizontal(|ui| {+ let style_label = ui.label(tl!("Style:"));+ let combo = egui::ComboBox::from_id_salt("defaults_style_pick").selected_text(&draft.display).width(240.0).show_ui(ui, |ui| {+ for id in &ids {+ let label = sheet.get(id).map(crate::i18n::style_name).unwrap_or_else(|| id.clone());+ if ui.selectable_label(draft.id == *id, label.clone()).clicked()+ && draft.id != *id+ && let Some(next) = draft_from_style(&sheet, id)+ {+ draft = next;+ }+ }+ });+ combo.response.labelled_by(style_label.id);+ });+ let display = draft.display.clone();+ crate::dialogs::style_fields(+ app,+ ui,+ crate::dialogs::StyleFields {+ name: &mut draft.name,+ font: &mut draft.font,+ size: &mut draft.size,+ bold: &mut draft.bold,+ italic: &mut draft.italic,+ color: &mut draft.color,+ before: &mut draft.before,+ after: &mut draft.after,+ },+ false,+ Some(&display),+ );+ // The sample: drawn on a temporary document with the defaults' styles (never the open one).+ let sample = draft_style(&sheet, &draft);+ let ppp = ui.ctx().pixels_per_point();+ let key = format!(+ "defaults-style:{}:{}:{}:{}:{}:{}:{}:{}:{ppp}",+ draft.id, draft.font, draft.size, draft.bold, draft.italic, draft.color, draft.before, draft.after+ );+ let text = tl!("AaBbCcDd").to_string();+ let tex = app.previews.get_or(ui.ctx(), &key, || crate::previews::style_sample(&sheet, &sample, &text, 260.0, 40.0, ppp));+ match tex {+ Some(h) => {+ let t = Tokens::get(ui.ctx());+ let sz = h.size_vec2() / ppp;+ let (r, _) = ui.allocate_exact_size(vec2(268.0, 34.0), egui::Sense::hover());+ ui.painter().rect_filled(r, 3.0, if t.dark { egui::Color32::from_gray(0xC8) } else { egui::Color32::WHITE });+ ui.painter().rect_stroke(r, 3.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);+ let inner = egui::Rect::from_min_size(r.min + vec2(4.0, 2.0), sz.min(r.size() - vec2(8.0, 4.0)));+ ui.painter().image(h.id(), inner, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);+ }+ None => {+ ui.ctx().request_repaint();+ }+ }+ ui.horizontal(|ui| {+ if ui.button(tl!("Set as Default")).clicked() {+ let params = style_params_for_draft(&draft, false);+ if app.run("defaults.saveStyle", params).is_ok() {+ // Show what was stored (the engine validates and cleans the definition).+ if let Some(saved) = draft_from_style(&app.session.defaults.styles_sheet(), &draft.id) {+ draft = saved;+ }+ flash(ui, "style");+ }+ }+ if ui.button(tl!("Import from Document")).clicked() {+ // Fill the editor from the open document's definition, without adopting it.+ if let Some(next) = draft_from_style(&app.session.doc.styles, &draft.id) {+ draft = next;+ }+ }+ if confirm_reset(ui, "style") {+ let _ = app.run("defaults.resetStyle", json!({"style": draft.id}));+ if let Some(next) = draft_from_style(&app.session.defaults.styles_sheet(), &draft.id) {+ draft = next;+ }+ }+ ui.label(tl!("All"));+ if confirm_reset(ui, "styles_all") {+ let _ = app.run("defaults.reset", json!({"what": "styles"}));+ if let Some(next) = draft_from_style(&app.session.defaults.styles_sheet(), &draft.id) {+ draft = next;+ }+ }+ saved_note(ui, "style");+ });+ ui.data_mut(|d| d.insert_temp(style_draft_id(), draft));++ // C. Default page setup: edited here, adopted here; nothing comes from a document unless the+ // person asks for it (Import).+ ui.add_space(12.0);+ ui.label(RichText::new(tl!("Page Setup")).font(semibold(13.5)));+ let mut page = ui.data(|d| d.get_temp::<PageDraft>(page_draft_id())).unwrap_or_else(|| match app.session.defaults.page {+ Some(p) => page_draft_from_defaults(&p),+ None => PageDraft::default_page(),+ });+ crate::dialogs::page_paper_group(ui, &mut page.paper, &mut page.landscape, &mut page.custom);+ ui.horizontal(|ui| {+ ui.radio_value(&mut page.landscape, false, tl!("Portrait"));+ ui.radio_value(&mut page.landscape, true, tl!("Landscape"));+ });+ egui::Grid::new("defaults_page").num_columns(4).show(ui, |ui| {+ let ppu = crate::options::unit().pt_per_unit();+ let unit = crate::options::unit();+ let speed = 3.6 / ppu;+ ui.label(tl!("Top:"));+ ui.add(egui::DragValue::new(&mut page.top).speed(speed).measure(unit).max_decimals(2));+ ui.label(tl!("Bottom:"));+ ui.add(egui::DragValue::new(&mut page.bottom).speed(speed).measure(unit).max_decimals(2));+ ui.end_row();+ ui.label(if page.mirror { tl!("Inside:") } else { tl!("Left:") });+ ui.add(egui::DragValue::new(&mut page.left).speed(speed).measure(unit).max_decimals(2));+ ui.label(if page.mirror { tl!("Outside:") } else { tl!("Right:") });+ ui.add(egui::DragValue::new(&mut page.right).speed(speed).measure(unit).max_decimals(2));+ ui.end_row();+ });+ ui.checkbox(&mut page.mirror, tl!("Mirror margins"));+ ui.horizontal(|ui| {+ ui.label(tl!("Page Color"));+ let theme = app.session.doc.settings.theme_colors.clone();+ crate::dialogs::color_field(ui, &theme, &mut page.color, "No Color", "");+ // Swatch of the chosen sheet colour.+ let t = Tokens::get(ui.ctx());+ let (r, _) = ui.allocate_exact_size(vec2(28.0, 18.0), egui::Sense::hover());+ match wordcraft_doc::Rgb::parse(&page.color) {+ Some(c) => {+ ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));+ }+ None => {+ ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);+ ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));+ }+ }+ ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);+ });+ page_preview(ui, &page);+ ui.horizontal(|ui| {+ if ui.button(tl!("Set as Default")).clicked() {+ let params = page_params(&page);+ if app.run("defaults.savePageValues", params).is_ok() {+ // The controls show what was stored: a value the page model limits (600 cm, a+ // margin that leaves no room) can't stay on screen as if it had been adopted.+ if let Some(saved) = app.session.defaults.page {+ page = page_draft_from_defaults(&saved);+ }+ flash(ui, "page");+ }+ }+ if ui.button(tl!("Import from Document")).clicked() {+ let sect = wordcraft_engine::cmd::page::sect(&app.session);+ page = page_draft_from(§, app.session.doc.settings.mirror_margins, app.session.doc.settings.page_color);+ }+ if confirm_reset(ui, "page") {+ let _ = app.run("defaults.reset", json!({"what": "page"}));+ page = page_draft_from(&wordcraft_doc::SectionProps::default(), false, None);+ }+ saved_note(ui, "page");+ });+ ui.data_mut(|d| d.insert_temp(page_draft_id(), page));++ // All the defaults at once (view preferences included), after a confirmation.+ ui.add_space(16.0);+ ui.horizontal(|ui| {+ ui.label(tl!("All"));+ if confirm_reset(ui, "all") {+ let _ = app.run("defaults.reset", json!({"what": "all"}));+ ui.data_mut(|d| d.remove::<StyleDraft>(style_draft_id()));+ ui.data_mut(|d| d.remove::<PageDraft>(page_draft_id()));+ }+ saved_note(ui, "all");+ });+}++/// The `defaults.saveStyle` parameters for the pane's draft (`apply` false: the open document is+/// not changed by editing the defaults here).+fn style_params_for_draft(draft: &StyleDraft, apply: bool) -> serde_json::Value {+ let mut chr = json!({"font": draft.font, "size": draft.size, "bold": draft.bold, "italic": draft.italic});+ if let Some(c) = wordcraft_doc::Rgb::parse(&draft.color) {+ chr["color"] = json!({"Rgb": [c.0, c.1, c.2]});+ }+ json!({+ "style": draft.id,+ "name": draft.name,+ "chr": chr,+ "para": {"spaceBefore": draft.before, "spaceAfter": draft.after},+ "apply": apply,+ })+}++/// The `defaults.savePageValues` parameters for the pane's draft (points, as the engine takes).+fn page_params(page: &PageDraft) -> serde_json::Value {+ let ppu = crate::options::unit().pt_per_unit();+ json!({+ "pageW": page.paper.0 * ppu,+ "pageH": page.paper.1 * ppu,+ "landscape": page.landscape,+ "marginTop": page.top * ppu,+ "marginBottom": page.bottom * ppu,+ "marginLeft": page.left * ppu,+ "marginRight": page.right * ppu,+ "mirror": page.mirror,+ "color": wordcraft_doc::Rgb::parse(&page.color).map(|c| c.hex()),+ })+}++/// A small, proportional view of the sheet: orientation, margins and colour, scaled to fit.+fn page_preview(ui: &mut Ui, page: &PageDraft) {+ let t = Tokens::get(ui.ctx());+ let ppu = crate::options::unit().pt_per_unit();+ let (w, h) = (page.paper.0 * ppu, page.paper.1 * ppu);+ let box_size = vec2(200.0, 150.0);+ let k = (box_size.x / w.max(1.0)).min(box_size.y / h.max(1.0)).min(1.0);+ let size = vec2((w * k).max(8.0), (h * k).max(8.0));+ let (r, _) = ui.allocate_exact_size(size, egui::Sense::hover());+ let paper = wordcraft_doc::Rgb::parse(&page.color).map(crate::theme::c32).unwrap_or(egui::Color32::WHITE);+ ui.painter().rect_filled(r, 1.0, paper);+ ui.painter().rect_stroke(r, 1.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);+ let m = |v: f32| (v * ppu * k).max(0.0);+ let inner = egui::Rect::from_min_max(+ egui::pos2(r.min.x + m(page.left), r.min.y + m(page.top)),+ egui::pos2((r.max.x - m(page.right)).max(r.min.x + 1.0), (r.max.y - m(page.bottom)).max(r.min.y + 1.0)),+ );+ ui.painter().rect_stroke(inner, 0.0, egui::Stroke::new(1.0, t.text_disabled), egui::StrokeKind::Inside);+}+ fn agents(ui: &mut Ui) { section(ui, "Agents"); ui.label(tl!("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel."));@@ -859,7 +1317,7 @@ mod tests { use serde_json::json; use wordcraft_geom::Unit; - use super::{decimal, length, parse_length, unit};+ use super::{PageDraft, StyleDraft, decimal, draft_from_style, length, page_draft_id, parse_length, style_draft_id, unit}; use crate::theme::Appearance; use crate::{Services, UiState, WordApp};@@ -880,6 +1338,111 @@ mod tests { WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()) } + fn defaults_harness() -> egui_kittest::Harness<'static, WordApp> {+ let mut a = app();+ a.run("file.options", json!({"pane": "defaults"})).unwrap();+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(+ |ui, app: &mut WordApp| {+ let ctx = ui.ctx().clone();+ app.logic(&ctx);+ app.ui(ui);+ },+ a,+ );+ h.run_steps(4);+ h+ }++ /// The Defaults pane edits the default style itself: its "Save" adopts the style shown (the+ /// open document is untouched), and the pane's draft starts from the app's built-in sheet.+ #[test]+ fn the_defaults_pane_edits_and_adopts_a_style() {+ let mut h = defaults_harness();+ let doc_style = h.state().session.doc.styles.get("Normal").cloned();+ // The editor was filled from the built-in default (not from the document's accident).+ let draft = h.ctx.data(|d| d.get_temp::<StyleDraft>(style_draft_id())).expect("a style draft");+ assert_eq!(draft.id, "Normal");+ let builtin = draft_from_style(&wordcraft_doc::styles::StyleSheet::builtin(), "Normal").expect("built-in Normal");+ assert_eq!((draft.font.clone(), draft.size, draft.color.clone()), (builtin.font, builtin.size, builtin.color));+ // The styles section's "Set as Default" adopts it.+ h.query_all_by_label("Set as Default").next().expect("styles Set as Default").click();+ h.run_steps(3);+ assert_eq!(h.state().session.defaults.style_ids(), ["Normal"]);+ assert_eq!(h.state().session.doc.styles.get("Normal").cloned(), doc_style, "the open document is untouched");+ }++ /// "Import from Document" fills the editor with the document's style, without adopting it.+ #[test]+ fn importing_a_style_fills_the_editor_without_adopting_it() {+ let mut h = defaults_harness();+ h.state_mut().run("styles.modify", json!({"style": "Normal", "chr": {"font": "Georgia", "size": 15.0}})).unwrap();+ h.run_steps(3);+ h.query_all_by_label("Import from Document").next().expect("Import").click();+ h.run_steps(3);+ let draft = h.ctx.data(|d| d.get_temp::<StyleDraft>(style_draft_id())).expect("a style draft");+ assert_eq!(draft.font, "Georgia", "the editor shows the document's definition");+ assert_eq!(draft.size, 15.0);+ assert!(h.state().session.defaults.is_empty(), "importing alone adopts nothing");+ }++ /// The page section adopts the values in its own controls, with no document involved.+ #[test]+ fn the_defaults_pane_adopts_the_page_setup_it_shows() {+ let mut h = defaults_harness();+ let (w0, m0) = (h.state().session.doc.last_section.page_w, h.state().session.doc.last_section.margin_top);+ h.ctx.data_mut(|d| {+ let page = d.get_temp_mut_or_insert_with(page_draft_id(), PageDraft::default_page);+ page.paper = (8.27, 11.69);+ page.top = 1.0;+ page.bottom = 1.0;+ page.left = 1.0;+ page.right = 1.0;+ page.color = "181818".into();+ });+ h.run_steps(2);+ // The page section's "Set as Default" is the second one in the pane.+ h.query_all_by_label("Set as Default").nth(1).expect("page Set as Default").click();+ h.run_steps(3);+ let page = h.state().session.defaults.page.expect("page defaults");+ let ppu = crate::options::unit().pt_per_unit();+ assert!((page.page_w - 8.27 * ppu).abs() < 0.1 && (page.page_h - 11.69 * ppu).abs() < 0.1, "{page:?}");+ assert!((page.margin_top - 1.0 * ppu).abs() < 0.1);+ // The pane shows what was stored, so the fields can't claim a value the model limited.+ let shown = h.ctx.data(|d| d.get_temp::<PageDraft>(page_draft_id())).expect("the page draft");+ assert!((shown.paper.0 - 8.27).abs() < 0.02 && (shown.paper.1 - 11.69).abs() < 0.02, "{shown:?}");+ assert_eq!(page.page_color, Some(wordcraft_doc::Rgb(0x18, 0x18, 0x18)));+ assert_eq!(+ (h.state().session.doc.last_section.page_w, h.state().session.doc.last_section.margin_top),+ (w0, m0),+ "the open document is untouched"+ );+ }++ /// "Import from Document" in the page section fills the editor with the open document's page+ /// setup, and adopting it is a separate, explicit step.+ #[test]+ fn importing_the_page_does_not_adopt_it() {+ let mut h = defaults_harness();+ h.state_mut()+ .run(+ "defaults.savePageValues",+ json!({"pageW": 500.0, "pageH": 700.0, "marginTop": 30.0, "marginBottom": 30.0, "marginLeft": 30.0, "marginRight": 30.0}),+ )+ .unwrap();+ // The document itself has another page setup.+ h.state_mut().run("layout.size", json!({"width": 400.0, "height": 600.0})).unwrap();+ h.run_steps(3);+ // Import the page (the second "Import from Document" in the pane).+ h.query_all_by_label("Import from Document").nth(1).expect("page Import").click();+ h.run_steps(3);+ let ppu = crate::options::unit().pt_per_unit();+ let draft = h.ctx.data(|d| d.get_temp::<PageDraft>(page_draft_id())).expect("the page draft");+ assert!((draft.paper.0 * ppu - 400.0).abs() < 0.5 && (draft.paper.1 * ppu - 600.0).abs() < 0.5, "{draft:?}");+ // The defaults still hold what was adopted before the import, not the document's page.+ let page = h.state().session.defaults.page.expect("page defaults");+ assert!((page.page_w - 500.0).abs() < 0.1 && (page.page_h - 700.0).abs() < 0.1, "{page:?}");+ }+ fn nav_harness(language: &str, percent: u16) -> egui_kittest::Harness<'static, WordApp> { let mut a = app(); a.run("ui.language", json!({"value": language})).unwrap();@@ -423,6 +423,29 @@ pub fn style_snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None, dark) } +/// A sample of `style` as the defaults would make it: the style drawn on a temporary document+/// with `sheet`'s styles (never the open document), for the Defaults pane's preview.+pub fn style_sample(+ sheet: &wordcraft_doc::styles::StyleSheet,+ style: &wordcraft_doc::styles::Style,+ text: &str,+ w: f32,+ h: f32,+ ppp: f32,+) -> Option<egui::ColorImage> {+ let mut base = Document::new();+ base.styles = sheet.clone();+ match base.styles.styles.iter_mut().find(|s| s.id == style.id) {+ Some(slot) => *slot = style.clone(),+ None => base.styles.styles.push(style.clone()),+ }+ let mut p = Paragraph::with_text(text, CharProps::default()).styled(&style.id);+ p.props.space_before = Some(0.0);+ p.props.space_after = Some(0.0);+ p.props.align = Some(wordcraft_doc::Align::Left);+ style_snippet(&base, p, w, h, ppp, 2.0, false)+}+ /// How a gallery draws a page preview: as rendered on a light interface or on Switch Modes' dark /// page, and dimmed in the dark theme so a white page isn't the brightest thing on screen (#663). pub fn preview_tint(t: &Tokens, dark_page: bool) -> egui::Color32 {@@ -275,6 +275,7 @@ impl WordApp { last_caption_label: _, ink_pens: _, initials: _,+ defaults: _, options_pane: _, recent_count: _, mini_toolbar: _,