diff --git a/ROADMAP.md b/ROADMAP.md index d619fcd24..d6159cf47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303: catalog 396/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -14,14 +14,14 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **396 / 434 (91.2%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | | **Essentials user** | **~63%** | estimated: core-feature depth × discounts for launch (×0.90), discoverability (×0.95), opening received files (×0.92) | | Remaining to **beta** | **~150–230 h** of Opus 5.5 agent wall-clock (gaps #1–#7) | estimated | | Remaining to **full parity** with Word desktop | **~750–1,250 h** (70–80% parallelizable) | estimated | -| Commands / tests / code | 431 commands · 852 tests + 10 property blocks · ~88,700 lines of Rust | measured (source) | +| Commands / tests / code | 433 commands · 855 tests + 10 property blocks · ~88,700 lines of Rust | measured (source) | | Releases | v0.4.0 (2026-10-10): signed macOS universal DMG, Windows MSI x64/x86/arm64, Linux AppImage/deb/rpm/Flatpak x86_64 + aarch64, FreeBSD, web | measured | Hours are calibrated from this repo's PRs (equations #191: ~9.7k lines in ~5 h; `.doc` reader @@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) | | 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) | | 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); v0.4.0 released | | 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321); v0.4.0 released | @@ -145,6 +146,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | | 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) | | 2026-10-10 | trivial | Progress log: free rotation and flips (#332) | | 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f0135b834..cbb16da4b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -20,6 +20,7 @@ pub mod references; pub mod review; pub mod speech; pub mod table; +pub mod table_draw; pub mod table_style; pub mod text; pub mod tools; @@ -49,6 +50,7 @@ pub fn registry() -> Registry { v.extend(page::specs()); v.extend(table::specs()); v.extend(table_style::specs()); + v.extend(table_draw::specs()); v.extend(review::specs()); v.extend(file::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 133ce523e..d88b0589a 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -204,7 +204,7 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult { /// Give every cell the width of the grid columns it spans, so the cells' preferred widths /// (saved to .docx) agree with the grid. -fn sync_cell_widths(t: &mut Table) { +pub(crate) fn sync_cell_widths(t: &mut Table) { let grid = t.grid.clone(); for row in &mut t.rows { let mut g = 0usize; diff --git a/crates/engine/src/cmd/table_draw.rs b/crates/engine/src/cmd/table_draw.rs new file mode 100644 index 000000000..3df1977d8 --- /dev/null +++ b/crates/engine/src/cmd/table_draw.rs @@ -0,0 +1,578 @@ +//! Table Layout › Draw: Draw Table and Eraser. A pen stroke on a page either draws a new one-cell +//! table (a rectangle outside any table) or splits the cells a straight line crosses; the eraser +//! removes the border nearest a point by merging the two cells beside it. Coordinates are page +//! points, like the layout's; the app turns the pen and eraser on as canvas modes and sends each +//! finished stroke or click here, so every stroke is one undo step. + +use serde_json::Value; +use wordcraft_doc::props::{HeightRule, VMerge}; +use wordcraft_doc::table::{MAX_COLS, MAX_ROWS}; +use wordcraft_doc::{Block, Cell, Path, Pos, StoryRef, Table, para_block}; +use wordcraft_geom::Rect; +use wordcraft_layout::{DocLayout, Placed}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +/// A drag shorter than this (points) both ways isn't a rectangle. +pub const MIN_RECT: f32 = 12.0; +/// How near (points) the eraser has to be to a border. +pub const ERASER_REACH: f32 = 4.0; +/// A line closer than this (points) to an existing border lands on it. +const SNAP: f32 = 3.0; +/// The shortest stroke (points) that counts as a line. +const MIN_LINE: f32 = 6.0; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("table.draw", "Draw Table", "Insert › Tables", draw).params( + r#"{"page": n, "x0": pt, "y0": pt, "x1": pt, "y1": pt, "story"?: StoryRef} (a pen stroke in page points, 0-based page: a rectangle at least 12 pt each way outside any table inserts a one-cell table of that size at the paragraph under its start; a straight vertical or horizontal line inside a table splits the cells it crosses there. In the app the button turns the pen on.)"#, + ), + CommandSpec::new("table.eraser", "Eraser", "Table Layout › Draw", eraser).params( + r#"{"page": n, "x": pt, "y": pt} (removes the border between two cells within 4 pt of the point by merging them. In the app the button turns the eraser on.)"#, + ), + ] +} + +/// What a pen stroke does, judged from the layout (also the app's live preview). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stroke { + /// Draw a new table in this rectangle (page points). + Rect(Rect), + /// Split the cells the vertical line at `x` crosses between `y0` and `y1`. + Vertical { x: f32, y0: f32, y1: f32 }, + /// Split the cells the horizontal line at `y` crosses between `x0` and `x1`. + Horizontal { y: f32, x0: f32, x1: f32 }, +} + +/// A table cell's area on a page. +#[derive(Clone, Debug)] +struct CellBox { + rect: Rect, + table: Path, + story: StoryRef, + row: usize, + cell: usize, +} + +fn cell_boxes(l: &DocLayout, page: usize) -> Vec { + let Some(pg) = l.pages.get(page) else { return Vec::new() }; + pg.items + .iter() + .filter_map(|it| match it { + Placed::Cell { rect, table, row, cell, story } => { + Some(CellBox { rect: *rect, table: table.clone(), story: *story, row: *row, cell: *cell }) + } + _ => None, + }) + .collect() +} + +fn inside(r: &Rect, x: f32, y: f32) -> bool { + x >= r.x && x <= r.right() && y >= r.y && y <= r.bottom() +} + +/// The innermost table with a cell under the point. +fn table_at(boxes: &[CellBox], x: f32, y: f32) -> Option<(StoryRef, Path)> { + boxes.iter().filter(|b| inside(&b.rect, x, y)).max_by_key(|b| b.table.0.len()).map(|b| (b.story, b.table.clone())) +} + +/// Classify a pen stroke: a straight line inside a table, a rectangle outside one, else `None`. +pub fn stroke(l: &DocLayout, page: usize, x0: f32, y0: f32, x1: f32, y1: f32) -> Option { + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) { + return None; + } + let (dx, dy) = ((x1 - x0).abs(), (y1 - y0).abs()); + let boxes = cell_boxes(l, page); + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + if table_at(&boxes, x0, y0).is_some() || table_at(&boxes, mx, my).is_some() { + if dy >= dx && dy >= MIN_LINE && dx <= MIN_RECT.max(dy * 0.2) { + return Some(Stroke::Vertical { x: mx, y0: y0.min(y1), y1: y0.max(y1) }); + } + if dx > dy && dx >= MIN_LINE && dy <= MIN_RECT.max(dx * 0.2) { + return Some(Stroke::Horizontal { y: my, x0: x0.min(x1), x1: x0.max(x1) }); + } + return None; + } + (dx >= MIN_RECT && dy >= MIN_RECT).then(|| Stroke::Rect(Rect::new(x0.min(x1), y0.min(y1), dx, dy))) +} + +fn page_param(v: &Value) -> Result { + p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into())) +} + +fn draw(s: &mut Session, v: &Value) -> CmdResult { + let page = page_param(v)?; + let (x0, y0, x1, y1) = (p::req_f32(v, "x0")?, p::req_f32(v, "y0")?, p::req_f32(v, "x1")?, p::req_f32(v, "y1")?); + let l = s.layout(); + if l.pages.get(page).is_none() { + return Err(CmdError::Params(format!("no page {page}"))); + } + match stroke(&l, page, x0, y0, x1, y1) { + Some(Stroke::Rect(r)) => draw_table(s, v, &l, page, (x0, y0), r), + Some(Stroke::Vertical { x, y0, y1 }) => split_columns(s, &l, page, x, (y0, y1)), + Some(Stroke::Horizontal { y, x0, x1 }) => split_rows(s, &l, page, y, (x0, x1)), + None => Err(CmdError::Failed( + "draw a rectangle (at least 12 pt each way) outside a table, or a straight horizontal or vertical line across table cells".into(), + )), + } +} + +/// A one-cell table the size of `r`, at the paragraph under the stroke's start. +fn draw_table(s: &mut Session, v: &Value, l: &DocLayout, page: usize, start: (f32, f32), r: Rect) -> CmdResult { + let story = super::story_param(s, v); + let pos = l.hit(page, start.0, start.1, story).ok_or_else(|| CmdError::Failed("there's no text to put a table at there".into()))?; + let pos = s.doc.clamp(&pos); + if pos.path.cell().is_some() { + return Err(CmdError::Failed("draw new tables outside a table; inside one, draw a line to split cells".into())); + } + let len = s.doc.para(pos.story, &pos.path).map(|q| q.len()).ok_or_else(|| CmdError::Failed("there's no paragraph there".into()))?; + let tw = super::page::sect(s).text_width().max(18.0); + let w = r.w.clamp(18.0, tw); + let mut t = Table::new(1, 1, w); + t.props.width = Some(w); + let body_x = l.pages.get(page).map_or(0.0, |pg| pg.body.x); + let indent = r.x - body_x; + if indent > 1.0 { + t.props.indent = Some(indent.min((tw - w).max(0.0))); + } + if let Some(row) = t.rows.first_mut() { + row.props.height = Some(r.h.clamp(4.0, 1584.0)); + row.props.height_rule = HeightRule::AtLeast; + } + // Before the paragraph when the stroke starts at its beginning (or it's empty), else after it. + let at = if pos.off == 0 || len == 0 { pos.path.clone() } else { s.doc.split_paragraph(&Pos { off: len, ..pos.clone() })?.path }; + s.doc.insert_block(pos.story, &at, Block::Table(t))?; + let mut first = at.0.clone(); + first.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story: pos.story, path: Path(first), off: 0 }); + sel_result(s) +} + +/// The cells of one table a stroke crosses, as (row, cell) with their areas, and that table. +fn crossed(boxes: &[CellBox], at: (f32, f32), hit: impl Fn(&Rect) -> bool) -> Option<(StoryRef, Path, Vec<(usize, usize, Rect)>)> { + let (story, table) = table_at(boxes, at.0, at.1) + .or_else(|| boxes.iter().filter(|b| hit(&b.rect)).max_by_key(|b| b.table.0.len()).map(|b| (b.story, b.table.clone())))?; + let mut cells: Vec<(usize, usize, Rect)> = + boxes.iter().filter(|b| b.story == story && b.table == table && hit(&b.rect)).map(|b| (b.row, b.cell, b.rect)).collect(); + cells.sort_by_key(|c| (c.0, c.1)); + cells.dedup_by_key(|c| (c.0, c.1)); + Some((story, table, cells)) +} + +/// Grid column offsets: `cum[i]` is where grid column `i` starts. +fn offsets(t: &Table) -> Vec { + let mut cum = Vec::with_capacity(t.grid.len() + 1); + let mut x = 0.0f32; + cum.push(0.0); + for w in &t.grid { + x += w.max(0.0); + cum.push(x); + } + cum +} + +/// The rows of the vertically merged group cell `c` of row `r` belongs to (just `r` when none). +fn vgroup(t: &Table, r: usize, c: usize) -> (usize, usize) { + let g = t.grid_col(r, c); + let starts_at = |row: usize| t.cell_at_grid(row, g).filter(|&ci| t.grid_col(row, ci) == g).and_then(|ci| t.rows.get(row)?.cells.get(ci)); + let Some(cell) = starts_at(r) else { return (r, r) }; + if cell.props.vmerge == VMerge::None { + return (r, r); + } + let mut r0 = r; + while r0 > 0 && starts_at(r0).is_some_and(|x| x.props.vmerge == VMerge::Continue) { + r0 -= 1; + } + let mut r1 = r; + while starts_at(r1 + 1).is_some_and(|x| x.props.vmerge == VMerge::Continue) { + r1 += 1; + } + (r0, r1) +} + +/// A vertical line at page `x`: split each crossed cell into two columns there. +fn split_columns(s: &mut Session, l: &DocLayout, page: usize, x: f32, (ya, yb): (f32, f32)) -> CmdResult { + let boxes = cell_boxes(l, page); + let hit = |r: &Rect| { + let overlap = yb.min(r.bottom()) - ya.max(r.y); + x > r.x + SNAP && x < r.right() - SNAP && overlap >= r.h * 0.5 + }; + let Some((story, tp, cells)) = crossed(&boxes, (x, (ya + yb) / 2.0), hit) else { + return Err(CmdError::Failed("the line doesn't cross a table cell".into())); + }; + let Some(&(r0, c0, rect0)) = cells.first() else { return Err(CmdError::Failed("the line doesn't cross a table cell".into())) }; + let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("the table isn't there any more".into()))?; + // Where the line falls in the table's grid, measured in the first crossed cell. + let cum = offsets(t); + let a = t.grid_col(r0, c0); + let span = t.rows.get(r0).and_then(|row| row.cells.get(c0)).map_or(1, Cell::span); + let (start, end) = (cum.get(a).copied().unwrap_or(0.0), cum.get(a + span).or(cum.last()).copied().unwrap_or(0.0)); + let g = start + (x - rect0.x) / rect0.w.max(1.0) * (end - start); + if !g.is_finite() || g <= 0.0 { + return Err(CmdError::Failed("the line doesn't cross a table cell".into())); + } + let crossed_set: std::collections::BTreeSet<(usize, usize)> = cells.iter().map(|c| (c.0, c.1)).collect(); + // A vertically merged cell splits only as a whole. + for &(r, c, _) in &cells { + let (g0, g1) = vgroup(t, r, c); + let gc = t.grid_col(r, c); + for rr in g0..=g1 { + let ci = t.cell_at_grid(rr, gc).unwrap_or(usize::MAX); + if !crossed_set.contains(&(rr, ci)) { + return Err(CmdError::Failed("the line crosses only part of a merged cell; draw it across the whole cell".into())); + } + } + } + // Snap to an existing grid line, else a new one inside grid column `k`. + let existing = (1..t.grid.len()).find(|&j| cum.get(j).is_some_and(|c| (c - g).abs() < SNAP)); + let k = (0..t.grid.len()).find(|&k| cum.get(k + 1).is_some_and(|&e| g < e)).unwrap_or(t.grid.len().saturating_sub(1)); + if existing.is_none() && t.grid.len() >= MAX_COLS { + return Err(CmdError::Failed(format!("a table can't have more than {MAX_COLS} columns"))); + } + let t = s.doc.table_mut(story, &tp)?; + let mut changed = false; + match existing { + Some(j) => { + for &(r, c) in crossed_set.iter().rev() { + let a = t.grid_col(r, c); + let Some(row) = t.rows.get_mut(r) else { continue }; + let Some(cell) = row.cells.get_mut(c) else { continue }; + let span = cell.span(); + if a < j && j < a + span { + cell.props.span = (j - a) as u32; + let mut nc = Cell::empty(); + nc.props = cell.props.clone(); + nc.props.span = (a + span - j) as u32; + row.cells.insert(c + 1, nc); + changed = true; + } + } + } + None => { + let ks = cum.get(k).copied().unwrap_or(0.0); + let ke = cum.get(k + 1).copied().unwrap_or(ks); + let (left, right) = (g - ks, ke - g); + if left < SNAP || right < SNAP { + return Err(CmdError::Failed("there's already a border there".into())); + } + if let Some(w) = t.grid.get_mut(k) { + *w = left; + } + t.grid.insert((k + 1).min(t.grid.len()), right); + for r in 0..t.rows.len() { + let Some(ci) = t.cell_at_grid(r, k) else { continue }; + let a = t.grid_col(r, ci); + let Some(row) = t.rows.get_mut(r) else { continue }; + let Some(cell) = row.cells.get_mut(ci) else { continue }; + if crossed_set.contains(&(r, ci)) { + let span = cell.span(); + cell.props.span = (k + 1 - a) as u32; + let mut nc = Cell::empty(); + nc.props = cell.props.clone(); + nc.props.span = (a + span - k) as u32; + row.cells.insert(ci + 1, nc); + } else { + cell.props.span = cell.props.span.saturating_add(1); + } + } + changed = true; + } + } + if !changed { + return Err(CmdError::Failed("there's already a border there".into())); + } + super::table::sync_cell_widths(t); + finish(s, story, &tp, r0, c0 + 1) +} + +/// A horizontal line at page `y`: split the row it crosses there. Crossed cells become two; +/// the row's other cells span both rows (a vertical merge). +fn split_rows(s: &mut Session, l: &DocLayout, page: usize, y: f32, (xa, xb): (f32, f32)) -> CmdResult { + let boxes = cell_boxes(l, page); + let hit = |r: &Rect| { + let overlap = xb.min(r.right()) - xa.max(r.x); + y > r.y + SNAP && y < r.bottom() - SNAP && overlap >= r.w * 0.5 + }; + let Some((story, tp, cells)) = crossed(&boxes, ((xa + xb) / 2.0, y), hit) else { + return Err(CmdError::Failed("the line doesn't cross a table cell".into())); + }; + let Some(&(r, c0, _)) = cells.first() else { return Err(CmdError::Failed("the line doesn't cross a table cell".into())) }; + if cells.iter().any(|c| c.0 != r) { + return Err(CmdError::Failed("draw the line within one row".into())); + } + // The row's extent on this page. + let row_boxes: Vec<&Rect> = boxes.iter().filter(|b| b.story == story && b.table == tp && b.row == r).map(|b| &b.rect).collect(); + let top = row_boxes.iter().map(|b| b.y).fold(f32::INFINITY, f32::min); + let bottom = row_boxes.iter().map(|b| b.bottom()).fold(f32::NEG_INFINITY, f32::max); + let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("the table isn't there any more".into()))?; + if t.rows.len() >= MAX_ROWS { + return Err(CmdError::Failed("the table has too many rows".into())); + } + let crossed_set: std::collections::BTreeSet = cells.iter().map(|c| c.1).collect(); + let merged = |ci: &usize| t.rows.get(r).and_then(|row| row.cells.get(*ci)).is_some_and(|c| c.props.vmerge != VMerge::None); + if crossed_set.iter().any(merged) { + return Err(CmdError::Failed("splitting a vertically merged cell across rows isn't supported; erase its merge first".into())); + } + let t = s.doc.table_mut(story, &tp)?; + let Some(row) = t.rows.get_mut(r) else { return Err(CmdError::Failed("the row isn't there any more".into())) }; + let mut new_row = wordcraft_doc::Row { props: row.props.clone(), cells: Vec::with_capacity(row.cells.len()) }; + new_row.props.header = false; + if top.is_finite() && bottom.is_finite() && bottom > top { + let rule = if row.props.height_rule == HeightRule::Exact { HeightRule::Exact } else { HeightRule::AtLeast }; + row.props.height = Some((y - top).clamp(1.0, 1584.0)); + row.props.height_rule = rule; + new_row.props.height = Some((bottom - y).clamp(1.0, 1584.0)); + new_row.props.height_rule = rule; + } + for (ci, cell) in row.cells.iter_mut().enumerate() { + let mut nc = Cell::empty(); + nc.props = cell.props.clone(); + if crossed_set.contains(&ci) { + nc.props.vmerge = VMerge::None; + } else { + if cell.props.vmerge == VMerge::None { + cell.props.vmerge = VMerge::Restart; + } + nc.props.vmerge = VMerge::Continue; + } + new_row.cells.push(nc); + } + t.rows.insert(r + 1, new_row); + finish(s, story, &tp, r + 1, c0) +} + +/// Put the caret in cell (`r`, `c`) of the table (clamped) and re-lay its paragraphs out. +fn finish(s: &mut Session, story: StoryRef, tp: &Path, r: usize, c: usize) -> CmdResult { + let Some(t) = s.doc.table(story, tp) else { return sel_result(s) }; + let r = r.min(t.rows.len().saturating_sub(1)); + let c = c.min(t.rows.get(r).map_or(0, |x| x.cells.len().saturating_sub(1))); + let mut path = tp.0.clone(); + path.extend([r as u32, c as u32, 0]); + s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: Path(path), off: 0 })); + super::table::touch_cells(s, tp)?; + sel_result(s) +} + +/// What the eraser would remove at a point: the border's two ends (page points) and the merge. +#[derive(Clone, Debug, PartialEq)] +pub struct EraseTarget { + pub from: (f32, f32), + pub to: (f32, f32), + story: StoryRef, + table: Path, + /// (row, cell) of the cell left of / above the border. + first: (usize, usize), + /// (row, cell) of the cell right of / below it. + second: (usize, usize), + across: bool, +} + +/// The border between two cells nearest page point (`x`, `y`), within [`ERASER_REACH`]. +pub fn erase_target(l: &DocLayout, page: usize, x: f32, y: f32) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let boxes = cell_boxes(l, page); + let mut best: Option<(f32, usize, EraseTarget)> = None; + let mut offer = |d: f32, depth: usize, e: EraseTarget| { + if d <= ERASER_REACH && best.as_ref().is_none_or(|(bd, bdepth, _)| d < *bd - 0.01 || (d <= *bd + 0.01 && depth > *bdepth)) { + best = Some((d, depth, e)); + } + }; + for a in &boxes { + let (ar, ab) = (a.rect.right(), a.rect.bottom()); + let same = |b: &&CellBox| b.story == a.story && b.table == a.table; + // The border on a's right, shared with the next cell of its row. + if (x - ar).abs() <= ERASER_REACH + && y >= a.rect.y + && y <= ab + && let Some(b) = boxes.iter().filter(same).find(|b| b.row == a.row && b.cell == a.cell + 1 && (b.rect.x - ar).abs() <= 1.5) + { + let e = EraseTarget { + from: (ar, a.rect.y.max(b.rect.y)), + to: (ar, ab.min(b.rect.bottom())), + story: a.story, + table: a.table.clone(), + first: (a.row, a.cell), + second: (b.row, b.cell), + across: true, + }; + offer((x - ar).abs(), a.table.0.len(), e); + } + // The border below a, shared with a cell of the next row. + if (y - ab).abs() <= ERASER_REACH + && x >= a.rect.x + && x <= ar + && let Some(b) = + boxes.iter().filter(same).find(|b| b.row == a.row + 1 && (b.rect.y - ab).abs() <= 1.5 && x >= b.rect.x && x <= b.rect.right()) + { + let e = EraseTarget { + from: (a.rect.x.max(b.rect.x), ab), + to: (ar.min(b.rect.right()), ab), + story: a.story, + table: a.table.clone(), + first: (a.row, a.cell), + second: (b.row, b.cell), + across: false, + }; + offer((y - ab).abs(), a.table.0.len(), e); + } + } + best.map(|b| b.2) +} + +fn eraser(s: &mut Session, v: &Value) -> CmdResult { + let page = page_param(v)?; + let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?); + let l = s.layout(); + if l.pages.get(page).is_none() { + return Err(CmdError::Params(format!("no page {page}"))); + } + let e = erase_target(&l, page, x, y).ok_or_else(|| CmdError::Failed("there's no border between two cells there".into()))?; + let (story, tp) = (e.story, e.table.clone()); + let t = s.doc.table_mut(story, &tp)?; + let ((r, c), (r2, c2)) = (e.first, e.second); + let cell = |t: &Table, r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map(|x| (x.props.vmerge, x.span())); + let (Some((va, sa)), Some((vb, sb))) = (cell(t, r, c), cell(t, r2, c2)) else { + return Err(CmdError::Failed("the cells aren't there any more".into())); + }; + if e.across { + if va != VMerge::None || vb != VMerge::None { + return Err(CmdError::Failed("erasing a border beside a vertically merged cell isn't supported".into())); + } + t.merge_right(r, c, c2); + super::table::sync_cell_widths(t); + return finish(s, story, &tp, r, c); + } + let (ga, gb) = (t.grid_col(r, c), t.grid_col(r2, c2)); + if ga != gb || sa != sb { + return Err(CmdError::Failed("the cells above and below the border don't line up, so they can't be merged".into())); + } + if vb == VMerge::Continue { + return Err(CmdError::Failed("those cells are already merged".into())); + } + let (top, _) = vgroup(t, r, c); + if let Some(a) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) + && a.props.vmerge == VMerge::None + { + a.props.vmerge = VMerge::Restart; + } + let mut moved = Vec::new(); + if let Some(b) = t.rows.get_mut(r2).and_then(|row| row.cells.get_mut(c2)) { + b.props.vmerge = VMerge::Continue; + let blocks = std::mem::replace(&mut b.blocks, vec![para_block(wordcraft_doc::Paragraph::new())]); + moved.extend(blocks.into_iter().filter(|x| x.as_para().is_none_or(|q| !q.is_empty()))); + } + let tc = t.cell_at_grid(top, ga).unwrap_or(0); + if !moved.is_empty() + && let Some(dst) = t.rows.get_mut(top).and_then(|row| row.cells.get_mut(tc)) + { + if dst.blocks.len() == 1 && dst.blocks.first().and_then(|b| b.as_para()).is_some_and(wordcraft_doc::Paragraph::is_empty) { + dst.blocks.clear(); + } + dst.blocks.extend(moved); + } + finish(s, story, &tp, top, tc) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Each row's cell spans. + fn shape(t: &Table) -> Value { + json!(t.rows.iter().map(|r| r.cells.iter().map(|c| c.span()).collect::>()).collect::>()) + } + + fn first_table(s: &Session) -> Table { + s.doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap() + } + + fn cell_rect(s: &mut Session, row: usize, cell: usize) -> Rect { + s.layout().pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect), + _ => None, + }) + .unwrap() + } + + /// A rectangle drawn on the page inserts a one-cell table of its size; undo takes it out. + #[test] + fn a_drawn_rectangle_inserts_a_table() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Above the table"})).unwrap(); + let before = s.doc.clone(); + let l = s.layout(); + let body = l.pages[0].body; + let (x0, y0) = (body.x + 40.0, body.y + 2.0); + s.run("table.draw", &json!({"page": 0, "x0": x0, "y0": y0, "x1": x0 + 200.0, "y1": y0 + 60.0})).unwrap(); + let t = first_table(&s); + assert_eq!((t.rows.len(), t.rows[0].cells.len()), (1, 1)); + assert_eq!(t.props.width, Some(200.0)); + assert_eq!(t.rows[0].props.height, Some(60.0)); + assert!(t.props.indent.is_some_and(|i| (i - 40.0).abs() < 0.5)); + assert!(s.sel.focus.path.cell().is_some(), "the caret goes into the new cell"); + let r = cell_rect(&mut s, 0, 0); + assert!((r.w - 200.0).abs() < 1.0 && r.h >= 59.5, "{r:?}"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.body, before.body, "one undo step"); + // Too small, or nowhere: errors, never panics. + assert!(s.run("table.draw", &json!({"page": 0, "x0": x0, "y0": y0, "x1": x0 + 5.0, "y1": y0 + 5.0})).is_err()); + assert!(s.run("table.draw", &json!({"page": 9, "x0": 1, "y0": 1, "x1": 100, "y1": 100})).is_err()); + assert!(s.run("table.draw", &json!({})).is_err()); + } + + /// 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] + fn lines_split_cells_and_the_eraser_merges_them() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); + s.run("text.insert", &json!({"text": "A"})).unwrap(); + let start = first_table(&s); + let r = cell_rect(&mut s, 0, 0); + let x = r.x + r.w * 0.25; + s.run("table.draw", &json!({"page": 0, "x0": x, "y0": r.y + 1.0, "x1": x + 1.0, "y1": r.bottom() - 1.0})).unwrap(); + let t = first_table(&s); + assert_eq!(t.grid.len(), 3); + assert_eq!(shape(&t), json!([[1, 1, 1], [2, 1]]), "the crossed cell splits, the other row's cell spans the new column"); + assert!((t.grid[0] - start.grid[0] * 0.25).abs() < 1.0, "{:?}", t.grid); + let text = t.rows[0].cells[0].blocks.iter().filter_map(|b| b.as_para().map(|q| q.plain_text())).collect::(); + assert_eq!(text, "A", "the text stays in the left part"); + // The eraser on the new border merges the two cells again. + let r0 = cell_rect(&mut s, 0, 0); + s.run("table.eraser", &json!({"page": 0, "x": r0.right() + 1.0, "y": r0.y + r0.h / 2.0})).unwrap(); + let t = first_table(&s); + assert_eq!(shape(&t), json!([[2, 1], [2, 1]])); + // A horizontal line across the second column's first cell splits its row. + let r1 = cell_rect(&mut s, 0, 1); + let y = r1.y + r1.h / 2.0; + s.run("table.draw", &json!({"page": 0, "x0": r1.x + 1.0, "y0": y, "x1": r1.right() - 1.0, "y1": y})).unwrap(); + let t = first_table(&s); + assert_eq!(t.rows.len(), 3); + assert_eq!(t.rows[0].cells[0].props.vmerge, VMerge::Restart, "the uncrossed cell spans both rows"); + assert_eq!(t.rows[1].cells[0].props.vmerge, VMerge::Continue); + assert_eq!(t.rows[1].cells[1].props.vmerge, VMerge::None); + // Erasing that line merges the split cells (a vertical merge). + let a = cell_rect(&mut s, 0, 1); + 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[1].props.vmerge, VMerge::Restart); + assert_eq!(t.rows[1].cells[1].props.vmerge, VMerge::Continue); + // Four steps, four undos back to the start. + for _ in 0..4 { + s.run("edit.undo", &json!({})).unwrap(); + } + assert_eq!(first_table(&s), start); + // Nothing near a border, or a hostile point: an error. + let c = cell_rect(&mut s, 0, 0); + assert!(s.run("table.eraser", &json!({"page": 0, "x": c.x + c.w / 2.0, "y": c.y + c.h / 2.0})).is_err()); + assert!(s.run("table.eraser", &json!({"page": 0, "x": 1e30, "y": -1e30})).is_err()); + assert_eq!(first_table(&s), start); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c4f5e7ee..3fe1e384b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -65,6 +65,10 @@ pub struct CanvasState { pub balloon_rects: Vec<(u32, Rect)>, /// A paste event arrived since the last Mod+V release (see `keys::canvas_events`). pub(crate) pasted: bool, + /// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret. + pub table_tool: Option, + /// The Draw Table stroke (or eraser press) in progress. + pub(crate) table_stroke: Option, } impl CanvasState { @@ -108,6 +112,8 @@ impl Default for CanvasState { balloon_reply: false, balloon_h: 0.0, balloon_rects: Vec::new(), + table_tool: None, + table_stroke: None, } } } @@ -633,6 +639,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale); + crate::table_pen::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); app.canvas.scroll_offset = out.state.offset; @@ -653,7 +660,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } app.canvas.focused = resp.has_focus(); - mouse(app, ui, &resp, &rects, &layout, geo.scale); + // Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing. + if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { + mouse(app, ui, &resp, &rects, &layout, geo.scale); + } // Right-click: move the caret there (unless inside the selection), then the context menu. // Right-click in an equation puts the caret there and opens the equation menu. if resp.secondary_clicked() @@ -692,6 +702,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p)); ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text)); } + crate::table_pen::cursor(app, ui, &resp); // While "Save changes?" is up, keys answer it rather than edit the document behind it. if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { crate::keys::canvas_events(app, ui.ctx()); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1cea884b6..82786539f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -193,6 +193,7 @@ Clear Character Style Borrar estilo de carácter Clear Formatting Level Borrar nivel de formato Clear Paragraph Formatting Borrar formato de párrafo + Click a border between two cells to merge them. Esc stops. Haga clic en un borde entre dos celdas para combinarlas. Esc termina. Clipboard Portapapeles Clipboard Pane Panel del portapapeles Clipboard Items Elementos del portapapeles @@ -292,7 +293,9 @@ Double Underline Subrayado doble Down Abajo Draft Borrador + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Arrastre un rectángulo para dibujar una tabla, o una línea sobre las celdas para dividirlas. Esc termina. Draw Dibujar + Draw Table Dibujar tabla Drawing Tools Herramientas de dibujo Drawing\nCanvas Lienzo de\ndibujo Drop Cap Letra capital diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 13361eae3..e6f29ff72 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -64,6 +64,7 @@ AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。 Balloons 吹き出し Bottom Margin 下余白 + Click a border between two cells to merge them. Esc stops. 2 つのセルの間の境界線をクリックすると結合します。Esc で終了します。 Bullet 行頭文字 By author 校閲者別 Changed lines: 変更された行: @@ -71,6 +72,8 @@ Color only 色のみ Compress punctuation at the start of a line 行頭の約物を詰める Dark page (white text on black) ダーク ページ (黒地に白い文字) + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 四角形をドラッグして表を描くか、セルを横切る線を引いて分割します。Esc で終了します。 + Draw Table 表を描画 Define New Multilevel List 新しいアウトラインの定義 Define New Multilevel List… 新しいアウトラインの定義… Deletions: 削除された箇所: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 412ce875f..912a9581c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -183,6 +183,7 @@ Citations & Bibliography Citações e bibliografia Clear Limpar Clear All Formatting Limpar toda a formatação + Click a border between two cells to merge them. Esc stops. Clique em uma borda entre duas células para mesclá-las. Esc encerra. Clipboard Área de transferência Clipboard Pane Painel da área de transferência Clipboard Items Itens da área de transferência @@ -254,6 +255,8 @@ Delete Style Excluir estilo Delete Table Excluir tabela Design Design + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Arraste um retângulo para desenhar uma tabela, ou uma linha sobre as células para dividi-las. Esc encerra. + Draw Table Desenhar tabela Edit Comment Editar comentário Left Margin Margem esquerda Indents and Spacing Recuos e espaçamento diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 53edfcda8..8ab425620 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -81,6 +81,7 @@ AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko čuvanje neuspelo: {error}. Sačuvajte dokument da biste ga ponovo uključili. Balloons Oblačići Bottom Margin Donja margina + Click a border between two cells to merge them. Esc stops. Kliknite ivicu između dve ćelije da ih spojite. Esc završava. Based on: {style} Zasnovan na: {style} Built-in style Ugrađeni stil Built-in styles can't be deleted. Ugrađeni stilovi ne mogu da se obrišu. @@ -98,6 +99,8 @@ Compress punctuation at the start of a line Sažmi interpunkciju na početku reda Confirm password: Potvrdite lozinku: Dark page (white text on black) Tamna stranica (beo tekst na crnom) + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Prevucite pravougaonik da nacrtate tabelu ili liniju preko ćelija da ih podelite. Esc završava. + Draw Table Nacrtaj tabelu Define New Multilevel List Definiši novu višenivosku listu Define New Multilevel List… Definiši novu višenivosku listu… Deletions: Brisanja: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index fc84806c4..e5ce43980 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -85,6 +85,7 @@ AutoSave failed: {error}. Save the document to turn AutoSave back on. Аутоматско чување неуспело: {error}. Сачувајте документ да бисте га поново укључили. Balloons Облачићи Bottom Margin Доња маргина + Click a border between two cells to merge them. Esc stops. Кликните ивицу између две ћелије да их спојите. Esc завршава. Based on: {style} Заснован на: {style} Built-in style Уграђени стил Built-in styles can't be deleted. Уграђени стилови не могу да се обришу. @@ -102,6 +103,8 @@ Compress punctuation at the start of a line Сажми интерпункцију на почетку реда Confirm password: Потврдите лозинку: Dark page (white text on black) Тамна страница (бео текст на црном) + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Превуците правоугаоник да нацртате табелу или линију преко ћелија да их поделите. Esc завршава. + Draw Table Нацртај табелу Define New Multilevel List Дефиниши нову вишенивоску листу Define New Multilevel List… Дефиниши нову вишенивоску листу… Deletions: Брисања: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e7c6eda0f..ddf28d721 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -191,6 +191,7 @@ Clear Character Style Очистити стиль символів Clear Formatting Level Очистити рівень форматування Clear Paragraph Formatting Очистити форматування абзацу + Click a border between two cells to merge them. Esc stops. Клацніть межу між двома клітинками, щоб об'єднати їх. Esc завершує. Clipboard Буфер обміну Clipboard Pane Панель буфера обміну Clipboard Items Елементи буфера обміну @@ -261,6 +262,8 @@ Delete Style Видалити стиль Delete Table Видалити таблицю Design Дизайн + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Перетягніть прямокутник, щоб намалювати таблицю, або лінію через клітинки, щоб їх розділити. Esc завершує. + Draw Table Намалювати таблицю Edit Comment Редагувати коментар Left Margin Ліве поле Indents and Spacing Відступи й інтервали diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dfae50b0a..ea3c77cc4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -75,6 +75,7 @@ AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。 Balloons 批注框 Bottom Margin 下边距 + Click a border between two cells to merge them. Esc stops. 单击两个单元格之间的边框以合并它们。按 Esc 结束。 Bullet 项目符号 By author 按作者 Changed lines: 修订行: @@ -82,6 +83,8 @@ Color only 仅颜色 Compress punctuation at the start of a line 压缩行首标点 Dark page (white text on black) 深色页面(黑底白字) + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 拖出矩形以绘制表格,或拖出穿过单元格的线条以拆分单元格。按 Esc 结束。 + Draw Table 绘制表格 Define New Multilevel List 定义新的多级列表 Define New Multilevel List… 定义新的多级列表… Deletions: 删除内容: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d309cd8e4..e378b92b9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -65,6 +65,7 @@ AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。 Balloons 註解方塊 Bottom Margin 下邊界 + Click a border between two cells to merge them. Esc stops. 按一下兩個儲存格之間的框線以合併它們。按 Esc 結束。 Bullet 項目符號 By author 依作者 Changed lines: 修訂行: @@ -72,6 +73,8 @@ Color only 僅色彩 Compress punctuation at the start of a line 壓縮行首標點 Dark page (white text on black) 深色頁面(黑底白字) + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 拖出矩形以繪製表格,或拖出穿過儲存格的線條以分割儲存格。按 Esc 結束。 + Draw Table 繪製表格 Define New Multilevel List 定義新的多層次清單 Define New Multilevel List… 定義新的多層次清單… Deletions: 刪除內容: diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0cc270d60..bc8c0feca 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -290,6 +290,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.line(&[(4.0, 18.0), (4.0, 13.0), (16.0, 13.0), (16.0, 18.0)]); pen.line_c(&[(2.0, 10.0), (18.0, 10.0)], a); } + "drawTable" => { + pen.rect(2.5, 3.0, 14.0, 14.5, c); + pen.line(&[(2.5, 8.5), (14.0, 8.5)]); + pen.line(&[(8.0, 3.0), (8.0, 14.5)]); + pen.line(&[(9.5, 18.0), (17.0, 10.5), (18.5, 12.0), (11.0, 19.5), (9.0, 19.8), (9.5, 18.0)]); + pen.fill(&[(17.0, 10.5), (18.0, 9.5), (19.5, 11.0), (18.5, 12.0)], a); + } "table" => { pen.frect(3.0, 3.0, 17.0, 7.0, a); pen.rect(3.0, 3.0, 17.0, 17.0, c); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 18db674df..7e8602dc0 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -234,6 +234,9 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.session.painter = None; continue; } + if key == Key::Escape && crate::table_pen::stop(app) { + continue; + } if crate::objects::key(app, key, modifiers) { continue; } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 232419fe5..b88bed199 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -335,6 +335,8 @@ fn controls(tab: &str) -> Vec { m("Select", "table.selectCell"), c("View Gridlines", "table.viewGridlines"), c("Properties", "table.properties"), + c("Draw Table", "table.draw"), + c("Eraser", "table.eraser"), m("Delete", "table.deleteCells"), c("Insert Above", "table.insertRowAbove"), c("Insert Below", "table.insertRowBelow"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5a20eec1..6c156e8b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -37,6 +37,7 @@ pub mod previews; pub mod read_aloud; pub mod ribbon; pub mod scroll; +pub mod table_pen; pub mod theme; pub mod widgets; pub mod window_geometry; @@ -360,6 +361,9 @@ impl WordApp { if id == "file.autosave" { return self.set_autosave(¶ms); } + if let Some(r) = table_pen::toggle(self, id, ¶ms) { + return r; + } let ctx = self.ctx.clone(); if let Some(r) = zotero::command(self, id, ¶ms, ctx.as_ref()) { if let Err(e) = &r { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 1f31130bc..3868fb76a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -518,6 +518,8 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { crate::dialogs::table_grid_picker(ui, app); ui.separator(); mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"})); + let drawing = app.canvas.table_tool == Some(crate::table_pen::TableTool::Draw); + mi_check(ui, app, "Draw Table", drawing, "table.draw", json!({})); mi(ui, app, "Convert Text to Table…", "table.fromText", json!({})); ui.menu_button(tl!("Quick Tables"), |ui| { mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"})); @@ -1557,6 +1559,12 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false); }); }); + group(ui, "Draw", None, app, |ui, app| { + use crate::table_pen::TableTool; + let tool = app.canvas.table_tool; + crate::widgets::big_toggle(ui, app, "drawTable", "Draw\nTable", "table.draw", tool == Some(TableTool::Draw)); + crate::widgets::big_toggle(ui, app, "eraser", "Eraser", "table.eraser", tool == Some(TableTool::Erase)); + }); group(ui, "Rows & Columns", None, app, |ui, app| { menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, |ui, app| { mi(ui, app, "Delete Cells", "table.deleteCells", json!({})); diff --git a/crates/ui-egui/src/table_pen.rs b/crates/ui-egui/src/table_pen.rs new file mode 100644 index 000000000..f3de15fea --- /dev/null +++ b/crates/ui-egui/src/table_pen.rs @@ -0,0 +1,205 @@ +//! Draw Table and Eraser as canvas modes (Insert › Tables, Table Layout › Draw). The buttons +//! toggle a pen or an eraser; while one is on, presses on the page draw (or erase) instead of +//! moving the caret, a preview follows the pointer, and each finished stroke or click runs +//! `table.draw` / `table.eraser` with page coordinates (one undo step each). Esc or the button +//! again turns it off. The geometry decisions are the engine's (`cmd::table_draw`). + +use egui::{Color32, Pos2, Rect, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use wordcraft_engine::cmd::table_draw::{self, Stroke as Kind}; +use wordcraft_layout::DocLayout; + +use crate::WordApp; +use crate::theme::Tokens; + +/// Which table tool is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableTool { + Draw, + Erase, +} + +impl TableTool { + fn id(self) -> &'static str { + match self { + TableTool::Draw => "table.draw", + TableTool::Erase => "table.eraser", + } + } +} + +/// A stroke in progress: its page and its start and current end (page points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PenStroke { + page: usize, + from: (f32, f32), + to: (f32, f32), +} + +/// The ribbon buttons (no page coordinates): turn the tool on, or off when it already is. +/// `{"on": bool}` sets it. With coordinates the command goes to the engine instead (`None`). +pub fn toggle(app: &mut WordApp, id: &str, p: &Value) -> Option> { + let tool = match id { + "table.draw" => TableTool::Draw, + "table.eraser" => TableTool::Erase, + _ => return None, + }; + if p.get("page").is_some() { + return None; + } + let on = p.get("on").and_then(Value::as_bool).unwrap_or(app.canvas.table_tool != Some(tool)); + app.canvas.table_tool = on.then_some(tool); + app.canvas.table_stroke = None; + if on { + app.canvas.want_focus = true; + app.status(match tool { + TableTool::Draw => tl!("Drag a rectangle to draw a table, or a line across cells to split them. Esc stops."), + TableTool::Erase => tl!("Click a border between two cells to merge them. Esc stops."), + }); + } + Some(Ok(json!({"tool": app.canvas.table_tool.map(TableTool::id)}))) +} + +/// Turn the tool off (Esc). +pub fn stop(app: &mut WordApp) -> bool { + let was = app.canvas.table_tool.is_some(); + app.canvas.table_tool = None; + app.canvas.table_stroke = None; + was +} + +/// The page point under screen point `p` on page `page` (its screen rect from `rects`). +fn on_page(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize, p: Pos2) -> Option<(f32, f32)> { + let r = rects.get(page)?; + let scale = layout.pages.get(page).filter(|pg| pg.w > 0.0).map_or(scale, |pg| r.width() / pg.w); + (scale > 0.0).then(|| ((p.x - r.min.x) / scale, (p.y - r.min.y) / scale)) +} + +/// Mouse input while a tool is on; `false` (nothing done) when none is, so the caret logic runs. +pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool { + let Some(tool) = app.canvas.table_tool else { return false }; + if !app.canvas.focused && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + stop(app); + return true; + } + let latest = ui.input(|i| i.pointer.latest_pos()); + if ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() { + if let Some(p) = latest + && let Some((page, x, y)) = crate::canvas::page_at(rects, layout, scale, p) + { + app.canvas.table_stroke = Some(PenStroke { page, from: (x, y), to: (x, y) }); + } + return true; + } + let Some(mut st) = app.canvas.table_stroke else { return true }; + if let Some(p) = latest + && let Some(to) = on_page(rects, layout, scale, st.page, p) + { + st.to = to; + app.canvas.table_stroke = Some(st); + } + if ui.input(|i| i.pointer.primary_down()) { + return true; + } + app.canvas.table_stroke = None; + let params = match tool { + TableTool::Draw => { + // A click without a drag draws nothing. + if (st.to.0 - st.from.0).abs() < 2.0 && (st.to.1 - st.from.1).abs() < 2.0 { + return true; + } + json!({"page": st.page, "x0": st.from.0, "y0": st.from.1, "x1": st.to.0, "y1": st.to.1}) + } + TableTool::Erase => json!({"page": st.page, "x": st.to.0, "y": st.to.1}), + }; + if let Err(e) = app.run(tool.id(), params) { + app.status(e); + } + true +} + +/// The pointer while a tool is on (a crosshair; the tool's icon is drawn beside it). +pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response) { + if app.canvas.table_tool.is_some() && (resp.hovered() || app.canvas.table_stroke.is_some()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } +} + +fn dashed_rect(painter: &egui::Painter, r: Rect, s: Stroke) { + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, s, 5.0, 3.0)); +} + +/// The live preview: the rectangle or line being drawn, or the border the eraser would remove; +/// and the tool's icon beside the pointer. +pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLayout, pages: &[Rect], scale: f32) { + let Some(tool) = app.canvas.table_tool else { return }; + let to_screen = |page: usize, x: f32, y: f32| -> Option { + let r = pages.get(page)?; + let sc = layout.pages.get(page).filter(|pg| pg.w > 0.0).map_or(scale, |pg| r.width() / pg.w); + Some(pos2(r.min.x + x * sc, r.min.y + y * sc)) + }; + let ink = Stroke::new(1.5, t.accent); + let hover = painter.ctx().input(|i| i.pointer.hover_pos()); + match (tool, app.canvas.table_stroke) { + (TableTool::Draw, Some(st)) => match table_draw::stroke(layout, st.page, st.from.0, st.from.1, st.to.0, st.to.1) { + Some(Kind::Rect(r)) => { + if let (Some(a), Some(b)) = (to_screen(st.page, r.x, r.y), to_screen(st.page, r.right(), r.bottom())) { + painter.rect_stroke(Rect::from_two_pos(a, b), 0.0, ink, egui::StrokeKind::Middle); + } + } + Some(Kind::Vertical { x, y0, y1 }) => { + if let (Some(a), Some(b)) = (to_screen(st.page, x, y0), to_screen(st.page, x, y1)) { + painter.line_segment([a, b], ink); + } + } + Some(Kind::Horizontal { y, x0, x1 }) => { + if let (Some(a), Some(b)) = (to_screen(st.page, x0, y), to_screen(st.page, x1, y)) { + painter.line_segment([a, b], ink); + } + } + // Not a table or a line yet: the drag's box, dashed and dim. + None => { + if let (Some(a), Some(b)) = (to_screen(st.page, st.from.0, st.from.1), to_screen(st.page, st.to.0, st.to.1)) { + dashed_rect(painter, Rect::from_two_pos(a, b), Stroke::new(1.0, t.text_dim)); + } + } + }, + (TableTool::Erase, _) => { + if let Some(p) = hover + && let Some((page, x, y)) = crate::canvas::page_at(pages, layout, scale, p) + && let Some(e) = table_draw::erase_target(layout, page, x, y) + && let (Some(a), Some(b)) = (to_screen(page, e.from.0, e.from.1), to_screen(page, e.to.0, e.to.1)) + { + painter.line_segment([a, b], Stroke::new(3.0, Color32::from_rgb(0xD1, 0x3B, 0x3B))); + } + } + _ => {} + } + if let Some(p) = hover.filter(|p| pages.iter().any(|r| r.contains(*p))) { + let r = Rect::from_min_size(p + vec2(8.0, 6.0), vec2(16.0, 16.0)); + let icon = if tool == TableTool::Draw { "pencil" } else { "eraser" }; + crate::icons::paint(painter, r, icon, t.icon, t.accent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The buttons toggle the tools (one at a time); coordinates go to the engine. + #[test] + fn buttons_toggle_the_pen_and_the_eraser() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + assert_eq!(a.run("table.draw", json!({})).unwrap()["tool"], "table.draw"); + assert_eq!(a.canvas.table_tool, Some(TableTool::Draw)); + assert_eq!(a.run("table.eraser", json!({})).unwrap()["tool"], "table.eraser"); + assert_eq!(a.run("table.eraser", json!({})).unwrap()["tool"], Value::Null); + assert_eq!(a.canvas.table_tool, None); + a.run("table.draw", json!({"on": true})).unwrap(); + assert!(stop(&mut a) && a.canvas.table_tool.is_none()); + // With coordinates it is the engine's command (an error here: nothing to split). + assert!(a.run("table.draw", json!({"page": 0, "x0": 1, "y0": 1, "x1": 2, "y1": 2})).is_err()); + assert!(a.canvas.table_tool.is_none()); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f84fda850..bfe5eee1e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -101,6 +101,16 @@ fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { /// A large ribbon button: 32 px icon over a (possibly two-line) label. pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response { + big_button(ui, app, icon, label, id, params, menu, false) +} + +/// A large ribbon button for a mode that stays on (`checked` draws it pressed), run without params. +pub fn big_toggle(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, checked: bool) -> Response { + big_button(ui, app, icon, label, id, Value::Object(Default::default()), false, checked) +} + +#[allow(clippy::too_many_arguments)] +fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool, checked: bool) -> Response { let label = tl!(label); let t = Tokens::get(ui.ctx()); let galley_w = @@ -109,7 +119,7 @@ pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, pa let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); keytip_badge(ui, r, id, &label.replace('\n', " ")); let on = enabled(app, id); - bg(ui, r, &resp, false, &t); + bg(ui, r, &resp, checked, &t); let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; icons::paint(ui.painter(), ic, icon, c, a); diff --git a/docs/gaps.md b/docs/gaps.md index 37551caa3..9a8881822 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -52,9 +52,9 @@ full number from ~60% to ~75%. Everything else is depth on the way to full parit The same gaps grouped the way the parity documents are, for agents working in one area. - **Feature gaps:** #2 (preserve), #5, #10, #12, #13, #15, #17, #21, #25, #27, #28, #29; Clipboard - pane, Manage Styles, Group, Draw Table (open PRs exist for the first three — review and merge - before re-implementing). Paste Special, Advanced Find, Column Selection, Style Inspector and - custom table styles landed on 2026-10-10. + pane, Manage Styles, Group (open PRs exist for the first three — review and merge before + re-implementing). Paste Special, Advanced Find, Column Selection, Style Inspector, custom table + styles, and Draw Table and Eraser (#303) landed on 2026-10-10. - **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column drag on the ruler (#49, #217); context menus for objects and tables; Linux title bar theming (#78). - **File-format gaps:** #1, #2, #5, #9, #14, #19, #23, #26; embedded fonts; glossary/building blocks. @@ -66,6 +66,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list | | 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list | | 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list | | 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index cb859f0d0..5fba32cd4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,14 +2,14 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**393 of 434 catalog features have commands (90.6%).** +**396 of 434 catalog features have commands (91.2%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | | Home | 99 | 102 | -| Insert | 42 | 53 | +| Insert | 43 | 53 | | Draw | 0 | 11 | | Design | 10 | 10 | | Layout | 19 | 19 | @@ -18,7 +18,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Review | 27 | 30 | | View | 25 | 29 | | Table Design | 8 | 8 | -| Table Layout | 28 | 30 | +| Table Layout | 30 | 30 | | Equation | 21 | 22 | | Picture Format | 14 | 14 | | Shape Format | 3 | 6 | @@ -36,7 +36,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Insert -- Tables › Draw Table (`table.draw`) - Illustrations › Online Pictures (`insert.onlinePicture`) - Illustrations › Icons (`insert.icon`) - Illustrations › 3D Models (`insert.model3d`) @@ -75,11 +74,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Window › Synchronous Scrolling (`view.syncScroll`) - Window › Switch Windows (`view.switchWindows`) -### Table Layout - -- Draw › Draw Table (`table.draw`) -- Draw › Eraser (`table.eraser`) - ### Equation - Tools › Ink Equation (`insert.inkEquation`) diff --git a/docs/roadmap.md b/docs/roadmap.md index 272976ec9..e5d01276d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -49,7 +49,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four) | 5–10 h | | M3 | Insert tab | mostly done; equations done (#191); charts, SmartArt, icons, 3D, screenshot, online media missing | 50–80 h | | M4 | Layout and Design tabs | done except column balancing, group | 6–10 h | -| M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table missing | 6–10 h | +| M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h | | M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation, 8 more bibliography styles missing | 15–25 h | | M7 | Review | done (balloons, paragraph-mark revisions); formatting revisions, move tracking, multilingual proofing missing | 35–60 h | | M8 | View | mostly done; window commands missing | 3–5 h | @@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | M5: Draw Table and Eraser landed (#303) | | 2026-10-10 | trivial | Merged main: PRs that landed removed from the Current focus list | | 2026-10-10 | minor | Current focus heading back to ~150–230 h to beta: full number restored to the additive weighted sum (~60%), method aligned with the standard, no new evidence | | 2026-10-10 | minor | Alpha gate table added (core-workflow gate from the progress-docs standard): all six workflows pass, stage stays alpha | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ff29ea992..ff8dcb792 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **396 / 434 (91.2%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -60,7 +60,7 @@ full parity for the area. | Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border. Missing: Phonetic Guide, Enclose Characters, Asian Typography, kerning/ligature/stylistic-set options in the Font dialog's Advanced tab. | | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | -| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243). Missing: Draw Table/Eraser, nested-table polish, interactive column resize (#49 open, #217). Cell text direction landed (#245). | +| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, interactive column resize (#49 open, #217). Cell text direction landed (#245). | | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; no document grid; no vertical text; RTL sections missing. Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 5–8 | Placed and editable, numbers from Word files (#103). Long notes don't continue onto the next page; no continuation separator; no note options per section. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) | | 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change | | 2026-10-10 | minor | Merged main (12 PRs): Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info fields, Serbian. Catalog 378/428 → 384/430 (89.3%); typing 82→85%, styles 72→74%, tables 74→76%; feature depth 67→68%; mainstream 54→55%; full stays ~60%, essentials ~63% | | 2026-10-10 | minor | Readiness table: hours to ~95% per audience (full ~650–1,100 h, mainstream ~300–500 h, essentials ~90–140 h). Full number restored to the additive weighted sum, ~47% → ~60%: method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |