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 FillIfEmptyFromCatalogPrefix {
16 max: usize,
17 },
18 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 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}