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) <noreply@anthropic.com>
This commit is contained in:
RealtaNua
2026-10-18 11:14:39 +07:00
co-authored by Claude Opus 5.5
parent cf6bd4eeb2
commit db43dc5a4a
10 changed files with 87 additions and 23 deletions
+2
View File
@@ -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. |
+1 -1
View File
@@ -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<Missing> {
// Lower-case family → (spelling, layers).
let mut used: BTreeMap<String, (String, usize)> = BTreeMap::new();
+4 -1
View File
@@ -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)]);
}
+10
View File
@@ -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() {
+1 -1
View File
@@ -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;
+1
View File
@@ -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"
+1
View File
@@ -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"
+24 -14
View File
@@ -15,19 +15,26 @@ pub struct Shown {
/// The document it lists: the window goes when that document closes.
pub uid: u64,
pub families: Vec<Missing>,
/// 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::<Vec<_>>()})
json!({
"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
"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<Vec<Missing>> {
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(
+42 -5
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#).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);
}
+1 -1
View File
@@ -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