diff --git a/ROADMAP.md b/ROADMAP.md index 90c51e17dc..801736e8d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-31 · **Change:** trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Home › Paragraph Tools: nine paragraph conversion and cleanup commands, #5043, with a Home ribbon drop-down; readiness estimates unchanged); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -680,6 +680,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Home › Paragraph Tools (#5043): nine commands — line breaks to paragraphs, blank paragraphs between, separate paragraphs, delete blanks, trim leading spaces, collapse extra spaces, spaces to indent, indents to spaces, join lines; scope is the selection or the whole document; one undo step per command, Track Changes deletions recorded; 16 engine tests and a ribbon drop-down test; Word feature catalog unchanged (WPS-style tools); readiness estimates unchanged | | 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) | | 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) | | 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) | diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 40cd997043..6e987d1fd4 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -15,6 +15,7 @@ mod lists; mod outline; mod styles; mod tabs_borders; +mod tools; use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog}; pub use format::{fmt, indent, outdent}; @@ -25,6 +26,10 @@ use outline::{move_paras, outline_step, sort}; use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match}; pub use tabs_borders::border_sides; use tabs_borders::{borders, tabs}; +use tools::{ + blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent, + trim_leading, +}; pub fn specs() -> Vec { vec![ @@ -261,5 +266,25 @@ pub fn specs() -> Vec { } sel_result(s) }), + // Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the + // selection, or the whole document with `scope: "all"`. One undo step per command. + CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between) + .params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#), + CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks) + .params(r#"{"scope"?: "selection|all"}"#), ] } diff --git a/crates/engine/src/cmd/para/tools.rs b/crates/engine/src/cmd/para/tools.rs new file mode 100644 index 0000000000..f7f208bb9c --- /dev/null +++ b/crates/engine/src/cmd/para/tools.rs @@ -0,0 +1,266 @@ +//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's +//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole +//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes. + +use serde_json::{Value, json}; + +use wordcraft_doc::para::{LINE_BREAK, PAGE_BREAK}; +use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef}; + +use super::{CmdError, CmdResult, Session, p, sel_result}; +use crate::cmd::{has_next_para, new_revision, split_para, track_delete_as}; + +/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a +/// command can add, stay bounded whatever the document says. +const MAX_TOOL_PARAS: usize = 10_000; + +/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says. +const MAX_INDENT_SPACES: usize = 1_000; + +/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default. +/// `all` selects the whole document first, so the command, its undo step and the result all +/// follow the full selection like any other command. +fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> { + match p::str(v, "scope").unwrap_or("selection") { + "selection" => Ok(()), + "all" => { + s.run("select.all", &json!({}))?; + Ok(()) + } + x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))), + } +} + +/// The paragraphs a command runs over, in document order, and the story they are in. +fn targets(s: &Session) -> Result<(StoryRef, Vec), CmdError> { + let (a, b) = s.sel.ordered(); + let paths = s.doc.para_paths_in(&a, &b); + if paths.is_empty() { + return Err(CmdError::Failed("no paragraphs selected".into())); + } + if paths.len() > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command"))); + } + Ok((a.story, paths)) +} + +/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on. +/// Offsets past the paragraph's end are clamped, so the document is never half-edited. +fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> { + let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len(); + let (a, b) = (a.min(b).min(len), b.min(len)); + if a >= b { + return Ok(()); + } + let at = |off: usize| Pos { story, path: path.clone(), off }; + if s.doc.settings.track_changes { + let rid = new_revision(s, RevisionKind::Delete); + track_delete_as(s, &at(a), &at(b), rid)?; + } else { + s.doc.delete_range(&at(a), &at(b))?; + } + Ok(()) +} + +/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied +/// last first so the earlier offsets stay valid. +fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> { + ranges.sort(); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (a, b) in ranges { + match merged.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => merged.push((a, b)), + } + } + for (a, b) in merged.into_iter().rev() { + delete_in_para(s, story, path, a, b)?; + } + Ok(()) +} + +/// The byte ranges of every manual line break in the paragraph. +fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> { + para.text.char_indices().filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect() +} + +/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a +/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it. +pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths.iter().rev() { + let Some(para) = s.doc.para(story, path) else { continue }; + for (i, _) in break_ranges(para).into_iter().rev() { + let new = split_para(s, &Pos { story, path: path.clone(), off: i })?; + // The break stays at the head of the new paragraph; the paragraph break replaces it. + delete_in_para(s, story, &new.path, 0, 1)?; + } + } + sel_result(s) +} + +/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after` +/// adds one blank paragraph after the last selected paragraph too. +pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult { + let after = p::bool(v, "after").unwrap_or(false); + scope_all(s, v)?; + let (story, paths) = targets(s)?; + let count = paths.len().saturating_sub(1) + usize::from(after); + if count > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once"))); + } + // Last first, each split at a paragraph's end: a split only shifts the paths after it. + if after { + let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?; + let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0); + split_para(s, &Pos { story, path: last.clone(), off: len })?; + } + for path in paths[..paths.len().saturating_sub(1)].iter().rev() { + let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0); + split_para(s, &Pos { story, path: path.clone(), off: len })?; + } + sel_result(s) +} + +/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a +/// blank line between them. +pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult { + blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true})) +} + +/// `para.removeBlank`: delete every empty paragraph in the selection. +pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + // Last first: removing a block leaves the paths before it as they are. + for path in paths.iter().rev() { + let Some(para) = s.doc.para(story, path) else { continue }; + // A paragraph must be empty, carry no section break and have a paragraph after it in + // its container: a story's, part's or cell's last mark can't be removed, and a paragraph + // before a table can't be joined across it. + if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) { + continue; + } + if s.doc.settings.track_changes { + let next = path.with_last(path.last() + 1); + let rid = new_revision(s, RevisionKind::Delete); + track_delete_as(s, &Pos { story, path: path.clone(), off: 0 }, &Pos { story, path: next, off: 0 }, rid)?; + } else { + s.doc.remove_block(story, path)?; + } + } + sel_result(s) +} + +/// The number of leading spaces in the paragraph's text. +fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize { + para.text.chars().take_while(|c| *c == ' ').count() +} + +/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph. +pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let n = leading_spaces(para); + apply_deletions(s, story, &path, vec![(0, n)])?; + } + sel_result(s) +} + +/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one +/// space; tabs and page breaks count as spaces, so spaces around a tab collapse to it. +/// Leading spaces stay (that is `para.trimLeadingSpaces`). +pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let mut runs = Vec::new(); + let mut start = None; + for (i, c) in para.text.char_indices() { + if matches!(c, ' ' | '\t' | PAGE_BREAK) { + start.get_or_insert(i); + } else if let Some(a) = start.take() { + // A run at the paragraph's start stays; a run inside the text collapses to one space. + if a > 0 && i - a > 1 { + runs.push((a, i - 1)); + } + } + } + // A run that reaches the paragraph's end collapses to one space too. + if let Some(a) = start + && a > 0 + && para.len() - a > 1 + { + runs.push((a + 1, para.len())); + } + apply_deletions(s, story, &path, runs)?; + } + sel_result(s) +} + +/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt +/// per space (Word's point-based indent from typing spaces), and are deleted. +pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let spaces = leading_spaces(para); + if spaces == 0 { + continue; + } + { + let p = s.doc.para_mut(story, &path)?; + p.props.indent_first = Some((p.props.indent_first.unwrap_or(0.0) + spaces as f32 * 72.0).clamp(-1584.0, 1584.0)); + p.props.cancel_char_indents(false, false, true); + p.touch(); + } + // A space is one byte, so the byte range is the count. + apply_deletions(s, story, &path, vec![(0, spaces)])?; + } + sel_result(s) +} + +/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces +/// (72 pt per space, as typing spaces indents), and the indents go to zero. +pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0); + let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize; + if n == 0 { + continue; + } + let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default(); + // The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow. + // The spaces are marked as a tracked insertion through `props.ins`, as typing is. + if s.doc.settings.track_changes { + props.ins = Some(new_revision(s, RevisionKind::Insert)); + } + let p = s.doc.para_mut(story, &path)?; + p.insert_text(0, &" ".repeat(n), &props)?; + p.props.indent_first = Some(0.0); + p.props.indent_left = Some(0.0); + p.props.cancel_char_indents(true, false, true); + p.touch(); + } + sel_result(s) +} + +/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines +/// join into one paragraph. +pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + apply_deletions(s, story, &path, break_ranges(para))?; + } + sel_result(s) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6cc770c64b..7fad83f443 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,6 +14,7 @@ mod documents; mod font_sizes; mod fonts; mod ime_noop; +mod para_tools; mod revision_checks; mod typing; mod wordart; diff --git a/crates/engine/src/tests/para_tools.rs b/crates/engine/src/tests/para_tools.rs new file mode 100644 index 0000000000..aa2292fcab --- /dev/null +++ b/crates/engine/src/tests/para_tools.rs @@ -0,0 +1,202 @@ +//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole +//! document, one undo step, Track Changes respected. + +use serde_json::json; +use wordcraft_doc::{Path, Pos, RevisionKind, SectionProps, StoryRef}; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: serde_json::Value) { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +/// Select the whole document (Mod+A's command). +fn select_all(s: &mut Session) { + run(s, "select.all", json!({})); +} + +/// One paragraph with `\n` as manual line breaks, typed as text. +fn type_breaks(s: &mut Session, t: &str) { + run(s, "text.insert", json!({"text": t})); +} + +#[test] +fn line_breaks_become_paragraphs() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3); + // One undo step brings the manual line breaks back. + assert!(s.undo()); + let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks"); +} + +#[test] +fn blank_paragraphs_go_in_between() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc"); +} + +#[test] +fn blank_paragraphs_go_after_too() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn separate_paragraphs_blanks_after_every_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.separateParagraphs", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn blank_paragraphs_are_deleted() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"})); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn a_section_break_paragraph_stays() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb"})); + // The second paragraph carries a section break and is emptied: removing it would drop the + // section, so it stays. + let second = Path::top(1); + let p = s.doc.para_mut(StoryRef::Body, &second).unwrap(); + p.text.clear(); + p.runs.clear(); + p.section = Some(Box::new(SectionProps::default())); + p.touch(); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays"); +} + +#[test] +fn leading_spaces_are_removed() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb\n c"})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn extra_spaces_collapse_to_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b c d \n e"})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay"); +} + +#[test] +fn a_space_run_at_the_end_collapses() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a "})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a "); +} + +#[test] +fn indented_spaces_become_an_indent() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb"})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt"); + assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces"); +} + +#[test] +fn indents_become_leading_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), " a", "72 + 144 pt become three spaces"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); +} + +#[test] +fn line_breaks_are_removed() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + select_all(&mut s); + run(&mut s, "para.removeLineBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "onetwothree"); +} + +#[test] +fn the_selection_is_the_scope() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\nc d"})); + // Only the first paragraph: select from its start to its end. + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) }; + run(&mut s, "para.removeExtraSpaces", json!({})); + assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched"); +} + +#[test] +fn deletions_are_recorded_under_track_changes() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\n c"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + // The text stays marked deleted, not gone, as Word records the deletion. + assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text"); + assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete)); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); +} + +#[test] +fn junk_scope_is_refused() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + select_all(&mut s); + let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err(); + assert!(format!("{e}").contains("scope"), "{e}"); + assert_eq!(text(&s), "a b", "the document is unchanged"); +} + +#[test] +fn too_many_paragraphs_are_refused_before_any_change() { + let text = "x\n".repeat(15_000); + let mut s = Session::new(wordcraft_doc::Document::from_text(&text)); + select_all(&mut s); + let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err(); + assert!(format!("{e}").contains("at most"), "{e}"); + let kept = s.doc.para_paths(StoryRef::Body).len(); + assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}"); +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 1eb15a6772..114076437f 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -328,6 +328,32 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) { }); }); }); + // Advanced paragraph tools (#5043): conversions and cleanup over the selection, or the + // whole document with Ctrl+A first (the commands take `scope: "all"` for that as well). + group(ui, "Paragraph Tools", None, app, |ui, app| { + menu_button(ui, app, crate::widgets::MenuButtonOptions::new("pilcrow", Some("Paragraph\nTools"), "Paragraph Tools", true, ""), |ui, app| { + mi(ui, app, "Split Lines into Paragraphs", "para.convertBreaks", json!({})); + mi(ui, app, "Add Blank Paragraphs Between", "para.blankBetween", json!({})); + mi(ui, app, "Separate Paragraphs", "para.separateParagraphs", json!({})); + ui.separator(); + mi(ui, app, "Delete Blank Paragraphs", "para.removeBlank", json!({})); + mi(ui, app, "Remove Leading Spaces", "para.trimLeadingSpaces", json!({})); + mi(ui, app, "Remove Extra Spaces", "para.removeExtraSpaces", json!({})); + ui.separator(); + mi(ui, app, "Indent Leading Spaces", "para.spacesToIndent", json!({})); + mi(ui, app, "Indents to Leading Spaces", "para.indentToSpaces", json!({})); + mi(ui, app, "Join Lines", "para.removeLineBreaks", json!({})); + ui.separator(); + // The whole document, not just the selection. + for (l, id) in [ + ("Delete Blank Paragraphs in the Whole Document", "para.removeBlank"), + ("Remove Leading Spaces in the Whole Document", "para.trimLeadingSpaces"), + ("Remove Extra Spaces in the Whole Document", "para.removeExtraSpaces"), + ] { + mi(ui, app, l, id, json!({"scope": "all"})); + } + }); + }); group(ui, "Styles", Some("styles.pane"), app, |ui, app| { crate::previews::style_gallery(app, ui, &st); }); diff --git a/crates/ui-egui/src/ribbon/tests.rs b/crates/ui-egui/src/ribbon/tests.rs index 1f1aeab9db..302fcb887e 100644 --- a/crates/ui-egui/src/ribbon/tests.rs +++ b/crates/ui-egui/src/ribbon/tests.rs @@ -98,6 +98,50 @@ fn design_style_set_gallery_stays_in_its_box() { assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}"); } +/// #5043: Home › Paragraph Tools: the drop-down's entries each run their own command over the +/// selection; "in the Whole Document" entries run the same command with `scope: "all"`. +#[test] +fn paragraph_tools_menu_entries_run_their_commands() { + use egui_kittest::kittest::Queryable; + let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("a b\n\nc d")), crate::Services::default()); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(900.0, 300.0)) + .with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(false, false))) + .build_ui_state(|ui, app: &mut WordApp| super::home::home(app, ui), a); + h.run_steps(2); + // The accessible label is the menu button's tip, "Paragraph Tools". + h.get_by_label("Paragraph Tools").click(); + h.run_steps(2); + // Items carry their (empty) shortcut text in the label, so match by containment. + for name in [ + "Split Lines into Paragraphs", + "Add Blank Paragraphs Between", + "Separate Paragraphs", + "Delete Blank Paragraphs", + "Remove Leading Spaces", + "Remove Extra Spaces", + "Indent Leading Spaces", + "Indents to Leading Spaces", + "Join Lines", + "Delete Blank Paragraphs in the Whole Document", + "Remove Leading Spaces in the Whole Document", + "Remove Extra Spaces in the Whole Document", + ] { + assert!(!h.query_all_by_label_contains(name).collect::>().is_empty(), "{name} is a named entry"); + } + // A caret, not a selection: the whole-document entries cover both paragraphs. + h.get_by_label_contains("Remove Extra Spaces in the Whole Document").click(); + h.run_steps(2); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "a b\n\nc d"); + // The menu closed with the click; open it again for the next entry. + h.get_by_label("Paragraph Tools").click(); + h.run_steps(2); + // Both entries match by containment; the plain one is first in the menu. + h.query_all_by_label_contains("Delete Blank Paragraphs").min_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y)).unwrap().click(); + h.run_steps(2); + assert_eq!(h.state().session.undo_depth(), 2, "the second entry is its own undo step"); +} + /// #2738: Home › Underline's drop-down: every line sample is a named entry (assistive /// technology, tooltips) that applies its own style; Words only, None and Underline Color › /// Automatic do what they say.