diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 0586fee05..ca55b30ad 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -814,192 +814,6 @@ mod object_find_tests { use crate::Session; - #[test] - fn change_format_styles_the_found_text_and_keeps_it() { - // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with - // Change to empty, so the words stay. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); - assert_eq!(r["count"], 2); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!(story.text, text, "the words stay"); - for at in [19, 36] { - assert_eq!(&story.text[at..at + 4], "eggs"); - assert_eq!(style_at(&s, at), "eggs"); - } - assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); - assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); - // A paragraph style goes to the found text's paragraphs, with attributes over the text. - s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); - let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); - assert_eq!(story.format_after(25).over.underline, Some(true)); - assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); - // Change to and a Change Format: the new text is formatted. - s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); - assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); - // No such style: refused, nothing changed. - let before = s.doc().unwrap().doc.clone(); - assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); - assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); - assert_eq!(s.doc().unwrap().doc, before); - // With no Change Format, an empty Change to still deletes the found text. - s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); - assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); - } - - #[test] - fn a_find_format_finds_only_text_with_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) - .unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); - for (a, b) in [(6, 10), (12, 16)] { - s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); - s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); - } - s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); - let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { - let m = s.execute("find.find", &p).unwrap(); - m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() - }; - // "eggs" where it has the character style: the first only. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); - // Nothing in Find what: each stretch of text in the style. - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); - // A paragraph style, and resolved attributes. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); - // Find Next walks the stretches. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); - assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); - assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); - // Equivalent spellings the attributes accept find the same text: a snake_case key, and a - // nested number written as an integer (14 pt leading on the second paragraph). - s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); - s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); - for (a, b) in [ - (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), - (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), - ] { - let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); - let all = found(&mut s, a.clone()); - assert!(!all.is_empty(), "{a}"); - assert_eq!(found(&mut s, b.clone()), all, "{b}"); - for p in [&a, &b] { - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); - } - } - // A null attribute asks for nothing. - assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); - // With a Change Format: the text in the style goes back to [None], underlined. - let r = s - .execute( - "find.change", - &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), - ) - .unwrap(); - assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); - assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); - assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); - // Unknown styles and attributes are refused; an empty search needs a Find Format. - assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); - assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); - assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); - // Malformed fields are refused, not searched without. - for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { - assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); - } - assert!(s.execute("find.find", &json!({"find": ""})).is_err()); - } - - /// Change and Change/Find: only the text Find Next found changes; it stays selected and the - /// search goes on after it in its domain (here the original selection). A selection that - /// isn't a match changes nothing and makes no undo step. - #[test] - fn change_changes_the_found_text_and_the_search_goes_on() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "change": "blue", "scope": "selection"}); - let mut change = p.clone(); - change["found"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); - assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); - let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone(); - assert_eq!(text(&s), "blue red | red"); - assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected"); - scope_hit(&mut s, &p, sid, 5, 8); - s.execute("find.change", &change).unwrap(); - assert_eq!(text(&s), "blue blue | red"); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched"); - // Not a match: nothing changes, no undo step. - scope_select(&mut s, sid, 0, 4); - let history = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - // A Change Format with nothing in Change to formats the found text only. - scope_select(&mut s, sid, 0, 0); - let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}}); - let hit = s.execute("find.next", &fmt).unwrap(); - assert_eq!(hit["start"], 12); - let mut found = fmt.clone(); - found["found"] = json!(true); - s.execute("find.change", &found).unwrap(); - assert_eq!(text(&s), "blue blue | red"); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true)); - 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 find_and_change_objects_by_attributes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index ae8742702..8a679269e 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -768,3 +768,189 @@ fn find_scope_revert_releases_context_with_same_document_uid() { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); } + +#[test] +fn change_format_styles_the_found_text_and_keeps_it() { + // The course's chapter 22: every lowercase "eggs" gets the character style "eggs", with + // Change to empty, so the words stay. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold", "fill": "C=15 M=100 Y=100 K=0"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("find.change", &json!({"find": "eggs", "change": "", "caseSensitive": true, "charStyle": "eggs"})).unwrap(); + assert_eq!(r["count"], 2); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let style_at = |s: &Session, at: usize| s.doc().unwrap().doc.story(sid).unwrap().format_after(at).style.clone(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(story.text, text, "the words stay"); + for at in [19, 36] { + assert_eq!(&story.text[at..at + 4], "eggs"); + assert_eq!(style_at(&s, at), "eggs"); + } + assert_eq!(style_at(&s, 8), designcraft_doc::NO_CHAR_STYLE, "case sensitive: not \"Eggs\""); + assert_eq!(style_at(&s, 25), designcraft_doc::NO_CHAR_STYLE, "only the found text"); + // A paragraph style goes to the found text's paragraphs, with attributes over the text. + s.execute("find.change", &json!({"find": "cook", "change": "", "paraStyle": "Method", "attrs": {"underline": true}})).unwrap(); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story.paras[0].style.as_str(), story.paras[1].style.as_str()), (designcraft_doc::BASIC_PARAGRAPH, "Method")); + assert_eq!(story.format_after(25).over.underline, Some(true)); + assert_eq!(story.format_after(19).style, "eggs", "the other text keeps its character style"); + // Change to and a Change Format: the new text is formatted. + s.execute("find.change", &json!({"find": "set", "change": "firm", "charStyle": "eggs"})).unwrap(); + assert_eq!(style_at(&s, s.doc().unwrap().doc.story(sid).unwrap().text.find("firm").unwrap()), "eggs"); + // No such style: refused, nothing changed. + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "charStyle": "Nope"})).is_err()); + assert!(s.execute("find.change", &json!({"find": "eggs", "change": "", "paraStyle": "Nope"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + // With no Change Format, an empty Change to still deletes the found text. + s.execute("find.change", &json!({"find": "Break ", "change": ""})).unwrap(); + assert!(!s.doc().unwrap().doc.story(sid).unwrap().text.contains("Break")); +} + +#[test] +fn a_find_format_finds_only_text_with_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Break eggs; cook eggs.\nEggs and eggs"})) + .unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.character.create", &json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + for (a, b) in [(6, 10), (12, 16)] { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + s.execute("style.character.apply", &json!({"name": "eggs"})).unwrap(); + } + s.execute("text.select", &json!({"story": sid, "anchor": 25, "focus": 25})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Method"})).unwrap(); + let found = |s: &mut Session, p: serde_json::Value| -> Vec<(u64, u64)> { + let m = s.execute("find.find", &p).unwrap(); + m.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap(), m["end"].as_u64().unwrap())).collect() + }; + // "eggs" where it has the character style: the first only. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs"}})), [(6, 10)]); + // Nothing in Find what: each stretch of text in the style. + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})), [(6, 10), (12, 16)]); + // A paragraph style, and resolved attributes. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"paraStyle": "Method"}})), [(23, 27), (32, 36)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"fontStyle": "Bold"}}})), [(6, 10), (12, 16)]); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"size": 12}}})), [(0, 22), (23, 36)], "each paragraph apart"); + // Find Next walks the stretches. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let next = json!({"find": "", "findFormat": {"charStyle": "eggs"}}); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 6); + assert_eq!(s.execute("find.next", &next).unwrap()["start"], 12); + // Equivalent spellings the attributes accept find the same text: a snake_case key, and a + // nested number written as an integer (14 pt leading on the second paragraph). + s.execute("text.select", &json!({"story": sid, "anchor": 23, "focus": 36})).unwrap(); + s.execute("type.char", &json!({"leading": {"kind": "points", "value": 14.0}})).unwrap(); + for (a, b) in [ + (json!({"fontStyle": "Bold"}), json!({"font_style": "Bold"})), + (json!({"leading": {"kind": "points", "value": 14.0}}), json!({"leading": {"kind": "points", "value": 14}})), + ] { + let (a, b) = (json!({"find": "", "findFormat": {"chars": a}}), json!({"find": "", "findFormat": {"chars": b}})); + let all = found(&mut s, a.clone()); + assert!(!all.is_empty(), "{a}"); + assert_eq!(found(&mut s, b.clone()), all, "{b}"); + for p in [&a, &b] { + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert_eq!(s.execute("find.next", p).unwrap()["start"].as_u64(), Some(all[0].0), "{p}"); + } + } + // A null attribute asks for nothing. + assert_eq!(found(&mut s, json!({"find": "eggs", "findFormat": {"charStyle": "eggs", "chars": {"size": null}}})), [(6, 10)]); + // With a Change Format: the text in the style goes back to [None], underlined. + let r = s + .execute( + "find.change", + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2, "a snake_case attribute changes the same text"); + assert!(found(&mut s, json!({"find": "", "findFormat": {"charStyle": "eggs"}})).is_empty()); + assert_eq!(found(&mut s, json!({"find": "", "findFormat": {"chars": {"underline": true}}})), [(6, 10), (12, 16)]); + // Unknown styles and attributes are refused; an empty search needs a Find Format. + assert!(s.execute("find.find", &json!({"find": "", "findFormat": {"charStyle": "Nope"}})).is_err()); + assert!(s.execute("find.next", &json!({"find": "eggs", "findFormat": {"paraStyle": "Nope"}})).is_err()); + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": {"chars": {"bogus": 1}}})).is_err()); + // Malformed fields are refused, not searched without. + for f in [json!({"chars": [1]}), json!({"chars": 12}), json!({"charStyle": 3}), json!({"paraStyle": ["Body"]})] { + assert!(s.execute("find.find", &json!({"find": "eggs", "findFormat": f})).is_err(), "{f}"); + } + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); +} + +/// Change and Change/Find: only the text Find Next found changes; it stays selected and the +/// search goes on after it in its domain (here the original selection). A selection that +/// isn't a match changes nothing and makes no undo step. +#[test] +fn change_changes_the_found_text_and_the_search_goes_on() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let mut change = p.clone(); + change["found"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1); + let text = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(text(&s), "blue red | red"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..4, "the changed text stays selected"); + scope_hit(&mut s, &p, sid, 5, 8); + s.execute("find.change", &change).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "the last red is outside the selection searched"); + // Not a match: nothing changes, no undo step. + scope_select(&mut s, sid, 0, 4); + let history = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "found": true})).unwrap()["count"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + // A Change Format with nothing in Change to formats the found text only. + scope_select(&mut s, sid, 0, 0); + let fmt = json!({"find": "red", "change": "", "attrs": {"underline": true}}); + let hit = s.execute("find.next", &fmt).unwrap(); + assert_eq!(hit["start"], 12); + let mut found = fmt.clone(); + found["found"] = json!(true); + s.execute("find.change", &found).unwrap(); + assert_eq!(text(&s), "blue blue | red"); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(12).over.underline, Some(true)); + 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" }); + } +} diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index bccb8f6c2..bb70a8cfc 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -328,7 +328,7 @@ fn apply_para_in_story( f.chars.retain_differences(style_chars); } }); - for (pi, range) in st.paras_touching(r.clone()).into_iter().filter(|(pi, _)| (first..=last).contains(pi)) { + for (pi, range) in st.para_ranges().into_iter().enumerate().filter(|(pi, _)| (first..=last).contains(pi)) { let Some(pf) = st.paras.get(pi) else { continue }; let (_, base) = styles.resolve_para(pf); let end = range.end.saturating_add(1).min(st.len());