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    subpixel_font, 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)]
33#[deprecated(
34    since = "0.7.1",
35    note = "`kbd` is a frozen composition (issue #333). Compose a keycap from a small `card` \
36            (border + shadow) with a `text` label instead — see \
37            crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
38            scheduled for removal in a future major version via `rustmotion migrate` (#335)."
39)]
40pub struct Kbd {
41    pub key: String,
42    #[serde(default = "default_font_size")]
43    pub font_size: f32,
44    #[serde(default = "default_bg_color")]
45    pub background_color: String,
46    #[serde(default = "default_border_color")]
47    pub border_color: String,
48    #[serde(default = "default_text_color")]
49    pub text_color: String,
50    #[serde(flatten)]
51    pub timing: TimingConfig,
52    /// `align-self` defaults to `flex-start` (not the flex container's
53    /// `stretch`) — a keycap is an atomic chip that must keep its natural
54    /// intrinsic width even inside a `flex`/`card` column; without this,
55    /// the default cross-axis `stretch` wins over `KbdIntrinsic` and the
56    /// key renders as a full-width slab. An author-specified `align-self`
57    /// in JSON is always respected (this only fills the gap when absent).
58    #[serde(
59        default = "default_kbd_style",
60        deserialize_with = "deserialize_no_stretch_style"
61    )]
62    pub style: CssStyle,
63    #[serde(default)]
64    pub timeline: Vec<TimelineStep>,
65    #[serde(default)]
66    pub stagger: Option<f32>,
67}
68
69fn default_kbd_style() -> CssStyle {
70    CssStyle {
71        align_self: Some(AlignSelf::FlexStart),
72        ..CssStyle::default()
73    }
74}
75
76fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result<CssStyle, D::Error>
77where
78    D: serde::Deserializer<'de>,
79{
80    let mut style = CssStyle::deserialize(deserializer)?;
81    if style.align_self.is_none() {
82        style.align_self = Some(AlignSelf::FlexStart);
83    }
84    Ok(style)
85}
86
87rustmotion_core::impl_traits!(Kbd {
88    Animatable => animation,
89    Timed => timing,
90    Styled => style,
91});
92
93impl Kbd {
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(subpixel_font(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);
122        let cap_h = (h - shadow_h).max(0.0);
123        let shadow_rect = Rect::from_xywh(0.0, shadow_h, w, cap_h);
124        let shadow_rrect = skia_safe::RRect::new_rect_xy(shadow_rect, radius, radius);
125        let mut shadow_paint = paint_from_hex(&self.border_color);
126        shadow_paint.set_style(PaintStyle::Fill);
127        shadow_paint.set_anti_alias(true);
128        canvas.draw_rrect(shadow_rrect, &shadow_paint);
129
130        let face_rect = Rect::from_xywh(0.0, 0.0, w, cap_h);
131        let face_rrect = skia_safe::RRect::new_rect_xy(face_rect, radius, radius);
132        let mut face_paint = paint_from_hex(bg_color);
133        face_paint.set_style(PaintStyle::Fill);
134        face_paint.set_anti_alias(true);
135        canvas.draw_rrect(face_rrect, &face_paint);
136
137        let mut border_paint = paint_from_hex(&self.border_color);
138        border_paint.set_style(PaintStyle::Stroke);
139        border_paint.set_stroke_width(1.0);
140        border_paint.set_anti_alias(true);
141        canvas.draw_rrect(face_rrect, &border_paint);
142
143        let Some(font) = self.make_font(ctx) else {
144            return;
145        };
146        let fs = self.resolved_font_size(ctx);
147        let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, fs));
148
149        let text_color = self.style.color_str().unwrap_or(&self.text_color);
150        let mut text_paint = paint_from_hex(text_color);
151        text_paint.set_anti_alias(true);
152
153        let text_w = measure_text_with_fallback(&self.key, &font, &emoji_font, 0.0);
154        let (_, metrics) = font.metrics();
155        let text_x = (w - text_w) / 2.0;
156        let text_y = (cap_h + (-metrics.ascent)) / 2.0;
157
158        draw_text_with_fallback(
159            canvas,
160            &self.key,
161            &font,
162            &emoji_font,
163            0.0,
164            text_x,
165            text_y,
166            &text_paint,
167        );
168    }
169}
170
171impl Painter for Kbd {
172    fn paint_content(
173        &self,
174        canvas: &Canvas,
175        layout: &BoxLayout,
176        _props: &AnimatedProperties,
177        ctx: &PaintCtx,
178    ) {
179        self.paint(canvas, layout.width, layout.height, ctx);
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn parse(json: &str) -> Kbd {
188        serde_json::from_str(json).expect("kbd should deserialize")
189    }
190
191    fn test_ctx() -> PaintCtx {
192        PaintCtx {
193            time: 0.0,
194            scenario_time: 0.0,
195            scene_duration: 1.0,
196            frame_index: 0,
197            fps: 30,
198            video_width: 400,
199            video_height: 200,
200            stagger_offset: 0.0,
201        }
202    }
203
204    #[test]
205    fn style_defaults_to_flex_start_when_absent() {
206        let kbd = parse(r#"{"type":"kbd","key":"K"}"#);
207        assert_eq!(kbd.style.align_self, Some(AlignSelf::FlexStart));
208    }
209
210    #[test]
211    fn style_defaults_to_flex_start_with_other_style_keys_present() {
212        let kbd = parse(r##"{"type":"kbd","key":"K","style":{"color":"#f00"}}"##);
213        assert_eq!(kbd.style.align_self, Some(AlignSelf::FlexStart));
214        assert_eq!(kbd.style.color_str(), Some("#f00"));
215    }
216
217    #[test]
218    fn explicit_align_self_is_respected() {
219        let kbd = parse(r#"{"type":"kbd","key":"K","style":{"align-self":"stretch"}}"#);
220        assert_eq!(kbd.style.align_self, Some(AlignSelf::Stretch));
221    }
222
223    #[test]
224    fn shadow_never_extends_past_the_assigned_box() {
225        let kbd = Kbd {
226            key: "K".to_string(),
227            font_size: default_font_size(),
228            background_color: default_bg_color(),
229            border_color: default_border_color(),
230            text_color: default_text_color(),
231            timing: Default::default(),
232            style: CssStyle::default(),
233            timeline: Vec::new(),
234            stagger: None,
235        };
236        const W: i32 = 100;
237        const H: i32 = 20;
238        let box_w = 60.0_f32;
239        let box_h = 10.0_f32;
240        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
241        {
242            let canvas = surface.canvas();
243            canvas.translate((20.0, 5.0));
244            kbd.paint(canvas, box_w, box_h, &test_ctx());
245        }
246        let snapshot = surface.image_snapshot();
247        let info = skia_safe::ImageInfo::new(
248            (W, H),
249            skia_safe::ColorType::RGBA8888,
250            skia_safe::AlphaType::Premul,
251            None,
252        );
253        let mut buf = vec![0u8; (W * H * 4) as usize];
254        let ok = snapshot.read_pixels(
255            &info,
256            &mut buf,
257            (W * 4) as usize,
258            skia_safe::IPoint::new(0, 0),
259            skia_safe::image::CachingHint::Disallow,
260        );
261        assert!(ok, "pixel read should succeed");
262        let box_bottom = 15;
263        for y in (box_bottom + 1)..H {
264            for x in 0..W {
265                let a = buf[((y * W + x) * 4 + 3) as usize];
266                assert_eq!(
267                    a, 0,
268                    "kbd painted past its assigned box bottom at ({x},{y}), box_bottom={box_bottom}"
269                );
270            }
271        }
272    }
273
274    #[test]
275    fn rem_font_size_paints_visible_ink() {
276        let kbd = Kbd {
277            key: "K".to_string(),
278            font_size: default_font_size(),
279            background_color: default_bg_color(),
280            border_color: default_border_color(),
281            text_color: default_text_color(),
282            timing: Default::default(),
283            style: CssStyle {
284                font_size: Some(rustmotion_core::css::Length::String("2rem".into())),
285                ..Default::default()
286            },
287            timeline: Vec::new(),
288            stagger: None,
289        };
290        const W: i32 = 200;
291        const H: i32 = 100;
292        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
293        {
294            let canvas = surface.canvas();
295            kbd.paint(canvas, 100.0, 60.0, &test_ctx());
296        }
297        let snapshot = surface.image_snapshot();
298        let info = skia_safe::ImageInfo::new(
299            (W, H),
300            skia_safe::ColorType::RGBA8888,
301            skia_safe::AlphaType::Premul,
302            None,
303        );
304        let mut buf = vec![0u8; (W * H * 4) as usize];
305        let ok = snapshot.read_pixels(
306            &info,
307            &mut buf,
308            (W * 4) as usize,
309            skia_safe::IPoint::new(0, 0),
310            skia_safe::image::CachingHint::Disallow,
311        );
312        assert!(ok, "pixel read should succeed");
313        let text_ink = buf
314            .as_chunks::<4>()
315            .0
316            .iter()
317            .filter(|p| p[3] > 0 && p[0] > 180 && p[1] > 180 && p[2] > 180)
318            .count();
319        assert!(
320            text_ink > 5,
321            "kbd at font-size: 2rem must paint visible text, got {text_ink} pixels"
322        );
323    }
324}