From db43dc5a4a25fc0b908286da451d6ee413ea5243 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 22:06:03 +0700 Subject: [PATCH] M7: missing fonts window only after a file opens; wait for the font scan (#752) From review: - The check ran after any file.open/openRecent/import result, so a pasted or dropped image, reopening a file that was already open, or Clear recent brought the window back. It now runs only for results with a `summary` (a file opened, or pages imported), and never in the browser. - Text > Missing fonts... before the startup scan finished listed installed fonts as missing. It now says it is checking the installed fonts and lists them once that is done (`figcraft_text::installed_fonts_known`, also true when system fonts are opted out, where checks used to wait forever). - ROADMAP progress log and file-format-parity status line. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts_tests.rs | 5 ++- crates/text/src/fontdb.rs | 10 +++++ crates/text/src/lib.rs | 2 +- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/missing_fonts.rs | 38 +++++++++++------- crates/ui-egui/src/missing_fonts_tests.rs | 47 ++++++++++++++++++++--- docs/file-format-parity.md | 2 +- 10 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..7cef08a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Missing fonts (M7, #752): fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables) are listed in a Missing fonts window after the user opens the file (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Their text still draws in a fallback font. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -317,6 +318,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | +| 2026-10-17 | minor | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Numbers unchanged. | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index e20d4d40..3548124d 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -21,7 +21,7 @@ pub struct Missing { /// 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::system_fonts_scanned`), or every installed family counts as missing. +/// (`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(); diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 6ba925b3..a6e2d213 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -112,7 +112,10 @@ fn blank_families_and_aliases_are_handled() { 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; the cycle resolves to nothing. + // 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)]); } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8d1840b1..f9eda2c6 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -893,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 721471ad..683350f6 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,7 @@ 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, has_family, - no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + 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 4003891b..41c5b9d6 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -198,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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b442d6d3..db4fba3e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -201,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" diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs index 8236ca90..6c07068d 100644 --- a/crates/ui-egui/src/missing_fonts.rs +++ b/crates/ui-egui/src/missing_fonts.rs @@ -15,19 +15,26 @@ 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::>()}) + json!({ + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "indexing": self.indexing, + }) } } -/// After a file the user opened (or imported into) is in front: look for missing fonts in it -/// once that can be told ([`show`]). +/// 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 result.is_some() + 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); @@ -38,25 +45,26 @@ pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { 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() }); + 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) { - if let Ok(uid) = app.session.ds().map(|d| d.uid) { + 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 }); } } -/// Installed fonts are indexed: before that, every one of them would count as missing. The -/// browser has only its built-in fonts, so files there don't open the window by themselves. -fn fonts_known() -> bool { - !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() -} - pub fn show(app: &mut FigApp, ctx: &egui::Context) { - if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let known = figcraft_text::installed_fonts_known(); + if let Some(uid) = app.ui.missing_fonts.as_ref().filter(|m| m.indexing && known).map(|m| m.uid) { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && known { let uid = app.ui.font_checks.remove(0); if list(app, uid).is_some_and(|f| f.is_empty()) { app.ui.missing_fonts = None; @@ -71,7 +79,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 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.families.is_empty() { + 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( diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs index ba86cc7e..2b11260a 100644 --- a/crates/ui-egui/src/missing_fonts_tests.rs +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -16,11 +16,10 @@ fn saved(name: &str, families: &[&str]) -> String { path } -/// The installed fonts are indexed, as the desktop app does at startup; false when this machine -/// opted out (nothing would ever count as known). +/// The installed fonts are indexed, as the desktop app does at startup. fn fonts_indexed() -> bool { figcraft_text::scan_system_fonts(); - figcraft_text::system_fonts_scanned() + figcraft_text::installed_fonts_known() } fn listed(u: &Ui) -> Value { @@ -42,11 +41,12 @@ fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { 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}]})); + 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")); @@ -99,9 +99,10 @@ fn it_waits_for_the_import_report_to_close() { #[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": []})); + 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")); @@ -110,3 +111,39 @@ fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() 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/docs/file-format-parity.md b/docs/file-format-parity.md index 54e2dd25..900559df 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's