From deb9df299c04bbd7ca590e8189e6973c0347f0ca Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:16:26 -0400 Subject: [PATCH] =?UTF-8?q?M7.7:=20Type=20=E2=86=92=20Find=20Font=E2=80=A6?= =?UTF-8?q?=20(fonts=20in=20document=20with=20missing=20ones=20flagged,=20?= =?UTF-8?q?Find,=20Change,=20Change=20All;=20engine:=20text.fonts,=20text.?= =?UTF-8?q?replaceFont,=20select.font)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fonts.rs | 188 ++++++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/find_font.rs | 151 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 7 +- 6 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/fonts.rs create mode 100644 crates/ui-egui/src/find_font.rs diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs new file mode 100644 index 00000000..d1257312 --- /dev/null +++ b/crates/engine/src/cmd/fonts.rs @@ -0,0 +1,188 @@ +//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with +//! another everywhere or in the selection, and selecting the text that uses a font. + +use std::collections::BTreeMap; + +use drawcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use super::typecmd::refresh_bounds; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.fonts", + "Fonts in Document", + [], + None, + "{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name", + has_doc, + fonts + ), + cmd!( + "text.replaceFont", + "Replace Font", + [], + None, + "{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}", + has_doc, + replace + ), + cmd!( + "select.font", + "Select Text by Font", + [], + None, + "{family, style?} select the text objects using the font → {count}", + has_doc, + select_font + ), + ] +} + +/// Text objects in scope: the selection's (and their descendants') or the whole document's. +fn scope(s: &Session, selection_only: bool) -> Result> { + let st = s.doc()?; + let mut v = vec![]; + let mut add = |n: &drawcraft_doc::Node| { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Text(_)) && !v.contains(&c.id) { + v.push(c.id); + } + }) + }; + if selection_only { + for id in &st.selection.objects { + if let Some(n) = st.doc.node(*id) { + add(n); + } + } + } else { + for l in &st.doc.layers { + add(l); + } + } + Ok(v) +} + +fn text(d: &Document, id: NodeId) -> Option<&drawcraft_doc::TextObject> { + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + } +} + +fn fonts(s: &mut Session, p: &Value) -> Result { + let ids = scope(s, bool_or(p, "selectionOnly", false))?; + let d = &s.doc()?.doc; + let db = drawcraft_text::FontDb::global(); + let families = db.families(); + let mut found: BTreeMap<(String, String), (usize, Vec)> = BTreeMap::new(); + for id in ids { + for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() { + let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default(); + e.0 += 1; + if !e.1.contains(&id) { + e.1.push(id); + } + } + } + let list: Vec = found + .into_iter() + .map(|((family, style), (runs, objects))| { + let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family)); + let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style)); + json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing }) + }) + .collect(); + Ok(json!(list)) +} + +fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option)> { + let v = p.get(k).ok_or_else(|| bad(c, format!("missing `{k}`")))?; + let family = + v.get("family").and_then(Value::as_str).filter(|f| !f.trim().is_empty()).ok_or_else(|| bad(c, format!("`{k}.family` is required")))?; + Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string))) +} + +fn replace(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.replaceFont"; + let (ff, fs) = font_param(p, "from", C)?; + let (tf, ts) = font_param(p, "to", C)?; + let db = drawcraft_text::FontDb::global(); + if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) { + return Err(bad(C, format!("font family `{tf}` is not available"))); + } + let ids = scope(s, bool_or(p, "selectionOnly", false))?; + let matches = + |st: &drawcraft_doc::CharStyle| st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)); + let n = s.edit("Replace Font", |d, _| { + let mut n = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let mut changed = false; + for r in &mut t.runs { + if matches(&r.style) { + // An explicit style, else the closest the new family has to the old one. + let style = ts.clone().unwrap_or_else(|| db.face(&tf, &r.style.font_style).style.clone()); + r.style.font_family = tf.clone(); + r.style.font_style = style; + changed = true; + n += 1; + } + } + if changed { + drawcraft_text::edit::normalize(&mut t.runs); + refresh_bounds(t); + } + } + Ok(n) + })?; + Ok(json!({ "runs": n })) +} + +fn select_font(s: &mut Session, p: &Value) -> Result { + let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string(); + let style = str_param(p, "style").map(str::to_string); + let ids = scope(s, false)?; + let d = &s.doc()?.doc; + let hits: Vec = ids + .into_iter() + .filter(|id| { + text(d, *id).is_some_and(|t| { + t.runs.iter().any(|r| { + r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x)) + }) + }) + }) + .collect(); + let n = hits.len(); + s.doc_mut()?.selection.set(hits); + Ok(json!({ "count": n })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_flag_missing_replace_and_select() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let a = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Gone", "font": "No Such Font Family"})).unwrap()["id"].as_u64().unwrap(); + let list = s.execute("text.fonts", &json!({})).unwrap(); + let missing: Vec<&str> = list.as_array().unwrap().iter().filter(|f| f["missing"] == true).map(|f| f["family"].as_str().unwrap()).collect(); + assert_eq!(missing, ["No Such Font Family"]); + assert_eq!(s.execute("select.font", &json!({"family": "No Such Font Family"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(b)]); + let r = s.execute("text.replaceFont", &json!({"from": {"family": "No Such Font Family"}, "to": {"family": "Source Sans 3"}})).unwrap(); + assert_eq!(r["runs"], 1); + let fams: Vec = + s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().map(|f| f["family"].as_str().unwrap().to_string()).collect(); + assert_eq!(fams, ["Source Sans 3", "Source Serif 4"]); + assert!(s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Nope"}})).is_err()); + let _ = a; + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 106948f5..c0e1b234 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -13,6 +13,7 @@ mod draw2; mod edit; mod effectcmd; mod file; +mod fonts; mod layer; mod live; pub(crate) mod menucmds; @@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(typemenu::specs()); v.extend(textedit::specs()); v.extend(textstyles::specs()); + v.extend(fonts::specs()); v.extend(threads::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab95f835..0fac13bd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -126,6 +126,7 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result { "preferences" => return crate::prefs_dialog::confirm(app), "shortcuts" => return crate::shortcut_editor::confirm(app), "newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app), + "findFont" => return crate::find_font::confirm(app), _ => {} } let copy = d.bool("copy"); @@ -262,6 +263,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { "preferences" => return crate::prefs_dialog::show(app, ctx), "shortcuts" => return crate::shortcut_editor::show(app, ctx), "newWorkspace" | "manageWorkspaces" => return crate::workspaces::show(app, ctx), + "findFont" => return crate::find_font::show(app, ctx), _ => {} } let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs new file mode 100644 index 00000000..2bbe018a --- /dev/null +++ b/crates/ui-egui/src/find_font.rs @@ -0,0 +1,151 @@ +//! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text +//! using a font), and Change / Change All to another installed font. + +use egui::Ui; +use serde_json::{Value, json}; + +use crate::DrawcraftApp; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::widgets; + +/// Open the dialog. +pub fn open(app: &mut DrawcraftApp) { + app.ui.dialog = Some(Dialog::new("findFont", json!({ "selected": 0, "family": "Source Sans 3", "style": "", "selectionOnly": false }))); +} + +fn fonts(app: &mut DrawcraftApp) -> Vec { + app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default() +} + +pub fn confirm(app: &mut DrawcraftApp) -> Result { + app.ui.dialog = None; + Ok(Value::Null) +} + +/// Replace the selected document font with the chosen one (`all`: everywhere, else the selection). +fn change(app: &mut DrawcraftApp, d: &Dialog, from: &Value, all: bool) -> Result { + let style = d.str("style"); + let to = if style.is_empty() { json!({ "family": d.str("family") }) } else { json!({ "family": d.str("family"), "style": style }) }; + app.run("text.replaceFont", json!({ "from": { "family": from["family"], "style": from["style"] }, "to": to, "selectionOnly": !all })) +} + +pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.dialog.clone() else { return }; + let t = Tokens::get(ctx); + let list = fonts(app); + let mut close = false; + let mut act: Option<&str> = None; + egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { + ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); + }); + egui::Window::new("Find Font") + .id(egui::Id::new("dialog-find-font")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0]) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) + .show(ctx, |ui: &mut Ui| { + ui.set_width(380.0); + ui.label(egui::RichText::new("Find Font").font(theme::semibold(16.0)).color(t.text)); + ui.add_space(10.0); + widgets::subheader(ui, &format!("Fonts in Document: {}", list.len())); + let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize; + egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { + ui.set_min_height(120.0); + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { + for (i, f) in list.iter().enumerate() { + let missing = f["missing"] == true; + let label = format!( + "{} {}{} ({})", + f["family"].as_str().unwrap_or(""), + f["style"].as_str().unwrap_or(""), + if missing { " — missing" } else { "" }, + f["runs"] + ); + let text = egui::RichText::new(label).color(if missing { egui::Color32::from_rgb(230, 90, 90) } else { t.text }); + if ui.selectable_label(i == sel, text).clicked() { + d.fields.insert("selected".into(), json!(i)); + } + } + }); + }); + ui.add_space(10.0); + widgets::subheader(ui, "Replace With Font"); + let families = drawcraft_text::FontDb::global().families(); + let fam = d.str("family"); + ui.horizontal(|ui| { + let names: Vec<&str> = families.iter().map(String::as_str).collect(); + if let Some(i) = widgets::dropdown(ui, "ff-family", &fam, &names, 220.0) { + d.fields.insert("family".into(), json!(families[i])); + d.fields.insert("style".into(), json!("")); + } + let styles = drawcraft_text::FontDb::global().styles(&d.str("family")); + let mut opts: Vec<&str> = vec!["(closest)"]; + opts.extend(styles.iter().map(String::as_str)); + let cur = if d.str("style").is_empty() { "(closest)".to_string() } else { d.str("style") }; + if let Some(i) = widgets::dropdown(ui, "ff-style", &cur, &opts, 120.0) { + d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() })); + } + }); + ui.add_space(14.0); + ui.horizontal(|ui| { + let has = sel < list.len(); + if ui.add_enabled(has, egui::Button::new("Find")).clicked() { + act = Some("find"); + } + if ui.add_enabled(has, egui::Button::new("Change")).on_hover_text("In the selected objects").clicked() { + act = Some("change"); + } + if ui.add_enabled(has, egui::Button::new("Change All")).clicked() { + act = Some("changeAll"); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::primary_button(ui, "Done").clicked() { + close = true; + } + }); + }); + }); + let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize; + if let (Some(a), Some(from)) = (act, list.get(sel)) { + let r = match a { + "find" => app.run("select.font", json!({ "family": from["family"], "style": from["style"] })), + "change" => change(app, &d, from, false), + _ => change(app, &d, from, true), + }; + if let Err(e) = r { + app.ui.status = e; + } + } + if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.dialog = None; + } else if app.ui.dialog.is_some() { + app.ui.dialog = Some(d); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dialog_draws_and_changes_all() { + let mut app = DrawcraftApp::new(drawcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap(); + open(&mut app); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.clone().unwrap(); + let from = fonts(&mut app)[0].clone(); + assert_eq!(from["missing"], true); + change(&mut app, &d, &from, true).unwrap(); + assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 57c29016..b944c6d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -12,6 +12,7 @@ pub mod control; pub mod cursors; pub mod dialogs; pub mod dock; +pub mod find_font; pub mod icon_data; pub mod icons; pub mod io; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 803f32f7..d60574a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -60,6 +60,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.openRecent9", "Open Recent File 9", "", "{}"), ("file.openRecent10", "Open Recent File 10", "", "{}"), ("file.clearRecent", "Clear Recent Files", "", "{}"), + ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), ("file.export.svg", "Export As SVG…", "", "{path?}"), ("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"), @@ -169,6 +170,10 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option Err("no such recent file".into()), } } + "type.findFont" => { + crate::find_font::open(app); + Ok(Value::Null) + } "file.clearRecent" => { app.ui.recent_files.clear(); Ok(Value::Null) @@ -828,7 +833,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ), c("Fit Headline", "text.fitHeadline"), Sep, - todo("Find Font…"), + c("Find Font…", "type.findFont"), sub( "Change Case", vec![