Skip to main content

rustmotion_components/
callout.rs

1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, PaintStyle, Path, PathBuilder, RRect, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10    paint_from_hex, subpixel_font, typeface_with_fallback, wrap_text,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "snake_case")]
17#[derive(Default)]
18pub enum ArrowDirection {
19    Top,
20    #[default]
21    Bottom,
22    Left,
23    Right,
24}
25
26/// Speech bubble with a directional arrow.
27///
28/// CSS-like properties go in `style`:
29/// - `style.background` — bubble background color (default: `"#333333"`)
30/// - `style.color` — text color (default: `"#FFFFFF"`)
31/// - `style.border-radius` — corner radius (default: `8`)
32/// - `style.font-size` — text size (default: `16`)
33#[derive(Debug, Serialize, Deserialize, JsonSchema)]
34#[deprecated(
35    since = "0.7.1",
36    note = "`callout` is a frozen composition (issue #333), the same recipe as `tooltip`: \
37            compose a small rounded `card` + a rotated triangle `shape` (the arrow) + `text` \
38            instead — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
39            compatibility; scheduled for removal in a future major version via \
40            `rustmotion migrate` (#335)."
41)]
42pub struct Callout {
43    pub text: String,
44    #[serde(default)]
45    pub arrow_direction: ArrowDirection,
46    #[serde(default = "default_arrow_size")]
47    pub arrow_size: f32,
48    #[serde(flatten)]
49    pub timing: TimingConfig,
50    #[serde(default)]
51    pub style: CssStyle,
52    #[serde(default)]
53    pub timeline: Vec<TimelineStep>,
54    #[serde(default)]
55    pub stagger: Option<f32>,
56}
57
58fn default_arrow_size() -> f32 {
59    12.0
60}
61
62rustmotion_core::impl_traits!(Callout {
63    Animatable => animation,
64    Timed => timing,
65    Styled => style,
66});
67
68impl Callout {
69    fn bg_color(&self) -> &str {
70        self.style.background_color_str().unwrap_or("#333333")
71    }
72
73    fn text_color(&self) -> &str {
74        self.style.color_str_or("#FFFFFF")
75    }
76
77    fn radius(&self) -> f32 {
78        self.style.border_radius_px_or(8.0)
79    }
80
81    fn font_size(&self, ctx: &PaintCtx) -> f32 {
82        self.style.font_size_px_ctx(
83            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
84            16.0,
85        )
86    }
87
88    fn bubble_rect(&self, w: f32, h: f32) -> Rect {
89        match self.arrow_direction {
90            ArrowDirection::Top => Rect::from_xywh(0.0, self.arrow_size, w, h - self.arrow_size),
91            ArrowDirection::Bottom => Rect::from_xywh(0.0, 0.0, w, h - self.arrow_size),
92            ArrowDirection::Left => Rect::from_xywh(self.arrow_size, 0.0, w - self.arrow_size, h),
93            ArrowDirection::Right => Rect::from_xywh(0.0, 0.0, w - self.arrow_size, h),
94        }
95    }
96
97    fn arrow_path(&self, w: f32, h: f32) -> Path {
98        let mut path = PathBuilder::new();
99        let a = self.arrow_size;
100        let overlap = 1.0;
101
102        match self.arrow_direction {
103            ArrowDirection::Bottom => {
104                let cx = w / 2.0;
105                let top = h - a - overlap;
106                path.move_to((cx - a, top));
107                path.line_to((cx, h));
108                path.line_to((cx + a, top));
109                path.close();
110            }
111            ArrowDirection::Top => {
112                let cx = w / 2.0;
113                let bottom = a + overlap;
114                path.move_to((cx - a, bottom));
115                path.line_to((cx, 0.0));
116                path.line_to((cx + a, bottom));
117                path.close();
118            }
119            ArrowDirection::Left => {
120                let cy = h / 2.0;
121                let right = a + overlap;
122                path.move_to((right, cy - a));
123                path.line_to((0.0, cy));
124                path.line_to((right, cy + a));
125                path.close();
126            }
127            ArrowDirection::Right => {
128                let cy = h / 2.0;
129                let left = w - a - overlap;
130                path.move_to((left, cy - a));
131                path.line_to((w, cy));
132                path.line_to((left, cy + a));
133                path.close();
134            }
135        }
136
137        path.detach()
138    }
139}
140
141impl Callout {
142    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) -> Result<()> {
143        let w = layout_w;
144        let h = layout_h;
145        let radius = self.radius();
146        let font_size = self.font_size(ctx);
147
148        let bubble = self.bubble_rect(w, h);
149        let rrect = RRect::new_rect_xy(bubble, radius, radius);
150        let mut bg_paint = paint_from_hex(self.bg_color());
151        bg_paint.set_style(PaintStyle::Fill);
152        bg_paint.set_anti_alias(true);
153        canvas.draw_rrect(rrect, &bg_paint);
154
155        let arrow = self.arrow_path(w, h);
156        canvas.draw_path(&arrow, &bg_paint);
157
158        let font_style = skia_safe::FontStyle::normal();
159        let family = self.style.font_family.as_deref().unwrap_or("Inter");
160        let typeface = typeface_with_fallback(family, font_style)?;
161
162        let font = subpixel_font(typeface, font_size);
163        let (_, metrics) = font.metrics();
164        let ascent = -metrics.ascent;
165        let line_height = font_size * 1.4;
166
167        let padding = 12.0;
168        let text_area_x = bubble.left + padding;
169        let text_area_w = bubble.width() - padding * 2.0;
170
171        let lines = wrap_text(&self.text, &font, Some(text_area_w));
172        let total_text_h = lines.len() as f32 * line_height;
173        let text_y_start = bubble.top + (bubble.height() - total_text_h) / 2.0 + ascent;
174
175        let mut text_paint = paint_from_hex(self.text_color());
176        text_paint.set_anti_alias(true);
177
178        for (i, line) in lines.iter().enumerate() {
179            if line.is_empty() {
180                continue;
181            }
182            if let Some(blob) = skia_safe::TextBlob::new(line, &font) {
183                let blob_w = blob.bounds().width();
184                let x = text_area_x + (text_area_w - blob_w) / 2.0;
185                let y = text_y_start + i as f32 * line_height;
186                canvas.draw_text_blob(&blob, (x, y), &text_paint);
187            }
188        }
189
190        Ok(())
191    }
192}
193
194impl Painter for Callout {
195    fn paint_content(
196        &self,
197        canvas: &Canvas,
198        layout: &BoxLayout,
199        _props: &AnimatedProperties,
200        ctx: &PaintCtx,
201    ) {
202        let _ = self.paint(canvas, layout.width, layout.height, ctx);
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use rustmotion_core::css::CssStyle;
210    use rustmotion_core::css::Length;
211
212    fn test_ctx() -> PaintCtx {
213        PaintCtx {
214            time: 0.0,
215            scenario_time: 0.0,
216            scene_duration: 1.0,
217            frame_index: 0,
218            fps: 30,
219            video_width: 400,
220            video_height: 200,
221            stagger_offset: 0.0,
222        }
223    }
224
225    #[test]
226    fn rem_font_size_paints_visible_ink() {
227        let callout = Callout {
228            text: "hello".to_string(),
229            arrow_direction: ArrowDirection::default(),
230            arrow_size: default_arrow_size(),
231            timing: Default::default(),
232            style: CssStyle {
233                font_size: Some(Length::String("2rem".into())),
234                ..Default::default()
235            },
236            timeline: Vec::new(),
237            stagger: None,
238        };
239        const W: i32 = 400;
240        const H: i32 = 200;
241        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
242        {
243            let canvas = surface.canvas();
244            callout
245                .paint(canvas, W as f32, H as f32, &test_ctx())
246                .expect("paint succeeds");
247        }
248        let snapshot = surface.image_snapshot();
249        let info = skia_safe::ImageInfo::new(
250            (W, H),
251            skia_safe::ColorType::RGBA8888,
252            skia_safe::AlphaType::Premul,
253            None,
254        );
255        let mut buf = vec![0u8; (W * H * 4) as usize];
256        let ok = snapshot.read_pixels(
257            &info,
258            &mut buf,
259            (W * 4) as usize,
260            skia_safe::IPoint::new(0, 0),
261            skia_safe::image::CachingHint::Disallow,
262        );
263        assert!(ok, "pixel read should succeed");
264        let text_ink = buf
265            .as_chunks::<4>()
266            .0
267            .iter()
268            .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
269            .count();
270        assert!(
271            text_ink > 10,
272            "callout at font-size: 2rem must paint visible text, got {text_ink} pixels"
273        );
274    }
275}