From b400a26aaae6f8c68e1125f2cd98df5ac3cbfd8e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:09:19 -0700 Subject: [PATCH] Read Mode: a Page color menu shows the columns on white, sepia or a dark page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word's Read Mode has View › Page Color with None, Sepia and Inverse. The Read Mode toolbar now has a Page color menu beside Line spacing, backed by the view.readPageColor command. None draws the columns on white and does not show the document's page color or page picture. Sepia draws them on Word's sepia paper (RGB 112, 66, 20 at 80% transparency over white) and leaves the text's colors alone. Inverse draws the dark page that View › Switch Modes draws. The setting is screen only, is not saved, and starts at None on each entry to Read Mode, as the other reading controls do. In Read Mode it sets the paper even while Switch Modes is on. Print Layout and the other views still show the document's page color, and Switch Modes still darkens them. --- crates/engine/src/cmd/view.rs | 29 +++++++++++++++ crates/engine/src/session.rs | 4 +++ crates/engine/src/tests.rs | 26 ++++++++++++++ crates/render/src/lib.rs | 59 ++++++++++++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 67 +++++++++++++++++++++++++++++++++-- crates/ui-egui/src/ribbon.rs | 14 ++++++++ docs/ui-parity.md | 5 +-- 7 files changed, 196 insertions(+), 8 deletions(-) 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 |