mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
64007e4002
commit
bf01ff3b2c
@@ -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 字体"]),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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, ®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<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, ®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::<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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user