#4085: check the entire target before removing highlighting

Resolve highlight runs one at a time without the formatting-state query cap.
Pin mixed and inherited tails at 10000/10001/10002 runs, including Undo/Redo.
Document the changed command/session/helpers and explicit palette setter behavior.

Validation: red regression at10001 runs; all3 highlight tests pass after fix.
Full cargo xtask ci passed all8 gates:3963 passed,0 failed,4 existing ignored.
RUST_TEST_THREADS=1. Local named-function doc audit:13/13 documented; CodeRabbit
uses its own analyzer. Native Word comparison remains pending.
This commit is contained in:
LQRD
2026-10-25 00:55:51 +05:00
parent 3dda0274e5
commit d67f5fffcb
6 changed files with 105 additions and 6 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+50 -2
View File
@@ -13,6 +13,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
/// Register character-formatting commands, their parameters and keyboard bindings.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
@@ -228,7 +229,8 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
sel_result(s)
}
/// Resolved formatting of every character in the target (or the caret).
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
if let Some(rows) = column_rows(s) {
@@ -391,6 +393,52 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
apply(s, &|x| x.underline_color = rgb)
}
/// Test the entire highlight target without the formatting-state query's run cap.
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
let mut seen = false;
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
if a == b {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
if seen {
return true;
}
}
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
}
}
}
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
/// the last choice off only when the whole formatting target already has that colour.
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
@@ -407,7 +455,7 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
})
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
};
let remove = color.is_none() && resolved(s).iter().all(|r| r.highlight == h.rgb());
let remove = color.is_none() && target_has_highlight(s, h);
let value = if remove { Highlight::None } else { h };
let result = apply(s, &|x| x.highlight = Some(value))?;
s.last_highlight = h;
+2
View File
@@ -674,6 +674,8 @@ impl Session {
}
}
/// Start an editing session at the document beginning with empty history and
/// default view, preferences and a yellow highlighter independent of document undo.
pub fn new(doc: Document) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
+43
View File
@@ -8,10 +8,12 @@ use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
/// Create an empty session for command-driven typing fixtures.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
/// Execute a registry command and report its id on a test failure.
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
@@ -39,10 +41,12 @@ fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
.collect()
}
/// Expected paragraph text and list membership for typing assertions.
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
(text.to_string(), level, level.is_some())
}
/// Create a session and enter its fixture through the actual typing commands.
fn typed(keys: &str) -> Session {
let mut s = s();
typ(&mut s, keys);
@@ -2569,3 +2573,42 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
/// A differently coloured final paragraph must remain visible to the shortcut beyond
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
#[cfg(not(target_os = "macos"))]
#[test]
fn highlight_toggle_checks_past_the_resolved_run_limit() {
use wordcraft_doc::para::Run;
use wordcraft_doc::props::{CharProps, Highlight};
for count in [10_000, 10_001, 10_002] {
for inherited in [false, true] {
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
.collect();
if inherited {
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
} else {
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
}
let original = doc.clone();
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.all", json!({}));
press(&mut s, "Mod+Alt+H").unwrap();
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
"count={count}, inherited={inherited}"
);
press(&mut s, "Mod+Alt+H").unwrap();
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
}
}
+2 -1
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** trivial (highlight shortcut toggle, #4085; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (full-target highlight check beyond 10,000 runs, #4085; previously trivial: highlight shortcut toggle, #4085; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
@@ -15,7 +15,9 @@ new session). If every character in the formatting target already has that colou
it applies explicit No Color; otherwise it applies the chosen colour to the target.
Choosing another colour applies it, and choosing No Color makes that the current choice.
Explicit `format.highlight {"color": ...}` remains an idempotent setter; omitting `color`
uses the toggle. Palette state is outside document undo; the formatting itself is undoable.
uses the toggle. Ribbon and mini-toolbar colour buttons remain setters, not toggles.
The highlight decision checks the complete target, including mixed colours after 10,000 runs;
formatting-state queries retain their existing sampling limit. Palette state is outside document undo; the formatting itself is undoable.
The existing selection/word/pending-caret targeting is unchanged (the separate request #4086
is not addressed).
@@ -357,6 +359,7 @@ again.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). |
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
| 2026-10-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text |
| 2026-10-21 | 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) |