diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3e08474a..65b19ee4 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) }) .collect(); + let missing = figcraft_engine::fonts::missing(&doc); let v = json!({ "file": path, "name": doc.name, @@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { "variables": doc.variables.variables.len(), "images": doc.images.len(), "notes": notes, + "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), }); if as_json { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); } } + for m in &missing { + let layers = if m.layers == 1 { "text layer" } else { "text layers" }; + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + } Ok(ExitCode::SUCCESS) } diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index c5901ac5..ef5c638f 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() { assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); } + +#[test] +fn info_lists_the_fonts_a_file_uses_that_are_missing() { + let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let (script, file) = (d.join("make.json"), d.join("doc.figcraft")); + let text = + |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}}); + std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap(); + let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let out = cli().arg("info").arg(&file).arg("--json").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}])); + + let out = cli().arg("info").arg(&file).output().unwrap(); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 10147c2c..fe89d0a7 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -161,6 +161,7 @@ fn main() -> eframe::Result { log::error!("{f}: {e}"); } } + let opened: Vec = session.docs.iter().map(|d| d.uid).collect(); // Documents a crashed or killed run left unsaved come back; this run keeps its own copies. let recovery_root = services::config_dir().map(|d| d.join("Recovery")); // The preferences first: they hold the online files a recovered document may be one of. @@ -194,6 +195,8 @@ fn main() -> eframe::Result { let mut fig = FigApp::new(session, Box::new(svc)); fig.automation_roots = roots; fig.ui.home = false; + // Files named on the command line are the user's too: their missing fonts are listed. + fig.ui.font_checks = opened; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { figcraft_ui_egui::live::open_invite(&mut fig, link); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc1..4b6cd3fc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), @@ -287,7 +289,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; +const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b942fc25..f011238d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -80,6 +80,7 @@ fn registry() -> &'static Registry { list.extend(page::specs()); list.extend(view::specs()); list.extend(text::specs()); + list.extend(crate::fonts::specs()); list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index de69e690..69b938e9 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul Ok(json!({"on": *slot})) } +/// One of a view's on/off settings. +type Slot = fn(&mut ViewState) -> &mut bool; + +/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and +/// whether the preferences keep each: files opened or made later start with the kept ones. Outline +/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one. +const TOGGLES: &[(&str, Slot, bool)] = &[ + ("view.pixelGrid", |v| &mut v.pixel_grid, true), + ("view.layoutGrids", |v| &mut v.layout_grids, true), + ("view.rulers", |v| &mut v.rulers, true), + ("view.slices", |v| &mut v.show_slices, true), + ("view.comments", |v| &mut v.comments, true), + ("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true), + ("view.outlines", |v| &mut v.outlines, false), + ("view.outlinesSelection", |v| &mut v.outlines_selection, false), + ("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true), + ("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true), + ("view.maskOutlines", |v| &mut v.mask_outlines, true), + ("view.frameOutlines", |v| &mut v.frame_outlines, true), + ("view.proofSrgb", |v| &mut v.proof_srgb, false), + ("view.snapGeometry", |v| &mut v.snap_geometry, true), + ("view.snapObjects", |v| &mut v.snap_objects, true), + ("view.snapPixel", |v| &mut v.snap_pixel, true), +]; + +/// View toggle `id`: the file's view changes, and the preferences remember a kept one. +fn toggle(s: &mut Session, p: &Value, id: &str) -> Result { + let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) }; + let r = flag(s, p, f)?; + if keep && let Some(on) = r.get("on").and_then(Value::as_bool) { + s.prefs.view.insert(id.to_string(), on); + } + Ok(r) +} + +/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are). +pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) { + for (id, f, _) in TOGGLES.iter().filter(|t| t.2) { + if let Some(on) = prefs.view.get(*id) { + *f(v) = *on; + } + } +} + /// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command /// that isn't one. pub fn checked(s: &Session, id: &str) -> Option { - let v = &s.ds().ok()?.view; - Some(match id { - "view.pixelGrid" => v.pixel_grid, - "view.layoutGrids" => v.layout_grids, - "view.rulers" => v.rulers, - "view.slices" => v.show_slices, - "view.comments" => v.comments, - "view.multiplayerCursors" => v.multiplayer_cursors, - "view.outlines" => v.outlines, - "view.outlinesSelection" => v.outlines_selection, - "view.outlinesHiddenLayers" => v.outline_hidden, - "view.outlinesObjectBounds" => v.outline_bounds, - "view.maskOutlines" => v.mask_outlines, - "view.pixelPreview" => v.pixel_preview, - "view.frameOutlines" => v.frame_outlines, - "view.proofSrgb" => v.proof_srgb, - "view.snapGeometry" => v.snap_geometry, - "view.snapObjects" => v.snap_objects, - "view.snapPixel" => v.snap_pixel, - _ => return None, - }) + let mut v = s.ds().ok()?.view.clone(); + if id == "view.pixelPreview" { + return Some(v.pixel_preview); + } + TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v)) } /// Pixel preview: toggles, or turns on at a resolution of 1x or 2x. @@ -235,46 +263,46 @@ pub fn specs() -> Vec { spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)), spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)), spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)), - spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)), - spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)), - spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)), - spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)), - spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)), + spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")), + spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")), + spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")), + spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")), + spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")), spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| { - flag(s, p, |v| &mut v.multiplayer_cursors) + toggle(s, p, "view.multiplayerCursors") }), spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan), - spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)), + spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")), spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| { - flag(s, p, |v| &mut v.outlines_selection) + toggle(s, p, "view.outlinesSelection") }), spec!( "view.outlinesHiddenLayers", "Include hidden layers in outlines", "{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there", false, - |s, p| { flag(s, p, |v| &mut v.outline_hidden) } + |s, p| { toggle(s, p, "view.outlinesHiddenLayers") } ), spec!( "view.outlinesObjectBounds", "Include object bounds in outlines", "{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer", false, - |s, p| { flag(s, p, |v| &mut v.outline_bounds) } + |s, p| { toggle(s, p, "view.outlinesObjectBounds") } ), - spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)), + spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")), spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview), - spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)), + spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")), spec!( "view.proofSrgb", "Soft-proof sRGB", "{on?} mark the colours of a Display P3 file that sRGB screens and files can't show", false, - |s, p| { flag(s, p, |v| &mut v.proof_srgb) } + |s, p| { toggle(s, p, "view.proofSrgb") } ), - spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)), - spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)), - spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)), + spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")), + spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")), + spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| { s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")})); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..3548124d --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,100 @@ +//! Fonts a document names that this machine doesn't have (#752): text layers, their styled +//! ranges, text styles and the string variables bound to a font family. Text in such a family +//! is drawn in a fallback font, so the user is told which ones are missing. + +use std::collections::BTreeMap; + +use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::{Result, Session, spec}; + +/// A family the document names that no installed or built-in font provides. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + /// As the document spells it. + pub family: String, + /// Text layers that use it (0: only a text style or a variable names it). + pub layers: usize, +} + +/// The families `doc` names that the font database doesn't have, by name. On the desktop the +/// installed fonts are indexed in the background: ask once that's done +/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing. +pub fn missing(doc: &Document) -> Vec { + // Lower-case family → (spelling, layers). + let mut used: BTreeMap = BTreeMap::new(); + let mut add = |family: &str, layer: bool| { + let family = family.trim(); + if family.is_empty() { + return; + } + let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0)); + e.1 += usize::from(layer); + }; + for n in doc.nodes() { + if let NodeKind::Text(t) = &n.kind { + let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect(); + families.push(&t.style.font_family); + families.sort_by_key(|f| f.trim().to_lowercase()); + families.dedup_by_key(|f| f.trim().to_lowercase()); + for f in families { + add(f, true); + } + } + if let Some(var) = n.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + for st in &doc.styles { + if let StyleKind::Text { style } = &st.kind { + add(&style.font_family, false); + } + if let Some(var) = st.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() +} + +/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +fn string_values(vars: &Variables, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![(id.to_string(), 0)]; + while let Some((id, depth)) = todo.pop() { + let Some(var) = vars.get(&id) else { continue }; + for v in var.values.values() { + match v { + VariableValue::String(s) => out.push(s.clone()), + VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + _ => {} + } + } + } + out +} + +fn missing_fonts(s: &mut Session, _: &Value) -> Result { + let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + s.request_ui("dialog.missingFonts", json!({})); + Ok(json!({"families": families})) +} + +pub fn specs() -> Vec { + vec![spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + )] +} + +#[cfg(test)] +#[path = "fonts_tests.rs"] +mod tests; diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs new file mode 100644 index 00000000..a6e2d213 --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,126 @@ +use serde_json::json; + +use super::*; +use crate::params::id_of; +use figcraft_doc::{Style, StyleRun, TextStyle}; + +fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId { + let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + t.style.font_family = family.into(); + } + id +} + +fn families(s: &mut Session) -> Vec<(String, u64)> { + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect() +} + +/// An installed family (built in, or found by the system scan), if this machine has any. +fn installed() -> Option { + figcraft_text::scan_system_fonts(); + figcraft_text::family_names().into_iter().next() +} + +#[test] +fn lists_families_of_layers_ranges_styles_and_variables_once_each() { + let mut s = Session::new(); + text(&mut s, "FigCraft Missing A"); + let b = text(&mut s, "figcraft missing a"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind { + let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() }; + t.runs.push(StyleRun { start: 0, end: 3, style }); + } + let doc = s.doc_mut().unwrap(); + doc.styles.push(Style { + id: "S:1".into(), + name: "Heading".into(), + description: String::new(), + kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + bound: Default::default(), + }); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap(); + s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap(); + let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var); + + assert_eq!( + families(&mut s), + vec![ + ("FigCraft Missing A".to_string(), 2), + ("FigCraft Missing B".to_string(), 1), + ("FigCraft Missing C".to_string(), 0), + ("FigCraft Missing D".to_string(), 0), + ("FigCraft Missing H".to_string(), 0), + ] + ); + assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts")); +} + +#[test] +fn installed_families_are_not_listed() { + let Some(family) = installed() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + text(&mut s, &family.to_uppercase()); + text(&mut s, "FigCraft Missing E"); + assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]); +} + +#[test] +fn a_family_found_only_by_the_system_scan_is_not_missing() { + figcraft_text::scan_system_fonts(); + let embedded: Vec = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect(); + let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else { + eprintln!("skipped: no system fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + assert!(families(&mut s).is_empty(), "{family} is installed"); +} + +#[test] +fn blank_families_and_aliases_are_handled() { + let mut s = Session::new(); + text(&mut s, " "); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap(); + let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone(); + let t = text(&mut s, "FigCraft Missing G"); + let doc = s.doc_mut().unwrap(); + // A variable that only aliases another, and one aliasing itself. + let mut alias = doc.variables.get(&base).unwrap().clone(); + alias.id = "V:alias".into(); + alias.name = "Alias".into(); + for v in alias.values.values_mut() { + *v = VariableValue::Alias(base.clone()); + } + let mut cycle = alias.clone(); + cycle.id = "V:cycle".into(); + cycle.name = "Cycle".into(); + for v in cycle.values.values_mut() { + *v = VariableValue::Alias("V:cycle".into()); + } + doc.variables.variables.push(alias); + doc.variables.variables.push(cycle); + doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); + let other = text(&mut s, "FigCraft Missing G"); + s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); + // The bound layer takes the value its alias resolves to (bindings are applied after the next + // command); the cycle resolves to nothing. + let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() }; + assert_eq!(bound.style.font_family, "FigCraft Missing F"); + assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); +} + +#[test] +fn needs_an_open_document() { + let mut s = Session::empty(); + assert!(s.execute("text.missingFonts", &json!({})).is_err()); +} diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs index 8e9ede7b..3da9522f 100644 --- a/crates/engine/src/frame_label_tests.rs +++ b/crates/engine/src/frame_label_tests.rs @@ -1,6 +1,7 @@ //! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it, //! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it -//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing. +//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked, +//! hidden, deleted or other pages' frames do nothing. use figcraft_doc::NodeId; use figcraft_geom::Point; @@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() { let flat = [Point::new(1.0, 1.0); 4]; assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None); } + +#[test] +fn double_clicking_a_label_asks_to_rename_its_frame() { + let (mut s, f, q) = setup(); + let c = center(&q); + let before = s.doc().unwrap().world_bounds(f).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] { + s.pointer(&ev(kind, c)).unwrap(); + } + assert_eq!(s.selection(), vec![f]); + let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"])); + assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label"); + assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet"); + // Other tools ignore the label. + s.ui_requests.clear(); + s.set_tool(crate::tools::ToolId::Hand); + s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap(); + assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2254269e..af0f555e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod distance; pub mod doc_urls; pub mod drop; pub mod file_access; +pub mod fonts; pub mod guard; pub mod guides; pub mod inspect; @@ -375,6 +376,8 @@ pub struct DocState { pub guide: Option<(NodeId, usize)>, /// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved). pub(crate) rotation_origin: Option, + /// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]). + view_from_prefs: bool, /// The last duplicate while its copies are the selection: Duplicate repeats its offset (see /// [`cmd::edit::Duplicated`]; not saved). pub(crate) last_duplicate: Option, @@ -436,6 +439,7 @@ impl DocState { focus: None, guide: None, rotation_origin: None, + view_from_prefs: false, last_duplicate: None, }; st.zoom_to_fit(); @@ -664,6 +668,10 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id: + /// files opened or made later start with them. Missing: the toggle's default. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub view: BTreeMap, /// Recently run actions (the Actions palette's Recents), newest first, at most /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or /// `widget:`. @@ -702,6 +710,7 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + view: BTreeMap::new(), recent_actions: Vec::new(), } } @@ -854,6 +863,14 @@ impl Session { s } + /// Files new to the session start with the View toggles the preferences keep. + pub fn apply_view_prefs(&mut self) { + for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) { + cmd::view::apply_kept(&self.prefs, &mut ds.view); + ds.view_from_prefs = true; + } + } + pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } @@ -895,6 +912,7 @@ impl Session { return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into())); } branches::sync(self); + self.apply_view_prefs(); if textpreview::COMMANDS.contains(&id) { // A live preview is no edit: no undo step, normalisation or rollback here. let r = guard::run(id, || (spec.run)(self, params)); @@ -920,6 +938,7 @@ impl Session { match &result { Ok(_) => { self.after_command(spec, before); + self.apply_view_prefs(); if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a5b61ca2..00599afc 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1859,6 +1859,20 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } // Quick clicks on a widget's clickable part are clicks, one by one. if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index 27bc3771..7f9e5a84 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -742,3 +742,32 @@ fn alt_measures_between_layers() { assert_eq!(measured(&mut s, p, m), want, "{why}"); } } + +#[test] +fn view_toggles_are_kept_for_the_next_launch() { + let mut s = Session::new(); + // (command, turned to, ViewState field, derivation) + let rows: Vec<(&str, bool, fn(&ViewState) -> bool, &str)> = vec![ + ("view.rulers", true, |v| v.rulers, "rulers shown stay shown"), + ("view.pixelGrid", false, |v| v.pixel_grid, "a hidden pixel grid stays hidden"), + ("view.layoutGrids", false, |v| v.layout_grids, "hidden layout grids stay hidden"), + ("view.snapPixel", false, |v| v.snap_pixel, "snapping to the pixel grid off stays off"), + ]; + for (cmd, on, _, _) in &rows { + ex(&mut s, cmd, json!({"on": on})); + } + // Modes stay with their file: a new one never opens in outline mode or soft-proofing. + for cmd in ["view.outlines", "view.outlinesSelection", "view.proofSrgb"] { + ex(&mut s, cmd, json!({"on": true})); + } + // The app writes its preferences as JSON and reads them back at the next launch. + let saved = serde_json::to_value(&s.prefs).unwrap(); + let mut next = Session::empty(); + next.prefs = serde_json::from_value(saved).unwrap(); + ex(&mut next, "file.new", json!({})); + let view = &next.ds().unwrap().view; + for (cmd, on, get, why) in rows { + assert_eq!(get(view), on, "{cmd}: {why}"); + } + assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}"); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a6d52d67..f9eda2c6 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -852,6 +852,11 @@ pub fn family_names() -> Vec { db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families() } +/// The global db has `family` (embedded, loaded or indexed; any case). +pub fn has_family(family: &str) -> bool { + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family) +} + /// Style names of a family in the global db. pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) @@ -888,6 +893,16 @@ fn system_fonts_opted_out() -> bool { std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") } +/// Every font text can use is known: the system fonts were indexed, or there are none to index +/// (opted out, or the web). Until then a family the scan would find counts as missing. +pub fn installed_fonts_known() -> bool { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + return system_fonts_scanned(); + } + true +} + /// Index the system fonts the first time the font database is used (headless apps: a run that /// lays out no text never pays for the scan). The desktop app scans in the background instead. pub fn scan_system_fonts_on_demand() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27456d82..683350f6 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,8 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, - scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, + installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..41c5b9d6 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -91,6 +91,7 @@ name = "English" "All components" = "All components" "All fonts" = "All fonts" "All supported properties" = "All supported properties" +"All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" @@ -197,6 +198,7 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" @@ -771,6 +773,7 @@ name = "English" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" "Missing" = "Missing" +"Missing fonts" = "Missing fonts" "Missing layer" = "Missing layer" "Missing style" = "Missing style" "Missing variable" = "Missing variable" @@ -1374,6 +1377,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." @@ -1593,6 +1597,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped" "now" = "now" +"only in a text style or variable" = "only in a text style or variable" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped" @@ -1700,6 +1705,7 @@ name = "English" "{n} replies" = { one = "{n} reply", other = "{n} replies" } "{n} rows" = { one = "{n} row", other = "{n} rows" } "{n} styles" = { one = "{n} style", other = "{n} styles" } +"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..db4fba3e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -93,6 +93,7 @@ status = "draft — needs native review" "All comments" = "Todos los comentarios" "All components" = "Todos los componentes" "All fonts" = "Todas las fuentes" +"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas" "All supported properties" = "Todas las propiedades compatibles" "All variables" = "Todas las variables" "Alpha" = "Alfa" @@ -200,6 +201,7 @@ status = "draft — needs native review" "C or ⇧C" = "C o ⇧C" "CMYK colour" = "Color CMYK" "Can view" = "Puede ver" +"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…" "Checking the server…" = "Comprobando el servidor…" "Clear from this list" = "Borrar de esta lista" "Code or invite link" = "Código o enlace de invitación" @@ -774,6 +776,7 @@ status = "draft — needs native review" "Missing" = "No encontrado" "Missing layer" = "Capa no encontrada" "Missing style" = "Estilo no encontrado" +"Missing fonts" = "Fuentes que faltan" "Missing variable" = "Variable no encontrada" "Miter" = "Inglete" "Miter angle" = "Ángulo de inglete" @@ -1375,6 +1378,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." "This frame isn't on the branch." = "Este marco no está en la rama." @@ -1594,6 +1598,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron" "now" = "ahora" +"only in a text style or variable" = "solo en un estilo de texto o una variable" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron" @@ -1701,6 +1706,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" } "{n} rows" = { one = "{n} fila", other = "{n} filas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } +"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } @@ -2126,6 +2132,7 @@ status = "draft — needs native review" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" "text.maxLines" = "Máximo de líneas" +"text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" "text.onPath" = "Texto en trazado" "text.paragraphIndent" = "Sangría de párrafo" diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 915c7e11..58c5df93 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -274,8 +274,9 @@ fn word_at(text: &str, i: usize) -> (usize, usize) { (a, b) } -/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data. -pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontData)> { +/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data; +/// `taken` as for [`crate::theme::system_fallbacks`]. +pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, egui::FontData)> { // Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with). const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}']; const FAMILIES: [&str; 11] = [ @@ -291,17 +292,7 @@ pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontD "FreeSans", "Noto Sans", ]; - let mut out: Vec<(String, egui::FontData)> = Vec::new(); - let mut taken: Vec> = Vec::new(); - for c in SAMPLES { - if have(c) || taken.iter().any(|f| f.has_char(c)) { - continue; - } - let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue }; - out.push((format!("system-{}-{}", face.family, face.style), crate::theme::font_data(&face))); - taken.push(face); - } - out + crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken) } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bc184270..4c2b66ef 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -242,6 +242,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } app.session.tool.labels = overlays(app, &painter, &t); + crate::frame_labels::rename_field(app, ui); // The zoom tool's area. if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 39001b56..448501ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.is_some() || u.batch_rename.is_some() || u.import_report.is_some() + || u.missing_fonts.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel @@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() + || u.missing_fonts.take().is_some() { return; } @@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); crate::import_report::show(app, ctx); + crate::missing_fonts::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index 38ebef0a..687669e8 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -1,6 +1,6 @@ //! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits //! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing -//! either label selects and drags its frame. +//! either label selects and drags its frame; double-clicking one renames the frame or section there. use egui::{Event, Pos2, pos2, vec2}; use figcraft_doc::NodeId; @@ -79,3 +79,80 @@ fn pressing_a_rotated_label_selects_and_drags_the_frame() { assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}"); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1); } + +fn key(k: egui::Key) -> Vec { + vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }, + ] +} + +/// Double-click `f`'s label and wait for the field to take the keyboard. +fn double_click_label(u: &mut Ui, f: NodeId) { + let at = mid(&label(u, f)); + for _ in 0..2 { + u.button(at, true); + u.button(at, false); + } + u.frame(vec![]); + u.frame(vec![]); +} + +fn name(u: &Ui, f: NodeId) -> String { + u.app.session.doc().unwrap().node(f).unwrap().name.clone() +} + +#[test] +fn double_clicking_a_label_renames_the_frame_there() { + let (mut u, a, b) = setup(); + let before = u.app.session.doc().unwrap().world_bounds(a).unwrap(); + double_click_label(&mut u, a); + assert_eq!(u.app.session.selection(), vec![a]); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the label"); + // The name starts selected: typing replaces it, Enter keeps it. + u.frame(vec![Event::Text("Sign in".into())]); + u.frame(key(egui::Key::Enter)); + u.frame(vec![]); + assert_eq!(name(&u, a), "Sign in"); + assert!(u.app.ui.rename_label.is_none()); + // The double-click didn't move the frame or rename the other one. + assert_eq!(u.app.session.doc().unwrap().world_bounds(a).unwrap(), before); + assert_eq!(name(&u, b), "Turned"); + // One undo step puts the old name back. + u.app.session.undo().unwrap(); + assert_eq!(name(&u, a), "Upright"); +} + +#[test] +fn escape_leaves_a_label_rename_with_the_old_name() { + let (mut u, _, b) = setup(); + double_click_label(&mut u, b); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(b), "rotated labels rename too"); + u.frame(vec![Event::Text("Nope".into())]); + u.frame(key(egui::Key::Escape)); + u.frame(vec![]); + assert_eq!(name(&u, b), "Turned"); + assert!(u.app.ui.rename_label.is_none()); +} + +#[test] +fn a_sections_label_renames_it_and_a_click_elsewhere_commits() { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Area"})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let undo = u.app.session.ds().unwrap().history.undo.len(); + double_click_label(&mut u, a); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the section's label"); + assert_eq!(u.app.session.selection(), vec![a]); + u.frame(vec![Event::Text("Zone".into())]); + let at = mid(&label(&u, a)); + let away = pos2(at.x + 200.0, at.y + 500.0); + u.button(away, true); + u.button(away, false); + u.frame(vec![]); + assert_eq!(name(&u, a), "Zone", "a click on the canvas commits"); + assert!(u.app.ui.rename_label.is_none()); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 2718fddd..79557141 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -151,6 +151,28 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl))) } +/// The field over a frame's or section's name label being renamed (`UiState::rename_label`, from a +/// double-click on the label): Enter or a click elsewhere renames the layer, Escape keeps the name. +/// The rename ends when the field lets go of the keyboard or the label is gone. +pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { + let Some(r) = app.ui.rename_label else { return }; + let quad = app.session.tool.labels.iter().find(|(i, _)| *i == r.id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); + let name = app.session.doc().ok().and_then(|d| d.node(r.id)).map(|n| n.name.clone()); + let (Some(quad), Some(name)) = (quad, name) else { + app.ui.rename_label = None; + return; + }; + let area = Rect::from_points(&quad); + let font = regular(11.0); + let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; + let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start); + app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false }); + if let Some(new) = done { + app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 8742ccd3..a2411582 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -108,7 +108,7 @@ fn escape_closes_the_import_report_like_other_simple_dialogs() { assert!(u.app.ui.import_report.is_none(), "Escape closes the report"); } -fn shown_texts(shapes: Vec) -> Vec { +pub(crate) fn shown_texts(shapes: Vec) -> Vec { fn walk(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 4f5c6af8..c4b9211c 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -170,3 +170,28 @@ fn a_rename_scrolled_out_of_view_goes_on() { press(&mut u, Key::Enter, Modifiers::NONE); assert_eq!(name(&u, top), "Far"); } + +#[test] +fn a_key_typed_in_the_frame_a_rename_starts_replaces_the_name() { + // The app draws no frame between the double-click's last release (or F2, or File ▸ Rename) and + // the first key, so the name field first shows in the frame that types into it. + let (mut u, a, _, _, c) = setup(); + let p = u.row_at(c); + for _ in 0..2 { + u.button(p, true); + u.button(p, false); + } + type_text(&mut u, "Hero"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, c), "Hero", "a double-click rename"); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + press(&mut u, Key::F2, Modifiers::NONE); + type_text(&mut u, "Base"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, a), "Base", "an F2 rename"); + crate::menus::invoke(&mut u.app, "file.rename"); + type_text(&mut u, "Brand kit"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(u.app.session.doc().unwrap().name, "Brand kit", "File ▸ Rename"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d1be479..55c5438d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live; pub mod live_text; pub mod measure_overlay; pub mod menus; +pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; @@ -149,6 +150,8 @@ mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod missing_fonts_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -434,6 +437,8 @@ pub struct UiState { /// The layer being renamed in the layers panel. pub rename: Option, pub rename_page: Option<(NodeId, String)>, + /// The frame or section whose name label on the canvas is being renamed (a double-click on it). + pub rename_label: Option, pub main_menu: bool, pub palette: Option, /// The Actions palette's tab. @@ -506,6 +511,9 @@ pub struct UiState { pub pending_open_frames: u8, /// The latest successful file import's summary and compatibility notes. pub import_report: Option, + /// Files the user opened whose fonts are yet to be checked (by document uid). + pub font_checks: Vec, + pub missing_fonts: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -591,6 +599,7 @@ impl Default for UiState { expanded: BTreeSet::new(), rename: None, rename_page: None, + rename_label: None, main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, @@ -633,6 +642,8 @@ impl Default for UiState { pending_opens: Vec::new(), pending_open_frames: 0, import_report: None, + font_checks: Vec::new(), + missing_fonts: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -761,6 +772,7 @@ impl FigApp { if let Some(result) = &result { import_report::capture(self, id, ¶ms, result); } + missing_fonts::check_later(self, id, result.as_ref()); result } @@ -875,6 +887,7 @@ impl FigApp { self.ui.about_tab = dialogs::AboutTab::About; } "dialog.preferences" => self.ui.preferences = true, + "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, @@ -965,6 +978,9 @@ impl FigApp { "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), + "canvas.renameLabel" => { + self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); + } "layers.rename" => { let ids: Vec = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); @@ -1378,6 +1394,8 @@ impl FigApp { } self.background_done(&ctx); } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui); @@ -1661,6 +1679,7 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state } } diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs new file mode 100644 index 00000000..b6c79477 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts.rs @@ -0,0 +1,115 @@ +//! The Missing fonts window (#752): the fonts a file uses that aren't installed, whose text is +//! drawn in a fallback font. It opens by itself after the user opens a file that has any (once +//! the installed fonts are indexed and any import report is closed), and from Text ▸ Missing fonts. + +use egui::RichText; +use figcraft_engine::fonts::{Missing, missing}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, trn}; +use crate::theme::{Tokens, regular}; + +#[derive(Clone, Debug)] +pub struct Shown { + /// The document it lists: the window goes when that document closes. + pub uid: u64, + pub families: Vec, + /// Asked for before the installed fonts were indexed: the list comes once they are. + pub indexing: bool, +} + +impl Shown { + pub fn inspect(&self) -> Value { + json!({ + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "indexing": self.indexing, + }) + } +} + +/// After the user opened a file, or imported pages into one (results with a `summary`; not a +/// placed image or SVG, a file that was open already, or Clear recent): look for missing fonts in +/// it once that can be told ([`show`]). Not in the browser, which has only its built-in fonts. +pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { + if !cfg!(target_arch = "wasm32") + && matches!(command, "file.open" | "file.openRecent" | "file.import") + && result.is_some_and(|r| r.get("summary").is_some()) + && let Ok(ds) = app.session.ds() + { + app.ui.font_checks.push(ds.uid); + } +} + +/// The window for document `uid`, listing what it misses (even nothing, when asked for). +fn list(app: &mut FigApp, uid: u64) -> Option> { + let ds = app.session.docs.iter().find(|d| d.uid == uid)?; + let families = missing(&ds.doc); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone(), indexing: false }); + Some(families) +} + +/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. +pub fn request(app: &mut FigApp) { + let Ok(uid) = app.session.ds().map(|d| d.uid) else { return }; + if figcraft_text::installed_fonts_known() { + list(app, uid); + } else { + app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true }); + } +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + // Whether the installed fonts are indexed is asked only while a list waits for it. + let waiting = app.ui.missing_fonts.as_ref().filter(|m| m.indexing).map(|m| m.uid); + if (waiting.is_some() || !app.ui.font_checks.is_empty()) && figcraft_text::installed_fonts_known() { + if let Some(uid) = waiting { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() { + let uid = app.ui.font_checks.remove(0); + if list(app, uid).is_some_and(|f| f.is_empty()) { + app.ui.missing_fonts = None; + } + } + } + if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) { + app.ui.missing_fonts = None; + } + let Some(shown) = &app.ui.missing_fonts else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut close_clicked = false; + crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + if shown.indexing { + ui.label(tr("Checking the fonts installed on this computer…")); + } else if shown.families.is_empty() { + ui.label(tr("All the fonts this file uses are installed")); + } else { + ui.label( + RichText::new(tr( + "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ); + ui.add_space(6.0); + egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| { + for m in &shown.families { + ui.horizontal(|ui| { + ui.label(RichText::new(&m.family).strong()); + let used = + if m.layers == 0 { tr("only in a text style or variable").to_string() } else { trn("{n} text layers", m.layers, &[]) }; + ui.label(RichText::new(used).color(t.text_secondary)); + }); + } + }); + } + ui.add_space(12.0); + close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + }); + if !open || close_clicked { + app.ui.missing_fonts = None; + } +} diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs new file mode 100644 index 00000000..2b11260a --- /dev/null +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -0,0 +1,149 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::import_report::Report; +use crate::layers_drag_tests::Ui; + +/// A saved file with text in `families` (the first two layers share the first family). +fn saved(name: &str, families: &[&str]) -> String { + let mut s = Session::new(); + for (i, f) in families.iter().chain(families.first()).enumerate() { + s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 40 * i, "props": {"fontFamily": f}})).unwrap(); + } + let path = std::env::temp_dir().join(format!("figcraft-missing-fonts-{name}-{}.figcraft", std::process::id())); + let path = path.to_string_lossy().into_owned(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + path +} + +/// The installed fonts are indexed, as the desktop app does at startup. +fn fonts_indexed() -> bool { + figcraft_text::scan_system_fonts(); + figcraft_text::installed_fonts_known() +} + +fn listed(u: &Ui) -> Value { + u.app.inspect()["missingFonts"].clone() +} + +#[test] +fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let installed = figcraft_text::family_names().into_iter().next(); + let mut families = vec!["FigCraft Missing UI"]; + families.extend(installed.as_deref()); + let path = saved("open", &families); + let mut u = Ui::new(Session::new()); + // Programmatic opens (control channel, MCP, scripts) never open the window. + u.app.run("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + u.app.run("file.close", json!({})); + + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}], "indexing": false})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); + + // It goes with its file. + u.app.run("file.close", json!({})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); +} + +#[test] +fn a_file_without_missing_fonts_opens_no_window() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let Some(installed) = figcraft_text::family_names().into_iter().next() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let path = saved("clean", &[&installed]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + assert!(u.app.ui.font_checks.is_empty(), "the check ran"); +} + +#[test] +fn it_waits_for_the_import_report_to_close() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let path = saved("report", &["FigCraft Missing Report"]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + let mut report = + Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic"}})).unwrap(); + report.uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = Some(report); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null, "the import report comes first"); + u.app.ui.import_report = None; + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Report"); +} + +#[test] +fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + assert!(fonts_indexed()); + let mut u = Ui::new(Session::new()); + assert!(u.app.run("text.missingFonts", json!({})).is_some()); + assert_eq!(listed(&u), json!({"families": [], "indexing": false})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); + let escape = + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + u.frame(escape); + assert_eq!(listed(&u), Value::Null, "Escape closes it"); +} + +#[test] +fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() { + assert!(fonts_indexed()); + let path = saved("indexing", &["FigCraft Missing Indexing"]); + let mut u = Ui::new(Session::new()); + u.app.run("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + // As Text ▸ Missing fonts leaves it while the startup scan is still running. + let uid = u.app.session.ds().unwrap().uid; + u.app.ui.missing_fonts = Some(crate::missing_fonts::Shown { uid, families: Vec::new(), indexing: true }); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false})); +} + +#[test] +fn placing_images_or_clearing_recents_does_not_bring_it_back() { + assert!(fonts_indexed()); + let path = saved("place", &["FigCraft Missing Place"]); + let svg = std::env::temp_dir().join(format!("figcraft-missing-fonts-place-{}.svg", std::process::id())); + std::fs::write(&svg, r#""#).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Place"); + u.app.ui.missing_fonts = None; + // A placed SVG, the same file opened again (it only comes to the front), Clear recent. + assert!(u.app.run_user("file.import", json!({"path": svg.to_string_lossy()})).is_some()); + assert!(u.app.run_user("file.open", json!({"path": path})).is_some()); + u.app.run_user("file.openRecent", json!({"clear": true})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&svg).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.font_checks.is_empty()); + assert_eq!(listed(&u), Value::Null); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 080bce43..a4fa8754 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -273,7 +273,9 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { .filter_map(|f| figcraft_text::find_face(f, "Regular")) .any(|x| x.has_char(c)) }; - extra.extend(crate::bidi::script_fallbacks(&embedded)); + let mut taken = Vec::new(); + extra.extend(crate::bidi::script_fallbacks(&embedded, &mut taken)); + extra.extend(key_symbol_fallbacks(&embedded, &mut taken)); extra.extend(crate::cjk::faces(cjk, lang, &embedded).iter().map(|f| (format!("system-{}-{}", f.family, f.style), font_data(f)))); } extra.extend(craft(true)); @@ -288,6 +290,38 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { ctx.set_fonts(fonts); } +/// The glyphs macOS shortcuts are written with ([`figcraft_engine::keymap::display`]). +const KEY_SYMBOLS: &str = "⌃⌥⇧⌘⌫↵⇥⇞⇟←→↑↓"; +/// System families that have them, in order of preference. +const KEY_FAMILIES: [&str; 6] = ["Lucida Grande", "Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols 2", "FreeSans"]; + +/// System faces for the shortcut glyphs the UI's own fonts lack (egui's default face has ⌘ but +/// not ⌥ or ↵); `taken` as for [`system_fallbacks`]. +fn key_symbol_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, FontData)> { + system_fallbacks(KEY_SYMBOLS.chars(), &KEY_FAMILIES, have, taken) +} + +/// System faces, the first of `families` that has each, for the `samples` neither the UI's own +/// fonts (`have`) nor the faces already `taken` draw. Each face found joins `taken`, so a face that +/// several lists name is added once. +pub(crate) fn system_fallbacks( + samples: impl IntoIterator, + families: &[&str], + have: &dyn Fn(char) -> bool, + taken: &mut Vec>, +) -> Vec<(String, FontData)> { + let mut out = Vec::new(); + for c in samples { + if have(c) || taken.iter().any(|f| f.has_char(c)) { + continue; + } + let Some(face) = figcraft_text::face_with_char(c, families) else { continue }; + out.push((format!("system-{}-{}", face.family, face.style), font_data(&face))); + taken.push(face); + } + out +} + /// egui font data over a face's bytes, without copying them (CJK faces are 10–20 MB). Font /// database faces are never dropped, so a system face's shared bytes are kept for good: leaking /// one more reference to them costs a pointer and gives egui the `'static` slice it needs. @@ -331,3 +365,30 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { let conv = ctx.data(|d| d.get_temp::(screen_conversion_id())); c32(conv.map_or(c, |k| k.apply(c))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_font_draws_every_mac_shortcut_glyph() { + figcraft_text::scan_system_fonts(); + let lacking: String = KEY_SYMBOLS.chars().filter(|c| figcraft_text::face_with_char(*c, &KEY_FAMILIES).is_none()).collect(); + if !lacking.is_empty() { + eprintln!("no system face here draws {lacking}; skipping"); + return; + } + let ctx = Context::default(); + install_fonts(&ctx, true, 0); + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 100.0))), ..Default::default() }; + ctx.run_ui(input, |_| {}).textures_delta.clear(); + ctx.fonts_mut(|f| { + for c in KEY_SYMBOLS.chars() { + for font in [regular(11.0), semibold(11.0)] { + assert!(f.has_glyphs(&font, &c.to_string()), "UI font draws {c:?}"); + } + } + }); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b4413545..3a3f2322 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -501,7 +501,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str if started { editing = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(id.with("te"))); - select_all_next(ui, id.with("te")); + select_all_next(ui, id.with("te"), value); } let mut out = None; match editing { @@ -593,21 +593,33 @@ pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) { ui.data_mut(|d| d.remove::(pending)); return; } - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), r.id); - ui.ctx().request_repaint(); + // A key typed in the frame the edit started already replaced the selection laid beforehand. + if r.changed() { + ui.data_mut(|d| d.remove::(pending)); + return; } + select_all(ui, r.id, text); + ui.ctx().request_repaint(); if !down { ui.data_mut(|d| d.remove::(pending)); } } /// Has [`select_all_on_focus`] select all of editor `te_id` from now on (an edit that starts -/// with the editor already focused, which then gains no focus). -fn select_all_next(ui: &Ui, te_id: Id) { +/// with the editor already focused, which then gains no focus). Call it before adding the editor: +/// the text is selected at once, so a key typed in the frame the edit starts (the frame after a +/// double-click or F2 is often the one with the first key) replaces it. +fn select_all_next(ui: &Ui, te_id: Id, text: &str) { ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true)); + select_all(ui, te_id, text); +} + +/// Selects all of `text` in editor `te_id`. +fn select_all(ui: &Ui, te_id: Id, text: &str) { + let mut state = egui::TextEdit::load_state(ui.ctx(), te_id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), te_id); } fn select_all_id(te_id: Id) -> Id { @@ -1015,7 +1027,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu if started { buf = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(te_id)); - select_all_next(ui, te_id); + select_all_next(ui, te_id, value); } match buf { Some(mut text) => { diff --git a/docs/cli.md b/docs/cli.md index 7b35c2d5..7af73fd6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser ### `info [--json]` Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs -of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). +of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). Fonts the +file names that aren't installed are listed as `missing font` lines (`missingFonts: [{family, +layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a06874e..0dc52a1e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -198,7 +198,8 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d (`x`, `y`) or screen pixels (`sx`, `sy`). `pressure` (0–1) is pen pressure (the Pencil records it); a drag's pressure goes from `pressure` to `pressureTo`. Returns `{selection, tool}`. With the Move and Scale tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated -frame's top edge) selects and drags that frame. +frame's top edge) selects and drags that frame; a double-click there renames the frame in place in +the desktop app (the name field opens over the label). ```json {"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}} diff --git a/docs/mcp.md b/docs/mcp.md index e63283bd..a84bf05b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -233,6 +233,8 @@ Or in a project's `.mcp.json`: user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies); `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). + `text.missingFonts` lists the families the document names that aren't installed + (`{families: [{family, layers}]}`); their text draws in a fallback font. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dcda2c4e..766284f1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -27,7 +27,7 @@ interaction feel not yet compared. | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | -| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) | +| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged |