diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs new file mode 100644 index 00000000..4991cd1f --- /dev/null +++ b/crates/engine/src/cmd/find.rs @@ -0,0 +1,240 @@ +//! Home › Editing: Find and Replace across slide text, table cells and speaker notes. + +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.find", + "Find", + ["Home", "Editing"], + None, + "{text, matchCase?: bool, wholeWords?: bool} → [{slide, shape (null = notes), cell?: [row, col], para, start, end}] (char offsets in the paragraph)", + has_doc, + find + ), + cmd!( + "text.replace", + "Replace", + ["Home", "Editing"], + None, + "{text, with, matchCase?, wholeWords?, at?: a text.find match (replace just that one; default: replace all)} → {replaced}", + has_doc, + replace + ), + ] +} + +/// Where a text body lives on a slide. +#[derive(Clone, Copy, PartialEq, Debug)] +struct Loc { + shape: Option, + cell: Option<(usize, usize)>, +} + +/// Visit every text body on a slide: shapes depth-first (groups, table cells), then the notes. +fn bodies(slide: &mut Slide, f: &mut dyn FnMut(Loc, &mut TextBody)) { + fn rec(shapes: &mut [Shape], depth: usize, f: &mut dyn FnMut(Loc, &mut TextBody)) { + if depth > 64 { + return; + } + for sh in shapes { + let id = Some(u64::from(sh.id.0)); + if let Some(t) = &mut sh.text { + f(Loc { shape: id, cell: None }, t); + } + match &mut sh.kind { + ShapeKind::Table(t) => { + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + f(Loc { shape: id, cell: Some((r, c)) }, &mut cell.text); + } + } + } + ShapeKind::Group { children, .. } => rec(children, depth + 1, f), + _ => {} + } + } + } + rec(&mut slide.shapes, 0, f); + f(Loc { shape: None, cell: None }, &mut slide.notes); +} + +struct Opts { + pat: Vec, + case: bool, + whole: bool, +} + +impl Opts { + fn from(p: &Value, cmd: &str) -> Result { + let pat: Vec = str_param(p, "text").unwrap_or("").chars().collect(); + if pat.is_empty() { + return Err(bad(cmd, "missing `text`")); + } + Ok(Opts { pat, case: bool_or(p, "matchCase", false), whole: bool_or(p, "wholeWords", false) }) + } + fn eq(&self, a: char, b: char) -> bool { + a == b || (!self.case && a.to_lowercase().eq(b.to_lowercase())) + } + /// Non-overlapping matches as char ranges. + fn matches(&self, text: &[char]) -> Vec<(usize, usize)> { + let n = self.pat.len(); + let word = |c: Option<&char>| c.is_some_and(|c| c.is_alphanumeric() || *c == '_'); + let mut out = vec![]; + let mut i = 0; + while i + n <= text.len() { + let hit = text[i..i + n].iter().zip(&self.pat).all(|(a, b)| self.eq(*a, *b)) + && (!self.whole || (!word(i.checked_sub(1).and_then(|j| text.get(j))) && !word(text.get(i + n)))); + if hit { + out.push((i, i + n)); + i += n; + } else { + i += 1; + } + } + out + } +} + +fn para_chars(p: &Paragraph) -> Vec { + p.runs.iter().flat_map(|r| r.text.chars()).collect() +} + +/// Matches that lie wholly in plain text runs (never inside a field, equation or break). +fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut off = 0; + for r in &p.runs { + let n = r.text.chars().count(); + spans.push((off, off + n, matches!(r.kind, RunKind::Text))); + off += n; + } + o.matches(¶_chars(p)).into_iter().filter(|&(a, b)| spans.iter().all(|&(s, e, text)| text || e <= a || s >= b || s == e)).collect() +} + +/// Replace chars `a..b` of the paragraph; the replacement takes the first run's formatting. +fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) { + let mut off = 0; + let mut placed = false; + for r in &mut p.runs { + let chars: Vec = r.text.chars().collect(); + let (s, e) = (off, off + chars.len()); + off = e; + if e <= a || s >= b { + continue; + } + let keep_head: String = chars[..a.saturating_sub(s)].iter().collect(); + let keep_tail: String = chars[b.saturating_sub(s).min(chars.len())..].iter().collect(); + r.text = if placed { keep_tail } else { format!("{keep_head}{with}{keep_tail}") }; + placed = true; + } + if p.runs.len() > 1 { + p.runs.retain(|r| !(r.text.is_empty() && matches!(r.kind, RunKind::Text))); + } +} + +fn find(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.find")?; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + let mut sl = (**sl).clone(); + bodies(&mut sl, &mut |loc, t| { + for (pi, para) in t.paragraphs.iter().enumerate() { + for (a, b) in para_matches(para, &o) { + out.push(json!({"slide": i, "shape": loc.shape, "cell": loc.cell.map(|(r, c)| [r, c]), "para": pi, "start": a, "end": b})); + } + } + }); + } + Ok(Value::Array(out)) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + let o = Opts::from(p, "text.replace")?; + let with = str_param(p, "with").unwrap_or("").to_string(); + let at = p.get("at").filter(|v| v.is_object()).map(|v| -> Result<(usize, Loc, usize, usize)> { + let n = |k: &str| v.get(k).and_then(Value::as_u64).map(|x| x as usize).ok_or_else(|| bad("text.replace", format!("`at` needs `{k}`"))); + let cell = v.get("cell").and_then(Value::as_array).and_then(|c| Some((c.first()?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))); + Ok((n("slide")?, Loc { shape: v.get("shape").and_then(Value::as_u64), cell }, n("para")?, n("start")?)) + }); + let at = at.transpose()?; + let count = s.edit(|doc, _| { + let mut count = 0; + for (i, slp) in doc.slides.iter_mut().enumerate() { + if at.is_some_and(|a| a.0 != i) { + continue; + } + let mut sl = (**slp).clone(); + let mut changed = 0; + bodies(&mut sl, &mut |loc, t| { + if at.is_some_and(|a| a.1 != loc) { + return; + } + for (pi, para) in t.paragraphs.iter_mut().enumerate() { + if at.is_some_and(|a| a.2 != pi) { + continue; + } + for (a, b) in para_matches(para, &o).into_iter().rev() { + if at.is_some_and(|x| x.3 != a) { + continue; + } + splice(para, a, b, &with); + changed += 1; + } + } + }); + if changed > 0 { + *slp = Arc::new(sl); + count += changed; + } + } + if count == 0 { + return Err(bad("text.replace", "no matches")); + } + Ok(count) + })?; + Ok(json!({"replaced": count})) +} + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_model::{Run, RunProps}; + + fn opts(t: &str, case: bool, whole: bool) -> Opts { + Opts { pat: t.chars().collect(), case, whole } + } + + #[test] + fn matching_honours_case_and_whole_words() { + let t: Vec = "Cat cat category".chars().collect(); + assert_eq!(opts("cat", false, false).matches(&t).len(), 3); + assert_eq!(opts("cat", true, false).matches(&t).len(), 2); + assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]); + } + + #[test] + fn splice_across_runs_keeps_the_first_runs_format() { + let bold = RunProps { bold: Some(true), ..Default::default() }; + let mut p = Paragraph { + runs: vec![ + Run { text: "Hel".into(), props: bold.clone(), kind: RunKind::Text }, + Run { text: "lo world".into(), props: RunProps::default(), kind: RunKind::Text }, + ], + ..Default::default() + }; + splice(&mut p, 1, 5, "ola"); + assert_eq!(para_chars(&p).iter().collect::(), "Hola world"); + assert_eq!(p.runs[0].text, "Hola"); + assert_eq!(p.runs[0].props, bold); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b4770c19..6dd914e9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub mod background; pub mod design; pub mod edit; pub mod file; +pub mod find; pub mod format; pub mod ideas; pub mod insert; @@ -152,6 +153,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(transition::specs()); v.extend(animation::specs()); v.extend(table::specs()); + v.extend(find::specs()); v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a22b6d56..2bc5f96e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2357,3 +2357,29 @@ fn shape_picture_fill_options_tile_offset_and_round_trip() { assert!(back.upright); assert!((back.alpha.unwrap() - 0.6).abs() < 1e-6); } + +#[test] +fn find_and_replace_across_shapes_and_notes() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Old plan, old team\nboldly"})).unwrap(); + s.execute("slide.notes", &json!({"text": "Discuss the old plan"})).unwrap(); + let q = json!({"text": "old"}); + let found = s.execute("text.find", &q).unwrap(); + let n = found.as_array().unwrap().len(); + assert!(n >= 3, "{found}"); + let whole = s.execute("text.find", &json!({"text": "old", "wholeWords": true, "matchCase": true})).unwrap(); + assert_eq!(whole.as_array().unwrap().len(), n - 2, "Old and boldly are not whole lower-case matches"); + + let first = found[0].clone(); + assert_eq!((first["para"].as_u64(), first["start"].as_u64()), (Some(0), Some(0))); + s.execute("text.replace", &json!({"text": "old", "with": "New", "at": first})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); + + let r = s.execute("text.replace", &json!({"text": "old", "with": "new", "wholeWords": true})).unwrap(); + assert_eq!(r["replaced"].as_u64(), Some(n as u64 - 2)); + let text = s.doc().unwrap().doc.slides[0].shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::>().join("|"); + assert!(text.contains("New plan, new team\nboldly"), "{text}"); + assert!(s.execute("text.replace", &json!({"text": "zebra", "with": "x"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c3010053..e31fcba1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -58,6 +58,7 @@ fn title(id: &str) -> &'static str { "dateTime" => "Date and Time", "spelling" => "Spelling", "pasteSpecial" => "Paste Special", + "find" => "Find and Replace", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", @@ -1687,6 +1688,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "find" => find_dialog(app, ui, d), "spelling" => { let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); @@ -2895,6 +2897,85 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } +/// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. +fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let replace_mode = d.get("replace", "0") == "1"; + field(ui, d, "Find what:", "text", ""); + if replace_mode { + field(ui, d, "Replace with:", "with", ""); + } + let case = check(ui, d, "Match case", "matchCase", false); + let whole = check(ui, d, "Find whole words only", "wholeWords", false); + let text = d.get("text", ""); + let q = json!({"text": text, "matchCase": case, "wholeWords": whole}); + let mut close = false; + let mut find_next = false; + ui.add_space(6.0); + ui.horizontal(|ui| { + let has = !text.is_empty(); + if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + find_next = true; + } + if replace_mode { + if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); + if let Some(at) = cur { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + p["at"] = at; + if app.session.execute("text.replace", &p).is_ok() { + let n = d.get("next", "1").parse::().unwrap_or(1); + d.fields.insert("next".into(), n.saturating_sub(1).to_string()); + } + } + find_next = true; + } + if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + let mut p = q.clone(); + p["with"] = json!(d.get("with", "")); + let msg = match app.session.execute("text.replace", &p) { + Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), + Err(_) => format!("DeckCraft can't find “{text}”."), + }; + d.fields.insert("status".into(), msg); + d.fields.remove("cur"); + } + } else if ui.button("Replace…").clicked() { + d.fields.insert("replace".into(), "1".into()); + } + if ui.button("Close").clicked() { + close = true; + } + }); + if find_next { + let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if list.is_empty() { + d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.remove("cur"); + } else { + let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); + let m = &list[n]; + let _ = app.run("slide.go", json!({"index": m.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = m.get("shape").filter(|v| !v.is_null()) { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + d.fields.insert("cur".into(), m.to_string()); + d.fields.insert("next".into(), (n + 1).to_string()); + let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + d.fields.insert( + "status".into(), + format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + ); + } + } + let status = d.get("status", ""); + if !status.is_empty() { + ui.add_space(4.0); + ui.label(status); + } + close +} + #[cfg(test)] #[path = "font_dialog_tests.rs"] mod font_dialog_tests; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92d06c1c..c22f851b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -216,6 +216,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.dialog", "Open Dialog", None, "{id}"), ("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"), ("app.qat", "Quick Access Toolbar", None, "{add: command id, params?, label?, icon?} | {remove: id, params?} | {reset: true} → [buttons]"), + ("app.find", "Find…", Some("Cmd+F"), "{}"), + ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -731,6 +733,12 @@ impl SlideApp { } } "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1f133ac4..440fd939 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -70,10 +70,8 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); return true; } - (F, true, false, false) => { - app.palette = Some((String::new(), 0)); - return true; - } + (F, true, false, false) => "app.find", + (H, true, false, false) => "app.replace", _ => return false, }; let _ = app.run(id, json!({})); @@ -193,7 +191,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Duplicate", "edit.duplicate"), ("Delete Slide", "slide.delete"), ("-", ""), - ("Find…", "app.palette"), + ("Find…", "app.find"), + ("Replace…", "app.replace"), ], ), ( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 936764b5..1aa06a52 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1160,6 +1160,12 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + }); + }); group(ui, |ui| { // Also under Arrange, but that is disabled until something is selected (#132). big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bacd8b06..b1e07a75 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -115,6 +115,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Font, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 29 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | | Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Grid Options, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Find and Replace, Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -200,6 +201,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Action Settings, Print, Insert Object) | 8–14 | | Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 | | Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 4–8 | +| Missing dialogs (Font, Bullets and Numbering, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | | Window chrome and system appearance (#41, #71) | 3–5 |