diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index f929d2bfd..1f9c7e3ec 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2885,6 +2885,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Show Similar Fonts", ["Ähnliche Schriften anzeigen", "Afficher les polices semblables", "Mostrar fuentes similares", "類似フォントを表示", "显示相似字体"], ), + ("Loading fonts…", ["Schriften werden geladen…", "Chargement des polices…", "Cargando fuentes…", "フォントを読み込み中…", "正在加载字体…"]), ("Show Styles", ["Schnitte einblenden", "Afficher les styles", "Mostrar estilos", "スタイルを表示", "显示样式"]), ("Hide Styles", ["Schnitte ausblenden", "Masquer les styles", "Ocultar estilos", "スタイルを隠す", "隐藏样式"]), ("TrueType font", ["TrueType-Schrift", "Police TrueType", "Fuente TrueType", "TrueType フォント", "TrueType 字体"]), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 9bffdee23..3e062e987 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1530,6 +1530,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Sample", "Esempio"), ("Similar to {family}", "Simili a {family}"), ("No fonts match", "Nessun font corrisponde"), + ("Loading fonts…", "Caricamento dei font…"), ("Show Similar Fonts", "Mostra font simili"), ("Show Styles", "Mostra stili"), ("Hide Styles", "Nascondi stili"), diff --git a/crates/ui-egui/src/panels/font_menu.rs b/crates/ui-egui/src/panels/font_menu.rs index 8bd6318c8..aa28f0318 100644 --- a/crates/ui-egui/src/panels/font_menu.rs +++ b/crates/ui-egui/src/panels/font_menu.rs @@ -52,12 +52,11 @@ struct MenuState { frame: Option, } -/// What the menu knows about a family (read once, then kept for the session). +/// What the menu knows about a family it shows (read once, then kept for the session). #[derive(Clone)] struct FamilyMeta { styles: Vec, kind: FaceKind, - added: bool, } fn regular(styles: &[String]) -> String { @@ -70,13 +69,80 @@ fn meta(ui: &Ui, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32, fa return m; } let styles = fonts.styles(family); - let face = fonts.face(family, ®ular(&styles)); - let added = face.added().and_then(|t| t.elapsed().ok()).is_some_and(|age| age < ADDED_WITHIN); - let m = FamilyMeta { styles, kind: face.kind(), added }; + let kind = fonts.face(family, ®ular(&styles)).kind(); + let m = FamilyMeta { styles, kind }; ui.data_mut(|d| d.insert_temp(key, m.clone())); m } +/// Every family's kind and whether it was added recently: the class and Recently Added filters and +/// Show Similar Fonts need them for all families at once, and reading them opens each font file, +/// so they are read on a background thread, once per document font scope. +#[derive(Default)] +struct Kinds { + scope: Option, + of: std::collections::HashMap, + done: bool, +} + +type SharedKinds = std::sync::Arc>; + +fn lock(kinds: &SharedKinds) -> std::sync::MutexGuard<'_, Kinds> { + kinds.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// A family's kind, and whether its file was added within [`ADDED_WITHIN`]. +fn family_kind(fonts: &designcraft_fonts::ScopedFonts<'static>, family: &str) -> (FaceKind, bool) { + let face = fonts.face(family, ®ular(&fonts.styles(family))); + (face.kind(), face.added().and_then(|t| t.elapsed().ok()).is_some_and(|age| age < ADDED_WITHIN)) +} + +/// The families' kinds, starting to read them the first time they're needed for `scope`. +fn kinds(ctx: &egui::Context, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32) -> SharedKinds { + let shared: SharedKinds = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("font_kinds")).clone()); + let start = { + let mut k = lock(&shared); + let start = k.scope != Some(scope); + if start { + *k = Kinds { scope: Some(scope), ..Default::default() }; + } + start + }; + if start { + let (job_ctx, job_kinds, job_fonts) = (ctx.clone(), shared.clone(), fonts.clone()); + let job = move || read_kinds(&job_ctx, &job_kinds, &job_fonts, scope); + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("font-kinds".into()).spawn(job).is_err() { + // No thread to spare: read them here. + read_kinds(ctx, &shared, fonts, scope); + } + // The web has no threads, and only its few bundled fonts. + #[cfg(target_arch = "wasm32")] + job(); + } + shared +} + +fn read_kinds(ctx: &egui::Context, shared: &SharedKinds, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32) { + for chunk in fonts.families().chunks(64) { + let read: Vec<(String, (FaceKind, bool))> = chunk.iter().map(|f| (f.clone(), family_kind(fonts, f))).collect(); + let mut k = lock(shared); + // Another document's fonts were asked for since. + if k.scope != Some(scope) { + return; + } + k.of.extend(read); + drop(k); + ctx.request_repaint(); + } + let mut k = lock(shared); + if k.scope == Some(scope) { + k.done = true; + } + drop(k); + ctx.request_repaint(); +} + /// One row of the list. #[derive(Clone)] enum Row { @@ -112,8 +178,8 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: Col } /// The format badge after a family's sample: TT, O (PostScript outlines), a variable font's -/// slider, or a colour font's four dots. -fn paint_format(p: &egui::Painter, r: Rect, format: FontFormat, color: Color32) { +/// slider, or a colour font's four dots (in `color` and `accent`). +fn paint_format(p: &egui::Painter, r: Rect, format: FontFormat, color: Color32, accent: Color32) { let c = r.center(); match format { FontFormat::TrueType => { @@ -127,11 +193,7 @@ fn paint_format(p: &egui::Painter, r: Rect, format: FontFormat, color: Color32) p.circle_filled(c + vec2(2.0, 0.0), 2.6, color); } FontFormat::Color => { - for (i, dot) in - [Color32::from_rgb(230, 80, 70), Color32::from_rgb(250, 190, 60), Color32::from_rgb(80, 180, 110), Color32::from_rgb(70, 140, 230)] - .into_iter() - .enumerate() - { + for (i, dot) in [accent, color, color, accent].into_iter().enumerate() { let (dx, dy) = ((i % 2) as f32 * 5.0 - 2.5, (i / 2) as f32 * 5.0 - 2.5); p.circle_filled(c + vec2(dx, dy), 2.1, dot); } @@ -217,6 +279,11 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur ui.set_width(width.max(560.0)); let mut pick: Option = None; let mut favs_changed = false; + // The keys the menu uses are its own: a dialog under it never sees Return or Escape. + let (down, up, enter, escape) = ui.input_mut(|i| { + let mut key = |k| i.consume_key(egui::Modifiers::NONE, k); + (key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape)) + }); // Search. let search = ui @@ -315,30 +382,31 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur // The rows: Recently Used (only for the plain list), then the families that pass the filters. let q = st.query.to_lowercase(); let filtered = st.class.is_some() || st.only_recent || st.similar.is_some(); - let mut families: Vec<&FamilyInfo> = menu - .iter() - .filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family))) - .filter(|f| { - !filtered || { - let m = meta(ui, &fonts, scope, &f.family); - st.class.is_none_or(|c| m.kind.class == c) && (!st.only_recent || m.added) - } - }) - .collect(); - if let Some(like) = &st.similar { - let base = meta(ui, &fonts, scope, like).kind; - let mut near: Vec<(u32, &FamilyInfo)> = families - .iter() - .filter_map(|f| { - let k = meta(ui, &fonts, scope, &f.family).kind; - if f.family == *like { - return Some((0, *f)); - } - (k.class == base.class).then(|| (designcraft_fonts::panose_distance(&base.panose, &k.panose).unwrap_or(u32::MAX / 2) + 1, *f)) - }) - .collect(); - near.sort_by_key(|(d, _)| *d); - families = near.into_iter().take(SIMILAR).map(|(_, f)| f).collect(); + let mut families: Vec<&FamilyInfo> = menu.iter().filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family))).collect(); + // While the kinds are read, the families read so far. + let mut loading = false; + if filtered { + let shared = kinds(ui.ctx(), &fonts, scope); + let k = lock(&shared); + loading = !k.done; + // A family the read didn't list (just activated) is read here, once the read is done. + let kind = |f: &str| k.of.get(f).copied().or_else(|| k.done.then(|| family_kind(&fonts, f))); + families.retain(|f| kind(&f.family).is_some_and(|(kind, added)| st.class.is_none_or(|c| kind.class == c) && (!st.only_recent || added))); + if let Some(like) = &st.similar { + let base = meta(ui, &fonts, scope, like).kind; + let mut near: Vec<(u32, &FamilyInfo)> = families + .iter() + .filter_map(|f| { + if f.family == *like { + return Some((0, *f)); + } + let (k, _) = kind(&f.family)?; + (k.class == base.class).then(|| (designcraft_fonts::panose_distance(&base.panose, &k.panose).unwrap_or(u32::MAX / 2) + 1, *f)) + }) + .collect(); + near.sort_by_key(|(d, _)| *d); + families = near.into_iter().take(SIMILAR).map(|(_, f)| f).collect(); + } } let mut rows: Vec = Vec::new(); if q.is_empty() && !filtered && !st.only_favs { @@ -376,7 +444,6 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur if search.changed() { st.highlight = if q.is_empty() { None } else { Some(0) }; } - let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter))); let last = pickable.len().checked_sub(1); st.highlight = match (st.highlight, last) { (_, None) => None, @@ -408,7 +475,9 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur }) .sum(); egui::ScrollArea::vertical().max_height(420.0).min_scrolled_height(list_h.clamp(24.0, 420.0)).auto_shrink([false, true]).show(ui, |ui| { - if rows.is_empty() { + if loading { + crate::rtl::label(ui, egui::RichText::new(tr("Loading fonts…")).color(t.text_dim)); + } else if rows.is_empty() { crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim)); } for (i, row) in rows.iter().enumerate() { @@ -507,7 +576,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur } if style.is_none() { let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0)); - paint_format(&p, badge, m.kind.format, t.text_dim); + paint_format(&p, badge, m.kind.format, t.text_dim, t.accent); ui.interact(badge, ui.id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format))); } if resp.clicked() && !handled { @@ -544,6 +613,8 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur recent.truncate(RECENT); let _ = app.run("prefs.set", json!({ "recentFonts": recent })); ui.close(); + } else if escape { + ui.close(); } pick } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 30b4ca642..6ced52c72 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -235,18 +235,19 @@ pub fn font_combo( // A field and a plain popup rather than egui's combo box: its own scroll area would keep the // menu at the size it last had (a family opening, a filter clearing, wouldn't show). let field = ui.push_id(id_salt, |ui| crate::widgets::dropdown(ui, &shown, width)).inner; + font_popup(app, &field, &menu, current) +} + +/// [`font_menu_body`] in a popup under `field` (a font field the caller draws), which screen +/// readers hear as a combo box with the current family as its value. Returns the font picked. +pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { + let shown = if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }; field.widget_info(|| { let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); info.enabled = true; info.current_text_value = Some(shown.clone()); info }); - font_popup(app, &field, &menu, current) -} - -/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the font -/// picked. -pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { egui::Popup::menu(field) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| font_menu_body(app, ui, menu, current, field.rect.width())) @@ -385,9 +386,17 @@ mod font_menu_ui_tests { h.state_mut().app.run("story.get", json!({"story": story})).unwrap()["text"].as_str().unwrap().to_string() } - /// The Control bar's font field, showing `family`. + /// The font fields showing `family`, top to bottom. + fn fields(h: &Harness<'static, Window>, family: &str) -> Vec { + let mut r: Vec = + h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect(); + r.sort_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x))); + r + } + + /// The Control bar's font field (the topmost), showing `family`. fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect { - fields(h, family)[0] + *fields(h, family).first().unwrap_or_else(|| panic!("a font field shows {family}")) } /// The text in the focused search field. @@ -399,6 +408,18 @@ mod font_menu_ui_tests { h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fontFamily"].as_str().unwrap().to_string() } + /// Run the menu until the filters' font kinds are read (it says Loading fonts… meanwhile). + fn settle(h: &mut Harness<'static, Window>) { + for _ in 0..3000 { + if h.query_by_label("Loading fonts…").is_none() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + h.run_steps(1); + } + panic!("the font kinds were never read"); + } + /// Open the Control bar's font menu (showing `family`) and search for `query`. fn open_menu(h: &mut Harness<'static, Window>, family: &str, query: &str) { let at = field(h, family); @@ -459,6 +480,7 @@ mod font_menu_ui_tests { let at = h.get_by_label("Monospaced").rect().center(); click_at(&mut h, at); } + settle(&mut h); assert!(h.query_by_label("JetBrains Mono").is_some(), "a monospaced family is listed"); assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "a serif isn't"); // Back to all classes, then fonts like the serif: it leads, other serifs follow, no sans. @@ -484,6 +506,50 @@ mod font_menu_ui_tests { assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_some()); } + #[test] + fn every_font_field_reports_as_a_combo_box_showing_the_family() { + let (h, _) = typing(); + let shown = fields(&h, designcraft_fonts::DEFAULT_FAMILY); + // The Control bar's, and the hand-drawn ones (Properties, the Contextual Task Bar). + assert!(shown.len() >= 2, "{shown:?}"); + } + + #[test] + fn return_and_escape_in_a_dialogs_font_menu_stay_in_the_menu() { + let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"}); + for (what, id, fields, shown) in [ + ("Paragraph Style Options", "paragraphStyleOptions", paragraph, designcraft_fonts::DEFAULT_FAMILY), + ("Find/Replace Font", "findFont", json!({}), "—"), + ] { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + h.state_mut().app.ui.dialog = Some(crate::dialogs::Dialog::new(id, fields)); + h.run_steps(4); + let dialog = |h: &Harness<'static, Window>| h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()); + let combo = |h: &Harness<'static, Window>, family: &str| { + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect().center() + }; + let at = combo(&h, shown); + click_at(&mut h, at); + assert!(h.ctx.text_edit_focused(), "{what}: the menu opens with its search focused"); + h.event(egui::Event::Text("source sans".into())); + h.run_steps(4); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(dialog(&h).as_deref(), Some(id), "{what}: Return picked in the menu and left the dialog open"); + assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed"); + let at = combo(&h, "Source Sans 3"); + click_at(&mut h, at); + assert!(h.ctx.text_edit_focused(), "{what}: the menu opened again"); + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu"); + assert_eq!(dialog(&h).as_deref(), Some(id), "{what}: Escape in the menu left the dialog open"); + } + } + #[test] fn typing_in_the_font_menu_searches_and_never_reaches_the_story() { let (mut h, story) = typing(); @@ -532,14 +598,6 @@ mod font_menu_ui_tests { assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected"); } - /// The font fields showing `family`, left to right and top to bottom. - fn fields(h: &Harness<'static, Window>, family: &str) -> Vec { - let mut r: Vec = - h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect(); - r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y))); - r - } - /// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu /// stays open and lists only the matches, then picks the first with ↓ and Return. fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) {