Skip to main content

rustmotion_components/
pill_nav.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, RRect, 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_pill_color() -> String {
16    "#3B82F6".to_string()
17}
18fn default_text_color() -> String {
19    "#FFFFFF".to_string()
20}
21fn default_inactive_text_color() -> String {
22    "#9CA3AF".to_string()
23}
24fn default_background_color() -> String {
25    "#1E293B".to_string()
26}
27fn default_height() -> f32 {
28    44.0
29}
30fn default_border_radius() -> f32 {
31    22.0
32}
33fn default_gap() -> f32 {
34    4.0
35}
36fn default_transition_duration() -> f64 {
37    0.3
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41pub struct PillTransition {
42    pub to: u32,
43    pub at: f64,
44}
45
46#[derive(Debug, Serialize, Deserialize, JsonSchema)]
47#[deprecated(
48    since = "0.7.1",
49    note = "`pill_nav` is a frozen composition (issue #333). Compose tabs from a row of \
50            `text`/`div` items plus a `shape` pill animated between their positions instead \
51            — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \
52            compatibility; scheduled for removal in a future major version via \
53            `rustmotion migrate` (#335)."
54)]
55pub struct PillNav {
56    pub items: Vec<String>,
57    #[serde(default)]
58    pub active_index: u32,
59    #[serde(default)]
60    pub transitions: Vec<PillTransition>,
61    #[serde(default = "default_pill_color")]
62    pub pill_color: String,
63    #[serde(default = "default_text_color")]
64    pub text_color: String,
65    #[serde(default = "default_inactive_text_color")]
66    pub inactive_text_color: String,
67    #[serde(default = "default_background_color")]
68    pub background_color: String,
69    #[serde(default = "default_height")]
70    pub height: f32,
71    #[serde(default = "default_border_radius")]
72    pub border_radius: f32,
73    #[serde(default = "default_gap")]
74    pub gap: f32,
75    #[serde(default = "default_transition_duration")]
76    pub transition_duration: f64,
77    #[serde(flatten)]
78    pub timing: TimingConfig,
79    #[serde(default)]
80    pub style: CssStyle,
81    #[serde(default)]
82    pub timeline: Vec<TimelineStep>,
83    #[serde(default)]
84    pub stagger: Option<f32>,
85}
86
87rustmotion_core::impl_traits!(PillNav {
88    Animatable => animation,
89    Timed => timing,
90    Styled => style,
91});
92
93impl PillNav {
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            14.0,
98        )
99    }
100
101    fn make_font(&self, bold: bool, ctx: &PaintCtx) -> Option<skia_safe::Font> {
102        let font_style = if bold {
103            skia_safe::FontStyle::bold()
104        } else {
105            skia_safe::FontStyle::normal()
106        };
107        let family = self.style.font_family.as_deref().unwrap_or("Inter");
108        let typeface = typeface_with_fallback(family, font_style).ok()?;
109        Some(skia_safe::Font::from_typeface(
110            typeface,
111            self.resolved_font_size(ctx),
112        ))
113    }
114
115    fn compute_tab_layout(&self, ctx: &PaintCtx) -> Option<(f32, Vec<f32>, Vec<f32>)> {
116        let font = self.make_font(false, ctx)?;
117        let font_size = self.resolved_font_size(ctx);
118        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
119        let h_pad = font_size * 1.2;
120
121        let mut tab_widths: Vec<f32> = Vec::new();
122        for label in &self.items {
123            let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
124            tab_widths.push(text_w + h_pad * 2.0);
125        }
126
127        let inner_pad = self.gap;
128        let mut tab_positions: Vec<f32> = Vec::new();
129        let mut x = inner_pad;
130        for (i, tw) in tab_widths.iter().enumerate() {
131            tab_positions.push(x);
132            x += tw;
133            if i < tab_widths.len() - 1 {
134                x += self.gap;
135            }
136        }
137
138        let total_w = x + inner_pad;
139        Some((total_w, tab_positions, tab_widths))
140    }
141
142    fn active_at_time(&self, time: f64) -> (u32, Option<(u32, f64)>) {
143        let mut current = self.active_index;
144        let mut _prev_index = self.active_index;
145        let mut transition_info: Option<(u32, f64)> = None;
146
147        let mut sorted_transitions = self.transitions.clone();
148        sorted_transitions.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap());
149
150        for tr in &sorted_transitions {
151            if time < tr.at {
152                break;
153            }
154            let end_time = tr.at + self.transition_duration;
155            if time < end_time {
156                let progress = (time - tr.at) / self.transition_duration;
157                _prev_index = current;
158                current = tr.to;
159                transition_info = Some((_prev_index, progress));
160                break;
161            }
162            _prev_index = current;
163            current = tr.to;
164        }
165
166        (current, transition_info)
167    }
168}
169
170impl PillNav {
171    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64, ctx: &PaintCtx) {
172        if self.items.is_empty() {
173            return;
174        }
175
176        let w = layout_w;
177        let h = layout_h;
178
179        let outer_rect = Rect::from_xywh(0.0, 0.0, w, h);
180        let outer_rrect = RRect::new_rect_xy(outer_rect, self.border_radius, self.border_radius);
181        let mut bg_paint = paint_from_hex(&self.background_color);
182        bg_paint.set_style(PaintStyle::Fill);
183        bg_paint.set_anti_alias(true);
184        canvas.draw_rrect(outer_rrect, &bg_paint);
185
186        let Some((_total_w, tab_positions, tab_widths)) = self.compute_tab_layout(ctx) else {
187            return;
188        };
189        let (active, transition_info) = self.active_at_time(time);
190
191        let pill_h = h - self.gap * 2.0;
192        let pill_y = self.gap;
193        let pill_radius = self.border_radius - self.gap;
194
195        let active_idx = active.min(self.items.len() as u32 - 1) as usize;
196        let (pill_x, pill_w) = if let Some((from_idx, progress)) = transition_info {
197            let from = from_idx.min(self.items.len() as u32 - 1) as usize;
198            let to = active_idx;
199            let t = progress as f32;
200            let from_x = tab_positions[from];
201            let to_x = tab_positions[to];
202            let from_w = tab_widths[from];
203            let to_w = tab_widths[to];
204            (from_x + (to_x - from_x) * t, from_w + (to_w - from_w) * t)
205        } else {
206            (tab_positions[active_idx], tab_widths[active_idx])
207        };
208
209        let pill_rect = Rect::from_xywh(pill_x, pill_y, pill_w, pill_h);
210        let pill_rrect = RRect::new_rect_xy(pill_rect, pill_radius, pill_radius);
211        let mut pill_paint = paint_from_hex(&self.pill_color);
212        pill_paint.set_style(PaintStyle::Fill);
213        pill_paint.set_anti_alias(true);
214        canvas.draw_rrect(pill_rrect, &pill_paint);
215
216        let Some(font) = self.make_font(false, ctx) else {
217            return;
218        };
219        let font_size = self.resolved_font_size(ctx);
220        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
221        let (_, metrics) = font.metrics();
222        let text_y = (h + (-metrics.ascent)) / 2.0;
223
224        for (i, label) in self.items.iter().enumerate() {
225            let is_active = i == active_idx;
226            let color = if is_active {
227                &self.text_color
228            } else {
229                &self.inactive_text_color
230            };
231            let mut label_paint = paint_from_hex(color);
232            label_paint.set_anti_alias(true);
233
234            let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
235            let text_x = tab_positions[i] + (tab_widths[i] - text_w) / 2.0;
236
237            draw_text_with_fallback(
238                canvas,
239                label,
240                &font,
241                &emoji_font,
242                0.0,
243                text_x,
244                text_y,
245                &label_paint,
246            );
247        }
248    }
249}
250
251impl Painter for PillNav {
252    fn paint_content(
253        &self,
254        canvas: &Canvas,
255        layout: &BoxLayout,
256        _props: &AnimatedProperties,
257        ctx: &PaintCtx,
258    ) {
259        self.paint(canvas, layout.width, layout.height, ctx.time, ctx);
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use rustmotion_core::css::CssStyle;
267    use rustmotion_core::css::Length;
268
269    fn test_ctx() -> PaintCtx {
270        PaintCtx {
271            time: 0.0,
272            scenario_time: 0.0,
273            scene_duration: 1.0,
274            frame_index: 0,
275            fps: 30,
276            video_width: 400,
277            video_height: 200,
278            stagger_offset: 0.0,
279        }
280    }
281
282    #[test]
283    fn rem_font_size_paints_visible_ink() {
284        let nav = PillNav {
285            items: vec!["Home".to_string(), "About".to_string()],
286            active_index: 0,
287            transitions: Vec::new(),
288            pill_color: default_pill_color(),
289            text_color: default_text_color(),
290            inactive_text_color: default_inactive_text_color(),
291            background_color: default_background_color(),
292            height: default_height(),
293            border_radius: default_border_radius(),
294            gap: default_gap(),
295            transition_duration: default_transition_duration(),
296            timing: Default::default(),
297            style: CssStyle {
298                font_size: Some(Length::String("2rem".into())),
299                ..Default::default()
300            },
301            timeline: Vec::new(),
302            stagger: None,
303        };
304        const W: i32 = 400;
305        const H: i32 = 100;
306        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
307        {
308            let canvas = surface.canvas();
309            nav.paint(canvas, 300.0, 44.0, 0.0, &test_ctx());
310        }
311        let snapshot = surface.image_snapshot();
312        let info = skia_safe::ImageInfo::new(
313            (W, H),
314            skia_safe::ColorType::RGBA8888,
315            skia_safe::AlphaType::Premul,
316            None,
317        );
318        let mut buf = vec![0u8; (W * H * 4) as usize];
319        let ok = snapshot.read_pixels(
320            &info,
321            &mut buf,
322            (W * 4) as usize,
323            skia_safe::IPoint::new(0, 0),
324            skia_safe::image::CachingHint::Disallow,
325        );
326        assert!(ok, "pixel read should succeed");
327        let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
328        assert!(
329            lit > 20,
330            "pill_nav at font-size: 2rem must paint visible ink, got {lit} lit pixels"
331        );
332    }
333}