1use crate::platform::fonts;
7use fontdue::{Font, FontSettings};
8use std::sync::OnceLock;
9use tiny_skia::{Color, Pixmap, PremultipliedColorU8};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum FontWeight {
14 #[default]
16 Regular,
17 Semibold,
19}
20
21struct FontCache {
23 regular: Font,
24 semibold: Option<Font>,
25}
26
27static FONTS: OnceLock<FontCache> = OnceLock::new();
29
30pub struct TextRenderer;
32
33impl TextRenderer {
34 fn fonts() -> &'static FontCache {
40 FONTS.get_or_init(|| {
41 load_fonts_via_fontconfig().unwrap_or_else(|msg| {
42 panic!(
44 "FATAL: {}\n\n\
45 Letter Launcher requires fontconfig to resolve system fonts.\n\
46 This should work automatically on any properly configured Linux system.\n\n\
47 Ensure fontconfig is installed and has fonts available:\n\
48 fc-match sans",
49 msg
50 );
51 })
52 })
53 }
54
55 pub fn font(weight: FontWeight) -> &'static Font {
57 let cache = Self::fonts();
58 match weight {
59 FontWeight::Semibold => cache.semibold.as_ref().unwrap_or(&cache.regular),
60 FontWeight::Regular => &cache.regular,
61 }
62 }
63
64 pub fn render_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, size: f32, color: Color) {
66 Self::render_text_weighted(pixmap, text, x, y, size, color, FontWeight::Regular);
67 }
68
69 pub fn render_text_weighted(
71 pixmap: &mut Pixmap,
72 text: &str,
73 x: f32,
74 y: f32,
75 size: f32,
76 color: Color,
77 weight: FontWeight,
78 ) {
79 let font = Self::font(weight);
80
81 let mut cursor_x = x;
82 let px_size = size;
83
84 for c in text.chars() {
85 let (metrics, bitmap) = font.rasterize(c, px_size);
86
87 if !bitmap.is_empty() && metrics.width > 0 && metrics.height > 0 {
88 let glyph_x = cursor_x as i32 + metrics.xmin;
89 let glyph_y = y as i32 - metrics.height as i32 - metrics.ymin;
91
92 Self::blend_glyph(
93 pixmap,
94 &bitmap,
95 metrics.width,
96 metrics.height,
97 glyph_x,
98 glyph_y,
99 color,
100 c,
101 );
102 }
103
104 cursor_x += metrics.advance_width;
105 }
106 }
107
108 #[allow(clippy::too_many_arguments)] fn blend_glyph(
113 pixmap: &mut Pixmap,
114 bitmap: &[u8],
115 width: usize,
116 height: usize,
117 x: i32,
118 y: i32,
119 color: Color,
120 character: char,
121 ) {
122 let expected_len = width.saturating_mul(height);
124 if bitmap.len() < expected_len {
125 tracing::warn!(
126 "Malformed glyph bitmap for '{}' (U+{:04X}): expected {} bytes ({}x{}), got {}. Skipping glyph.",
127 character,
128 character as u32,
129 expected_len,
130 width,
131 height,
132 bitmap.len()
133 );
134 return;
135 }
136
137 let pixmap_width = pixmap.width() as i32;
138 let pixmap_height = pixmap.height() as i32;
139 let pixels = pixmap.pixels_mut();
140
141 for row in 0..height {
142 for col in 0..width {
143 let px = x + col as i32;
144 let py = y + row as i32;
145
146 if px < 0 || py < 0 || px >= pixmap_width || py >= pixmap_height {
147 continue;
148 }
149
150 let bitmap_idx = row * width + col;
152 let alpha = bitmap[bitmap_idx];
153 if alpha == 0 {
154 continue;
155 }
156
157 let idx = (py as usize) * (pixmap_width as usize) + (px as usize);
158 let src_alpha = (alpha as f32 * color.alpha()) as u8;
160
161 if src_alpha == 0 {
162 continue;
163 }
164
165 let dst = pixels[idx];
166 let blended = blend_pixel(dst, color, src_alpha);
167 pixels[idx] = blended;
168 }
169 }
170 }
171
172 pub fn measure_text(text: &str, size: f32) -> f32 {
174 Self::measure_text_weighted(text, size, FontWeight::Regular)
175 }
176
177 pub fn measure_text_weighted(text: &str, size: f32, weight: FontWeight) -> f32 {
179 let font = Self::font(weight);
180 text.chars()
181 .map(|c| font.metrics(c, size).advance_width)
182 .sum()
183 }
184
185 pub fn ascent(size: f32) -> f32 {
187 let font = Self::font(FontWeight::Regular);
188 let metrics = font.horizontal_line_metrics(size);
189 metrics.map(|m| m.ascent).unwrap_or(size * 0.8)
190 }
191
192 pub fn descent(size: f32) -> f32 {
194 let font = Self::font(FontWeight::Regular);
195 let metrics = font.horizontal_line_metrics(size);
196 metrics.map(|m| m.descent.abs()).unwrap_or(size * 0.2)
197 }
198
199 pub fn line_height(size: f32) -> f32 {
201 Self::ascent(size) + Self::descent(size)
202 }
203
204 pub fn truncate_to_width(text: &str, max_width: f32, size: f32) -> String {
206 let font = Self::font(FontWeight::Regular);
207
208 let ellipsis = "...";
209 let ellipsis_width: f32 = ellipsis
210 .chars()
211 .map(|c| font.metrics(c, size).advance_width)
212 .sum();
213
214 if max_width <= ellipsis_width {
215 return String::new();
216 }
217
218 let mut width = 0.0;
219 let mut result = String::new();
220
221 for c in text.chars() {
222 let char_width = font.metrics(c, size).advance_width;
223 if width + char_width + ellipsis_width > max_width {
224 result.push_str(ellipsis);
225 break;
226 }
227 width += char_width;
228 result.push(c);
229 }
230
231 result
232 }
233}
234
235fn blend_pixel(dst: PremultipliedColorU8, src_color: Color, src_alpha: u8) -> PremultipliedColorU8 {
242 if src_alpha == 0 {
243 return dst;
244 }
245
246 let sa = src_alpha as u32;
249 let sr = ((src_color.red() * 255.0) as u32 * sa / 255).min(255) as u8;
250 let sg = ((src_color.green() * 255.0) as u32 * sa / 255).min(255) as u8;
251 let sb = ((src_color.blue() * 255.0) as u32 * sa / 255).min(255) as u8;
252
253 if src_alpha == 255 {
254 return PremultipliedColorU8::from_rgba(sr, sg, sb, 255).unwrap();
256 }
257
258 let inv_sa = 255 - sa;
260
261 let out_r = (sr as u32 + dst.red() as u32 * inv_sa / 255).min(255) as u8;
262 let out_g = (sg as u32 + dst.green() as u32 * inv_sa / 255).min(255) as u8;
263 let out_b = (sb as u32 + dst.blue() as u32 * inv_sa / 255).min(255) as u8;
264 let out_a = (sa + dst.alpha() as u32 * inv_sa / 255).min(255) as u8;
265
266 PremultipliedColorU8::from_rgba(out_r, out_g, out_b, out_a).unwrap()
267}
268
269fn load_fonts_via_fontconfig() -> Result<FontCache, String> {
274 let resolved = fonts::resolve_sans()
276 .ok_or_else(|| "fontconfig could not resolve 'sans' font".to_string())?;
277
278 tracing::info!(
279 "fontconfig resolved sans to: {} ({})",
280 resolved.family,
281 resolved.path.display()
282 );
283
284 let regular_data = std::fs::read(&resolved.path).map_err(|e| {
286 format!(
287 "Failed to read font file {}: {}",
288 resolved.path.display(),
289 e
290 )
291 })?;
292
293 let regular = Font::from_bytes(regular_data, FontSettings::default())
294 .map_err(|e| format!("Failed to parse font {}: {:?}", resolved.path.display(), e))?;
295
296 const WEIGHT_PRIORITY: &[&str] = &["Bold", "SemiBold", "Semibold", "Medium"];
298
299 let semibold = WEIGHT_PRIORITY
300 .iter()
301 .find_map(|&style| fonts::resolve_font_with_style(&resolved.family, style))
302 .and_then(|resolved| {
303 tracing::debug!(
304 "Resolved semibold variant: {} ({})",
305 resolved.family,
306 resolved.path.display()
307 );
308 std::fs::read(&resolved.path)
309 .ok()
310 .and_then(|data| Font::from_bytes(data, FontSettings::default()).ok())
311 });
312
313 Ok(FontCache { regular, semibold })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_text_measurement() {
322 let width = TextRenderer::measure_text("test", 14.0);
323 assert!(width > 0.0, "Text should have positive width");
324 }
325
326 #[test]
327 fn test_truncation() {
328 let result = TextRenderer::truncate_to_width("Hello World", 1000.0, 14.0);
329 assert!(result.len() <= "Hello World".len() + 3);
330 }
331
332 #[test]
333 fn test_fontconfig_resolution() {
334 let resolved = fonts::resolve_sans();
335 assert!(resolved.is_some(), "fontconfig should resolve sans font");
336 }
337}