Skip to main content

rustmotion_components/
tooltip.rs

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