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)]
45pub struct Tooltip {
46    pub text: String,
47    #[serde(default)]
48    pub arrow: TooltipArrow,
49    #[serde(default = "default_font_size")]
50    pub font_size: f32,
51    #[serde(default = "default_bg_color")]
52    pub background_color: String,
53    #[serde(default = "default_text_color")]
54    pub text_color: String,
55    #[serde(default = "default_arrow_size")]
56    pub arrow_size: f32,
57    #[serde(default)]
58    pub border_color: Option<String>,
59    #[serde(flatten)]
60    pub timing: TimingConfig,
61    #[serde(default)]
62    pub style: CssStyle,
63    #[serde(default)]
64    pub timeline: Vec<TimelineStep>,
65    #[serde(default)]
66    pub stagger: Option<f32>,
67}
68
69rustmotion_core::impl_traits!(Tooltip {
70    Animatable => animation,
71    Timed => timing,
72    Styled => style,
73});
74
75impl Tooltip {
76    /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/
77    /// `vh` now resolve instead of silently dropping to 0px — lot B, wave
78    /// S). `em`/`%` on `font-size` itself remain approximate — see
79    /// `crate::intrinsic::font_size_ctx`'s doc comment.
80    fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 {
81        self.style.font_size_px_ctx(
82            &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
83            self.font_size,
84        )
85    }
86
87    fn make_font(&self, ctx: &PaintCtx) -> Option<skia_safe::Font> {
88        let fs = self.resolved_font_size(ctx);
89        let font_style = skia_safe::FontStyle::normal();
90        let family = self.style.font_family_or("Inter");
91        let typeface = typeface_with_fallback(family, font_style).ok()?;
92        Some(skia_safe::Font::from_typeface(typeface, fs))
93    }
94}
95
96impl Tooltip {
97    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) {
98        let w = layout_w;
99        let h = layout_h;
100        let bg_color = self
101            .style
102            .background_color_str()
103            .unwrap_or(&self.background_color);
104        let radius = self.style.border_radius_px_or(8.0);
105        let arrow_sz = self.arrow_size;
106
107        // Compute body rect (excluding arrow area)
108        let (body_x, body_y, body_w, body_h) = match self.arrow {
109            TooltipArrow::Bottom => (0.0, 0.0, w, h - arrow_sz),
110            TooltipArrow::Top => (0.0, arrow_sz, w, h - arrow_sz),
111            TooltipArrow::Right => (0.0, 0.0, w - arrow_sz, h),
112            TooltipArrow::Left => (arrow_sz, 0.0, w - arrow_sz, h),
113            TooltipArrow::None => (0.0, 0.0, w, h),
114        };
115
116        // Body rounded rect
117        let body_rect = Rect::from_xywh(body_x, body_y, body_w, body_h);
118        let body_rrect = skia_safe::RRect::new_rect_xy(body_rect, radius, radius);
119
120        let mut bg_paint = paint_from_hex(bg_color);
121        bg_paint.set_style(PaintStyle::Fill);
122        bg_paint.set_anti_alias(true);
123        canvas.draw_rrect(body_rrect, &bg_paint);
124
125        // Border
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        // Arrow triangle
135        if !matches!(self.arrow, TooltipArrow::None) {
136            let mut arrow_path = PathBuilder::new();
137            match self.arrow {
138                TooltipArrow::Bottom => {
139                    let cx = body_x + body_w / 2.0;
140                    let ay = body_y + body_h;
141                    arrow_path.move_to((cx - arrow_sz, ay));
142                    arrow_path.line_to((cx, ay + arrow_sz));
143                    arrow_path.line_to((cx + arrow_sz, ay));
144                    arrow_path.close();
145                }
146                TooltipArrow::Top => {
147                    let cx = body_x + body_w / 2.0;
148                    let ay = body_y;
149                    arrow_path.move_to((cx - arrow_sz, ay));
150                    arrow_path.line_to((cx, ay - arrow_sz));
151                    arrow_path.line_to((cx + arrow_sz, ay));
152                    arrow_path.close();
153                }
154                TooltipArrow::Right => {
155                    let cy = body_y + body_h / 2.0;
156                    let ax = body_x + body_w;
157                    arrow_path.move_to((ax, cy - arrow_sz));
158                    arrow_path.line_to((ax + arrow_sz, cy));
159                    arrow_path.line_to((ax, cy + arrow_sz));
160                    arrow_path.close();
161                }
162                TooltipArrow::Left => {
163                    let cy = body_y + body_h / 2.0;
164                    let ax = body_x;
165                    arrow_path.move_to((ax, cy - arrow_sz));
166                    arrow_path.line_to((ax - arrow_sz, cy));
167                    arrow_path.line_to((ax, cy + arrow_sz));
168                    arrow_path.close();
169                }
170                TooltipArrow::None => {}
171            }
172            canvas.draw_path(&arrow_path.detach(), &bg_paint);
173        }
174
175        // Text centered in body
176        let Some(font) = self.make_font(ctx) else {
177            return;
178        };
179        let fs = self.resolved_font_size(ctx);
180        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, fs));
181
182        let text_color = self.style.color_str().unwrap_or(&self.text_color);
183        let mut text_paint = paint_from_hex(text_color);
184        text_paint.set_anti_alias(true);
185
186        let text_w = measure_text_with_fallback(&self.text, &font, &emoji_font, 0.0);
187        let (_, metrics) = font.metrics();
188        let text_x = body_x + (body_w - text_w) / 2.0;
189        let text_y = body_y + (body_h + (-metrics.ascent)) / 2.0;
190
191        draw_text_with_fallback(
192            canvas,
193            &self.text,
194            &font,
195            &emoji_font,
196            0.0,
197            text_x,
198            text_y,
199            &text_paint,
200        );
201    }
202}
203
204impl Painter for Tooltip {
205    fn paint_content(
206        &self,
207        canvas: &Canvas,
208        layout: &BoxLayout,
209        _props: &AnimatedProperties,
210        ctx: &PaintCtx,
211    ) {
212        self.paint(canvas, layout.width, layout.height, ctx);
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use rustmotion_core::css::CssStyle;
220    use rustmotion_core::css::Length;
221
222    fn test_ctx() -> PaintCtx {
223        PaintCtx {
224            time: 0.0,
225            scenario_time: 0.0,
226            scene_duration: 1.0,
227            frame_index: 0,
228            fps: 30,
229            video_width: 400,
230            video_height: 200,
231            stagger_offset: 0.0,
232        }
233    }
234
235    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
236
237    #[test]
238    fn rem_font_size_paints_visible_ink() {
239        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
240        // context-free `font_size_px_or`.
241        let tooltip = Tooltip {
242            text: "hello".to_string(),
243            arrow: TooltipArrow::None,
244            font_size: default_font_size(),
245            background_color: default_bg_color(),
246            text_color: default_text_color(),
247            arrow_size: default_arrow_size(),
248            border_color: None,
249            timing: Default::default(),
250            style: CssStyle {
251                font_size: Some(Length::String("2rem".into())),
252                ..Default::default()
253            },
254            timeline: Vec::new(),
255            stagger: None,
256        };
257        const W: i32 = 200;
258        const H: i32 = 100;
259        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
260        {
261            let canvas = surface.canvas();
262            tooltip.paint(canvas, 150.0, 60.0, &test_ctx());
263        }
264        let snapshot = surface.image_snapshot();
265        let info = skia_safe::ImageInfo::new(
266            (W, H),
267            skia_safe::ColorType::RGBA8888,
268            skia_safe::AlphaType::Premul,
269            None,
270        );
271        let mut buf = vec![0u8; (W * H * 4) as usize];
272        let ok = snapshot.read_pixels(
273            &info,
274            &mut buf,
275            (W * 4) as usize,
276            skia_safe::IPoint::new(0, 0),
277            skia_safe::image::CachingHint::Disallow,
278        );
279        assert!(ok, "pixel read should succeed");
280        // Text is near-white (#E2E8F0 default) on a dark #1E293B body —
281        // probe for near-white ink specifically.
282        let text_ink = buf
283            .chunks_exact(4)
284            .filter(|p| p[3] > 0 && p[0] > 180 && p[1] > 180 && p[2] > 180)
285            .count();
286        assert!(
287            text_ink > 5,
288            "tooltip at font-size: 2rem must paint visible text, got {text_ink} pixels"
289        );
290    }
291}