Skip to main content

fret_runtime/
font_bootstrap.rs

1use fret_core::TextFontFamilyConfig;
2use std::collections::HashSet;
3
4use crate::{FontCatalog, FontCatalogCache, FontCatalogEntry, FontCatalogMetadata, GlobalsHost};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FontFamilyDefaultsPolicy {
8    None,
9    FillIfEmpty,
10    /// If any UI family list is empty, seed it from the head of the current font catalog.
11    ///
12    /// This is primarily intended for Web/WASM bootstrap, where system font discovery is not
13    /// available and we need a deterministic, minimal fallback without exploding settings to
14    /// "all fonts".
15    FillIfEmptyFromCatalogPrefix {
16        max: usize,
17    },
18    /// If any UI family list is empty, seed it with a small curated list of common UI families.
19    ///
20    /// This is primarily intended for Web/WASM bootstrap.
21    FillIfEmptyWithCuratedCandidates,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FontCatalogUpdate {
26    pub revision: u64,
27    pub families: Vec<String>,
28    pub cache: FontCatalogCache,
29    pub config: TextFontFamilyConfig,
30    pub config_changed: bool,
31}
32
33fn merge_unique_family_candidates(lists: &[&[&str]]) -> Vec<String> {
34    let mut seen_lower: HashSet<String> = HashSet::new();
35    let mut out = Vec::new();
36    for list in lists {
37        for &family in *list {
38            let trimmed = family.trim();
39            if trimmed.is_empty() {
40                continue;
41            }
42            let key = trimmed.to_ascii_lowercase();
43            if seen_lower.insert(key) {
44                out.push(trimmed.to_string());
45            }
46        }
47    }
48    out
49}
50
51fn bundled_profile() -> &'static fret_fonts::BundledFontProfile {
52    fret_fonts::default_profile()
53}
54
55fn curated_ui_sans_candidates() -> Vec<String> {
56    #[cfg(target_arch = "wasm32")]
57    {
58        merge_unique_family_candidates(&[bundled_profile().ui_sans_families])
59    }
60    #[cfg(not(target_arch = "wasm32"))]
61    {
62        merge_unique_family_candidates(&[
63            bundled_profile().ui_sans_families,
64            &[
65                "Segoe UI",
66                "Helvetica",
67                "Arial",
68                "Ubuntu",
69                "Adwaita Sans",
70                "Cantarell",
71                "Noto Sans",
72                "DejaVu Sans",
73            ],
74        ])
75    }
76}
77
78fn curated_ui_serif_candidates() -> Vec<String> {
79    #[cfg(target_arch = "wasm32")]
80    {
81        merge_unique_family_candidates(&[bundled_profile().ui_serif_families])
82    }
83    #[cfg(not(target_arch = "wasm32"))]
84    {
85        merge_unique_family_candidates(&[
86            bundled_profile().ui_serif_families,
87            &["Noto Serif", "Times New Roman", "Georgia", "DejaVu Serif"],
88        ])
89    }
90}
91
92fn curated_ui_mono_candidates() -> Vec<String> {
93    #[cfg(target_arch = "wasm32")]
94    {
95        merge_unique_family_candidates(&[bundled_profile().ui_mono_families])
96    }
97    #[cfg(not(target_arch = "wasm32"))]
98    {
99        merge_unique_family_candidates(&[
100            bundled_profile().ui_mono_families,
101            &["Consolas", "Menlo", "DejaVu Sans Mono", "Noto Sans Mono"],
102        ])
103    }
104}
105
106fn curated_common_fallback_candidates() -> Vec<String> {
107    #[cfg(target_arch = "wasm32")]
108    {
109        merge_unique_family_candidates(&[bundled_profile().common_fallback_families])
110    }
111    #[cfg(not(target_arch = "wasm32"))]
112    {
113        merge_unique_family_candidates(&[
114            bundled_profile().common_fallback_families,
115            &[
116                "Noto Sans CJK JP",
117                "Noto Sans CJK TC",
118                "Microsoft YaHei UI",
119                "Microsoft YaHei",
120                "PingFang SC",
121                "Hiragino Sans",
122                "Apple Color Emoji",
123                "Segoe UI Emoji",
124                "Segoe UI Symbol",
125            ],
126        ])
127    }
128}
129
130fn apply_family_defaults_policy(
131    mut config: TextFontFamilyConfig,
132    families: &[String],
133    policy: FontFamilyDefaultsPolicy,
134) -> TextFontFamilyConfig {
135    match policy {
136        FontFamilyDefaultsPolicy::None => {}
137        FontFamilyDefaultsPolicy::FillIfEmpty => {
138            if config.ui_sans.is_empty() {
139                config.ui_sans = families.to_vec();
140            }
141            if config.ui_serif.is_empty() {
142                config.ui_serif = families.to_vec();
143            }
144            if config.ui_mono.is_empty() {
145                config.ui_mono = families.to_vec();
146            }
147        }
148        FontFamilyDefaultsPolicy::FillIfEmptyFromCatalogPrefix { max } => {
149            let max = max.max(1);
150            let seed: Vec<String> = families.iter().take(max).cloned().collect();
151            if config.ui_sans.is_empty() {
152                config.ui_sans = seed.clone();
153            }
154            if config.ui_serif.is_empty() {
155                config.ui_serif = seed.clone();
156            }
157            if config.ui_mono.is_empty() {
158                config.ui_mono = seed;
159            }
160        }
161        FontFamilyDefaultsPolicy::FillIfEmptyWithCuratedCandidates => {
162            if config.ui_sans.is_empty() {
163                config.ui_sans = curated_ui_sans_candidates();
164            }
165            if config.ui_serif.is_empty() {
166                config.ui_serif = curated_ui_serif_candidates();
167            }
168            if config.ui_mono.is_empty() {
169                config.ui_mono = curated_ui_mono_candidates();
170            }
171            if config.common_fallback.is_empty() {
172                config.common_fallback = curated_common_fallback_candidates();
173            }
174        }
175    }
176
177    config
178}
179
180pub fn apply_font_catalog_update(
181    app: &mut impl GlobalsHost,
182    families: Vec<String>,
183    policy: FontFamilyDefaultsPolicy,
184) -> FontCatalogUpdate {
185    let prev_rev = app.global::<FontCatalog>().map(|c| c.revision).unwrap_or(0);
186    let catalog_changed = app
187        .global::<FontCatalog>()
188        .map(|c| c.families.as_slice() != families.as_slice())
189        .unwrap_or(true);
190    let revision = if catalog_changed {
191        prev_rev.saturating_add(1)
192    } else {
193        prev_rev
194    };
195
196    let cache = if catalog_changed {
197        let cache = FontCatalogCache::from_families(revision, &families);
198        app.set_global::<FontCatalog>(FontCatalog {
199            families: families.clone(),
200            revision,
201        });
202        app.set_global::<FontCatalogCache>(cache.clone());
203        cache
204    } else {
205        app.global::<FontCatalogCache>()
206            .cloned()
207            .unwrap_or_else(|| FontCatalogCache::from_families(revision, &families))
208    };
209
210    let prev_config = app
211        .global::<TextFontFamilyConfig>()
212        .cloned()
213        .unwrap_or_default();
214    let config = apply_family_defaults_policy(prev_config.clone(), &families, policy);
215
216    let config_changed = config != prev_config;
217    // Always re-set the config global so renderers can react even if the value is unchanged.
218    app.set_global::<TextFontFamilyConfig>(config.clone());
219
220    FontCatalogUpdate {
221        revision,
222        families,
223        cache,
224        config,
225        config_changed,
226    }
227}
228
229pub fn apply_font_catalog_update_with_metadata(
230    app: &mut impl GlobalsHost,
231    entries: Vec<FontCatalogEntry>,
232    policy: FontFamilyDefaultsPolicy,
233) -> FontCatalogUpdate {
234    let families = entries.iter().map(|e| e.family.clone()).collect::<Vec<_>>();
235
236    let prev_rev = app.global::<FontCatalog>().map(|c| c.revision).unwrap_or(0);
237    let catalog_changed = app
238        .global::<FontCatalog>()
239        .map(|c| c.families.as_slice() != families.as_slice())
240        .unwrap_or(true);
241    let metadata_changed = app
242        .global::<FontCatalogMetadata>()
243        .map(|m| m.entries.as_slice() != entries.as_slice())
244        .unwrap_or(true);
245
246    let revision = if catalog_changed || metadata_changed {
247        prev_rev.saturating_add(1)
248    } else {
249        prev_rev
250    };
251
252    let prev_config = app
253        .global::<TextFontFamilyConfig>()
254        .cloned()
255        .unwrap_or_default();
256    let config = apply_family_defaults_policy(prev_config.clone(), &families, policy);
257    let config_changed = config != prev_config;
258    app.set_global::<TextFontFamilyConfig>(config.clone());
259
260    let cache = if catalog_changed || metadata_changed {
261        let cache = FontCatalogCache::from_families(revision, &families);
262        app.set_global::<FontCatalog>(FontCatalog {
263            families: families.clone(),
264            revision,
265        });
266        app.set_global::<FontCatalogCache>(cache.clone());
267        app.set_global::<FontCatalogMetadata>(FontCatalogMetadata { entries, revision });
268        cache
269    } else {
270        app.global::<FontCatalogCache>()
271            .cloned()
272            .unwrap_or_else(|| FontCatalogCache::from_families(revision, &families))
273    };
274
275    FontCatalogUpdate {
276        revision,
277        families,
278        cache,
279        config,
280        config_changed,
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::any::{Any, TypeId};
288    use std::collections::HashMap;
289
290    #[derive(Default)]
291    struct TestApp {
292        globals: HashMap<TypeId, Box<dyn Any>>,
293    }
294
295    impl GlobalsHost for TestApp {
296        fn global<T: 'static>(&self) -> Option<&T> {
297            self.globals
298                .get(&TypeId::of::<T>())
299                .and_then(|v| v.downcast_ref::<T>())
300        }
301
302        fn set_global<T: 'static>(&mut self, value: T) {
303            self.globals.insert(TypeId::of::<T>(), Box::new(value));
304        }
305
306        fn with_global_mut<T: 'static, R>(
307            &mut self,
308            init: impl FnOnce() -> T,
309            f: impl FnOnce(&mut T, &mut Self) -> R,
310        ) -> R {
311            let type_id = TypeId::of::<T>();
312
313            let mut value: T = self
314                .globals
315                .remove(&type_id)
316                .and_then(|v| v.downcast::<T>().ok())
317                .map(|v| *v)
318                .unwrap_or_else(init);
319
320            let out = f(&mut value, self);
321
322            self.globals.insert(type_id, Box::new(value));
323            out
324        }
325    }
326
327    #[test]
328    fn curated_defaults_include_profile_and_platform_fallbacks() {
329        let mut app = TestApp::default();
330        let update = apply_font_catalog_update(
331            &mut app,
332            vec!["Inter".to_string(), "JetBrains Mono".to_string()],
333            FontFamilyDefaultsPolicy::FillIfEmptyWithCuratedCandidates,
334        );
335
336        for family in fret_fonts::default_profile().common_fallback_families {
337            assert!(update.config.common_fallback.iter().any(|v| v == family));
338        }
339        assert!(
340            update
341                .config
342                .common_fallback
343                .iter()
344                .any(|v| v == "Apple Color Emoji")
345        );
346        assert!(
347            update
348                .config
349                .common_fallback
350                .iter()
351                .any(|v| v == "Segoe UI Emoji")
352        );
353    }
354
355    #[test]
356    fn apply_update_does_not_bump_revision_when_families_unchanged() {
357        let mut app = TestApp::default();
358
359        let update0 = apply_font_catalog_update(
360            &mut app,
361            vec!["Inter".to_string(), "JetBrains Mono".to_string()],
362            FontFamilyDefaultsPolicy::None,
363        );
364        let update1 = apply_font_catalog_update(
365            &mut app,
366            vec!["Inter".to_string(), "JetBrains Mono".to_string()],
367            FontFamilyDefaultsPolicy::FillIfEmptyWithCuratedCandidates,
368        );
369
370        assert_eq!(update0.revision, update1.revision);
371        let catalog = app.global::<FontCatalog>().expect("font catalog");
372        assert_eq!(catalog.revision, update0.revision);
373        assert_eq!(
374            catalog.families,
375            vec!["Inter".to_string(), "JetBrains Mono".to_string()]
376        );
377    }
378
379    #[test]
380    fn apply_update_with_metadata_sets_metadata_global() {
381        let mut app = TestApp::default();
382        let entries = vec![
383            FontCatalogEntry {
384                family: "Inter".to_string(),
385                has_variable_axes: false,
386                known_variable_axes: vec![],
387                variable_axes: vec![],
388                is_monospace_candidate: false,
389            },
390            FontCatalogEntry {
391                family: "Roboto Flex".to_string(),
392                has_variable_axes: true,
393                known_variable_axes: vec!["wght".to_string(), "wdth".to_string()],
394                variable_axes: vec![],
395                is_monospace_candidate: false,
396            },
397        ];
398
399        let update = apply_font_catalog_update_with_metadata(
400            &mut app,
401            entries.clone(),
402            FontFamilyDefaultsPolicy::None,
403        );
404
405        let catalog = app.global::<FontCatalog>().expect("font catalog");
406        assert_eq!(catalog.revision, update.revision);
407        assert_eq!(
408            catalog.families,
409            vec!["Inter".to_string(), "Roboto Flex".to_string()]
410        );
411
412        let meta = app
413            .global::<FontCatalogMetadata>()
414            .expect("font catalog metadata");
415        assert_eq!(meta.revision, update.revision);
416        assert_eq!(meta.entries, entries);
417    }
418
419    #[test]
420    fn apply_update_with_metadata_does_not_bump_revision_when_entries_unchanged() {
421        let mut app = TestApp::default();
422        let entries = vec![
423            FontCatalogEntry {
424                family: "Inter".to_string(),
425                has_variable_axes: false,
426                known_variable_axes: vec![],
427                variable_axes: vec![],
428                is_monospace_candidate: false,
429            },
430            FontCatalogEntry {
431                family: "Roboto Flex".to_string(),
432                has_variable_axes: true,
433                known_variable_axes: vec!["wght".to_string(), "wdth".to_string()],
434                variable_axes: vec![],
435                is_monospace_candidate: false,
436            },
437        ];
438
439        let update0 = apply_font_catalog_update_with_metadata(
440            &mut app,
441            entries.clone(),
442            FontFamilyDefaultsPolicy::None,
443        );
444        let update1 = apply_font_catalog_update_with_metadata(
445            &mut app,
446            entries.clone(),
447            FontFamilyDefaultsPolicy::FillIfEmptyWithCuratedCandidates,
448        );
449
450        assert_eq!(update0.revision, update1.revision);
451        let catalog = app.global::<FontCatalog>().expect("font catalog");
452        assert_eq!(catalog.revision, update0.revision);
453        let meta = app
454            .global::<FontCatalogMetadata>()
455            .expect("font catalog metadata");
456        assert_eq!(meta.revision, update0.revision);
457        assert_eq!(meta.entries, entries);
458    }
459}