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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
32pub struct Callout {
33 pub text: String,
34 #[serde(default)]
35 pub arrow_direction: ArrowDirection,
36 #[serde(default = "default_arrow_size")]
37 pub arrow_size: f32,
38 #[serde(flatten)]
39 pub timing: TimingConfig,
40 #[serde(default)]
41 pub style: CssStyle,
42 #[serde(default)]
43 pub timeline: Vec<TimelineStep>,
44 #[serde(default)]
45 pub stagger: Option<f32>,
46}
47
48fn default_arrow_size() -> f32 {
49 12.0
50}
51
52rustmotion_core::impl_traits!(Callout {
53 Animatable => animation,
54 Timed => timing,
55 Styled => style,
56});
57
58impl Callout {
59 fn bg_color(&self) -> &str {
60 self.style.background_color_str().unwrap_or("#333333")
61 }
62
63 fn text_color(&self) -> &str {
64 self.style.color_str_or("#FFFFFF")
65 }
66
67 fn radius(&self) -> f32 {
68 self.style.border_radius_px_or(8.0)
69 }
70
71 fn font_size(&self, ctx: &PaintCtx) -> f32 {
76 self.style.font_size_px_ctx(
77 &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
78 16.0,
79 )
80 }
81
82 fn bubble_rect(&self, w: f32, h: f32) -> Rect {
83 match self.arrow_direction {
84 ArrowDirection::Top => Rect::from_xywh(0.0, self.arrow_size, w, h - self.arrow_size),
85 ArrowDirection::Bottom => Rect::from_xywh(0.0, 0.0, w, h - self.arrow_size),
86 ArrowDirection::Left => Rect::from_xywh(self.arrow_size, 0.0, w - self.arrow_size, h),
87 ArrowDirection::Right => Rect::from_xywh(0.0, 0.0, w - self.arrow_size, h),
88 }
89 }
90
91 fn arrow_path(&self, w: f32, h: f32) -> Path {
92 let mut path = PathBuilder::new();
93 let a = self.arrow_size;
94 let overlap = 1.0;
96
97 match self.arrow_direction {
98 ArrowDirection::Bottom => {
99 let cx = w / 2.0;
100 let top = h - a - overlap;
101 path.move_to((cx - a, top));
102 path.line_to((cx, h));
103 path.line_to((cx + a, top));
104 path.close();
105 }
106 ArrowDirection::Top => {
107 let cx = w / 2.0;
108 let bottom = a + overlap;
109 path.move_to((cx - a, bottom));
110 path.line_to((cx, 0.0));
111 path.line_to((cx + a, bottom));
112 path.close();
113 }
114 ArrowDirection::Left => {
115 let cy = h / 2.0;
116 let right = a + overlap;
117 path.move_to((right, cy - a));
118 path.line_to((0.0, cy));
119 path.line_to((right, cy + a));
120 path.close();
121 }
122 ArrowDirection::Right => {
123 let cy = h / 2.0;
124 let left = w - a - overlap;
125 path.move_to((left, cy - a));
126 path.line_to((w, cy));
127 path.line_to((left, cy + a));
128 path.close();
129 }
130 }
131
132 path.detach()
133 }
134}
135
136impl Callout {
137 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) -> Result<()> {
138 let w = layout_w;
139 let h = layout_h;
140 let radius = self.radius();
141 let font_size = self.font_size(ctx);
142
143 let bubble = self.bubble_rect(w, h);
145 let rrect = RRect::new_rect_xy(bubble, radius, radius);
146 let mut bg_paint = paint_from_hex(self.bg_color());
147 bg_paint.set_style(PaintStyle::Fill);
148 bg_paint.set_anti_alias(true);
149 canvas.draw_rrect(rrect, &bg_paint);
150
151 let arrow = self.arrow_path(w, h);
153 canvas.draw_path(&arrow, &bg_paint);
154
155 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]
226 fn rem_font_size_paints_visible_ink() {
227 let callout = Callout {
230 text: "hello".to_string(),
231 arrow_direction: ArrowDirection::default(),
232 arrow_size: default_arrow_size(),
233 timing: Default::default(),
234 style: CssStyle {
235 font_size: Some(Length::String("2rem".into())),
236 ..Default::default()
237 },
238 timeline: Vec::new(),
239 stagger: None,
240 };
241 const W: i32 = 400;
242 const H: i32 = 200;
243 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
244 {
245 let canvas = surface.canvas();
246 callout
247 .paint(canvas, W as f32, H as f32, &test_ctx())
248 .expect("paint succeeds");
249 }
250 let snapshot = surface.image_snapshot();
251 let info = skia_safe::ImageInfo::new(
252 (W, H),
253 skia_safe::ColorType::RGBA8888,
254 skia_safe::AlphaType::Premul,
255 None,
256 );
257 let mut buf = vec![0u8; (W * H * 4) as usize];
258 let ok = snapshot.read_pixels(
259 &info,
260 &mut buf,
261 (W * 4) as usize,
262 skia_safe::IPoint::new(0, 0),
263 skia_safe::image::CachingHint::Disallow,
264 );
265 assert!(ok, "pixel read should succeed");
266 let text_ink = buf
270 .chunks_exact(4)
271 .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
272 .count();
273 assert!(
274 text_ink > 10,
275 "callout at font-size: 2rem must paint visible text, got {text_ink} pixels"
276 );
277 }
278}