ltk/theme/
font_registry.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Runtime font registry: families → (weight, style) → `fontdue::Font`.
//!
//! The registry is the live, loaded-in-memory counterpart of the theme's
//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load
//! time by calling [`FontRegistry::from_families`], then handed to the
//! render backends as an `Arc<FontRegistry>` so they can resolve specific
//! sources on demand without re-reading TTFs from disk.
//!
//! # Resolution order
//!
//! [`FontRegistry::resolve`] looks up a family / weight / style triple with
//! the following precedence:
//!
//! 1. Exact `(family, weight, style)` match.
//! 2. Same family and style, weight closest to the request (absolute diff).
//! 3. Same family, any style, weight closest to the request.
//! 4. Walk the family's `fallbacks` chain, recursing into each fallback
//!    family with the original weight/style.
//!
//! When nothing matches, [`FontRegistry::resolve`] returns `None`; callers
//! fall back to the canvas' system font.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use fontdue::{ Font, FontSettings };

use super::fonts::FontFamilyDef;
use super::text_style::FontStyle;

// ─── Key ─────────────────────────────────────────────────────────────────────

/// Composite key identifying a single font source within a family.
///
/// `family` is the id under which the family was declared in the theme's
/// `fonts` block (e.g. `"sora"`), not its human-readable name.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub struct FontKey
{
	pub family: String,
	pub weight: u16,
	pub style:  FontStyle,
}

// ─── Errors ──────────────────────────────────────────────────────────────────

/// Error raised when loading a font source fails.
#[ derive( Debug ) ]
pub enum FontLoadError
{
	/// The source file could not be read.
	Io( PathBuf, std::io::Error ),
	/// The source file was read but `fontdue` rejected its contents.
	Parse( PathBuf, String ),
}

impl std::fmt::Display for FontLoadError
{
	fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
	{
		match self
		{
			FontLoadError::Io( p, e )    => write!( f, "reading font {}: {}", p.display(), e ),
			FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ),
		}
	}
}

impl std::error::Error for FontLoadError {}

// ─── Registry ────────────────────────────────────────────────────────────────

/// A loaded font registry: the theme's declared families materialised into
/// live `fontdue::Font` handles, indexed by family id / weight / style.
#[ derive( Debug, Default ) ]
pub struct FontRegistry
{
	by_key:    HashMap<FontKey, Arc<Font>>,
	fallbacks: HashMap<String, Vec<String>>,
}

impl FontRegistry
{
	/// Create an empty registry. Use [`Self::insert`] / [`Self::set_fallbacks`]
	/// to populate it, or [`Self::from_families`] to load a whole theme in
	/// one go.
	pub fn new() -> Self
	{
		Self { by_key: HashMap::new(), fallbacks: HashMap::new() }
	}

	/// Register a single font source.
	pub fn insert
	(
		&mut self,
		family: impl Into<String>,
		weight: u16,
		style:  FontStyle,
		font:   Arc<Font>,
	)
	{
		self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
	}

	/// Set the fallback chain for a family.
	pub fn set_fallbacks( &mut self, family: impl Into<String>, chain: Vec<String> )
	{
		self.fallbacks.insert( family.into(), chain );
	}

	/// Number of loaded sources across all families. Useful in tests.
	pub fn len( &self ) -> usize { self.by_key.len() }

