From 19e2e37ca63fbba4133efe07c2852242cb93b1c1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:44:09 +0200 Subject: [PATCH] =?UTF-8?q?View=20=E2=80=BA=20Gridlines=20shows=20the=20dr?= =?UTF-8?q?awing=20grid;=20Table=20Layout=20=E2=80=BA=20View=20Gridlines?= =?UTF-8?q?=20outlines=20cells=20(#69)=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Show › Gridlines only outlined table cells, so with no table on the page it seemed to do nothing. It now draws a light 0.5 cm grid over the page's text area, inside the margins, in Print Layout. Table Layout › View Gridlines gets its own command, table.viewGridlines, with its own view flag, and draws the table cell outlines as before. The ribbon button, keytip and parity catalog point at it. Both are screen-only overlays drawn by the canvas; print, PDF and image exports never show them. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/catalog.rs | 2 +- crates/engine/src/cmd/view.rs | 9 +++- crates/engine/src/session.rs | 5 ++ crates/engine/src/tests.rs | 31 +++++++++++ crates/ui-egui/src/canvas.rs | 97 ++++++++++++++++++++++++++++++++++- crates/ui-egui/src/keytips.rs | 2 +- crates/ui-egui/src/ribbon.rs | 4 +- 7 files changed, 143 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index adbd11d26..1597ddc48 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -333,7 +333,7 @@ Table Design|Borders|Border Painter|table.borderPainter Table Layout|Table|Select Table|table.selectTable Table Layout|Table|Select Row|table.selectRow Table Layout|Table|Select Cell|table.selectCell -Table Layout|Table|View Gridlines|view.gridlines +Table Layout|Table|View Gridlines|table.viewGridlines Table Layout|Table|Properties|table.properties Table Layout|Draw|Draw Table|table.draw Table Layout|Draw|Eraser|table.eraser diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d03b51aa6..9dcec582a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -33,7 +33,14 @@ pub fn specs() -> Vec { CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(), CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), - CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)).pure(), + // The drawing grid over the page (screen only; never printed or exported). + CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)) + .params(r#"{"value"?: bool}"#) + .pure(), + // Outlines of table cells (screen only; never printed or exported). + CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines)) + .params(r#"{"value"?: bool}"#) + .pure(), CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(), CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom) .params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f779da505..56dd6dac7 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -41,7 +41,11 @@ pub struct ViewState { pub focus_mode: bool, pub marks: bool, pub ruler: bool, + /// View › Show › Gridlines: the drawing grid over the page's text area (on screen only). pub gridlines: bool, + /// Table Layout › View Gridlines: outlines of table cells (on screen only). + #[serde(default)] + pub table_gridlines: bool, pub nav_pane: bool, pub styles_pane: bool, pub comments_pane: bool, @@ -66,6 +70,7 @@ impl Default for ViewState { marks: false, ruler: true, gridlines: false, + table_gridlines: false, nav_pane: false, styles_pane: false, comments_pane: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 973d52333..7397ff3b2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1701,6 +1701,37 @@ fn timestamps_come_from_the_clock() { assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } +#[test] +fn page_and_table_gridlines_toggle_independently() { + // #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell + // outlines) are separate view switches; neither touches the document or the undo stack. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let undo = s.undo_labels(); + assert!(!s.view.gridlines && !s.view.table_gridlines); + + assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true); + assert!(s.view.gridlines && !s.view.table_gridlines); + + // Works with the caret outside a table. + assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true); + assert!(s.view.gridlines && s.view.table_gridlines); + + assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false); + assert!(!s.view.gridlines && s.view.table_gridlines); + assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false); + assert!(!s.view.gridlines); + + assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false); + assert!(!s.view.gridlines && !s.view.table_gridlines); + + assert_eq!(text(&s), "Hello"); + assert_eq!(s.undo_labels(), undo); + let state = run(&mut s, "view.state", json!({})); + assert_eq!(state["gridlines"], false); + assert_eq!(state["tableGridlines"], false); +} + /// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom /// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck /// short of the 10%–500% limits. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1dcc4a965..d6c88b0a0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -256,6 +256,43 @@ fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 { if page.w > 0.0 { rect.width() / page.w } else { fallback } } +/// Spacing of the View › Gridlines drawing grid: 0.5 cm, in points. +const DRAWING_GRID_PT: f32 = 72.0 * 0.5 / 2.54; +/// Closest the drawing grid's lines get on screen; zoomed far out, every other line is skipped. +const DRAWING_GRID_MIN_PX: f32 = 4.0; +/// Most grid lines drawn each way on one page (page sizes come from files). +const DRAWING_GRID_MAX_LINES: usize = 2000; + +/// The View › Gridlines drawing grid over a page's text area, in document points: the x of each +/// vertical line and the y of each horizontal line, starting at the margins and `step` apart, +/// never outside the margins. Drawn on screen only — never printed or exported. +fn drawing_grid(body: wordcraft_geom::Rect, step: f32) -> (Vec, Vec) { + let axis = |start: f32, len: f32| -> Vec { + if !(step.is_finite() && step > 0.0 && start.is_finite() && len.is_finite() && len >= 0.0) { + return Vec::new(); + } + let end = start + len + 0.01; + (0..DRAWING_GRID_MAX_LINES).map(|k| start + k as f32 * step).take_while(|p| *p <= end).collect() + }; + (axis(body.x, body.w), axis(body.y, body.h)) +} + +/// The drawing grid's spacing in points at a screen scale: the default 0.5 cm, doubled until the +/// lines are at least [`DRAWING_GRID_MIN_PX`] apart on screen. +fn drawing_grid_step(scale: f32) -> f32 { + let mut step = DRAWING_GRID_PT; + if !(scale.is_finite() && scale > 0.0) { + return step; + } + for _ in 0..16 { + if step * scale >= DRAWING_GRID_MIN_PX { + break; + } + step *= 2.0; + } + step +} + pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let layout = app.session.layout(); @@ -407,8 +444,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } } - // Table gridlines. - if app.session.view.gridlines { + // View › Gridlines: the drawing grid over the text area (Print Layout, on screen only). + if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { + let (xs, ys) = drawing_grid(page.body, drawing_grid_step(page_scale)); + let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22)); + let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale); + let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale); + for x in xs { + let sx = visual_sr.min.x + x * page_scale; + painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke); + } + for y in ys { + let sy = visual_sr.min.y + y * page_scale; + painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke); + } + } + // Table Layout › View Gridlines: table cell outlines (on screen only). + if app.session.view.table_gridlines { for it in &page.items { if let Placed::Cell { rect, .. } = it { let r = Rect::from_min_size( @@ -1284,6 +1336,47 @@ mod tests { assert_eq!(visual.size(), vec2(80.5, 120.5)); } + #[test] + fn drawing_grid_starts_at_the_margins_and_stays_inside_them() { + // Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72). + let body = wordcraft_geom::Rect::new(72.0, 72.0, 468.0, 648.0); + let (xs, ys) = drawing_grid(body, DRAWING_GRID_PT); + assert_eq!(xs.first().copied(), Some(72.0)); + assert_eq!(ys.first().copied(), Some(72.0)); + assert!(xs.iter().all(|x| (72.0..=540.0).contains(x)), "{xs:?}"); + assert!(ys.iter().all(|y| (72.0..=720.0).contains(y)), "{ys:?}"); + // 0.5 cm apart, the same both ways: 468 pt holds 33 steps, 648 pt holds 45. + assert_eq!(xs.len(), 34); + assert_eq!(ys.len(), 46); + for w in xs.windows(2).chain(ys.windows(2)) { + assert!((w[1] - w[0] - DRAWING_GRID_PT).abs() < 0.01, "{w:?}"); + } + // A grid line that lands on the margin is kept. + let (xs, _) = drawing_grid(wordcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0), 25.0); + assert_eq!(xs, vec![0.0, 25.0, 50.0, 75.0, 100.0]); + } + + #[test] + fn drawing_grid_survives_hostile_sizes() { + let r = wordcraft_geom::Rect::new; + for (body, step) in [ + (r(72.0, 72.0, 468.0, 648.0), 0.0), + (r(72.0, 72.0, 468.0, 648.0), -5.0), + (r(72.0, 72.0, 468.0, 648.0), f32::NAN), + (wordcraft_geom::Rect { x: f32::NAN, y: 0.0, w: f32::INFINITY, h: -10.0 }, DRAWING_GRID_PT), + ] { + let (xs, ys) = drawing_grid(body, step); + assert!(xs.is_empty() && ys.is_empty(), "{body:?} {step}"); + } + let (xs, ys) = drawing_grid(r(0.0, 0.0, 1.0e9, 1.0e9), DRAWING_GRID_PT); + assert_eq!((xs.len(), ys.len()), (DRAWING_GRID_MAX_LINES, DRAWING_GRID_MAX_LINES)); + // Zoomed far out, lines thin out instead of filling the page. + assert_eq!(drawing_grid_step(PX_PER_PT), DRAWING_GRID_PT); + assert!(drawing_grid_step(0.1) * 0.1 >= DRAWING_GRID_MIN_PX); + assert_eq!(drawing_grid_step(0.0), DRAWING_GRID_PT); + assert_eq!(drawing_grid_step(f32::NAN), DRAWING_GRID_PT); + } + #[test] fn texel_aligned_rect_keeps_integer_pixel_pages_unchanged() { let layout = Rect::from_min_size(pos2(10.0, 20.0), vec2(80.0, 120.0)); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index f8f5c5045..f9cc4731e 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -333,7 +333,7 @@ fn controls(tab: &str) -> Vec { } "Table Layout" => vec![ m("Select", "table.selectCell"), - c("View Gridlines", "view.gridlines"), + c("View Gridlines", "table.viewGridlines"), c("Properties", "table.properties"), m("Delete", "table.deleteCells"), c("Insert Above", "table.insertRowAbove"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 396310d52..978256ef2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1293,8 +1293,8 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Select Row", "table.selectRow", json!({})); mi(ui, app, "Select Table", "table.selectTable", json!({})); }); - let g = app.session.view.gridlines; - small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g); + let g = app.session.view.table_gridlines; + small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g); small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false); }); });