diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 98e2c5eb5..28b4fd4cb 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -37,6 +37,9 @@ pub fn specs() -> Vec { 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(), + CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color) + .params(r#"{"value": "none" | "sepia" | "inverse"}"#) + .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}"#) @@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { s.view.read_column_width = None; s.view.read_zoom = 1.0; s.view.read_line_spacing = None; + s.view.read_page_color = ReadPageColor::None; } s.view.mode = m; s.view.read_mode = read; @@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"spacing": spacing})) } +/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color +/// are unchanged. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReadPageColor { + /// White paper; the document's page color and page picture are not shown. + #[default] + None, + /// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors. + Sepia, + /// A dark page with light text, as View › Switch Modes draws it. + Inverse, +} + +fn read_page_color(s: &mut Session, v: &Value) -> CmdResult { + let color = match v.get("value").and_then(Value::as_str) { + Some("none") => ReadPageColor::None, + Some("sepia") => ReadPageColor::Sepia, + Some("inverse") => ReadPageColor::Inverse, + _ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())), + }; + s.view.read_page_color = color; + Ok(json!({"pageColor": color})) +} + /// 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() { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9428d3ed6..327565e6d 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -178,6 +178,9 @@ pub struct ViewState { pub read_zoom: f32, #[serde(skip)] pub read_line_spacing: Option, + /// Read Mode's Page Color: the paper the reading columns are drawn on. + #[serde(skip)] + pub read_page_color: crate::cmd::view::ReadPageColor, pub dark_mode: bool, pub show_markup: bool, pub track_changes_pane: bool, @@ -265,6 +268,7 @@ impl Default for ViewState { read_column_width: None, read_zoom: default_read_zoom(), read_line_spacing: None, + read_page_color: Default::default(), dark_mode: false, show_markup: true, track_changes_pane: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 75bb875b9..ae54db850 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5181,6 +5181,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() { assert_eq!(s.view.read_line_spacing, None); } +/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the +/// document alone, is not saved, and starts at None on each entry to Read Mode. +#[test] +fn reading_page_color_is_a_transient_view_setting() { + use crate::cmd::view::ReadPageColor; + let mut s = s(); + let original = s.doc.clone(); + let dirty = s.dirty; + run(&mut s, "view.readMode", json!({})); + assert_eq!(s.view.read_page_color, ReadPageColor::None); + assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"})); + assert_eq!(s.view.read_page_color, ReadPageColor::Sepia); + run(&mut s, "view.readPageColor", json!({"value": "inverse"})); + assert_eq!(s.view.read_page_color, ReadPageColor::Inverse); + for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] { + assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}"); + } + assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color"); + assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none()); + assert_eq!(s.doc, original); + assert_eq!(s.dirty, dirty); + run(&mut s, "view.printLayout", json!({})); + run(&mut s, "view.readMode", json!({})); + assert_eq!(s.view.read_page_color, ReadPageColor::None); +} + /// File › Options › Advanced › Numeral: the display mode for digits (display only). #[test] fn numeral_mode_sets_gets_and_rejects_junk() { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a67f91447..97d50cfd6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -460,12 +460,24 @@ pub struct RenderOptions { pub dark: bool, /// The grey that black becomes on a dark page (white paper turns this, not pure black). pub dark_paper: u8, + /// Read Mode's Page Color: paper drawn instead of the document's page color and page + /// picture. `None` shows the document's page background. Screen only. + pub read_paper: Option, } /// Default grey for a dark page's paper. pub const DARK_PAPER: u8 = 0x33; +/// Read Mode's Sepia paper: Word's RGB (112, 66, 20) at 80% transparency over white. +pub const SEPIA_PAPER: Rgb = Rgb(0xE2, 0xD9, 0xD0); + impl RenderOptions { + /// The on-screen color of the paper under `doc`'s pages: [`RenderOptions::read_paper`], + /// else the document's page color, else [`RenderOptions::paper`]. + pub fn paper_of(&self, doc: &Document) -> Rgb { + self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper)) + } + /// The on-screen colour for a document colour (see [`RenderOptions::dark`]). pub fn ink(&self, c: Rgb) -> Rgb { if !self.dark { @@ -541,6 +553,7 @@ impl Default for RenderOptions { text_darkening: false, dark: false, dark_paper: DARK_PAPER, + read_paper: None, } } } @@ -567,9 +580,11 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin let (w16, h16) = (cw as u16, ch as u16); let mut ctx = context_pool::CONTEXTS.take(w16, h16); ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0)); + ctx.set_paint(color(opts.paper_of(doc), 1.0)); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)); - draw_page_picture(&mut ctx, doc, page, view); + if opts.read_paper.is_none() { + draw_page_picture(&mut ctx, doc, page, view); + } let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0); if let Some(wm) = &doc.settings.watermark { draw_watermark(&mut ctx, view, page, wm, opts); @@ -602,7 +617,7 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin /// pictures and turned or rotated content are left out. The caret takes its colour from this /// so it shows against whatever lies behind it (#299). pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb { - let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper)); + let paper = opts.paper_of(doc); if !(x.is_finite() && y.is_finite()) { return paper; } @@ -1566,6 +1581,44 @@ mod tests { assert_eq!(&img.pixels[0..3], &[255, 255, 255]); } + /// Read Mode's Page Color: the reading paper replaces the document's page color and page + /// picture on screen, in the raster and under the caret; the text keeps its color on Sepia. + #[test] + fn read_paper_replaces_the_page_background() { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut d = Document::from_text("Hello WordCraft"); + d.media.insert("bg.png".into(), Arc::new(png)); + d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40)); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let page = &l.pages[0]; + let at = |img: &Rendered, x: u32, y: u32| { + let i = ((y * img.width + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]] + }; + let sepia = RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() }; + let white = RenderOptions { read_paper: Some(Rgb::WHITE), ..Default::default() }; + assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 5, 5), [0x10, 0x10, 0x40], "the page color"); + assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 5, 5), [0xE2, 0xD9, 0xD0]); + assert_eq!(at(&render_page(&d, page, 1.0, &white), 5, 5), [255, 255, 255]); + assert_eq!(background_at(&d, page, &sepia, 5.0, 5.0), SEPIA_PAPER); + assert_eq!(background_at(&d, page, &white, 5.0, 5.0), Rgb::WHITE); + let inverse = RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() }; + assert_eq!(background_at(&d, page, &inverse, 5.0, 5.0), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER)); + d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "tile".into(), title: String::new() }); + assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 300, 300), [10, 200, 30], "the page picture"); + assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 300, 300), [0xE2, 0xD9, 0xD0]); + // The text is drawn in its own color on sepia paper: its darkest pixel is within 8 levels of its value on white. + d.settings.page_picture = None; + d.settings.page_color = None; + let darkest = |img: &Rendered| img.pixels.chunks(4).map(|p| p[0].min(p[1]).min(p[2])).min().unwrap_or(255); + let plain = render_page(&d, page, 2.0, &RenderOptions::default()); + let on_sepia = render_page(&d, page, 2.0, &sepia); + assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia)); + } + /// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so /// the header parses), with no real pixel data. fn ihdr_only_png(w: u32, h: u32) -> Vec { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 99b8a171c..0ef5be178 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -780,6 +780,7 @@ fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); let v = &app.session.view; (v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h); + (v.read_mode, v.read_page_color).hash(&mut h); app.session.prefs.markup.hash(&mut h); dim_body.hash(&mut h); format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark) @@ -1059,7 +1060,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0)); } // View › Switch Modes: dark paper, inverted document colours. - let dark_page = app.session.view.dark_mode; + // In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia + // draws sepia paper and None draws white paper. + use wordcraft_engine::cmd::view::ReadPageColor; + let read_mode = app.session.view.read_mode; + let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode }; + let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia; let dark_paper = wordcraft_render::DARK_PAPER; let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print @@ -1358,7 +1364,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }; // Shadow and paper. painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); - painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE }); + let paper = if dark_page { + Color32::from_gray(dark_paper) + } else if sepia_page { + let s = wordcraft_render::SEPIA_PAPER; + Color32::from_rgb(s.0, s.1, s.2) + } else { + Color32::WHITE + }; + painter.rect_filled(visual_sr, 0.0, paper); if tiled { // Render before painting to keep the frame sharp immediately after an edit or // zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling. @@ -1941,7 +1955,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { { let opts = page_render_options(app, dim_body); let bg = if on_visible_page(&rects, &geo.bands, p) { - opts.ink(app.session.doc.settings.page_color.unwrap_or(opts.paper)) + opts.paper_of(&app.session.doc) } else { let c = Tokens::get(ui.ctx()).canvas; wordcraft_doc::props::Rgb(c.r(), c.g(), c.b()) @@ -3586,6 +3600,13 @@ fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::Rende opts.display.revisions = app.session.prefs.markup.clone(); opts.dark = app.session.view.dark_mode; opts.dark_paper = wordcraft_render::DARK_PAPER; + // In Read Mode the Page color sets the paper and the dark page, and Switch Modes is ignored. + if app.session.view.read_mode { + use wordcraft_engine::cmd::view::ReadPageColor; + let color = app.session.view.read_page_color; + opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE }); + opts.dark = color == ReadPageColor::Inverse; + } opts.display.dim_header = !dim_body; opts.display.dim_body = dim_body; opts @@ -4061,6 +4082,46 @@ mod tests { assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache"); } + /// Read Mode › View › Page Color: the reading columns are drawn on white, sepia or a dark + /// page, in place of the document's page color; other views keep the page color. + #[test] + fn read_mode_page_color_sets_the_reading_paper() { + use wordcraft_doc::props::Rgb; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default()); + a.run("design.pageColor", json!({"color": "#102040"})).unwrap(); + let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc); + assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color"); + let print_key = view_key(&a, false, None); + a.run("view.readMode", json!({})).unwrap(); + assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored"); + let none_key = view_key(&a, false, None); + a.run("view.readPageColor", json!({"value": "sepia"})).unwrap(); + assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER); + assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors"); + let sepia_key = view_key(&a, false, None); + a.run("view.readPageColor", json!({"value": "inverse"})).unwrap(); + assert!(page_render_options(&a, false).dark); + let d = wordcraft_render::DARK_PAPER; + assert_eq!(paper(&a), Rgb(d, d, d)); + let inverse_key = view_key(&a, false, None); + let keys = [print_key, none_key, sepia_key, inverse_key]; + for (i, k) in keys.iter().enumerate() { + assert!(!keys[..i].contains(k), "each paper has its own view key"); + } + a.run("view.printLayout", json!({})).unwrap(); + assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "leaving Read Mode brings the page color back"); + assert!(!page_render_options(&a, false).dark); + // With Switch Modes on, Read Mode's paper still follows the Page color: None is white and Sepia is sepia. + a.session.view.dark_mode = true; + a.run("view.readMode", json!({})).unwrap(); + assert!(!page_render_options(&a, false).dark); + assert_eq!(paper(&a), Rgb::WHITE); + a.run("view.readPageColor", json!({"value": "sepia"})).unwrap(); + assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER); + a.run("view.printLayout", json!({})).unwrap(); + assert!(page_render_options(&a, false).dark, "Print Layout keeps Switch Modes"); + } + /// #299: the caret's colour is worked out only when the caret is drawn: with text selected /// there is no caret, so the page's draw list isn't built for it. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2fe11d765..8bb260e99 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -75,6 +75,20 @@ fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) { } }); ui.separator(); + use wordcraft_engine::cmd::view::ReadPageColor; + let colors = + [(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "Inverse", "inverse")]; + let current = app.session.view.read_page_color; + let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1); + ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| { + for (color, name, value) in colors { + if ui.selectable_label(current == color, tl!(name)).clicked() { + let _ = app.run("view.readPageColor", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); // Headings to jump to, in the Navigation pane beside the columns (Outline view // is an editing layout, so it isn't offered here). if ui.button(tl!("Navigation Pane")).clicked() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 90ca04e79..0063ef4f6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -50,7 +50,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | | Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | -| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772) | reading-optimized view and navigation | 75% | 1–2 | +| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None on each entry (`view.readPageColor`) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the page is the caret's while the caret is on screen, else the page at the top of the window, so scrolling updates it (#3880); the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299). On Windows, whose inverting I-beam pointer stays white over the page, the pointer there is drawn the same way: black on light paper, white on dark (#1746) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252); Interface Theme as a searchable View command (light/dark/system, toggle without a value, #1861) | ✅ | ✅ | 90% | — | @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | | 2026-10-23 | trivial | Insert › Table: hovering the grid shows the table on the page, at the caret, as Word's Live Preview does; it follows the pointer and is laid out on a copy, so the document and Undo change only when a square is clicked (#3931) | | 2026-10-22 | trivial | Mail merge: First Record, Last Record, a record number box, and record choices for Edit Individual Documents and Print Documents (#3028) | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed |