ailloli_ui_text/font_db/mod.rs
1//! Fontique-backed discovery for bundled and operating-system fonts.
2
3use ailloli_ui_core::FontId;
4use fontique::{
5 Attributes, Blob, Collection, CollectionOptions, FallbackKey, GenericFamily, QueryFamily,
6 QueryFont, QueryStatus, Script, SourceCache, SourceCacheOptions,
7};
8
9/// Bundled font bytes that guarantee a monospace-family candidate.
10const JBM_NERD_REGULAR: &[u8] =
11 include_bytes!("../../assets/fonts/JetBrainsMonoNerdFont-Regular.ttf");
12
13/// Font database via `fontique`: bundled assets + system fonts for Parley layout.
14///
15/// The database owns its discovery collection and mutable source cache. Clones
16/// have cloneable Fontique state but are otherwise independent values. System
17/// font contents and ordering are platform-dependent; the bundled monospace
18/// face is registered first to provide a deterministic candidate for
19/// [`FontId::Mono`].
20///
21/// # Examples
22///
23/// ```
24/// use ailloli_ui_core::FontId;
25/// use ailloli_ui_text::FontDb;
26/// let db = FontDb::new();
27/// assert_eq!(db.families_for(FontId::Ui).len(), 1);
28/// assert!(db.families_for(FontId::Mono).len() >= 1);
29/// ```
30#[derive(Clone)]
31pub struct FontDb {
32 /// Bundled and system-font collection used for matching.
33 collection: Collection,
34 /// Cache for loading font sources selected by collection queries.
35 source_cache: SourceCache,
36 /// Registered family identifier for the bundled monospace face.
37 mono_family: Option<fontique::FamilyId>,
38}
39
40impl Default for FontDb {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl FontDb {
47 /// Builds a database with system discovery and bundled fonts enabled.
48 ///
49 /// Construction registers JetBrains Mono Nerd Font, scans the platform's
50 /// font sources through Fontique, and optionally scans the relative
51 /// `assets/fonts` directory. Invalid optional paths are ignored. System
52 /// discovery may perform filesystem I/O and its results vary by platform.
53 ///
54 /// # Examples
55 ///
56 /// ```
57 /// use ailloli_ui_core::FontId;
58 /// use ailloli_ui_text::FontDb;
59 /// let db = FontDb::new();
60 /// assert!(db.families_for(FontId::Mono).len() >= 1);
61 /// ```
62 pub fn new() -> Self {
63 let mut collection = Collection::new(CollectionOptions {
64 shared: false,
65 system_fonts: true,
66 });
67 let source_cache = SourceCache::new(SourceCacheOptions::default());
68
69 // 1) Register at least one known font from bundled assets.
70 let mono_family = {
71 let blob: Blob<u8> = Blob::from(JBM_NERD_REGULAR.to_vec());
72 let families = collection.register_fonts(blob, None);
73 families.first().map(|(id, _)| *id)
74 };
75
76 // 2) Optionally load from `assets/fonts` (invalid paths are ignored).
77 collection.load_fonts_from_paths(["assets/fonts"]);
78
79 Self {
80 collection,
81 source_cache,
82 mono_family,
83 }
84 }
85
86 /// Returns the ordered query families for an Ailloli font slot.
87 ///
88 /// [`FontId::Ui`] returns only generic sans-serif. [`FontId::Mono`] returns
89 /// the bundled family first when registration succeeded, then generic
90 /// monospace. The newly allocated vector is suitable for [`Self::query_first`].
91 ///
92 /// # Examples
93 ///
94 /// ```
95 /// use ailloli_ui_core::FontId;
96 /// use ailloli_ui_text::FontDb;
97 /// let db = FontDb::new();
98 /// assert_eq!(db.families_for(FontId::Ui).len(), 1);
99 /// assert!(db.families_for(FontId::Mono).len() >= 1);
100 /// ```
101 pub fn families_for(&self, font_id: FontId) -> Vec<QueryFamily<'static>> {
102 match font_id {
103 FontId::Ui => vec![QueryFamily::Generic(GenericFamily::SansSerif)],
104 FontId::Mono => {
105 let mut out = Vec::new();
106 if let Some(id) = self.mono_family {
107 out.push(QueryFamily::Id(id));
108 }
109 out.push(QueryFamily::Generic(GenericFamily::Monospace));
110 out
111 }
112 }
113 }
114
115 /// Returns the first matching `QueryFont` for the requested style.
116 ///
117 /// Matching uses the ordered families from [`Self::families_for`], the
118 /// supplied weight/stretch/style attributes, and a Latin-script fallback.
119 /// It stops after the first result. `None` means Fontique found no candidate;
120 /// system-font availability makes that outcome platform-dependent for UI.
121 /// The mutable borrow updates Fontique's source cache.
122 ///
123 /// # Examples
124 ///
125 /// ```
126 /// use ailloli_ui_core::FontId;
127 /// use ailloli_ui_text::FontDb;
128 /// use fontique::Attributes;
129 /// let mut db = FontDb::new();
130 /// assert!(db.resolve_first(FontId::Mono, Attributes::default()).is_some());
131 /// ```
132 pub fn resolve_first(&mut self, font_id: FontId, attrs: Attributes) -> Option<QueryFont> {
133 let families = self.families_for(font_id);
134 self.query_first(&families, attrs)
135 }
136
137 /// Returns the first Fontique match for explicit ordered families.
138 ///
139 /// The query always installs a Latin (`Latn`) fallback key and stops after
140 /// its first callback. An empty or unmatched family slice may still resolve
141 /// through fallback; callers must treat `None` as a normal miss. The result
142 /// borrows no data from `self`.
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use ailloli_ui_core::FontId;
148 /// use ailloli_ui_text::FontDb;
149 /// use fontique::Attributes;
150 /// let mut db = FontDb::new();
151 /// let families = db.families_for(FontId::Mono);
152 /// assert!(db.query_first(&families, Attributes::default()).is_some());
153 /// ```
154 pub fn query_first(
155 &mut self,
156 families: &[QueryFamily<'static>],
157 attrs: Attributes,
158 ) -> Option<QueryFont> {
159 let mut q = self.collection.query(&mut self.source_cache);
160 q.set_families(families.iter().copied());
161 q.set_attributes(attrs);
162 // Minimal fallback: Latin script.
163 q.set_fallbacks(FallbackKey::new(Script::from_bytes(*b"Latn"), None));
164
165 let mut found: Option<QueryFont> = None;
166 q.matches_with(|font| {
167 found = Some(font.clone());
168 QueryStatus::Stop
169 });
170 found
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn resolves_mono_non_empty() {
180 let mut db = FontDb::new();
181 let f = db.resolve_first(FontId::Mono, Attributes::default());
182 assert!(f.is_some());
183 }
184
185 #[test]
186 fn fallback_queries_do_not_panic() {
187 let mut db = FontDb::new();
188 // Coverage is not guaranteed yet; resolution should not panic.
189 let _ = db.resolve_first(FontId::Ui, Attributes::default());
190 }
191}