fix(ui): The font menu keeps its keys, filters off the UI thread, uses theme colours

- Return, Escape and the arrows are consumed by the menu (Escape closes
  it), so a dialog under the menu never confirms or cancels (#447).
- The class and Recently Added filters and Show Similar Fonts read every
  family's kind on a background thread, once per document font scope;
  the menu says Loading fonts... meanwhile. The web reads its few bundled
  fonts inline.
- The colour font badge uses theme tokens instead of fixed colours.
- Every font field, the hand-drawn ones included, reports as a combo box
  with the current family as its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 09:56:30 +02:00
co-authored by Claude Opus 5.5
parent 64007e4002
commit bf01ff3b2c
4 changed files with 186 additions and 55 deletions
+1
View File
@@ -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 字体"]),
+1
View File
@@ -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"),
+110 -39
View File
@@ -52,12 +52,11 @@ struct MenuState {
frame: Option<u64>,
}
/// 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<String>,
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, &regular(&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, &regular(&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<u32>,
of: std::collections::HashMap<String, (FaceKind, bool)>,
done: bool,
}
type SharedKinds = std::sync::Arc<std::sync::Mutex<Kinds>>;
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, &regular(&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::<SharedKinds>(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<FontPick> = 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<Row> = 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
}
+74 -16
View File
@@ -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<FontPick> {
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<FontPick> {
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<egui::Rect> {
let mut r: Vec<egui::Rect> =
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<egui::Rect> {
let mut r: Vec<egui::Rect> =
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) {