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#[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}