Skip to main content

rustmotion_core/engine/text/
cosmic.rs

1use std::sync::{Mutex, OnceLock};
2
3use cosmic_text::{
4    Attrs, Buffer, Color as CColor, Family, FontSystem, Metrics, Shaping, SwashCache, SwashContent,
5    Weight, Wrap,
6};
7use skia_safe::{images, Canvas, Color, ColorType, Data, ImageInfo, Paint, Point};
8
9static FONT_SYSTEM: OnceLock<Mutex<FontSystem>> = OnceLock::new();
10static SWASH_CACHE: OnceLock<Mutex<SwashCache>> = OnceLock::new();
11
12pub fn font_system() -> &'static Mutex<FontSystem> {
13    FONT_SYSTEM.get_or_init(|| Mutex::new(FontSystem::new()))
14}
15
16pub fn swash_cache() -> &'static Mutex<SwashCache> {
17    SWASH_CACHE.get_or_init(|| Mutex::new(SwashCache::new()))
18}
19
20#[derive(Debug, Clone, Copy, Default)]
21pub struct TextMetrics {
22    pub width: f32,
23    pub height: f32,
24}
25
26#[derive(Debug, Clone)]
27pub struct TextStyle<'a> {
28    pub font_family: Option<&'a str>,
29    pub font_size: f32,
30    pub line_height: f32,
31    pub weight: u16,
32    pub italic: bool,
33    pub max_width: Option<f32>,
34    pub wrap: bool,
35    pub letter_spacing: f32,
36}
37
38impl<'a> Default for TextStyle<'a> {
39    fn default() -> Self {
40        Self {
41            font_family: None,
42            font_size: 16.0,
43            line_height: 0.0,
44            weight: 400,
45            italic: false,
46            max_width: None,
47            wrap: true,
48            letter_spacing: 0.0,
49        }
50    }
51}
52
53fn metrics_for(style: &TextStyle) -> Metrics {
54    let lh = if style.line_height > 0.0 {
55        style.line_height
56    } else {
57        style.font_size * 1.2
58    };
59    Metrics::new(style.font_size, lh)
60}
61
62fn attrs_for<'a>(style: &'a TextStyle) -> Attrs<'a> {
63    let mut a = Attrs::new();
64    if let Some(fam) = style.font_family {
65        a = a.family(Family::Name(fam));
66    }
67    a = a.weight(Weight(style.weight));
68    if style.italic {
69        a = a.style(cosmic_text::Style::Italic);
70    }
71    a = a.letter_spacing(style.letter_spacing);
72    a
73}
74
75pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics {
76    if text.is_empty() {
77        return TextMetrics {
78            width: 0.0,
79            height: metrics_for(style).line_height,
80        };
81    }
82    let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner());
83    let metrics = metrics_for(style);
84    let mut buf = Buffer::new(&mut fs, metrics);
85    buf.set_size(style.max_width, None);
86    buf.set_wrap(if style.wrap { Wrap::Word } else { Wrap::None });
87    buf.set_text(text, &attrs_for(style), Shaping::Advanced, None);
88    buf.shape_until_scroll(&mut fs, false);
89
90    let mut max_w = 0.0_f32;
91    let mut lines = 0;
92    for run in buf.layout_runs() {
93        max_w = max_w.max(run.line_w);
94        lines += 1;
95    }
96    let lines = lines.max(1) as f32;
97    TextMetrics {
98        width: max_w,
99        height: lines * metrics.line_height,
100    }
101}
102
103pub fn paint_text(
104    canvas: &Canvas,
105    text: &str,
106    origin: (f32, f32),
107    style: &TextStyle,
108    color: Color,
109) {
110    if text.is_empty() {
111        return;
112    }
113    let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner());
114    let metrics = metrics_for(style);
115    let mut buf = Buffer::new(&mut fs, metrics);
116    buf.set_size(style.max_width, None);
117    buf.set_wrap(if style.wrap { Wrap::Word } else { Wrap::None });
118    buf.set_text(text, &attrs_for(style), Shaping::Advanced, None);
119    buf.shape_until_scroll(&mut fs, false);
120
121    let mut sc = swash_cache().lock().unwrap_or_else(|e| e.into_inner());
122    let ccolor = CColor::rgba(color.r(), color.g(), color.b(), color.a());
123
124    for run in buf.layout_runs() {
125        let line_y = run.line_y;
126        for glyph in run.glyphs.iter() {
127            let physical = glyph.physical((origin.0, origin.1 + line_y), 1.0);
128
129            let img = match sc.get_image(&mut fs, physical.cache_key) {
130                Some(image) => image,
131                None => continue,
132            };
133            if img.placement.width == 0 || img.placement.height == 0 {
134                continue;
135            }
136            blit_glyph(
137                canvas,
138                &img.data,
139                img.placement.width as i32,
140                img.placement.height as i32,
141                physical.x as f32 + img.placement.left as f32,
142                physical.y as f32 - img.placement.top as f32,
143                img.content == SwashContent::Color,
144                glyph
145                    .color_opt
146                    .map(|c| Color::from_argb(c.a(), c.r(), c.g(), c.b()))
147                    .unwrap_or_else(|| {
148                        Color::from_argb(ccolor.a(), ccolor.r(), ccolor.g(), ccolor.b())
149                    }),
150            );
151        }
152    }
153}
154
155fn blit_glyph(
156    canvas: &Canvas,
157    data: &[u8],
158    w: i32,
159    h: i32,
160    x: f32,
161    y: f32,
162    is_color: bool,
163    tint: Color,
164) {
165    if w <= 0 || h <= 0 || data.is_empty() {
166        return;
167    }
168    let mut rgba: Vec<u8> = Vec::with_capacity((w * h * 4) as usize);
169    if is_color {
170        rgba.extend_from_slice(data);
171    } else {
172        let tr = tint.r() as u32;
173        let tg = tint.g() as u32;
174        let tb = tint.b() as u32;
175        let ta = tint.a() as u32;
176        for &a in data.iter() {
177            let alpha = ta * a as u32 / 255;
178            rgba.push((tr * alpha / 255) as u8);
179            rgba.push((tg * alpha / 255) as u8);
180            rgba.push((tb * alpha / 255) as u8);
181            rgba.push(alpha as u8);
182        }
183    }
184    let info = ImageInfo::new(
185        (w, h),
186        ColorType::RGBA8888,
187        skia_safe::AlphaType::Premul,
188        None,
189    );
190    let row_bytes = (w * 4) as usize;
191    let data_obj = Data::new_copy(&rgba);
192    let Some(image) = images::raster_from_data(&info, data_obj, row_bytes) else {
193        return;
194    };
195    let paint = Paint::default();
196    canvas.draw_image(image, Point::new(x, y), Some(&paint));
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn measure_empty_returns_zero_width() {
205        let m = measure_text("", &TextStyle::default());
206        assert_eq!(m.width, 0.0);
207        assert!(m.height > 0.0);
208    }
209
210    #[test]
211    fn measure_hello_world_has_positive_width() {
212        let m = measure_text(
213            "Hello, world!",
214            &TextStyle {
215                font_size: 24.0,
216                ..Default::default()
217            },
218        );
219        assert!(m.width > 0.0);
220        assert!(m.height > 0.0);
221    }
222
223    #[test]
224    fn measure_wraps_with_max_width() {
225        let style = TextStyle {
226            font_size: 16.0,
227            max_width: Some(40.0),
228            wrap: true,
229            ..Default::default()
230        };
231        let m = measure_text("the quick brown fox", &style);
232        assert!(m.height > 16.0 * 1.2, "should wrap to multiple lines");
233    }
234
235    #[test]
236    fn measure_no_wrap_stays_one_line() {
237        let style = TextStyle {
238            font_size: 16.0,
239            max_width: None,
240            wrap: false,
241            ..Default::default()
242        };
243        let m = measure_text("the quick brown fox", &style);
244        assert!(m.height < 16.0 * 1.2 * 2.0, "should be one line");
245    }
246
247    #[test]
248    fn measure_survives_poisoned_font_mutex() {
249        let _ = std::thread::spawn(|| {
250            let _guard = font_system().lock().unwrap_or_else(|e| e.into_inner());
251            panic!("deliberate poison");
252        })
253        .join();
254        assert!(font_system().is_poisoned(), "setup must have poisoned");
255        let m = measure_text("still shaping", &TextStyle::default());
256        assert!(m.width > 0.0, "measure works despite the poisoned mutex");
257    }
258}