From 64242eb5bfbf2b787b8668839dfd600cbcd9de2d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:57:02 +0200 Subject: [PATCH 01/12] Find/Change: Find Format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find.find, find.next and find.change take findFormat {charStyle?, paraStyle?, chars?}: only matches whose text has the format all through are found, and with an empty find, each stretch of text with it (the longest runs, one per paragraph). Resolved attributes are compared to within rounding; unknown styles and attributes are refused. A changed Find Format restarts Find Next. The dialog has InDesign's Find Format box above Change Format, with its ⓘ beside Find what; Find Format Settings is the Change Format Settings dialog opened for finding. Found in the Learn Skills Daily course, chapter 22. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/find.rs | 258 ++++++++++++++++++++++++++++--- crates/ui-egui/src/dialogs.rs | 163 +++++++++++++++---- crates/ui-egui/src/i18n.rs | 25 +++ crates/ui-egui/src/i18n/ar.rs | 7 + crates/ui-egui/src/i18n/cs.rs | 7 + crates/ui-egui/src/i18n/it.rs | 7 + crates/ui-egui/src/i18n/ka.rs | 7 + crates/ui-egui/src/i18n/pl.rs | 7 + crates/ui-egui/src/i18n/pt_br.rs | 7 + crates/ui-egui/src/i18n/ru.rs | 7 + crates/ui-egui/src/i18n/uk.rs | 7 + 12 files changed, 450 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fa44d189e..466b7f1e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Find/Change has InDesign's Find Format: Specify Attributes opens Find Format Settings (the same Style Options and character sections as Change Format Settings), and only text with the format is found. With nothing in Find what, each stretch of text with the format is found, so Change All can restyle every word in a character style. `find.find`, `find.next` and `find.change` take `findFormat {charStyle?, paraStyle?, chars?}`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-21** · Find/Change has InDesign's Change Format. Specify Attributes opens Change Format Settings: Style Options (the character and paragraph style to apply, [Any Style] for none), then Basic and Advanced Character Formats, Character Color, OpenType Features, Underline and Strikethrough Options. The box shows the format ("Character Style: eggs"), and Change All gives it to the changed text. With a Change Format and nothing in Change to, the found text stays and is only formatted, as InDesign's (an ⓘ beside Change to says so). `find.change` takes `charStyle` and `paraStyle`. Found in the Learn Skills Daily course, chapter 22. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index d6a43855d..eb821b0ae 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,12 +1,12 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, -//! optional formatting in the change, scoped to the document, a story or the selection. +//! a Find Format and a Change Format, scoped to the document, a story or the selection. use std::ops::Range; use std::sync::{Arc, Weak}; use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement}; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{Document, Selection, StoryId, TextSel}; +use designcraft_doc::{Document, Selection, Story, StoryId, Styles, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; @@ -61,18 +61,18 @@ pub fn specs() -> Vec { } ), cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None, - "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", + "{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]", has_doc, find), cmd!( "find.change", "Change All", ["Edit", "Find/Change"], None, - "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted", + "{find, change, grep?, caseSensitive?, wholeWord?, scope?, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), first?: bool (change only the first match), attrs?: {character attributes applied to changed text}, charStyle?: a character style applied to it, paraStyle?: a paragraph style applied to its paragraphs} → {count} — attrs, charStyle and paraStyle are the Change Format: with one and an empty change, the found text stays and is only formatted; with no find, change is literal text", has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?, findFormat?: {charStyle?, paraStyle?, chars?: resolved character attributes} (Find Format: only text with it all through; with an empty find, each stretch of text with it), change?|first?|attrs?|charStyle?|paraStyle?: ignored (find.change takes them; one request serves all three Find/Change commands)} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -108,6 +108,170 @@ fn build(p: &Value) -> Result { re.map_err(|e| bad("find", e)) } +/// Find/Change's Find Format: the text a search finds must have it all through. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct FindFormat { + char_style: Option, + para_style: Option, + /// Resolved character attributes, as `type.selectionAttrs` reports them. + chars: serde_json::Map, +} + +impl FindFormat { + fn from_params(p: &Value) -> Result> { + let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; + let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; + let name = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); + let chars = o.get("chars").and_then(Value::as_object).cloned().unwrap_or_default(); + // Only attributes text has. + let mut known = designcraft_doc::CharAttrs::default(); + for (k, v) in &chars { + known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; + } + let f = Self { char_style: name("charStyle"), para_style: name("paraStyle"), chars }; + Ok((f != Self::default()).then_some(f)) + } + + /// Styles the document doesn't have are refused, as `find.change` refuses them. + fn check(&self, styles: &Styles) -> Result<()> { + if let Some(n) = self.char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find", format!("no character style `{n}`"))); + } + if let Some(n) = self.para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find", format!("no paragraph style `{n}`"))); + } + Ok(()) + } + + /// The text in `r` of `story` that has the format, relative to `r.start`: the longest + /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). + fn spans(&self, styles: &Styles, story: &Story, r: &Range) -> Vec> { + let mut out: Vec> = Vec::new(); + for (pi, pr) in story.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + let Some(pf) = story.paras.get(pi) else { continue }; + if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { + continue; + } + let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); + for (run, cf) in story.runs() { + let (x, y) = (run.start.max(a), run.end.min(b)); + if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { + continue; + } + if let Some(base) = &base { + let cp = serde_json::to_value(styles.resolve_char(base, cf)).unwrap_or_default(); + if !self.chars.iter().all(|(k, v)| cp.get(k).is_some_and(|c| same_value(c, v))) { + continue; + } + } + match out.last_mut() { + Some(last) if last.end == x - r.start => last.end = y - r.start, + _ => out.push(x - r.start..y - r.start), + } + } + } + out + } +} + +/// Attribute values alike: numbers to within rounding, the rest exactly. +fn same_value(a: &Value, b: &Value) -> bool { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + } +} + +/// Whether match `m` lies in one of `spans`. +fn within(spans: &[Range], m: &Range) -> bool { + spans.iter().any(|s| s.start <= m.start && m.end <= s.end) +} + +/// What a search finds: the pattern's matches (with a Find Format, those whose text has it all +/// through), or, with no pattern, the stretches of text with the Find Format. +struct Search { + re: Option, + format: Option, +} + +/// The search of a Find/Change request: an empty `find` needs a Find Format. +fn search(p: &Value) -> Result { + let format = FindFormat::from_params(p)?; + if format.is_some() && str_param(p, "find") == Some("") { + return Ok(Search { re: None, format }); + } + Ok(Search { re: Some(build(p)?), format }) +} + +impl Search { + fn check(&self, styles: &Styles) -> Result<()> { + self.format.as_ref().map_or(Ok(()), |f| f.check(styles)) + } + + /// Where the Find Format holds in target `r` of story `sid` (`None`: no Find Format). + fn spans(&self, d: &Document, sid: StoryId, r: &Range) -> Option>> { + let f = self.format.as_ref()?; + Some(d.story(sid).map(|story| f.spans(&d.styles, story, r)).unwrap_or_default()) + } + + /// Up to `limit` matches in `text`, target `r` of story `sid` (offsets in `text`). + fn find_all(&self, d: &Document, sid: StoryId, r: &Range, text: &str, limit: usize) -> Result>> { + let spans = self.spans(d, sid, r); + let Some(re) = &self.re else { return Ok(spans.unwrap_or_default().into_iter().take(limit).collect()) }; + let Some(spans) = spans else { return re.find_all(text, limit).map_err(|e| bad("find", e)) }; + Ok(re.find_all(text, usize::MAX).map_err(|e| bad("find", e))?.into_iter().filter(|m| within(&spans, m)).take(limit).collect()) + } + + /// The first match in `text` (target `r` of story `sid`) at or after byte `from`. + fn find_at(&self, d: &Document, sid: StoryId, r: &Range, text: &str, from: usize) -> Result>> { + let spans = self.spans(d, sid, r); + let Some(re) = &self.re else { + // From inside a stretch, the rest of it. + return Ok(spans.unwrap_or_default().into_iter().find(|s| s.end > from).map(|s| s.start.max(from)..s.end)); + }; + let mut at = from; + loop { + let Some(m) = re.find_at(text, at).map_err(|e| bad("find.next", e))? else { return Ok(None) }; + if spans.as_ref().is_none_or(|s| within(s, &m)) { + return Ok(Some(m)); + } + // On past it: its end, or a character on from an empty match. + at = if m.is_empty() { text.get(m.end..).and_then(|t| t.chars().next()).map_or(text.len() + 1, |c| m.end + c.len_utf8()) } else { m.end }; + if at > text.len() { + return Ok(None); + } + } + } + + /// 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)] + fn replacements( + &self, + d: &Document, + sid: StoryId, + r: &Range, + text: &str, + rep: &str, + grep: bool, + first_only: bool, + ) -> Result, String)>> { + let limit = if first_only { 1 } else { usize::MAX }; + match (&self.re, self.spans(d, sid, r)) { + (Some(re), None) if grep => re.replacements(text, rep, first_only).map_err(|e| bad("find.change", e)), + (Some(re), Some(spans)) if grep => Ok(re + .replacements(text, rep, false) + .map_err(|e| bad("find.change", e))? + .into_iter() + .filter(|(m, _)| within(&spans, m)) + .take(limit) + .collect()), + _ => Ok(self.find_all(d, sid, r, text, limit)?.into_iter().map(|m| (m, rep.to_string())).collect()), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] enum Domain { Document, @@ -140,6 +304,7 @@ struct Matcher { grep: bool, case_sensitive: bool, whole_word: bool, + format: Option, } impl Matcher { @@ -149,6 +314,7 @@ impl Matcher { grep: bool_or(p, "grep", false), case_sensitive: bool_or(p, "caseSensitive", false), whole_word: bool_or(p, "wholeWord", false), + format: FindFormat::from_params(p).ok().flatten(), } } } @@ -281,13 +447,14 @@ fn scope(s: &Session, p: &Value) -> Result { const MAX_MATCHES: usize = 10_000; fn find(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); for (sid, r) in &context.targets { let text = target_text(&st.doc, *sid, r)?; - for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? { + for m in re.find_all(&st.doc, *sid, r, text, MAX_MATCHES.saturating_sub(out.len()))? { out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)})); } } @@ -302,9 +469,11 @@ fn replacement(p: &Value) -> String { } fn change(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; - let grep = bool_or(p, "grep", false); - let rep = replacement(p); + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; + // With no pattern (a Find Format alone), the change is literal text. + let grep = bool_or(p, "grep", false) && re.re.is_some(); + let rep = if re.re.is_some() { replacement(p) } else { metachars(str_param(p, "change").unwrap_or("")) }; let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); let mut over = designcraft_doc::CharAttrs::default(); @@ -333,15 +502,11 @@ 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 edits: Vec<(std::ops::Range, String)> = if grep { - re.replacements(text, &rep, first_only) - } else { - re.find_all(text, if first_only { 1 } else { usize::MAX }).map(|ms| ms.into_iter().map(|m| (m, rep.clone())).collect()) - } - .map_err(|e| bad("find.change", e))? - .into_iter() - .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) - .collect(); + let 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(); let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -386,7 +551,8 @@ fn change(s: &mut Session, p: &Value) -> Result { } fn find_next(s: &mut Session, p: &Value) -> Result { - let re = build(p)?; + let re = search(p)?; + re.check(&s.doc()?.doc.styles)?; let mut context = scope(s, p)?; let st = s.doc()?; let mut hit = None; @@ -396,7 +562,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { if text.get(from..).is_none() { return Err(bad("find", "invalid UTF-8 search cursor")); } - if let Some(m) = re.find_at(text, from).map_err(|e| bad("find.next", e))? { + if let Some(m) = re.find_at(&st.doc, sid, &r, text, from)? { let (a, b) = (r.start + m.start, r.start + m.end); // Match find_iter/captures_iter: suppress an empty match adjacent to the // previous match's end, but keep adjacent nonempty matches. @@ -501,6 +667,56 @@ mod tests { 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); + // 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"}, "charStyle": "[None]", "attrs": {"underline": true}}), + ) + .unwrap(); + assert_eq!(r["count"], 2); + 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()); + assert!(s.execute("find.find", &json!({"find": ""})).is_err()); + } + #[test] fn change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6359a6c84..b66b6c168 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2281,9 +2281,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.label(d.s("status")); } else { - let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); + let (find_formatted, formatted) = (format_set(&d, FIND_FORMAT), format_set(&d, CHANGE_FORMAT)); egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + // InDesign's ⓘ: a Find Format narrows what is found. + if find_formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + ), + ); + } + }); text_field(ui, &mut d, "find", 260.0); ui.end_row(); ui.horizontal(|ui| { @@ -2320,7 +2332,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); }); - change_format_box(&app.ui.language, ui, &mut d); + format_box(&app.ui.language, ui, &mut d, true); + format_box(&app.ui.language, ui, &mut d, false); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); @@ -2887,7 +2900,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::new_document::preview(app, &mut d, ctx); } if d.id == "findChange" && result.is_none() && d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object) { - change_format_settings(app, ctx, &mut d); + format_settings(app, ctx, &mut d); } app.ui.dialog = Some(d); match result { @@ -3044,6 +3057,10 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.clone(); } + // Find Format: only text with it is found (with nothing in Find what, all text with it). + if let Some(f) = d.fields.get(FIND_FORMAT).and_then(Value::as_object).filter(|f| !f.is_empty()) { + p["findFormat"] = Value::Object(f.clone()); + } // Change Format: find.change applies it to the changed text (the other commands ignore it). if let Some(f) = d.fields.get(CHANGE_FORMAT) { for key in ["charStyle", "paraStyle"] { @@ -3058,14 +3075,21 @@ fn find_change_params(d: &Dialog) -> Value { p } -/// Find/Change's Change Format ({charStyle?, paraStyle?, chars?}), as Change Format Settings set -/// it; absent when there is none. +/// Find/Change's Find Format and Change Format ({charStyle?, paraStyle?, chars?}), as their +/// settings set them; absent when there is none. +const FIND_FORMAT: &str = "findFormat"; const CHANGE_FORMAT: &str = "changeFormat"; -/// The fields of Change Format Settings while it is open (its section, `charStyle`, `paraStyle` -/// and `c.`), until OK makes them the Change Format. +/// The fields of Find or Change Format Settings while it is open (`for`: find|change, its +/// section, `charStyle`, `paraStyle` and `c.`), until OK makes them the format. const FORMAT_EDIT: &str = "formatEdit"; -/// The sections of Change Format Settings: Style Options, then those of Character Style Options. +/// Whether the dialog has a Find (`FIND_FORMAT`) or Change Format. +fn format_set(d: &Dialog, key: &str) -> bool { + d.fields.get(key).and_then(Value::as_object).is_some_and(|f| !f.is_empty()) +} + +/// The sections of Find and Change Format Settings: Style Options, then those of Character Style +/// Options. const FORMAT_SECTIONS: &[(&str, &str)] = &[ ("style", "Style Options"), ("chars", "Basic Character Formats"), @@ -3084,8 +3108,8 @@ fn info_icon(ui: &mut egui::Ui, tip: &str) { resp.on_hover_text(tip); } -/// What the Change Format box shows: "Character Style: eggs + …". -fn change_format_summary(lang: &str, format: Option<&Value>) -> String { +/// What a Find or Change Format box shows: "Character Style: eggs + …". +fn format_summary(lang: &str, format: Option<&Value>) -> String { let Some(f) = format else { return String::new() }; let mut parts = Vec::new(); for (key, label) in [("charStyle", "Character Style:"), ("paraStyle", "Paragraph Style:")] { @@ -3099,23 +3123,30 @@ fn change_format_summary(lang: &str, format: Option<&Value>) -> String { parts.join(" + ") } -/// Find/Change's Change Format, as InDesign's: a box with what Change All also gives the changed -/// text, a button that opens Change Format Settings and one that clears it. -fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { +/// Find/Change's Find Format (`find`) or Change Format, as InDesign's: a box with what the found +/// text must have or what Change All also gives the changed text, a button that opens its +/// settings and one that clears it. +fn format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, find: bool) { + let (key, label, specify) = if find { + (FIND_FORMAT, "Find Format:", "Specify attributes to find") + } else { + (CHANGE_FORMAT, "Change Format:", "Specify attributes to change") + }; ui.add_space(6.0); - crate::rtl::label(ui, crate::i18n::tr(lang, "Change Format:")); + crate::rtl::label(ui, crate::i18n::tr(lang, label)); ui.horizontal_top(|ui| { let t = crate::theme::Tokens::get(ui.ctx()); egui::Frame::new().fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(6.0).show(ui, |ui| { ui.set_width(340.0); ui.set_min_height(40.0); - let summary = change_format_summary(lang, d.fields.get(CHANGE_FORMAT)); + let summary = format_summary(lang, d.fields.get(key)); ui.add(egui::Label::new(egui::RichText::new(summary).color(t.text)).wrap()); }); ui.vertical(|ui| { - if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, "Specify attributes to change")).clicked() { - let f = d.fields.get(CHANGE_FORMAT).cloned().unwrap_or_default(); + if crate::icons::button(ui, "find-format", 22.0, false, crate::i18n::tr(lang, specify)).clicked() { + let f = d.fields.get(key).cloned().unwrap_or_default(); let mut edit = Map::new(); + edit.insert("for".into(), json!(if find { "find" } else { "change" })); edit.insert("section".into(), json!("style")); for key in ["charStyle", "paraStyle"] { if let Some(v) = f.get(key) { @@ -3128,16 +3159,17 @@ fn change_format_box(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert(FORMAT_EDIT.into(), Value::Object(edit)); } if crate::icons::button(ui, "trash", 22.0, false, crate::i18n::tr(lang, "Clear specified attributes")).clicked() { - d.fields.remove(CHANGE_FORMAT); + d.fields.remove(key); } }); }); } -/// Find/Change › Change Format Settings, over Find/Change as InDesign's: Style Options (the -/// character and paragraph style to apply, [Any Style] for none), then the character sections of -/// Character Style Options with every field blank (left as it is). OK makes it the Change Format. -fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { +/// Find/Change › Find or Change Format Settings, over Find/Change as InDesign's: Style Options +/// (the character and paragraph style the found text has or the changed text gets, [Any Style] +/// for none), then the character sections of Character Style Options with every field blank (any +/// value, or left as it is). OK makes it the Find or Change Format. +fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { let lang = app.ui.language.clone(); let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { d.fields.remove(FORMAT_EDIT); @@ -3147,12 +3179,14 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial Dialog { id: "changeFormat".into(), fields: d.fields.get(FORMAT_EDIT).and_then(Value::as_object).cloned().unwrap_or_default(), unit: d.unit }; let base = json!({}); let cf = CharFields { base: &base, sparse: true }; + let find = fd.s("for") == "find"; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::new("change_format_settings"); + let host = egui::Id::new("format_settings"); crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let title_top = ui.cursor().top(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Change Format Settings")).font(semibold(16.0))); + let title = if find { "Find Format Settings" } else { "Change Format Settings" }; + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, title)).font(semibold(16.0))); let margin = egui::Frame::popup(ui.style()).inner_margin; let title_row = egui::Rect::from_min_max( egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), @@ -3212,10 +3246,11 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial if !chars.is_empty() { f.insert("chars".into(), Value::Object(chars)); } + let key = if find { FIND_FORMAT } else { CHANGE_FORMAT }; if f.is_empty() { - d.fields.remove(CHANGE_FORMAT); + d.fields.remove(key); } else { - d.fields.insert(CHANGE_FORMAT.into(), Value::Object(f)); + d.fields.insert(key.into(), Value::Object(f)); } } Some(false) => { @@ -3227,8 +3262,8 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial } } -/// Change Format Settings › Style Options: the character and paragraph style the changed text -/// gets ([Any Style]: as it is). +/// Find or Change Format Settings › Style Options: the character and paragraph style the found +/// text has or the changed text gets ([Any Style]: any, or as it is). fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: &designcraft_doc::Styles) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Options")).font(semibold(13.0))); ui.add_space(6.0); @@ -8553,8 +8588,8 @@ mod tests { [8, 19, 36].map(|at| story.format_after(at).style.clone()), [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] ); - // The trash clears it. - h.get_by_label("Clear specified attributes").click(); + // The trash clears it (the second: the first is the Find Format's). + h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); h.run_steps(3); assert_eq!(field(&h, CHANGE_FORMAT), None); assert!(h.query_by_label(TIP).is_none()); @@ -8571,8 +8606,72 @@ mod tests { (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true})) ); - assert_eq!(change_format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + // A Find Format goes with every search. + d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); + assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); + } + + /// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the + /// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in + /// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears + /// it. + #[test] + fn find_format_finds_only_text_with_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("find_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; + assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); + h.get_by_label("Specify attributes to find").click(); + h.run_steps(3); + assert!(h.query_by_label("Find Format Settings").is_some()); + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); + h.get_by_label("Count").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); + // Nothing in Find what: the text in the style. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); + h.run_steps(2); + h.get_by_label("Find Next").click(); + h.run_steps(3); + let sel = h.state().session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.range(), 6..10); + // The first trash is the Find Format's. + h.get_all_by_label("Clear specified attributes").next().unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, FIND_FORMAT), None); } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0e8b47103..fc7e906be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -821,6 +821,31 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Change All", ["Change All", "Tout remplacer", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Remplacer par :", "Change to:", "Change to:", "更改为:"]), ("Change Format:", ["Format ersetzen:", "Remplacer le format :", "Cambiar formato:", "置換形式 :", "更改格式:"]), + ("Find Format:", ["Format suchen:", "Rechercher le format :", "Buscar formato:", "検索形式 :", "查找格式:"]), + ( + "Find Format Settings", + ["Einstellungen für Format suchen", "Paramètres de recherche de format", "Ajustes de búsqueda de formato", "検索形式の設定", "查找格式设置"], + ), + ( + "Specify attributes to find", + [ + "Zu suchende Attribute festlegen", + "Indiquer les attributs à rechercher", + "Especificar atributos que buscar", + "検索する属性を指定", + "指定要查找的属性", + ], + ), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + [ + "Mit einem Suchformat wird nur Text gefunden, der es hat; bei leerem Feld „Suchen nach“ jeder Text, der es hat", + "Avec un format de recherche, seul le texte qui l’a est trouvé ; si « Rechercher » est vide, tout le texte qui l’a", + "Con un formato de búsqueda solo se encuentra el texto que lo tiene; con «Buscar» vacío, todo el texto que lo tiene", + "検索形式があるときは、その書式のテキストだけが見つかります。検索文字列が空のときは、その書式のテキストすべてが見つかります", + "设置了查找格式时,只查找具有该格式的文本;“查找内容”为空时,查找所有具有该格式的文本", + ], + ), ( "Change Format Settings", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 33eff157d..9fa1ac6cb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Find Format:", "البحث عن تنسيق:"), + ("Find Format Settings", "إعدادات البحث عن تنسيق"), + ("Specify attributes to find", "تحديد السمات المراد البحث عنها"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "مع تنسيق للبحث، يُعثر فقط على النص الذي يحمله؛ وإذا كان حقل «البحث عن» فارغًا، فعلى كل نص يحمله", + ), ("Change Format:", "تغيير التنسيق:"), ("Change Format Settings", "إعدادات تغيير التنسيق"), ("Style Options", "خيارات النمط"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 633978380..03f84dfde 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Find Format:", "Hledat formát:"), + ("Find Format Settings", "Nastavení hledání formátu"), + ("Specify attributes to find", "Určit atributy k hledání"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "S formátem hledání se najde jen text, který ho má; s prázdným polem „Najít“ veškerý text, který ho má", + ), ("Change Format:", "Změnit formát:"), ("Change Format Settings", "Nastavení změny formátu"), ("Style Options", "Volby stylu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 101167ea2..7f4c96bdc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Find Format:", "Trova formato:"), + ("Find Format Settings", "Impostazioni di ricerca formato"), + ("Specify attributes to find", "Specifica gli attributi da trovare"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Con un formato di ricerca si trova solo il testo che lo ha; con «Trova» vuoto, tutto il testo che lo ha", + ), ("Change Format:", "Modifica formato:"), ("Change Format Settings", "Impostazioni di modifica formato"), ("Style Options", "Opzioni stile"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 357350579..bda369b7f 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Find Format:", "ფორმატის ძიება:"), + ("Find Format Settings", "ფორმატის ძიების პარამეტრები"), + ("Specify attributes to find", "საძიებო ატრიბუტების მითითება"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "ძიების ფორმატით მხოლოდ ის ტექსტი მოიძებნება, რომელსაც ის აქვს; ცარიელი „ძიება“ ველით — ყველა ასეთი ტექსტი", + ), ("Change Format:", "ფორმატის შეცვლა:"), ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), ("Style Options", "სტილის პარამეტრები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e13ee7de2..fc6bf7712 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Find Format:", "Znajdź format:"), + ("Find Format Settings", "Ustawienia wyszukiwania formatu"), + ("Specify attributes to find", "Określ atrybuty do znalezienia"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Przy formacie wyszukiwania znajdowany jest tylko tekst, który go ma; przy pustym polu „Znajdź” — cały taki tekst", + ), ("Change Format:", "Zmień format:"), ("Change Format Settings", "Ustawienia zmiany formatu"), ("Style Options", "Opcje stylu"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index c96ced85e..748926937 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Find Format:", "Localizar formato:"), + ("Find Format Settings", "Configurações de localização de formato"), + ("Specify attributes to find", "Especificar atributos a localizar"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "Com um formato de localização, só o texto que o tem é encontrado; com “Localizar” vazio, todo o texto que o tem", + ), ("Change Format:", "Alterar formato:"), ("Change Format Settings", "Configurações de alteração de formato"), ("Style Options", "Opções de estilo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 719a6d653..3ac6179c1 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Find Format:", "Найти формат:"), + ("Find Format Settings", "Параметры поиска формата"), + ("Specify attributes to find", "Задать атрибуты для поиска"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "С форматом поиска находится только текст с этим форматом; если поле «Найти» пусто — весь такой текст", + ), ("Change Format:", "Заменить формат:"), ("Change Format Settings", "Параметры замены формата"), ("Style Options", "Параметры стиля"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index ed57d814c..163f7ae67 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Find Format:", "Знайти формат:"), + ("Find Format Settings", "Параметри пошуку формату"), + ("Specify attributes to find", "Задати атрибути для пошуку"), + ( + "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it", + "З форматом пошуку знаходиться лише текст із цим форматом; якщо поле «Знайти» порожнє — увесь такий текст", + ), ("Change Format:", "Замінити формат:"), ("Change Format Settings", "Параметри заміни формату"), ("Style Options", "Параметри стилю"), From 0ecb63229cfad89fca8169a01fb93b07631469ac Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:17:07 +0200 Subject: [PATCH 02/12] Find Format: match on the attributes as validated, numbers at any depth, one pass over the runs - The Find Format's character attributes are read through `CharAttrs::set_json` and matched as the document writes them (camelCase keys, its number forms), so `font_style` finds what `fontStyle` finds. A null attribute asks for nothing. - Values compare numbers within rounding inside objects and lists too, so a 14 pt leading written as `{"kind": "points", "value": 14}` matches. - `FindFormat::spans` passes over a story's runs once, with a cursor that only moves forward across the paragraphs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find.rs | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index eb821b0ae..11a30e86b 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -122,12 +122,16 @@ impl FindFormat { let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; let name = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); - let chars = o.get("chars").and_then(Value::as_object).cloned().unwrap_or_default(); - // Only attributes text has. + // Only attributes text has, kept as the document writes them: `font_style` and + // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. let mut known = designcraft_doc::CharAttrs::default(); - for (k, v) in &chars { + for (k, v) in o.get("chars").and_then(Value::as_object).into_iter().flatten() { known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; } + let chars = match serde_json::to_value(&known) { + Ok(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; let f = Self { char_style: name("charStyle"), para_style: name("paraStyle"), chars }; Ok((f != Self::default()).then_some(f)) } @@ -147,14 +151,20 @@ impl FindFormat { /// stretches, each paragraph's apart (a paragraph's `\n` isn't in one). fn spans(&self, styles: &Styles, story: &Story, r: &Range) -> Vec> { let mut out: Vec> = Vec::new(); + // Paragraphs and runs both go in text order: the runs before a paragraph are passed once. + let runs: Vec<_> = story.runs().collect(); + let mut first = 0; for (pi, pr) in story.para_ranges().into_iter().enumerate() { let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); let Some(pf) = story.paras.get(pi) else { continue }; if a >= b || self.para_style.as_ref().is_some_and(|n| *n != pf.style) { continue; } + while runs.get(first).is_some_and(|(run, _)| run.end <= a) { + first += 1; + } let base = (!self.chars.is_empty()).then(|| styles.resolve_para(pf).1); - for (run, cf) in story.runs() { + for (run, cf) in runs[first..].iter().take_while(|(run, _)| run.start < b) { let (x, y) = (run.start.max(a), run.end.min(b)); if x >= y || self.char_style.as_ref().is_some_and(|n| *n != cf.style) { continue; @@ -175,10 +185,16 @@ impl FindFormat { } } -/// Attribute values alike: numbers to within rounding, the rest exactly. +/// Attribute values alike: numbers to within rounding (inside objects and lists too), the rest +/// exactly. fn same_value(a: &Value, b: &Value) -> bool { - match (a.as_f64(), b.as_f64()) { - (Some(x), Some(y)) => (x - y).abs() < 1e-6, + match (a, b) { + (Value::Number(_), Value::Number(_)) => match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same_value(a, b)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_value(v, w))), _ => a == b, } } @@ -700,14 +716,33 @@ mod tests { 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"}, "charStyle": "[None]", "attrs": {"underline": true}}), + &json!({"find": "", "change": "", "findFormat": {"charStyle": "eggs", "chars": {"font_style": "Bold"}}, "charStyle": "[None]", "attrs": {"underline": true}}), ) .unwrap(); - assert_eq!(r["count"], 2); + 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. From 6b100a5791f35b3fc528f12c322f76605e5937fd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:30:47 +0200 Subject: [PATCH 03/12] Find Format: refuse malformed fields rather than search without them `findFormat.chars` must be an object and `charStyle` / `paraStyle` style names (or null); any other value is an error instead of being skipped. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 11a30e86b..1838207b8 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -121,18 +121,29 @@ impl FindFormat { fn from_params(p: &Value) -> Result> { let Some(f) = p.get("findFormat").filter(|f| !f.is_null()) else { return Ok(None) }; let o = f.as_object().ok_or_else(|| bad("find", "`findFormat` is an object"))?; - let name = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); + // A style is named; a null asks for none. Any other value is refused rather than + // searched without. + let name = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) => Ok(Some(n.clone())), + Some(v) => Err(bad("find", format!("findFormat: `{k}` is a style name, not {v}"))), + }; + let chars = match o.get("chars") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(m), + Some(v) => return Err(bad("find", format!("findFormat: `chars` is an object of character attributes, not {v}"))), + }; // Only attributes text has, kept as the document writes them: `font_style` and // `fontStyle`, or 12 and 12.0, then find the same text. A null asks for nothing. let mut known = designcraft_doc::CharAttrs::default(); - for (k, v) in o.get("chars").and_then(Value::as_object).into_iter().flatten() { + for (k, v) in chars.into_iter().flatten() { known.set_json(k, v).map_err(|e| bad("find", format!("findFormat: {e}")))?; } let chars = match serde_json::to_value(&known) { Ok(Value::Object(m)) => m, _ => serde_json::Map::new(), }; - let f = Self { char_style: name("charStyle"), para_style: name("paraStyle"), chars }; + let f = Self { char_style: name("charStyle")?, para_style: name("paraStyle")?, chars }; Ok((f != Self::default()).then_some(f)) } @@ -749,6 +760,10 @@ mod tests { 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()); } From f615508ea59f28b0606d69aac4d6d6e8b5247b9f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:56:33 +0200 Subject: [PATCH 04/12] Parity: Find/Change lists Find Format Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c7e0678d7..b9801ec11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -269,7 +269,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), document grid (View ▸ Grids & Guides, ⌘', on the snap positions, behind or in front of items), Lock Guides (⌥⌘;) and Lock Column Guides (View ▸ Grids & Guides; Lock Column Guides is saved with the document and in IDML), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP, with Find Format: text that has a character or paragraph style or character attributes), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows, expandable groups and drag-and-drop reorder of layers and objects (the selection square moves the selection to another layer). - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). From d19f41e54508f29c888178c492e5211456cd2e05 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 05/12] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 154 ---------------------------- crates/ui-egui/src/dialogs/tests.rs | 154 ++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 154 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4d362966f..6de0afa0a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,160 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in - /// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box - /// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All - /// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. - #[test] - fn find_change_gives_the_found_text_a_change_format() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - d.fields.insert("caseSensitive".into(), json!(true)); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - show(app, ui.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; - assert!(h.query_by_label("Change Format:").is_some()); - assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - // Style Options is the section's name in the list and its heading. - for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { - assert!(h.query_all_by_label(l).next().is_some(), "{l}"); - } - // Style Options › Character Style: eggs. - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); - assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); - // Escape closes Change Format Settings only, and keeps the Change Format. - h.get_by_label("Specify attributes to change").click(); - h.run_steps(3); - h.key_press(egui::Key::Escape); - h.run_steps(3); - assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); - assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); - // Change All: the words stay, every lowercase "eggs" has the style. - h.get_by_label("Change All").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); - let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); - assert_eq!(story.text, text); - assert_eq!( - [8, 19, 36].map(|at| story.format_after(at).style.clone()), - [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] - ); - // The trash clears it (the second: the first is the Find Format's). - h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); - h.run_steps(3); - assert_eq!(field(&h, CHANGE_FORMAT), None); - assert!(h.query_by_label(TIP).is_none()); - } - - /// What Change Format Settings sets goes to find.change: a paragraph style and character - /// attributes as well as a character style; the box sums them up. - #[test] - fn a_change_format_goes_with_change_all() { - let mut d = Dialog::new("findChange", json!({"find": "cook"})); - d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); - let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); - assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); - assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); - // A Find Format goes with every search. - d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); - assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); - } - - /// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the - /// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in - /// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears - /// it. - #[test] - fn find_format_finds_only_text_with_it() { - use egui_kittest::{Harness, kittest::Queryable}; - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); - app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); - app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); - app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); - app.run("app.findChange", json!({})).unwrap(); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("find".into(), json!("eggs")); - } - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let key = egui::Id::new("find_format_test_fonts"); - if ui.data(|d| d.get_temp::(key)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(key, true)); - return; - } - show(app, ui.ctx()); - }, - app, - ); - h.run_steps(4); - const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; - assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); - h.get_by_label("Specify attributes to find").click(); - h.run_steps(3); - assert!(h.query_by_label("Find Format Settings").is_some()); - h.get_all_by_value("[Any Style]").next().unwrap().click(); - h.run_steps(3); - h.get_by_label("eggs").click(); - h.run_steps(3); - h.get_all_by_label(" OK ").last().unwrap().click(); - h.run_steps(3); - let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); - assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); - assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); - h.get_by_label("Count").click(); - h.run_steps(3); - assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); - // Nothing in Find what: the text in the style. - h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); - h.run_steps(2); - h.get_by_label("Find Next").click(); - h.run_steps(3); - let sel = h.state().session.doc().unwrap().selection.text.unwrap(); - assert_eq!(sel.range(), 6..10); - // The first trash is the Find Format's. - h.get_all_by_label("Clear specified attributes").next().unwrap().click(); - h.run_steps(3); - assert_eq!(field(&h, FIND_FORMAT), None); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..8dfe5c8c2 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,157 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// The course's chapter 22: Find/Change › Change Format. "eggs", case sensitive, nothing in +/// Change to; Change Format Settings › Style Options sets Character Style "eggs", and the box +/// shows it (with Change to's ⓘ). Escape leaves the settings, not Find/Change. Change All +/// gives every lowercase "eggs" the style and keeps the words; the trash clears the format. +#[test] +fn find_change_gives_the_found_text_a_change_format() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = "Poached Eggs\nBreak eggs; cook until eggs are set."; + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + d.fields.insert("caseSensitive".into(), json!(true)); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("change_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Change Format and nothing in Change to, the found text stays and is only formatted"; + assert!(h.query_by_label("Change Format:").is_some()); + assert!(h.query_by_label(TIP).is_none(), "no ⓘ without a Change Format"); + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + // Style Options is the section's name in the list and its heading. + for l in ["Change Format Settings", "Style Options", "Basic Character Formats", "Character Color"] { + assert!(h.query_all_by_label(l).next().is_some(), "{l}"); + } + // Style Options › Character Style: eggs. + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some(), "the box shows it"); + assert!(h.query_by_label(TIP).is_some(), "Change to's ⓘ"); + // Escape closes Change Format Settings only, and keeps the Change Format. + h.get_by_label("Specify attributes to change").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("findChange")); + assert_eq!((field(&h, CHANGE_FORMAT), field(&h, FORMAT_EDIT)), (Some(json!({"charStyle": "eggs"})), None)); + // Change All: the words stay, every lowercase "eggs" has the style. + h.get_by_label("Change All").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("2 replacement(s) made"))); + let story = h.state().session.doc().unwrap().doc.story(sid).cloned().unwrap(); + assert_eq!(story.text, text); + assert_eq!( + [8, 19, 36].map(|at| story.format_after(at).style.clone()), + [designcraft_doc::NO_CHAR_STYLE.to_string(), "eggs".into(), "eggs".into()] + ); + // The trash clears it (the second: the first is the Find Format's). + h.get_all_by_label("Clear specified attributes").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, CHANGE_FORMAT), None); + assert!(h.query_by_label(TIP).is_none()); +} + +/// What Change Format Settings sets goes to find.change: a paragraph style and character +/// attributes as well as a character style; the box sums them up. +#[test] +fn a_change_format_goes_with_change_all() { + let mut d = Dialog::new("findChange", json!({"find": "cook"})); + d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); + let p = find_change_params(&d); + assert_eq!( + (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), + (json!("eggs"), json!("Method"), json!({"underline": true})) + ); + assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); + assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); + // A Find Format goes with every search. + d.fields.insert(FIND_FORMAT.into(), json!({"paraStyle": "Method"})); + assert_eq!(find_change_params(&d)["findFormat"], json!({"paraStyle": "Method"})); +} + +/// Find/Change › Find Format, as InDesign's: Find Format Settings sets the character style the +/// found text must have, the box shows it with Find what's ⓘ, and Count counts only "eggs" in +/// it; with nothing in Find what, Find Next finds the text in the style; the first trash clears +/// it. +#[test] +fn find_format_finds_only_text_with_it() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 400, 300], "content": "text", "text": "Break eggs; cook eggs."})).unwrap(); + app.run("style.character.create", json!({"name": "eggs", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "eggs"})).unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("find".into(), json!("eggs")); + } + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("find_format_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + const TIP: &str = "With a Find Format, only text that has it is found; with nothing in Find what, all text that has it"; + assert!(h.query_by_label("Find Format:").is_some() && h.query_by_label(TIP).is_none()); + h.get_by_label("Specify attributes to find").click(); + h.run_steps(3); + assert!(h.query_by_label("Find Format Settings").is_some()); + h.get_all_by_value("[Any Style]").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("eggs").click(); + h.run_steps(3); + h.get_all_by_label(" OK ").last().unwrap().click(); + h.run_steps(3); + let field = |h: &Harness<'_, DesignApp>, k: &str| h.state().ui.dialog.as_ref().unwrap().fields.get(k).cloned(); + assert_eq!((field(&h, FIND_FORMAT), field(&h, CHANGE_FORMAT)), (Some(json!({"charStyle": "eggs"})), None)); + assert!(h.query_by_label("Character Style: eggs").is_some() && h.query_by_label(TIP).is_some()); + h.get_by_label("Count").click(); + h.run_steps(3); + assert_eq!(field(&h, "status"), Some(json!("1 match(es)")), "only the \"eggs\" in the style"); + // Nothing in Find what: the text in the style. + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("find".into(), json!("")); + h.run_steps(2); + h.get_by_label("Find Next").click(); + h.run_steps(3); + let sel = h.state().session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.range(), 6..10); + // The first trash is the Find Format's. + h.get_all_by_label("Clear specified attributes").next().unwrap().click(); + h.run_steps(3); + assert_eq!(field(&h, FIND_FORMAT), None); +} From c43319e0a2425c761e7f9057989d538eedee934d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:07:17 +0100 Subject: [PATCH 06/12] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8dfe5c8c2..c80127706 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3604,10 +3604,7 @@ fn a_change_format_goes_with_change_all() { let mut d = Dialog::new("findChange", json!({"find": "cook"})); d.fields.insert(CHANGE_FORMAT.into(), json!({"charStyle": "eggs", "paraStyle": "Method", "chars": {"underline": true}})); let p = find_change_params(&d); - assert_eq!( - (p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), - (json!("eggs"), json!("Method"), json!({"underline": true})) - ); + assert_eq!((p["charStyle"].clone(), p["paraStyle"].clone(), p["attrs"].clone()), (json!("eggs"), json!("Method"), json!({"underline": true}))); assert_eq!(format_summary("en", d.fields.get(CHANGE_FORMAT)), "Character Style: eggs + Paragraph Style: Method + Underline: on"); assert!(find_change_params(&Dialog::new("findChange", json!({"find": "cook"}))).get("attrs").is_none()); // A Find Format goes with every search. From 776088c0d559b7fd4edc75aea078cd2e693a4d38 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:10:49 +0100 Subject: [PATCH 07/12] Paragraph style apply: the story's paragraph ranges, as main lists them Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style/paragraph.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()); From d6210b40ee8916efd9b9d9babe5c3af7cc80285f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:13:20 +0100 Subject: [PATCH 08/12] Find/Change: the branch's find tests beside main's scope helpers (find/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find.rs | 116 ---------------------------- crates/engine/src/cmd/find/tests.rs | 116 ++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 116 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index af05e45af..849d54232 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -709,122 +709,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()); - } - #[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 5914c2e51..e88c81a3a 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -747,3 +747,119 @@ 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()); +} From c9621daa3635056c756a76a9ae4817873b3bf508 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:36:33 +0100 Subject: [PATCH 09/12] Find/Change: Find what, Change to and Search named by their captions, for screen readers Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6de0afa0a..fd96522cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2474,7 +2474,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "find", 260.0); + text_field_named(ui, &mut d, "find", 260.0, crate::i18n::tr(&app.ui.language, "Find what:")); ui.end_row(); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); @@ -2489,9 +2489,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ); } }); - text_field(ui, &mut d, "change", 260.0); + text_field_named(ui, &mut d, "change", 260.0, crate::i18n::tr(&app.ui.language, "Change to:")); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let scope_name = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); let cur = d.s("scope"); craft_ui::ComboBox::from_id_salt("fcscope") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) @@ -2503,7 +2503,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.session.reset_find_scope(); } } - }); + }) + .response + .labelled_by(scope_name.id); ui.end_row(); }); ui.horizontal(|ui| { From 5db160f9e8d6353ceac0767038d9cd2539b3ed80 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:56 +0100 Subject: [PATCH 10/12] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fd96522cd..35031149a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3476,7 +3476,7 @@ fn format_style_options(lang: &str, ui: &mut egui::Ui, fd: &mut Dialog, styles: crate::rtl::label(ui, crate::i18n::tr(lang, label)); let cur = fd.fields.get(key).and_then(Value::as_str).map(str::to_string); let shown = cur.as_deref().map_or_else(|| any.to_string(), |n| crate::i18n::style_name(lang, n).to_string()); - egui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("format_style", key)).selected_text(crate::rtl::widget(ui, shown)).width(240.0).show_ui(ui, |ui| { if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { fd.fields.remove(key); } From 9eb11f3eeed58f38b1ef5122f2876a008bed385d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:24:57 +0100 Subject: [PATCH 11/12] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/find/tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs index e88c81a3a..7de29f8ea 100644 --- a/crates/engine/src/cmd/find/tests.rs +++ b/crates/engine/src/cmd/find/tests.rs @@ -795,9 +795,8 @@ fn change_format_styles_the_found_text_and_keeps_it() { 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 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(); From cd84bd903ae92530eb0ce6cf248629d491b51e20 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 14:36:10 +0100 Subject: [PATCH 12/12] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- crates/ui-egui/src/dialogs/tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 35031149a..d6da8e43e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3305,7 +3305,7 @@ const FORMAT_SECTIONS: &[(&str, &str)] = &[ fn info_icon(ui: &mut egui::Ui, tip: &str) { let (r, resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); crate::icons::paint(ui.painter(), r, "info", crate::theme::Tokens::get(ui.ctx()).icon); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_text(tip); } @@ -3383,7 +3383,7 @@ fn format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dialog) { let find = fd.s("for") == "find"; let popup_open = egui::Popup::is_any_open(ctx); let mut result = None; - let host = egui::Id::new("format_settings"); + let host = egui::Id::unique("format_settings"); crate::modal_drag::modal(ctx, host, host).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let title_top = ui.cursor().top(); let title = if find { "Find Format Settings" } else { "Change Format Settings" }; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index c80127706..e1ce3f2dd 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3542,7 +3542,7 @@ fn find_change_gives_the_found_text_a_change_format() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("change_format_test_fonts"); + let key = egui::Id::unique("change_format_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -3632,7 +3632,7 @@ fn find_format_finds_only_text_with_it() { } let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("find_format_test_fonts"); + let key = egui::Id::unique("find_format_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true));