mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 22:07:06 -06:00
Draw tables and split or merge cells with a pen and eraser (#308)
* Draw Table and Eraser: draw tables and split or merge cells with a pen (#303) Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser. table.draw takes a pen stroke in page points: a rectangle outside a 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 (new grid column or row; untouched cells span it). table.eraser merges the two cells beside the border nearest a point. Each stroke is one undo step. In the app the buttons toggle pen and eraser modes with a live preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks don't move the caret while a tool is on. Splitting through part of a vertically merged cell and erasing beside one are refused with a clear error. Catalog 384/430 -> 387/430. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Draw Table and Eraser: draw tables and split or merge cells with a pen (#303) Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser. table.draw takes a pen stroke in page points: a rectangle outside a 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 (new grid column or row; untouched cells span it). table.eraser merges the two cells beside the border nearest a point. Each stroke is one undo step. In the app the buttons toggle pen and eraser modes with a live preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks don't move the caret while a tool is on. Splitting through part of a vertically merged cell and erasing beside one are refused with a clear error. Catalog 384/430 -> 387/430. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
5c970f90bd
commit
0e2e4cde80
+5
-3
@@ -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) |
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<CellBox> {
|
||||
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<Stroke> {
|
||||
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<usize, CmdError> {
|
||||
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<f32> {
|
||||
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<usize> = 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<EraseTarget> {
|
||||
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::<Vec<_>>()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
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::<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<crate::table_pen::TableTool>,
|
||||
/// The Draw Table stroke (or eraser press) in progress.
|
||||
pub(crate) table_stroke: Option<crate::table_pen::PenStroke>,
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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: 削除された箇所:
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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: Брисања:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -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 Відступи й інтервали
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -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: 删除内容:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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: 刪除內容:
|
||||
|
||||
|
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -335,6 +335,8 @@ fn controls(tab: &str) -> Vec<Control> {
|
||||
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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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<Result<Value, String>> {
|
||||
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<Pos2> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+5
-4
@@ -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 |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user