Sonar: extract helpers from highlight check, revision span and outline remap

Behaviour-preserving extractions for S3776:
- engine cmd/format.rs target_has_highlight: span_matches checks a paragraph's mark and runs.
- engine cmd/review.rs resolve_sel: revision_span finds the tracked change at a caret.
- engine cmd/outline.rs after_command: remap_collapsed_headings keeps collapsed headings.
This commit is contained in:
Dylan M. Taylor
2026-10-26 08:46:03 -04:00
parent 6a2b520fa4
commit 7b0e079705
3 changed files with 119 additions and 104 deletions
+34 -26
View File
@@ -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 {
+46 -40
View File
@@ -460,6 +460,51 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
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<Option<u64>> = (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<u64, BTreeSet<usize>> = 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<usize>| 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<CmdResult> {
/// `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<Option<u64>> = (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<u64, BTreeSet<usize>> = 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<usize>| 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;
+39 -38
View File
@@ -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<std::ops::Range<usize>> {
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 {