rustmotion_core/engine/text/
cosmic.rs1use 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
41pub fn font_system() -> &'static Mutex<FontSystem> {
43 FONT_SYSTEM.get_or_init(|| Mutex::new(FontSystem::new()))
44}
45
46pub fn swash_cache() -> &'static Mutex<SwashCache> {
48 SWASH_CACHE.get_or_init(|| Mutex::new(SwashCache::new()))
49}
50
51#[derive(Debug, Clone, Copy, Default)]
53pub struct TextMetrics {
54 pub width: f32,
55 pub height: f32,
56}
57
58#[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, 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
108pub 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 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
141pub 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 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 rgba.extend_from_slice(data);
213 } else {
214 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); }
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 #[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}