1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex,
11 typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_gap() -> f32 {
17 16.0
18}
19fn default_icon_size() -> f32 {
20 20.0
21}
22fn default_icon_color() -> String {
23 "#22C55E".to_string()
24}
25fn default_unchecked_color() -> String {
26 "#6B7280".to_string()
27}
28fn default_width() -> f32 {
29 400.0
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34#[derive(Default)]
35pub enum ListVariant {
36 #[default]
37 Bullet,
38 Numbered,
39 Checklist,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct ListItem {
44 pub text: String,
45 #[serde(default)]
46 pub icon: Option<String>,
47 #[serde(default)]
48 pub checked: Option<bool>,
49}
50
51#[derive(Debug, Serialize, Deserialize, JsonSchema)]
52#[deprecated(
53 since = "0.7.1",
54 note = "`list` is a frozen composition (issue #333). Compose each row from `icon` + \
55 `text` inside a `for-each` instead of the dedicated list — see \
56 crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
57 scheduled for removal in a future major version via `rustmotion migrate` (#335)."
58)]
59pub struct List {
60 pub items: Vec<ListItem>,
61 #[serde(default)]
62 pub variant: ListVariant,
63 #[serde(default = "default_gap")]
64 pub gap: f32,
65 #[serde(default = "default_icon_size")]
66 pub icon_size: f32,
67 #[serde(default = "default_icon_color")]
68 pub icon_color: String,
69 #[serde(default = "default_unchecked_color")]
70 pub unchecked_color: String,
71 #[serde(default = "default_width")]
72 pub width: f32,
73 #[serde(flatten)]
74 pub timing: TimingConfig,
75 #[serde(default)]
76 pub style: CssStyle,
77 #[serde(default)]
78 pub timeline: Vec<TimelineStep>,
79 #[serde(default)]
80 pub stagger: Option<f32>,
81}
82
83rustmotion_core::impl_traits!(List {
84 Animatable => animation,
85 Timed => timing,
86 Styled => style,
87});
88
89impl List {
90 fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 {
91 self.style.font_size_px_ctx(
92 &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
93 16.0,
94 )
95 }
96
97 fn make_font(&self, ctx: &PaintCtx) -> Option<skia_safe::Font> {
98 let font_style = skia_safe::FontStyle::normal();
99 let family = self.style.font_family.as_deref().unwrap_or("Inter");
100 let typeface = typeface_with_fallback(family, font_style).ok()?;
101 Some(skia_safe::Font::from_typeface(
102 typeface,
103 self.resolved_font_size(ctx),
104 ))
105 }
106
107 fn render_icon_svg(
108 &self,
109 canvas: &Canvas,
110 icon_id: &str,
111 color: &str,
112 x: f32,
113 y: f32,
114 ) -> Result<()> {
115 let icon_w = self.icon_size.round() as u32;
116 let icon_h = self.icon_size.round() as u32;
117 let cache_key = format!("icon:{}:{}:{}x{}", icon_id, color, icon_w, icon_h);
118
119 let cache = asset_cache();
120 let img = if let Some(cached) = cache.get(&cache_key) {
121 cached.clone()
122 } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) {
123 let opt = usvg::Options::default();
124 if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
125 let svg_size = tree.size();
126 if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) {
127 let sx = icon_w as f32 / svg_size.width();
128 let sy = icon_h as f32 / svg_size.height();
129 resvg::render(
130 &tree,
131 tiny_skia::Transform::from_scale(sx, sy),
132 &mut pixmap.as_mut(),
133 );
134 let img_data = skia_safe::Data::new_copy(pixmap.data());
135 let info = ImageInfo::new(
136 (icon_w as i32, icon_h as i32),
137 ColorType::RGBA8888,
138 skia_safe::AlphaType::Premul,
139 None,
140 );
141 if let Some(decoded) =
142 skia_safe::images::raster_from_data(&info, img_data, icon_w as usize * 4)
143 {
144 cache.insert(cache_key, decoded.clone());
145 decoded
146 } else {
147 return Ok(());
148 }
149 } else {
150 return Ok(());
151 }
152 } else {
153 return Ok(());
154 }
155 } else {
156 return Ok(());
157 };
158
159 let dst = Rect::from_xywh(x, y, self.icon_size, self.icon_size);
160 canvas.draw_image_rect(img, None, dst, &Paint::default());
161 Ok(())
162 }
163}
164
165impl List {
166 fn paint(&self, canvas: &Canvas, ctx: &PaintCtx) -> Result<()> {
167 let Some(font) = self.make_font(ctx) else {
168 return Ok(());
169 };
170 let font_size = self.resolved_font_size(ctx);
171 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
172 let text_color = self.style.color_str_or("#FFFFFF");
173 let mut text_paint = paint_from_hex(text_color);
174 text_paint.set_anti_alias(true);
175
176 let (_, metrics) = font.metrics();
177 let line_height = font_size * 1.3;
178 let left_margin = self.icon_size + 12.0;
179
180 let mut y_offset = 0.0;
181
182 for (i, item) in self.items.iter().enumerate() {
183 let text_y = y_offset + (line_height + (-metrics.ascent)) / 2.0;
184 let icon_y = y_offset + (line_height - self.icon_size) / 2.0;
185
186 match &self.variant {
187 ListVariant::Bullet => {
188 let bullet_r = 4.0;
189 let bullet_cx = self.icon_size / 2.0;
190 let bullet_cy = y_offset + line_height / 2.0;
191 let mut bullet_paint = paint_from_hex(&self.icon_color);
192 bullet_paint.set_style(PaintStyle::Fill);
193 bullet_paint.set_anti_alias(true);
194 canvas.draw_circle((bullet_cx, bullet_cy), bullet_r, &bullet_paint);
195 }
196 ListVariant::Numbered => {
197 let num_text = format!("{}.", i + 1);
198 let mut num_paint = paint_from_hex(&self.icon_color);
199 num_paint.set_anti_alias(true);
200 draw_text_with_fallback(
201 canvas,
202 &num_text,
203 &font,
204 &emoji_font,
205 0.0,
206 0.0,
207 text_y,
208 &num_paint,
209 );
210 }
211 ListVariant::Checklist => {
212 let checked = item.checked.unwrap_or(false);
213 if let Some(icon_id) = &item.icon {
214 let color = if checked {
215 &self.icon_color
216 } else {
217 &self.unchecked_color
218 };
219 self.render_icon_svg(canvas, icon_id, color, 0.0, icon_y)?;
220 } else {
221 let circle_r = self.icon_size / 2.0 - 2.0;
222 let circle_cx = self.icon_size / 2.0;
223 let circle_cy = y_offset + line_height / 2.0;
224 if checked {
225 let mut fill_paint = paint_from_hex(&self.icon_color);
226 fill_paint.set_style(PaintStyle::Fill);
227 fill_paint.set_anti_alias(true);
228 canvas.draw_circle((circle_cx, circle_cy), circle_r, &fill_paint);
229 } else {
230 let mut stroke_paint = paint_from_hex(&self.unchecked_color);
231 stroke_paint.set_style(PaintStyle::Stroke);
232 stroke_paint.set_stroke_width(2.0);
233 stroke_paint.set_anti_alias(true);
234 canvas.draw_circle((circle_cx, circle_cy), circle_r, &stroke_paint);
235 }
236 }
237 }
238 }
239
240 draw_text_with_fallback(
241 canvas,
242 &item.text,
243 &font,
244 &emoji_font,
245 0.0,
246 left_margin,
247 text_y,
248 &text_paint,
249 );
250
251 y_offset += line_height + self.gap;
252 }
253
254 Ok(())
255 }
256}
257
258impl Painter for List {
259 fn paint_content(
260 &self,
261 canvas: &Canvas,
262 _layout: &BoxLayout,
263 _props: &AnimatedProperties,
264 ctx: &PaintCtx,
265 ) {
266 let _ = self.paint(canvas, ctx);
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use rustmotion_core::css::CssStyle;
274 use rustmotion_core::css::Length;
275
276 fn test_ctx() -> PaintCtx {
277 PaintCtx {
278 time: 0.0,
279 scenario_time: 0.0,
280 scene_duration: 1.0,
281 frame_index: 0,
282 fps: 30,
283 video_width: 400,
284 video_height: 200,
285 stagger_offset: 0.0,
286 }
287 }
288
289 #[test]
290 fn rem_font_size_paints_visible_ink() {
291 let list = List {
292 items: vec![ListItem {
293 text: "hello".to_string(),
294 icon: None,
295 checked: None,
296 }],
297 variant: ListVariant::Bullet,
298 gap: default_gap(),
299 icon_size: default_icon_size(),
300 icon_color: default_icon_color(),
301 unchecked_color: default_unchecked_color(),
302 width: default_width(),
303 timing: Default::default(),
304 style: CssStyle {
305 font_size: Some(Length::String("2rem".into())),
306 color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())),
307 ..Default::default()
308 },
309 timeline: Vec::new(),
310 stagger: None,
311 };
312 const W: i32 = 400;
313 const H: i32 = 200;
314 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
315 {
316 let canvas = surface.canvas();
317 list.paint(canvas, &test_ctx()).expect("paint succeeds");
318 }
319 let snapshot = surface.image_snapshot();
320 let info = skia_safe::ImageInfo::new(
321 (W, H),
322 skia_safe::ColorType::RGBA8888,
323 skia_safe::AlphaType::Premul,
324 None,
325 );
326 let mut buf = vec![0u8; (W * H * 4) as usize];
327 let ok = snapshot.read_pixels(
328 &info,
329 &mut buf,
330 (W * 4) as usize,
331 skia_safe::IPoint::new(0, 0),
332 skia_safe::image::CachingHint::Disallow,
333 );
334 assert!(ok, "pixel read should succeed");
335 let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
336 assert!(
337 lit > 20,
338 "list at font-size: 2rem must paint visible ink, got {lit} lit pixels"
339 );
340 }
341}