From 03a8d4b95525ab14a3ea69f47fd44af65f3cc90f Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 1/9] feat(fonts): Font format, classification and PANOSE for the font menus Each face reports what InDesign's font menu shows and filters by, read once from its tables: its format (TrueType, OpenType/CFF, variable, colour), its class (serif, slab serif, sans serif, script, monospaced, decorative, symbol) from PANOSE, else the OS/2 IBM family class, else the family name, else the shape of its "l" (serifs widen the foot), its PANOSE numbers (with a distance for similar fonts) and when its file was added. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 76 +++++++++++++++ crates/fonts/src/kind.rs | 191 +++++++++++++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 2 + 3 files changed, 269 insertions(+) create mode 100644 crates/fonts/src/kind.rs diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..079c0f1b1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -188,6 +190,32 @@ impl FontFace { pub fn skrifa(&self) -> Option> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// The face's format, class and PANOSE numbers, as the font menus show and filter them. + pub fn kind(&self) -> crate::kind::FaceKind { + *self.kind.get_or_init(|| { + let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); + // Nothing in the tables or the name: look at the letters. + if kind.class == crate::kind::FontClass::Other + && let Some(serifs) = has_serifs(self) + { + kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; + } + kind + }) + } + /// When the font file was added (created, else last modified): the font menus' Show Recently + /// Added. `None` for fonts without a file. + pub fn added(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let meta = std::fs::metadata(self.source.path()?).ok()?; + meta.created().or_else(|_| meta.modified()).ok() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } pub fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -709,6 +737,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + kind: std::sync::OnceLock::new(), }) } @@ -1432,6 +1461,53 @@ fn over_total() -> String { format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) } +/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when +/// it has neither letter. +fn has_serifs(face: &FontFace) -> Option { + let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; + let path = font_outline(face, gid); + let bbox = kurbo::Shape::bounding_box(&path); + // An empty or degenerate outline (NaN included) says nothing. + if bbox.height().is_nan() || bbox.height() <= 1.0 { + return None; + } + let mut edges = Vec::new(); + let mut last = None; + let mut start = None; + kurbo::flatten(&path, 1.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + last = Some(p); + start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = last { + edges.push((a, p)); + } + last = Some(p); + } + kurbo::PathEl::ClosePath => { + if let (Some(a), Some(b)) = (last, start) { + edges.push((a, b)); + } + last = start; + } + _ => {} + }); + // The outline is y-down: the foot is near bbox.y1 (the baseline). + let width_at = |y: f64| { + let xs: Vec = edges + .iter() + .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) + .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) + .collect(); + let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + (hi > lo).then_some(hi - lo) + }; + let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; + let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; + Some(foot > stem * 1.6) +} + /// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). fn font_outline(face: &FontFace, gid: u32) -> BezPath { let mut pen = FlipPen(BezPath::new()); diff --git a/crates/fonts/src/kind.rs b/crates/fonts/src/kind.rs new file mode 100644 index 000000000..6a224bc00 --- /dev/null +++ b/crates/fonts/src/kind.rs @@ -0,0 +1,191 @@ +//! What the font menus say about a face: its file format (the icon after the sample), its +//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts). + +use skrifa::raw::TableProvider; +use skrifa::raw::types::Tag; + +/// A face's font format, as the font menus show it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontFormat { + /// TrueType outlines (`glyf`). + TrueType, + /// PostScript (CFF) outlines. + OpenType, + /// A variable font (`fvar`). + Variable, + /// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts. + Color, +} + +/// The classes the font menus filter by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontClass { + Serif, + SlabSerif, + SansSerif, + Script, + Monospaced, + Decorative, + Symbol, + /// Nothing in the font says. + Other, +} + +impl FontClass { + /// The classes, in the Filters menu's order. + pub const ALL: [FontClass; 8] = [ + FontClass::Serif, + FontClass::SlabSerif, + FontClass::SansSerif, + FontClass::Script, + FontClass::Monospaced, + FontClass::Decorative, + FontClass::Symbol, + FontClass::Other, + ]; + + /// The name the Filters menu shows (English; the UI translates it). + pub fn label(self) -> &'static str { + match self { + FontClass::Serif => "Serif", + FontClass::SlabSerif => "Slab Serif", + FontClass::SansSerif => "Sans Serif", + FontClass::Script => "Script", + FontClass::Monospaced => "Monospaced", + FontClass::Decorative => "Decorative", + FontClass::Symbol => "Symbol", + FontClass::Other => "Other", + } + } +} + +/// A face's format, class and PANOSE numbers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FaceKind { + pub format: FontFormat, + pub class: FontClass, + /// The OS/2 table's PANOSE classification (all zero when the font has none). + pub panose: [u8; 10], +} + +impl Default for FaceKind { + fn default() -> Self { + FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] } + } +} + +/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say. +pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind { + let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some(); + let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) { + FontFormat::Color + } else if has(b"fvar") { + FontFormat::Variable + } else if has(b"CFF ") || has(b"CFF2") { + FontFormat::OpenType + } else { + FontFormat::TrueType + }; + let os2 = f.os2().ok(); + let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]); + let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8); + let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0); + FaceKind { format, class: classify(&panose, ibm, fixed, family), panose } +} + +/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2, +/// else the family name. +pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass { + let name = family.to_lowercase(); + let named = |words: &[&str]| words.iter().any(|w| name.contains(w)); + // PANOSE proportion 9 is monospaced (Latin text). + if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) { + return FontClass::Monospaced; + } + match panose[0] { + 3 => return FontClass::Script, + 4 => return FontClass::Decorative, + 5 => return FontClass::Symbol, + 2 => match panose[1] { + // Square serifs are slabs; cove, bone, triangle… serifs are serifs. + 6 => return FontClass::SlabSerif, + 2..=10 => return FontClass::Serif, + 11..=15 => return FontClass::SansSerif, + _ => {} + }, + _ => {} + } + match ibm_class { + 1 | 2 | 3 | 7 => FontClass::Serif, + 4 | 5 => FontClass::SlabSerif, + 8 => FontClass::SansSerif, + 9 => FontClass::Decorative, + 10 => FontClass::Script, + 12 => FontClass::Symbol, + _ if named(&["slab"]) => FontClass::SlabSerif, + _ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif, + _ if named(&["script", "hand", "brush"]) => FontClass::Script, + _ if named(&["serif"]) => FontClass::Serif, + _ => FontClass::Other, + } +} + +/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both +/// fonts set; `None` when they share none. +pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option { + let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect(); + if pairs.is_empty() { + return None; + } + Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::() * 9 / pairs.len() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_come_from_panose_then_the_ibm_class_then_the_name() { + let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0]; + assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif); + assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif); + assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif); + assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9"); + assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script); + assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8"); + assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch"); + assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced); + assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif); + assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other); + } + + #[test] + fn bundled_fonts_are_classified() { + let db = crate::FontDb::global(); + for (family, class) in [ + ("Source Serif 4", FontClass::Serif), + ("Source Sans 3", FontClass::SansSerif), + ("Inter", FontClass::SansSerif), + ("JetBrains Mono", FontClass::Monospaced), + ] { + let kind = db.face(family, "Regular").kind(); + assert_eq!(kind.class, class, "{family}"); + assert_eq!(kind.format, FontFormat::TrueType, "{family}"); + } + let serif = db.face("Source Serif 4", "Regular").kind().panose; + let sans = db.face("Source Sans 3", "Regular").kind().panose; + let serif_bold = db.face("Source Serif 4", "Bold").kind().panose; + if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) { + assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})"); + } + } + + #[test] + fn panose_distance_counts_shared_digits() { + let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4]; + assert_eq!(panose_distance(&a, &a), Some(0)); + let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4]; + assert!(panose_distance(&a, &b).is_some_and(|d| d > 0)); + assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..dd4583701 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,6 +9,7 @@ mod fontdb; mod group; +mod kind; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical; @@ -20,6 +21,7 @@ pub use fontdb::{ pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; +pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; From 6baa57b503009b10927c563f5d454a1438dfbe06 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:13:57 +0200 Subject: [PATCH 2/9] feat(fonts): A family's kind without keeping it loaded ScopedFonts::family_kind reads a family's format, class, PANOSE and file date from its Regular face. A loaded face is asked; an installed family that isn't loaded is read from its file, which is let go afterwards, instead of loading every family the font menu's filters look at for the rest of the session. Files over MAX_DOCUMENT_FONT_BYTES are skipped. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 37 ++++++++++++++++++++++++++++++ crates/fonts/src/tests_sysfonts.rs | 17 ++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 079c0f1b1..1ccc8d076 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1306,6 +1306,43 @@ impl ScopedFonts<'_> { None } + /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular + /// face (else its first), for the font menus' filters, which look at every family. A face + /// already loaded is asked; an installed family that isn't loaded is read from its file, which + /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. + pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option)> { + let styles = self.styles(family); + let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); + if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { + let f = self.find(family, &style)?; + return Some((f.kind(), f.added())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = { + let cat = self.db.read_catalog(); + let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); + let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); + entry.map(|c| c.path.clone())? + }; + if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { + return None; + } + let data = Arc::new(std::fs::read(&path).ok()?); + let found = enumerate_faces(&data); + let pick = found + .iter() + .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) + .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? + .clone(); + let (index, fam, st, coords) = pick; + let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; + Some((face.kind(), face.added())) + } + #[cfg(target_arch = "wasm32")] + None + } + /// Is `family` available (the document's, loaded, or installed on the system), as named or /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cdc..ca82ad4d7 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() { assert_eq!(db.load_system_fonts(), 2); } +#[test] +fn a_familys_kind_is_read_without_keeping_it_loaded() { + let dir = font_dir("kind"); + let db = FontDb::with_font_dirs(vec![dir]); + db.load_system_fonts(); + let fonts = db.scoped(0); + let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family"); + assert_eq!(kind.class, crate::kind::FontClass::SansSerif); + assert!(added.is_some(), "the file's date"); + assert!(!db.is_loaded(FAMILY), "the file was read and let go"); + // A loaded family is asked directly, with the same answer. + let _ = fonts.face(FAMILY, "Regular"); + assert!(db.is_loaded(FAMILY)); + assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif)); + assert!(fonts.family_kind("No Such Family").is_none()); +} + #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); From 358e3786b94d0b6c849e2b6f97840a825d0caa9a Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 3/9] feat(engine): Report the selected text; remember recently used fonts type.selectionAttrs reports the start of the selected text on one line (`selectedText`, up to 60 characters), for previews such as the font menu's Selected Text sample. The preferences keep `recentFonts`, the fonts last picked in the font menus. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 36 +++++++++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 3 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..1e3272fe3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -253,7 +253,7 @@ pub fn specs() -> Vec { has_text_or_frames, break_link ), - cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs), + cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), ] } @@ -970,6 +970,9 @@ fn open_type(s: &mut Session, p: &Value) -> Result { Ok(json!({"features": list, "figures": otf::figures(&list)})) } +/// How much of the selected text `type.selectionAttrs` reports. +const SELECTED_TEXT_CHARS: usize = 60; + fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let targets = format_targets(s); @@ -980,8 +983,17 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); + // The selected text (its start, on one line), for previews such as the font menu's. + let line: String = story + .text + .get(r.start..r.end) + .unwrap_or_default() + .chars() + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .collect(); + let selected: String = line.trim_start().chars().take(SELECTED_TEXT_CHARS).collect(); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()})) + "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } /// Our own filler text (not Adobe's). @@ -1154,6 +1166,26 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + #[test] fn open_type_toggles_reach_the_text() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 164719305..061165cb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -179,6 +179,8 @@ pub struct Prefs { pub rich_black_output: bool, /// Font menu favourites (family names). pub favorite_fonts: Vec, + /// Fonts picked in the font menus, most recent first (shown at the menus' top). + pub recent_fonts: Vec, /// Preferences › Type › Show Font Names in English: the font menus show CJK families by their /// English names instead of their native ones. Documents store the English name either way. pub show_font_names_in_english: bool, @@ -235,6 +237,7 @@ impl Default for Prefs { highlight_substituted_fonts: true, rich_black_output: false, favorite_fonts: Vec::new(), + recent_fonts: Vec::new(), show_font_names_in_english: false, smart_text_reflow: true, autocorrect: false, From 31d235013f0b8ac8e5c1bd0d8b34b0fdbeaed361 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:22:03 +0200 Subject: [PATCH 4/9] fix(engine): The selected-text preview reads only the 60 characters it reports type.selectionAttrs copied the whole selection to a string before taking the first 60 characters, so selecting a long story copied all of it on every query. It now skips leading blanks and stops after 60 characters. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1e3272fe3..334dfad03 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -973,6 +973,14 @@ fn open_type(s: &mut Session, p: &Value) -> Result { /// How much of the selected text `type.selectionAttrs` reports. const SELECTED_TEXT_CHARS: usize = 60; +fn selected_text_preview(chars: impl Iterator) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let targets = format_targets(s); @@ -984,14 +992,7 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); // The selected text (its start, on one line), for previews such as the font menu's. - let line: String = story - .text - .get(r.start..r.end) - .unwrap_or_default() - .chars() - .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) - .collect(); - let selected: String = line.trim_start().chars().take(SELECTED_TEXT_CHARS).collect(); + let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } @@ -1166,6 +1167,17 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + #[test] fn selection_attrs_report_the_selected_text() { let mut s = Session::new(); From 4198798007adc51e30e1368aed84648318c92549 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 5/9] feat(ui): Lay out the font menu like InDesign's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The font menu (Control bar, Character panel, Properties, task bar, style dialogs, Find Font, Glyphs) now has InDesign's layout: - a search field with a clear button, and a Filters row: classification (All Classes, serif, slab serif, sans serif, script, monospaced, decorative, symbol), Show Favorites Only, Show Recently Added (fonts added in the last 30 days); - the sample text (Selected Text, Sample, Aa Bb Cc, a pangram, digits) in three sizes, remembered with the UI state; - Recently Used fonts at the top, then every family; - a family of several styles shows how many and opens to list them, each previewed in its own face; picking a style applies it as it is; - each family's format after its sample, and Show Similar Fonts (≈) to list the families of the same class nearest by PANOSE. Find More and the activated-fonts filter are Adobe Fonts services and are left out. The font combo is a field and a plain popup now: egui's combo box kept its menu at the size it last had, so a family opening or a filter clearing didn't show. The ≈ button stays while the pointer is on it (the row under it no longer counts as hovered there). New strings are translated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 + crates/ui-egui/src/dialogs.rs | 9 +- crates/ui-egui/src/i18n.rs | 49 +++ crates/ui-egui/src/i18n/it.rs | 25 ++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/panels/font_menu.rs | 549 ++++++++++++++++++++++++ crates/ui-egui/src/panels/glyphs.rs | 4 +- crates/ui-egui/src/panels/mod.rs | 302 +++++-------- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/taskbar.rs | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 12 files changed, 756 insertions(+), 198 deletions(-) create mode 100644 crates/ui-egui/src/panels/font_menu.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..445227b57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,9 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Font menu laid out like InDesign's: filters (classification, favourites, + recently added), families opening to their styles, the selected text as the sample in three + sizes, Recently Used fonts, Show Similar Fonts and each font's format. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..221a36018 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2909,7 +2909,10 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo cf.set(d, "fontFamily", Value::Null); } if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) { - cf.set(d, "fontFamily", json!(f)); + cf.set(d, "fontFamily", json!(f.family)); + if let Some(style) = f.style { + cf.set(d, "fontStyle", json!(style)); + } } }); ui.end_row(); @@ -3723,7 +3726,9 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); if let Some(f) = crate::panels::font_combo(app, ui, "toFamily", &d.s("toFamily"), 170.0) { - d.fields.insert("toFamily".into(), json!(f)); + let style = crate::panels::pick_style(app, &f); + d.fields.insert("toFamily".into(), json!(f.family)); + d.fields.insert("toStyle".into(), json!(style)); } ui.end_row(); let styles = db.styles(&d.s("toFamily")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..1f34cb9f5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2812,6 +2812,55 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), + // Font menu + ("Filters:", ["Filter:", "Filtres :", "Filtros:", "フィルター:", "过滤器:"]), + ("All Classes", ["Alle Klassen", "Toutes les classes", "Todas las clases", "すべての分類", "所有分类"]), + ("Serif", ["Serifenschrift", "Serif", "Con serifa", "セリフ", "衬线"]), + ("Slab Serif", ["Egyptienne", "Slab serif", "Serifa cuadrada", "スラブセリフ", "粗衬线"]), + ("Sans Serif", ["Serifenlos", "Sans serif", "Sin serifa", "サンセリフ", "无衬线"]), + ("Script", ["Schreibschrift", "Script", "Manuscrita", "スクリプト", "手写体"]), + ("Monospaced", ["Dicktengleich", "Chasse fixe", "Monoespaciada", "等幅", "等宽"]), + ("Decorative", ["Dekorativ", "Décorative", "Decorativa", "装飾", "装饰"]), + ("Symbol", ["Symbol", "Symbole", "Símbolo", "記号", "符号"]), + ( + "Show Recently Added", + [ + "Zuletzt hinzugefügte anzeigen", + "Afficher les polices ajoutées récemment", + "Mostrar añadidas recientemente", + "最近追加したフォントを表示", + "显示最近添加的字体", + ], + ), + ( + "Small Sample Text Size", + ["Kleine Beispieltextgröße", "Petite taille de l'exemple", "Tamaño de muestra pequeño", "サンプルテキスト(小)", "小号示例文本"], + ), + ( + "Medium Sample Text Size", + ["Mittlere Beispieltextgröße", "Taille moyenne de l'exemple", "Tamaño de muestra mediano", "サンプルテキスト(中)", "中号示例文本"], + ), + ( + "Large Sample Text Size", + ["Große Beispieltextgröße", "Grande taille de l'exemple", "Tamaño de muestra grande", "サンプルテキスト(大)", "大号示例文本"], + ), + ("Selected Text", ["Ausgewählter Text", "Texte sélectionné", "Texto seleccionado", "選択したテキスト", "所选文本"]), + ("Sample", ["Beispiel", "Exemple", "Muestra", "サンプル", "示例"]), + ("Similar to {family}", ["Ähnlich wie {family}", "Semblables à {family}", "Similares a {family}", "{family} に類似", "与 {family} 相似"]), + ( + "No fonts match", + ["Keine passenden Schriften", "Aucune police ne correspond", "Ninguna fuente coincide", "一致するフォントはありません", "没有匹配的字体"], + ), + ( + "Show Similar Fonts", + ["Ähnliche Schriften anzeigen", "Afficher les polices semblables", "Mostrar fuentes similares", "類似フォントを表示", "显示相似字体"], + ), + ("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 字体"]), + ("OpenType font", ["OpenType-Schrift", "Police OpenType", "Fuente OpenType", "OpenType フォント", "OpenType 字体"]), + ("Variable font", ["Variable Schrift", "Police variable", "Fuente variable", "バリアブルフォント", "可变字体"]), + ("Color font", ["Farbschrift", "Police couleur", "Fuente de color", "カラーフォント", "彩色字体"]), ]; /// Interface direction; independent of document binding and paragraph direction. diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..25f294b5a 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1493,4 +1493,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + // Font menu + ("Filters:", "Filtri:"), + ("All Classes", "Tutte le classi"), + ("Serif", "Graziato"), + ("Slab Serif", "Graziato squadrato"), + ("Sans Serif", "Senza grazie"), + ("Script", "Calligrafico"), + ("Monospaced", "Monospaziato"), + ("Decorative", "Decorativo"), + ("Symbol", "Simboli"), + ("Show Recently Added", "Mostra aggiunti di recente"), + ("Small Sample Text Size", "Testo di esempio piccolo"), + ("Medium Sample Text Size", "Testo di esempio medio"), + ("Large Sample Text Size", "Testo di esempio grande"), + ("Selected Text", "Testo selezionato"), + ("Sample", "Esempio"), + ("Similar to {family}", "Simili a {family}"), + ("No fonts match", "Nessun font corrisponde"), + ("Show Similar Fonts", "Mostra font simili"), + ("Show Styles", "Mostra stili"), + ("Hide Styles", "Nascondi stili"), + ("TrueType font", "Font TrueType"), + ("OpenType font", "Font OpenType"), + ("Variable font", "Font variabile"), + ("Color font", "Font a colori"), ]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0fa1dff0..85d6d7203 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -243,6 +243,9 @@ pub struct UiState { pub workspace: String, /// Window › Workspace › New Workspace: saved panel arrangements. pub custom_workspaces: Vec, + /// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2). + pub font_sample: String, + pub font_preview_size: u8, /// Transform reference point (0..8, row-major; 0 = top-left). pub ref_point: u8, /// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`). @@ -340,6 +343,8 @@ impl Default for UiState { units: Unit::Picas, workspace: "Essentials".into(), custom_workspaces: Vec::new(), + font_sample: "selected".into(), + font_preview_size: 1, ref_point: 0, align_to: "selection".into(), text_style_tab: 0, diff --git a/crates/ui-egui/src/panels/font_menu.rs b/crates/ui-egui/src/panels/font_menu.rs new file mode 100644 index 000000000..8bd6318c8 --- /dev/null +++ b/crates/ui-egui/src/panels/font_menu.rs @@ -0,0 +1,549 @@ +//! The Font menu, laid out like InDesign's: a search field, a Filters row (classification, +//! favourites, recently added, similar fonts), the sample text and its size, then the recently used +//! fonts and every family. A family with several styles shows how many and opens to list them, +//! each previewed in its own face; the format of each family shows after its sample. + +use std::collections::HashSet; + +use designcraft_fonts::{FaceKind, FamilyInfo, FontClass, FontFormat}; +use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::DesignApp; +use crate::theme::Tokens; + +/// A font picked in the menu: the family, and the style when one of its styles was picked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FontPick { + pub family: String, + pub style: Option, +} + +/// The sample texts the menu can preview (the first is the text selection's own). +pub const SAMPLES: [(&str, &str); 5] = [ + ("selected", "Selected Text"), + ("sample", "Sample"), + ("pairs", "Aa Bb Cc"), + ("pangram", "The quick brown fox jumps over the lazy dog"), + ("digits", "0123456789"), +]; +/// Preview sizes: (row height, sample size in points). +const SIZES: [(f32, f32); 3] = [(22.0, 13.0), (28.0, 17.0), (40.0, 26.0)]; +/// Fonts listed as recently used, and a recently added font's age. +const RECENT: usize = 10; +const ADDED_WITHIN: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60); +/// Families Show Similar Fonts lists. +const SIMILAR: usize = 30; + +/// The menu's state while it is open. +#[derive(Clone, Default)] +struct MenuState { + query: String, + only_favs: bool, + only_recent: bool, + class: Option, + /// Show Similar Fonts: the family the list is like. + similar: Option, + expanded: HashSet, + /// The Filters and Sample choices, shown in the menu (a menu can't hold another popup). + show_classes: bool, + show_samples: bool, + highlight: Option, + frame: Option, +} + +/// What the menu knows about a family (read once, then kept for the session). +#[derive(Clone)] +struct FamilyMeta { + styles: Vec, + kind: FaceKind, + added: bool, +} + +fn regular(styles: &[String]) -> String { + styles.iter().find(|s| *s == "Regular").or(styles.first()).cloned().unwrap_or_else(|| "Regular".into()) +} + +fn meta(ui: &Ui, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32, family: &str) -> FamilyMeta { + let key = egui::Id::new(("font_meta", family, scope)); + if let Some(m) = ui.data(|d| d.get_temp::(key)) { + 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 }; + ui.data_mut(|d| d.insert_temp(key, m.clone())); + m +} + +/// One row of the list. +#[derive(Clone)] +enum Row { + /// A family; `recent` rows are the Recently Used ones at the top. + Family { + info: FamilyInfo, + recent: bool, + }, + Style { + family: String, + style: String, + }, + Separator, +} + +/// A five-pointed star (filled, or outlined). +fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: Color32) { + let pt = |i: usize, rad: f32| { + let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0; + c + egui::vec2(a.cos(), a.sin()) * rad + }; + let outline: Vec = (0..10).map(|i| pt(i, if i % 2 == 0 { r } else { r * 0.42 })).collect(); + if filled { + // The inner pentagon and the five points (each convex). + let inner: Vec = (0..5).map(|i| outline[2 * i + 1]).collect(); + p.add(egui::Shape::convex_polygon(inner, color, Stroke::NONE)); + for i in 0..5 { + p.add(egui::Shape::convex_polygon(vec![outline[(2 * i + 9) % 10], outline[2 * i], outline[2 * i + 1]], color, Stroke::NONE)); + } + } else { + p.add(egui::Shape::closed_line(outline, Stroke::new(1.0, color))); + } +} + +/// 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) { + let c = r.center(); + match format { + FontFormat::TrueType => { + p.text(c, Align2::CENTER_CENTER, "TT", FontId::proportional(10.0), color); + } + FontFormat::OpenType => { + p.circle_stroke(c, 5.0, Stroke::new(1.3, color)); + } + FontFormat::Variable => { + p.line_segment([c - vec2(6.0, 0.0), c + vec2(6.0, 0.0)], Stroke::new(1.2, color)); + 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() + { + 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); + } + } + } +} + +fn format_name(format: FontFormat) -> &'static str { + match format { + FontFormat::TrueType => "TrueType font", + FontFormat::OpenType => "OpenType font", + FontFormat::Variable => "Variable font", + FontFormat::Color => "Color font", + } +} + +/// `text` in `family`'s `style`, rendered once per text, size and colour. +fn preview( + ui: &Ui, + fonts: &designcraft_fonts::ScopedFonts<'static>, + scope: u32, + family: &str, + style: &str, + text: &str, + size: f32, + color: Color32, +) -> egui::TextureHandle { + let ppp = ui.ctx().pixels_per_point(); + let px = (size * ppp).round() as u32; + let key = egui::Id::new(("font_preview", family, style, scope, text, px, color.to_array())); + if let Some(t) = ui.data(|d| d.get_temp::(key)) { + return t; + } + let img = designcraft_render::glyphs::text_line(fonts, family, style, text, px, color.to_array()); + let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let t = ui.ctx().load_texture(format!("font_preview_{family}_{style}"), ci, egui::TextureOptions::LINEAR); + ui.data_mut(|d| d.insert_temp(key, t.clone())); + t +} + +/// A small toggle in the filter row (an icon), with its tooltip. +fn toggle(ui: &mut Ui, on: bool, tip: &str, paint: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + if on { + ui.painter().rect(r, 3.0, t.well, Stroke::new(1.0, t.well_rim), egui::StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + paint(ui.painter(), r, if on { t.text_strong } else { t.icon }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tip)); + resp.on_hover_text(tip).clicked() +} + +fn clock(p: &egui::Painter, r: Rect, color: Color32) { + let c = r.center(); + p.circle_stroke(c, 6.5, Stroke::new(1.2, color)); + p.line_segment([c, c - vec2(0.0, 4.0)], Stroke::new(1.2, color)); + p.line_segment([c, c + vec2(3.0, 1.5)], Stroke::new(1.2, color)); +} + +/// The Font menu inside any font field's popup or combo box. The search field has the keyboard +/// while the menu is open (empty each time it opens); ↑/↓ move through the rows and Return picks +/// one. A click on a family picks it (its Regular style), on one of its styles that style; the +/// arrow opens a family, the star marks a favourite and ≈ shows similar fonts, all keeping the menu +/// open (it closes on a click outside only). +pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], current: &str, width: f32) -> Option { + let lang = app.ui.language.clone(); + let tr = |s: &'static str| crate::i18n::tr(&lang, s); + let (fonts, scope) = (super::fonts(app), super::font_scope(app)); + let english = app.session.prefs.show_font_names_in_english; + let mut favs = app.session.prefs.favorite_fonts.clone(); + let state_id = egui::Id::new("font_menu_state"); + let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); + let frame = ui.ctx().cumulative_frame_nr(); + if st.frame.is_none_or(|f| f.saturating_add(1) < frame) { + // Just opened: an empty search, nothing highlighted. + st.query.clear(); + st.highlight = None; + } + st.frame = Some(frame); + let t = Tokens::get(ui.ctx()); + ui.set_width(width.max(560.0)); + let mut pick: Option = None; + let mut favs_changed = false; + + // Search. + let search = ui + .horizontal(|ui| { + let search = ui.add( + egui::TextEdit::singleline(&mut st.query) + .hint_text(crate::rtl::widget(ui, tr("Search fonts"))) + .desired_width(ui.available_width() - 30.0) + // Return picks the highlighted row instead of leaving the field. + .return_key(None), + ); + let clear = ui.add_enabled(!st.query.is_empty(), egui::Button::new("×").frame(false)).on_hover_text(tr("Clear")); + if clear.clicked() { + st.query.clear(); + } + search + }) + .inner; + // Typing always goes to the search, never to the document under the menu. + if !search.has_focus() { + search.request_focus(); + } + + // Filters, sample text and size. + let sample_key = app.ui.font_sample.clone(); + let size_index = usize::from(app.ui.font_preview_size).min(SIZES.len() - 1); + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Filters:")).color(t.text)); + let class = st.class.map_or_else(|| tr("All Classes").to_string(), |c| tr(c.label()).to_string()); + if ui.selectable_label(st.show_classes, format!("{class} ▾")).clicked() { + st.show_classes = !st.show_classes; + st.show_samples = false; + } + if toggle(ui, st.only_favs, tr("Show Favorites Only"), |p, r, c| paint_star(p, r.center(), 6.5, true, c)) { + st.only_favs = !st.only_favs; + } + if toggle(ui, st.only_recent, tr("Show Recently Added"), clock) { + st.only_recent = !st.only_recent; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + for (i, (_, size)) in SIZES.iter().enumerate().rev() { + let tip = [tr("Small Sample Text Size"), tr("Medium Sample Text Size"), tr("Large Sample Text Size")][i]; + if toggle(ui, size_index == i, tip, |p, r, c| { + p.text(r.center(), Align2::CENTER_CENTER, "A", FontId::proportional(8.0 + *size * 0.35), c); + }) { + app.ui.font_preview_size = i as u8; + } + } + let shown = SAMPLES.iter().find(|(k, _)| *k == sample_key).map_or(SAMPLES[0].1, |(_, l)| l); + if ui.selectable_label(st.show_samples, format!("{} ▾", tr(shown))).clicked() { + st.show_samples = !st.show_samples; + st.show_classes = false; + } + }); + }); + if st.show_classes { + ui.horizontal_wrapped(|ui| { + if ui.selectable_label(st.class.is_none(), tr("All Classes")).clicked() { + st.class = None; + st.show_classes = false; + } + for c in FontClass::ALL { + if ui.selectable_label(st.class == Some(c), tr(c.label())).clicked() { + st.class = Some(c); + st.show_classes = false; + } + } + }); + } + if st.show_samples { + ui.horizontal_wrapped(|ui| { + for (key, label) in SAMPLES { + if ui.selectable_label(sample_key == key, tr(label)).clicked() { + app.ui.font_sample = key.to_string(); + st.show_samples = false; + } + } + }); + } + if let Some(like) = st.similar.clone() { + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Similar to {family}").replace("{family}", &like)).color(t.text_strong)); + if ui.small_button("×").on_hover_text(tr("Clear")).clicked() { + st.similar = None; + } + }); + } + let selected = super::text_attrs(app).and_then(|a| a["selectedText"].as_str().map(str::to_string)).filter(|s| !s.trim().is_empty()); + let sample = match app.ui.font_sample.as_str() { + "selected" => selected.unwrap_or_else(|| tr("Sample").to_string()), + key => tr(SAMPLES.iter().find(|(k, _)| *k == key).map_or("Sample", |(_, l)| l)).to_string(), + }; + let (row_h, sample_size) = SIZES[usize::from(app.ui.font_preview_size).min(SIZES.len() - 1)]; + ui.separator(); + + // 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 rows: Vec = Vec::new(); + if q.is_empty() && !filtered && !st.only_favs { + let recent: Vec = app + .session + .prefs + .recent_fonts + .iter() + .filter_map(|r| menu.iter().find(|f| f.family == *r)) + .take(RECENT) + .map(|f| Row::Family { info: f.clone(), recent: true }) + .collect(); + if !recent.is_empty() { + rows.extend(recent); + rows.push(Row::Separator); + } + } + let mut group = None; + for f in families { + // A separator between the Western fonts and each CJK language's. + if group.is_some_and(|g| g != f.group) && st.similar.is_none() { + rows.push(Row::Separator); + } + group = Some(f.group); + rows.push(Row::Family { info: f.clone(), recent: false }); + if st.expanded.contains(&f.family) { + for s in meta(ui, &fonts, scope, &f.family).styles { + rows.push(Row::Style { family: f.family.clone(), style: s }); + } + } + } + + // Keyboard: ↑/↓ through the pickable rows, Return picks. + let pickable: Vec = rows.iter().enumerate().filter(|(_, r)| !matches!(r, Row::Separator)).map(|(i, _)| i).collect(); + 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, + (None, Some(_)) if down => Some(0), + (Some(h), Some(l)) if down => Some((h + 1).min(l)), + (Some(h), Some(l)) if up => Some(h.saturating_sub(1).min(l)), + (h, Some(l)) => h.map(|h| h.min(l)), + }; + let highlighted_row = st.highlight.and_then(|h| pickable.get(h).copied()); + if enter && let Some(row) = highlighted_row.and_then(|i| rows.get(i)) { + pick = match row { + Row::Family { info, .. } => Some(FontPick { family: info.family.clone(), style: None }), + Row::Style { family, style } => Some(FontPick { family: family.clone(), style: Some(style.clone()) }), + Row::Separator => None, + }; + } + + let mut toggle_expand = None; + let mut toggle_fav = None; + let mut similar = None; + // A popup only offers last frame's height: ask for the rows' own (a family just opened, a + // filter just cleared), up to the list's limit. + let list_h: f32 = rows + .iter() + .map(|r| match r { + Row::Family { .. } => row_h + ui.spacing().item_spacing.y, + Row::Style { .. } => row_h.min(30.0) + ui.spacing().item_spacing.y, + Row::Separator => 8.0, + }) + .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() { + crate::rtl::label(ui, egui::RichText::new(tr("No fonts match")).color(t.text_dim)); + } + for (i, row) in rows.iter().enumerate() { + let (family, style, recent) = match row { + Row::Separator => { + ui.separator(); + continue; + } + Row::Family { info, recent } => (info, None, *recent), + Row::Style { family, style } => { + let Some(info) = menu.iter().find(|f| f.family == *family) else { continue }; + (info, Some(style.as_str()), false) + } + }; + let f = &family.family; + let (r, mut resp) = + ui.allocate_exact_size(vec2(ui.available_width(), if style.is_some() { row_h.min(30.0) } else { row_h }), Sense::click()); + let highlighted = highlighted_row == Some(i); + if highlighted && (down || up) { + ui.scroll_to_rect(r, None); + } + if !ui.is_rect_visible(r) { + continue; + } + let label = match style { + Some(s) => s.to_string(), + None => family.label(english).to_string(), + }; + let is_current = style.is_none() && f == current; + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_current, &label)); + if style.is_none() && label != *f { + resp = resp.on_hover_text(f); + } + // Over the row or one of its buttons (the star, the arrow, ≈ are on top of it). + let hovered = resp.contains_pointer(); + if is_current { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if highlighted || hovered { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let p = ui.painter_at(r); + let m = meta(ui, &fonts, scope, f); + let cy = r.center().y; + let mut handled = false; + if style.is_none() { + // Favourite star. + let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0)); + let fav = favs.contains(f); + let sr = ui.interact(star, ui.id().with(("fav", f, recent)), Sense::click()); + paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); + if sr.clicked() { + toggle_fav = Some(f.clone()); + handled = true; + } + // The arrow opens a family of several styles. + if m.styles.len() > 1 && !recent { + let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0)); + let ar = ui.interact(arrow, ui.id().with(("open", f)), Sense::click()); + ar.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" }), + ) + }); + let open = st.expanded.contains(f); + crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon); + if ar.clicked() { + toggle_expand = Some(f.clone()); + handled = true; + } + } + } + // Name (and how many styles), then the sample in the face, then the format. + let name_x = r.min.x + if style.is_some() { 48.0 } else { 40.0 }; + let name = if style.is_none() && m.styles.len() > 1 { format!("{label} ({})", m.styles.len()) } else { label.clone() }; + p.text(pos2(name_x, cy), Align2::LEFT_CENTER, &name, FontId::proportional(12.5), t.text); + let preview_x = r.min.x + (r.width() * 0.42).max(190.0); + let icons_x = r.max.x - 52.0; + let face_style = style.map_or_else(|| regular(&m.styles), str::to_string); + let tex = preview(ui, &fonts, scope, f, &face_style, &sample, sample_size, t.text); + let size = tex.size_vec2() / ui.ctx().pixels_per_point(); + let shown = Rect::from_min_size(pos2(preview_x, cy - size.y / 2.0), size); + let clip = Rect::from_min_max(pos2(preview_x, r.min.y), pos2(icons_x - 6.0, r.max.y)); + ui.painter_at(clip).image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + // Show Similar Fonts (on hover), and the format. + if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) { + let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0)); + let sr = ui.interact(sim, ui.id().with(("similar", f, recent)), Sense::click()); + sr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tr("Show Similar Fonts"))); + p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon }); + if sr.on_hover_text(tr("Show Similar Fonts")).clicked() { + similar = Some(f.clone()); + handled = true; + } + } + 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); + ui.interact(badge, ui.id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format))); + } + if resp.clicked() && !handled { + pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) }); + } + } + }); + if let Some(f) = toggle_expand + && !st.expanded.remove(&f) + { + st.expanded.insert(f); + } + if let Some(f) = toggle_fav { + if favs.contains(&f) { + favs.retain(|x| *x != f); + } else { + favs.push(f); + } + favs_changed = true; + } + if let Some(f) = similar { + st.similar = if st.similar.as_deref() == Some(f.as_str()) { None } else { Some(f) }; + st.query.clear(); + } + ui.data_mut(|d| d.insert_temp(state_id, st)); + if favs_changed { + let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); + } + if let Some(p) = &pick { + // Recently used, newest first. + let mut recent = app.session.prefs.recent_fonts.clone(); + recent.retain(|f| *f != p.family); + recent.insert(0, p.family.clone()); + recent.truncate(RECENT); + let _ = app.run("prefs.set", json!({ "recentFonts": recent })); + ui.close(); + } + pick +} diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 3fb9d00eb..961d2b611 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -34,8 +34,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let (db, scope) = (super::fonts(app), super::font_scope(app)); ui.horizontal(|ui| { if let Some(f) = super::font_combo(app, ui, "glyph_family", &st.family, 140.0) { - st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into()); - st.family = f; + st.style = f.style.clone().or_else(|| db.styles(&f.family).into_iter().next()).unwrap_or_else(|| "Regular".into()); + st.family = f.family; } egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { for s in db.styles(&st.family) { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 581f6747e..f4d01b98f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,8 @@ //! Panels and shared panel helpers. pub mod conditions; +mod font_menu; +pub use font_menu::{FontPick, SAMPLES as FONT_SAMPLES, font_menu_body}; pub mod datamerge; pub mod glyphs; pub mod interactive; @@ -219,220 +221,57 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: } } -/// A five-pointed star (filled, or outlined). -fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egui::Color32) { - let pt = |i: usize, rad: f32| { - let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0; - c + egui::vec2(a.cos(), a.sin()) * rad - }; - let outline: Vec = (0..10).map(|i| pt(i, if i % 2 == 0 { r } else { r * 0.42 })).collect(); - if filled { - // The inner pentagon and the five points (each convex). - let inner: Vec = (0..5).map(|i| outline[2 * i + 1]).collect(); - p.add(egui::Shape::convex_polygon(inner, color, egui::Stroke::NONE)); - for i in 0..5 { - p.add(egui::Shape::convex_polygon(vec![outline[(2 * i + 9) % 10], outline[2 * i], outline[2 * i + 1]], color, egui::Stroke::NONE)); - } - } else { - p.add(egui::Shape::closed_line(outline, egui::Stroke::new(1.0, color))); - } -} - -/// The font menu's state while it is open: the search, Show Favorites Only, the match ↑/↓ highlight -/// and the frame it was last shown (a menu not shown the frame before has just opened). -#[derive(Clone, Default)] -struct FontMenuState { - query: String, - only_favs: bool, - highlight: Option, - frame: Option, -} - -/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard -/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites -/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move -/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog -/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the -/// stars keep it open (the popup closes on a click outside only). -pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option { - let (fonts, scope) = (fonts(app), font_scope(app)); - let english = app.session.prefs.show_font_names_in_english; - let mut favs = app.session.prefs.favorite_fonts.clone(); - let mut pick = None; - let mut favs_changed = false; - let state_id = egui::Id::new("font_menu_state"); - let mut st: FontMenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); - let frame = ui.ctx().cumulative_frame_nr(); - if st.frame.is_none_or(|f| f.saturating_add(1) < frame) { - st.query.clear(); - st.highlight = None; - } - st.frame = Some(frame); - let t = crate::theme::Tokens::get(ui.ctx()); - ui.set_width(width.max(300.0)); - let search = ui - .horizontal(|ui| { - let search = ui.add( - egui::TextEdit::singleline(&mut st.query) - .hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search fonts"))) - .desired_width(200.0) - // Return picks the highlighted match instead of leaving the field. - .return_key(None), - ); - ui.toggle_value(&mut st.only_favs, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Favorites"))).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Favorites Only")); - }); - search - }) - .inner; - // Typing always goes to the search, never to the document under the menu. - if !search.has_focus() { - search.request_focus(); - } - let q = st.query.to_lowercase(); - let shown: Vec<&designcraft_fonts::FamilyInfo> = menu.iter().filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family))).collect(); - if search.changed() { - st.highlight = if q.is_empty() { None } else { Some(0) }; - } - // The menu owns these keys while it is open: a dialog under it must not take Return as OK or - // Escape as Cancel. - 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)) - }); - let last = shown.len().checked_sub(1); - st.highlight = match (st.highlight, last) { - (_, None) => None, - (None, Some(_)) if down => Some(0), - (Some(h), Some(l)) if down => Some((h + 1).min(l)), - (Some(h), Some(l)) if up => Some(h.saturating_sub(1).min(l)), - (h, Some(l)) => h.map(|h| h.min(l)), - }; - if enter && let Some(f) = st.highlight.and_then(|h| shown.get(h)) { - pick = Some(f.family.clone()); - } - let ppp = ui.ctx().pixels_per_point(); - egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { - let mut group = None; - for (k, info) in shown.iter().enumerate() { - // A separator between the Western fonts and each CJK language's. - if group.is_some_and(|g| g != info.group) { - ui.separator(); - } - group = Some(info.group); - let f = &info.family; - let label = info.label(english); - let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click()); - let highlighted = st.highlight == Some(k); - if highlighted && (down || up) { - ui.scroll_to_rect(row, None); - } - if !ui.is_rect_visible(row) { - continue; - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, f == current, label)); - if label != f { - resp = resp.on_hover_text(f); - } - if f == current { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if highlighted || resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); - } - // Favourite star. - let star = egui::Rect::from_min_size(row.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 16.0)); - let fav = favs.contains(f); - let sr = ui.interact(star, ui.id().with(("fav", f)), egui::Sense::click()); - paint_star(ui.painter(), star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); - if sr.clicked() { - if fav { - favs.retain(|x| x != f); - } else { - favs.push(f.clone()); - } - favs_changed = true; - } - ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text); - // The name in its own face (rendered once per family and scale). - let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array())); - let tex: Option = ui.data(|d| d.get_temp(key)); - let tex = tex.unwrap_or_else(|| { - let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); - let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); - ui.data_mut(|d| d.insert_temp(key, h.clone())); - h - }); - let size = tex.size_vec2() / ppp; - let at = egui::pos2(row.max.x - size.x - 4.0, row.center().y - size.y / 2.0); - ui.painter().image( - tex.id(), - egui::Rect::from_min_size(at, size), - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - if resp.clicked() && !sr.clicked() { - pick = Some(f.clone()); - } - } - }); - ui.data_mut(|d| d.insert_temp(state_id, st)); - if favs_changed { - let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); - } - if pick.is_some() || escape { - ui.close(); - } - pick -} - /// A font family combo box showing `current` by its menu name, with [`font_menu_body`] as its menu. -/// Returns the family picked. +/// Returns the font picked. pub fn font_combo( app: &mut DesignApp, ui: &mut egui::Ui, id_salt: impl std::hash::Hash + std::fmt::Debug, current: &str, width: f32, -) -> Option { +) -> Option { let menu = font_menu(app); let shown = if current.is_empty() { "—".to_string() } else { font_label(app, &menu, current) }; - egui::ComboBox::from_id_salt(id_salt) - .selected_text(shown) - .width(width) - .height(440.0) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| font_menu_body(app, ui, &menu, current, width)) - .inner - .flatten() -} - -/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family -/// picked. -pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { - // Screen readers (and tests) see the field as the combo box it looks like. + // 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; field.widget_info(|| { let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); - info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }); + 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())) .and_then(|r| r.inner) } -/// Set the text's font to `family`, in its Regular style (else its first). -pub fn apply_font_family(app: &mut DesignApp, family: &str) { - let styles = fonts(app).styles(family); - let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; - let _ = app.run("type.char", json!({"attrs": {"fontFamily": family, "fontStyle": style}})); +/// The style a pick sets: the one picked, else the family's Regular (else its first). +pub fn pick_style(app: &DesignApp, pick: &FontPick) -> String { + if let Some(s) = &pick.style { + return s.clone(); + } + let styles = fonts(app).styles(&pick.family); + if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() } +} + +/// Set the text's font to the one picked (a family's Regular style unless a style was picked). +pub fn apply_font(app: &mut DesignApp, pick: &FontPick) { + let style = pick_style(app, pick); + let _ = app.run("type.char", json!({"attrs": {"fontFamily": pick.family, "fontStyle": style}})); } /// The text's font family field: [`font_combo`], applied to the text. pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) { if let Some(f) = font_combo(app, ui, "font_family", current, width) { - apply_font_family(app, &f); + apply_font(app, &f); } } @@ -560,6 +399,89 @@ mod font_menu_ui_tests { h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fontFamily"].as_str().unwrap().to_string() } + /// 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); + click_at(h, at.center()); + if !query.is_empty() { + h.event(egui::Event::Text(query.into())); + } + h.run_steps(4); + } + + #[test] + fn a_family_opens_to_its_styles_and_a_style_is_picked() { + let (mut h, _) = typing(); + let serif = designcraft_fonts::DEFAULT_FAMILY; + open_menu(&mut h, serif, "serif 4"); + // The arrow before the name opens the family; the menu stays open. + { + let at = h.get_by_label("Show Styles").rect().center(); + click_at(&mut h, at); + } + assert!(h.query_by_label("Bold Italic").is_some(), "its styles are listed"); + { + let at = h.get_by_label("Bold Italic").rect().center(); + click_at(&mut h, at); + } + let attrs = h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((attrs["chars"]["fontFamily"].as_str(), attrs["chars"]["fontStyle"].as_str()), (Some(serif), Some("Bold Italic"))); + assert!(!h.ctx.text_edit_focused(), "picking closes the menu"); + } + + #[test] + fn picked_fonts_come_first_as_recently_used() { + let (mut h, _) = typing(); + open_menu(&mut h, designcraft_fonts::DEFAULT_FAMILY, "source sans"); + { + let at = h.get_by_label("Source Sans 3").rect().center(); + click_at(&mut h, at); + } + assert_eq!(h.state().app.session.prefs.recent_fonts, ["Source Sans 3"]); + // Reopened without a search: Source Sans 3 is listed at the top, and again in its place. + open_menu(&mut h, "Source Sans 3", ""); + let recent = h.get_by_label("Source Sans 3").rect(); + let search = h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).rect(); + assert!(recent.min.y > search.max.y && recent.min.y - search.max.y < 80.0, "the first row, under the filters: {recent:?} vs {search:?}"); + } + + #[test] + fn the_class_filter_and_similar_fonts_narrow_the_list() { + let (mut h, _) = typing(); + open_menu(&mut h, designcraft_fonts::DEFAULT_FAMILY, ""); + { + let at = h.get_by(|n| n.label().is_some_and(|l| l.starts_with("All Classes"))).rect().center(); + click_at(&mut h, at); + } + { + let at = h.get_by_label("Monospaced").rect().center(); + click_at(&mut h, at); + } + 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. + { + let at = h.get_by(|n| n.label().is_some_and(|l| l.starts_with("Monospaced ▾"))).rect().center(); + click_at(&mut h, at); + } + { + let at = h.get_by_label("All Classes").rect().center(); + click_at(&mut h, at); + } + h.event(egui::Event::Text("serif 4".into())); + h.run_steps(4); + let row = h.get_by_label(designcraft_fonts::DEFAULT_FAMILY).rect(); + h.hover_at(row.center()); + h.run_steps(2); + { + let at = h.get_by_label("Show Similar Fonts").rect().center(); + click_at(&mut h, at); + } + assert!(h.query_by_label(&format!("Similar to {}", designcraft_fonts::DEFAULT_FAMILY)).is_some()); + assert!(h.query_by_label("Source Sans 3").is_none() && h.query_by_label("JetBrains Mono").is_none(), "other classes aren't similar"); + assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_some()); + } + #[test] fn typing_in_the_font_menu_searches_and_never_reaches_the_story() { let (mut h, story) = typing(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..c9139955b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -998,7 +998,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { t.text, ); if let Some(f) = super::font_popup(app, &resp, &menu, &fam) { - super::apply_font_family(app, &f); + super::apply_font(app, &f); } ui.add_space(1.0); let styles = fonts.styles(&fam); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index cf66e9d5f..57e267420 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -146,7 +146,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) }; let field = widgets::dropdown(ui, &shown, 150.0); if let Some(f) = panels::font_popup(app, &field, &menu, &fam) { - panels::apply_font_family(app, &f); + panels::apply_font(app, &f); } let styles = fonts.styles(&fam); let cur = styles.iter().position(|s| *s == sty); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5705ee738..0163179c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -325,7 +325,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. -- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. +- Font menu laid out like InDesign's: search, a Filters row (classification: serif, slab serif, sans serif, script, monospaced, decorative, symbol, read from PANOSE, the OS/2 family class, the name, else the shape of the "l"; Show Favorites Only; Show Recently Added, fonts added in the last 30 days), the sample text (the selected text, Sample, pangrams) in three sizes, Recently Used fonts at the top (`recentFonts` preference), favourites (stars, `favoriteFonts`), and each family previewed in its own face with its format (TrueType, OpenType, variable, colour). A family of several styles shows how many and opens to list them, each previewed in its own face, and a style picked is applied as it is; Show Similar Fonts (≈) lists the families of the same class nearest by PANOSE. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8805defce..77030fc0b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -40,7 +40,7 @@ basics of editing fail for too many users on Windows and Linux. | Copy / cut / paste objects and text, Paste Into / in Place | ✓ | ✓ on macOS | ~ | **failing on Windows/Linux** (#351, #348, #312, #185, #164, #201, #212) | | Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) | | IME text input on the canvas | ✓ | ✗ | ✗ | #322 | -| Font menu: search, preview, favourites, type-ahead | ✓ | ✓ | ~ | search field focus on macOS (#335, #86) | +| Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope | | Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | | | Quick Apply | ✓ | ✓ | ✓ | | | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | From 3141df334b84ccbabc48609e1253512db57158c7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 11:29:22 +0200 Subject: [PATCH 6/9] test: The recently used font is the topmost row, whatever fonts are installed With few fonts installed the font's own row in the main list is on screen too, so the label matched twice (FreeBSD CI). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f4d01b98f..30b4ca642 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -438,9 +438,11 @@ mod font_menu_ui_tests { click_at(&mut h, at); } assert_eq!(h.state().app.session.prefs.recent_fonts, ["Source Sans 3"]); - // Reopened without a search: Source Sans 3 is listed at the top, and again in its place. + // Reopened without a search: Source Sans 3 is listed at the top, and again in its place (on + // screen too when few fonts are installed). open_menu(&mut h, "Source Sans 3", ""); - let recent = h.get_by_label("Source Sans 3").rect(); + let recent = + h.query_all_by_label("Source Sans 3").map(|n| n.rect()).min_by(|a, b| a.min.y.total_cmp(&b.min.y)).expect("Source Sans 3 is listed"); let search = h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).rect(); assert!(recent.min.y > search.max.y && recent.min.y - search.max.y < 80.0, "the first row, under the filters: {recent:?} vs {search:?}"); } From ede0ab31a4d30a33b211c82e99cc6837abffe0ad Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 19:14:01 +0200 Subject: [PATCH 7/9] 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 --- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/panels/font_menu.rs | 149 ++++++++++++++++++------- crates/ui-egui/src/panels/mod.rs | 90 ++++++++++++--- 4 files changed, 186 insertions(+), 55 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1f34cb9f5..d2b08dbbd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2855,6 +2855,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 25f294b5a..a64418f9d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1511,6 +1511,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) { From 1bb4a70d5b48396c0af44d974831677403bbc801 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:13:57 +0200 Subject: [PATCH 8/9] fix(ui): The font menu's filters don't keep every font loaded The class, Recently Added and Show Similar Fonts filters read every family's kind through face(), which loads the family and keeps its bytes for the session (with no size cap). They use family_kind now, which reads each file and lets it go. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/font_menu.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/font_menu.rs b/crates/ui-egui/src/panels/font_menu.rs index aa28f0318..7a50791c9 100644 --- a/crates/ui-egui/src/panels/font_menu.rs +++ b/crates/ui-egui/src/panels/font_menu.rs @@ -91,10 +91,12 @@ 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`]. +/// A family's kind, and whether its file was added within [`ADDED_WITHIN`]. Read without keeping +/// the family loaded: the filters look at every installed family. 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)) + fonts + .family_kind(family) + .map_or((FaceKind::default(), false), |(kind, added)| (kind, 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`. From f5b9bab01625f8e1a04cb1fd6bd1da655e5f11d0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 10:03:50 +0200 Subject: [PATCH 9/9] fix(ui): Bounded font previews; a family missing from the read is read once - Previews were kept in the context's data for each sample text, size and colour, and never let go: every new text selection loaded textures for every visible row. They now live in a cache of at most 256 that drops the least recently used. - A family the background read didn't list (installed since) was read from disk on the UI thread every frame and never kept; it is now read once and stored with the others. - Return and Escape in a dialog's font menu are covered by #447's test on main; this branch's duplicate is dropped. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/font_menu.rs | 74 ++++++++++++++++++++++++-- crates/ui-egui/src/panels/mod.rs | 36 ------------- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/panels/font_menu.rs b/crates/ui-egui/src/panels/font_menu.rs index 7a50791c9..fa86f925f 100644 --- a/crates/ui-egui/src/panels/font_menu.rs +++ b/crates/ui-egui/src/panels/font_menu.rs @@ -226,16 +226,51 @@ fn preview( let ppp = ui.ctx().pixels_per_point(); let px = (size * ppp).round() as u32; let key = egui::Id::new(("font_preview", family, style, scope, text, px, color.to_array())); - if let Some(t) = ui.data(|d| d.get_temp::(key)) { + let cache_id = egui::Id::new("font_previews"); + if let Some(t) = ui.data_mut(|d| d.get_temp_mut_or_default::(cache_id).get(key)) { return t; } let img = designcraft_render::glyphs::text_line(fonts, family, style, text, px, color.to_array()); let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let t = ui.ctx().load_texture(format!("font_preview_{family}_{style}"), ci, egui::TextureOptions::LINEAR); - ui.data_mut(|d| d.insert_temp(key, t.clone())); + ui.data_mut(|d| d.get_temp_mut_or_default::(cache_id).insert(key, t.clone())); t } +/// Font menu previews kept for reuse, at most [`Previews::CAP`]: each sample text, size and colour +/// renders new ones, so the oldest are let go (their textures freed) as new ones come in. +#[derive(Clone, Default)] +struct Previews { + map: std::collections::HashMap, + order: std::collections::VecDeque, +} + +impl Previews { + /// About two screens of rows in each of two sample sizes. + const CAP: usize = 256; + + fn get(&mut self, key: egui::Id) -> Option { + let t = self.map.get(&key).cloned()?; + // Most recently used last. + if let Some(i) = self.order.iter().position(|k| *k == key) { + self.order.remove(i); + } + self.order.push_back(key); + Some(t) + } + + fn insert(&mut self, key: egui::Id, t: egui::TextureHandle) { + if self.map.insert(key, t).is_none() { + self.order.push_back(key); + } + while self.order.len() > Self::CAP { + if let Some(old) = self.order.pop_front() { + self.map.remove(&old); + } + } + } +} + /// A small toggle in the filter row (an icon), with its tooltip. fn toggle(ui: &mut Ui, on: bool, tip: &str, paint: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool { let t = Tokens::get(ui.ctx()); @@ -389,10 +424,18 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur let mut loading = false; if filtered { let shared = kinds(ui.ctx(), &fonts, scope); - let k = lock(&shared); + let mut 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))); + // A family the read didn't list (installed or activated since) is read here once, after the + // read is done, and kept. + if k.done { + let missing: Vec = families.iter().filter(|f| !k.of.contains_key(&f.family)).map(|f| f.family.clone()).collect(); + for f in missing { + let v = family_kind(&fonts, &f); + k.of.insert(f, v); + } + } + let kind = |f: &str| k.of.get(f).copied(); 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; @@ -620,3 +663,24 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur } pick } + +#[cfg(test)] +mod preview_cache_tests { + use super::Previews; + + #[test] + fn the_preview_cache_keeps_at_most_its_cap_and_drops_the_oldest() { + let ctx = egui::Context::default(); + let tex = |i: usize| ctx.load_texture(format!("t{i}"), egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE]), Default::default()); + let mut c = Previews::default(); + let key = |i: usize| egui::Id::new(("p", i)); + for i in 0..Previews::CAP + 40 { + c.insert(key(i), tex(i)); + // The first one stays in use, so it is kept. + assert!(c.get(key(0)).is_some(), "{i}"); + } + assert_eq!((c.map.len(), c.order.len()), (Previews::CAP, Previews::CAP)); + assert!(c.get(key(1)).is_none(), "the oldest unused one was let go"); + assert!(c.get(key(Previews::CAP + 39)).is_some()); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6ced52c72..bc023a28d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -514,42 +514,6 @@ mod font_menu_ui_tests { 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();