1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, Rect};
4
5use rustmotion_core::css::style::AlignSelf;
6use rustmotion_core::css::CssStyle;
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
11 typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_font_size() -> f32 {
17 14.0
18}
19
20fn default_bg_color() -> String {
21 "#1E293B".to_string()
22}
23
24fn default_border_color() -> String {
25 "#475569".to_string()
26}
27
28fn default_text_color() -> String {
29 "#E2E8F0".to_string()
30}
31
32#[derive(Debug, Serialize, Deserialize, JsonSchema)]
33pub struct Kbd {
34 pub key: String,
35 #[serde(default = "default_font_size")]
36 pub font_size: f32,
37 #[serde(default = "default_bg_color")]
38 pub background_color: String,
39 #[serde(default = "default_border_color")]
40 pub border_color: String,
41 #[serde(default = "default_text_color")]
42 pub text_color: String,
43 #[serde(flatten)]
44 pub timing: TimingConfig,
45 #[serde(
52 default = "default_kbd_style",
53 deserialize_with = "deserialize_no_stretch_style"
54 )]
55 pub style: CssStyle,
56 #[serde(default)]
57 pub timeline: Vec<TimelineStep>,
58 #[serde(default)]
59 pub stagger: Option<f32>,
60}
61
62fn default_kbd_style() -> CssStyle {
63 CssStyle {
64 align_self: Some(AlignSelf::FlexStart),
65 ..CssStyle::default()
66 }
67}
68
69fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result<CssStyle, D::Error>
73where
74 D: serde::Deserializer<'de>,
75{
76 let mut style = CssStyle::deserialize(deserializer)?;
77 if style.align_self.is_none() {
78 style.align_self = Some(AlignSelf::FlexStart);
79 }
80 Ok(style)
81}
82
83rustmotion_core::impl_traits!(Kbd {
84 Animatable => animation,
85 Timed => timing,
86 Styled => style,
87});
88
89impl Kbd {
90 fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 {
95 self.style.font_size_px_ctx(
96 &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
97 self.font_size,
98 )
99 }
100
101 fn make_font(&self, ctx: &PaintCtx) -> Option<skia_safe::Font> {
102 let fs = self.resolved_font_size(ctx);
103 let font_style = skia_safe::FontStyle::normal();
104 let family = self.style.font_family.as_deref().unwrap_or("SF Mono");
105 let typeface = typeface_with_fallback(family, font_style).ok()?;
106 Some(skia_safe::Font::from_typeface(typeface, fs))
107 }
108}
109
110impl Kbd {
111 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) {
112 let w = layout_w;
113 let h = layout_h;
114 let radius = 6.0;
115
116 let bg_color = self
117 .style
118 .background_color_str()
119 .unwrap_or(&self.background_color);
120
121 let shadow_h = 3.0_f32.min(h);
128 let cap_h = (h - shadow_h).max(0.0);
129 let shadow_rect = Rect::from_xywh(0.0, shadow_h, w, cap_h);
130 let shadow_rrect = skia_safe::RRect::new_rect_xy(shadow_rect, radius, radius);
131 let mut shadow_paint = paint_from_hex(&self.border_color);
132 shadow_paint.set_style(PaintStyle::Fill);
133 shadow_paint.set_anti_alias(true);
134 canvas.draw_rrect(shadow_rrect, &shadow_paint);
135
136 let face_rect = Rect::from_xywh(0.0, 0.0, w, cap_h);
138 let face_rrect = skia_safe::RRect::new_rect_xy(face_rect, radius, radius);
139 let mut face_paint = paint_from_hex(bg_color);
140 face_paint.set_style(PaintStyle::Fill);
141 face_paint.set_anti_alias(true);
142 canvas.draw_rrect(face_rrect, &face_paint);
143
144 let mut border_paint = paint_from_hex(&self.border_color);
146 border_paint.set_style(PaintStyle::Stroke);
147 border_paint.set_stroke_width(1.0);
148 border_paint.set_anti_alias(true);
149 canvas.draw_rrect(face_rrect, &border_paint);
150
151 let Some(font) = self.make_font(ctx) else {
153 return;
154 };
155 let fs = self.resolved_font_size(ctx);
156 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, fs));
157
158 let text_color = self.style.color_str().unwrap_or(&self.text_color);
159 let mut text_paint = paint_from_hex(text_color);
160 text_paint.set_anti_alias(true);
161
162 let text_w = measure_text_with_fallback(&self.key, &font, &emoji_font, 0.0);
163 let (_, metrics) = font.metrics();
164 let text_x = (w - text_w) / 2.0;
165 let text_y = (cap_h + (-metrics.ascent)) / 2.0;
166
167 draw_text_with_fallback(
168 canvas,
169 &self.key,
170 &font,
171 &emoji_font,
172 0.0,
173 text_x,
174 text_y,
175 &text_paint,
176 );
177 }
178}
179
180impl Painter for Kbd {
181 fn paint_content(
182 &self,
183 canvas: &Canvas,
184 layout: &BoxLayout,
185 _props: &AnimatedProperties,
186 ctx: &PaintCtx,
187 ) {
188 self.paint(canvas, layout.width, layout.height, ctx);
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn parse(json: &str) -> Kbd {
197 serde_json::from_str(json).expect("kbd should deserialize")
198 }
199
200 fn test_ctx() -> PaintCtx {
201 PaintCtx {
202 time: 0.0,
203 scenario_time: 0.0,
204 scene_duration: 1.0,
205 frame_index: 0,
206 fps: 30,
207 video_width: 400,
208 video_height: 200,
209 stagger_offset: 0.0,
210 }
211 }
212
213 #[test]
214 fn style_defaults_to_flex_start_when_absent() {
215 let kbd = parse(r#"{"type":"kbd","key":"K"}"#);
219 assert_eq!(kbd.style.align_self, Some(AlignSelf::FlexStart));
220 }
221
222 #[test]
223 fn style_defaults_to_flex_start_with_other_style_keys_present() {
224 let kbd = parse(r##"{"type":"kbd","key":"K","style":{"color":"#f00"}}"##);
225 assert_eq!(kbd.style.align_self, Some(AlignSelf::FlexStart));
226 assert_eq!(kbd.style.color_str(), Some("#f00"));
227 }
228
229 #[test]
230 fn explicit_align_self_is_respected() {
231 let kbd = parse(r#"{"type":"kbd","key":"K","style":{"align-self":"stretch"}}"#);
232 assert_eq!(kbd.style.align_self, Some(AlignSelf::Stretch));
233 }
234
235 #[test]
236 fn shadow_never_extends_past_the_assigned_box() {
237 let kbd = Kbd {
245 key: "K".to_string(),
246 font_size: default_font_size(),
247 background_color: default_bg_color(),
248 border_color: default_border_color(),
249 text_color: default_text_color(),
250 timing: Default::default(),
251 style: CssStyle::default(),
252 timeline: Vec::new(),
253 stagger: None,
254 };
255 const W: i32 = 100;
256 const H: i32 = 20;
257 let box_w = 60.0_f32;
258 let box_h = 10.0_f32; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
260 {
261 let canvas = surface.canvas();
262 canvas.translate((20.0, 5.0));
263 kbd.paint(canvas, box_w, box_h, &test_ctx());
264 }
265 let snapshot = surface.image_snapshot();
266 let info = skia_safe::ImageInfo::new(
267 (W, H),
268 skia_safe::ColorType::RGBA8888,
269 skia_safe::AlphaType::Premul,
270 None,
271 );
272 let mut buf = vec![0u8; (W * H * 4) as usize];
273 let ok = snapshot.read_pixels(
274 &info,
275 &mut buf,
276 (W * 4) as usize,
277 skia_safe::IPoint::new(0, 0),
278 skia_safe::image::CachingHint::Disallow,
279 );
280 assert!(ok, "pixel read should succeed");
281 let box_bottom = 15;
284 for y in (box_bottom + 1)..H {
285 for x in 0..W {
286 let a = buf[((y * W + x) * 4 + 3) as usize];
287 assert_eq!(
288 a, 0,
289 "kbd painted past its assigned box bottom at ({x},{y}), box_bottom={box_bottom}"
290 );
291 }
292 }
293 }
294
295 #[test]
298 fn rem_font_size_paints_visible_ink() {
299 let kbd = Kbd {
302 key: "K".to_string(),
303 font_size: default_font_size(),
304 background_color: default_bg_color(),
305 border_color: default_border_color(),
306 text_color: default_text_color(),
307 timing: Default::default(),
308 style: CssStyle {
309 font_size: Some(rustmotion_core::css::Length::String("2rem".into())),
310 ..Default::default()
311 },
312 timeline: Vec::new(),
313 stagger: None,
314 };
315 const W: i32 = 200;
316 const H: i32 = 100;
317 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
318 {
319 let canvas = surface.canvas();
320 kbd.paint(canvas, 100.0, 60.0, &test_ctx());
321 }
322 let snapshot = surface.image_snapshot();
323 let info = skia_safe::ImageInfo::new(
324 (W, H),
325 skia_safe::ColorType::RGBA8888,
326 skia_safe::AlphaType::Premul,
327 None,
328 );
329 let mut buf = vec![0u8; (W * H * 4) as usize];
330 let ok = snapshot.read_pixels(
331 &info,
332 &mut buf,
333 (W * 4) as usize,
334 skia_safe::IPoint::new(0, 0),
335 skia_safe::image::CachingHint::Disallow,
336 );
337 assert!(ok, "pixel read should succeed");
338 let text_ink = buf
342 .chunks_exact(4)
343 .filter(|p| p[3] > 0 && p[0] > 180 && p[1] > 180 && p[2] > 180)
344 .count();
345 assert!(
346 text_ink > 5,
347 "kbd at font-size: 2rem must paint visible text, got {text_ink} pixels"
348 );
349 }
350}