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}