Find/Change: Find Format

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 <noreply@anthropic.com>
This commit is contained in:
paaspaas00andClaude Opus 5.5 committed 2026-10-21 17:57:02 +02:00
1 parent 95006e9e9e
commit 64242eb5bf
12 files changed
+450 -53

No files matched your search

+1
View File
@@ -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.
+237 -21
View File
@@ -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<CommandSpec> {
}
),
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<Grep> {
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<String>,
para_style: Option<String>,
/// Resolved character attributes, as `type.selectionAttrs` reports them.
chars: serde_json::Map<String, Value>,
}
impl FindFormat {
fn from_params(p: &Value) -> Result<Option<Self>> {
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<usize>) -> Vec<Range<usize>> {
let mut out: Vec<Range<usize>> = 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<usize>], m: &Range<usize>) -> 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<Grep>,
format: Option<FindFormat>,
}
/// The search of a Find/Change request: an empty `find` needs a Find Format.
fn search(p: &Value) -> Result<Search> {
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<usize>) -> Option<Vec<Range<usize>>> {
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<usize>, text: &str, limit: usize) -> Result<Vec<Range<usize>>> {
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<usize>, text: &str, from: usize) -> Result<Option<Range<usize>>> {
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<usize>,
text: &str,
rep: &str,
grep: bool,
first_only: bool,
) -> Result<Vec<(Range<usize>, 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<FindFormat>,
}
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<FindContext> {
const MAX_MATCHES: usize = 10_000;
fn find(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<usize>, 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<usize>, 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<Value> {
}
fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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] {
+131 -32
View File
@@ -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.<attr>`), 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.<attr>`), 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::<bool>(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]
+25
View File
@@ -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",
[
+7
View File
@@ -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", "خيارات النمط"),
+7
View File
@@ -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"),
+7
View File
@@ -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"),
+7
View File
@@ -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", "სტილის პარამეტრები"),
+7
View File
@@ -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"),
+7
View File
@@ -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"),
+7
View File
@@ -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", "Параметры стиля"),
+7
View File
@@ -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", "Параметри стилю"),