diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 15c6f9bb9..1cccd5e23 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -401,19 +401,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool { 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 || (a.off == 0 && b.off == p.len()) { - 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; - } - } + let mark = a == b || (a.off == 0 && b.off == p.len()); + if !span_matches(p, mark, a.off, b.off, &matches, &mut seen) { + return false; } } if seen { @@ -426,19 +416,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool { let from = if path == a.path { a.off } else { 0 }; let to = if path == b.path { b.off } else { p.len() }; // Match format_range: fully selected paragraphs include their mark. - if to == p.len() && (from == 0 || path != a.path) { - seen = true; - if !matches(p.props.style.as_deref(), &p.mark) { - return false; - } - } - 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; - } - } + let mark = to == p.len() && (from == 0 || path != a.path); + if !span_matches(p, mark, from, to, &matches, &mut seen) { + return false; } } } @@ -446,6 +426,34 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool { seen || resolved(s).iter().all(|r| r.highlight == h.rgb()) } +/// Whether the paragraph's mark (when `mark`) and the runs overlapping `from..to` all match +/// `matches`; `seen` records that something was checked. +fn span_matches( + p: &wordcraft_doc::Paragraph, + mark: bool, + from: usize, + to: usize, + matches: &impl Fn(Option<&str>, &CharProps) -> bool, + seen: &mut bool, +) -> bool { + let style = p.props.style.as_deref(); + if mark { + *seen = true; + if !matches(style, &p.mark) { + return false; + } + } + for (r, c) in p.run_ranges() { + if r.end > from && r.start < to { + *seen = true; + if !matches(style, c) { + return false; + } + } + } + true +} + /// 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 { diff --git a/crates/engine/src/cmd/outline.rs b/crates/engine/src/cmd/outline.rs index 0cf5727cb..571776b16 100644 --- a/crates/engine/src/cmd/outline.rs +++ b/crates/engine/src/cmd/outline.rs @@ -460,6 +460,51 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option { Some(sel_result(s)) } +/// Keeps collapsed headings collapsed after an edit: each one moves to the nearest heading with the +/// same text that is still free. +fn remap_collapsed_headings(s: &mut Session) { + let hashes: Vec> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect(); + let levels = ol::levels(&s.doc); + // The headings not yet taken, by text hash. + let mut by_hash: HashMap> = HashMap::new(); + for (j, h) in hashes.iter().enumerate() { + if let Some(h) = h + && levels.get(j).is_some_and(|l| *l < BODY) + { + by_hash.entry(*h).or_default().insert(j); + } + } + let mut used = BTreeSet::new(); + let mut out = Vec::new(); + for (i, h) in std::mem::take(&mut s.view.outline.collapsed) { + let free = |j: usize, used: &BTreeSet| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j); + let same = |j: usize| hashes.get(j).copied().flatten() == Some(h); + let found = if free(i, &used) && same(i) { + Some(i) + } else { + // The nearest free heading with the same text (the earlier one on a tie). + let nearest = by_hash.get(&h).and_then(|set| { + let below = set.range(..=i).next_back().copied(); + let above = set.range(i..).next().copied(); + match (below, above) { + (Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }), + (b, a) => b.or(a), + } + }); + nearest.or_else(|| free(i, &used).then_some(i)) + }; + if let Some(j) = found { + used.insert(j); + if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) { + set.remove(&j); + } + out.push((j, hashes.get(j).copied().flatten().unwrap_or(h))); + } + } + out.sort_unstable(); + s.view.outline.collapsed = out; +} + /// After every command: collapsed headings are found again where edits moved them (by their /// text; one whose text changed in place stays collapsed), and in Outline view a caret the /// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to @@ -467,46 +512,7 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option { /// `changed` says whether the document changed (its revision moved); if not, nothing moved. pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) { if changed && !s.view.outline.collapsed.is_empty() { - let hashes: Vec> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect(); - let levels = ol::levels(&s.doc); - // The headings not yet taken, by text hash. - let mut by_hash: HashMap> = HashMap::new(); - for (j, h) in hashes.iter().enumerate() { - if let Some(h) = h - && levels.get(j).is_some_and(|l| *l < BODY) - { - by_hash.entry(*h).or_default().insert(j); - } - } - let mut used = BTreeSet::new(); - let mut out = Vec::new(); - for (i, h) in std::mem::take(&mut s.view.outline.collapsed) { - let free = |j: usize, used: &BTreeSet| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j); - let same = |j: usize| hashes.get(j).copied().flatten() == Some(h); - let found = if free(i, &used) && same(i) { - Some(i) - } else { - // The nearest free heading with the same text (the earlier one on a tie). - let nearest = by_hash.get(&h).and_then(|set| { - let below = set.range(..=i).next_back().copied(); - let above = set.range(i..).next().copied(); - match (below, above) { - (Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }), - (b, a) => b.or(a), - } - }); - nearest.or_else(|| free(i, &used).then_some(i)) - }; - if let Some(j) = found { - used.insert(j); - if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) { - set.remove(&j); - } - out.push((j, hashes.get(j).copied().flatten().unwrap_or(h))); - } - } - out.sort_unstable(); - s.view.outline.collapsed = out; + remap_collapsed_headings(s); } if s.view.mode != ViewMode::Outline || !s.sel.is_collapsed() || s.sel.focus.story != StoryRef::Body { return; diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 9fcaa98a4..5af39f7a2 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -606,6 +606,44 @@ fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult { resolve_sel(s, accept, p::bool(v, "next") == Some(true)) } +/// The span of the tracked change around `off` in `p`: its run, joined across the comment anchors +/// that split one revision's text into several runs. +fn revision_span(p: &Paragraph, off: usize) -> Option> { + let runs: Vec<_> = p.run_ranges().collect(); + let i = runs.iter().position(|(r, c)| r.start <= off && off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?; + let (mut start, mut end) = (runs[i].0.start, runs[i].0.end); + let key = (runs[i].1.ins, runs[i].1.del); + if key == (None, None) { + return Some(start..end); + } + // Comment anchors split a revision's text into several runs; the revision goes on past them. + let is_anchor = |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. })); + let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key; + let mut k = i; + while k > 0 { + if same(k - 1) { + k -= 1; + } else if is_anchor(k - 1) && k > 1 && same(k - 2) { + k -= 2; + } else { + break; + } + start = runs[k].0.start; + } + let mut k = i; + while k + 1 < runs.len() { + if same(k + 1) { + k += 1; + } else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) { + k += 2; + } else { + break; + } + end = runs[k].0.end; + } + Some(start..end) +} + fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult { let (a, b) = s.sel.ordered(); // A caret right before a tracked paragraph mark (and no tracked text around it): that mark. @@ -616,44 +654,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult { let (a, b) = if a == b { // The change at the caret: the run around it, else the paragraph mark right after it, // else the paragraph's formatting, else the table cell's. - let found = s.doc.para_at(&a).and_then(|p| { - let runs: Vec<_> = p.run_ranges().collect(); - let i = runs - .iter() - .position(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?; - let (mut start, mut end) = (runs[i].0.start, runs[i].0.end); - let key = (runs[i].1.ins, runs[i].1.del); - if key == (None, None) { - return Some(start..end); - } - // Comment anchors split a revision's text into several runs; the revision goes on past them. - let is_anchor = - |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. })); - let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key; - let mut k = i; - while k > 0 { - if same(k - 1) { - k -= 1; - } else if is_anchor(k - 1) && k > 1 && same(k - 2) { - k -= 2; - } else { - break; - } - start = runs[k].0.start; - } - let mut k = i; - while k + 1 < runs.len() { - if same(k + 1) { - k += 1; - } else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) { - k += 2; - } else { - break; - } - end = runs[k].0.end; - } - Some(start..end) - }); + let found = s.doc.para_at(&a).and_then(|p| revision_span(p, a.off)); let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some()); let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some()); match found {