From bc60cbaadb41942760b090b51994a38fa5767bf4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:05:40 +0200 Subject: [PATCH] Zoom after One Page keeps one page per row (#768) (#832) Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/tools.rs | 1 + crates/engine/src/cmd/view.rs | 1 + crates/engine/src/session.rs | 4 ++++ crates/engine/src/tests.rs | 19 +++++++++++++++++ crates/ui-egui/src/canvas.rs | 4 ++-- crates/ui-egui/src/lib.rs | 37 ++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 7 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index c7f5c95..0f4581a 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -651,6 +651,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("view.sideToSide", "Side to Side", "View › Page Movement", |s, _| { s.view.multi_page = true; + s.view.single_column = false; s.view.fit = "multiplePages".into(); Ok(json!({"pageMovement": "sideToSide"})) }) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 824e4db..da156c5 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -164,6 +164,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult { Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => { s.view.fit = f.clone(); s.view.multi_page = f == "multiplePages"; + s.view.single_column = f == "onePage" || f == "pageWidth"; } Some(x) => { let pct = x.as_f64().ok_or_else(|| CmdError::Params("zoom percent or fit mode".into()))? as f32; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index ca4df64..f200f99 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -87,6 +87,9 @@ pub struct ViewState { #[serde(default)] pub alt_text_pane: bool, pub multi_page: bool, + /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. + #[serde(default)] + pub single_column: bool, /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. pub fit: String, pub web_width: f32, @@ -169,6 +172,7 @@ impl Default for ViewState { accessibility_pane: false, alt_text_pane: false, multi_page: false, + single_column: false, fit: String::new(), web_width: 800.0, read_width: default_read_width(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 62dd408..6962b62 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2761,6 +2761,25 @@ fn page_and_table_gridlines_toggle_independently() { assert_eq!(state["tableGridlines"], false); } +/// Issue #768: zooming by percent after One Page or Page Width keeps one page per row. +#[test] +fn zoom_after_one_page_keeps_single_column() { + let mut s = s(); + run(&mut s, "view.onePage", json!({})); + assert!(s.view.single_column); + run(&mut s, "view.zoomOut", json!({})); + assert!(s.view.fit.is_empty() && s.view.single_column); + run(&mut s, "view.zoom", json!({"value": 50})); + assert!(s.view.single_column); + run(&mut s, "view.multiplePages", json!({})); + assert!(!s.view.single_column); + run(&mut s, "view.zoomIn", json!({})); + assert!(!s.view.single_column); + run(&mut s, "view.pageWidth", json!({})); + run(&mut s, "view.sideToSide", json!({})); + assert!(!s.view.single_column); +} + #[test] fn navigation_pane_opens_without_changing_view_mode() { // Reading Mode's heading navigation is the Navigation pane; Outline is an editing view (#493). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db0127c..c57e74d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -478,8 +478,8 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { scale = PX_PER_PT; } // Pages flow side by side in Print Layout whenever more than one fits across; One Page and - // Page Width show one per row, Web Layout has no pages. Reading Mode is handled above. - let (min_cols, auto) = if web || v.fit == "onePage" || v.fit == "pageWidth" { + // Page Width show one per row (and so does a zoom after One Page, #768), Web Layout has no pages. Reading Mode is handled above. + let (min_cols, auto) = if web || v.fit == "onePage" || v.fit == "pageWidth" || v.single_column { (1, false) } else if v.multi_page { (2, true) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c1f0f3..3377f02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2411,6 +2411,43 @@ mod tests { assert_eq!(caret.page, 3); } + /// Issue #768: after One Page, zooming out (Zoom Out, the Zoom box, Ctrl + wheel) keeps one page per row. + #[test] + fn zoom_after_one_page_keeps_one_page_per_row() { + let ctx = egui::Context::default(); + let mut a = app(); + for _ in 0..3 { + a.run("insert.pageBreak", json!({})).unwrap(); + } + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp| { + t += 1.0 / 60.0; + let input = egui::RawInput { 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(); + }; + a.run("view.onePage", json!({})).unwrap(); + for _ in 0..3 { + frame(&mut a); + } + assert_eq!(a.canvas.cols, 1); + a.run("view.zoom", json!({"value": 30})).unwrap(); + for _ in 0..3 { + frame(&mut a); + } + assert_eq!(a.canvas.cols, 1, "30% after One Page: still one page per row"); + a.run("view.multiplePages", json!({})).unwrap(); + a.run("view.zoom", json!({"value": 30})).unwrap(); + for _ in 0..3 { + frame(&mut a); + } + assert_eq!(a.canvas.cols, 4, "30% after Multiple Pages: all four pages fit across"); + } + #[test] fn user_name_survives_restart() { let mut first = app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5218d86..30f6ad3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -27,7 +27,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Tables on the page: drag column and row borders, resize handle past the bottom-right corner | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217). Missing: Ctrl and Alt drags, the left edge, borders of selected cells only, double-click AutoFit | ✅ | 80% | 1–2 | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | -| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | +| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | 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; a click sets the proofing language) | ✅ | 85% | — |