rustmotion_core/engine/text/
cosmic.rs1use 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}