From 33f496a05b883affc2e1be952112bc543b9c3d83 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:23:35 +0200 Subject: [PATCH] Find/Change: Change takes the match Find Next selected; Selection is translated Change used to accept a selection only when Change All's matches (from the start of the target) included it. Find Next can select text those never produce: the rest of a Find Format stretch from a caret inside it, or an overlapping match ("aa" in "aaa" from the second a). Both commands now share one definition (`Search::found_edit`). Without a pattern, a selection inside one stretch is a match; with one, the pattern's match from the selection's start must be the selection, and a GREP replacement is expanded for that match (`Grep::replacement_at`). "Selection" has a row in the shared table (Spanish and Chinese fell back to English); the French entry moves there from `fr.rs`. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/grep.rs | 10 ++++ crates/engine/src/cmd/find.rs | 109 +++++++++++++++++++++++++++------- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 - 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 1d7e0e6a9..0b4d2eca3 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -378,6 +378,16 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// The first match starting at or after byte `start`, with its replacement: `template` + /// expanded as [`Self::replacements`] does. + pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result, String)>, GrepError> { + if start > text.len() || !text.is_char_boundary(start) { + return Ok(None); + } + let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) }; + Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps)))) + } + /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see /// [`translate_replacement`]). Stops after the first match when `first_only`. pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result, String)>, GrepError> { diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index e2efd0732..4e3591c7b 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -260,6 +260,33 @@ impl Search { } } + /// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story + /// `sid`) is what Find Next found there, so the two commands agree on what a match is. With + /// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest + /// of a stretch from inside it). With a pattern: the match it makes from the selection's start + /// must be the selection, overlapping matches Find Next can select included. + #[allow(clippy::too_many_arguments)] + fn found_edit( + &self, + d: &Document, + sid: StoryId, + r: &Range, + text: &str, + range: &Range, + rep: &str, + grep: bool, + ) -> Result, String)>> { + let spans = self.spans(d, sid, r); + let Some(re) = &self.re else { + let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end); + return Ok(inside.then(|| (range.clone(), rep.to_string()))); + }; + let found = + if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) }; + let found = found.map_err(|e| bad("find.change", e))?; + Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m)))) + } + /// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each /// GREP match, else `rep` itself. #[allow(clippy::too_many_arguments)] @@ -531,26 +558,28 @@ fn change(s: &mut Session, p: &Value) -> Result { let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); let context = scope(s, p)?; let targets = context.targets.clone(); - // Change: only the text Find Next found, the selection, while it is still a match. - let found = if bool_or(p, "found", false) { + // Change: only the text Find Next found, the selection, while it is still a match (as Find + // Next defines one), with its replacement. + let (found, found_out) = if bool_or(p, "found", false) { let st = s.doc()?; let selected = st.selection.text.filter(|t| t.cell.is_none() && !t.is_caret()).map(|t| (t.story, t.range())); - let hit = selected.filter(|(sid, range)| { - targets.iter().any(|(tid, r)| { - tid == sid - && r.start <= range.start - && range.end <= r.end - && target_text(&st.doc, *tid, r) - .and_then(|text| re.replacements(&st.doc, *tid, r, text, &rep, grep, false)) - .is_ok_and(|edits| edits.iter().any(|(m, _)| r.start + m.start == range.start && r.start + m.end == range.end)) + let hit = selected.and_then(|(sid, range)| { + targets.iter().find_map(|(tid, r)| { + if *tid != sid || range.start < r.start || r.end < range.end { + return None; + } + let text = target_text(&st.doc, *tid, r).ok()?; + let local = range.start - r.start..range.end - r.start; + let (m, out) = re.found_edit(&st.doc, *tid, r, text, &local, &rep, grep).ok()??; + Some(((sid, r.start + m.start..r.start + m.end), out)) }) }); match hit { - Some(hit) => Some(hit), + Some((hit, out)) => (Some(hit), Some(out)), None => return Ok(json!({"count": 0})), } } else { - None + (None, None) }; let (count, changed) = s.edit(|d, sel| { let styles = d.styles.clone(); @@ -563,14 +592,21 @@ fn change(s: &mut Session, p: &Value) -> Result { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let mut edits: Vec<(std::ops::Range, String)> = re - .replacements(d, sid, &r, text, &rep, grep, first_only)? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); - if let Some((_, range)) = &found { - edits.retain(|(m, _)| m == range); - } + let edits: Vec<(std::ops::Range, String)> = match (&found, &found_out) { + // Change: the found text alone, in the target it lies in. + (Some((_, range)), Some(out)) => { + if r.start <= range.start && range.end <= r.end { + vec![(range.clone(), out.clone())] + } else { + Vec::new() + } + } + _ => re + .replacements(d, sid, &r, text, &rep, grep, first_only)? + .into_iter() + .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) + .collect(), + }; let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -853,6 +889,39 @@ mod tests { assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15); } + /// Change accepts what Find Next selected even when Change All wouldn't match there: the + /// rest of a Find Format stretch from a caret inside it, and an overlapping match. + #[test] + fn change_takes_any_selection_find_next_made() { + // A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it. + let (mut s, sid) = scope_session("plain bold text"); + s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap(); + scope_select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap(); + scope_select(&mut s, sid, 8, 8); + let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10))); + let mut change = p.clone(); + change["found"] = json!(true); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest"); + // "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds. + for grep in [false, true] { + let (mut s, sid) = scope_session("aaa"); + scope_select(&mut s, sid, 1, 1); + let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") }; + let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"}); + let hit = s.execute("find.next", &p).unwrap(); + assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3))); + let mut found = p.clone(); + found["found"] = json!(true); + assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a" } else { "ab" }); + } + } + #[test] fn change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0bfb01e7e..08d6d57c6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1058,6 +1058,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Selection", ["Auswahl", "Sélection", "Selección", "選択範囲", "选区"]), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..c2e29dc20 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -160,7 +160,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Scripts", "Scripts"), ("Section Marker", "Marque de section"), ("Section Symbol", "Symbole de section"), - ("Selection", "Sélection"), ("Sentence case", "Majuscule en début de phrase"), ("Simulate Paper Color", "Simuler la couleur du papier"), ("Single Left Quotation Mark", "Guillemet simple ouvrant"),