From 37bd53c62e0ce3d8068dcae7093472d23e9df531 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:20:09 +0300 Subject: [PATCH] Home: Replace Fonts dialog and font.replace command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit font.replace {from, to} renames a font family everywhere it is named explicitly: runs (latin, east Asian, complex script and symbol faces), end marks, bullet fonts, list styles of shapes, layouts and masters, the master text styles, the deck default style, notes, tables and the theme fonts, so placeholders that follow the theme change too. Names compare case-insensitively, theme references (+mn-lt) are left alone, a replace that finds nothing adds no undo step, and missing fonts are recomputed. font.used lists the families the slides draw in, for the dialog's Replace list. Home gets a Replace Fonts… button opening the dialog: Replace (fonts in the deck), With (type or pick from installed fonts), and it stays open with a confirmation like PowerPoint's. Ribbon small buttons now expose their label to accessibility, which the UI test uses to click it. Co-authored-by: Cursor --- crates/engine/src/cmd/text.rs | 78 ++++++++++++++++++++ crates/engine/src/fonts.rs | 90 +++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 47 +++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/replacefonts_tests.rs | 70 ++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 5 ++ crates/ui-egui/src/widgets.rs | 4 ++ docs/gaps.md | 4 +- docs/ui-parity.md | 4 +- 9 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/replacefonts_tests.rs diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..1aafb4de 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -13,6 +13,16 @@ use crate::{DocState, Result, Selection, Session, Target, TextSel}; pub fn specs() -> Vec { vec![ + cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used), + cmd!( + "font.replace", + "Replace Fonts", + ["Home", "Editing", "Replace"], + None, + "{from, to} — every explicit use of family `from`, theme fonts included → {replaced}", + has_doc, + replace_font + ), cmd!(noundo "text.edit", "Edit Text", [], Some("Enter"), "{id?, at?: [paragraph, char], end?: bool, cell?: [row, col], notes?: bool}", has_slide, edit), cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit), cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert), @@ -900,3 +910,71 @@ pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex } ok() } + +fn fonts_used(s: &mut Session, _: &Value) -> Result { + let mut v = crate::fonts::used_fonts(&s.doc()?.doc); + v.sort_by_key(|f| f.to_lowercase()); + Ok(json!(v)) +} + +fn replace_font(s: &mut Session, p: &Value) -> Result { + let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string(); + let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string(); + if to.len() > 256 || to.chars().any(char::is_control) { + return Err(bad("font.replace", "`to` is not a font name")); + } + // Count on a copy first so a replace that finds nothing leaves no undo step. + let mut probe = (*s.doc()?.doc).clone(); + if crate::fonts::replace_font(&mut probe, &from, &to) == 0 { + return Ok(json!({"replaced": 0})); + } + let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?; + let st = s.doc_mut()?; + st.missing_fonts = crate::fonts::missing_fonts(&st.doc); + Ok(json!({"replaced": n})) +} + +#[cfg(test)] +mod replace_font_tests { + use serde_json::json; + + use crate::Session; + + fn fonts(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap() + } + + #[test] + fn replaces_runs_tables_notes_and_theme_and_undoes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s)); + let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap(); + assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}"); + let used = fonts(&mut s); + assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}"); + // The theme's body font is replaced too, so placeholders follow. + s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue")); + } + + #[test] + fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() { + let mut s = Session::with_new(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err()); + } +} diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index fcbd2feb..328ad157 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -21,7 +21,7 @@ pub fn missing_fonts(p: &Presentation) -> Vec { } /// The families the slides' text runs and bullets are drawn in. -fn used_fonts(p: &Presentation) -> Vec { +pub fn used_fonts(p: &Presentation) -> Vec { let mut out: Vec = Vec::new(); let mut add = |f: String| { let f = f.trim().to_string(); @@ -63,3 +63,91 @@ fn body_fonts(ctx: &Ctx, s: &Shape, body: &TextBody, add: &mut dyn FnMut(String) } } } + +/// Replace the font family `from` with `to` everywhere it is named explicitly: text runs (and +/// their east Asian, complex script and symbol faces), bullet fonts, paragraph end marks, list +/// styles of shapes, layouts and masters, the master text styles, the deck default style, speaker +/// notes, tables and the theme fonts. Names compare case-insensitively; theme references +/// (`+mn-lt`) are left alone. Returns how many names changed. +pub fn replace_font(p: &mut Presentation, from: &str, to: &str) -> usize { + use std::sync::Arc; + let from = from.trim(); + let to = to.trim(); + if from.is_empty() || to.is_empty() || from.starts_with('+') || from == to { + return 0; + } + let mut n = 0usize; + let mut fix = |f: &mut Option| { + if f.as_deref().is_some_and(|x| x.trim().eq_ignore_ascii_case(from)) { + *f = Some(to.to_string()); + n = n.saturating_add(1); + } + }; + replace_list(&mut p.default_text_style, &mut fix); + for slide in &mut p.slides { + let sl = Arc::make_mut(slide); + replace_shapes(&mut sl.shapes, &mut fix); + replace_body(&mut sl.notes, &mut fix); + } + let mut theme_hits = 0usize; + let masters = p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()); + for m in masters { + let m = Arc::make_mut(m); + replace_shapes(&mut m.shapes, &mut fix); + for l in [&mut m.title_style, &mut m.body_style, &mut m.other_style] { + replace_list(l, &mut fix); + } + for lay in &mut m.layouts { + replace_shapes(&mut lay.shapes, &mut fix); + } + for set in [&mut m.theme.fonts.major, &mut m.theme.fonts.minor] { + for face in [&mut set.latin, &mut set.ea, &mut set.cs].into_iter().chain(set.scripts.iter_mut().map(|s| &mut s.typeface)) { + if face.trim().eq_ignore_ascii_case(from) { + *face = to.to_string(); + theme_hits = theme_hits.saturating_add(1); + } + } + } + } + n.saturating_add(theme_hits) +} + +fn replace_shapes(shapes: &mut [Shape], fix: &mut dyn FnMut(&mut Option)) { + for s in shapes { + if let Some(body) = &mut s.text { + replace_body(body, fix); + } + if let ShapeKind::Table(t) = &mut s.kind { + for cell in t.rows.iter_mut().flat_map(|r| &mut r.cells) { + replace_body(&mut cell.text, fix); + } + } + if let Some(children) = s.children_mut() { + replace_shapes(children, fix); + } + } +} + +fn replace_body(body: &mut TextBody, fix: &mut dyn FnMut(&mut Option)) { + replace_list(&mut body.list_style, fix); + for para in &mut body.paragraphs { + fix(&mut para.props.bullet_font); + replace_run(&mut para.end_props, fix); + for run in &mut para.runs { + replace_run(&mut run.props, fix); + } + } +} + +fn replace_list(l: &mut deckcraft_model::text::ListStyle, fix: &mut dyn FnMut(&mut Option)) { + for lvl in l.levels.iter_mut().flatten() { + fix(&mut lvl.para.bullet_font); + replace_run(&mut lvl.run, fix); + } +} + +fn replace_run(r: &mut deckcraft_model::text::RunProps, fix: &mut dyn FnMut(&mut Option)) { + for f in [&mut r.font, &mut r.font_ea, &mut r.font_cs, &mut r.font_sym] { + fix(f); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..802b56c0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -47,6 +47,7 @@ fn title(id: &str) -> &'static str { "preferences" => "Preferences", "export" => "Export", "symbol" => "Symbol", + "replaceFonts" => "Replace Font", "equation" => "Equation", "spelling" => "Spelling", "accessibility" => "Accessibility Checker", @@ -466,6 +467,52 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "replaceFonts" => { + let used: Vec = + app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); + let mut to = d.get("to", ""); + egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + ui.label("Replace:"); + egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { + for f in &used { + ui.selectable_value(&mut from, f.clone(), f); + } + }); + ui.end_row(); + ui.label("With:"); + ui.vertical(|ui| { + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + let needle = to.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { + for f in + crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) + { + if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { + to = f.clone(); + } + } + }); + }); + ui.end_row(); + }); + if let Some(msg) = d.fields.get("result") { + ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to.clone()); + let (ok, cancel) = buttons(ui, "Replace"); + if ok && !from.is_empty() && !to.trim().is_empty() { + let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); + let msg = + if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; + d.fields.insert("result".into(), msg); + d.fields.remove("from"); + // Stay open, like PowerPoint, so several fonts can be replaced in a row. + return cancel; + } + cancel + } "symbol" => { ui.label("Click a symbol to insert it at the insertion point."); let groups: [(&str, &str); 6] = [ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..ca7405b2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1129,6 +1129,8 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod replacefonts_tests; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs new file mode 100644 index 00000000..cec694bf --- /dev/null +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -0,0 +1,70 @@ +//! Home › Replace Fonts…: the dialog lists the deck's fonts and replaces one, as one undo step. + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("insert.textBox", &json!({"rect": [40, 40, 400, 80], "text": "Hello fonts"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); + s.execute("format.size", &json!({"size": 40})).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn run_font(h: &Harness<'_, SlideApp>) -> Option { + let st = h.state().session.active().unwrap(); + st.current_slide() + .unwrap() + .shapes + .iter() + .find_map(|s| s.text.as_ref()) + .and_then(|b| b.paragraphs.first()) + .and_then(|p| p.runs.first()) + .and_then(|r| r.props.font.clone()) +} + +#[test] +fn ribbon_button_opens_the_dialog_and_replaces() { + let mut h = harness(); + h.get_by_label("Replace Fonts…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("replaceFonts")); + // Pick the font to replace and type the new one, the way a person would. + if let Some(d) = h.state_mut().dialog.as_mut() { + d.fields.insert("from".into(), "Comic Neue".into()); + d.fields.insert("to".into(), "Lato".into()); + } + h.run_steps(2); + h.get_by_label("Replace").click(); + h.run_steps(3); + assert_eq!(run_font(&h).as_deref(), Some("Lato")); + // The dialog stays open with a confirmation, like PowerPoint's. + assert!(h.query_by_label_contains("Replaced “Comic Neue”").is_some()); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(run_font(&h).as_deref(), Some("Comic Neue")); +} + +#[test] +#[ignore = "writes a screenshot for review"] +fn screenshot() { + let mut h = harness(); + h.render().unwrap().save("/tmp/replacefonts0.png").unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + h.run_steps(4); + h.render().unwrap().save("/tmp/replacefonts.png").unwrap(); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..643c9da0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -998,6 +998,11 @@ fn home(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); }); drawing_group(app, ui); + group(ui, |ui| { + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } + }); group(ui, |ui| { big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..972630ac 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -121,6 +121,10 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); let main_resp = if split { ui.interact(main_rect, resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() }; + // Screen readers (and UI tests) find the button by its text. + if !label.is_empty() { + main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + } let arrow_resp = split.then(|| { ui.interact( Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max), diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..fa1afcfb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,8 +203,8 @@ Detail in [ui-parity.md](ui-parity.md). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark). 2–4 h. - **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules. -- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, - Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h. +- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog, + Action Settings, Print, Insert Object…). 8–14 h. - **Command palette defaults to an Export entry that fails** (#79). 0.5 h. ## Hardware diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..f2eda97b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | -| 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…) | 27 (`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, 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, Export, Symbol, Replace Fonts, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages | ## Direct manipulation @@ -137,7 +137,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---| | Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 | | Format Shape pane depth | 6–10 | -| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 | +| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Action Settings, Print, Insert Object) | 8–14 | | Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 | | Grid Options, edit points UI, crop depth | 4–7 | | Multiple windows | 3–5 |