View › Gridlines shows the drawing grid; Table Layout › View Gridlines outlines cells (#69) (#243)

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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-10 13:44:09 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 69eaf9925c
commit 19e2e37ca6
7 changed files with 143 additions and 7 deletions
+1 -1
View File
@@ -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
+8 -1
View File
@@ -33,7 +33,14 @@ pub fn specs() -> Vec<CommandSpec> {
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"}"#)
+5
View File
@@ -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,
+31
View File
@@ -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.
+95 -2
View File
@@ -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<f32>, Vec<f32>) {
let axis = |start: f32, len: f32| -> Vec<f32> {
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));
+1 -1
View File
@@ -333,7 +333,7 @@ fn controls(tab: &str) -> Vec<Control> {
}
"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"),
+2 -2
View File
@@ -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);
});
});