Skip to main content

truce_cpu/
font.rs

1//! Font rendering using fontdue (TrueType rasterization).
2//!
3//! The bundled `JetBrains` Mono ships in the dedicated `truce-font`
4//! crate. Advanced users can override the bundled font via Cargo's
5//! `[patch]` table on `truce-font` instead of forking `truce-gui`.
6
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::sync::LazyLock;
10
11use truce_core::cast::len_u32;
12
13pub use truce_font::JETBRAINS_MONO;
14
15/// Cached rasterized glyph.
16struct CachedGlyph {
17    bitmap: Vec<u8>, // alpha values, row-major
18    width: u32,
19    height: u32,
20    advance: f32,  // horizontal advance in pixels
21    y_offset: f32, // offset from baseline (negative = above baseline)
22}
23
24struct GlyphCache {
25    font: fontdue::Font,
26    glyphs: HashMap<(char, u32), CachedGlyph>,
27}
28
29// Per-thread glyph cache. A single shared `Mutex<Option<GlyphCache>>`
30// would force every `draw_text` call through a lock, contending
31// across multi-instance hosts that drive plugin UIs from different
32// threads. Each thread lazy-inits its own cache instead; the font
33// bytes are `'static` (re-exported from `truce-font`) so the
34// per-thread duplication only covers parsed font tables and
35// rasterized glyphs (one per `(char, size)` the thread has drawn).
36thread_local! {
37    static CACHE: RefCell<Option<GlyphCache>> = const { RefCell::new(None) };
38}
39
40fn with_cache<R>(f: impl FnOnce(&mut GlyphCache) -> R) -> R {
41    CACHE.with(|cell| {
42        let mut guard = cell.borrow_mut();
43        let cache = guard.get_or_insert_with(|| {
44            let font = fontdue::Font::from_bytes(JETBRAINS_MONO, fontdue::FontSettings::default())
45                .expect("failed to parse embedded font");
46            GlyphCache {
47                font,
48                glyphs: HashMap::new(),
49            }
50        });
51        f(cache)
52    })
53}
54
55// Quantized cache key (one decimal place). The truncation is the
56// quantization's whole point - `12.34` and `12.36` both → `123`.
57#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
58fn size_key(size: f32) -> u32 {
59    (size * 10.0) as u32
60}
61
62/// Rasterize and cache a glyph, returning its cached data.
63//
64// Glyph metrics widen i32 metric values to f32 - bitmap heights and
65// pixel offsets are tiny (a glyph fits in screen-space pixels).
66#[allow(clippy::cast_precision_loss)]
67fn get_glyph(cache: &mut GlyphCache, ch: char, size: f32) -> &CachedGlyph {
68    let key = (ch, size_key(size));
69    let GlyphCache { font, glyphs } = cache;
70    glyphs.entry(key).or_insert_with(|| {
71        let (metrics, bitmap) = font.rasterize(ch, size);
72        CachedGlyph {
73            bitmap,
74            width: len_u32(metrics.width),
75            height: len_u32(metrics.height),
76            advance: metrics.advance_width,
77            y_offset: metrics.ymin as f32,
78        }
79    })
80}
81
82/// sRGB-to-linear lookup for byte-encoded color channels. Used by
83/// `draw_text_fontdue` to composite glyphs in linear space - see the
84/// gamma rationale on that function.
85#[allow(clippy::cast_precision_loss)]
86static SRGB_TO_LINEAR: LazyLock<[f32; 256]> = LazyLock::new(|| {
87    let mut table = [0.0f32; 256];
88    for (i, slot) in table.iter_mut().enumerate() {
89        let s = i as f32 / 255.0;
90        *slot = if s <= 0.04045 {
91            s / 12.92
92        } else {
93            ((s + 0.055) / 1.055).powf(2.4)
94        };
95    }
96    table
97});
98
99#[inline]
100fn srgb_f32_to_linear(s: f32) -> f32 {
101    if s <= 0.04045 {
102        s / 12.92
103    } else {
104        ((s + 0.055) / 1.055).powf(2.4)
105    }
106}
107
108#[inline]
109// `lin` is clamped to `[0, 1]`; the sRGB curve produces ≤ 1.0 for
110// every clamped input, so `s * 255.0 + 0.5` lands in `[0, 255.5]`.
111#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
112fn linear_to_srgb_u8(lin: f32) -> u8 {
113    let lin = lin.clamp(0.0, 1.0);
114    let s = if lin <= 0.003_130_8 {
115        12.92 * lin
116    } else {
117        1.055 * lin.powf(1.0 / 2.4) - 0.055
118    };
119    (s * 255.0 + 0.5) as u8
120}
121
122/// Draw text into an RGBA pixel buffer.
123///
124/// Compositing happens in linear space: destination bytes are decoded
125/// sRGB → linear via a 256-entry lookup table, the source color is decoded
126/// the same way, the Porter-Duff "over" operator runs in linear (so a
127/// half-coverage pixel against opaque white produces a perceptual
128/// midtone, not the gamma-darkened midtone of naive sRGB blending),
129/// and the result is re-encoded to sRGB. Treats the destination as
130/// straight sRGB rather than sRGB-premultiplied - fully correct when
131/// the destination alpha is 1 (the dominant case for text rendering),
132/// approximate when the destination is itself translucent. The rest
133/// of the CPU backend uses tiny-skia which is sRGB-naive too, so a
134/// fully gamma-correct pipeline would need matching changes there.
135///
136/// Glyph caching is internal - first call for a given (char, size)
137/// pair rasterizes; subsequent calls blit from the per-thread cache.
138//
139// Glyph dimensions widen `u32 as f32`. Glyph bitmaps are tens of
140// pixels wide - far below 2^23 / 2^31. The bounds-checked
141// `i32 -> u32` indexing already guards against negative values.
142#[allow(
143    clippy::cast_precision_loss,
144    clippy::cast_sign_loss,
145    clippy::cast_possible_wrap,
146    clippy::many_single_char_names
147)]
148pub fn draw_text_fontdue(
149    pixmap_data: &mut [u8],
150    pixmap_width: u32,
151    pixmap_height: u32,
152    text: &str,
153    x: f32,
154    y: f32,
155    size: f32,
156    r: f32,
157    g: f32,
158    b: f32,
159    a: f32,
160) {
161    with_cache(|cache| {
162        let mut cursor_x = x;
163
164        let line_metrics = cache.font.horizontal_line_metrics(size);
165        let ascent = line_metrics.map_or(size * 0.8, |m| m.ascent);
166
167        // Pre-compute the source color in linear space; only the
168        // glyph-coverage alpha varies per pixel.
169        let src_lin_r = srgb_f32_to_linear(r.clamp(0.0, 1.0));
170        let src_lin_g = srgb_f32_to_linear(g.clamp(0.0, 1.0));
171        let src_lin_b = srgb_f32_to_linear(b.clamp(0.0, 1.0));
172
173        for ch in text.chars() {
174            let glyph = get_glyph(cache, ch, size);
175            let gw = glyph.width;
176            let gh = glyph.height;
177
178            // Glyph coordinates fit in i32 (window is < 32k px).
179            #[allow(clippy::cast_possible_truncation)]
180            let gx = cursor_x as i32;
181            #[allow(clippy::cast_possible_truncation)]
182            let gy = (y + ascent - glyph.y_offset - gh as f32) as i32;
183
184            for row in 0..gh {
185                for col in 0..gw {
186                    let px = gx + col as i32;
187                    let py = gy + row as i32;
188
189                    if px < 0 || py < 0 || px >= pixmap_width as i32 || py >= pixmap_height as i32 {
190                        continue;
191                    }
192
193                    let coverage = glyph.bitmap[(row * gw + col) as usize];
194                    if coverage == 0 {
195                        continue;
196                    }
197
198                    let ga = (f32::from(coverage) / 255.0) * a;
199                    let idx = ((py as u32 * pixmap_width + px as u32) * 4) as usize;
200                    if idx + 3 >= pixmap_data.len() {
201                        continue;
202                    }
203
204                    // Decode dst sRGB → linear, blend Porter-Duff over
205                    // (premultiplied source), encode back. Alpha stays
206                    // linear by definition (it's a coverage value, not
207                    // a perceptual signal).
208                    let dst_lin_r = SRGB_TO_LINEAR[pixmap_data[idx] as usize];
209                    let dst_lin_g = SRGB_TO_LINEAR[pixmap_data[idx + 1] as usize];
210                    let dst_lin_b = SRGB_TO_LINEAR[pixmap_data[idx + 2] as usize];
211                    let dst_a = f32::from(pixmap_data[idx + 3]) / 255.0;
212
213                    let inv_sa = 1.0 - ga;
214                    let out_lin_r = src_lin_r * ga + dst_lin_r * inv_sa;
215                    let out_lin_g = src_lin_g * ga + dst_lin_g * inv_sa;
216                    let out_lin_b = src_lin_b * ga + dst_lin_b * inv_sa;
217                    let out_a = ga + dst_a * inv_sa;
218
219                    pixmap_data[idx] = linear_to_srgb_u8(out_lin_r);
220                    pixmap_data[idx + 1] = linear_to_srgb_u8(out_lin_g);
221                    pixmap_data[idx + 2] = linear_to_srgb_u8(out_lin_b);
222                    // `out_a` is bounded in `[0, 1]` (alpha-blended).
223                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
224                    let out_a_u8 = (out_a * 255.0 + 0.5) as u8;
225                    pixmap_data[idx + 3] = out_a_u8;
226                }
227            }
228
229            cursor_x += glyph.advance;
230        }
231    });
232}
233
234/// Measure text width in pixels.
235#[must_use]
236pub fn text_width_fontdue(text: &str, size: f32) -> f32 {
237    with_cache(|cache| {
238        let mut width = 0.0f32;
239        for ch in text.chars() {
240            let glyph = get_glyph(cache, ch, size);
241            width += glyph.advance;
242        }
243        width
244    })
245}