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 subpixel_font, 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(subpixel_font(typeface, self.resolved_font_size(ctx)))
110 }
111
112 fn compute_tab_layout(&self, ctx: &PaintCtx) -> Option<(f32, Vec<f32>, Vec<f32>)> {
113 let font = self.make_font(false, ctx)?;
114 let font_size = self.resolved_font_size(ctx);
115 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
116 let h_pad = font_size * 1.2;
117
118 let mut tab_widths: Vec<f32> = Vec::new();
119 for label in &self.items {
120 let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
121 tab_widths.push(text_w + h_pad * 2.0);
122 }
123
124 let inner_pad = self.gap;
125 let mut tab_positions: Vec<f32> = Vec::new();
126 let mut x = inner_pad;
127 for (i, tw) in tab_widths.iter().enumerate() {
128 tab_positions.push(x);
129 x += tw;
130 if i < tab_widths.len() - 1 {
131 x += self.gap;
132 }
133 }
134
135 let total_w = x + inner_pad;
136 Some((total_w, tab_positions, tab_widths))
137 }
138
139 fn active_at_time(&self, time: f64) -> (u32, Option<(u32, f64)>) {
140 let mut current = self.active_index;
141 let mut _prev_index = self.active_index;
142 let mut transition_info: Option<(u32, f64)> = None;
143
144 let mut sorted_transitions = self.transitions.clone();
145 sorted_transitions.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap());
146
147 for tr in &sorted_transitions {
148 if time < tr.at {
149 break;
150 }
151 let end_time = tr.at + self.transition_duration;
152 if time < end_time {
153 let progress = (time - tr.at) / self.transition_duration;
154 _prev_index = current;
155 current = tr.to;
156 transition_info = Some((_prev_index, progress));
157 break;
158 }
159 _prev_index = current;
160 current = tr.to;
161 }
162
163 (current, transition_info)
164 }
165}
166
167impl PillNav {
168 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64, ctx: &PaintCtx) {
169 if self.items.is_empty() {
170 return;
171 }
172
173 let w = layout_w;
174 let h = layout_h;
175
176 let outer_rect = Rect::from_xywh(0.0, 0.0, w, h);
177 let outer_rrect = RRect::new_rect_xy(outer_rect, self.border_radius, self.border_radius);
178 let mut bg_paint = paint_from_hex(&self.background_color);
179 bg_paint.set_style(PaintStyle::Fill);
180 bg_paint.set_anti_alias(true);
181 canvas.draw_rrect(outer_rrect, &bg_paint);
182
183 let Some((_total_w, tab_positions, tab_widths)) = self.compute_tab_layout(ctx) else {
184 return;
185 };
186 let (active, transition_info) = self.active_at_time(time);
187
188 let pill_h = h - self.gap * 2.0;
189 let pill_y = self.gap;
190 let pill_radius = self.border_radius - self.gap;
191
192 let active_idx = active.min(self.items.len() as u32 - 1) as usize;
193 let (pill_x, pill_w) = if let Some((from_idx, progress)) = transition_info {
194 let from = from_idx.min(self.items.len() as u32 - 1) as usize;
195 let to = active_idx;
196 let t = progress as f32;
197 let from_x = tab_positions[from];
198 let to_x = tab_positions[to];
199 let from_w = tab_widths[from];
200 let to_w = tab_widths[to];
201 (from_x + (to_x - from_x) * t, from_w + (to_w - from_w) * t)
202 } else {
203 (tab_positions[active_idx], tab_widths[active_idx])
204 };
205
206 let pill_rect = Rect::from_xywh(pill_x, pill_y, pill_w, pill_h);
207 let pill_rrect = RRect::new_rect_xy(pill_rect, pill_radius, pill_radius);
208 let mut pill_paint = paint_from_hex(&self.pill_color);
209 pill_paint.set_style(PaintStyle::Fill);
210 pill_paint.set_anti_alias(true);
211 canvas.draw_rrect(pill_rrect, &pill_paint);
212
213 let Some(font) = self.make_font(false, ctx) else {
214 return;
215 };
216 let font_size = self.resolved_font_size(ctx);
217 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
218 let (_, metrics) = font.metrics();
219 let text_y = (h + (-metrics.ascent)) / 2.0;
220
221 for (i, label) in self.items.iter().enumerate() {
222 let is_active = i == active_idx;
223 let color = if is_active {
224 &self.text_color
225 } else {
226 &self.inactive_text_color
227 };
228 let mut label_paint = paint_from_hex(color);
229 label_paint.set_anti_alias(true);
230
231 let text_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
232 let text_x = tab_positions[i] + (tab_widths[i] - text_w) / 2.0;
233
234 draw_text_with_fallback(
235 canvas,
236 label,
237 &font,
238 &emoji_font,
239 0.0,
240 text_x,
241 text_y,
242 &label_paint,
243 );
244 }
245 }
246}
247
248impl Painter for PillNav {
249 fn paint_content(
250 &self,
251 canvas: &Canvas,
252 layout: &BoxLayout,
253 _props: &AnimatedProperties,
254 ctx: &PaintCtx,
255 ) {
256 self.paint(canvas, layout.width, layout.height, ctx.time, ctx);
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use rustmotion_core::css::CssStyle;
264 use rustmotion_core::css::Length;
265
266 fn test_ctx() -> PaintCtx {
267 PaintCtx {
268 time: 0.0,
269 scenario_time: 0.0,
270 scene_duration: 1.0,
271 frame_index: 0,
272 fps: 30,
273 video_width: 400,
274 video_height: 200,
275 stagger_offset: 0.0,
276 }
277 }
278
279 #[test]
280 fn rem_font_size_paints_visible_ink() {
281 let nav = PillNav {
282 items: vec!["Home".to_string(), "About".to_string()],
283 active_index: 0,
284 transitions: Vec::new(),
285 pill_color: default_pill_color(),
286 text_color: default_text_color(),
287 inactive_text_color: default_inactive_text_color(),
288 background_color: default_background_color(),
289 height: default_height(),
290 border_radius: default_border_radius(),
291 gap: default_gap(),
292 transition_duration: default_transition_duration(),
293 timing: Default::default(),
294 style: CssStyle {
295 font_size: Some(Length::String("2rem".into())),
296 ..Default::default()
297 },
298 timeline: Vec::new(),
299 stagger: None,
300 };
301 const W: i32 = 400;
302 const H: i32 = 100;
303 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
304 {
305 let canvas = surface.canvas();
306 nav.paint(canvas, 300.0, 44.0, 0.0, &test_ctx());
307 }
308 let snapshot = surface.image_snapshot();
309 let info = skia_safe::ImageInfo::new(
310 (W, H),
311 skia_safe::ColorType::RGBA8888,
312 skia_safe::AlphaType::Premul,
313 None,
314 );
315 let mut buf = vec![0u8; (W * H * 4) as usize];
316 let ok = snapshot.read_pixels(
317 &info,
318 &mut buf,
319 (W * 4) as usize,
320 skia_safe::IPoint::new(0, 0),
321 skia_safe::image::CachingHint::Disallow,
322 );
323 assert!(ok, "pixel read should succeed");
324 let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
325 assert!(
326 lit > 20,
327 "pill_nav at font-size: 2rem must paint visible ink, got {lit} lit pixels"
328 );
329 }
330}