From a4d28e25862b01807b5268cc55f5f44a381e7ed0 Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 16 Oct 2026 00:33:35 +0200 Subject: [PATCH 1/2] Apply Format Painter on single-click release (#1455) --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 5 +- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/lib.rs | 116 ++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 13 +++- 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40407d18b6..7467439c01 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-14 · **Change:** 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-15 · **Change:** trivial (Format Painter single-click application, #1455; 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-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; 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. @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-15 | Format Painter applies to a clicked target word and disarms; trailing spaces stay outside the collapsed-caret word target (#1455). Explicit selections keep their existing scope. | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | @@ -403,6 +404,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Format Painter mouse-up application and collapsed-caret word scope (#1455) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4213967a65..64d9f0cced 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -734,7 +734,10 @@ fn paste_format(s: &mut Session, _: &Value) -> CmdResult { let f = s.sel.focus.clone(); if let Some(pp) = s.doc.para_at(&f) { let (x, y) = pp.word_at(f.off); - s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?; + // Word selection includes trailing spaces; a painter click formats just the word, + // matching the ordinary collapsed-caret formatting target. + let word_end = pp.text.get(x..y).map(|word| x + word.trim_end().len()).unwrap_or(y); + s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: word_end, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?; } s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?; } else { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4eca0d304b..343f213268 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2368,7 +2368,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo app.canvas.dragging = false; app.canvas.column_drag = None; app.canvas.unit_drag = None; - if app.session.painter.is_some() && !app.session.sel.is_collapsed() { + if app.session.painter.is_some() { let _ = app.run("edit.pasteFormat", json!({})); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 662516fbd6..8e394a38c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2663,6 +2663,122 @@ mod tests { assert_eq!(a.run("ui.caretAnimation", json!({"value": true})).unwrap()["caretAnimation"], true); } + #[test] + fn format_painter_single_click_formats_the_target_word_and_finishes() { + let mut a = app(); + let text = "ORIGEM formatada e DESTINO comum"; + a.run("text.insert", json!({"text": text})).unwrap(); + let mut start = a.session.sel.focus.clone(); + start.off = 0; + let mut end = start.clone(); + end.off = "ORIGEM".len(); + a.run("caret.set", json!({"pos": start})).unwrap(); + a.run("caret.set", json!({"pos": end, "extend": true})).unwrap(); + for id in ["format.bold", "format.italic", "format.underline"] { + a.run(id, json!({})).unwrap(); + } + let source = a.session.doc.para_at(&start).unwrap().props_of_char(0).clone(); + a.run("edit.formatPainter", json!({})).unwrap(); + let mut target = start.clone(); + target.off = text.find("DESTINO").unwrap() + 2; + let mut h = harness(a); + h.run_steps(6); + let caret = h.state_mut().session.layout().caret_on(&target, 0).unwrap(); + let p = canvas::page_to_screen(h.state_mut(), caret.page, caret.x, caret.top + caret.height * 0.5).unwrap(); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + assert!(h.state().session.painter.is_none(), "one click consumes the non-sticky painter"); + let para = h.state().session.doc.para_at(&target).unwrap(); + let got = para.props_of_char(target.off); + assert_eq!((&got.bold, &got.italic, &got.underline), (&source.bold, &source.italic, &source.underline)); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), text); + assert_ne!(para.props_of_char(text.find("DESTINO").unwrap() + "DESTINO".len()).bold, source.bold, "trailing space stays plain"); + assert_ne!(para.props_of_char(text.len() - 2).bold, source.bold, "surrounding text stays plain"); + } + + #[test] + fn format_painter_double_click_drag_and_copy_paste_preserve_target_scope() { + for gesture in ["double", "drag", "commands"] { + let mut a = app(); + let text = "ORIGEM formatada e DESTINO comum"; + a.run("text.insert", json!({"text": text})).unwrap(); + let mut source_start = a.session.sel.focus.clone(); + source_start.off = 0; + let mut source_end = source_start.clone(); + source_end.off = "ORIGEM".len(); + a.run("caret.set", json!({"pos": source_start})).unwrap(); + a.run("caret.set", json!({"pos": source_end, "extend": true})).unwrap(); + for id in ["format.bold", "format.italic", "format.underline"] { + a.run(id, json!({})).unwrap(); + } + let source = a.session.doc.para_at(&source_start).unwrap().props_of_char(0).clone(); + let mut target_start = source_start.clone(); + target_start.off = text.find("DESTINO").unwrap(); + let mut target_end = target_start.clone(); + target_end.off += "DESTINO".len(); + a.run(if gesture == "commands" { "edit.copyFormat" } else { "edit.formatPainter" }, json!({})).unwrap(); + let mut h = harness(a); + h.run_steps(6); + if gesture == "commands" { + h.state_mut().run("caret.set", json!({"pos": target_start})).unwrap(); + h.state_mut().run("caret.set", json!({"pos": target_end, "extend": true})).unwrap(); + h.state_mut().run("edit.pasteFormat", json!({})).unwrap(); + } else { + let mut press = target_start.clone(); + if gesture == "double" { + press.off += 2; + } + let caret = h.state_mut().session.layout().caret_on(&press, 0).unwrap(); + let start = canvas::page_to_screen(h.state_mut(), caret.page, caret.x, caret.top + caret.height * 0.5).unwrap(); + let caret = h.state_mut().session.layout().caret_on(&target_end, 0).unwrap(); + let end = canvas::page_to_screen(h.state_mut(), caret.page, caret.x, caret.top + caret.height * 0.5).unwrap(); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + let clicks = if gesture == "double" { 2 } else { 1 }; + for _ in 0..clicks { + h.event(egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + let release = if gesture == "drag" { + h.event(egui::Event::PointerMoved(end)); + h.run_steps(2); + end + } else { + start + }; + h.event(egui::Event::PointerButton { + pos: release, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + } + } + assert!(h.state().session.painter.is_none(), "{gesture}: one-use painter must disarm"); + let para = h.state().session.doc.para_at(&target_start).unwrap(); + for off in target_start.off..target_end.off { + let got = para.props_of_char(off); + assert_eq!( + (&got.bold, &got.italic, &got.underline), + (&source.bold, &source.italic, &source.underline), + "{gesture}: target offset {off}" + ); + } + assert_ne!(para.props_of_char(target_end.off).bold, source.bold, "{gesture}: trailing space stays plain"); + assert_ne!(para.props_of_char(text.len() - 2).bold, source.bold, "{gesture}: surrounding text stays plain"); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), text); + } + } + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut WordApp| { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1c503f5c5..7327c93d32 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-14 · **Change:** 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-15 · **Change:** trivial (Format Painter mouse-up and word scope, #1455; 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 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -11,7 +11,8 @@ Document title opens Rename Document; the account badge opens Options › Genera **Dimension: ~65% (estimated), 59–92 h to full** (the sum of the rows below; dialog depth is counted here, not under features). | Area | WordCraft | Word | Parity | Hours | -|---|---|---|---|---| +|---|---|---| +| 2026-10-15 | trivial | Format Painter applies on a single-click release, disarms after one use and keeps trailing spaces outside a collapsed-caret word target (#1455) |---|---| | Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 | @@ -55,6 +56,14 @@ Document title opens Rename Document; the account badge opens Options › Genera | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); not yet tried with real screen readers | full | 50% | 5–8 | | Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | +## Format Painter scope (#1455) + +A one-use painter applies on mouse release even when the target is a collapsed caret, +then disarms. For that caret case, character formatting covers the target word without +its trailing spaces, matching ordinary collapsed-caret formatting. A dragged or explicit +selection keeps its selected range. Copy Formatting / Paste Formatting uses the same +engine command; the browser-reserved Copy Formatting shortcut remains a separate issue. + ## Revision history | Date | Change | Summary | From b2ec73926fe595a2384d171d676d20126190d6c0 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:23:48 +0100 Subject: [PATCH 2/2] style: format relocated Format Painter assertion --- crates/ui-egui/src/tests.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index ea361e1cad..a8d74a203f 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -7292,11 +7292,7 @@ fn format_painter_double_click_drag_and_copy_paste_preserve_target_scope() { let para = h.state().session.doc.para_at(&target_start).unwrap(); for off in target_start.off..target_end.off { let got = para.props_of_char(off); - assert_eq!( - (&got.bold, &got.italic, &got.underline), - (&source.bold, &source.italic, &source.underline), - "{gesture}: target offset {off}" - ); + assert_eq!((&got.bold, &got.italic, &got.underline), (&source.bold, &source.italic, &source.underline), "{gesture}: target offset {off}"); } assert_ne!(para.props_of_char(target_end.off).bold, source.bold, "{gesture}: trailing space stays plain"); assert_ne!(para.props_of_char(text.len() - 2).bold, source.bold, "{gesture}: surrounding text stays plain");