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)]
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 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 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 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 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) {
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 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 #[test]
238 fn rem_font_size_paints_visible_ink() {
239 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 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}