From 95006e9e9e2fd18c0dff17d85a167cf7ad7c9d5b Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:44:12 +0200 Subject: [PATCH 1/8] Find/Change: Change Format, with a character or paragraph style find.change takes a Change Format: charStyle and paraStyle as well as the character attributes (attrs) it took. With a Change Format and an empty change, the found text stays and is only formatted, as InDesign does; without one an empty change still deletes. The style-apply code of style.paragraph.apply / style.character.apply moves into helpers that find.change shares. The Find/Change dialog has InDesign's Change Format box, with Specify Attributes (opening Change Format Settings over Find/Change: Style Options, then the character sections of Character Style Options) and Clear. An info icon beside Change to says an empty Change to keeps the text. 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 | 80 +++++++- crates/engine/src/cmd/style.rs | 99 +++++----- crates/ui-egui/src/dialogs.rs | 324 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 37 ++++ crates/ui-egui/src/i18n/ar.rs | 10 + crates/ui-egui/src/i18n/cs.rs | 10 + crates/ui-egui/src/i18n/it.rs | 10 + crates/ui-egui/src/i18n/ka.rs | 10 + crates/ui-egui/src/i18n/pl.rs | 10 + crates/ui-egui/src/i18n/pt_br.rs | 10 + crates/ui-egui/src/i18n/ru.rs | 10 + crates/ui-egui/src/i18n/uk.rs | 10 + crates/ui-egui/src/icons.rs | 11 ++ 14 files changed, 578 insertions(+), 54 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..fa44d189e 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 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. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 3ba6970a1..d6a43855d 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -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?: 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?, 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}} → {count}", + "{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", 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?: 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?, 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), ] } @@ -314,8 +314,20 @@ fn change(s: &mut Session, p: &Value) -> Result { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; + let char_style = str_param(p, "charStyle").map(str::to_string); + let para_style = str_param(p, "paraStyle").map(str::to_string); + let styles = &s.doc()?.doc.styles; + if let Some(n) = char_style.as_ref().filter(|n| styles.char_style(n).is_none()) { + return Err(bad("find.change", format!("no character style `{n}`"))); + } + if let Some(n) = para_style.as_ref().filter(|n| styles.para(n).is_none()) { + return Err(bad("find.change", format!("no paragraph style `{n}`"))); + } + // InDesign: with a Change Format and nothing in Change to, the found text is only formatted. + let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty()); let targets = scope(s, p)?.targets; s.edit(|d, sel| { + let styles = d.styles.clone(); let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; @@ -332,9 +344,22 @@ fn change(s: &mut Session, p: &Value) -> Result { .collect(); let Some(st) = d.story_mut(sid) else { continue }; for (range, new) in edits.iter().rev() { - st.replace(range.clone(), new); - if !over.is_empty() && !new.is_empty() { - st.format_chars(range.start..range.start + new.len(), |f| f.over.merge(&over)); + let changed = if format_only { + range.clone() + } else { + st.replace(range.clone(), new); + range.start..range.start + new.len() + }; + if !changed.is_empty() { + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } + if let Some(n) = &char_style { + super::style::apply_char_style(st, &styles, n, &changed); + } + if !over.is_empty() { + st.format_chars(changed, |f| f.over.merge(&over)); + } } count += 1; } @@ -433,6 +458,49 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + #[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 change_first_changes_one_match_in_the_whole_document() { for grep in [false, true] { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..9ae967613 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -763,45 +763,50 @@ fn apply_para(s: &mut Session, p: &Value) -> Result { let targets = super::text::format_targets_pub(s); s.edit(|d, _| { let styles = d.styles.clone(); - let (style_para, style_chars) = styles.resolve_para_style(&name); for t in &targets { - let r = &t.range; if let Some(st) = d.text_story_mut(t.story, t.cell) { - // Text selections are half-open: a selection ending at the next paragraph's - // start must not apply a style to that paragraph too. - let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; - let (first, last) = (st.para_at(r.start), st.para_at(end)); - st.format_paras(r.start..end, |f| { - f.style = name.clone(); - if clear { - f.para = ParaAttrs::default(); - f.chars = CharAttrs::default(); - } else { - f.para.retain_differences(&style_para); - f.chars.retain_differences(&style_chars); - } - }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - 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()); - let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; - st.format_chars(range, |f| { - if clear { - f.over = CharAttrs::default(); - } else { - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&f.style)); - f.over.retain_differences(&inherited); - } - }); - } + apply_para_style(st, &styles, &name, &t.range, clear); } } ok() }) } +/// Paragraph style `name` applied to the paragraphs of `r` in `st`: the paragraphs' and their +/// text's overrides that the style now gives go (all of them with `clear`). +pub(super) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range, clear: bool) { + let (style_para, style_chars) = styles.resolve_para_style(name); + // Text selections are half-open: a selection ending at the next paragraph's + // start must not apply a style to that paragraph too. + let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; + let (first, last) = (st.para_at(r.start), st.para_at(end)); + st.format_paras(r.start..end, |f| { + f.style = name.to_string(); + if clear { + f.para = ParaAttrs::default(); + f.chars = CharAttrs::default(); + } else { + f.para.retain_differences(&style_para); + f.chars.retain_differences(&style_chars); + } + }); + for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + 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()); + let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; + st.format_chars(range, |f| { + if clear { + f.over = CharAttrs::default(); + } else { + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&f.style)); + f.over.retain_differences(&inherited); + } + }); + } +} + fn apply_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); if s.doc()?.doc.styles.char_style(&name).is_none() { @@ -812,25 +817,31 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } + apply_char_style(st, &styles, &name, &t.range); } } ok() }) } +/// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its +/// paragraph) now gives go. +pub(super) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { + for (pi, range) in st.para_ranges().into_iter().enumerate() { + let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(name)); + st.format_chars(range, |f| { + f.style = name.to_string(); + f.over.retain_differences(&inherited); + }); + } +} + /// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit /// "continue" so it also overrides a Start At the style would inherit. fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..6359a6c84 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2026,9 +2026,10 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; - // Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog. - // Read before the dialog draws: a popup closed by this frame's Escape is still open here. - let popup_open = egui::Popup::is_any_open(ctx); + // Enter and Escape belong to an open popup (a menu, a name field) or to Find/Change's Change + // Format Settings rather than the dialog. Read before the dialog draws: a popup closed by this + // frame's Escape is still open here. + let popup_open = egui::Popup::is_any_open(ctx) || d.fields.get(FORMAT_EDIT).is_some_and(Value::is_object); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -2280,11 +2281,24 @@ 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()); 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:")); text_field(ui, &mut d, "find", 260.0); ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + // InDesign's ⓘ: with a Change Format, an empty Change to keeps the text. + if formatted { + info_icon( + ui, + crate::i18n::tr( + &app.ui.language, + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + ), + ); + } + }); text_field(ui, &mut d, "change", 260.0); ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); @@ -2306,6 +2320,7 @@ 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); ui.horizontal(|ui| { let params0 = find_change_params(&d); let params = || params0.clone(); @@ -2871,6 +2886,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { if d.id == "newDocument" && result.is_none() { 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); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -3026,9 +3044,217 @@ fn find_change_params(d: &Dialog) -> Value { if let Some(story) = d.fields.get("story") { p["story"] = story.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"] { + if let Some(v) = f.get(key).filter(|v| v.is_string()) { + p[key] = v.clone(); + } + } + if let Some(chars) = f.get("chars").filter(|c| c.as_object().is_some_and(|o| !o.is_empty())) { + p["attrs"] = chars.clone(); + } + } p } +/// Find/Change's Change Format ({charStyle?, paraStyle?, chars?}), as Change Format Settings set +/// it; absent when there is none. +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. +const FORMAT_EDIT: &str = "formatEdit"; + +/// The sections of Change Format Settings: Style Options, then those of Character Style Options. +const FORMAT_SECTIONS: &[(&str, &str)] = &[ + ("style", "Style Options"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), +]; + +/// A small ⓘ whose tooltip is `tip`. +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.on_hover_text(tip); +} + +/// What the Change Format box shows: "Character Style: eggs + …". +fn change_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:")] { + if let Some(name) = f.get(key).and_then(Value::as_str) { + parts.push(format!("{} {}", crate::i18n::tr(lang, label), crate::i18n::style_name(lang, name))); + } + } + parts.extend( + f.get("chars").and_then(Value::as_object).into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v))), + ); + 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) { + ui.add_space(6.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "Change Format:")); + 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)); + 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(); + let mut edit = Map::new(); + edit.insert("section".into(), json!("style")); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = f.get(key) { + edit.insert(key.into(), v.clone()); + } + } + for (k, v) in f.get("chars").and_then(Value::as_object).into_iter().flatten() { + edit.insert(format!("c.{k}"), v.clone()); + } + 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); + } + }); + }); +} + +/// 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) { + let lang = app.ui.language.clone(); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { + d.fields.remove(FORMAT_EDIT); + return; + }; + let mut fd = + 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 popup_open = egui::Popup::is_any_open(ctx); + let mut result = None; + let host = egui::Id::new("change_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 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()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, host, title_row); + ui.add_space(10.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list(&lang, ui, &mut fd, false, FORMAT_SECTIONS); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(440.0); + match fd.s("section").as_str() { + "chars" => basic_character_formats(app, ui, &mut fd, &cf), + "advanced" => advanced_character_formats(&lang, ui, &mut fd, &cf), + "color" => character_color(&lang, ui, &mut fd, &cf, &doc), + "openType" => open_type_features(&lang, ui, &mut fd, &cf), + "underline" => line_options(&lang, ui, &mut fd, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, &mut fd, &cf, &doc, "strikethrough"), + _ => format_style_options(&lang, ui, &mut fd, &doc.styles), + } + }); + }); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&lang, " OK ")).color(egui::Color32::WHITE))) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + ) + .clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + result = Some(true); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Cancel"))).clicked() + || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) + { + result = Some(false); + } + }); + }); + match result { + Some(true) => { + d.fields.remove(FORMAT_EDIT); + let mut f = Map::new(); + for key in ["charStyle", "paraStyle"] { + if let Some(v) = fd.fields.get(key).filter(|v| v.is_string()) { + f.insert(key.into(), v.clone()); + } + } + let chars: Map = + fd.fields.iter().filter(|(_, v)| !v.is_null()).filter_map(|(k, v)| Some((k.strip_prefix("c.")?.to_string(), v.clone()))).collect(); + if !chars.is_empty() { + f.insert("chars".into(), Value::Object(chars)); + } + if f.is_empty() { + d.fields.remove(CHANGE_FORMAT); + } else { + d.fields.insert(CHANGE_FORMAT.into(), Value::Object(f)); + } + } + Some(false) => { + d.fields.remove(FORMAT_EDIT); + } + None => { + d.fields.insert(FORMAT_EDIT.into(), Value::Object(fd.fields)); + } + } +} + +/// Change Format Settings › Style Options: the character and paragraph style the changed text +/// gets ([Any Style]: 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); + let any = crate::i18n::tr(lang, "[Any Style]"); + let chars: Vec<&str> = styles.character.iter().map(|s| s.name.as_str()).collect(); + let paras: Vec<&str> = styles.paragraph.iter().map(|s| s.name.as_str()).collect(); + egui::Grid::new("format_style_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, names) in [("charStyle", "Character Style:", &chars), ("paraStyle", "Paragraph Style:", ¶s)] { + 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| { + if ui.selectable_label(cur.is_none(), crate::rtl::widget(ui, any)).clicked() { + fd.fields.remove(key); + } + for n in names.iter() { + if ui.selectable_label(cur.as_deref() == Some(*n), crate::rtl::widget(ui, crate::i18n::style_name(lang, n))).clicked() { + fd.fields.insert(key.into(), json!(n)); + } + } + }); + ui.end_row(); + } + }); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -8259,6 +8485,96 @@ mod tests { } } + /// 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. + h.get_by_label("Clear specified attributes").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!(change_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()); + } + #[test] fn find_scope_dialog_control_effective_noops_retain_bounds() { for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..0e8b47103 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -820,6 +820,43 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Center Content", ["Center Content", "Centrer le contenu", "Center Content", "Center Content", "内容居中"]), ("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:", "置換形式 :", "更改格式:"]), + ( + "Change Format Settings", + [ + "Einstellungen für Format ersetzen", + "Paramètres de remplacement du format", + "Ajustes de cambio de formato", + "置換形式の設定", + "更改格式设置", + ], + ), + ("Style Options", ["Formatoptionen", "Options de style", "Opciones de estilo", "スタイルオプション", "样式选项"]), + ("[Any Style]", ["[Beliebiges Format]", "[Tout style]", "[Cualquier estilo]", "[任意のスタイル]", "[任意样式]"]), + ( + "Specify attributes to change", + [ + "Zu ersetzende Attribute festlegen", + "Indiquer les attributs à remplacer", + "Especificar atributos que cambiar", + "置換する属性を指定", + "指定要更改的属性", + ], + ), + ( + "Clear specified attributes", + ["Festgelegte Attribute löschen", "Effacer les attributs indiqués", "Borrar atributos especificados", "指定した属性を消去", "清除指定的属性"], + ), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + [ + "Mit einem Ersetzungsformat und leerem Feld „Ersetzen durch“ bleibt der gefundene Text erhalten und wird nur formatiert", + "Avec un format de remplacement et « Remplacer par » vide, le texte trouvé reste et seule sa mise en forme change", + "Con un formato de cambio y «Cambiar a» vacío, el texto encontrado se conserva y solo cambia su formato", + "置換形式があり置換文字列が空のときは、検索したテキストはそのままで書式だけが変わります", + "设置了更改格式且“更改为”为空时,找到的文本保持不变,只更改其格式", + ], + ), ("Character Style:", ["Character Style:", "Style de caractère :", "Character Style:", "Character Style:", "字符样式:"]), ("Checked by default", ["Checked by default", "Cochée par défaut", "Checked by default", "Checked by default", "默认选中"]), ("Choices", ["Choices", "Choix", "Choices", "Choices", "选项"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..33eff157d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Change Format:", "تغيير التنسيق:"), + ("Change Format Settings", "إعدادات تغيير التنسيق"), + ("Style Options", "خيارات النمط"), + ("[Any Style]", "[أي نمط]"), + ("Specify attributes to change", "تحديد السمات المراد تغييرها"), + ("Clear specified attributes", "مسح السمات المحددة"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "مع تنسيق للتغيير وحقل «تغيير إلى» فارغ، يبقى النص الذي تم العثور عليه ويتغير تنسيقه فقط", + ), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..633978380 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Change Format:", "Změnit formát:"), + ("Change Format Settings", "Nastavení změny formátu"), + ("Style Options", "Volby stylu"), + ("[Any Style]", "[Libovolný styl]"), + ("Specify attributes to change", "Určit atributy ke změně"), + ("Clear specified attributes", "Vymazat určené atributy"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Se změnou formátu a prázdným polem „Změnit na“ nalezený text zůstane a změní se jen jeho formát", + ), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..101167ea2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Change Format:", "Modifica formato:"), + ("Change Format Settings", "Impostazioni di modifica formato"), + ("Style Options", "Opzioni stile"), + ("[Any Style]", "[Qualsiasi stile]"), + ("Specify attributes to change", "Specifica gli attributi da modificare"), + ("Clear specified attributes", "Cancella gli attributi specificati"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Con un formato di modifica e «Sostituisci con» vuoto, il testo trovato resta e cambia solo la formattazione", + ), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..357350579 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Change Format:", "ფორმატის შეცვლა:"), + ("Change Format Settings", "ფორმატის შეცვლის პარამეტრები"), + ("Style Options", "სტილის პარამეტრები"), + ("[Any Style]", "[ნებისმიერი სტილი]"), + ("Specify attributes to change", "შესაცვლელი ატრიბუტების მითითება"), + ("Clear specified attributes", "მითითებული ატრიბუტების გასუფთავება"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "ფორმატის შეცვლით და ცარიელი „ჩანაცვლება“ ველით ნაპოვნი ტექსტი რჩება და მხოლოდ ფორმატი იცვლება", + ), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..e13ee7de2 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Change Format:", "Zmień format:"), + ("Change Format Settings", "Ustawienia zmiany formatu"), + ("Style Options", "Opcje stylu"), + ("[Any Style]", "[Dowolny styl]"), + ("Specify attributes to change", "Określ atrybuty do zmiany"), + ("Clear specified attributes", "Wyczyść określone atrybuty"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Przy zmianie formatu i pustym polu „Zmień na” znaleziony tekst zostaje, a zmienia się tylko jego format", + ), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..c96ced85e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Change Format:", "Alterar formato:"), + ("Change Format Settings", "Configurações de alteração de formato"), + ("Style Options", "Opções de estilo"), + ("[Any Style]", "[Qualquer estilo]"), + ("Specify attributes to change", "Especificar atributos a alterar"), + ("Clear specified attributes", "Limpar atributos especificados"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Com um formato de alteração e “Alterar para” vazio, o texto encontrado permanece e só a formatação muda", + ), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..719a6d653 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Change Format:", "Заменить формат:"), + ("Change Format Settings", "Параметры замены формата"), + ("Style Options", "Параметры стиля"), + ("[Any Style]", "[Любой стиль]"), + ("Specify attributes to change", "Задать атрибуты для замены"), + ("Clear specified attributes", "Очистить заданные атрибуты"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Если задан формат замены, а поле «Заменить на» пусто, найденный текст остаётся, меняется только его формат", + ), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..ed57d814c 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Change Format:", "Замінити формат:"), + ("Change Format Settings", "Параметри заміни формату"), + ("Style Options", "Параметри стилю"), + ("[Any Style]", "[Будь-який стиль]"), + ("Specify attributes to change", "Задати атрибути для заміни"), + ("Clear specified attributes", "Очистити задані атрибути"), + ( + "With a Change Format and nothing in Change to, the found text stays and is only formatted", + "Якщо задано формат заміни, а поле «Замінити на» порожнє, знайдений текст лишається, змінюється лише його формат", + ), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..29952ca1d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1040,6 +1040,17 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); } + // Find/Change's Specify Attributes: a letter under a magnifier. + "find-format" => { + pen.text(7.5, 8.5, "A", 12.0); + pen.circle(13.0, 13.0, 3.2); + pen.line(&[(15.3, 15.3), (18.0, 18.0)]); + } + "info" => { + pen.circle(10.0, 10.0, 7.0); + pen.fcircle(10.0, 6.6, 1.0); + pen.line(&[(10.0, 9.0), (10.0, 14.2)]); + } _ => { pen.rect(4.0, 4.0, 16.0, 16.0); } From c6a0e1e8137e2c6d5561526a5bd031cc5b17d147 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 13:50:38 +0200 Subject: [PATCH 2/8] Change Format: empty matches get the paragraph style; style helpers bounded - A GREP match that is empty (^) was counted but got no paragraph style; the paragraph style now goes to the paragraphs of every match, empty ones too. Character formatting still needs characters. - apply_char_style and apply_para_style listed every paragraph of the story for each match; Story::paras_touching gives the ones a range reaches, from the range itself. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/story.rs | 39 ++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/find.rs | 36 ++++++++++++++++++++++++++++--- crates/engine/src/cmd/style.rs | 4 ++-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..c21d54c4b 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -244,6 +244,24 @@ impl Story { } /// Index of the paragraph containing byte `pos`. + /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without + /// its return), found from `r` itself rather than by listing every paragraph of the story. + pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { + let start = floor_char_boundary(&self.text, r.start.min(self.text.len())); + let mut pi = self.para_at(start); + let mut a = self.text[..start].rfind('\n').map_or(0, |i| i + 1); + let mut out = Vec::new(); + loop { + let b = self.text[a..].find('\n').map_or(self.text.len(), |i| a + i); + out.push((pi, a..b)); + // The next paragraph starts after this one's return: is it still inside `r`? + if b >= self.text.len() || b + 1 >= r.end { + return out; + } + (a, pi) = (b + 1, pi + 1); + } + } + pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() } @@ -596,6 +614,27 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// `paras_touching` gives the paragraphs a range reaches into (returns included), as listing + /// every paragraph would, for every range of a story with empty and multi-byte paragraphs. + #[test] + fn paras_touching_matches_the_full_listing() { + let s = st("ab + +çé +last"); + let all = s.para_ranges(); + for a in 0..=s.len() { + for b in a..=s.len() { + if !s.text.is_char_boundary(a) || !s.text.is_char_boundary(b) { + continue; + } + let want: Vec<(usize, std::ops::Range)> = + all.iter().cloned().enumerate().filter(|(i, p)| *i == s.para_at(a) || (a < b && p.start < b && a <= p.end)).collect(); + assert_eq!(s.paras_touching(a..b), want, "{a}..{b}"); + } + } + } + #[test] fn paragraphs_track_newlines() { let mut s = st("one\ntwo"); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index b87a7b226..9e6f984b4 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -372,10 +372,12 @@ fn change(s: &mut Session, p: &Value) -> Result { st.replace(range.clone(), new); range.start..range.start + new.len() }; + // A paragraph style goes to the match's paragraphs, an empty match's (`^`) too; + // character formatting needs characters. + if let Some(n) = ¶_style { + super::style::apply_para_style(st, &styles, n, &changed, false); + } if !changed.is_empty() { - if let Some(n) = ¶_style { - super::style::apply_para_style(st, &styles, n, &changed, false); - } if let Some(n) = &char_style { super::style::apply_char_style(st, &styles, n, &changed); } @@ -485,6 +487,34 @@ mod tests { assert!(s.doc().unwrap().selection.text.is_some()); } + /// A GREP match that is empty (`^`, the start of each paragraph) gets the paragraph style: + /// only character formatting needs characters. + #[test] + fn change_format_styles_paragraphs_of_empty_matches() { + 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": "One +Two +Three"}), + ) + .unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("style.paragraph.create", &json!({"name": "Method"})).unwrap(); + let r = s.execute("find.change", &json!({"find": "^", "change": "", "grep": true, "paraStyle": "Method"})).unwrap(); + assert_eq!(r["count"], 3); + let story = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(story.paras.iter().all(|p| p.style == "Method"), "{:?}", story.paras.iter().map(|p| &p.style).collect::>()); + assert_eq!( + story.text, + "One +Two +Three" + ); + } + #[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 diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 9ae967613..27414306e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -790,7 +790,7 @@ pub(super) fn apply_para_style(st: &mut designcraft_doc::Story, styles: &Styles, f.chars.retain_differences(&style_chars); } }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + for (pi, range) in st.paras_touching(r.clone()).into_iter().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()); @@ -827,7 +827,7 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { /// Character style `name` applied to `r` in `st`: the text's overrides that the style (over its /// paragraph) now gives go. pub(super) fn apply_char_style(st: &mut designcraft_doc::Story, styles: &Styles, name: &str, r: &std::ops::Range) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { + for (pi, range) in st.paras_touching(r.clone()) { let range = range.start.max(r.start)..range.end.saturating_add(1).min(st.len()).min(r.end); if range.is_empty() { continue; From 408c7e14b58ded1f672abab2cda1ad08054c2296 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:18 +0100 Subject: [PATCH 3/8] 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 | 90 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 90 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 90 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 438636154..e0e47e22c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,96 +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. - h.get_by_label("Clear specified attributes").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!(change_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()); - } - #[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..4d9a1ddb6 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,93 @@ 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. + h.get_by_label("Clear specified attributes").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!(change_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()); +} From 10e9970cfedaaecfe0fa9e76e2e8c80f92065d89 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 21:44:05 +0100 Subject: [PATCH 4/8] 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 4d9a1ddb6..ba5531870 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!(change_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()); } From 27684daabefec09489f51f323bd709f39727e8c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:02:59 +0100 Subject: [PATCH 5/8] 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 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e0e47e22c..302a1b069 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3279,7 +3279,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); } @@ -3348,7 +3348,7 @@ fn change_format_settings(app: &mut DesignApp, ctx: &egui::Context, d: &mut Dial let cf = CharFields { base: &base, sparse: true }; 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::unique("change_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))); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ba5531870..f6f6f0e77 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)); From d42333fb9d82c3757526cde9e3204484a1c0e7bd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:10:58 +0100 Subject: [PATCH 6/8] Story: para_at's doc comment back on para_at Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/story.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index de753891c..fb04f9c9f 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -286,7 +286,6 @@ impl Story { v } - /// Index of the paragraph containing byte `pos`. /// The paragraphs `r` touches (at least the one at `r.start`), as (index, byte range without /// its return), found from `r` itself rather than by listing every paragraph of the story. pub fn paras_touching(&self, r: std::ops::Range) -> Vec<(usize, std::ops::Range)> { @@ -305,6 +304,7 @@ impl Story { } } + /// Index of the paragraph containing byte `pos`. pub fn para_at(&self, pos: usize) -> usize { self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count() } From 840d75b46fc02128ca572ac62531925cf0206108 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 23:36:32 +0100 Subject: [PATCH 7/8] 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 302a1b069..e245b2c5c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2462,7 +2462,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let formatted = d.fields.get(CHANGE_FORMAT).and_then(Value::as_object).is_some_and(|f| !f.is_empty()); 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:")); - 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:")); @@ -2477,9 +2477,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))) @@ -2491,7 +2491,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 d865a7cca15fa7ed81d18e0bf6e2bad41e47a153 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:00:58 +0100 Subject: [PATCH 8/8] 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 e245b2c5c..805a5ffc6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3441,7 +3441,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); }