Skip to main content

rustmotion_core/engine/text/
cosmic.rs

1//! cosmic-text → Skia bridge.
2//!
3//! Provides:
4//! - a global [`FontSystem`] (lazy, system + bundled fonts)
5//! - `measure_text(...)` for taffy's `measure_fn`
6//! - `paint_text(...)` to draw a laid-out buffer onto a Skia canvas via
7//!   per-glyph rasterization through `SwashCache`
8//!
9//! Shaping & line-breaking are delegated to cosmic-text. Paint is done by
10//! rasterizing each glyph to an alpha mask, tinting it with the requested
11//! color, and blitting it as a small `Image` into Skia.
12//!
13//! **Not currently wired into the real render path (audit #10).** Every
14//! component's actual measure/paint goes through `skia_safe::Font::
15//! measure_str` / `TextBlob::new` in `engine::renderer::text` +
16//! `rustmotion-components::intrinsic::TextIntrinsic`, not through this
17//! module — `measure_text`/`paint_text` below have no callers outside their
18//! own tests (`grep -rn "engine::text\|text::cosmic" crates/` confirms
19//! this). If you're chasing a text overflow/measure-vs-paint bug, look in
20//! `engine::renderer::text.rs` and `rustmotion-components::intrinsic`
21//! instead — the shaping/bidi/glyph-fallback behaviour cosmic-text would
22//! provide here is not what actually renders today. Kept building (and the
23//! `cosmic-text` dependency kept) as a candidate landing spot for a future
24//! real shaping engine; not deleted unilaterally by this fix since that
25//! call — wire it in for real vs. remove the module and its dependency —
26//! is bigger than any single finding in this pass. See
27//! `rustmotion-components::intrinsic` module doc for the other side of this
28//! (it also used to claim a cosmic-text backing it doesn't have).
29
30use std::sync::{Mutex, OnceLock};
31
32use cosmic_text::{
33    Attrs, Buffer, Color as CColor, Family, FontSystem, Metrics, Shaping, SwashCache, SwashContent,
34    Weight, Wrap,
35};
36use skia_safe::{images, Canvas, Color, ColorType, Data, ImageInfo, Paint, Point};
37
38static FONT_SYSTEM: OnceLock<Mutex<FontSystem>> = OnceLock::new();
39static SWASH_CACHE: OnceLock<Mutex<SwashCache>> = OnceLock::new();
40
41/// Borrow the global FontSystem. Created on first use with system fonts.
42pub fn font_system() -> &'static Mutex<FontSystem> {
43    FONT_SYSTEM.get_or_init(|| Mutex::new(FontSystem::new()))
44}
45
46/// Borrow the global SwashCache (for glyph rasterization).
47pub fn swash_cache() -> &'static Mutex<SwashCache> {
48    SWASH_CACHE.get_or_init(|| Mutex::new(SwashCache::new()))
49}
50
51/// Result of measuring a text run.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct TextMetrics {
54    pub width: f32,
55    pub height: f32,
56}
57
58/// Configuration for laying out a single text run.
59#[derive(Debug, Clone)]
60pub struct TextStyle<'a> {
61    pub font_family: Option<&'a str>,
62    pub font_size: f32,
63    pub line_height: f32,
64    pub weight: u16,
65    pub italic: bool,
66    pub max_width: Option<f32>,
67    pub wrap: bool,
68    pub letter_spacing: f32,
69}
70
71impl<'a> Default for TextStyle<'a> {
72    fn default() -> Self {
73        Self {
74            font_family: None,
75            font_size: 16.0,
76            line_height: 0.0, // 0 → derived from font_size * 1.2
77            weight: 400,
78            italic: false,
79            max_width: None,
80            wrap: true,
81            letter_spacing: 0.0,
82        }
83    }
84}
85
86fn metrics_for(style: &TextStyle) -> Metrics {
87    let lh = if style.line_height > 0.0 {
88        style.line_height
89    } else {
90        style.font_size * 1.2
91    };
92    Metrics::new(style.font_size, lh)
93}
94
95fn attrs_for<'a>(style: &'a TextStyle) -> Attrs<'a> {
96    let mut a = Attrs::new();
97    if let Some(fam) = style.font_family {
98        a = a.family(Family::Name(fam));
99    }
100    a = a.weight(Weight(style.weight));
101    if style.italic {
102        a = a.style(cosmic_text::Style::Italic);
103    }
104    a = a.letter_spacing(style.letter_spacing);
105    a
106}
107
108/// Measure a text string given font + line constraints.
109pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics {
110    if text.is_empty() {
111        return TextMetrics {
112            width: 0.0,
113            height: metrics_for(style).line_height,
114        };
115    }
116    // Poison-tolerant: a panic on another render thread (e.g. a Skia panic
117    // caught by a preview worker's panic fence) must not poison text shaping
118    // for every subsequent frame — the FontSystem stays usable, each shape
119    // call builds its own Buffer.
120    let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner());
121    let metrics = metrics_for(style);
122    let mut buf = Buffer::new(&mut fs, metrics);
123    buf.set_size(style.max_width, None);
124    buf.set_wrap(if style.wrap { Wrap::Word } else { Wrap::None });
125    buf.set_text(text, &attrs_for(style), Shaping::Advanced, None);
126    buf.shape_until_scroll(&mut fs, false);
127
128    let mut max_w = 0.0_f32;
129    let mut lines = 0;
130    for run in buf.layout_runs() {
131        max_w = max_w.max(run.line_w);
132        lines += 1;
133    }
134    let lines = lines.max(1) as f32;
135    TextMetrics {
136        width: max_w,
137        height: lines * metrics.line_height,
138    }
139}
140
141/// Paint a text string onto a Skia canvas at `origin` (top-left).
142pub fn paint_text(
143    canvas: &Canvas,
144    text: &str,
145    origin: (f32, f32),
146    style: &TextStyle,
147    color: Color,
148) {
149    if text.is_empty() {
150        return;
151    }
152    // Poison-tolerant for the same reason as in `measure_text`.
153    let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner());
154    let metrics = metrics_for(style);
155    let mut buf = Buffer::new(&mut fs, metrics);
156    buf.set_size(style.max_width, None);
157    buf.set_wrap(if style.wrap { Wrap::Word } else { Wrap::None });
158    buf.set_text(text, &attrs_for(style), Shaping::Advanced, None);
159    buf.shape_until_scroll(&mut fs, false);
160
161    let mut sc = swash_cache().lock().unwrap_or_else(|e| e.into_inner());
162    let ccolor = CColor::rgba(color.r(), color.g(), color.b(), color.a());
163
164    for run in buf.layout_runs() {
165        let line_y = run.line_y;
166        for glyph in run.glyphs.iter() {
167            let physical = glyph.physical((origin.0, origin.1 + line_y), 1.0);
168
169            let img = match sc.get_image(&mut fs, physical.cache_key) {
170                Some(image) => image,
171                None => continue,
172            };
173            if img.placement.width == 0 || img.placement.height == 0 {
174                continue;
175            }
176            blit_glyph(
177                canvas,
178                &img.data,
179                img.placement.width as i32,
180                img.placement.height as i32,
181                physical.x as f32 + img.placement.left as f32,
182                physical.y as f32 - img.placement.top as f32,
183                img.content == SwashContent::Color,
184                glyph
185                    .color_opt
186                    .map(|c| Color::from_argb(c.a(), c.r(), c.g(), c.b()))
187                    .unwrap_or_else(|| {
188                        Color::from_argb(ccolor.a(), ccolor.r(), ccolor.g(), ccolor.b())
189                    }),
190            );
191        }
192    }
193}
194
195fn blit_glyph(
196    canvas: &Canvas,
197    data: &[u8],
198    w: i32,
199    h: i32,
200    x: f32,
201    y: f32,
202    is_color: bool,
203    tint: Color,
204) {
205    if w <= 0 || h <= 0 || data.is_empty() {
206        return;
207    }
208    let mut rgba: Vec<u8> = Vec::with_capacity((w * h * 4) as usize);
209    if is_color {
210        // SwashContent::Color : data is already RGBA premultiplied per glyph.
211        // Layout matches what Skia expects with ColorType::RGBA8888 & Premul.
212        rgba.extend_from_slice(data);
213    } else {
214        // Mask: alpha-only. Tint with `tint` and premultiply.
215        let tr = tint.r() as u32;
216        let tg = tint.g() as u32;
217        let tb = tint.b() as u32;
218        let ta = tint.a() as u32;
219        for &a in data.iter() {
220            let alpha = ta * a as u32 / 255;
221            rgba.push((tr * alpha / 255) as u8);
222            rgba.push((tg * alpha / 255) as u8);
223            rgba.push((tb * alpha / 255) as u8);
224            rgba.push(alpha as u8);
225        }
226    }
227    let info = ImageInfo::new(
228        (w, h),
229        ColorType::RGBA8888,
230        skia_safe::AlphaType::Premul,
231        None,
232    );
233    let row_bytes = (w * 4) as usize;
234    let data_obj = Data::new_copy(&rgba);
235    let Some(image) = images::raster_from_data(&info, data_obj, row_bytes) else {
236        return;
237    };
238    let paint = Paint::default();
239    canvas.draw_image(image, Point::new(x, y), Some(&paint));
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn measure_empty_returns_zero_width() {
248        let m = measure_text("", &TextStyle::default());
249        assert_eq!(m.width, 0.0);
250        assert!(m.height > 0.0); // one empty line of line_height
251    }
252
253    #[test]
254    fn measure_hello_world_has_positive_width() {
255        let m = measure_text(
256            "Hello, world!",
257            &TextStyle {
258                font_size: 24.0,
259                ..Default::default()
260            },
261        );
262        assert!(m.width > 0.0);
263        assert!(m.height > 0.0);
264    }
265
266    #[test]
267    fn measure_wraps_with_max_width() {
268        let style = TextStyle {
269            font_size: 16.0,
270            max_width: Some(40.0),
271            wrap: true,
272            ..Default::default()
273        };
274        let m = measure_text("the quick brown fox", &style);
275        assert!(m.height > 16.0 * 1.2, "should wrap to multiple lines");
276    }
277
278    #[test]
279    fn measure_no_wrap_stays_one_line() {
280        let style = TextStyle {
281            font_size: 16.0,
282            max_width: None,
283            wrap: false,
284            ..Default::default()
285        };
286        let m = measure_text("the quick brown fox", &style);
287        assert!(m.height < 16.0 * 1.2 * 2.0, "should be one line");
288    }
289
290    /// A panic on another thread while it holds the font mutex (e.g. a Skia
291    /// panic caught by a preview worker's panic fence) poisons the lock.
292    /// Shaping must survive that — otherwise one panic turns every subsequent
293    /// text render on every thread into a panic cascade.
294    #[test]
295    fn measure_survives_poisoned_font_mutex() {
296        let _ = std::thread::spawn(|| {
297            let _guard = font_system().lock().unwrap_or_else(|e| e.into_inner());
298            panic!("deliberate poison");
299        })
300        .join();
301        assert!(font_system().is_poisoned(), "setup must have poisoned");
302        let m = measure_text("still shaping", &TextStyle::default());
303        assert!(m.width > 0.0, "measure works despite the poisoned mutex");
304    }
305}