From 16622f0853401f073f334d67b036ee3c20db3f83 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 09:20:37 +0200 Subject: [PATCH] #1876: Web Layout text fills the window width (#2539) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 --- ROADMAP.md | 4 +- crates/engine/src/session.rs | 18 ++++- crates/engine/src/tests.rs | 22 +++++++ crates/ui-egui/src/canvas.rs | 124 ++++++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 80 ++++++++++++++++++++++ docs/ui-parity.md | 4 +- 6 files changed, 231 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 034c4a155..b1ac2581b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (Web Layout fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-16 | Web Layout text fills the window width and re-wraps once a resize settles, or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876). | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | @@ -404,6 +405,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 4f16aea9a..5668a8f93 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -143,6 +143,9 @@ pub struct ViewState { pub single_column: bool, /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. pub fit: String, + /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. + /// Draft and Outline don't use it: they keep a fixed 800 pt column. + #[serde(skip, default = "default_web_width")] pub web_width: f32, /// Transient viewport metrics for reading pagination; never saved in a document or preferences. #[serde(skip, default = "default_read_width")] @@ -195,6 +198,13 @@ fn on() -> bool { true } +/// The text column of Draft and Outline, and of Web Layout in a headless session. +const DRAFT_WIDTH: f32 = 800.0; + +fn default_web_width() -> f32 { + DRAFT_WIDTH +} + fn default_read_width() -> f32 { 300.0 } @@ -229,7 +239,7 @@ impl Default for ViewState { multi_page: false, single_column: false, fit: String::new(), - web_width: 800.0, + web_width: default_web_width(), read_width: default_read_width(), read_height: default_read_height(), read_column_width: None, @@ -765,7 +775,11 @@ impl Session { /// The current layout (recomputed when the document or view changed). pub fn layout(&mut self) -> Arc { let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; - let width = if self.view.read_mode { self.view.read_width } else { self.view.web_width }; + let width = match mode { + ViewMode::Read => self.view.read_width, + ViewMode::Web => self.view.web_width, + _ => DRAFT_WIDTH, + }; let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 038786e02..ee45de0dd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7232,3 +7232,25 @@ fn vertical_align_command_sets_justified() { assert_eq!(serde_json::to_value(crate::cmd::page::sect(&s)).unwrap()["valign"], json!(name)); } } + +/// #1876 review: only Web Layout wraps to the window's `web_width`. Draft and Outline keep a fixed +/// 800 pt column, also after a visit to a wide Web Layout, and the window width is never serialized. +#[test] +fn draft_keeps_its_column_after_web_layout() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Draft text wraps in a fixed column. ".repeat(80)})); + run(&mut s, "view.draft", json!({})); + let before = s.layout(); + assert_eq!(before.pages[0].w, 800.0); + run(&mut s, "view.webLayout", json!({})); + s.view.web_width = 1400.0; + assert_eq!(s.layout().pages[0].w, 1400.0, "Web Layout follows the window"); + run(&mut s, "view.draft", json!({})); + assert_eq!(s.layout().pages[0].w, 800.0, "Draft never sees Web Layout's window width"); + run(&mut s, "view.outline", json!({})); + assert_eq!(s.layout().pages[0].w, 800.0, "nor does Outline"); + let serialized = serde_json::to_value(&s.view).unwrap(); + assert!(serialized.get("webWidth").is_none(), "a window width is not view state to keep"); + let restored: crate::ViewState = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored.web_width, 800.0); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4eca0d304..54eb260f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -17,6 +17,8 @@ use crate::theme::{Tokens, regular, semibold}; /// Points on screen per document point at 100% (96 px per inch, like a word processor). pub const PX_PER_PT: f32 = 96.0 / 72.0; const GAP: f32 = 18.0; +/// Width reserved for the vertical scrollbar beside the canvas. +const SCROLLBAR: f32 = 14.0; const RULER: f32 = 22.0; /// Maximum device-pixel height of a tile on very tall Web/Draft pages. const TILE_HEIGHT_PX: usize = 1024; @@ -48,8 +50,9 @@ pub struct CanvasState { /// Touchpad travel not yet converted to an entire reading-column turn. read_wheel_accum: f32, /// Last actual viewport seen during a resize, distinct from the committed reading page size. - read_viewport: Option, - read_resized_at: f64, + read_viewport: Settle, + /// The same for Web Layout's window-wide text width (#1876). + web_viewport: Settle, /// Reading controls (as opposed to window resizes) must reflow without debounce. pub(crate) read_force_reflow: bool, /// First text position on the column currently leading the reading viewport, captured from @@ -190,8 +193,8 @@ impl Default for CanvasState { scroll_to_caret: true, nav_to_caret: false, read_wheel_accum: 0.0, - read_viewport: None, - read_resized_at: 0.0, + read_viewport: Settle::default(), + web_viewport: Settle::default(), read_force_reflow: false, read_first_visible: None, read_target_page: None, @@ -436,6 +439,49 @@ fn place_pages(sizes: &[(f32, f32)], markup: f32, scale: f32, cols: usize, avail (rects, vec2(content_w, y.max(avail.y))) } +/// Full pagination is expensive. During a resize keep the already laid-out pages and reflow +/// once the viewport has been stable this long (seconds). +const RESIZE_SETTLE: f64 = 0.16; + +/// A layout size that follows the window (Read Mode's columns, Web Layout's width): the last +/// viewport seen and when it last changed, so a window drag reflows once it settles, not per frame. +#[derive(Clone, Copy, Debug, Default)] +struct Settle { + viewport: Option, + resized_at: f64, +} + +impl Settle { + /// Whether nothing has been seen since the view was entered (or [`Settle::leave`]). + fn entering(&self) -> bool { + self.viewport.is_none() + } + + /// Notes this frame's viewport `size` at time `now`. Returns whether a layout size that + /// `changed` should be applied now: at once when entering or `forced`, otherwise only after + /// the viewport has held still for [`RESIZE_SETTLE`]. + fn apply(&mut self, size: egui::Vec2, now: f64, changed: bool, forced: bool) -> bool { + let entering = self.entering(); + if self.viewport.is_none_or(|v| (v - size).length_sq() > 0.5) { + self.viewport = Some(size); + self.resized_at = now; + } + changed && (entering || forced || now - self.resized_at >= RESIZE_SETTLE) + } + + fn leave(&mut self) { + self.viewport = None; + } +} + +/// Web Layout's text width in points for a work area `avail_px` wide at `scale`: the whole width, +/// clamped to what the layout accepts so a tiny or huge window never gives zero or a giant page. +fn web_layout_width(avail_px: f32, scale: f32) -> f32 { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { PX_PER_PT }; + let w = avail_px / scale; + if w.is_finite() { w.clamp(144.0, 20_000.0) } else { 800.0 } +} + /// Reading columns use readable text at the user's chosen zoom. The window holds roughly 3–5 /// narrow, full-height columns, and a resize reflows the text, not just the paper scale. fn reading_size(avail: egui::Vec2, scale: f32, requested: Option) -> (f32, f32) { @@ -584,6 +630,20 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { }; let cols = columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto); let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect(); + if v.mode == wordcraft_layout::ViewMode::Web { + // The sheet runs from side to side of the work area, no centred page with grey margins. + let rects: Vec = sizes + .iter() + .scan(0.0f32, |y, &(w, h)| { + let r = Rect::from_min_size(pos2(0.0, *y), vec2(sane_dim(w, 1e5) * scale, sane_dim(h, 1e6) * scale)); + *y = r.bottom(); + Some(r) + }) + .collect(); + let size = vec2(rects.iter().map(|r| r.right()).fold(avail.x.max(0.0), f32::max), rects.last().map_or(0.0, |r| r.bottom()).max(avail.y)); + let bands = rects.iter().map(|r| (0.0, r.height())).collect(); + return Geometry { rects, size, scale, cols: 1, bands }; + } let (mut rects, mut size) = place_pages(&sizes, markup, scale, cols, avail); let mut bands: Vec<(f32, f32)> = rects.iter().map(|r| (0.0, r.height())).collect(); let editing_part = matches!(app.session.sel.focus.story, StoryRef::Part(_)); @@ -781,23 +841,18 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 { pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); - // Full pagination is expensive. During a resize keep the already laid-out columns and - // reflow once the viewport has been stable briefly; ordinary reading controls update now. - const RESIZE_SETTLE: f64 = 0.16; + // During a resize keep the already laid-out pages ([`Settle`]); ordinary reading controls + // and zoom update now. let mut reflow_anchor: Option = None; + let now = ui.input(|i| i.time); if app.session.view.read_mode { - let now = ui.input(|i| i.time); - let resized = app.canvas.read_viewport.is_none_or(|v| (v - full.size()).length_sq() > 0.5); - let entering = app.canvas.read_viewport.is_none(); - if resized { - app.canvas.read_viewport = Some(full.size()); - app.canvas.read_resized_at = now; - } + app.canvas.web_viewport.leave(); + let entering = app.canvas.read_viewport.entering(); let scale = app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT; let (w, h) = reading_size(full.size(), scale, app.session.view.read_column_width); let changed = (app.session.view.read_width - w).abs() > 0.5 || (app.session.view.read_height - h).abs() > 0.5; let forced = std::mem::take(&mut app.canvas.read_force_reflow) || entering; - if changed && (forced || now - app.canvas.read_resized_at >= RESIZE_SETTLE) { + if app.canvas.read_viewport.apply(full.size(), now, changed, forced) { if !entering { reflow_anchor = app.canvas.read_first_visible.clone(); } @@ -812,7 +867,24 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { reflow_anchor = app.canvas.read_first_visible.clone(); } } else { - app.canvas.read_viewport = None; + if app.session.view.mode == wordcraft_layout::ViewMode::Web { + // Web Layout wraps to the work area (#1876): at once on entry or a zoom (the viewport + // holds still), after a window drag has settled, and only on a real change. + let scale = app.session.view.zoom.clamp(0.1, 5.0) * PX_PER_PT; + let w = web_layout_width(full.width() - SCROLLBAR, scale); + let changed = (app.session.view.web_width - w).abs() > 0.5; + let entering = app.canvas.web_viewport.entering(); + if app.canvas.web_viewport.apply(full.size(), now, changed, false) { + app.session.view.web_width = w; + // The pixel offset no longer points at the same text: keep the caret's line in view. + app.canvas.scroll_to_caret |= !entering; + } else if changed { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(170)); + } + } else { + app.canvas.web_viewport.leave(); + } + app.canvas.read_viewport.leave(); app.canvas.read_force_reflow = false; app.canvas.read_first_visible = None; app.canvas.read_target_page = None; @@ -864,7 +936,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let id = if reading { "view.readTextSize" } else { "view.zoom" }; let _ = app.run(id, json!({"value": pct})); } - let avail = if app.session.view.read_mode { area.size() } else { area.size() - vec2(14.0, 0.0) }; + let avail = if app.session.view.read_mode { area.size() } else { area.size() - vec2(SCROLLBAR, 0.0) }; let geo = geometry(app, &layout, avail); // A pane or toolbar resize changes the reachable last column immediately. Navigation must // clamp against this frame's geometry, not the previous frame's scroll maximum. @@ -3063,6 +3135,24 @@ mod tests { use super::*; use crate::table_resize::tests::{H, cell, screen, steps, table_window}; + /// A window-following layout size (#1876, Read Mode): applied at once on entry or when forced, + /// held while the window is dragged, applied once the size has been still for the settle time. + #[test] + fn settle_waits_for_a_drag_to_stop() { + let mut s = Settle::default(); + assert!(s.entering()); + assert!(s.apply(vec2(1400.0, 700.0), 1.0, true, false), "entering applies at once"); + assert!(!s.apply(vec2(1400.0, 700.0), 1.01, false, false), "nothing changed"); + assert!(!s.apply(vec2(1300.0, 700.0), 1.02, true, false), "a drag frame waits"); + assert!(!s.apply(vec2(1200.0, 700.0), 1.10, true, false), "still dragging"); + assert!(!s.apply(vec2(1200.0, 700.0), 1.20, true, false), "not settled yet"); + assert!(s.apply(vec2(1200.0, 700.0), 1.11 + RESIZE_SETTLE, true, false), "settled"); + assert!(s.apply(vec2(1100.0, 700.0), 2.0, true, true), "forced applies mid-drag"); + assert!(s.apply(vec2(1100.0, 700.0), 5.0, true, false), "a zoom with a still window applies now"); + s.leave(); + assert!(s.entering()); + } + /// What `page_image` replaced: the raster un-premultiplied, for egui to premultiply again. fn by_straight_alpha(img: &wordcraft_render::Rendered) -> egui::ColorImage { egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 51dcc30d6..fcd512791 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3094,6 +3094,86 @@ mod tests { assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at); } + /// Web Layout (#1876): text wraps to the work area, re-wraps once a resize has settled (not on + /// every frame of a drag) and at once on a zoom, and the width is clamped. Print Layout keeps its + /// page width and Draft keeps its 800 pt column after a visit to Web Layout. + #[test] + fn web_layout_fills_the_window_width() { + let ctx = egui::Context::default(); + let mut a = app(); + a.run("text.insert", json!({"text": "A long paragraph that wraps across the work area. ".repeat(200)})).unwrap(); + let mut t = 0.0; + // One frame `dt` seconds after the last one, in a window `w` wide. + let mut frame = |a: &mut WordApp, w: f32, dt: f64| { + t += dt; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 740.0)); + let input = egui::RawInput { events: vec![], time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + let lines = |a: &mut WordApp| { + a.session + .layout() + .pages + .iter() + .flat_map(|p| p.items.iter()) + .map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }) + .sum::() + }; + let tick = 1.0 / 60.0; + for _ in 0..3 { + frame(&mut a, 1400.0, tick); + } + let print_lines = lines(&mut a); + a.run("view.draft", json!({})).unwrap(); + frame(&mut a, 1400.0, tick); + let draft_lines = lines(&mut a); + a.run("view.webLayout", json!({})).unwrap(); + frame(&mut a, 1400.0, tick); + let wide = a.session.view.web_width; + assert!((900.0..1400.0).contains(&wide), "entering Web Layout fills the window at once, not 800: {wide}"); + let wide_lines = lines(&mut a); + frame(&mut a, 1400.0, tick); + assert_eq!(a.session.view.web_width, wide, "no change without a resize"); + let page = a.canvas.page_rects[0]; + assert!(page.left() < 4.0, "page starts at the left edge: {page:?}"); + // A window drag: the width holds while the size keeps changing, then follows once it settles. + for w in [1300.0, 1100.0, 900.0, 700.0, 500.0] { + frame(&mut a, w, tick); + assert_eq!(a.session.view.web_width, wide, "no relayout mid-drag at {w}"); + } + frame(&mut a, 500.0, 0.2); + let narrow = a.session.view.web_width; + assert!((144.0..500.0).contains(&narrow), "{narrow}"); + assert!(lines(&mut a) > wide_lines, "narrower window wraps more"); + // A zoom re-wraps at once: at 200% the window holds half as many points. + frame(&mut a, 1400.0, 0.2); + frame(&mut a, 1400.0, 0.2); + let at_100 = a.session.view.web_width; + a.run("view.zoom", json!({"value": 200})).unwrap(); + frame(&mut a, 1400.0, tick); + let at_200 = a.session.view.web_width; + assert!((at_200 - at_100 / 2.0).abs() < 2.0, "200% halves the width: {at_100} -> {at_200}"); + a.run("view.zoom", json!({"value": 100})).unwrap(); + frame(&mut a, 20.0, 0.2); + frame(&mut a, 20.0, 0.2); + assert!(a.session.view.web_width >= 144.0); + frame(&mut a, 1400.0, 0.2); + frame(&mut a, 1400.0, 0.2); + a.run("view.draft", json!({})).unwrap(); + frame(&mut a, 1400.0, tick); + assert_eq!(a.session.layout().pages[0].w, 800.0, "Draft keeps its column after Web Layout"); + assert_eq!(lines(&mut a), draft_lines, "Draft wraps as before Web Layout"); + a.run("view.printLayout", json!({})).unwrap(); + for _ in 0..3 { + frame(&mut a, 1400.0, tick); + } + assert_eq!(lines(&mut a), print_lines, "Print Layout is unchanged"); + } + /// Reading Mode: reflowed viewport-height columns, horizontal wheel/navigation, exit restores /// Print Layout without editing or changing the document's page setup. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9c776ace1..8d96fd7cc 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-17 · **Change:** trivial (Language dialog keeps inherited proofing language and no-proof choices (#2717)); 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-17 · **Change:** trivial (Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); 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 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -41,6 +41,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. Web Layout, Draft and Read Mode show no margins and leave it alone | ✅ | 95% | 0.5 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | +| 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% | — | | 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 | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | @@ -61,6 +62,7 @@ The Language dialog reads effective character formatting, including document def | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |