diff --git a/ROADMAP.md b/ROADMAP.md index 5a0f79fb5..d56f1b72f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-10 · **Last updated:** 2026-10-23 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document. - 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison. - 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). - 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). @@ -299,6 +300,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips | | 2026-10-18 | Legacy menu keys (#1697; not on macOS): Alt, V, U turns on Full Screen View, as in Word for Windows, along with 17 other Word 2003 menu sequences observed there (views, ruler, zoom, Select All, Find/Replace/Go To, Symbol, Picture, Font, Paragraph, Word Count, Insert Table). Hidden as in Word, with a hint under the tab row while a sequence waits | | 2026-10-18 | View › Focus now fills the screen (#1678): the document on black with a slim bar whose "⋯" drops the ribbon over the page. Full Screen View (`view.fullScreenView`) shows the pages alone with Search at the top and can be bound to any key in Customize Keyboard; the two turn each other off. F11's Full Screen (#980) still fills the screen with the whole window. Esc leaves either; Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon, and a shortcut of your own on Ctrl+F1 wins. Immersive Reader no longer switches Focus on | +| 2026-10-15 | Selection bar mirrors for right-to-left paragraphs (the right margin) and works beside table rows: a click in the page margin beside a row selects the row, a drag selects rows, Shift+click extends by rows (a right-to-left table's bar is on the right); `table.selectAt {"extend"}`. Word's right-pointing arrow still missing | | 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. | | 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) | | 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) | @@ -587,6 +589,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) | | 2026-10-23 | trivial | Progress log: a relationship the markup names without the prefix is read as it wrote (#3411 follow-up) | | 2026-10-21 | trivial | Progress log: a document with many relationships opens in the time its part takes (#3411) | | 2026-10-23 | trivial | Progress log: an empty paragraph with a hidden mark draws no list number (#3982) | @@ -668,6 +671,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) | | 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) | | 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) | +| 2026-10-15 | trivial | Progress log: selection bar for right-to-left paragraphs and table rows | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | | 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. | diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index fd23166fc..896fa9e28 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -279,9 +279,13 @@ Mailings|Write & Insert Fields|Match Fields|mailings.matchFields Mailings|Preview Results|Preview Results|mailings.preview Mailings|Preview Results|Next Record|mailings.next Mailings|Preview Results|Previous Record|mailings.previous +Mailings|Preview Results|First Record|mailings.first +Mailings|Preview Results|Last Record|mailings.last +Mailings|Preview Results|Go to Record|mailings.goToRecord Mailings|Preview Results|Find Recipient|mailings.findRecipient Mailings|Preview Results|Check for Errors|mailings.checkErrors Mailings|Finish|Finish & Merge|mailings.finish +Mailings|Finish|Print Documents|mailings.printDocuments Review|Proofing|Spelling & Grammar|review.spelling Review|Proofing|Thesaurus|review.thesaurus Review|Proofing|Word Count|review.wordCount diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index eebbc162d..2d6e807d0 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -439,6 +439,15 @@ pub fn specs() -> Vec { .when(has_recipients), CommandSpec::new("mailings.next", "Next Record", "Mailings › Preview Results", |s, _| step(s, 1)).when(has_recipients), CommandSpec::new("mailings.previous", "Previous Record", "Mailings › Preview Results", |s, _| step(s, -1)).when(has_recipients), + CommandSpec::new("mailings.first", "First Record", "Mailings › Preview Results", |s, _| go_to(s, 0)).when(has_recipients), + CommandSpec::new("mailings.last", "Last Record", "Mailings › Preview Results", |s, _| go_to(s, i64::MAX)).when(has_recipients), + CommandSpec::new("mailings.goToRecord", "Go to Record", "Mailings › Preview Results", |s, v| { + let n = v.get("record").and_then(|r| r.as_i64().or_else(|| r.as_str().and_then(|t| t.trim().parse().ok()))); + let n = n.ok_or_else(|| CmdError::Params("`record` must be a record number (1 = the first)".into()))?; + go_to(s, n.saturating_sub(1)) + }) + .params(r#"{"record": n (1 = the first; past either end goes to the first or last record)}"#) + .when(has_recipients), CommandSpec::new("mailings.findRecipient", "Find Recipient", "Mailings › Preview Results", |s, v| { let q = p::req_str(v, "text")?.to_lowercase(); let idx = s @@ -464,9 +473,21 @@ pub fn specs() -> Vec { }) .pure(), CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish) - .params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#) + .params(r#"{"path"?: string (save the merged document), "records"?: "all|current" (current: the previewed record; default all, or from/to), "from"?: n, "to"?: n (record numbers, 1 = the first)}"#) .key(super::non_mac("Alt+Shift+N")) .when(has_recipients), + CommandSpec::new("mailings.printDocuments", "Print Documents", "Mailings › Finish › Finish & Merge", |s, v| { + let mut v = v.clone(); + if let Some(o) = v.as_object_mut() { + o.remove("path"); + } + let r = finish(s, &v)?; + s.push_ui_request(json!({"open": "print"})); + Ok(r) + }) + .params(r#"{"records"?: "all|current", "from"?: n, "to"?: n} (merges into a new document, as Finish & Merge does, and opens Print for it)"#) + .key(super::non_mac("Alt+Shift+M")) + .when(has_recipients), CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params( r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#, ), @@ -724,8 +745,13 @@ fn header_split(mut all: Vec>, headers: bool) -> Result<(Vec } fn step(s: &mut Session, d: i64) -> CmdResult { + go_to(s, (s.merge.record as i64).saturating_add(d)) +} + +/// Preview record `idx` (0-based), clamped to the list. +fn go_to(s: &mut Session, idx: i64) -> CmdResult { let n = s.merge.rows.len().max(1) as i64; - s.merge.record = (s.merge.record as i64 + d).clamp(0, n - 1) as usize; + s.merge.record = idx.clamp(0, n - 1) as usize; s.merge.preview = true; refresh(s); Ok(json!({"record": s.merge.record + 1, "of": n})) @@ -933,6 +959,23 @@ pub fn refresh(s: &mut Session) { s.touch(); } +/// The records Finish & Merge and Print Documents take (0-based, end exclusive): `records` +/// all or current, or `from` and `to` by record number. A range with none is an error. +pub fn merge_range(s: &Session, v: &Value) -> Result<(usize, usize), CmdError> { + let n = s.merge.rows.len(); + let (from, to) = match p::str(v, "records").map(str::trim) { + Some("current") => (s.merge.record.min(n.saturating_sub(1)), s.merge.record.saturating_add(1).min(n)), + None | Some("all") => { + ((p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1), (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize) + } + Some(_) => return Err(CmdError::Params("`records` must be all or current".into())), + }; + if from >= to { + return Err(CmdError::Failed(format!("no records from {} to {to} (the list has {n})", from.saturating_add(1)))); + } + Ok((from, to)) +} + /// Most merged documents' worth of body blocks Finish & Merge builds. const MAX_MERGED_BLOCKS: usize = 200_000; @@ -942,9 +985,7 @@ const MAX_MERGED_BLOCKS: usize = 200_000; /// are merged too: with merge fields in them, each letter becomes its own section with its own /// header/footer copies. fn finish(s: &mut Session, v: &Value) -> CmdResult { - let n = s.merge.rows.len(); - let from = (p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1); - let to = (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize; + let (from, to) = merge_range(s, v)?; let directory = s.merge.kind == "directory"; let hf: Vec = header_footer_ids(&s.doc).into_iter().filter(|id| s.doc.story(StoryRef::Part(*id)).is_some_and(|b| has_merge_fields(b, 0))).collect(); @@ -1580,6 +1621,65 @@ mod tests { assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha / Beta\nGamma / "); } + /// #3028: First, Last and Go to Record move the preview; Finish & Merge and Print Documents + /// take all records, the current one, or a range by record number. + #[test] + fn record_navigation_and_merge_ranges() { + let names = ["Alpha", "Beta", "Gamma", "Delta"]; + let fresh = || { + let mut s = Session::new(Document::new()); + s.run("mailings.recipients", &json!({"rows": names.map(|n| json!({"Name": n}))})).unwrap(); + s.run("mailings.start", &json!({"kind": "directory"})).unwrap(); + s.run("mailings.insertField", &json!({"field": "Name"})).unwrap(); + s + }; + let mut s = fresh(); + let body = |s: &Session| s.doc.plain_text(StoryRef::Body); + assert_eq!(s.run("mailings.last", &json!({})).unwrap(), json!({"record": 4, "of": 4})); + assert_eq!(body(&s), "Delta"); + assert_eq!(s.run("mailings.first", &json!({})).unwrap()["record"], 1); + assert_eq!(body(&s), "Alpha"); + assert_eq!(s.run("mailings.goToRecord", &json!({"record": 3})).unwrap()["record"], 3); + assert_eq!(body(&s), "Gamma"); + assert_eq!(s.run("mailings.goToRecord", &json!({"record": " 2 "})).unwrap()["record"], 2); + // Past either end: the last or first record, as the step buttons do. + assert_eq!(s.run("mailings.goToRecord", &json!({"record": 1141})).unwrap()["record"], 4); + assert_eq!(s.run("mailings.goToRecord", &json!({"record": -5})).unwrap()["record"], 1); + for bad in [json!({}), json!({"record": "two"}), json!({"record": 2.5})] { + assert!(matches!(s.run("mailings.goToRecord", &bad), Err(CmdError::Params(_))), "{bad}"); + } + + s.run("mailings.goToRecord", &json!({"record": 3})).unwrap(); + assert_eq!(s.run("mailings.finish", &json!({"records": "current"})).unwrap()["records"], 1); + assert_eq!(body(&s), "Gamma"); + let mut s = fresh(); + assert_eq!(s.run("mailings.finish", &json!({"from": 2, "to": 3})).unwrap()["records"], 2); + assert_eq!(body(&s), "Beta\nGamma"); + let mut s = fresh(); + assert_eq!(s.run("mailings.finish", &json!({"records": "all", "from": 3, "to": 99})).unwrap()["records"], 2); + assert_eq!(body(&s), "Gamma\nDelta"); + // An empty range is refused and the document is kept, not replaced by an empty one. + let mut s = fresh(); + let before = body(&s); + for bad in [json!({"from": 3, "to": 2}), json!({"from": 5}), json!({"records": "some"})] { + assert!(s.run("mailings.finish", &bad).is_err(), "{bad}"); + assert_eq!(body(&s), before, "{bad}"); + } + let e = s.run("mailings.finish", &json!({"from": 5})).unwrap_err().to_string(); + assert_eq!(e, "no records from 5 to 4 (the list has 4)", "a plain message for the dialog"); + + // Print Documents merges the same way, then asks the interface for Print. + let mut s = fresh(); + s.ui_requests.clear(); + assert_eq!(s.run("mailings.printDocuments", &json!({"from": 4, "path": "ignored.docx"})).unwrap()["records"], 1); + assert_eq!(body(&s), "Delta"); + assert!(s.ui_requests.contains(&json!({"open": "print"}))); + let mut s = fresh(); + s.ui_requests.clear(); + assert!(s.run("mailings.printDocuments", &json!({"from": 9})).is_err()); + assert!(s.ui_requests.is_empty(), "nothing to print, no Print dialog"); + } + /// #429: an IF rule shows its true / false text in Preview Results, as Finish & Merge does. #[test] fn if_rule_previews_its_result() { diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index 194f7d958..5aadf2e55 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("table.selectAt", "Select Table Cells by Clicking", "Table Layout › Table › Select", select_at) .params( - r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#, + r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt, "extend"?: bool, "anchorRow"?: n} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point; the page margin beside a table (its leading side: right for a right-to-left table) is a row area too, and `extend` (Shift+click) on one takes the rows from the selected ones' anchor, or from row `anchorRow` (0-based) when given, so a drag keeps its press's anchor. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#, ) .pure(), CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params( @@ -129,7 +129,7 @@ fn row_at(geom: &ResizeTarget, y: f32) -> usize { /// The spot of one table at (`x`, `y`): the handle, then the insert buttons, then the selection /// areas. `cells` are its cell boxes on the page. -fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) -> Option { +fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32, bar: Option<(f32, f32)>) -> Option { if on_handle(geom, x, y, reach) { return Some(Spot::Handle); } @@ -156,11 +156,47 @@ fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) return Some(Spot::Row(r.row)); } } + // The page margin beside the table is the selection bar: a row's area too. + if let Some((x0, x1)) = bar + && x >= x0 + && x <= x1 + && let Some(r) = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom) + { + return Some(Spot::Row(r.row)); + } // Inside a cell, along its left edge. let edge = CELL_EDGE.max(reach + 2.5); cells.iter().find(|b| x >= b.rect.x && x <= b.rect.x + edge && y >= b.rect.y && y <= b.rect.bottom()).map(|b| Spot::Cell(b.row, b.cell)) } +/// The x range of the selection bar beside the table's row at page height `y`: the page margin +/// on the table's leading side (left, or right for a right-to-left table). Only a non-floating +/// table of the main text has one, and only where it is what stands beside that margin: no line +/// of the text (another column, text wrapped round the table) lies between the two at that +/// height. A nested table's rows are the outermost table's. +fn row_bar(l: &DocLayout, doc: &Document, page: usize, geom: &ResizeTarget, y: f32) -> Option<(f32, f32)> { + let p = l.pages.get(page)?; + if geom.story != StoryRef::Body || geom.table.0.len() != 1 { + return None; + } + let t = doc.table(geom.story, &geom.table)?; + let row = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)?; + if t.props.float.is_some() { + return None; + } + let rtl = t.props.bidi_visual; + let blocked = wordcraft_layout::hit::page_lines(p, StoryRef::Body).iter().any(|ln| { + ln.path.0.len() <= 1 + && ln.top < row.bottom - 0.5 + && ln.bottom > row.top + 0.5 + && if rtl { ln.right > geom.right() } else { ln.left < geom.left() } + }); + if blocked { + return None; + } + Some(if rtl { (geom.right().max(p.body.right()), p.w) } else { (0.0, geom.left().min(p.body.x)) }) +} + /// The spot under page point (`x`, `y`) of a table in `story`, of nested tables the innermost's. /// None on a column or row border or the resize handle: those resize (`table_resize`). `reach` /// is their resize reach (points). @@ -178,7 +214,7 @@ pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: .filter(|g| g.story == story) .filter_map(|geom| { let cells: Vec<&CellBox> = boxes.iter().filter(|b| b.story == geom.story && b.table == geom.table).collect(); - spot_in(&geom, &cells, x, y, reach).map(|spot| Target { spot, geom }) + spot_in(&geom, &cells, x, y, reach, row_bar(l, doc, page, &geom, y)).map(|spot| Target { spot, geom }) }) .max_by_key(|t| t.geom.table.0.len()) } @@ -377,7 +413,17 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult { let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1)); let (rows, cols) = match spot { Spot::Column(g) => ((0, last_row), (g, to.map_or(g, |(tx, _)| column_at(&geom, tx)))), - Spot::Row(r) => ((r, to.map_or(r, |(_, ty)| row_at(&geom, ty))), (0, last_col)), + Spot::Row(r) => { + let r2 = to.map_or(r, |(_, ty)| row_at(&geom, ty)); + // Shift+click: the rows from the selection's anchor row to the one clicked. + // A drag after it keeps the anchor the press had (`anchorRow`). + let anchor = if v.get("extend").and_then(Value::as_bool).unwrap_or(false) { + p::u64(v, "anchorRow").map_or_else(|| anchor_row(s, &geom.table, r), |a| usize::try_from(a).unwrap_or(usize::MAX).min(last_row)) + } else { + r + }; + ((anchor, r2), (0, last_col)) + } Spot::Cell(r, c) => { let (g0, g1) = table::grid_cols(t, r, c); match to { @@ -394,6 +440,19 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult { table::select_range(s, story, &geom.table, rows, cols) } +/// The row a Shift+click on row `clicked` of table `tp` extends from: the end of the selected +/// rows farther from it, or the caret's row; the clicked row itself when nothing of the table is +/// selected. The app reads it at the press and sends it as `anchorRow` while the button is held. +pub fn anchor_row(s: &Session, tp: &Path, clicked: usize) -> usize { + if let Some(range) = s.cell_range().filter(|c| &c.table == tp) { + return if clicked >= range.rows.0 { range.rows.0 } else { range.rows.1 }; + } + match s.sel.anchor.path.cell() { + Some((t, row, _)) if &t == tp => row, + _ => clicked, + } +} + /// A cell to insert a column beside at grid line `line`: one starting there (the column goes /// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after) fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> { @@ -761,6 +820,103 @@ mod tests { assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true))); } + /// The page margin beside a table is a row selection area too, as far as the selection bar + /// reaches (Word): left of a left-to-right table, right of a right-to-left one. + #[test] + fn the_margin_beside_a_row_selects_it() { + let mut s = table_session(3, 3); + let r1 = cell_rect(&mut s, 1, 0); + let body_x = s.layout().pages[0].body.x; + assert!(r1.x - 40.0 > 0.0 && r1.x - body_x < 24.0); + // Far past the 24pt of the plain row area, still in the margin: the row. + assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), Some(Spot::Row(1))); + assert_eq!(spot_at(&mut s, body_x - 35.0, mid_y(r1)), Some(Spot::Row(1))); + // The right margin is not a row bar of a left-to-right table. + let right = s.layout().pages[0].body.right(); + assert_eq!(spot_at(&mut s, right + 20.0, mid_y(r1)), None); + // A right-to-left table has its bars on the right. + let tp = table_path(&s); + s.doc.table_mut(StoryRef::Body, &tp).unwrap().props.bidi_visual = true; + s.touch(); + let r1 = cell_rect(&mut s, 1, 0); + let (page_w, right) = (s.layout().pages[0].w, s.layout().pages[0].body.right()); + assert_eq!(spot_at(&mut s, right + 30.0, mid_y(r1)), Some(Spot::Row(1))); + assert_eq!(spot_at(&mut s, page_w - 5.0, mid_y(r1)), Some(Spot::Row(1))); + assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), None); + // Shift+click in the bar extends the selected rows from the anchor row. + s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r1)})).unwrap(); + let r2 = cell_rect(&mut s, 2, 0); + s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r2), "extend": true})).unwrap(); + assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2))); + let r0 = cell_rect(&mut s, 0, 0); + s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r0), "extend": true})).unwrap(); + assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the far edge of the old rows stays"); + } + + /// `anchorRow` fixes the row a Shift+click extends from, whatever is selected (a drag after + /// an upward Shift+click keeps its rows); out of range it's the last row. + #[test] + fn an_anchor_row_param_fixes_the_extend_anchor() { + let mut s = table_session(3, 3); + let (r0, r2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 0)); + let x = r0.x - 10.0; + s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r2)})).unwrap(); + s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true})).unwrap(); + assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2))); + // Without it, the next frame of the drag would re-read the anchor as row 0. + s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "toX": x, "toY": mid_y(r0) + 1.0, "extend": true, "anchorRow": 2})) + .unwrap(); + assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the press's anchor stays"); + s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 99999999999u64})).unwrap(); + assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "clamped to the last row"); + s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 1})).unwrap(); + assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 1))); + } + + /// The margin is the row bar only where the table is what's beside it: not at a column-2 + /// table's height beside column 1's lines (the selection bar's lines), nor beside text + /// wrapped round a floating table. + #[test] + fn the_row_bar_needs_the_table_beside_the_margin() { + // A two-column section: 36 short lines fill column 1 and start column 2, then a table. + let mut s = Session::new(Document::new()); + s.run("layout.columns", &json!({"count": 2})).unwrap(); + let lines: Vec = (0..36).map(|i| format!("Line {i}")).collect(); + s.run("text.insert", &json!({"text": format!("{}\r", lines.join("\r"))})).unwrap(); + s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); + let r0 = cell_rect(&mut s, 0, 0); + let body = s.layout().pages[0].body; + assert!(r0.x > body.x + body.w / 2.0, "the table is in column 2: {r0:?}"); + assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "column 1's line is the selection bar's"); + // A floating table with text wrapped to its left. + let mut d = Document::from_text(&"Words that wrap round the floating table beside them. ".repeat(40)); + let mut t = Table::new(3, 1, 150.0); + t.props.float = Some(wordcraft_doc::props::TableFloat { + h_rel: wordcraft_doc::para::Anchor::Margin, + v_rel: wordcraft_doc::para::Anchor::Paragraph, + x: 300.0, + overlap: false, + ..Default::default() + }); + d.insert_block(StoryRef::Body, &Path(vec![0]), wordcraft_doc::Block::Table(t)).unwrap(); + let mut s = Session::new(d); + let r0 = cell_rect(&mut s, 0, 0); + assert!(r0.x > 300.0, "the table floats at the right: {r0:?}"); + assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "the text wrapped beside it has the line bar"); + } + + /// A paragraph right above the table (no space after) doesn't hide the first row's bar. + #[test] + fn a_paragraph_flush_above_the_table_keeps_the_row_bar() { + let mut d = Document::from_text("Above"); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(0.0); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(Table::new(2, 2, 400.0))).unwrap(); + let mut s = Session::new(d); + let r0 = cell_rect(&mut s, 0, 0); + assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), Some(Spot::Row(0))); + assert_eq!(spot_at(&mut s, 10.0, r0.y + 0.3), Some(Spot::Row(0)), "even at the very top of the row"); + } + /// The insert buttons add a row or column at their border, one undo step, and select it (as /// Word does); typing then goes into its first cell. #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 88cb92680..b48a38c2a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3349,6 +3349,8 @@ fn hostile_params_never_panic() { json!({}), json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), json!({"value": -1e308, "rows": 1e9}), + json!({"page": 0, "x": 10, "y": 100, "extend": "x", "toX": null, "toY": 1e30, "reach": "r"}), + json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}), json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}), json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}), ]; diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 44d2434f5..8a4840479 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -566,20 +566,27 @@ impl DocLayout { None } - /// Word's selection bar (#856): when (x, y) is in the page's left margin, left of the body - /// text column, the start of the body line at that height. Not in the header/footer area, - /// not beside a table's lines (those are left to the table), nothing on an empty page. + /// Word's selection bar (#856): when (x, y) is in the page margin beside the text column, + /// the start of the body line at that height. The bar is the left margin for a left-to-right + /// paragraph and the right margin for a right-to-left one. Not in the header/footer area, + /// not beside a table's lines (those are the table's row areas), nothing on an empty page. pub fn selection_bar_at(&self, page: usize, x: f32, y: f32) -> Option { let p = self.pages.get(page)?; - if !(x.is_finite() && y.is_finite()) || x < 0.0 || x >= p.body.x || y < p.body.y || y > p.body.bottom() { + if !(x.is_finite() && y.is_finite()) || x < 0.0 || x > p.w || y < p.body.y || y > p.body.bottom() { + return None; + } + let left = x < p.body.x; + if !left && x <= p.body.right() { return None; } let l = nearest_line_at(p, y, false)?; - if l.path.0.len() > 1 || l.left < p.body.x - 0.5 { - // A table's line, or text hanging into the margin: not the selection bar. + let line = l.para.lines.get(l.li)?; + // The line's own side, and no text hanging into that margin. + let hangs = if line.rtl { l.right > p.body.right() + 0.5 } else { l.left < p.body.x - 0.5 }; + if l.path.0.len() > 1 || line.rtl == left || hangs { return None; } - Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start }) + Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: line.start }) } /// The start of the top-level body line nearest height `y` on `page` (dragging in the diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2cbfea635..5d8878aa4 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -404,6 +404,36 @@ fn selection_bar_finds_the_line_beside_the_pointer() { assert_eq!(l.body_line_at(0, -100.0), Some(Pos::body(0, 0))); } +#[test] +fn selection_bar_of_a_right_to_left_paragraph_is_in_the_right_margin() { + // Paragraph 0 reads left to right, paragraph 1 right to left, paragraph 2 left to right. + let mut d = Document::from_text("Left one\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e9}\u{5dc}\u{5d5}\u{5dd}\nLeft two"); + let at = Pos::body(1, 0); + d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap(); + let l = lay(&d); + let p = &l.pages[0]; + let mid = |i: usize| { + let c = l.caret(&Pos::body(i, 0)).unwrap(); + c.top + c.height / 2.0 + }; + let (left_x, right_x) = (p.body.x / 2.0, (p.body.right() + p.w) / 2.0); + // The right-to-left paragraph: the bar is on the right, not the left. + assert_eq!(l.selection_bar_at(0, right_x, mid(1)).map(|q| q.path), Some(Path::top(1))); + assert_eq!(l.selection_bar_at(0, left_x, mid(1)), None); + // Left-to-right neighbours keep the left margin only (each line uses its own side). + assert_eq!(l.selection_bar_at(0, left_x, mid(0)), Some(Pos::body(0, 0))); + assert_eq!(l.selection_bar_at(0, right_x, mid(0)), None); + assert_eq!(l.selection_bar_at(0, left_x, mid(2)), Some(Pos::body(2, 0))); + assert_eq!(l.selection_bar_at(0, right_x, mid(2)), None); + // Not in the text column, off the page, or hostile numbers. + assert_eq!(l.selection_bar_at(0, p.body.right() - 5.0, mid(1)), None); + assert_eq!(l.selection_bar_at(0, p.w + 5.0, mid(1)), None); + assert_eq!(l.selection_bar_at(0, f32::INFINITY, mid(1)), None); + // The line span is the whole line, as for the left bar. + let q = l.selection_bar_at(0, right_x, mid(1)).unwrap(); + assert!(l.line_span(&q, 0).is_some()); +} + #[test] fn deletions_leave_the_final_text_layout() { // "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED". diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 70b52b4b3..2ac93d185 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -71,7 +71,7 @@ pub struct CanvasState { column_drag: Option<((usize, f32, f32), (usize, f32, f32))>, /// The last primary press on the page (time, screen point, clicks in a row), to count /// double- and triple-presses as they go down (egui reports them on release). - last_press: Option<(f64, Pos2, u8)>, + pub(crate) last_press: Option<(f64, Pos2, u8)>, /// The first press of the current run of presses moved header/footer editing into another /// header/footer; the double-click it starts doesn't select a word there. hf_press_switched: bool, @@ -324,9 +324,17 @@ struct UnitDrag { last: Pos, } +/// After a whole-document selection from a press (not a line, so it can't grow): holds the press +/// so its release isn't a caret click. +pub(crate) fn hold_document_selection(app: &mut WordApp, clicks: u8) { + let (start, end) = app.session.sel.ordered(); + app.canvas.unit_drag = Some(UnitDrag { unit: "document", clicks, start, last: end.clone(), end }); + app.canvas.dragging = true; +} + /// Clicks in a row for a press at `at` (time `now`) after `last`: within the double-click /// delay and distance it counts up to 3 (a triple-click), otherwise it starts again at 1. -fn press_count(last: Option<(f64, Pos2, u8)>, now: f64, at: Pos2, delay: f64, dist: f32) -> u8 { +pub(crate) fn press_count(last: Option<(f64, Pos2, u8)>, now: f64, at: Pos2, delay: f64, dist: f32) -> u8 { match last { Some((t, p, n)) if now - t >= 0.0 && now - t < delay && p.distance(at) <= dist => n.saturating_add(1).min(3), _ => 1, @@ -4465,6 +4473,61 @@ mod tests { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); } + /// A right-to-left paragraph's selection bar is the right margin; the left margin beside it + /// is nothing special (a click there is the caret's). + #[test] + fn selection_bar_of_a_right_to_left_paragraph_is_on_the_right() { + use egui::{Event, PointerButton}; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "First para\r\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e9}\u{5dc}\u{5d5}\u{5dd}\rThird para"})).unwrap(); + a.run("caret.set", json!({"pos": serde_json::to_value(Pos::body(1, 0)).unwrap()})).unwrap(); + a.run("para.rtl", json!({})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let c = h.state_mut().session.layout().caret(&Pos::body(1, 0)).expect("caret"); + let (body_x, page_w) = { + let l = h.state_mut().session.layout(); + (l.pages[c.page].body.right(), l.pages[c.page].w) + }; + let y = c.top + c.height / 2.0; + let right = page_to_screen(h.state_mut(), c.page, (body_x + page_w) / 2.0, y).expect("on screen"); + let left = page_to_screen(h.state_mut(), c.page, 30.0, y).expect("on screen"); + let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| { + h.event(Event::PointerMoved(at)); + h.step(); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }); + for _ in 0..3 { + h.step(); + } + }; + h.event(Event::PointerMoved(right)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "the bar's arrow on the right"); + click(&mut h, right); + let (s, e) = h.state().session.sel.ordered(); + assert_eq!(s, Pos::body(1, 0), "the line is selected"); + assert!(e > s && (e.path == wordcraft_doc::Path::top(1) || e == Pos::body(2, 0)), "{s:?}..{e:?}"); + // Beside it on the left: no bar (not the bar's arrow, and a caret, not a line selection). + // The cursor there is the text cursor, or none at all on some platforms' test windows. + h.event(Event::PointerMoved(left)); + h.step(); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); + click(&mut h, left); + assert!(h.state().session.sel.is_collapsed(), "a click in the left margin beside it is a caret, not a line"); + } + /// #3453: while the Format Painter is armed the pointer over the text says so, and it goes /// back once the painter is off. The selection bar keeps its arrow while it is armed. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 977b9c1da..66bf92947 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -447,6 +447,15 @@ pub enum Dialog { edit: bool, message: String, }, + /// Mailings › Finish & Merge › Edit Individual Documents… or Print Documents… (`print`): + /// which records to merge (0 all, 1 the previewed one, 2 the From and To range). + MergeRecords { + print: bool, + choice: u8, + from: String, + to: String, + message: String, + }, /// Mailings › Find Recipient. FindRecipient { text: String, @@ -988,6 +997,8 @@ impl Dialog { Dialog::SelectTable { .. } => "selectTable", Dialog::InsertMergeField { .. } => "insertMergeField", Dialog::FindRecipient { .. } => "findRecipient", + Dialog::MergeRecords { print: false, .. } => "mergeRecords", + Dialog::MergeRecords { .. } => "printRecords", Dialog::FieldCode { edit: false, .. } => "fieldCode", Dialog::FieldCode { .. } => "editFieldCode", Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "ruleSkipIf", @@ -1249,6 +1260,13 @@ impl Dialog { Dialog::InsertMergeField { field: fields.first().cloned().unwrap_or_default(), fields } } "findRecipient" => Dialog::FindRecipient { text: String::new(), message: String::new() }, + "mergeRecords" | "printRecords" => Dialog::MergeRecords { + print: name == "printRecords", + choice: 0, + from: "1".into(), + to: app.session.merge.rows.len().to_string(), + message: String::new(), + }, "fieldCode" => Dialog::FieldCode { code: String::new(), edit: false, message: String::new() }, "editFieldCode" => { let field = wordcraft_engine::cmd::fields::selected(&app.session).into_iter().next()?; @@ -1446,6 +1464,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::SelectTable { .. } => "Select Table", Dialog::InsertMergeField { .. } => "Insert Merge Field", Dialog::FindRecipient { .. } => "Find Recipient", + Dialog::MergeRecords { .. } => "Finish & Merge", Dialog::FieldCode { edit: false, .. } => "Field", Dialog::FieldCode { .. } => "Edit Field Code", Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "Skip Record If", @@ -2978,6 +2997,46 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } cancel } + Dialog::MergeRecords { print, choice, from, to, message } => { + ui.radio_value(choice, 0, tl!("All")); + ui.radio_value(choice, 1, tl!("Current record")); + ui.horizontal(|ui| { + ui.radio_value(choice, 2, tl!("From:")); + // Typing a number picks the range, as in Word. + if ui.add(egui::TextEdit::singleline(from).desired_width(60.0)).changed() { + *choice = 2; + } + ui.label(tl!("To:")); + if ui.add(egui::TextEdit::singleline(to).desired_width(60.0)).changed() { + *choice = 2; + } + }); + if !message.is_empty() { + ui.label(egui::RichText::new(message.as_str()).small().weak()); + } + let (ok, cancel) = buttons(ui, tl!(if *print { "Print" } else { "OK" })); + if ok { + let params = match *choice { + 0 => Some(json!({"records": "all"})), + 1 => Some(json!({"records": "current"})), + _ => match (from.trim().parse::(), to.trim().parse::()) { + (Ok(f), Ok(t)) => Some(json!({"from": f, "to": t})), + _ => None, + }, + }; + let id = if *print { "mailings.printDocuments" } else { "mailings.finish" }; + // An empty range is reported here, before Save Changes asks about the document. + let run = params.ok_or_else(|| tl!("Enter a number.").to_string()).and_then(|p| { + wordcraft_engine::cmd::mailings::merge_range(&app.session, &p).map_err(|e| e.to_string())?; + app.run(id, p) + }); + match run { + Ok(_) => return true, + Err(e) => *message = e, + } + } + cancel + } Dialog::MergeRule { rule, fields, field, value, then, els } => { let skip = rule == "SKIPIF"; ui.label(tl!(if skip { @@ -4384,6 +4443,52 @@ mod tests { /// #1346: a check box the selected paragraphs differ in shows as neither on nor off; a click /// and OK turn it on in all of them, and the other fields they differ in stay as they were. + /// #3028: the records dialog refuses a range with no records itself, before Save Changes asks + /// about the unsaved document; a good range goes on to that question. + #[test] + fn merge_records_dialog_checks_the_range_before_asking_to_save() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Dear "); + let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"})); + let _ = app.run("text.insert", json!({"text": "unsaved"})); + assert!(app.session.dirty); + app.dialog = Dialog::open("mergeRecords", &mut app); + if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog { + *choice = 2; + *from = "3".into(); + *to = "2".into(); + } + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + for _ in 0..2 { + h.step(); + } + let Some(Dialog::MergeRecords { message, .. }) = &h.state().dialog else { panic!("the dialog stays open") }; + assert!(message.contains("no records from 3 to 2"), "{message}"); + if let Some(Dialog::MergeRecords { to, .. }) = &mut h.state_mut().dialog { + *to = "3".into(); + } + for _ in 0..2 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + for _ in 0..2 { + h.step(); + } + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet"); + } + #[test] fn paragraph_dialog_sets_a_mixed_check_box_for_all() { use egui_kittest::kittest::{NodeT, Queryable}; diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 4ccaed5df..33fff7011 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -240,6 +240,8 @@ fn untranslated_interface_text() { "models", "insertMergeField", "findRecipient", + "mergeRecords", + "printRecords", "caption", "index", "markEntry", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index b727c9ef6..ae20992f6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2766,3 +2766,13 @@ More tabs Още раздели # #3013: Insert Table of Figures Include label and number Включване на етикета и номера + First Record Първи запис + Last Record Последен запис + Go to Record Отиване до запис + Edit Individual Documents… Редактиране на отделни документи… + Print Documents… Печат на документи… + Print Documents Печат на документи + Current record Текущ запис + From: От: + To: До: + Enter a number. Въведете число. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 732923751..0c665983e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2768,3 +2768,13 @@ preview Title Ukázka More tabs Další karty # #3013: Insert Table of Figures Include label and number Zahrnout popisek a číslo + First Record První záznam + Last Record Poslední záznam + Go to Record Přejít na záznam + Edit Individual Documents… Upravit jednotlivé dokumenty… + Print Documents… Tisk dokumentů… + Print Documents Tisk dokumentů + Current record Aktuální záznam + From: Od: + To: Do: + Enter a number. Zadejte číslo. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 75f66ba95..1f8809bca 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2601,3 +2601,13 @@ preview Title Titel Full Screen View Full Screen View Personal Templates Personal Templates Underline Color Underline Color + First Record Første post + Last Record Sidste post + Go to Record Gå til post + Edit Individual Documents… Rediger enkelte dokumenter… + Print Documents… Udskriv dokumenter… + Print Documents Udskriv dokumenter + Current record Aktuel post + From: Fra: + To: Til: + Enter a number. Angiv et tal. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5802c975a..d48b5e64f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2368,3 +2368,13 @@ More tabs Weitere Registerkarten # #3013: Insert Table of Figures Include label and number Bezeichnung und Nummer einbeziehen + First Record Erster Datensatz + Last Record Letzter Datensatz + Go to Record Gehe zu Datensatz + Edit Individual Documents… Einzelne Dokumente bearbeiten… + Print Documents… Dokumente drucken… + Print Documents Dokumente drucken + Current record Aktueller Datensatz + From: Von: + To: Bis: + Enter a number. Bitte eine Zahl eingeben. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c337fd557..adc2981e8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2731,3 +2731,13 @@ preview Title Título More tabs Más pestañas # #3013: Insert Table of Figures Include label and number Incluir rótulo y número + First Record Primer registro + Last Record Último registro + Go to Record Ir al registro + Edit Individual Documents… Editar documentos individuales… + Print Documents… Imprimir documentos… + Print Documents Imprimir documentos + Current record Registro actual + From: Desde: + To: Hasta: + Enter a number. Introduce un número. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 45a78b76f..ee6bda138 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2403,3 +2403,13 @@ More tabs Veel vahekaarte # #3013: Insert Table of Figures Include label and number Lisa silt ja number + First Record Esimene kirje + Last Record Viimane kirje + Go to Record Mine kirjele + Edit Individual Documents… Redigeeri üksikdokumente… + Print Documents… Prindi dokumendid… + Print Documents Prindi dokumendid + Current record Praegune kirje + From: Alates: + To: Kuni: + Enter a number. Sisesta arv. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8fea70006..c6fe7980b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2777,3 +2777,13 @@ template Letter Lettre More tabs Plus d'onglets # #3013: Insert Table of Figures Include label and number Inclure l’étiquette et le numéro + First Record Premier enregistrement + Last Record Dernier enregistrement + Go to Record Atteindre l’enregistrement + Edit Individual Documents… Modifier les documents individuels… + Print Documents… Imprimer les documents… + Print Documents Imprimer les documents + Current record Enregistrement actif + From: De : + To: À : + Enter a number. Entrez un nombre. diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 47944d5e8..5316f1095 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2776,3 +2776,13 @@ preview Title Naslov More tabs Više kartica # #3013: Insert Table of Figures Include label and number Uključi oznaku i broj + First Record Prvi zapis + Last Record Posljednji zapis + Go to Record Idi na zapis + Edit Individual Documents… Uredi pojedinačne dokumente… + Print Documents… Ispis dokumenata… + Print Documents Ispis dokumenata + Current record Trenutni zapis + From: Od: + To: Do: + Enter a number. Unesite broj. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 41b459f75..6a23a7377 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2772,3 +2772,13 @@ More tabs További lapok # #3013: Insert Table of Figures Include label and number Címkével és számmal + First Record Első rekord + Last Record Utolsó rekord + Go to Record Ugrás rekordra + Edit Individual Documents… Egyedi dokumentumok szerkesztése… + Print Documents… Dokumentumok nyomtatása… + Print Documents Dokumentumok nyomtatása + Current record Aktuális rekord + From: Ettől: + To: Eddig: + Enter a number. Adj meg egy számot. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 455c5fc1f..526a6aaf7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2801,3 +2801,13 @@ template Letter Lettera More tabs Altre schede # #3013: Insert Table of Figures Include label and number Includi etichetta e numero + First Record Primo record + Last Record Ultimo record + Go to Record Vai al record + Edit Individual Documents… Modifica singoli documenti… + Print Documents… Stampa documenti… + Print Documents Stampa documenti + Current record Record corrente + From: Da: + To: A: + Enter a number. Immetti un numero. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3aef566e8..67ab20d6a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2369,3 +2369,13 @@ More tabs その他のタブ # #3013: Insert Table of Figures Include label and number ラベルと番号を含める + First Record 最初のレコード + Last Record 最後のレコード + Go to Record レコードへ移動 + Edit Individual Documents… 個々のドキュメントを編集… + Print Documents… ドキュメントを印刷… + Print Documents ドキュメントを印刷 + Current record 現在のレコード + From: 開始: + To: 終了: + Enter a number. 数値を入力してください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64531d1b6..f97cae925 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2619,3 +2619,13 @@ preview Title 제목 More tabs 탭 더 보기 # #3013: Insert Table of Figures Include label and number 레이블 및 번호 포함 + First Record 첫 레코드 + Last Record 마지막 레코드 + Go to Record 레코드로 이동 + Edit Individual Documents… 개별 문서 편집… + Print Documents… 문서 인쇄… + Print Documents 문서 인쇄 + Current record 현재 레코드 + From: 시작: + To: 끝: + Enter a number. 숫자를 입력하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e58b557f9..95ddfb136 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2671,3 +2671,13 @@ preview Title Titel Full Screen View Full Screen View Personal Templates Personal Templates Underline Color Underline Color + First Record Eerste record + Last Record Laatste record + Go to Record Ga naar record + Edit Individual Documents… Afzonderlijke documenten bewerken… + Print Documents… Documenten afdrukken… + Print Documents Documenten afdrukken + Current record Huidig record + From: Van: + To: Tot: + Enter a number. Voer een getal in. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6dda63ff1..6b78f1245 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2797,3 +2797,13 @@ preview Title Tytuł This document has no bookmarks. This document has no bookmarks. Type a page number, or +2 / -1. Type a page number, or +2 / -1. Wait for the print job to finish submitting before closing. Wait for the print job to finish submitting before closing. + First Record Pierwszy rekord + Last Record Ostatni rekord + Go to Record Przejdź do rekordu + Edit Individual Documents… Edytuj poszczególne dokumenty… + Print Documents… Drukuj dokumenty… + Print Documents Drukuj dokumenty + Current record Bieżący rekord + From: Od: + To: Do: + Enter a number. Wpisz liczbę. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5b2981ded..4e6578cd2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2381,3 +2381,13 @@ More tabs Mais guias # #3013: Insert Table of Figures Include label and number Incluir rótulo e número + First Record Primeiro registro + Last Record Último registro + Go to Record Ir para o registro + Edit Individual Documents… Editar documentos individuais… + Print Documents… Imprimir documentos… + Print Documents Imprimir documentos + Current record Registro atual + From: De: + To: Até: + Enter a number. Digite um número. diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index b77f21da5..59118d2c6 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2764,3 +2764,13 @@ template Letter Scrisoare cm cm mm mm pi pi + First Record Prima înregistrare + Last Record Ultima înregistrare + Go to Record Salt la înregistrare + Edit Individual Documents… Editare documente individuale… + Print Documents… Imprimare documente… + Print Documents Imprimare documente + Current record Înregistrarea curentă + From: De la: + To: Până la: + Enter a number. Introduceți un număr. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8a352a00e..e77483404 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2775,3 +2775,13 @@ preview Title Пример More tabs Другие вкладки # #3013: Insert Table of Figures Include label and number Включить подпись и номер + First Record Первая запись + Last Record Последняя запись + Go to Record Перейти к записи + Edit Individual Documents… Изменить отдельные документы… + Print Documents… Печать документов… + Print Documents Печать документов + Current record Текущая запись + From: С: + To: По: + Enter a number. Введите число. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 8916e2983..8c8312cb8 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2765,3 +2765,13 @@ preview Title Naslov {names} and {n} more {names} and {n} more {name} options {name} options {width} × {height} pixels {width} × {height} pixels + First Record Prvi zapis + Last Record Zadnji zapis + Go to Record Pojdi na zapis + Edit Individual Documents… Uredi posamezne dokumente… + Print Documents… Natisni dokumente… + Print Documents Natisni dokumente + Current record Trenutni zapis + From: Od: + To: Do: + Enter a number. Vnesite število. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 95a04cf20..4e3c37f93 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2379,3 +2379,13 @@ More tabs Još kartica # #3013: Insert Table of Figures Include label and number Uključi oznaku i broj + First Record Prvi zapis + Last Record Poslednji zapis + Go to Record Idi na zapis + Edit Individual Documents… Uredi pojedinačne dokumente… + Print Documents… Štampaj dokumente… + Print Documents Štampaj dokumente + Current record Trenutni zapis + From: Od: + To: Do: + Enter a number. Unesite broj. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c6c78a314..cc1ff0a2d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2383,3 +2383,13 @@ More tabs Још картица # #3013: Insert Table of Figures Include label and number Укључи ознаку и број + First Record Први запис + Last Record Последњи запис + Go to Record Иди на запис + Edit Individual Documents… Уреди појединачне документе… + Print Documents… Штампај документе… + Print Documents Штампај документе + Current record Тренутни запис + From: Од: + To: До: + Enter a number. Унесите број. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f8fcf9de5..15faef5bb 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2804,3 +2804,13 @@ template Letter Brev More tabs Fler flikar # #3013: Insert Table of Figures Include label and number Ta med etikett och nummer + First Record Första post + Last Record Sista post + Go to Record Gå till post + Edit Individual Documents… Redigera enskilda dokument… + Print Documents… Skriv ut dokument… + Print Documents Skriv ut dokument + Current record Aktuell post + From: Från: + To: Till: + Enter a number. Ange ett tal. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 98e01a607..5bcd602f3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2774,3 +2774,13 @@ preview Title Örnek More tabs Diğer sekmeler # #3013: Insert Table of Figures Include label and number Etiketi ve numarayı ekle + First Record İlk Kayıt + Last Record Son Kayıt + Go to Record Kayda Git + Edit Individual Documents… Belgeleri Ayrı Ayrı Düzenle… + Print Documents… Belgeleri Yazdır… + Print Documents Belgeleri Yazdır + Current record Geçerli kayıt + From: Başlangıç: + To: Bitiş: + Enter a number. Bir sayı girin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 72d009fb3..605ec8e34 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2381,3 +2381,13 @@ More tabs Інші вкладки # #3013: Insert Table of Figures Include label and number Додавати мітку й номер + First Record Перший запис + Last Record Останній запис + Go to Record Перейти до запису + Edit Individual Documents… Редагувати окремі документи… + Print Documents… Друк документів… + Print Documents Друк документів + Current record Поточний запис + From: З: + To: По: + Enter a number. Введіть число. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f6b3812e1..5ffcaccf8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2816,3 +2816,13 @@ template Letter Thư More tabs Các tab khác # #3013: Insert Table of Figures Include label and number Bao gồm nhãn và số + First Record Bản ghi đầu tiên + Last Record Bản ghi cuối cùng + Go to Record Đi tới bản ghi + Edit Individual Documents… Sửa từng tài liệu… + Print Documents… In tài liệu… + Print Documents In tài liệu + Current record Bản ghi hiện tại + From: Từ: + To: Đến: + Enter a number. Nhập một số. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1988c4d6..4f4d9c875 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2381,3 +2381,13 @@ More tabs 更多选项卡 # #3013: Insert Table of Figures Include label and number 包含标签和编号 + First Record 第一条记录 + Last Record 最后一条记录 + Go to Record 转到记录 + Edit Individual Documents… 编辑单个文档… + Print Documents… 打印文档… + Print Documents 打印文档 + Current record 当前记录 + From: 从: + To: 到: + Enter a number. 请输入数字。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 66256c750..892e6f946 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2370,3 +2370,13 @@ More tabs 更多索引標籤 # #3013: Insert Table of Figures Include label and number 包含標籤和編號 + First Record 第一筆記錄 + Last Record 最後一筆記錄 + Go to Record 移至記錄 + Edit Individual Documents… 編輯個別文件… + Print Documents… 列印文件… + Print Documents 列印文件 + Current record 目前記錄 + From: 從: + To: 到: + Enter a number. 請輸入數字。 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 4a462a079..ac039e7ed 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -854,6 +854,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { let pts: &[(f32, f32)] = if name == "next" || name == "last" { &[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)] } else { &[(13.0, 4.0), (7.0, 10.0), (13.0, 16.0)] }; pen.line_c(pts, a); + // First and Last: a bar at the end the chevron points to. + match name { + "first" => pen.line_c(&[(5.0, 4.0), (5.0, 16.0)], a), + "last" => pen.line_c(&[(15.0, 4.0), (15.0, 16.0)], a), + _ => {} + } } "finish" => { pen.page(3.0, 2.0, 13.0, 15.0); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 6c465dea7..ed61700bb 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -332,8 +332,10 @@ fn controls(tab: &str) -> Vec { c("Rules", "mailings.rules"), c("Match Fields", "mailings.matchFields"), c("Preview Results", "mailings.preview"), + p("First Record", "mailings.first", serde_json::json!({})), p("Previous Record", "mailings.previous", serde_json::json!({})), p("Next Record", "mailings.next", serde_json::json!({})), + p("Last Record", "mailings.last", serde_json::json!({})), c("Find Recipient", "mailings.findRecipient"), c("Check for Errors", "mailings.checkErrors"), c("Finish & Merge", "mailings.finish"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1c9927beb..a41328d60 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2732,8 +2732,9 @@ pub fn keeps_everything(name: &str) -> bool { /// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get /// the command's own error or default instead): Select Recipients and Edit Recipient List without /// data, Insert Merge Field without a field, Find Recipient without text, the If and Skip -/// Record If rules (or Rules with no rule at all) without a field, Table Properties without -/// settings, Symbol and Field without a character or code, and Enclose Characters without a shape. +/// Record If rules (or Rules with no rule at all) without a field, Finish & Merge and Print +/// Documents without records, Table Properties without settings, Symbol and Field without a +/// character or code, and Enclose Characters without a shape. fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { let has = |k: &str| params.get(k).is_some_and(|v| !v.is_null()); let rule = params.get("rule").and_then(Value::as_str).map(str::to_ascii_uppercase); @@ -2748,6 +2749,9 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "mailings.editRecipients" if !has("rows") => Some("recipientList"), "mailings.insertField" if !has("field") => Some("insertMergeField"), "mailings.findRecipient" if !has("text") => Some("findRecipient"), + // Finish & Merge and Print Documents without records ask which (#3028). + "mailings.finish" if !["path", "records", "from", "to"].into_iter().any(has) => Some("mergeRecords"), + "mailings.printDocuments" if !["records", "from", "to"].into_iter().any(has) => Some("printRecords"), // Symbol without a character and Field without a code show their dialogs (#321). "insert.symbol" if !has("char") => Some("symbol"), "insert.field" if !has("instr") => Some("field"), @@ -2899,6 +2903,7 @@ pub(crate) fn discards_document(id: &str, params: &Value) -> bool { "file.open" => params.get("path").is_some(), "file.recover" => params.get("restore").is_some(), "mailings.finish" => params.get("path").and_then(Value::as_str).is_none(), + "mailings.printDocuments" => true, _ => false, } } @@ -7231,14 +7236,28 @@ mod tests { assert!(!a.has_document() && !a.quit_requested); } - const MAILINGS: [&str; 3] = ["mailings.envelopes", "mailings.labels", "mailings.finish"]; + const MAILINGS: [&str; 4] = ["mailings.envelopes", "mailings.labels", "mailings.finish", "mailings.printDocuments"]; /// What the user gave: Envelopes and Labels without anything open their dialogs (#407). fn mailing(id: &str) -> Value { match id { "mailings.envelopes" => json!({"delivery": "Ada"}), "mailings.labels" => json!({"text": "Ada"}), - _ => json!({}), + _ => json!({"records": "all"}), + } + } + + /// #3028: Finish & Merge and Print Documents from the ribbon ask which records first; with + /// records given (scripts, the dialog's OK) they merge. + #[test] + fn finish_and_print_documents_ask_which_records() { + let mut a = app(); + a.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan"})).unwrap(); + for (id, dialog) in [("mailings.finish", "mergeRecords"), ("mailings.printDocuments", "printRecords")] { + assert_eq!(a.run(id, json!({})).unwrap(), json!({"pending": dialog})); + assert_eq!(prompt(&a), Some(dialog)); + assert!(matches!(&a.dialog, Some(dialogs::Dialog::MergeRecords { from, to, .. }) if from == "1" && to == "2")); + a.dialog = None; } } @@ -7269,7 +7288,7 @@ mod tests { // A `path` that isn't text merges into a new document, like no path at all. let mut a = typed(); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); - a.run("mailings.finish", json!({"path": null})).unwrap(); + a.run("mailings.finish", json!({"path": null, "records": "all"})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3daff528a..e2ca5f491 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2054,18 +2054,44 @@ fn mailings(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false); stack(ui, |ui| { crate::widgets::row(ui, |ui| { + small(ui, app, "first", None, "First Record", "mailings.first", json!({}), false); small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false); + record_box(ui, app); small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false); + small(ui, app, "last", None, "Last Record", "mailings.last", json!({}), false); }); small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false); small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false); }); }); group(ui, "Finish", None, app, |ui, app| { - big(ui, app, "finish", "Finish &\nMerge", "mailings.finish", json!({}), false); + menu_button(ui, app, "finish", Some("Finish &\nMerge"), "Finish & Merge", true, "mailings.finish", |ui, app| { + mi(ui, app, "Edit Individual Documents…", "mailings.finish", json!({})); + mi(ui, app, "Print Documents…", "mailings.printDocuments", json!({})); + }); }); } +/// Preview Results' record number: shows the previewed record; a number typed and Enter go there. +fn record_box(ui: &mut Ui, app: &mut WordApp) { + let on = !app.session.merge.rows.is_empty(); + let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() }; + // The draft lives in temp memory while typing (frame-local text would lose keystrokes). + let key = egui::Id::new("merge_record_box"); + let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| shown.clone()); + let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).horizontal_align(egui::Align::Center)); + let r = r.on_hover_text(tl!("Go to Record")); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, text.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(key)); + if ui.input(|i| i.key_pressed(egui::Key::Enter)) && text.trim() != shown { + let _ = app.run("mailings.goToRecord", json!({"record": text.trim()})); + } + } +} + /// Zotero (`docs/zotero.md`): the Zotero desktop app does the work in its own window. fn zotero(app: &mut WordApp, ui: &mut Ui) { group(ui, "Citations", None, app, |ui, app| { diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index 8933eccd1..fa5c9ec2d 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -26,6 +26,19 @@ pub(crate) struct TableDrag { /// The resize reach at the press (page points). reach: f32, target: Target, + /// Shift was down at the press on a row area: the rows extend from the anchor row. + extend: bool, + /// The row that Shift+click extends from, read at the press: the rows it selects would + /// otherwise move it on the next frame. + anchor: Option, +} + +/// `table.selectAt`'s params, with the press's anchor row when it has one. +fn select_params(mut at: serde_json::Value, anchor: Option) -> serde_json::Value { + if let (Some(a), Some(m)) = (anchor, at.as_object_mut()) { + m.insert("anchorRow".into(), json!(a)); + } + at } /// Whether the table mouse is in reach: when resizing is, and not while a border is dragged. @@ -54,7 +67,10 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] d.to = ((p.x - r.min.x) / sc, (p.y - r.min.y) / sc); } let down = ui.input(|i| i.pointer.primary_down()); - let at = json!({"page": d.page, "x": d.from.0, "y": d.from.1, "reach": d.reach, "toX": d.to.0, "toY": d.to.1}); + let at = select_params( + json!({"page": d.page, "x": d.from.0, "y": d.from.1, "reach": d.reach, "toX": d.to.0, "toY": d.to.1, "extend": d.extend}), + d.anchor, + ); if d.target.spot == Spot::Handle { if ui.input(|i| i.key_pressed(egui::Key::Escape)) { return true; @@ -92,7 +108,33 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] } let Some(p) = latest else { return false }; let Some((page, x, y, reach, target)) = target_at(app, layout, rects, scale, p) else { return false }; - let at = json!({"page": page, "x": x, "y": y, "reach": reach}); + let mods = ui.input(|i| i.modifiers); + // Word: a triple-click or Ctrl/Cmd+click beside a row selects the whole document, as in the + // body's selection bar. + if matches!(target.spot, Spot::Row(_)) && !mods.shift { + let now = ui.input(|i| i.time); + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let clicks = crate::canvas::press_count(app.canvas.last_press, now, p, delay, dist); + app.canvas.last_press = Some((now, p, clicks)); + if clicks >= 3 || mods.command { + if let Err(e) = app.run("select.all", json!({"whole": true})) { + app.status(e); + } + crate::canvas::hold_document_selection(app, clicks); + return true; + } + } + let extend = matches!(target.spot, Spot::Row(_)) && mods.shift; + // Shift+click with the selection started outside the table is the ordinary Shift+click into + // a table (the caret logic extends it by whole rows); only the table's own selection extends here. + if extend && !in_table(app, &target.geom.table) { + return false; + } + let anchor = match target.spot { + Spot::Row(r) if extend => Some(table_mouse::anchor_row(&app.session, &target.geom.table, r)), + _ => None, + }; + let at = select_params(json!({"page": page, "x": x, "y": y, "reach": reach, "extend": extend}), anchor); match target.spot { Spot::InsertRow(_) | Spot::InsertColumn(_) => { if let Err(e) = app.run_keep_view("table.insertAt", at) { @@ -106,12 +148,19 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] { app.status(e); } - app.canvas.table_drag = Some(TableDrag { page, from: (x, y), to: (x, y), sent: (x, y), reach, target }); + app.canvas.table_drag = Some(TableDrag { page, from: (x, y), to: (x, y), sent: (x, y), reach, target, extend, anchor }); } } true } +/// Whether the selection's anchor (or its selected cells) is in the table at `tp`. +fn in_table(app: &WordApp, tp: &wordcraft_doc::Path) -> bool { + let body = |story| story == wordcraft_doc::StoryRef::Body; + app.session.cell_range().is_some_and(|c| body(c.story) && &c.table == tp) + || (body(app.session.sel.anchor.story) && app.session.sel.anchor.path.cell().is_some_and(|(t, ..)| &t == tp)) +} + /// The pointer: the move arrows on the handle, the plain arrow on an insert button; over a /// selection area the system pointer hides and `paint` draws the selection arrow. pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response, layout: &DocLayout, rects: &[Rect], scale: f32) { @@ -317,6 +366,150 @@ mod tests { assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2)))); } + /// The selection bar beside a table (the page margin, well past the row areas) selects the + /// row; a drag down it selects the rows it passes; Shift+click extends by rows. + #[test] + fn the_margin_beside_a_table_selects_rows() { + let mut h = table_window(); + let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 1, 0)); + let (ya, yb) = (a.y + a.h / 2.0, b.y + b.h / 2.0); + let far = 20.0; + assert!(a.x - far > 30.0, "well past the row areas"); + let at_a = screen(&mut h, far, ya); + h.event(egui::Event::PointerMoved(at_a)); + steps(&mut h, 2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the drawn selection arrow"); + click(&mut h, at_a); + assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 0), (0, 2))), "one click, the row"); + // Drag from row 0 to row 1. + let end = screen(&mut h, far, yb); + h.event(egui::Event::PointerMoved(at_a)); + h.step(); + button(&h, at_a, true); + h.step(); + h.event(egui::Event::PointerMoved(end)); + h.step(); + assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((0, 1)), "the selection follows the pointer"); + button(&h, end, false); + steps(&mut h, 2); + // Click row 0 again, then Shift+click row 1. + click(&mut h, at_a); + assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((0, 0))); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.step(); + h.event(egui::Event::PointerMoved(end)); + h.step(); + button(&h, end, true); + h.step(); + button(&h, end, false); + steps(&mut h, 2); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::default())); + h.step(); + assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2))), "extended by rows"); + } + + /// Shift+click beside an upper row with the last row selected takes the rows between, and + /// the pointer moving a little before the release keeps them: the anchor row is the one at + /// the press, not re-read from the rows the press selected. + #[test] + fn a_shift_click_up_the_margin_keeps_its_anchor_while_held() { + let mut h = table_window(); + let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 1, 0)); + let far = 20.0; + let at_b = screen(&mut h, far, b.y + b.h / 2.0); + click(&mut h, at_b); + assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((1, 1))); + let at = screen(&mut h, far, a.y + a.h / 2.0); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.step(); + h.event(egui::Event::PointerMoved(at)); + h.step(); + button(&h, at, true); + h.step(); + assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((0, 1)), "extended up"); + for k in 1..=3 { + h.event(egui::Event::PointerMoved(at + vec2(k as f32, 1.0))); + h.step(); + } + button(&h, at + vec2(3.0, 1.0), false); + steps(&mut h, 2); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::default())); + h.step(); + assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2))), "the jitter kept the rows"); + } + + /// Word: a triple-click, or Ctrl/Cmd+click, in the margin beside a table row selects the whole + /// document; a single click (and a drag) keeps selecting rows. + #[test] + fn triple_click_or_ctrl_click_beside_a_row_selects_the_document() { + let mut h = table_window_stepping(1.0 / 60.0); + let b = cell(&mut h, 1, 0); + let at = screen(&mut h, 20.0, b.y + b.h / 2.0); + let whole = |h: &mut H| { + let s = &h.state().session; + // From the first cell's start to the end of the paragraph after the table. + let (a, e) = s.sel.ordered(); + a.path == wordcraft_doc::Path(vec![0, 0, 0, 0]) && a.off == 0 && e == wordcraft_doc::Pos::body(1, 0) + }; + // One click: the row. + click(&mut h, at); + assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((1, 1)), "a click selects the row"); + // Three quick clicks: everything. + steps(&mut h, 40); + for _ in 0..3 { + h.event(egui::Event::PointerMoved(at)); + h.step(); + button(&h, at, true); + h.step(); + button(&h, at, false); + h.step(); + } + steps(&mut h, 2); + assert!(whole(&mut h), "triple-click: {:?}", h.state().session.sel); + // Ctrl+click: everything too. + steps(&mut h, 40); + click(&mut h, at); + assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((1, 1))); + steps(&mut h, 40); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + h.step(); + click(&mut h, at); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::default())); + h.step(); + assert!(whole(&mut h), "ctrl+click: {:?}", h.state().session.sel); + } + + /// Shift+click in the bar beside a row, with the selection started above the table, extends + /// that selection (the ordinary Shift+click into a table), instead of selecting only the row. + #[test] + fn shift_click_beside_a_row_extends_a_selection_from_above() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Above"})).unwrap(); + a.run("insert.table", json!({"rows": 3, "cols": 2})).unwrap(); + a.run("caret.set", json!({"pos": serde_json::to_value(wordcraft_doc::Pos::body(0, 0)).unwrap()})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + steps(&mut h, 6); + let b = cell(&mut h, 1, 0); + let at = screen(&mut h, 20.0, b.y + b.h / 2.0); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.step(); + click(&mut h, at); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::default())); + h.step(); + let sel = h.state().session.sel.clone(); + assert_eq!(sel.anchor, wordcraft_doc::Pos::body(0, 0), "the selection still starts above the table"); + assert!(!sel.is_collapsed()); + assert!(sel.focus.path.cell().is_some_and(|(_, r, _)| r == 1), "and runs into the clicked row: {:?}", sel.focus); + } + /// 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] diff --git a/docs/gaps.md b/docs/gaps.md index f18b1bdf8..0553a8ce0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (#8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-10 · **Last updated:** 2026-10-23 · **Change:** minor (#8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 3c29ab8c8..a096e516d 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 764c83f87..de35548a4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ 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. -**498 of 513 catalog features have commands (97.1%).** +**502 of 517 catalog features have commands (97.1%).** | Tab | Live | Total | |---|---|---| @@ -15,7 +15,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Design | 10 | 10 | | Layout | 22 | 22 | | References | 25 | 25 | -| Mailings | 19 | 19 | +| Mailings | 23 | 23 | | Review | 29 | 31 | | View | 25 | 29 | | Developer | 11 | 11 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7851a724..e399b9036 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-23 · **Change:** trivial (read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-23 · **Change:** trivial (mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -31,7 +31,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients (Filter and Sort, #778; Find in list and a merged-only view, #1576), Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **61** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **62** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Mail merge: First Record, Last Record, a record number box, and record choices for Edit Individual Documents and Print Documents (#3028) | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-19 | trivial | Insert Table of Figures opens a Table of Figures dialog: caption label (Figure, Table, Equation, the user's own and those the document's captions use), Include label and number, page numbers, right margin and tab leader, with a preview; the ribbon button, KeyTip and command search reach it (#3013) | @@ -113,6 +114,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-15 | trivial | Track Changes Options › Moves: Moved from and Moved to rows (mark and colour; green double strikethrough and double underline by default, as in Word for Mac, which has no Track moves checkbox). `review.trackingOptions {"moveFromMark", "moveFromColor", "moveToMark", "moveToColor"}`, saved per user; `"trackMoves"` sets the document's setting. Mark lists in Word for Mac's order; Deletions and Moved from gain (none), Bold, Italic and Double underline; moved text marked (none) is plain text | | 2026-10-14 | trivial | Comments pane and balloons: a comment or reply edit the document refuses keeps the typed text and shows why; drafts belong to their document, so a new or opened one never shows them; Save, Export, Print, Close, New and Open write pending drafts first; a refused draft doesn't block them (Save saves the rest, Close/New/Open ask Save / Don't Save / Cancel about it); Escape discards a draft; comment and reply fields are read-only in a document protected as read only or for forms (#1348) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | +| 2026-10-15 | trivial | Selection bar: the right margin for right-to-left paragraphs and tables, and the page margin beside table rows selects rows (click, drag, Shift+click) | | 2026-10-16 | trivial | Create Source: automatic Tag like Word's (`Smi20`, `Kim001`); Edit Source keeps the Tag and citations show the new Author and Year (#2094; Word for Mac 16.113.4, user-checked) | | 2026-10-18 | trivial | Split Cells on a merged cell: non-divisor row counts refused with a divisor message, 1 × 1 on a merged cell does nothing (#2592 follow-up) | | 2026-10-18 | trivial | Split Cells merged-cell limits and equal parts, Convert Text to Table Initial column width, both from the Word check (#2592) |