diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index adbd11d26f..1597ddc48e 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 d03b51aa6f..9dcec582a7 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 f779da5056..56dd6dac7a 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 973d523334..7397ff3b25 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 1dcc4a9657..d6c88b0a0d 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 f8f5c5045a..f9cc4731e0 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 396310d527..978256ef2f 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); }); });