From f24a5b22b2a4d704d090be349cded9d5edf78169 Mon Sep 17 00:00:00 2001 From: rabis7575 Date: Mon, 12 Oct 2026 16:34:10 +0200 Subject: [PATCH] Tables: drags and Shift+arrows over cells select whole cells (#722) * Tables: select, insert and move with the mouse; double-click a border to AutoFit Like Word, on top of the border resizing: - Selection areas: just above a column the pointer becomes a down arrow, left of a row the row arrow, along a cell's left edge (inside it) the cell arrow. A click selects the column, row or cell; a drag selects every one up to the pointer. Selected cells are shaded as cells, empty ones too. - Insert buttons: left of a row border and above a column border a "+" shows with the border marked; a click adds a row or column there and puts the caret in it. - Move handle past the table's top-left corner: a click selects the table; a drag shows a dashed outline and, on release, the table floats where it is dropped with the text wrapping around it (Word's behaviour). One undo step. Nested tables and tables outside the main text are only selected. - A double-click on a column border fits the column left of it to its contents (the right neighbour gives or takes the room; on the right edge the table's width changes, up to the right margin). - Table Layout > Select > Select Column (catalog 418/449). Whole-cell selection: a column (or a rectangle of cells over several rows) is not one run of the text, so selecting it makes a block of its cells' paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment, Delete, typing, copy and numbering act on those cells only (a numbered column is one list); table commands (shading, merge, delete columns) take the rectangle; Tab moves on without clearing it; Undo brings the selection back. Cells of one row, or whole rows, stay an ordinary stream selection. The format painter no longer clears a block's text (edit.pasteFormat was handled like a paste). The mini toolbar stays hidden over whole-cell selections and during these drags: pinned above the selection it covered the column areas, the "+" buttons and the move handle. Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt, table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so agents and the control channel can do the same) and table.selectColumn; hit testing reuses table_resize's drawn geometry, and borders keep priority for resizing. 10 engine tests (areas, a column edited cell by cell, numbering, rows/cells/drags, insert buttons, move, fit, format painter, hostile params). UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse handler (no extra ui.interact areas, so clicks in a cell's text still place the caret); kittest tests with real pointer events: column click, row drag, "+" button, move-handle drag in one undo step, double-click fit. New labels translated in all eleven catalogs. Progress docs updated: gaps, ui-parity, target-app-parity, ROADMAP, parity checklist. `cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm). Co-Authored-By: Claude Opus 5.5 * Tables: a selected column's word count is its cells'; merged cells shade once With whole cells selected (a column, or a rectangle that isn't one run of the text) the status bar said "12 of 23 words": `Session::selected_text` took everything between the selection's ends. It now takes each piece's own text, a line each, for a column block too, so the status bar, Word Count and the other callers see what is selected. Since #669 a vertically merged cell is one box as tall as its rows, with a `covered` box for each continuation row; shading selected cells skips those, so a merged cell isn't shaded twice. `selected_paths` follows main's `para_paths_in` (#418): a selection ending at the start of the next paragraph doesn't take it. - Test: a selected column's text is its three cells' and Word Count says three words. Co-Authored-By: Claude Opus 5.5 * Tables: double-click AutoFit and the "+" buttons work as in Word Checked against Word (2019, Windows): - A double-click on a column border fits the column left of it to its contents and the columns right of it keep their widths, moving with the border, so the table narrows or widens (up to the right margin). The right neighbour used to give or take the difference. - A "+" insert button selects the new row or column (the caret used to go into it); typing then goes into its first cell. If the table can't take another row or column, nothing changes. - A double-click along a cell's left edge (the cell selection area) selects its row. - Tests: engine (fit keeps the columns on the right; insert selects the new row or column, typing lands in its first cell) and kittest (real double-click on a border; "+" selects the row; double-click by a cell's edge selects the row). Co-Authored-By: Claude Opus 5.5 * Tables: drags and Shift+arrows over cells select whole cells An ordinary drag, Shift+click or Shift+arrow from one table cell into another now selects the rectangle of whole cells between them, as Word does, instead of the run of text between the two points: bold, alignment and Delete leave the cells outside the rectangle alone (they already did for Select Column and the table's selection areas). Between a cell and text outside its table the selection takes whole rows, from the cell's row out to the table's edge, and the text beyond. Extending again goes on from where the selection really started and ends: dragging back into the first cell selects its text again, Shift+Up after Shift+Down shrinks the rectangle, and from a nested table's cell into another cell of the table around it the outer table's cells are taken. - Engine: `table::extend_selection` (the rule, down through nested tables) and `Session::sel_ends` (where a widened selection really runs); every caret command run with `extend` goes through it in `column::dispatch`, so agents get the same with `caret.*`. - UI: a text drag extends through `caret.set` (still straight to the session, so nothing scrolls on every frame); the view follows the real caret. - Tests: engine (drag across cells and back, nested tables, Shift+Down and Shift+Up through and past a table, Shift+click, from text into a table) and a kittest with a real mouse drag. Progress docs updated: gaps (the known gap from #637 is gone), ui-parity, target-app-parity, ROADMAP. `cargo xtask ci` passes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/column.rs | 15 +++++ crates/engine/src/cmd/table.rs | 86 +++++++++++++++++++++++++++- crates/engine/src/cmd/table_mouse.rs | 85 +++++++++++++++++++++++++++ crates/engine/src/session.rs | 26 ++++++++- crates/ui-egui/src/canvas.rs | 10 +++- crates/ui-egui/src/table_mouse.rs | 25 ++++++++ docs/gaps.md | 4 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 10 files changed, 253 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6115ab0..ada6eb0 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-11 · **Last updated:** 2026-10-11 · **Change:** trivial (table context menu whose items follow the selection, as in Word; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously trivial: independent complex-script formatting preserved across DOCX open/save; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously trivial: independent complex-script formatting preserved across DOCX open/save; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/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 @@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | +| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | | 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents | | 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged | | 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon | @@ -256,6 +257,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-11 | trivial | Progress log: the table context menu | +| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells | | 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 | | 2026-10-12 | trivial | Progress log: Undo history list (#701) | | 2026-10-12 | trivial | Progress log: Start screen at launch (#766) | diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs index e5c3643..1db3cd1 100644 --- a/crates/engine/src/cmd/column.rs +++ b/crates/engine/src/cmd/column.rs @@ -5,6 +5,8 @@ //! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it. //! Whole table cells that aren't one run of the text (part of a column, `CellRange`) are a block //! of their paragraphs: edits and formatting go cell by cell; table commands take the cells. +//! Extending the selection from one cell into another (Shift+arrows, Shift+click, a drag) makes +//! such cells, as in Word (`table::extend_selection`). use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; @@ -139,6 +141,19 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) { s.column_mode = false; } + // Extending the selection (Shift+arrows, Shift+click, a drag) goes on from where it really + // runs, then takes whole cells or rows the way Word does around tables. + if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) { + let shown = s.sel.clone(); + s.sel = s.sel_ends().clone(); + if let Err(e) = run(s, v) { + s.sel = shown; + return Err(e); + } + let to = s.sel.focus.clone(); + super::table::extend_selection(s, to); + return super::sel_result(s); + } let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) }; let cells = s.cell_range().is_some(); match id { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 10ba0bd..e1e09f7 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::collate::{SortKey, sort_key}; use wordcraft_doc::locale_number::{format_number, parse_number}; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign}; -use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block}; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -819,11 +819,95 @@ pub(crate) fn select_range(s: &mut Session, story: StoryRef, tp: &Path, rows: (u s.sel = sel.clone(); s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, segments, cells: Some(range) }); s.column_mode = false; + s.widened = None; s.goal_x = None; s.pending = None; sel_result(s) } +/// Extend the selection from its anchor to `to` the way Word does around tables: from one cell +/// into another of the same table it takes whole cells, the rectangle between them; between a +/// table's cell and text outside the table, whole rows, from the cell's row out to the table's +/// edge facing the other end. Where the selection really runs is kept (`Session::sel_ends`), so +/// extending it again goes on from there. +pub(crate) fn extend_selection(s: &mut Session, to: Pos) { + let ends = Selection { anchor: s.sel.anchor.clone(), focus: to }; + // Shift+Up and Shift+Down keep their goal column. + let goal_x = s.goal_x; + s.sel = ends.clone(); + s.column = None; + widen(s, &ends); + s.widened = if s.sel == ends { None } else { Some(crate::session::Widened { sel: s.sel.clone(), ends }) }; + s.goal_x = goal_x; +} + +/// Widen the selection between `ends` (already `s.sel`) by Word's table rule (see +/// `extend_selection`). +fn widen(s: &mut Session, ends: &Selection) { + let (a, f) = (&ends.anchor, &ends.focus); + if a.story != f.story { + return; + } + // Down through the tables both ends are in, one nesting level (three indexes) at a time. + let mut i = 0; + while let (Some(ab), Some(fb)) = (a.path.0.get(i), f.path.0.get(i)) { + match (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) { + // The same cell: look inside it. + (Some(x), Some(y)) if ab == fb && x == y => i += 3, + // Two cells of one table: the rectangle between them. + (Some((tp, ra, ca)), Some((_, rf, cf))) if ab == fb => { + let cols = s.doc.table(a.story, &tp).map(|t| { + let ((g0, g1), (h0, h1)) = (grid_cols(t, ra, ca), grid_cols(t, rf, cf)); + (g0.min(h0), g1.max(h1)) + }); + if let Some(cols) = cols { + // A table without paragraphs keeps the plain selection. + let _ = select_range(s, a.story, &tp, (ra, rf), cols); + } + return; + } + // Different blocks: an end in a table takes its whole row, out to the table's edge + // facing the other end. + (x, y) if ab != fb => { + let forward = ab < fb; + if let Some((tp, r, _)) = x + && let Some((start, end)) = row_ends(&s.doc, a.story, &tp, r) + { + s.sel.anchor = if forward { start } else { end }; + } + if let Some((tp, r, _)) = y + && let Some((start, end)) = row_ends(&s.doc, f.story, &tp, r) + { + s.sel.focus = if forward { end } else { start }; + } + return; + } + // One paragraph. + _ => return, + } + } +} + +/// The cell of the table at index `i` of paragraph path `p`, if the paragraph is in one there: +/// (the table's path, row, cell). +fn cell_at(p: &[u32], i: usize) -> Option<(Path, usize, usize)> { + match (p.get(..=i), p.get(i + 1), p.get(i + 2)) { + (Some(t), Some(r), Some(c)) if p.len() > i + 3 => Some((Path(t.to_vec()), *r as usize, *c as usize)), + _ => None, + } +} + +/// Where row `r` of table `tp` starts and ends: its first cell's start and its last cell's end. +fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> { + let mut row = tp.0.clone(); + row.push(u32::try_from(r).ok()?); + let mut paths = doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&row)); + let first = paths.next()?; + let last = paths.next_back().unwrap_or_else(|| first.clone()); + let len = doc.para(story, &last).map_or(0, Paragraph::len); + Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: len })) +} + fn from_text(s: &mut Session, v: &Value) -> CmdResult { let sep = if p::str(v, "separator") == Some("comma") { ',' } else { '\t' }; let (a, b) = s.sel.ordered(); diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index de9210b..0bb1eb5 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -750,6 +750,91 @@ mod tests { assert_eq!(para(&s, 1, 1).plain_text(), "r1c1"); } + /// Where the app extends the selection while dragging (`caret.set` with `extend`). + fn drag_to(s: &mut Session, to: Pos) { + s.run("caret.set", &json!({"pos": to, "extend": true})).unwrap(); + } + + /// An ordinary drag from one cell into another selects the rectangle of whole cells between + /// them, so bold takes just those, as in Word; back in the first cell it's text again, from + /// where the drag started. Out of a nested table, the cells are the table's around it. + #[test] + fn a_drag_across_cells_selects_their_rectangle() { + let mut s = table_session(3, 3); + fill(&mut s); + let start = Pos { off: 1, ..cell_pos(&s, 0, 1) }; + s.run("caret.set", &json!({"pos": start})).unwrap(); + let down = Pos { off: 2, ..cell_pos(&s, 2, 1) }; + drag_to(&mut s, down); + let range = s.cell_range().cloned().unwrap(); + assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false)); + s.run("format.bold", &json!({})).unwrap(); + for r in 0..3 { + assert!(bold(&s, r, 1) && !bold(&s, r, 0) && !bold(&s, r, 2), "row {r}: just the dragged column"); + } + // On to the left: the rectangle still runs from the cell the drag started in. + let left = cell_pos(&s, 1, 0); + drag_to(&mut s, left); + assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1)))); + // Back in that cell: its text, from where the drag started. + let back = Pos { off: 3, ..cell_pos(&s, 0, 1) }; + drag_to(&mut s, back.clone()); + assert!(s.cell_range().is_none()); + assert_eq!(s.sel, Selection { anchor: start, focus: back }); + // From a nested table's cell into another cell of the table around it. + let mut s = table_session(2, 2); + s.sel = Selection::caret(cell_pos(&s, 0, 0)); + s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); + assert_eq!(s.sel.focus.path.0.len(), 7, "the caret is in the nested table"); + let to = cell_pos(&s, 1, 1); + drag_to(&mut s, to); + let range = s.cell_range().cloned().unwrap(); + assert_eq!((range.table, range.rows, range.cols), (table_path(&s), (0, 1), (0, 1))); + } + + /// Shift+Down from a cell takes it and the cell below, whole; on past the table, the anchor's + /// row and every row after it, then the text; Shift+Up comes back to the cells, then into the + /// first cell's text. Shift+click goes on from where the selection started, and from text + /// before the table a drag into a cell takes the rows down to it, whole. + #[test] + fn shift_arrows_and_clicks_select_cells_then_rows() { + let mut s = Session::new(Document::new()); + s.run("text.insert", &json!({"text": "Intro"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("insert.table", &json!({"rows": 3, "cols": 3})).unwrap(); + fill(&mut s); + let after = table_path(&s).last() + 1; + let shift = |s: &mut Session, id: &str| { + s.run(id, &json!({"extend": true})).unwrap(); + }; + s.sel = Selection::caret(cell_pos(&s, 1, 1)); + shift(&mut s, "caret.down"); + assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1)))); + shift(&mut s, "caret.down"); + assert!(s.cell_range().is_none()); + assert_eq!(s.sel.anchor, cell_pos(&s, 1, 0), "from the start of the anchor's row"); + assert_eq!(s.sel.focus.path, Path::top(after)); + s.run("format.italic", &json!({})).unwrap(); + let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true)); + assert!(italic(&s, 1, 0) && italic(&s, 2, 2) && !italic(&s, 0, 1), "whole rows from the anchor's"); + shift(&mut s, "caret.up"); + assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1)))); + shift(&mut s, "caret.up"); + assert!(s.cell_range().is_none()); + assert_eq!(s.sel.anchor, cell_pos(&s, 1, 1)); + assert_eq!(s.sel.focus.path, cell_pos(&s, 1, 1).path); + // Shift+click: the rectangle from the anchor's cell. + let corner = cell_pos(&s, 0, 0); + s.run("caret.set", &json!({"pos": corner, "extend": true})).unwrap(); + assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1)))); + // From the text before the table into a cell: the rows down to that cell's, whole. + s.sel = Selection::caret(Pos::body(0, 2)); + let into = cell_pos(&s, 1, 1); + drag_to(&mut s, into); + assert!(s.cell_range().is_none()); + assert_eq!(s.sel, Selection { anchor: Pos::body(0, 2), focus: Pos { off: 4, ..cell_pos(&s, 1, 2) } }); + } + /// Clicks away from the areas, and hostile numbers: an error and no change, never a panic. #[test] fn misses_and_hostile_params_change_nothing() { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index b85e5bf..1f4734c 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -89,6 +89,16 @@ impl CellRange { } } +/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), +/// and where it really runs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Widened { + /// The selection it became; this is current while `Session::sel` still equals it. + pub sel: Selection, + /// Where the selection was really extended from and to: extending it again goes on from here. + pub ends: Selection, +} + /// View state that commands can change (ribbon View tab, status bar). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -426,6 +436,9 @@ pub struct Session { pub column: Option, /// Column selection mode (Ctrl+Shift+F8): caret movement extends the block. pub column_mode: bool, + /// A selection widened to whole table cells or rows, and where it really runs (see + /// [`Session::sel_ends`]). + pub(crate) widened: Option, /// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back /// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used. edit_marks: Vec, @@ -602,6 +615,7 @@ impl Session { password: None, column: None, column_mode: false, + widened: None, edit_marks: Vec::new(), go_back_step: 0, } @@ -651,6 +665,13 @@ impl Session { self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) } + /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where + /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds + /// those). + pub fn sel_ends(&self) -> &Selection { + self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) + } + /// Document revision (bumped by every change). pub fn rev(&self) -> u64 { self.rev @@ -944,6 +965,7 @@ impl Session { self.also_selected.clear(); self.column = None; self.column_mode = false; + self.widened = None; self.edit_marks.clear(); self.go_back_step = 0; self.view.outline.collapsed.clear(); @@ -1025,9 +1047,11 @@ impl Session { // The save password, like the file path: not an edit to the document. password: _, also_selected: _, - // Column selection, like `sel`'s shape: valid only while `sel` matches it. + // Column selection and a widened selection's ends, like `sel`'s shape: valid only while + // `sel` matches them. column: _, column_mode: _, + widened: _, // Go Back's places, like the caret history: not part of the document. edit_marks: _, go_back_step: _, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d424010..5ebdcdb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -705,7 +705,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.scroll_to_caret = true; } } - let caret = layout.caret_on(&app.session.sel.focus, app.session.page_hint); + // Where the selection really ends, also when it took whole table cells. + let caret = layout.caret_on(&app.session.sel_ends().focus, app.session.page_hint); // Editing a header/footer (or a note) dims the body; once per frame, for every page. let dim_body = dims_body(app, &layout); if let Some(c) = caret { @@ -1861,10 +1862,13 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo app.canvas.caret_visible_since = crate::now_ms(); } } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) { + // Through the command, so from one table cell into another the drag takes whole cells + // (Word); straight to the session, as the app's `run` would scroll on every frame. if let Some(pos) = layout.hit(page, x, y, story) - && pos != app.session.sel.focus + && pos != app.session.sel_ends().focus { - app.session.sel.focus = pos; + let pj = serde_json::to_value(&pos).unwrap_or_default(); + let _ = app.session.run("caret.set", &json!({"pos": pj, "extend": true})); app.session.page_hint = page; app.canvas.caret_visible_since = crate::now_ms(); } diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index e8e0eb8..b289404 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -287,6 +287,31 @@ mod tests { assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2)))); } + /// An ordinary drag from one cell's text into another's selects the rectangle of whole cells + /// between them as the pointer goes: no undo step, no mini toolbar over the cells. + #[test] + fn dragging_across_cells_selects_their_rectangle() { + let mut h = table_window(); + let (a, d) = (cell(&mut h, 0, 1), cell(&mut h, 1, 2)); + let depth = h.state().session.undo_depth(); + let start = screen(&mut h, a.x + a.w / 2.0, a.y + a.h / 2.0); + let end = screen(&mut h, d.x + d.w / 2.0, d.y + d.h / 2.0); + h.event(egui::Event::PointerMoved(start)); + h.step(); + button(&h, start, true); + h.step(); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 4.0))); + h.step(); + } + assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (1, 2))), "the selection follows the pointer"); + button(&h, end, false); + steps(&mut h, 2); + assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (1, 2)))); + assert_eq!(h.state().session.undo_depth(), depth); + assert!(!crate::mini_toolbar::visible(h.state())); + } + /// The "+" left of a row border adds a row there and selects it, in one undo step; over it /// the pointer is the plain arrow. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index b721c71..459071d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (table context menu whose items follow the selection, as in Word; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page; previously trivial: DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page; previously trivial: DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously 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 @@ -62,7 +62,6 @@ The same gaps grouped the way the parity documents are, for agents working in on still missing: the Paste Options button after a paste, and RTF on macOS and X11. - **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column markers on the ruler (#49; dragging borders and the resize handle on the page landed, #217, and selecting, inserting and moving with the mouse); - ordinary drags and Shift+arrows over cells of several rows still select a run of text, not a rectangle of cells; context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File › Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real @@ -77,6 +76,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | +| 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps | | 2026-10-11 | trivial | Tables: selection areas, insert buttons, move handle and double-click AutoFit on the page landed; ordinary drags over cells of several rows (a run of text, not a rectangle) added to the UI/UX gaps | | 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut | | 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fa9d437..b74aa12 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-11 · **Change:** trivial (table context menu whose items follow the selection, as in Word; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously minor: Polish interface and Polish proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page, Select Column: catalog 484/499; previously minor: Polish interface and Polish proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously 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 @@ -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, Phonetic Guide (#288), Enclose Characters (#297). Missing: 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), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, table column markers on the ruler (#49). Dragging column and row borders and the resize handle on the page landed (#217), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, and Select Column; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); 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, table column markers on the ruler (#49). Dragging column and row borders and the resize handle on the page landed (#217), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); 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; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). 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% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | @@ -252,6 +252,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| | 2026-10-11 | trivial | Tables: the table context menu, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); Tables row updated, no percentage change | +| 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select whole cells, as in Word; Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: selection areas for columns, rows and cells, "+" insert buttons, the move handle and double-click AutoFit on the page; Select Column (catalog 484/499); Tables row updated, no percentage change | | 2026-10-12 | minor | Portuguese proofing (pt-BR spelling from the installed VERO dictionary, accent-first suggestions, grammar, hyphenation; Review › Language gains Português (Brasil)): Proofing 40% → 42%; other dimensions unchanged at this precision | | 2026-10-12 | trivial | Proofing: custom dictionary persists across sessions (#40); no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3702aee..56b391e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table context menu whose items follow the selection, as in Word; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drags and Shift+arrows over table cells select whole cells; previously trivial: table selection areas, insert buttons, move handle and double-click AutoFit on the page; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -24,7 +24,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; Esc leaves a blank document), with File › Options › General › Start-up to turn it off (#766) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | -| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); double-click a column border to AutoFit it; selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped). Missing: Ctrl and Alt drags, the left edge, borders of selected cells only, Shift+click to extend a cell selection | ✅ | 90% | 1–2 | +| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); double-click a column border to AutoFit it; selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows. Missing: Ctrl and Alt drags, the left edge, borders of selected cells only | ✅ | 90% | 1–2 | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | @@ -45,6 +45,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| | 2026-10-11 | trivial | Table context menu as in Word: its items follow the selection (the caret in a cell, several cells, whole columns or rows, the whole table), adding Delete Cells, Split Cells, Distribute Rows and Columns, AutoFit, Text Direction, Insert Caption… and Table Properties… to Insert, Delete and Merge Cells; a right-click beside selected cells drops them | +| 2026-10-11 | trivial | Tables on the page: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells, as in Word | | 2026-10-11 | trivial | Tables on the page: selection areas for columns, rows and cells, "+" insert buttons, the move handle and double-click AutoFit; Select Column in the ribbon | | 2026-10-12 | trivial | Quick Access Toolbar: Undo list to undo several steps at once (#701) | | 2026-10-12 | trivial | Start screen (#766): launched with no file, the app opens on File › Home (Blank document, templates, Recent) as Word does; Esc or the back arrow leaves an empty blank document; File › Options › General › Start-up turns it off, kept between runs; `ui.startScreen` for agents. Not shown with files, `--sample` or `--control` |