//! View tab and status bar: views, zoom, show/hide, panes. use serde_json::{Value, json}; use wordcraft_doc::{Zoom, ZoomFit}; use wordcraft_layout::{NumeralMode, ViewMode}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult { let cur = *f(&mut s.view); *f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur); Ok(json!({"value": *f(&mut s.view)})) } pub fn specs() -> Vec { vec![ CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| { let r = toggle(s, v, |x| &mut x.marks); s.relayout(); r }) .key("Mod+Shift+8 / Mod+8") .pure(), CommandSpec::new("view.printLayout", "Print Layout", "View › Views", |s, _| mode(s, ViewMode::Print, false)).key("Mod+Alt+P").pure(), CommandSpec::new("view.webLayout", "Web Layout", "View › Views", |s, _| mode(s, ViewMode::Web, false)).pure(), CommandSpec::new("view.draft", "Draft", "View › Views", |s, _| mode(s, ViewMode::Draft, false)).key("Mod+Alt+N").pure(), // The body as an outline of its headings, with the Outlining tab (`outline.*`). CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(), CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(), CommandSpec::new("view.readColumnWidth", "Reading Column Width", "Reading › Layout", read_column_width) .params(r#"{"value": "auto" | "narrow" | "medium" | "wide" | "page"}"#) .pure(), CommandSpec::new("view.readTextSize", "Reading Text Size", "Reading › Layout", read_text_size) .params(r#"{"value": percent (75–150)}"#) .pure(), CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing) .params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#) .pure(), // Print Layout: hide the white space (margins, headers, footers) between pages. Screen only. CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space)) .params(r#"{"value"?: bool}"#) .pure(), CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), // The drawing grid over the page (screen only; never printed or exported). CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)) .params(r#"{"value"?: bool}"#) .pure(), // Outlines of table cells (screen only; never printed or exported). CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines)) .params(r#"{"value"?: bool}"#) .pure(), CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(), CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom) .params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#) .pure(), CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(), CommandSpec::new("view.zoomIn", "Zoom In", "View › Zoom", |s, _| { let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0); zoom(s, &json!({"value": z * 100.0})) }) .pure(), CommandSpec::new("view.zoomOut", "Zoom Out", "View › Zoom", |s, _| { let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1); zoom(s, &json!({"value": z * 100.0})) }) .pure(), CommandSpec::new("view.onePage", "One Page", "View › Zoom", |s, _| zoom(s, &json!({"value": "onePage"}))).pure(), CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(), CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(), CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(), CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| { toggle(s, v, |x| &mut x.accessibility_pane) }) .params(r#"{"value"?: bool}"#) .pure(), CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane)) .params(r#"{"value"?: bool}"#) .pure(), CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| { s.ui_requests.push(json!({"open": "newWindow"})); sel_result(s) }) .pure(), CommandSpec::new("view.split", "Split", "View › Window", |s, _| { s.ui_requests.push(json!({"toggle": "split"})); sel_result(s) }) .key("Mod+Alt+S") .pure(), // The window fills the screen, OS-level full screen (#980). F11 as in browsers and most // desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl, // so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it. CommandSpec::new("view.fullScreen", "Full Screen", "View › Window", |s, v| toggle(s, v, |x| &mut x.full_screen)) .params(r#"{"value"?: bool}"#) .key(super::mac_or("Mod+Ctrl+F / F11", "F11")) .when(full_screen_available) .pure(), CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))) .pure(), // Word › File › Options › Advanced › Numeral: how digits look on screen (display // only; the document text keeps the typed codepoints). CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") { None => Ok(json!({"value": s.view.numeral})), Some(Value::String(name)) => { let Some(mode) = NumeralMode::parse(name) else { return Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())); }; s.view.numeral = mode; s.relayout(); Ok(json!({"value": s.view.numeral})) } Some(_) => Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())), }) .params(r#"{"value"?: "arabic|hindi|context|system"}"#) .pure(), ] } /// The browser keeps full screen to itself (its own F11); the web app can't ask for it. fn full_screen_available(_: &Session) -> Option<&'static str> { cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)") } fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { if read && !s.view.read_mode { // Reading controls are session-only, and reset on each entry to the view. s.view.read_column_width = None; s.view.read_zoom = 1.0; s.view.read_line_spacing = None; } s.view.mode = m; s.view.read_mode = read; s.relayout(); Ok(json!({"mode": m, "readMode": read})) } fn read_column_width(s: &mut Session, v: &Value) -> CmdResult { let width = match v.get("value").and_then(Value::as_str) { Some("auto") => None, Some("narrow") => Some(180.0), Some("medium") => Some(240.0), Some("wide") => Some(350.0), Some("page") => Some(612.0), _ => return Err(CmdError::Params("column width must be auto, narrow, medium, wide, or page".into())), }; s.view.read_column_width = width; Ok(json!({"width": width})) } fn read_text_size(s: &mut Session, v: &Value) -> CmdResult { let Some(pct) = v.get("value").and_then(Value::as_f64) else { return Err(CmdError::Params("reading text size must be a percentage".into())); }; if !pct.is_finite() || !(75.0..=150.0).contains(&pct) { return Err(CmdError::Params("reading text size must be 75–150%".into())); } s.view.read_zoom = pct as f32 / 100.0; Ok(json!({"percent": pct})) } fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult { let value = v.get("value"); let spacing = match value { Some(Value::String(s)) if s == "document" => None, Some(Value::Number(n)) if matches!(n.as_f64(), Some(1.0 | 1.25 | 1.5 | 2.0)) => n.as_f64().map(|n| n as f32), _ => return Err(CmdError::Params("reading line spacing must be document, 1, 1.25, 1.5, or 2".into())), }; s.view.read_line_spacing = spacing; Ok(json!({"spacing": spacing})) } /// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%. pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option { let fit = match v.fit.as_str() { "onePage" => Some(ZoomFit::OnePage), "pageWidth" => Some(ZoomFit::PageWidth), "textWidth" => Some(ZoomFit::TextWidth), _ => None, }; let percent = if v.zoom.is_finite() { (v.zoom * 100.0).round().clamp(10.0, 500.0) as u32 } else { 100 }; Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some()) } /// Show an opened document at the zoom it was saved with. pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) { let fit = match z.fit { Some(ZoomFit::OnePage) => "onePage", Some(ZoomFit::PageWidth) => "pageWidth", Some(ZoomFit::TextWidth) => "textWidth", None => "", }; s.view.zoom = z.percent.clamp(10, 500) as f32 / 100.0; s.view.fit = fit.into(); s.view.multi_page = false; s.view.single_column = fit == "onePage" || fit == "pageWidth"; } fn zoom(s: &mut Session, v: &Value) -> CmdResult { match v.get("value") { Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => { s.view.fit = f.clone(); s.view.multi_page = f == "multiplePages"; s.view.single_column = f == "onePage" || f == "pageWidth"; } Some(x) => { let pct = x.as_f64().ok_or_else(|| CmdError::Params("zoom percent or fit mode".into()))? as f32; if !(10.0..=500.0).contains(&pct) { return Err(CmdError::Params("zoom must be 10–500%".into())); } s.view.zoom = pct / 100.0; s.view.fit.clear(); s.view.multi_page = false; } None => { s.ui_requests.push(json!({"open": "zoom"})); } } Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit})) }