diff --git a/ROADMAP.md b/ROADMAP.md index 41cbe7a078..b10ed84d31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1445,3 +1445,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements. | 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) | | 2026-11-12 | minor | Progress log: layout.fitOnePage condenses a multi-page document by one page while preserving typography hierarchy (#6433) | + +### Format Painter integration refresh, 2026-10-30 + +Single-click release applies the armed painter to the target word without trailing spaces and disarms it. Explicit selections retain their scope (#1455, partial). Source and UI regressions are retained after the canvas/input module split. Earlier Windows pointer and browser qualification are historical; current-head hosted validation is pending, and native shortcut/Mint qualification is not newly claimed. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bcc9cff404..eef77407f9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -7,6 +7,13 @@ use wordcraft_doc::{Path, Pos, StoryRef}; use super::{delete_selection, find_pattern, pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// Browsers reserve Mod+Shift+C for their inspector. Keep the desktop binding, but +/// advertise a web alternative through the same registry/keymap as every other command. +/// Mod+Alt+C already inserts copyright, and Mod+Alt+Shift+V is Keep Text Only. +fn copy_format_shortcut(web: bool) -> &'static str { + if web { "Mod+Alt+Shift+C / Mod+Shift+C" } else { "Mod+Shift+C" } +} + /// Undo several steps at once: the Undo button's list (#701). `count` is validated here and /// clamped to the undo depth, so hostile numbers never reach the stack. fn undo_to(s: &mut Session, v: &Value) -> CmdResult { @@ -168,7 +175,7 @@ pub fn specs() -> Vec { painter_pick(s); sel_result(s) }) - .key("Mod+Shift+C") + .key(copy_format_shortcut(cfg!(target_arch = "wasm32"))) .pure(), CommandSpec::new("edit.pasteFormat", "Paste Formatting", "Home › Clipboard", paste_format).key("Mod+Shift+V"), ] @@ -854,7 +861,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 { @@ -872,3 +882,49 @@ fn paste_format(s: &mut Session, _: &Value) -> CmdResult { } sel_result(s) } + +#[cfg(test)] +mod shortcut_tests { + use super::*; + use crate::{KeyMap, Registry}; + + #[test] + fn copy_format_web_alias_is_unique_and_keeps_existing_shortcuts() { + for web in [false, true] { + let mut commands = super::super::registry().all().to_vec(); + let copy = commands.iter_mut().find(|c| c.id == "edit.copyFormat").unwrap(); + copy.shortcut = copy_format_shortcut(web); + let registry = Registry::new(commands); + let mut seen = std::collections::BTreeSet::new(); + for command in registry.all() { + for key in command.shortcut.split(" / ").filter_map(crate::keymap::normalize) { + assert!(seen.insert(key.clone()), "duplicate binding: {key}"); + } + } + let keys = KeyMap::default(); + let resolve = |key| keys.resolve(®istry, key).map(|c| c.id); + assert_eq!(resolve("Mod+Shift+C"), Some("edit.copyFormat")); + assert_eq!(resolve("Mod+Alt+Shift+C"), web.then_some("edit.copyFormat")); + assert_eq!(resolve("Mod+Shift+V"), Some("edit.pasteFormat")); + assert_eq!(resolve("Mod+Alt+Shift+V"), Some("edit.pasteText")); + if !cfg!(target_os = "macos") { + assert_eq!(resolve("Mod+Alt+C"), Some("text.copyright")); + } + } + } + + #[test] + fn copy_format_web_alias_respects_user_removal_and_reassignment() { + let mut commands = super::super::registry().all().to_vec(); + commands.iter_mut().find(|c| c.id == "edit.copyFormat").unwrap().shortcut = copy_format_shortcut(true); + let registry = Registry::new(commands); + let mut keys = KeyMap::default(); + assert_eq!(keys.keys_for(®istry, "edit.copyFormat").first().map(String::as_str), Some("Mod+Alt+Shift+C")); + assert!(keys.remove(®istry, "edit.copyFormat", "Mod+Alt+Shift+C").unwrap()); + assert!(keys.resolve(®istry, "Mod+Alt+Shift+C").is_none()); + keys.assign(®istry, "format.bold", "Mod+Alt+Shift+C").unwrap(); + let restored = KeyMap::from_value(&serde_json::to_value(&keys).unwrap()); + assert_eq!(restored.resolve(®istry, "Mod+Alt+Shift+C").map(|c| c.id), Some("format.bold")); + assert_eq!(restored.resolve(®istry, "Mod+Shift+C").map(|c| c.id), Some("edit.copyFormat")); + } +} diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index fd7a944084..93f9820ea4 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -85,7 +85,7 @@ pub(super) fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::V /// Paint the selection with the Format Painter's formatting, when it is armed and something is /// selected. fn apply_format_painter(app: &mut WordApp) { - 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/tests.rs b/crates/ui-egui/src/tests.rs index ac043c33dd..bd338e5b98 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -8070,6 +8070,118 @@ fn focus_hides_the_ribbon_and_escape_brings_it_back() { assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar"); } +#[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); + } +} + /// Issue #2716: taking documents off the recent list is final — the open document must not be /// pushed back by the after-command re-add — and the change goes to the settings at once, so /// it survives the app being killed. Junk params return errors, never panics. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ed89ed7f0a..ca233d2b99 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -413,3 +413,7 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-25 | trivial | Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers: filter-as-you-type command search across all commands (label or id, case-insensitive); clearing restores the category scope (#1845) | | 2026-10-29 | trivial | Table Properties: the Row, Column and Cell settings apply to every selected row, column and cell, as in Word, not to the caret's alone, so selecting the whole table and unticking Allow row to break across pages keeps every row whole; Allow row to break and Repeat as header row show as mixed when the selected rows differ and change nothing until clicked; the row-height rule box has an accessible name (#4857) | | 2026-10-27 | trivial | File › Options › Proofing › AutoCorrect Options opens its dialog with no document open (Start screen); it did nothing there (#4585) | + +### Format Painter integration refresh, 2026-10-30 + +Single-click release applies the armed painter to the target word without trailing spaces and disarms it. Explicit selections retain their scope (#1455, partial). Source and UI regressions are retained after the canvas/input module split. Earlier Windows pointer and browser qualification are historical; current-head hosted validation is pending, and native shortcut/Mint qualification is not newly claimed.