Find and replace text in Edit text mode: a Replace row in the Find box, Replace all as one undo step (#2467)

This commit is contained in:
regendorf-ux authored and GitHub committed 2026-11-10 15:19:55 +07:00
1 parent 2b46f8bf8c
commit b7cfbd3980
32 files changed
+1569 -39

No files matched your search

+9
View File
@@ -93,6 +93,15 @@ pub fn fold_case(s: &str) -> String {
out
}
/// Byte ranges in `text` where `needle` occurs, with the find bar's rules ([`PageText::find_opts`]):
/// case folded unless `case_sensitive`, any run of whitespace matching one space, and with
/// `whole_words` only where no letter or digit adjoins the match. At most `limit` ranges, in order
/// and not overlapping. Find and replace matches a paragraph's text with it, so what the find bar
/// highlights is what gets replaced.
pub fn find_in_text(text: &str, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec<std::ops::Range<usize>> {
search::find_in_text(text, needle, case_sensitive, whole_words, limit)
}
/// The text of one page, in content order, with line structure.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PageText {
+53 -10
View File
@@ -50,16 +50,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
let texts: Vec<String> = page.glyphs.iter().map(|g| fold(&g.text)).collect();
let divided: Vec<bool> = (0..texts.len()).map(|i| divides(page, &texts, i)).collect();
let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] };
// KMP reuses a matched prefix instead of comparing a long query again at every position.
// The table is bounded by the page's already materialized character count.
let mut prefix = vec![0; needle.len()];
let mut matched = 0;
for (i, &c) in needle.iter().enumerate().skip(1) {
matched = advance(&needle, &prefix, matched, c);
if let Some(slot) = prefix.get_mut(i) {
*slot = matched;
}
}
let prefix = prefix_table(&needle);
let mut hits: Vec<(Range<usize>, usize)> = Vec::new();
for (r, &reading) in readings.iter().enumerate() {
let (chars, zero_width_space) = characters(page, &texts, &divided, reading);
@@ -92,6 +83,58 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
kept
}
/// [`super::find_in_text`]: the same search over a string (find and replace matches a paragraph's
/// text with it), as the byte ranges of the characters each hit covers. Hyphenated line ends do
/// not arise in a string, so there is the one reading.
pub(super) fn find_in_text(text: &str, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec<Range<usize>> {
if limit == 0 {
return Vec::new();
}
let fold = |s: &str| if sensitive { ligature_letters(s.to_owned()) } else { fold_case(&ligature_letters(s.to_owned())) };
let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect();
let needle: Vec<char> = query.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
if needle.is_empty() {
return Vec::new();
}
// Each character is owned by its index in the string, turned back into bytes at the end.
let mut chars: Vec<(char, usize)> = Vec::new();
let mut zero_width_space: Vec<bool> = Vec::new();
let mut pending_zero_width_space = false;
for (index, (_, c)) in text.char_indices().enumerate() {
for c in fold(c.encode_utf8(&mut [0; 4])).chars() {
if is_invisible(c) {
pending_zero_width_space |= c == '\u{200B}';
continue;
}
let c = if c.is_whitespace() { ' ' } else { c };
if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') {
chars.push((c, index));
zero_width_space.push(pending_zero_width_space && c != ' ');
pending_zero_width_space = false;
}
}
}
let starts: Vec<usize> = text.char_indices().map(|(at, _)| at).chain(std::iter::once(text.len())).collect();
search(&chars, &zero_width_space, &needle, &prefix_table(&needle), whole_words, limit)
.into_iter()
.filter_map(|hit| Some(*starts.get(hit.start)?..*starts.get(hit.end)?))
.collect()
}
/// KMP reuses a matched prefix instead of comparing a long query again at every position. The
/// table is bounded by the page's already materialized character count.
fn prefix_table(needle: &[char]) -> Vec<usize> {
let mut prefix = vec![0; needle.len()];
let mut matched = 0;
for (i, &c) in needle.iter().enumerate().skip(1) {
matched = advance(needle, &prefix, matched, c);
if let Some(slot) = prefix.get_mut(i) {
*slot = matched;
}
}
prefix
}
/// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next
/// line starts with a letter. `texts` are the glyphs' folded texts; the invisible characters find
/// skips are passed over on both sides.
+27
View File
@@ -1123,6 +1123,33 @@ fn turkish_dotted_and_dotless_i_match_in_any_case() {
assert_eq!(lower.find("KIŞ"), vec![13..16]);
}
/// Find and replace matches a paragraph's text with the find bar's own rules: the same
/// matches as the page search, as byte ranges of the text (multi-byte letters included).
#[test]
fn text_search_agrees_with_the_page_search() {
let mut v = Vec::new();
word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0);
word(&mut v, "cat", 80.0, 10.0, 7.0);
word(&mut v, "Cats", 110.0, 10.0, 7.0);
word(&mut v, "cat.", 150.0, 10.0, 7.0);
let page = layout(v);
let text = page.plain_text();
assert_eq!(text, "İSTANBUL cat Cats cat.");
for (needle, case, whole) in
[("cat", false, false), ("cat", true, false), ("cat", false, true), ("istanbul cat", false, false), ("CATS", true, true)]
{
let glyphs: Vec<String> = page.find_opts(needle, case, whole).into_iter().map(|r| page.text_of(r)).collect();
let bytes: Vec<&str> = find_in_text(&text, needle, case, whole, usize::MAX).into_iter().filter_map(|r| text.get(r)).collect();
assert_eq!(glyphs, bytes, "{needle:?} case {case} whole {whole}");
}
assert_eq!(find_in_text(&text, "cat", false, true, usize::MAX), vec![10..13, 19..22]);
assert_eq!(find_in_text(&text, "istanbul", false, false, usize::MAX), vec![0..9], "İ is two bytes");
assert_eq!(find_in_text("a\n b", "a b", false, false, usize::MAX), vec![0..5], "any whitespace run is one space");
assert_eq!(find_in_text("cat cat", "cat", false, false, 1), vec![0..3]);
assert!(find_in_text("cat", " ", false, false, usize::MAX).is_empty());
assert!(find_in_text("cat", "cat", false, false, 0).is_empty());
}
#[test]
fn greek_sigma_forms_match_without_changing_exact_search_or_glyph_ranges() {
let mut v = Vec::new();