From b75ea712e9993218a2f66bb95df7782a61370f3f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:53:53 +0200 Subject: [PATCH 1/2] Count line breaks as one char in Find/Replace Found while testing crates/engine/src/cmd/find.rs:107-121 (para_chars / para_matches; splice at :124) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/find.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 4991cd1f..f7a3bcb6 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -105,7 +105,7 @@ impl Opts { } fn para_chars(p: &Paragraph) -> Vec { - p.runs.iter().flat_map(|r| r.text.chars()).collect() + p.runs.iter().flat_map(|r| r.content().chars()).collect() } /// Matches that lie wholly in plain text runs (never inside a field, equation or break). @@ -113,7 +113,7 @@ fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { let mut spans = vec![]; let mut off = 0; for r in &p.runs { - let n = r.text.chars().count(); + let n = r.char_len(); spans.push((off, off + n, matches!(r.kind, RunKind::Text))); off += n; } @@ -125,7 +125,7 @@ fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { let mut off = 0; let mut placed = false; for r in &mut p.runs { - let chars: Vec = r.text.chars().collect(); + let chars: Vec = r.content().chars().collect(); let (s, e) = (off, off + chars.len()); off = e; if e <= a || s >= b { @@ -222,6 +222,34 @@ mod tests { assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); } + fn text_run(t: &str) -> Run { + Run { text: t.into(), props: RunProps::default(), kind: RunKind::Text } + } + + fn br() -> Run { + Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break } + } + + #[test] + fn a_match_never_spans_a_line_break() { + let p = Paragraph { runs: vec![text_run("a"), br(), text_run("b")], ..Default::default() }; + assert!(para_matches(&p, &opts("ab", true, false)).is_empty()); + } + + #[test] + fn offsets_count_a_line_break_as_one_char() { + let p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() }; + assert_eq!(para_matches(&p, &opts("cd", true, false)), vec![(3, 5)]); + } + + #[test] + fn splice_after_a_line_break_keeps_the_break() { + let mut p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() }; + splice(&mut p, 3, 5, "X"); + assert_eq!(p.text(), "ab\u{b}X"); + assert_eq!(p.runs[1].kind, RunKind::Break); + } + #[test] fn splice_across_runs_keeps_the_first_runs_format() { let bold = RunProps { bold: Some(true), ..Default::default() }; From 4ada0efac2eb26122f6f8ed327e7e1bcf2cf8667 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:13:31 -0400 Subject: [PATCH 2/2] Tests: Find and Replace across a line break through Session::execute Covers find offsets, no match spanning the break, replace and undo (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08c..80ec96c6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3495,6 +3495,37 @@ fn find_and_replace_across_shapes_and_notes() { assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); } +/// Find and Replace count a line break as one character, so offsets agree with the editor and no +/// match spans the break. +#[test] +fn find_and_replace_after_a_line_break() { + let mut s = session(); + let id = s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 80], "text": "ab"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id, "end": true})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{b}cd"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + let text = |s: &Session| { + s.doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|x| u64::from(x.id.0) == id) + .and_then(|x| x.text.as_ref()) + .map(|t| t.text()) + .unwrap() + }; + assert_eq!(text(&s), "ab\u{b}cd"); + assert!(s.execute("text.find", &json!({"text": "bc"})).unwrap().as_array().unwrap().is_empty(), "no match spans the break"); + let found = s.execute("text.find", &json!({"text": "cd"})).unwrap(); + assert_eq!((found[0]["start"].as_u64(), found[0]["end"].as_u64()), (Some(3), Some(5)), "{found}"); + s.execute("text.replace", &json!({"text": "cd", "with": "X"})).unwrap(); + assert_eq!(text(&s), "ab\u{b}X"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s), "ab\u{b}cd"); +} + #[test] fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { let mut s = session();