Home: Replace Fonts dialog and font.replace command

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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:20:09 +03:00
co-authored by Cursor
parent 457406a7a5
commit 37bd53c62e
9 changed files with 299 additions and 5 deletions
+78
View File
@@ -13,6 +13,16 @@ use crate::{DocState, Result, Selection, Session, Target, TextSel};
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<String> {
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());
}
}
+89 -1
View File
@@ -21,7 +21,7 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
}
/// The families the slides' text runs and bullets are drawn in.
fn used_fonts(p: &Presentation) -> Vec<String> {
pub fn used_fonts(p: &Presentation) -> Vec<String> {
let mut out: Vec<String> = 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<String>| {
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<String>)) {
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<String>)) {
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<String>)) {
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<String>)) {
for f in [&mut r.font, &mut r.font_ea, &mut r.font_cs, &mut r.font_sym] {
fix(f);
}
}
+47
View File
@@ -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<String> =
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] = [
+2
View File
@@ -1129,6 +1129,8 @@ fn js_now() -> f64 {
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod replacefonts_tests;
#[cfg(test)]
mod tests {
+70
View File
@@ -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<String> {
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();
}
+5
View File
@@ -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"}));
});
+4
View File
@@ -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),
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 |