diff --git a/ROADMAP.md b/ROADMAP.md index 8d91569..44e8068 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| +| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) | | 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | | 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | diff --git a/crates/engine/src/cmd/table_draw.rs b/crates/engine/src/cmd/table_draw.rs index 0461f6f..53b202b 100644 --- a/crates/engine/src/cmd/table_draw.rs +++ b/crates/engine/src/cmd/table_draw.rs @@ -60,7 +60,7 @@ fn cell_boxes(l: &DocLayout, page: usize) -> Vec { pg.items .iter() .filter_map(|it| match it { - Placed::Cell { rect, table, row, cell, story } => { + Placed::Cell { rect, table, row, cell, story, .. } => { Some(CellBox { rect: *rect, table: table.clone(), story: *story, row: *row, cell: *cell }) } _ => None, @@ -527,6 +527,29 @@ mod tests { assert!(s.run("table.draw", &json!({})).is_err()); } + /// Erasing a horizontal border merges the cells into one laid-out cell (#640): the lower + /// row's box is `covered`, the merged cell's box spans both rows, and erasing the border + /// below the merge extends it. + #[test] + fn erasing_a_horizontal_border_lays_the_merge_out_as_one_cell() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.table", &json!({"rows": 3, "cols": 2})).unwrap(); + let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 0)); + s.run("table.eraser", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.bottom() + 0.5})).unwrap(); + let t = first_table(&s); + assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue)); + let merged = cell_rect(&mut s, 0, 0); + assert!((merged.bottom() - b.bottom()).abs() < 0.5 && merged.h > a.h * 1.9, "{merged:?}"); + let covered = s.layout().pages[0].items.iter().any(|it| matches!(it, Placed::Cell { row: 1, cell: 0, covered: true, .. })); + assert!(covered, "the continuation row's box is covered"); + let hit = s.layout().cell_at(0, merged.x + 3.0, b.y + b.h / 2.0).map(|c| (c.2, c.3)); + assert_eq!(hit, Some((0, 0)), "a click in the lower half is in the merged cell"); + // The border below the merge: the merge grows to three rows. + s.run("table.eraser", &json!({"page": 0, "x": merged.x + merged.w / 2.0, "y": merged.bottom() + 0.5})).unwrap(); + let t = first_table(&s); + assert_eq!(t.rows[2].cells[0].props.vmerge, VMerge::Continue); + } + /// A vertical line through a cell splits it into two columns; a horizontal one splits a row; /// the eraser merges the cells back; each is one undo step. #[test] diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 00f2fe7..53c10d7 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -655,7 +655,7 @@ impl DocLayout { pub fn cell_at(&self, page: usize, x: f32, y: f32) -> Option<(StoryRef, Path, usize, usize)> { let p = self.pages.get(page)?; p.items.iter().rev().find_map(|it| match it { - Placed::Cell { rect, table, row, cell, story } if rect.contains(wordcraft_geom::Point::new(x, y)) => { + Placed::Cell { rect, table, row, cell, story, covered: false } if rect.contains(wordcraft_geom::Point::new(x, y)) => { Some((*story, table.clone(), *row, *cell)) } _ => None, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index d67d1e9..2f37168 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -152,9 +152,12 @@ pub enum Placed { spin: Spin, items: Vec, }, - /// A table cell's area (for hit testing and cell selection). + /// A table cell's area (for hit testing and cell selection). A vertically merged cell is + /// one `Cell` as tall as all its rows; its continuation rows get a `covered` `Cell` (their + /// own row's height) so the rows keep a box each, but it isn't a cell of its own on screen. Cell { rect: Rect, + covered: bool, table: Path, row: usize, cell: usize, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 6f45294..c305674 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -662,7 +662,14 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava if let Some(e) = b.right.filter(Border::is_visible) { items.push(Placed::Rule { x0: x1, y0, x1, y1, border: e }); } - items.push(Placed::Cell { rect: Rect::new(c.x, 0.0, c.w, rh), table: wordcraft_doc::Path(path.to_vec()), row: ri, cell: ci, story }); + items.push(Placed::Cell { + rect: Rect::new(c.x, 0.0, c.w, if c.vmerge == VMerge::Continue { rh } else { span_h }), + covered: c.vmerge == VMerge::Continue, + table: wordcraft_doc::Path(path.to_vec()), + row: ri, + cell: ci, + story, + }); } out.push(RowLayout { height: rh, items }); } @@ -790,9 +797,9 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { a.extend(above.map(|rect| Placed::Fill { rect, color: *color })); b.extend(below.map(|rect| Placed::Fill { rect, color: *color })); } - Placed::Cell { rect, table, row: r, cell, story } => { + Placed::Cell { rect, covered, table, row: r, cell, story } => { let (above, below) = split_rect(*rect, cut, shift); - let cell_at = |rect| Placed::Cell { rect, table: table.clone(), row: *r, cell: *cell, story: *story }; + let cell_at = |rect| Placed::Cell { rect, covered: *covered, table: table.clone(), row: *r, cell: *cell, story: *story }; a.extend(above.map(cell_at)); b.extend(below.map(cell_at)); } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 736ea31..90f4711 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -498,6 +498,29 @@ fn tables_lay_out_cells() { assert!(l.cell_at(0, c.x, c.top + 2.0).is_some()); } +/// A vertical merge is one cell: its box spans the rows, a click anywhere in it finds it, and the +/// continuation row's box is `covered` (no gridline, not a hit). +#[test] +fn a_vertical_merge_lays_out_as_one_cell() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(3, 2, 468.0); + t.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart; + t.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue; + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let cells: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|i| if let Placed::Cell { rect, row, cell: 0, covered, .. } = i { Some((*row, *rect, *covered)) } else { None }) + .collect(); + let (r0, r1) = (cells[0].1, cells[1].1); + assert!(!cells[0].2 && cells[1].2 && !cells[2].2, "{cells:?}"); + assert!((r0.bottom() - r1.bottom()).abs() < 0.01 && r0.h > r1.h * 1.9, "{r0:?} {r1:?}"); + // The lower half of the merge hits the restart cell (row 0), not the continuation row. + let hit = l.cell_at(0, r1.x + 5.0, r1.y + r1.h / 2.0).unwrap(); + assert_eq!((hit.2, hit.3), (0, 0)); +} + /// A 1×3 table after "before" whose middle cell holds `text` running `dir`. fn turned_table(dir: TextDirection, text: &str, row: Option) -> Document { let mut d = Document::from_text("before\nafter"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 897c113..fb524e3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -571,7 +571,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // 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 { + if let Placed::Cell { rect, covered: false, .. } = it { let r = Rect::from_min_size( pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), vec2(rect.w * page_scale, rect.h * page_scale), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9308e8e..6dfe6bd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2137,14 +2137,27 @@ fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) { let row = t.rows.get(r).map(|x| x.props.clone()).unwrap_or_default(); let stored_w = t.grid.get(t.grid_col(r, c)).copied(); let laid = app.session.layout().pages.iter().flat_map(|pg| pg.items.iter()).find_map(|it| match it { - wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st } if *table == tp && *row == r && *cell == c && *st == story => { + wordcraft_layout::Placed::Cell { rect, table, row, cell, story: st, .. } if *table == tp && *row == r && *cell == c && *st == story => { Some(*rect) } _ => None, }); + // The row's own height: a vertically merged cell's box spans several rows, so take the + // shortest box in this row. + let row_h = app + .session + .layout() + .pages + .iter() + .flat_map(|pg| pg.items.iter()) + .filter_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, table, row, story: st, .. } if *table == tp && *row == r && *st == story => Some(rect.h), + _ => None, + }) + .reduce(f32::min); let unit = crate::options::unit(); let k = unit.pt_per_unit(); - let h0 = row.height.or(laid.map(|x| x.h)).unwrap_or(0.0); + let h0 = row.height.or(row_h).unwrap_or(0.0); let w0 = laid.map(|x| x.w).or(stored_w).unwrap_or(0.0); let exact = row.height_rule == wordcraft_doc::props::HeightRule::Exact; // The edited value in points, and whether it continues a drag (joins the previous Undo step).