Skip to main content

rustmotion_components/
kbd.rs

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    /// `align-self` defaults to `flex-start` (not the flex container's
46    /// `stretch`) — a keycap is an atomic chip that must keep its natural
47    /// intrinsic width even inside a `flex`/`card` column; without this,
48    /// the default cross-axis `stretch` wins over `KbdIntrinsic` and the
49    /// key renders as a full-width slab. An author-specified `align-self`
50    /// in JSON is always respected (this only fills the gap when absent).
51    #[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
69/// Deserializes `style` normally, then defaults `align-self` to
70/// `flex-start` when the author didn't set it explicitly — see the doc
71/// comment on [`Kbd::style`].
72fn 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    /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/
91    /// `vh` now resolve instead of silently dropping to 0px — lot B, wave
92    /// S). `em`/`%` on `font-size` itself remain approximate — see
93    /// `crate::intrinsic::font_size_ctx`'s doc comment.
94    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        // Shadow (bottom edge to simulate physical key depth), and the key
122        // face sitting on top of it. Both are inset to `h - shadow_h` tall
123        // so the "3D lip" the shadow peeks out from under the face stays
124        // inside the assigned box — the face used to be drawn full-height
125        // with the shadow offset *below* it, bleeding `shadow_h` px past
126        // the box bottom every frame.
127        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        // Key face background
137        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        // Border
145        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        // Text centered
152        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        // #127: same fix as `badge` — a keycap must keep its intrinsic
216        // width (`KbdIntrinsic`) instead of stretching to the flex
217        // container's full cross-axis size.
218        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        // The shadow "lip" used to be offset 3px *below* a full-height
238        // face (`shadow_rect` at y=3 with height=h), so its bottom edge
239        // sat at `h + 3` — 3px past whatever box the layout gave this
240        // component. `paint()` now insets the face to `h - shadow_h` and
241        // draws the shadow directly beneath it, so nothing should paint
242        // past `h` any more. Render at a tiny height where a 3px miss
243        // would be highly visible relative to the box.
244        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; // shorter than the 3px shadow inset would tolerate if unfixed by a wide margin
259        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        // Box bottom edge is at absolute y = 5 (translate) + 10 (box_h) = 15.
282        // Nothing painted should reach row 16 or beyond.
283        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    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
296
297    #[test]
298    fn rem_font_size_paints_visible_ink() {
299        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
300        // context-free `font_size_px_or`.
301        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        // Text is near-white (#E2E8F0 default `text_color`) on a dark key
339        // face — probe for near-white ink specifically, since the face/
340        // border/shadow paint regardless of font-size.
341        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}