1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Font, FontStyle};
4
5use rustmotion_core::css::style::{
6 FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign,
7};
8use rustmotion_core::css::CssStyle;
9use rustmotion_core::engine::animator::AnimatedProperties;
10use rustmotion_core::engine::layout_pass::BoxLayout;
11use rustmotion_core::engine::renderer::{
12 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
13 typeface_with_fallback,
14};
15use rustmotion_core::schema::{FontStyleType, FontWeight, TextAlign, TimelineStep};
16use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20pub struct RichTextSpan {
21 pub text: String,
22 #[serde(default)]
23 pub color: Option<String>,
24 #[serde(default, rename = "font-size")]
25 pub font_size: Option<f32>,
26 #[serde(default, rename = "font-weight")]
27 pub font_weight: Option<FontWeight>,
28 #[serde(default, rename = "font-family")]
29 pub font_family: Option<String>,
30 #[serde(default, rename = "font-style")]
31 pub font_style: Option<FontStyleType>,
32 #[serde(default, rename = "letter-spacing")]
33 pub letter_spacing: Option<f32>,
34}
35
36#[derive(Debug, Serialize, Deserialize, JsonSchema)]
38pub struct RichText {
39 pub spans: Vec<RichTextSpan>,
40 #[serde(default)]
41 pub max_width: Option<f32>,
42 #[serde(flatten)]
43 pub timing: TimingConfig,
44 #[serde(default)]
45 pub style: CssStyle,
46 #[serde(default)]
47 pub timeline: Vec<TimelineStep>,
48 #[serde(default)]
49 pub stagger: Option<f32>,
50}
51
52rustmotion_core::impl_traits!(RichText {
53 Animatable => animation,
54 Timed => timing,
55 Styled => style,
56});
57
58fn make_font(
60 family: &str,
61 weight: &FontWeight,
62 font_style_type: &FontStyleType,
63 size: f32,
64) -> Option<Font> {
65 let slant = match font_style_type {
66 FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
67 FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
68 FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
69 };
70 let weight_val = match weight {
71 FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
72 FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
73 FontWeight::Weight(w) => skia_safe::font_style::Weight::from(*w as i32),
74 };
75 let skia_style = FontStyle::new(weight_val, skia_safe::font_style::Width::NORMAL, slant);
76 let typeface = typeface_with_fallback(family, skia_style).ok()?;
77 Some(Font::from_typeface(typeface, size))
78}
79
80struct SpanFontInfo {
82 font: Font,
83 color: String,
84 letter_spacing: f32,
85}
86
87fn resolve_span_fonts(
99 spans: &[RichTextSpan],
100 style: &CssStyle,
101 default_size: f32,
102) -> Vec<Option<SpanFontInfo>> {
103 let default_color = style.color_str_or("#FFFFFF");
104 let default_family = style.font_family_or("Inter");
105 let default_weight = match &style.font_weight {
106 Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => FontWeight::Bold,
107 Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
108 Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
109 _ => FontWeight::Normal,
110 };
111 let default_font_style = match style.font_style {
112 Some(CssFontStyle::Italic) => FontStyleType::Italic,
113 Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
114 _ => FontStyleType::Normal,
115 };
116
117 spans
118 .iter()
119 .map(|span| {
120 let size = span.font_size.unwrap_or(default_size);
121 let family = span.font_family.as_deref().unwrap_or(default_family);
122 let weight = span.font_weight.as_ref().unwrap_or(&default_weight);
123 let fstyle = span.font_style.as_ref().unwrap_or(&default_font_style);
124 let color = span.color.as_deref().unwrap_or(default_color).to_string();
125 let letter_spacing = span.letter_spacing.unwrap_or(0.0);
126 make_font(family, weight, fstyle, size).map(|font| SpanFontInfo {
127 font,
128 color,
129 letter_spacing,
130 })
131 })
132 .collect()
133}
134
135pub struct RichTextToken {
137 pub span_idx: usize,
138 pub text: String,
139 pub x: f32,
140 pub width: f32,
141}
142
143pub struct RichTextLine {
145 pub tokens: Vec<RichTextToken>,
146 pub width: f32,
147}
148
149pub struct RichTextLayout {
153 pub lines: Vec<RichTextLine>,
154 pub max_width: f32,
155 pub line_height: f32,
156 pub max_ascent: f32,
157}
158
159impl RichText {
160 pub fn compute_layout(
185 spans: &[RichTextSpan],
186 style: &CssStyle,
187 viewport_width: f32,
188 viewport_height: f32,
189 wrap_width: Option<f32>,
190 visible_chars_progress: f32,
191 ) -> RichTextLayout {
192 let base_ctx = crate::intrinsic::font_size_ctx(
193 viewport_width,
194 viewport_height,
195 wrap_width.unwrap_or(0.0),
196 );
197 let (default_size, _letter_spacing_unused, line_height_val) =
198 style.typography_px_ctx(&base_ctx, 48.0);
199 let span_fonts = resolve_span_fonts(spans, style, default_size);
200 let emoji_tf = emoji_typeface();
201
202 let texts: Vec<String> = if visible_chars_progress >= 0.0 {
206 let total_chars: usize = spans.iter().map(|s| s.text.chars().count()).sum();
207 let visible =
208 ((visible_chars_progress * total_chars as f32).round() as usize).min(total_chars);
209 let mut remaining = visible;
210 spans
211 .iter()
212 .map(|s| {
213 let char_count = s.text.chars().count();
214 if remaining >= char_count {
215 remaining -= char_count;
216 s.text.clone()
217 } else if remaining == 0 {
218 String::new()
219 } else {
220 let truncated: String = s.text.chars().take(remaining).collect();
221 remaining = 0;
222 truncated
223 }
224 })
225 .collect()
226 } else {
227 spans.iter().map(|s| s.text.clone()).collect()
228 };
229
230 struct Tok {
236 span_idx: usize,
237 text: String,
238 space_before: bool,
239 }
240 let mut tokens: Vec<Tok> = Vec::new();
241 let mut prev_trailing_ws = true;
242 for (span_idx, text) in texts.iter().enumerate() {
243 if span_fonts.get(span_idx).and_then(|f| f.as_ref()).is_none() || text.is_empty() {
244 continue;
245 }
246 let starts_ws = text.chars().next().is_some_and(char::is_whitespace);
247 for (wi, w) in text.split_whitespace().enumerate() {
248 let space_before = if tokens.is_empty() {
249 false
250 } else if wi > 0 {
251 true
252 } else {
253 prev_trailing_ws || starts_ws
254 };
255 tokens.push(Tok {
256 span_idx,
257 text: w.to_string(),
258 space_before,
259 });
260 }
261 prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace);
262 }
263
264 let effective_wrap = wrap_width.unwrap_or(f32::INFINITY);
268 let mut lines: Vec<RichTextLine> = vec![RichTextLine {
269 tokens: Vec::new(),
270 width: 0.0,
271 }];
272
273 for tok in &tokens {
274 let sf = span_fonts[tok.span_idx]
275 .as_ref()
276 .expect("font presence checked during tokenization");
277 let emoji_font = emoji_tf
278 .as_ref()
279 .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
280 let tok_width =
281 measure_text_with_fallback(&tok.text, &sf.font, &emoji_font, sf.letter_spacing);
282 let space_width = if tok.space_before {
283 measure_text_with_fallback(" ", &sf.font, &emoji_font, 0.0)
284 } else {
285 0.0
286 };
287
288 let current = lines.last_mut().unwrap();
289 let has_content = !current.tokens.is_empty();
290 let extra = if has_content { space_width } else { 0.0 };
291 let projected = current.width + extra + tok_width;
292
293 if projected > effective_wrap && has_content {
294 lines.push(RichTextLine {
295 tokens: vec![RichTextToken {
296 span_idx: tok.span_idx,
297 text: tok.text.clone(),
298 x: 0.0,
299 width: tok_width,
300 }],
301 width: tok_width,
302 });
303 } else {
304 let x = current.width + extra;
305 current.tokens.push(RichTextToken {
306 span_idx: tok.span_idx,
307 text: tok.text.clone(),
308 x,
309 width: tok_width,
310 });
311 current.width = x + tok_width;
312 }
313 }
314
315 let max_width = lines.iter().map(|l| l.width).fold(0.0f32, f32::max);
316 let max_ascent = span_fonts
317 .iter()
318 .flatten()
319 .map(|sf| {
320 let (_, m) = sf.font.metrics();
321 -m.ascent
322 })
323 .fold(0.0f32, f32::max);
324
325 RichTextLayout {
326 lines,
327 max_width,
328 line_height: line_height_val,
329 max_ascent,
330 }
331 }
332
333 fn paint(
334 &self,
335 canvas: &Canvas,
336 layout_width: f32,
337 props: &AnimatedProperties,
338 ctx: &PaintCtx,
339 ) {
340 let align = match self.style.text_align {
341 Some(CssTextAlign::Center) => TextAlign::Center,
342 Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
343 _ => TextAlign::Left,
344 };
345
346 let wrap_width = if layout_width.is_finite() && layout_width > 0.0 {
347 match self.max_width {
348 Some(mw) => Some(mw.min(layout_width)),
349 None => Some(layout_width),
350 }
351 } else {
352 self.max_width
353 };
354
355 let layout = RichText::compute_layout(
356 &self.spans,
357 &self.style,
358 ctx.video_width as f32,
359 ctx.video_height as f32,
360 wrap_width,
361 props.visible_chars_progress,
362 );
363 if layout.lines.iter().all(|l| l.tokens.is_empty()) {
364 return;
365 }
366
367 let base_ctx = crate::intrinsic::font_size_ctx(
374 ctx.video_width as f32,
375 ctx.video_height as f32,
376 wrap_width.unwrap_or(0.0),
377 );
378 let default_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
379 let span_fonts = resolve_span_fonts(&self.spans, &self.style, default_size);
380 let emoji_tf = emoji_typeface();
381
382 let align_width = if layout_width.is_finite() && layout_width > 0.0 {
383 layout_width
384 } else {
385 layout.max_width
386 };
387
388 let baseline_offset = (layout.line_height + layout.max_ascent) / 2.0;
389
390 for (line_idx, line) in layout.lines.iter().enumerate() {
391 let line_x_offset = match align {
392 TextAlign::Left => 0.0,
393 TextAlign::Center => (align_width - line.width) / 2.0,
394 TextAlign::Right => align_width - line.width,
395 };
396 let y = line_idx as f32 * layout.line_height + baseline_offset;
397
398 for tok in &line.tokens {
399 let sf = span_fonts[tok.span_idx]
400 .as_ref()
401 .expect("font presence matches compute_layout's tokenization");
402 let paint = paint_from_hex(&sf.color);
403 let emoji_font = emoji_tf
404 .as_ref()
405 .map(|tf| Font::from_typeface(tf.clone(), sf.font.size()));
406
407 draw_text_with_fallback(
408 canvas,
409 &tok.text,
410 &sf.font,
411 &emoji_font,
412 sf.letter_spacing,
413 line_x_offset + tok.x,
414 y,
415 &paint,
416 );
417 }
418 }
419 }
420}
421
422impl Painter for RichText {
423 fn paint_content(
424 &self,
425 canvas: &Canvas,
426 layout: &BoxLayout,
427 props: &AnimatedProperties,
428 ctx: &PaintCtx,
429 ) {
430 self.paint(canvas, layout.width, props, ctx);
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use rustmotion_core::css::CssStyle;
438
439 fn span(text: &str) -> RichTextSpan {
440 RichTextSpan {
441 text: text.into(),
442 color: None,
443 font_size: None,
444 font_weight: None,
445 font_family: None,
446 font_style: None,
447 letter_spacing: None,
448 }
449 }
450
451 fn style(font_px: f32) -> CssStyle {
452 CssStyle {
453 font_size: Some(rustmotion_core::css::Length::Px(font_px)),
454 ..Default::default()
455 }
456 }
457
458 #[test]
459 fn single_long_span_wraps_into_multiple_lines_at_constrained_width() {
460 let spans = vec![span(
463 "the quick brown fox jumps over the lazy dog and keeps going",
464 )];
465 let s = style(24.0);
466 let unconstrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
467 assert_eq!(
468 unconstrained.lines.len(),
469 1,
470 "unconstrained width must fit on one line"
471 );
472
473 let constrained = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, Some(150.0), -1.0);
474 assert!(
475 constrained.lines.len() > 1,
476 "a single long span must wrap into multiple lines at 150px, got {} line(s)",
477 constrained.lines.len()
478 );
479 for line in &constrained.lines {
480 assert!(
481 line.width <= 150.0 + 0.5,
482 "each wrapped line must fit the constraint, got {}",
483 line.width
484 );
485 }
486 }
487
488 #[test]
489 fn spans_glue_without_extra_space_when_source_has_none() {
490 let glued = vec![span("Total:"), span("42"), span(" items")];
495 let s = style(20.0);
496 let layout = RichText::compute_layout(&glued, &s, 1920.0, 1080.0, None, -1.0);
497 assert_eq!(layout.lines.len(), 1);
498 let tokens = &layout.lines[0].tokens;
499 assert_eq!(
500 tokens.iter().map(|t| t.text.as_str()).collect::<Vec<_>>(),
501 vec!["Total:", "42", "items"]
502 );
503 assert_eq!(tokens[1].x, tokens[0].width, "no space between glued spans");
506 assert!(
510 tokens[2].x > tokens[1].x + tokens[1].width,
511 "a space must separate '42' and 'items' (source had a leading space)"
512 );
513 }
514
515 #[test]
516 fn typewriter_truncation_hides_tail_tokens() {
517 let spans = vec![span("Hello "), span("world")];
518 let s = style(20.0);
519 let full = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
520 let half = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.5);
521 let none = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, 0.0);
522
523 let full_tokens: usize = full.lines.iter().map(|l| l.tokens.len()).sum();
524 let half_tokens: usize = half.lines.iter().map(|l| l.tokens.len()).sum();
525 let none_tokens: usize = none.lines.iter().map(|l| l.tokens.len()).sum();
526
527 assert!(full_tokens >= half_tokens);
528 assert_eq!(none_tokens, 0, "progress 0.0 must show nothing");
529 assert!(
530 half_tokens >= 1,
531 "progress 0.5 must show at least one token"
532 );
533 }
534
535 #[test]
536 fn empty_spans_produce_one_empty_line_not_a_panic() {
537 let spans: Vec<RichTextSpan> = vec![];
538 let s = style(20.0);
539 let layout = RichText::compute_layout(&spans, &s, 1920.0, 1080.0, None, -1.0);
540 assert_eq!(layout.lines.len(), 1);
541 assert_eq!(layout.max_width, 0.0);
542 }
543}