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