open_sesame/render/
text.rs

1//! Text rendering utilities
2//!
3//! Uses fontconfig for font resolution, providing native integration with
4//! the system's font configuration and COSMIC desktop preferences.
5
6use crate::platform::fonts;
7use fontdue::{Font, FontSettings};
8use std::sync::OnceLock;
9use tiny_skia::{Color, Pixmap, PremultipliedColorU8};
10
11/// Font weight for text rendering
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum FontWeight {
14    /// Regular weight for body text
15    #[default]
16    Regular,
17    /// Semibold/medium weight for emphasis
18    Semibold,
19}
20
21/// Cached fonts for different weights
22struct FontCache {
23    regular: Font,
24    semibold: Option<Font>,
25}
26
27/// Global font cache - initialized once via fontconfig
28static FONTS: OnceLock<FontCache> = OnceLock::new();
29
30/// Text renderer with cached font
31pub struct TextRenderer;
32
33impl TextRenderer {
34    /// Get the font cache, loading fonts via fontconfig if necessary
35    ///
36    /// # Panics
37    /// Panics if fontconfig cannot resolve any fonts. This is a fatal error
38    /// that indicates the system is misconfigured (no fonts available).
39    fn fonts() -> &'static FontCache {
40        FONTS.get_or_init(|| {
41            load_fonts_via_fontconfig().unwrap_or_else(|msg| {
42                // Panics to allow proper unwinding and cleanup during font resolution failures.
43                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    /// Get a font for the specified weight
56    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    /// Render text to a pixmap at the given position
65    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    /// Render text with a specific font weight
70    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                // Position glyph relative to baseline: top of glyph = baseline - (height + ymin)
90                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    /// Blend a glyph bitmap onto the pixmap
109    ///
110    /// Safely handles bitmap bounds validation to prevent panics on malformed glyph data.
111    #[allow(clippy::too_many_arguments)] // All parameters are necessary for glyph rendering
112    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        // Validate bitmap dimensions match actual data length
123        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                // SAFETY: We validated bitmap.len() >= width * height above
151                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                // alpha is glyph coverage (0-255), color.alpha() is float (0.0-1.0)
159                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    /// Measure the width of text
173    pub fn measure_text(text: &str, size: f32) -> f32 {
174        Self::measure_text_weighted(text, size, FontWeight::Regular)
175    }
176
177    /// Measure the width of text with a specific font weight
178    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    /// Get the ascent (height above baseline) for a font size
186    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    /// Get the descent (depth below baseline) for a font size
193    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    /// Get the total line height for a font size
200    pub fn line_height(size: f32) -> f32 {
201        Self::ascent(size) + Self::descent(size)
202    }
203
204    /// Truncate text to fit within a maximum width
205    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
235/// Blend a source color onto a destination pixel using premultiplied alpha
236///
237/// Uses the standard Porter-Duff "over" operation for premultiplied alpha:
238/// out = src + dst * (1 - src_alpha)
239///
240/// Operates directly in premultiplied space to preserve color accuracy at glyph edges.
241fn blend_pixel(dst: PremultipliedColorU8, src_color: Color, src_alpha: u8) -> PremultipliedColorU8 {
242    if src_alpha == 0 {
243        return dst;
244    }
245
246    // Convert source to premultiplied alpha
247    // src_color is straight alpha (0.0-1.0), src_alpha is coverage (0-255)
248    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        // Fully opaque source pixel, no blending needed
255        return PremultipliedColorU8::from_rgba(sr, sg, sb, 255).unwrap();
256    }
257
258    // Porter-Duff "over" in premultiplied space: out = src + dst * (1 - src_alpha)
259    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
269/// Load fonts using fontconfig for resolution
270///
271/// Uses the system's fontconfig to resolve "sans" to the appropriate font file.
272/// This respects user font configuration and COSMIC desktop preferences.
273fn load_fonts_via_fontconfig() -> Result<FontCache, String> {
274    // Resolve sans font via fontconfig
275    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    // Load the regular font
285    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    // Try to find a bold/semibold variant in order of preference
297    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}