M7.7: Type → Find Font… (fonts in document with missing ones flagged, Find, Change, Change All; engine: text.fonts, text.replaceFont, select.font)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 13:16:26 -04:00
parent d5aa387367
commit deb9df299c
6 changed files with 350 additions and 1 deletions

View File

@ -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<CommandSpec> {
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<Vec<NodeId>> {
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<Value> {
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<NodeId>)> = 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<Value> = 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<String>)> {
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<Value> {
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<Value> {
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<NodeId> = 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<String> =
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;
}
}

View File

@ -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());

View File

@ -126,6 +126,7 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
"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);

View File

@ -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<Value> {
app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default()
}
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
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<Value, String> {
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");
}
}

View File

@ -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;

View File

@ -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<Res
None => 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<Item>)> {
),
c("Fit Headline", "text.fitHeadline"),
Sep,
todo("Find Font…"),
c("Find Font…", "type.findFont"),
sub(
"Change Case",
vec![