	/// Whether the registry has no sources loaded.
	pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }

	/// Resolve a triple to a loaded [`Font`]. See the module docs for the
	/// precedence order.
	pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
	{
		// 1. Exact match.
		let exact = FontKey { family: family.to_string(), weight, style };
		if let Some( f ) = self.by_key.get( &exact )
		{
			return Some( Arc::clone( f ) );
		}

		// 2. Same family + style, closest weight.
		let best_same_style = self.by_key.iter()
			.filter( |( k, _ )| k.family == family && k.style == style )
			.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
		if let Some( ( _, f ) ) = best_same_style
		{
			return Some( Arc::clone( f ) );
		}

		// 3. Same family, any style, closest weight.
		let best_same_family = self.by_key.iter()
			.filter( |( k, _ )| k.family == family )
			.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
		if let Some( ( _, f ) ) = best_same_family
		{
			return Some( Arc::clone( f ) );
		}

		// 4. Walk fallback chain.
		if let Some( chain ) = self.fallbacks.get( family )
		{
			for fb in chain
			{
				if let Some( f ) = self.resolve( fb, weight, style )
				{
					return Some( f );
				}
			}
		}

		None
	}

	/// Load every source declared in `families` into the registry.
	///
	/// `families` is keyed by family id (the string the theme JSON uses in
	/// `fonts.<id>` and that [`super::FontRef::Named`] references). The
	/// family's `name` field is carried for human display only; lookups go
	/// through the id.
	pub fn from_families
	(
		families: &HashMap<String, FontFamilyDef>,
	) -> Result<Self, FontLoadError>
	{
		let mut reg = Self::new();
		for ( id, family ) in families
		{
			if !family.fallbacks.is_empty()
			{
				reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
			}
			for src in &family.sources
			{
				let bytes = std::fs::read( &src.path )
					.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
				let font  = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
					.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
				reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
			}
		}
		Ok( reg )
	}

	/// Like [`Self::from_families`] but tolerant: sources that fail to load
	/// are logged via `eprintln!` and skipped instead of aborting the whole
	/// registry build. Intended for the runtime path where a missing TTF
	/// on a user's machine should degrade to "fall back to system font for
	/// that weight" rather than crash the shell.
	///
	/// Returns a registry that may be partial — callers cannot assume any
	/// specific weight is loaded, only that what COULD be loaded is
	/// loaded. Font resolution's fallback ladder (family → closest weight
	/// → fallback chain → `Canvas::font`) handles the gaps gracefully.
	pub fn from_families_lenient
	(
		families: &HashMap<String, FontFamilyDef>,
	) -> Self
	{
		let mut reg = Self::new();
		for ( id, family ) in families
		{
			if !family.fallbacks.is_empty()
			{
				reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
			}
			for src in &family.sources
			{
				let bytes = match std::fs::read( &src.path )
				{
					Ok( b )  => b,
					Err( e ) =>
					{
						eprintln!
						(
							"[ltk] skipping font {} (weight {}, {:?}): {}",
							src.path.display(), src.weight, src.style, e
						);
						continue;
					}
				};
				let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
				{
					Ok( f )  => f,
					Err( e ) =>
					{
						eprintln!
						(
							"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
							src.path.display(), src.weight, src.style, e
						);
						continue;
					}
				};
				reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
			}
		}
		reg
	}
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[ cfg( test ) ]
mod tests
{
	use super::*;

	/// Load a real TTF from the system via the same search chain the
	/// software canvas uses. Only used by tests that need an actual
	/// `Font` — skipped when no font is found (keeps CI green on images
	/// without the usual system fonts).
	fn system_font() -> Option<Arc<Font>>
	{
		let path = crate::render::helpers::find_font_opt()?;
		let bytes = std::fs::read( path ).ok()?;
		let font  = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
		Some( Arc::new( font ) )
	}

	#[ test ]
	fn empty_registry_resolves_to_none()
	{
		let reg = FontRegistry::new();
		assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() );
		assert!( reg.is_empty() );
	}

	#[ test ]
	fn exact_match_wins()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
		reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
		assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
		assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() );
		assert_eq!( reg.len(), 2 );
	}

	#[ test ]
	fn closest_weight_is_picked_when_exact_missing()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) );
		reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );

		// Ask for 400: closer to 300 than to 700. Both rounds 100 away or
		// 300 away respectively; 300 wins.
		assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );

		// Ask for 800: closer to 700.
		assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() );
	}

	#[ test ]
	fn fallback_chain_is_walked_when_family_unknown()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
		reg.set_fallbacks( "display", vec![ "sora".to_string() ] );

		// `display` has no direct entries, but its fallback chain leads to
		// `sora` which is loaded.
		assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() );
	}

	#[ test ]
	fn unreachable_family_returns_none()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 400, FontStyle::Normal, font );
		assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() );
	}

	#[ test ]
	fn from_families_reports_io_error_for_missing_path()
	{
		use crate::theme::fonts::FontSource;
		let mut fams = HashMap::new();
		fams.insert( "sora".to_string(), FontFamilyDef
		{
			name:      "Sora".to_string(),
			fallbacks: Vec::new(),
			sources:   vec!
			[
				FontSource
				{
					weight: 400,
					style:  FontStyle::Normal,
					path:   "/this/path/does/not/exist.ttf".into(),
				},
			],
		});
		match FontRegistry::from_families( &fams )
		{
			Err( FontLoadError::Io( p, _ ) ) =>
			{
				assert!( p.to_string_lossy().contains( "does/not/exist" ) );
			}
			other => panic!( "expected Io error, got {:?}", other ),
		}
	}
}