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