Skip to main content

truce_cpu/
font.rs

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