mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
#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:
+3
-1
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user