diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index ed4f6b1..ea402d3 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -2112,6 +2112,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }), position: None, x: None, @@ -2294,6 +2295,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }), position: None, x: None, diff --git a/crates/rustmotion-components/src/gradient_text.rs b/crates/rustmotion-components/src/gradient_text.rs index b9263b6..e64db6a 100644 --- a/crates/rustmotion-components/src/gradient_text.rs +++ b/crates/rustmotion-components/src/gradient_text.rs @@ -14,7 +14,7 @@ use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, parse_hex_color, typeface_with_fallback, wrap_text_with_tracking, }; -use rustmotion_core::schema::TimelineStep; +use rustmotion_core::schema::{TextAlign, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; fn default_colors() -> Vec { @@ -97,6 +97,7 @@ impl GradientText { layout_width: f32, content_height: Option, time: f64, + props: &AnimatedProperties, ctx: &PaintCtx, ) { let has_stops = self.stops.as_ref().is_some_and(|s| !s.is_empty()); @@ -240,6 +241,29 @@ impl GradientText { } }; + if let Some(ref resolved) = props.char_animation { + let schema_align = match align { + CssTextAlign::Center => TextAlign::Center, + CssTextAlign::Right | CssTextAlign::End => TextAlign::Right, + _ => TextAlign::Left, + }; + crate::intrinsic::render_char_animation( + canvas, + &font, + &emoji_font, + &fill_paint, + letter_spacing, + schema_align, + align_width, + line_height_val, + ascent, + &lines, + resolved, + time, + ); + return; + } + for (i, line) in lines.iter().enumerate() { if line.is_empty() { continue; @@ -270,13 +294,13 @@ impl Painter for GradientText { &self, canvas: &Canvas, layout: &BoxLayout, - _props: &AnimatedProperties, + props: &AnimatedProperties, ctx: &PaintCtx, ) { let (_, _, _, content_height) = layout.content_box(); let content_height = (content_height > 0.0 && content_height.is_finite()).then_some(content_height); - self.paint(canvas, layout.width, content_height, ctx.time, ctx); + self.paint(canvas, layout.width, content_height, ctx.time, props, ctx); } } @@ -285,6 +309,9 @@ mod tests { use super::*; use rustmotion_core::css::style::CssStyle; use rustmotion_core::css::Length; + use rustmotion_core::schema::{ + AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity, + }; fn make_gradient_text(content: &str, white_space: Option) -> GradientText { GradientText { @@ -382,8 +409,18 @@ mod tests { } fn render_unpremul(gt: &GradientText, w: i32, h: i32) -> (Vec, Vec) { + render_unpremul_at(gt, w, h, 0.0, &AnimatedProperties::default()) + } + + fn render_unpremul_at( + gt: &GradientText, + w: i32, + h: i32, + time: f64, + props: &AnimatedProperties, + ) -> (Vec, Vec) { let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); - gt.paint(surface.canvas(), w as f32, None, 0.0, &test_ctx()); + gt.paint(surface.canvas(), w as f32, None, time, props, &test_ctx()); let snapshot = surface.image_snapshot(); let info = skia_safe::ImageInfo::new( (w, h), @@ -460,7 +497,14 @@ mod tests { gt.style.text_align = Some(CssTextAlign::Center); let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); - gt.paint(surface.canvas(), BOX_W, None, 0.0, &test_ctx()); + gt.paint( + surface.canvas(), + BOX_W, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); let (lo, hi) = ink_x_span(&grid, W, H).expect("centred gradient_text must paint something"); @@ -487,7 +531,14 @@ mod tests { gt.style.text_align = Some(CssTextAlign::Right); let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); - gt.paint(surface.canvas(), BOX_W, None, 0.0, &test_ctx()); + gt.paint( + surface.canvas(), + BOX_W, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); let (lo, hi) = ink_x_span(&grid, W, H).expect("right-aligned gradient_text must paint"); @@ -505,7 +556,14 @@ mod tests { let gt = make_gradient_text("GRADIENT", Some(CssWhiteSpace::Nowrap)); let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); - gt.paint(surface.canvas(), 600.0, None, 0.0, &test_ctx()); + gt.paint( + surface.canvas(), + 600.0, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); let (lo, _) = ink_x_span(&grid, W, H).expect("gradient_text must paint"); @@ -526,7 +584,14 @@ mod tests { gt.style.text_align = align; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); - gt.paint(surface.canvas(), BOX_W, None, 0.0, &test_ctx()); + gt.paint( + surface.canvas(), + BOX_W, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let snapshot = surface.image_snapshot(); let info = skia_safe::ImageInfo::new( @@ -577,7 +642,14 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 80.0, None, 0.0, &test_ctx()); + gt.paint( + canvas, + 80.0, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -597,7 +669,14 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 80.0, None, 0.0, &test_ctx()); + gt.paint( + canvas, + 80.0, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -631,7 +710,14 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 300.0, None, 0.0, &test_ctx()); + gt.paint( + canvas, + 300.0, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -689,7 +775,14 @@ mod tests { const H: i32 = 300; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, BOX_W, None, 0.0, &test_ctx()); + gt.paint( + canvas, + BOX_W, + None, + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); let ink_right = max_ink_x(&grid, W, H).expect("gradient_text must paint some ink"); @@ -715,7 +808,14 @@ mod tests { const H: i32 = 200; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 80.0, Some(45.0), 0.0, &test_ctx()); + gt.paint( + canvas, + 80.0, + Some(45.0), + 0.0, + &AnimatedProperties::default(), + &test_ctx(), + ); let grid = alpha_grid(&mut surface, W, H); assert!( @@ -738,7 +838,14 @@ mod tests { let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let canvas = surface.canvas(); - gt.paint(canvas, 300.0, Some(60.0), t, &test_ctx()); + gt.paint( + canvas, + 300.0, + Some(60.0), + t, + &AnimatedProperties::default(), + &test_ctx(), + ); alpha_grid(&mut surface, W, H) }; @@ -873,4 +980,131 @@ mod tests { (even={mid_even:?}, skewed={mid_skewed:?})" ); } + + fn props_for(gt: &GradientText) -> AnimatedProperties { + AnimatedProperties { + char_animation: rustmotion_core::engine::animator::extract_effects(>.style.animation) + .char_animation, + ..Default::default() + } + } + + fn soft_pixel_fraction( + grid: &[u8], + surface_width: i32, + x0: i32, + x1: i32, + y0: i32, + y1: i32, + ) -> f32 { + let mut inked = 0u32; + let mut soft = 0u32; + for y in y0..y1 { + for x in x0..x1 { + let a = grid[(y * surface_width + x) as usize]; + if a > 0 { + inked += 1; + if a < 250 { + soft += 1; + } + } + } + } + if inked == 0 { + return 0.0; + } + soft as f32 / inked as f32 + } + + #[test] + fn char_blur_in_animates_a_gradient_text_word_instead_of_painting_it_sharp_immediately() { + let mut gt = make_gradient_text("BLUR", Some(CssWhiteSpace::Nowrap)); + gt.style.font_size = Some(Length::Px(100.0)); + gt.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming { + delay: 0.0, + duration: 0.5, + stagger: 0.03, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + ..Default::default() + })]; + + const W: i32 = 700; + const H: i32 = 220; + let props = props_for(>); + + let early = alpha_grid( + &mut { + let mut s = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface"); + gt.paint(s.canvas(), W as f32, None, 0.15, &props, &test_ctx()); + s + }, + W, + H, + ); + let settled = alpha_grid( + &mut { + let mut s = skia_safe::surfaces::raster_n32_premul((W, H)).expect("surface"); + gt.paint(s.canvas(), W as f32, None, 1.0, &props, &test_ctx()); + s + }, + W, + H, + ); + + assert!( + has_ink_in(&early, W, 0, W, 0, H), + "the word must have started painting by t=0.15 — a char_* preset on gradient_text \ + used to be silently ignored and paint the word fully sharp from frame 0" + ); + + let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H); + let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H); + assert!( + early_soft > settled_soft + 0.15, + "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \ + settled fraction ({settled_soft:.3}) — gradient_text must actually blur while \ + animating, not just render the sharp glyph unconditionally" + ); + assert!( + settled_soft < 0.25, + "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})" + ); + } + + #[test] + fn char_animation_never_restarts_the_gradient_ramp_per_unit() { + let mut gt = two_color_gradient(default_angle()); + gt.content = "IIII IIII IIII IIII".into(); + gt.style.white_space = Some(CssWhiteSpace::Nowrap); + gt.style.animation = vec![AnimationEffect::CharFadeIn(CharAnimationTiming { + delay: 0.0, + duration: 0.1, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + ..Default::default() + })]; + + const W: i32 = 1400; + const H: i32 = 300; + let props = props_for(>); + let (buf, alpha) = render_unpremul_at(>, W, H, 5.0, &props); + let (lo, hi) = ink_x_span(&alpha, W, H).expect("must paint some ink"); + + let left = best_pixel_in_column(&buf, &alpha, W, H, (lo + 2).min(hi)); + let right = best_pixel_in_column(&buf, &alpha, W, H, (hi - 2).max(lo)); + + assert!( + left.0 as i32 - left.2 as i32 > 60, + "the leftmost word must still read close to the ramp's first colour (red) once \ + settled — got {left:?}" + ); + assert!( + right.2 as i32 - right.0 as i32 > 60, + "the rightmost word must still read close to the ramp's last colour (blue) — \ + splitting the run into per-word animated units must not restart the gradient for \ + each word, got {right:?}" + ); + } } diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index d7968b1..93e68bb 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -733,6 +733,348 @@ impl IntrinsicMeasure for RichTextIntrinsic { } } +use rustmotion_core::engine::animator::{ease, ResolvedCharAnimation}; +use rustmotion_core::engine::renderer::{draw_text_with_fallback, paint_from_hex}; +use rustmotion_core::schema::{CharAnimPreset, TextAlign, TextAnimGranularity}; +use skia_safe::{Canvas, Paint}; + +pub fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { + let unit_start = cfg.unit_start(idx); + let unit_end = unit_start + cfg.duration as f64; + let raw_t = if time <= unit_start { + 0.0 + } else if time >= unit_end { + 1.0 + } else { + (time - unit_start) / (unit_end - unit_start) + }; + ease(raw_t, &cfg.easing) as f32 +} + +fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option { + let from = cfg.ink_from.as_deref()?; + let start = paint_from_hex(from).color(); + let end = paint.color(); + let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8; + let mut p = paint.clone(); + p.set_color(skia_safe::Color::from_argb( + end.a(), + lerp(start.r(), end.r()), + lerp(start.g(), end.g()), + lerp(start.b(), end.b()), + )); + Some(p) +} + +#[allow(clippy::too_many_arguments)] +pub fn apply_text_anim_preset( + canvas: &Canvas, + text: &str, + font: &Font, + emoji_font: &Option, + paint: &Paint, + cursor_x: f32, + line_y: f32, + unit_width: f32, + letter_spacing: f32, + cfg: &ResolvedCharAnimation, + t: f32, + time: f64, + unit_idx: usize, + font_size: f32, +) { + let preset = &cfg.preset; + let overshoot = cfg.overshoot; + let blur_radius = cfg.blur; + let center_x = cursor_x + unit_width / 2.0; + let center_y = line_y; + + let inked = ink_paint(cfg, paint, t); + let paint = inked.as_ref().unwrap_or(paint); + if let Some(from) = cfg.scale_from { + if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) { + let s = from + (1.0 - from) * t.clamp(0.0, 1.0); + canvas.translate((center_x, center_y)); + canvas.scale((s, s)); + canvas.translate((-center_x, -center_y)); + } + } + + match preset { + CharAnimPreset::ScaleIn => { + let scale = if overshoot > 0.001 { + if t < 0.7 { + let p = t / 0.7; + p * (1.0 + overshoot) + } else { + let p = (t - 0.7) / 0.3; + (1.0 + overshoot) - overshoot * p + } + } else { + t + }; + if scale < 0.001 { + return; + } + canvas.translate((center_x, center_y)); + canvas.scale((scale, scale)); + canvas.translate((-center_x, -center_y)); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + paint, + ); + } + CharAnimPreset::FadeIn => { + let mut p = paint.clone(); + p.set_alpha_f(t * paint.alpha_f()); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + &p, + ); + } + CharAnimPreset::Wave => { + let wave_offset = + (time as f32 * 4.0 + unit_idx as f32 * 0.5).sin() * 8.0 * (1.0 - t * 0.5); + let mut p = paint.clone(); + p.set_alpha_f(t.min(1.0) * paint.alpha_f()); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y + wave_offset, + &p, + ); + } + CharAnimPreset::Bounce => { + let peak = 1.0 + overshoot.max(0.3); + let scale = if t < 0.5 { + t * 2.0 * peak + } else { + peak - (peak - 1.0) * ((t - 0.5) * 2.0) + }; + let scale = scale.max(0.001); + canvas.translate((center_x, center_y)); + canvas.scale((scale, scale)); + canvas.translate((-center_x, -center_y)); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + paint, + ); + } + CharAnimPreset::RotateIn => { + let angle = (1.0 - t) * -90.0; + let mut p = paint.clone(); + p.set_alpha_f(t * paint.alpha_f()); + canvas.translate((center_x, center_y)); + canvas.rotate(angle, None); + canvas.translate((-center_x, -center_y)); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + &p, + ); + } + CharAnimPreset::SlideUp => { + let travel = (1.0 - t) * font_size * 0.8 * cfg.distance; + let (dx, dy) = cfg.direction.offset(travel); + let mut p = paint.clone(); + p.set_alpha_f(t * paint.alpha_f()); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x + dx, + line_y + dy, + &p, + ); + } + CharAnimPreset::BlurIn => { + let tt = t.clamp(0.0, 1.0); + let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance; + let (dx, dy) = cfg.direction.offset(travel); + let sigma = ((1.0 - tt) * blur_radius).max(0.0); + let mut p = paint.clone(); + p.set_alpha_f(tt * paint.alpha_f()); + if sigma > 0.05 { + if let Some(filter) = skia_safe::image_filters::blur( + (sigma, sigma), + skia_safe::TileMode::Clamp, + None, + None, + ) { + p.set_image_filter(filter); + } + } + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x + dx, + line_y + dy, + &p, + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn render_char_animation( + canvas: &Canvas, + font: &Font, + emoji_font: &Option, + paint: &Paint, + letter_spacing: f32, + align: TextAlign, + align_width: f32, + line_height_val: f32, + baseline_offset: f32, + lines: &[String], + char_anim: &ResolvedCharAnimation, + time: f64, +) { + let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word); + let mut global_unit_idx = 0usize; + + for (line_idx, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + + let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing); + let line_x = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - advance_width) / 2.0, + TextAlign::Right => align_width - advance_width, + }; + let line_y = line_idx as f32 * line_height_val + baseline_offset; + + if is_word_mode { + let mut cursor_x = line_x; + let mut chars = line.chars().peekable(); + + while chars.peek().is_some() { + let mut spaces = String::new(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + spaces.push(c); + chars.next(); + } else { + break; + } + } + if !spaces.is_empty() { + let space_w = + measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing); + draw_text_with_fallback( + canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint, + ); + cursor_x += space_w; + } + + let mut word = String::new(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + break; + } + word.push(c); + chars.next(); + } + if word.is_empty() { + continue; + } + + let word_width = + measure_text_with_fallback(&word, font, emoji_font, letter_spacing); + + let t = unit_progress(char_anim, global_unit_idx, time); + + canvas.save(); + apply_text_anim_preset( + canvas, + &word, + font, + emoji_font, + paint, + cursor_x, + line_y, + word_width, + letter_spacing, + char_anim, + t, + time, + global_unit_idx, + font.size(), + ); + canvas.restore(); + + cursor_x += word_width; + global_unit_idx += 1; + } + } else { + let mut cursor_x = line_x; + for ch in line.chars() { + let ch_str = ch.to_string(); + let (ch_width, _) = font.measure_str(&ch_str, None); + let ch_width = ch_width + letter_spacing; + + let t = unit_progress(char_anim, global_unit_idx, time); + + canvas.save(); + apply_text_anim_preset( + canvas, + &ch_str, + font, + emoji_font, + paint, + cursor_x, + line_y, + ch_width, + 0.0, + char_anim, + t, + time, + global_unit_idx, + font.size(), + ); + canvas.restore(); + + cursor_x += ch_width; + global_unit_idx += 1; + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -758,6 +1100,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }; let m = TextIntrinsic::from_text(&text); let (w, h) = m.measure( @@ -790,6 +1133,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }; let m = TextIntrinsic::from_text(&text); let (_w_unwrapped, h_unwrapped) = m.measure( @@ -826,6 +1170,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }; let m = TextIntrinsic::from_text(&text); let (w, h) = m.measure( @@ -854,6 +1199,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, } } @@ -1127,6 +1473,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, } } @@ -1403,6 +1750,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, } } @@ -1518,6 +1866,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, } } diff --git a/crates/rustmotion-components/src/rich_text.rs b/crates/rustmotion-components/src/rich_text.rs index 14cb979..64cd4a0 100644 --- a/crates/rustmotion-components/src/rich_text.rs +++ b/crates/rustmotion-components/src/rich_text.rs @@ -7,13 +7,15 @@ use rustmotion_core::css::style::{ TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace, }; use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::animator::AnimatedProperties; +use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimation}; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, typeface_with_fallback, }; -use rustmotion_core::schema::{FontStyleType, FontWeight, TextAlign, TimelineStep}; +use rustmotion_core::schema::{ + FontStyleType, FontWeight, TextAlign, TextAnimGranularity, TimelineStep, +}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; /// Padding around a pill span's background box, in px on each side. `left` @@ -419,6 +421,22 @@ impl RichText { let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0; + if let Some(ref resolved) = props.char_animation { + render_rich_text_char_animation( + canvas, + &layout, + &self.spans, + &span_fonts, + &emoji_tf, + align, + align_width, + baseline_offset, + resolved, + ctx.time, + ); + return; + } + for (line_idx, line) in layout.lines.iter().enumerate() { let line_x_offset = match align { TextAlign::Left => 0.0, @@ -536,6 +554,155 @@ impl RichText { } } +fn paint_pill_background( + canvas: &Canvas, + span: &RichTextSpan, + run: &[RichTextToken], + span_fonts: &[Option], + line_x_offset: f32, + y: f32, +) { + let (Some(first), Some(last)) = (run.first(), run.last()) else { + return; + }; + let sf = span_fonts[first.span_idx] + .as_ref() + .expect("font presence matches compute_layout's tokenization"); + let (_, metrics) = sf.font.metrics(); + let ascent = -metrics.ascent; + let descent = metrics.descent; + let padding = span.pill_padding(); + + let x0 = line_x_offset + first.x - padding.left; + let x1 = line_x_offset + last.x + last.width + padding.right; + let y0 = y - ascent - padding.top; + let y1 = y + descent + padding.bottom; + let rect = Rect::from_ltrb(x0, y0, x1, y1); + let radius = span.border_radius.unwrap_or(0.0).max(0.0); + let rrect = RRect::new_rect_xy(rect, radius, radius); + let center = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let rotation = span.rotation.unwrap_or(0.0); + + let background = span + .background + .as_deref() + .expect("paint_pill_background is only called for spans with a background"); + let bg_paint = paint_from_hex(background); + + canvas.save(); + if rotation != 0.0 { + canvas.rotate(rotation, Some(center)); + } + canvas.draw_rrect(rrect, &bg_paint); + canvas.restore(); +} + +#[allow(clippy::too_many_arguments)] +fn render_rich_text_char_animation( + canvas: &Canvas, + layout: &RichTextLayout, + spans: &[RichTextSpan], + span_fonts: &[Option], + emoji_tf: &Option, + align: TextAlign, + align_width: f32, + baseline_offset: f32, + char_anim: &ResolvedCharAnimation, + time: f64, +) { + let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word); + let mut global_unit_idx = 0usize; + + for (line_idx, line) in layout.lines.iter().enumerate() { + if line.tokens.is_empty() { + continue; + } + let line_x_offset = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - line.width) / 2.0, + TextAlign::Right => align_width - line.width, + }; + let y = line_idx as f32 * layout.line_height + baseline_offset; + + let mut i = 0; + while i < line.tokens.len() { + let span_idx = line.tokens[i].span_idx; + let mut j = i + 1; + while j < line.tokens.len() && line.tokens[j].span_idx == span_idx { + j += 1; + } + let run = &line.tokens[i..j]; + if spans[span_idx].background.is_some() { + paint_pill_background(canvas, &spans[span_idx], run, span_fonts, line_x_offset, y); + } + i = j; + } + + for tok in &line.tokens { + let sf = span_fonts[tok.span_idx] + .as_ref() + .expect("font presence matches compute_layout's tokenization"); + let paint = paint_from_hex(&sf.color); + let emoji_font = emoji_tf + .as_ref() + .map(|tf| Font::from_typeface(tf.clone(), sf.font.size())); + + if is_word_mode { + let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time); + canvas.save(); + crate::intrinsic::apply_text_anim_preset( + canvas, + &tok.text, + &sf.font, + &emoji_font, + &paint, + line_x_offset + tok.x, + y, + tok.width, + sf.letter_spacing, + char_anim, + t, + time, + global_unit_idx, + sf.font.size(), + ); + canvas.restore(); + global_unit_idx += 1; + } else { + let mut cursor_x = line_x_offset + tok.x; + for ch in tok.text.chars() { + let ch_str = ch.to_string(); + let (ch_width, _) = sf.font.measure_str(&ch_str, None); + let ch_width = ch_width + sf.letter_spacing; + + let t = crate::intrinsic::unit_progress(char_anim, global_unit_idx, time); + canvas.save(); + crate::intrinsic::apply_text_anim_preset( + canvas, + &ch_str, + &sf.font, + &emoji_font, + &paint, + cursor_x, + y, + ch_width, + 0.0, + char_anim, + t, + time, + global_unit_idx, + sf.font.size(), + ); + canvas.restore(); + + cursor_x += ch_width; + global_unit_idx += 1; + } + } + } + } +} + impl Painter for RichText { fn paint_content( &self, @@ -552,6 +719,9 @@ impl Painter for RichText { mod tests { use super::*; use rustmotion_core::css::CssStyle; + use rustmotion_core::schema::{ + AnimationEffect, CharAnimationTiming, EasingType, TextAnimGranularity, + }; fn span(text: &str) -> RichTextSpan { RichTextSpan { @@ -1112,4 +1282,302 @@ mod tests { unrotated position, got the same pixel {corner_rotated:?} at both" ); } + + fn richtext_with_char_anim( + spans: Vec, + effect: AnimationEffect, + font_px: f32, + ) -> RichText { + RichText { + spans, + max_width: None, + timing: Default::default(), + style: CssStyle { + font_size: Some(rustmotion_core::css::Length::Px(font_px)), + white_space: Some(CssWhiteSpace::Nowrap), + animation: vec![effect], + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + } + } + + fn props_for(rt: &RichText) -> AnimatedProperties { + AnimatedProperties { + char_animation: rustmotion_core::engine::animator::extract_effects(&rt.style.animation) + .char_animation, + ..Default::default() + } + } + + fn ctx_at(time: f64, video_width: u32, video_height: u32) -> PaintCtx { + PaintCtx { + time, + scenario_time: time, + scene_duration: 2.0, + frame_index: 0, + fps: 30, + video_width, + video_height, + stagger_offset: 0.0, + } + } + + fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool { + for y in y0..y1 { + for x in x0..x1 { + if grid[(y * surface_width + x) as usize] > 0 { + return true; + } + } + } + false + } + + fn soft_pixel_fraction( + grid: &[u8], + surface_width: i32, + x0: i32, + x1: i32, + y0: i32, + y1: i32, + ) -> f32 { + let mut inked = 0u32; + let mut soft = 0u32; + for y in y0..y1 { + for x in x0..x1 { + let a = grid[(y * surface_width + x) as usize]; + if a > 0 { + inked += 1; + if a < 250 { + soft += 1; + } + } + } + } + if inked == 0 { + return 0.0; + } + soft as f32 / inked as f32 + } + + fn inter_font_px(px: f32) -> Font { + let typeface = typeface_with_fallback("Inter", FontStyle::default()).expect("resolves"); + Font::from_typeface(typeface, px) + } + + #[test] + fn char_blur_in_animates_a_rich_text_word_instead_of_painting_it_sharp_immediately() { + let font_px = 100.0; + let rt = richtext_with_char_anim( + vec![span("BLUR")], + AnimationEffect::CharBlurIn(CharAnimationTiming { + delay: 0.0, + duration: 0.5, + stagger: 0.03, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + ..Default::default() + }), + font_px, + ); + + const W: i32 = 700; + const H: i32 = 220; + let props = props_for(&rt); + + let render_at = |t: f64| -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + rt.paint(canvas, W as f32, &props, &ctx_at(t, W as u32, H as u32)); + } + alpha_grid(&mut surface, W, H) + }; + + let early = render_at(0.15); + let settled = render_at(1.0); + + assert!( + has_ink_in(&early, W, 0, W, 0, H), + "the word must have started painting by t=0.15 — a char_* preset on rich_text used \ + to be silently ignored and paint the word fully sharp from frame 0" + ); + + let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H); + let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H); + + assert!( + early_soft > settled_soft + 0.15, + "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \ + settled fraction ({settled_soft:.3}) — rich_text must actually blur while animating, \ + not just render the sharp glyph unconditionally" + ); + assert!( + settled_soft < 0.25, + "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})" + ); + } + + #[test] + fn char_blur_in_word_stagger_carries_across_a_span_boundary() { + let font_px = 90.0; + let rt = richtext_with_char_anim( + vec![span("ONE "), span("TWO")], + AnimationEffect::CharBlurIn(CharAnimationTiming { + delay: 0.5, + duration: 0.3, + stagger: 0.6, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + blur: Some(16.0), + ..Default::default() + }), + font_px, + ); + + const W: i32 = 900; + const H: i32 = 180; + let props = props_for(&rt); + let font = inter_font_px(font_px); + let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32; + + let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = before.canvas(); + rt.paint(canvas, W as f32, &props, &ctx_at(0.1, W as u32, H as u32)); + } + let before_grid = alpha_grid(&mut before, W, H); + assert!( + !has_ink_in(&before_grid, W, 0, W, 0, H), + "nothing should paint before `delay` has elapsed" + ); + + let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = mid.canvas(); + rt.paint(canvas, W as f32, &props, &ctx_at(0.65, W as u32, H as u32)); + } + let mid_grid = alpha_grid(&mut mid, W, H); + assert!( + has_ink_in(&mid_grid, W, 0, word1_end, 0, H), + "the first span's word should show ink by t=0.65 (mid-reveal)" + ); + assert!( + !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H), + "the SECOND span's word (a different RichTextSpan, starting at delay+stagger=1.1s) \ + must still be fully invisible at t=0.65 — the stagger index must run across the span \ + boundary rather than resetting per span" + ); + } + + #[test] + fn ink_from_converges_to_each_spans_own_colour_not_a_shared_default() { + let font_px = 90.0; + let spans = vec![ + RichTextSpan { + color: Some("#00FF00".into()), + ..span("AAAA") + }, + RichTextSpan { + color: Some("#0000FF".into()), + ..span(" BBBB") + }, + ]; + let rt = richtext_with_char_anim( + spans.clone(), + AnimationEffect::CharFadeIn(CharAnimationTiming { + delay: 0.0, + duration: 1.0, + stagger: 0.0, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + ink_from: Some("#FF0000".into()), + ..Default::default() + }), + font_px, + ); + + const W: i32 = 900; + const H: i32 = 200; + let props = props_for(&rt); + + let layout = RichText::compute_layout(&spans, &rt.style, W as f32, H as f32, None, -1.0); + let win = |idx: usize| { + let tok = &layout.lines[0].tokens[idx]; + (tok.x as i32, (tok.x + tok.width) as i32) + }; + let (w1_x0, w1_x1) = win(0); + let (w2_x0, w2_x1) = win(1); + + let mean_rgb = |time: f64, x0: i32, x1: i32| -> (f32, f32, f32) { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + rt.paint(canvas, W as f32, &props, &ctx_at(time, W as u32, H as u32)); + } + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (W, H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (W * H * 4) as usize]; + assert!(snapshot.read_pixels( + &info, + &mut buf, + (W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + )); + let mut sum = (0u64, 0u64, 0u64); + let mut alpha_sum = 0u64; + for y in 0..H { + for x in x0.max(0)..x1.min(W) { + let i = ((y * W + x) * 4) as usize; + let a = buf[i + 3] as u64; + sum.0 += buf[i] as u64 * a; + sum.1 += buf[i + 1] as u64 * a; + sum.2 += buf[i + 2] as u64 * a; + alpha_sum += a; + } + } + assert!( + alpha_sum > 0, + "some inked pixels must exist in this window at t={time}" + ); + ( + sum.0 as f32 / alpha_sum as f32, + sum.1 as f32 / alpha_sum as f32, + sum.2 as f32 / alpha_sum as f32, + ) + }; + + let (r1_early, g1_early, b1_early) = mean_rgb(0.05, w1_x0, w1_x1); + let (r2_early, g2_early, b2_early) = mean_rgb(0.05, w2_x0, w2_x1); + assert!( + r1_early > g1_early && r1_early > b1_early, + "span 1 should read close to ink_from (red) early on, got rgb=({r1_early},{g1_early},{b1_early})" + ); + assert!( + r2_early > g2_early && r2_early > b2_early, + "span 2 should read close to ink_from (red) early on, got rgb=({r2_early},{g2_early},{b2_early})" + ); + + let (r1, g1, b1) = mean_rgb(5.0, w1_x0, w1_x1); + let (r2, g2, b2) = mean_rgb(5.0, w2_x0, w2_x1); + assert!( + g1 > r1 && g1 > b1, + "settled span 1 must converge to ITS OWN colour (green), got rgb=({r1},{g1},{b1})" + ); + assert!( + b2 > r2 && b2 > g2, + "settled span 2 must converge to ITS OWN colour (blue), not span 1's — a shared \ + default would fail this, got rgb=({r2},{g2},{b2})" + ); + } } diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index b104612..9049cf0 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -3,22 +3,20 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, Font, FontStyle, Paint, PaintStyle, Rect}; -use rustmotion_core::engine::animator::ease; - use rustmotion_core::css::style::{ FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign, WhiteSpace as CssWhiteSpace, }; use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::animator::{AnimatedProperties, ResolvedCharAnimation}; +use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, typeface_with_fallback, wrap_text_with_tracking, }; use rustmotion_core::schema::{ - CaretConfig, CaretShape, CharAnimPreset, FontStyleType, FontWeight, Stroke, TextAlign, - TextAnimGranularity, TextBackground, TextShadow, TextState, TextSwapConfig, TimelineStep, + CaretConfig, CaretShape, FontStyleType, FontWeight, Stroke, TextAlign, TextBackground, + TextShadow, TextState, TextSwapConfig, TimelineStep, }; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -51,6 +49,10 @@ pub struct Text { /// How the crossing between `states` is animated. See [`TextSwapConfig`]. #[serde(default)] pub swap: Option, + /// A letter-by-letter transition between two `states`, instead of the + /// whole-label rise-and-blur `swap` performs. See [`TextMorphConfig`]. + #[serde(default)] + pub morph: Option, } rustmotion_core::impl_traits!(Text { @@ -59,340 +61,56 @@ rustmotion_core::impl_traits!(Text { Styled => style, }); -fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { - let unit_start = cfg.unit_start(idx); - let unit_end = unit_start + cfg.duration as f64; - let raw_t = if time <= unit_start { - 0.0 - } else if time >= unit_end { - 1.0 - } else { - (time - unit_start) / (unit_end - unit_start) - }; - ease(raw_t, &cfg.easing) as f32 +/// How unmatched glyphs behave during a [`TextMorphConfig`] transition — +/// characters present in the incoming label with no identical counterpart in +/// the outgoing one. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TextMorphUnmatched { + /// The unmatched glyph simply fades in (or out) at its resting position. + #[default] + Fade, + /// The unmatched glyph cycles through deterministic random characters + /// before settling on the real one, instead of just fading in blank. + Scramble, } -fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option { - let from = cfg.ink_from.as_deref()?; - let start = paint_from_hex(from).color(); - let end = paint.color(); - let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8; - let mut p = paint.clone(); - p.set_color(skia_safe::Color::from_argb( - end.a(), - lerp(start.r(), end.r()), - lerp(start.g(), end.g()), - lerp(start.b(), end.b()), - )); - Some(p) +/// A letter-by-letter morph between two `states` labels: identical +/// characters are paired left to right and slide from their old position to +/// their new one; characters with no pair fade (or, with `unmatched: +/// "scramble"`, cycle through placeholder glyphs before settling). +/// +/// This is a different crossing than [`TextSwapConfig`]: `swap` treats each +/// label as one rigid block (rise + blur); `morph` treats it as a bag of +/// glyphs that rearranges itself. Setting both is not meaningful — `morph` +/// takes over the transition window whenever it applies, `swap` is only +/// consulted outside it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct TextMorphConfig { + /// How long the morph takes (seconds). + #[serde(default = "default_morph_duration")] + pub duration: f64, + /// Behaviour of glyphs that have no identical counterpart in the other label. + #[serde(default)] + pub unmatched: TextMorphUnmatched, + /// Seed for the deterministic scramble sequence. Same seed, same scramble. + #[serde(default)] + pub seed: u32, } -fn apply_text_anim_preset( - canvas: &Canvas, - text: &str, - font: &Font, - emoji_font: &Option, - paint: &Paint, - cursor_x: f32, - line_y: f32, - unit_width: f32, - letter_spacing: f32, - cfg: &ResolvedCharAnimation, - t: f32, - time: f64, - unit_idx: usize, - font_size: f32, -) { - let preset = &cfg.preset; - let overshoot = cfg.overshoot; - let blur_radius = cfg.blur; - let center_x = cursor_x + unit_width / 2.0; - let center_y = line_y; - - let inked = ink_paint(cfg, paint, t); - let paint = inked.as_ref().unwrap_or(paint); - if let Some(from) = cfg.scale_from { - if !matches!(preset, CharAnimPreset::ScaleIn | CharAnimPreset::Bounce) { - let s = from + (1.0 - from) * t.clamp(0.0, 1.0); - canvas.translate((center_x, center_y)); - canvas.scale((s, s)); - canvas.translate((-center_x, -center_y)); - } - } - - match preset { - CharAnimPreset::ScaleIn => { - let scale = if overshoot > 0.001 { - if t < 0.7 { - let p = t / 0.7; - p * (1.0 + overshoot) - } else { - let p = (t - 0.7) / 0.3; - (1.0 + overshoot) - overshoot * p - } - } else { - t - }; - if scale < 0.001 { - return; - } - canvas.translate((center_x, center_y)); - canvas.scale((scale, scale)); - canvas.translate((-center_x, -center_y)); - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x, - line_y, - paint, - ); - } - CharAnimPreset::FadeIn => { - let mut p = paint.clone(); - p.set_alpha_f(t * paint.alpha_f()); - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x, - line_y, - &p, - ); - } - CharAnimPreset::Wave => { - let wave_offset = - (time as f32 * 4.0 + unit_idx as f32 * 0.5).sin() * 8.0 * (1.0 - t * 0.5); - let mut p = paint.clone(); - p.set_alpha_f(t.min(1.0) * paint.alpha_f()); - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x, - line_y + wave_offset, - &p, - ); - } - CharAnimPreset::Bounce => { - let peak = 1.0 + overshoot.max(0.3); - let scale = if t < 0.5 { - t * 2.0 * peak - } else { - peak - (peak - 1.0) * ((t - 0.5) * 2.0) - }; - let scale = scale.max(0.001); - canvas.translate((center_x, center_y)); - canvas.scale((scale, scale)); - canvas.translate((-center_x, -center_y)); - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x, - line_y, - paint, - ); - } - CharAnimPreset::RotateIn => { - let angle = (1.0 - t) * -90.0; - let mut p = paint.clone(); - p.set_alpha_f(t * paint.alpha_f()); - canvas.translate((center_x, center_y)); - canvas.rotate(angle, None); - canvas.translate((-center_x, -center_y)); - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x, - line_y, - &p, - ); - } - CharAnimPreset::SlideUp => { - let travel = (1.0 - t) * font_size * 0.8 * cfg.distance; - let (dx, dy) = cfg.direction.offset(travel); - let mut p = paint.clone(); - p.set_alpha_f(t * paint.alpha_f()); - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x + dx, - line_y + dy, - &p, - ); - } - CharAnimPreset::BlurIn => { - let tt = t.clamp(0.0, 1.0); - let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance; - let (dx, dy) = cfg.direction.offset(travel); - let sigma = ((1.0 - tt) * blur_radius).max(0.0); - let mut p = paint.clone(); - p.set_alpha_f(tt * paint.alpha_f()); - if sigma > 0.05 { - if let Some(filter) = skia_safe::image_filters::blur( - (sigma, sigma), - skia_safe::TileMode::Clamp, - None, - None, - ) { - p.set_image_filter(filter); - } - } - draw_text_with_fallback( - canvas, - text, - font, - emoji_font, - letter_spacing, - cursor_x + dx, - line_y + dy, - &p, - ); +impl Default for TextMorphConfig { + fn default() -> Self { + Self { + duration: default_morph_duration(), + unmatched: TextMorphUnmatched::default(), + seed: 0, } } } -fn render_char_animation( - canvas: &Canvas, - _content: &str, - font: &Font, - emoji_font: &Option, - paint: &Paint, - letter_spacing: f32, - align: TextAlign, - align_width: f32, - line_height_val: f32, - baseline_offset: f32, - lines: &[String], - char_anim: &ResolvedCharAnimation, - time: f64, -) { - let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word); - let mut global_unit_idx = 0usize; - - for (line_idx, line) in lines.iter().enumerate() { - if line.is_empty() { - continue; - } - - let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing); - let line_x = match align { - TextAlign::Left => 0.0, - TextAlign::Center => (align_width - advance_width) / 2.0, - TextAlign::Right => align_width - advance_width, - }; - let line_y = line_idx as f32 * line_height_val + baseline_offset; - - if is_word_mode { - let mut cursor_x = line_x; - let mut chars = line.chars().peekable(); - - while chars.peek().is_some() { - let mut spaces = String::new(); - while let Some(&c) = chars.peek() { - if c.is_whitespace() { - spaces.push(c); - chars.next(); - } else { - break; - } - } - if !spaces.is_empty() { - let space_w = - measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing); - draw_text_with_fallback( - canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint, - ); - cursor_x += space_w; - } - - let mut word = String::new(); - while let Some(&c) = chars.peek() { - if c.is_whitespace() { - break; - } - word.push(c); - chars.next(); - } - if word.is_empty() { - continue; - } - - let word_width = - measure_text_with_fallback(&word, font, emoji_font, letter_spacing); - - let t = unit_progress(char_anim, global_unit_idx, time); - - canvas.save(); - apply_text_anim_preset( - canvas, - &word, - font, - emoji_font, - paint, - cursor_x, - line_y, - word_width, - letter_spacing, - char_anim, - t, - time, - global_unit_idx, - font.size(), - ); - canvas.restore(); - - cursor_x += word_width; - global_unit_idx += 1; - } - } else { - let mut cursor_x = line_x; - for ch in line.chars() { - let ch_str = ch.to_string(); - let (ch_width, _) = font.measure_str(&ch_str, None); - let ch_width = ch_width + letter_spacing; - - let t = unit_progress(char_anim, global_unit_idx, time); - - canvas.save(); - apply_text_anim_preset( - canvas, - &ch_str, - font, - emoji_font, - paint, - cursor_x, - line_y, - ch_width, - 0.0, - char_anim, - t, - time, - global_unit_idx, - font.size(), - ); - canvas.restore(); - - cursor_x += ch_width; - global_unit_idx += 1; - } - } - } +fn default_morph_duration() -> f64 { + 0.6 } impl Text { @@ -432,6 +150,30 @@ impl Text { }) } + fn active_morph(&self, time: f64) -> Option { + let cfg = self.morph.as_ref()?; + if cfg.duration <= 0.0 { + return None; + } + let (idx, state) = self + .states + .iter() + .enumerate() + .find(|(_, s)| time >= s.at && time < s.at + cfg.duration)?; + let from = if idx == 0 { + self.content.clone() + } else { + self.states[idx - 1].content.clone() + }; + Some(ActiveMorph { + from, + to: state.content.clone(), + progress: ((time - state.at) / cfg.duration) as f32, + unmatched: cfg.unmatched, + seed: cfg.seed, + }) + } + fn paint( &self, canvas: &Canvas, @@ -591,9 +333,8 @@ impl Text { }; if let Some(ref resolved) = props.char_animation { - render_char_animation( + crate::intrinsic::render_char_animation( canvas, - &content, &font, &emoji_font, &paint, @@ -609,6 +350,24 @@ impl Text { return Ok(()); } + if let Some(morph) = self.active_morph(time) { + paint_morph( + canvas, + &morph, + &font, + &emoji_font, + &paint, + letter_spacing, + wrap_width, + align, + align_width, + line_height_val, + baseline_offset, + time, + ); + return Ok(()); + } + if let Some(swap) = self.active_swap(time) { for (label, offset_y, blur, alpha) in swap.labels() { let label_lines = @@ -788,6 +547,211 @@ impl ActiveSwap { } } +struct ActiveMorph { + from: String, + to: String, + progress: f32, + unmatched: TextMorphUnmatched, + seed: u32, +} + +fn smoothstep(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn char_positions( + lines: &[String], + font: &Font, + emoji_font: &Option, + letter_spacing: f32, + align: &TextAlign, + align_width: f32, + line_height_val: f32, + baseline_offset: f32, +) -> Vec<(f32, f32)> { + let mut out = Vec::new(); + for (line_idx, line) in lines.iter().enumerate() { + let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing); + let line_x = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - advance_width) / 2.0, + TextAlign::Right => align_width - advance_width, + }; + let y = line_idx as f32 * line_height_val + baseline_offset; + let mut cursor_x = line_x; + for ch in line.chars() { + out.push((cursor_x, y)); + let ch_str = ch.to_string(); + let ch_width = measure_text_with_fallback(&ch_str, font, emoji_font, letter_spacing); + cursor_x += ch_width; + } + } + out +} + +fn lcs_pairs(from: &[char], to: &[char]) -> Vec<(usize, usize)> { + let n = from.len(); + let m = to.len(); + let mut dp = vec![vec![0u32; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + dp[i][j] = if from[i] == to[j] { + dp[i + 1][j + 1] + 1 + } else { + dp[i + 1][j].max(dp[i][j + 1]) + }; + } + } + let mut pairs = Vec::new(); + let (mut i, mut j) = (0usize, 0usize); + while i < n && j < m { + if from[i] == to[j] { + pairs.push((i, j)); + i += 1; + j += 1; + } else if dp[i + 1][j] >= dp[i][j + 1] { + i += 1; + } else { + j += 1; + } + } + pairs +} + +const SCRAMBLE_POOL: &[char] = &[ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', + 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#', '%', + '&', '*', '?', +]; + +fn scrambled_char(seed: u32, idx: usize, time: f64) -> char { + let bucket = (time * 20.0) as u64; + let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (seed as u64); + h ^= bucket.wrapping_mul(0xD1B5_4A32_D192_ED03); + h ^= h >> 33; + h = h.wrapping_mul(0xFF51_AFD7_ED55_8CCD); + h ^= h >> 33; + let pool_idx = (h % SCRAMBLE_POOL.len() as u64) as usize; + SCRAMBLE_POOL[pool_idx] +} + +#[allow(clippy::too_many_arguments)] +fn paint_morph( + canvas: &Canvas, + morph: &ActiveMorph, + font: &Font, + emoji_font: &Option, + paint: &Paint, + letter_spacing: f32, + wrap_width: Option, + align: TextAlign, + align_width: f32, + line_height_val: f32, + baseline_offset: f32, + time: f64, +) { + let from_lines = + wrap_text_with_tracking(&morph.from, font, emoji_font, wrap_width, letter_spacing); + let to_lines = wrap_text_with_tracking(&morph.to, font, emoji_font, wrap_width, letter_spacing); + + let from_positions = char_positions( + &from_lines, + font, + emoji_font, + letter_spacing, + &align, + align_width, + line_height_val, + baseline_offset, + ); + let to_positions = char_positions( + &to_lines, + font, + emoji_font, + letter_spacing, + &align, + align_width, + line_height_val, + baseline_offset, + ); + + let from_chars: Vec = from_lines.iter().flat_map(|l| l.chars()).collect(); + let to_chars: Vec = to_lines.iter().flat_map(|l| l.chars()).collect(); + + let pairs = lcs_pairs(&from_chars, &to_chars); + let mut matched_from = vec![false; from_chars.len()]; + let mut matched_to = vec![false; to_chars.len()]; + for &(i, j) in &pairs { + matched_from[i] = true; + matched_to[j] = true; + } + + let p = smoothstep(morph.progress); + + for &(i, j) in &pairs { + let (fx, fy) = from_positions[i]; + let (tx, ty) = to_positions[j]; + let x = fx + (tx - fx) * p; + let y = fy + (ty - fy) * p; + draw_text_with_fallback( + canvas, + &to_chars[j].to_string(), + font, + emoji_font, + 0.0, + x, + y, + paint, + ); + } + + for (i, &(x, y)) in from_positions.iter().enumerate() { + if matched_from[i] { + continue; + } + let alpha = 1.0 - p; + if alpha <= 0.001 { + continue; + } + let mut ap = paint.clone(); + ap.set_alpha_f(alpha * paint.alpha_f()); + draw_text_with_fallback( + canvas, + &from_chars[i].to_string(), + font, + emoji_font, + 0.0, + x, + y, + &ap, + ); + } + + for (j, &(x, y)) in to_positions.iter().enumerate() { + if matched_to[j] { + continue; + } + let alpha = p; + if alpha <= 0.001 { + continue; + } + let mut ap = paint.clone(); + ap.set_alpha_f(alpha * paint.alpha_f()); + let glyph = match morph.unmatched { + TextMorphUnmatched::Fade => to_chars[j], + TextMorphUnmatched::Scramble => { + if p < 0.7 { + scrambled_char(morph.seed, j, time) + } else { + to_chars[j] + } + } + }; + draw_text_with_fallback(canvas, &glyph.to_string(), font, emoji_font, 0.0, x, y, &ap); + } +} + fn draw_caret( canvas: &Canvas, cfg: &CaretConfig, @@ -851,7 +815,7 @@ mod tests { use rustmotion_core::css::Length; use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; use rustmotion_core::schema::{ - AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection, + AnimationEffect, CharAnimationTiming, EasingType, TextAnimDirection, TextAnimGranularity, }; fn make_text(content: &str, white_space: Option) -> Text { @@ -873,6 +837,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, } } @@ -991,6 +956,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }; const W: i32 = 400; const H: i32 = 200; @@ -1027,6 +993,7 @@ mod tests { caret: None, states: Vec::new(), swap: None, + morph: None, }; const W: i32 = 400; const H: i32 = 200; @@ -1957,4 +1924,158 @@ mod tests { (mean green {settled:.1})" ); } + + fn morphing_text(from: &str, to: &str, morph: Option) -> Text { + let mut text = make_text(from, Some(CssWhiteSpace::Nowrap)); + text.style.font_size = Some(Length::Px(90.0)); + text.states = vec![TextState { + at: 1.0, + content: to.into(), + }]; + text.morph = morph; + text + } + + #[test] + fn lcs_pairs_matches_identical_characters_left_to_right() { + let from: Vec = "AX".chars().collect(); + let to: Vec = "YA".chars().collect(); + assert_eq!( + lcs_pairs(&from, &to), + vec![(0, 1)], + "the shared 'A' must be paired even though it moves from index 0 to index 1" + ); + } + + #[test] + fn scrambled_char_is_deterministic_and_varies_across_time() { + let a = scrambled_char(4, 2, 0.31); + let b = scrambled_char(4, 2, 0.31); + assert_eq!( + a, b, + "the same seed/index/time must always scramble to the same glyph" + ); + + let distinct: std::collections::HashSet = (0..30) + .map(|i| scrambled_char(4, 2, i as f64 * 0.05)) + .collect(); + assert!( + distinct.len() > 1, + "the scramble must vary as time advances, not freeze on one glyph" + ); + } + + #[test] + fn morph_looks_different_from_a_hard_cut_mid_transition() { + let morphed = morphing_text( + "AX", + "YA", + Some(TextMorphConfig { + duration: 1.0, + unmatched: TextMorphUnmatched::Fade, + seed: 0, + }), + ); + let cut = morphing_text("AX", "YA", None); + + assert_ne!( + render_plain(&morphed, 1.5), + render_plain(&cut, 1.5), + "50% through the morph, the frame must differ from an immediate hard cut to the \ + final label — the matched letter should still be travelling and the unmatched \ + letters should still be fading, not already fully settled" + ); + } + + #[test] + fn morph_keeps_progressing_between_two_instants_in_the_window() { + let morphed = morphing_text( + "AX", + "YA", + Some(TextMorphConfig { + duration: 1.0, + unmatched: TextMorphUnmatched::Fade, + seed: 0, + }), + ); + + assert_ne!( + render_plain(&morphed, 1.1), + render_plain(&morphed, 1.9), + "the morph must keep changing across its window, not snap to one position and hold" + ); + } + + #[test] + fn scramble_mode_paints_a_glyph_other_than_the_target_before_settling() { + let seed = 3u32; + let idx = 0usize; + let visibly_faded_in_but_before_the_settle_threshold = 20..60; + let sample_time = visibly_faded_in_but_before_the_settle_threshold + .map(|i| 1.0 + i as f64 * 0.01) + .find(|&t| scrambled_char(seed, idx, t) != 'B') + .expect("at least one sampled instant must scramble to something other than 'B'"); + + let fade = morphing_text( + "A", + "B", + Some(TextMorphConfig { + duration: 1.0, + unmatched: TextMorphUnmatched::Fade, + seed, + }), + ); + let scramble = morphing_text( + "A", + "B", + Some(TextMorphConfig { + duration: 1.0, + unmatched: TextMorphUnmatched::Scramble, + seed, + }), + ); + + const W: i32 = 400; + const H: i32 = 200; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let render = |text: &Text| -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + text.paint(surface.canvas(), W as f32, None, sample_time, &props, &ctx) + .expect("paint succeeds"); + alpha_grid(&mut surface, W, H) + }; + + let fade_grid = render(&fade); + let scramble_grid = render(&scramble); + + assert_ne!( + fade_grid, scramble_grid, + "at t={sample_time}, `unmatched: scramble` must paint a different glyph than \ + `unmatched: fade` — scramble is supposed to cycle through placeholder characters \ + instead of just fading the real one in" + ); + } + + #[test] + fn morph_settles_on_a_plain_cut_once_the_window_has_passed() { + let morphed = morphing_text( + "AX", + "YA", + Some(TextMorphConfig { + duration: 1.0, + unmatched: TextMorphUnmatched::Fade, + seed: 0, + }), + ); + let cut = morphing_text("AX", "YA", None); + + assert_eq!( + render_plain(&morphed, 2.0), + render_plain(&cut, 2.0), + "once the morph window has passed, the frame must match a plain cut to the final label" + ); + } } diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 990a25e..285ba2f 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -240,6 +240,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry - [rules/camera-3d.md](rules/camera-3d.md) - Tilt a whole shot with `camera.rotate_x`/`rotate_y`/`perspective`: one shared vanishing point, and rotation scaled by each plane's `style.depth` - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths +- [rules/char-animation-rich-and-gradient-text.md](rules/char-animation-rich-and-gradient-text.md) - `char_*` presets on `rich_text` and `gradient_text`: stagger across span boundaries, per-span `ink_from`, and why `ink_from` is inert on a gradient +- [rules/text-morph.md](rules/text-morph.md) - `text.morph`: matched letters travel, unmatched ones fade or scramble — how it differs from `text.states` + `swap` - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` - [rules/iris-transition.md](rules/iris-transition.md) - `iris` beyond a centred circle: `origin`, `shape`, `fill`+`hold`, `ring` and `reverse`, and the pill coverage approximation - [rules/whip-transition.md](rules/whip-transition.md) - The whip cut: a directional slide that streaks **both** frames along its axis, unlike `zoom_blur` which streaks only the outgoing one @@ -1861,7 +1863,7 @@ See Rule 13 for usage guidance. | 3D | `flip_in_x`, `flip_in_y`, `flip_out_x`, `flip_out_y` (3D card flip), `tilt_in` (3D tilt with rotate_x + rotate_y) | | Stroke | `draw_in` (animate `draw_progress` 0→1 for arrows/connectors/lines), `stroke_reveal` (draw_in + fade-in opacity over first 20%) | | Special | `typewriter`, `wipe_left`, `wipe_right` | -| Char (text only) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` (per-char/word animation, extra fields: `stagger`, `granularity`, `overshoot`) | +| Char (text, rich_text, gradient_text) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` (per-char/word animation, extra fields: `stagger`, `granularity`, `overshoot`) | #### Wiggle (Procedural Noise) diff --git a/crates/rustmotion/skills/rules/char-animation-rich-and-gradient-text.md b/crates/rustmotion/skills/rules/char-animation-rich-and-gradient-text.md new file mode 100644 index 0000000..7d685b9 --- /dev/null +++ b/crates/rustmotion/skills/rules/char-animation-rich-and-gradient-text.md @@ -0,0 +1,52 @@ +# Rule: les `char_*` fonctionnent aussi sur `rich_text` et `gradient_text` + +Les sept presets `char_*` (`char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up`, `char_blur_in`) ne sont plus réservés à `text`. Posés dans `style.animation` de `rich_text` ou de `gradient_text`, ils animent désormais réellement les glyphes — avant ce correctif, ils étaient acceptés sans erreur et **n'avaient aucun effet** : le composant peignait son texte complet, net, dès la première frame. Voir [rules/char-animation-tuning.md](char-animation-tuning.md) pour le réglage commun (`direction`, `distance`, `scale_from`, `jitter`+`seed`, `ink_from`, `blur`) — il s'applique tel quel aux trois composants. + +```json +{ "type": "rich_text", "spans": [ + { "text": "Rich ", "color": "#1B1B25" }, + { "text": "words", "color": "#1D5FD8" } + ], "style": { "font-size": 64, + "animation": [{ "name": "char_fade_in", "stagger": 0.05, "duration": 0.3, "delay": 0.5 }] } } +``` + +## `rich_text` : l'unité court à travers toute la phrase, pas span par span + +En mode `"granularity": "word"`, l'indexation d'unité (donc le `stagger`) traverse **tous** les spans dans l'ordre de lecture, pas seulement le span courant. Un `rich_text` de deux spans formant "ONE TWO" avec `stagger: 0.6` retarde le second mot exactement comme un `text` unique "ONE TWO" l'aurait fait — découper la phrase en spans pour la colorer ne change pas le calendrier de l'animation. + +En mode `"char"`, les espaces entre mots ne consomment pas de créneau d'unité (ils ne sont de toute façon jamais dessinés comme glyphe séparé dans `rich_text` — leur largeur est déjà absorbée dans le positionnement du mot suivant). + +## `rich_text` : `ink_from` converge vers la couleur **propre** du span + +Chaque unité anime avec la police et la couleur de **son** span. `ink_from` (couleur de départ) converge donc vers la couleur finale de ce span précis, pas vers une couleur par défaut partagée : + +```json +{ "type": "rich_text", "spans": [ + { "text": "AAAA", "color": "#00FF00" }, + { "text": " BBBB", "color": "#0000FF" } + ], "style": { + "animation": [{ "name": "char_fade_in", "granularity": "word", "ink_from": "#FF0000" }] } } +``` + +Les deux mots partent du même rouge et convergent chacun vers sa propre couleur (vert, puis bleu) — pas vers une troisième couleur commune. + +Les fonds de pilule (`background` sur un span, voir [rules/rich-text-pills.md](rich-text-pills.md)) restent peints normalement, sans animation, pendant qu'un `char_*` anime les glyphes par-dessus. + +## `gradient_text` : le dégradé reste celui de la phrase entière + +Découper le texte en mots ou en caractères animés **ne relance jamais la rampe** pour chaque unité. Le dégradé est calculé une seule fois sur l'étendue du texte complet, avant tout découpage ; chaque unité se contente de peindre sa portion de glyphes avec ce même dégradé, à sa position réelle. Un texte "IIII IIII IIII IIII" avec `char_fade_in` en mode mot garde donc son premier mot proche de la première couleur et son dernier mot proche de la dernière, exactement comme sans animation. + +```json +{ "type": "gradient_text", "content": "Build it faster today", + "colors": ["#FF4FB0", "#4B5BFF"], + "style": { "font-size": 64, + "animation": [{ "name": "char_blur_in", "granularity": "word", "stagger": 0.08 }] } } +``` + +## `gradient_text` : `ink_from` n'a pas d'effet visible + +`ink_from` fonctionne en teintant le `Paint` du texte — sur `text` et `rich_text`, ce `Paint` porte une couleur unie, donc la teinte se voit. Sur `gradient_text`, le `Paint` porte un **shader** (le dégradé) : le shader l'emporte toujours sur la composante couleur du `Paint`, donc `ink_from` ne change rien au rendu (seule son influence sur l'alpha survit, imperceptible). Ce n'est pas un oubli à corriger silencieusement en aval : c'est une conséquence directe du fait que la teinte d'un dégradé n'a pas de valeur "de départ" unique et cohérente à faire converger. Les six autres réglages (position, échelle, rotation, flou, alpha) fonctionnent normalement. + +## Ce qui n'a pas bougé + +`rotate_from`, le bruit indépendant par unité (`scale_jitter`/`baseline_jitter` distincts du décalage de `stagger`), et le reflow animé (`reflow`) demandés par ailleurs restent absents des trois composants — `jitter`+`seed` continuent de ne régler que l'irrégularité du **calendrier** de départ, pas la géométrie de chaque unité. Aucun de ces noms n'existe dans le schéma : les déclarer serait accepté puis ignoré, exactement le défaut que ce correctif referme pour les sept presets eux-mêmes. diff --git a/crates/rustmotion/skills/rules/text-morph.md b/crates/rustmotion/skills/rules/text-morph.md new file mode 100644 index 0000000..e339499 --- /dev/null +++ b/crates/rustmotion/skills/rules/text-morph.md @@ -0,0 +1,38 @@ +# Rule: `text.morph` — une transition lettre par lettre entre deux labels + +`text.states` + `swap` (voir [rules/text-polish.md](text-polish.md)) traite chaque label comme un bloc rigide : l'ancien monte en se floutant, le nouveau monte du bas en se nettant. `morph` traite le label comme un sac de glyphes qui se réarrange — les caractères identiques glissent de leur ancienne position vers leur nouvelle, les autres apparaissent ou disparaissent sur place. + +```json +{ "type": "text", "content": "On sait très bien que", + "states": [{ "at": 1.2, "content": "tu les griffonnes" }], + "morph": { "duration": 0.6, "unmatched": "scramble", "seed": 2 }, + "style": { "font-size": 64, "white-space": "nowrap" } } +``` + +C'est un champ **séparé** de `swap`, pas un mode de celui-ci — poser les deux en même temps n'a pas de sens : `morph` prend la fenêtre de transition dès qu'il s'applique, `swap` n'est consulté qu'en dehors. + +## L'appariement : plus long sous-motif commun, de gauche à droite + +Les caractères identiques entre le label sortant et le label entrant sont appariés par plus longue sous-séquence commune (LCS) — le même algorithme qu'un diff de texte. Un caractère apparié **glisse** de sa position dans l'ancien label vers sa position dans le nouveau ; il reste à pleine opacité pendant tout le trajet, seule sa position interpole. Un caractère qui n'a pas de partenaire (présent dans un seul des deux labels) ne glisse jamais : il s'estompe sur place. + +```json +{ "type": "text", "content": "AX", "states": [{ "at": 1.0, "content": "YA" }], + "morph": { "duration": 1.0 } } +``` + +Ici le seul `A` commun glisse de sa position dans `"AX"` vers sa position dans `"YA"` ; le `X` sortant s'efface sur place, le `Y` entrant apparaît sur place — trois trajectoires indépendantes, pas un bloc qui se translate. + +## `unmatched` : comment les glyphes sans partenaire arrivent + +| Valeur | Comportement | +|---|---| +| `fade` (défaut) | Le glyphe entrant apparaît directement en fondu — c'est le glyphe final du premier au dernier instant, seule son opacité change. | +| `scramble` | Le glyphe entrant cycle à travers des caractères aléatoires (déterministes, dérivés de `seed`) pendant la première partie de la transition, puis se fixe sur le vrai glyphe pour la finir. | + +`scramble` ne s'applique qu'aux glyphes **entrants** sans partenaire : un glyphe sortant n'a pas de "caractère final" vers lequel se fixer, il s'estompe donc toujours comme en mode `fade`. + +`seed` ne pilote que la séquence de caractères de remplacement — deux morphs avec le même `seed` scramblent identiquement ; il n'a aucun effet en mode `fade`. + +## Ce que `morph` ne fait pas + +Il ignore `visible_chars_progress` (un `typewriter` en cours) exactement comme `swap` — les deux mécanismes agissent sur le label complet, pas sur une révélation progressive en cours. Il ne wrap pas différemment de la mise en page normale du composant : chaque label est réenveloppé (`wrap_text_with_tracking`) séparément avec le même `style`, donc un caractère apparié peut glisser en diagonale s'il change de ligne entre les deux labels — un mouvement plausible, pas un artefact à corriger.