1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, 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
15#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18#[derive(Default)]
19pub enum ColumnAlign {
20 #[default]
21 Left,
22 Center,
23 Right,
24}
25
26pub(crate) const DEFAULT_FONT_SIZE: f32 = 14.0;
27pub(crate) const DEFAULT_ROW_HEIGHT_RATIO: f32 = 2.5;
28pub(crate) const DEFAULT_CELL_PADDING: f32 = 12.0;
29
30fn default_cell_padding() -> f32 {
31 DEFAULT_CELL_PADDING
32}
33
34fn default_show_borders() -> bool {
35 true
36}
37
38#[derive(Debug, Serialize, Deserialize, JsonSchema)]
39#[deprecated(
40 since = "0.7.1",
41 note = "`table` is a frozen composition. Compose a `for-each` over the rows inside a \
42 grid-styled container instead — see \
43 crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
44 scheduled for removal in a future major version via `rustmotion migrate` (#335)."
45)]
46pub struct Table {
47 pub headers: Vec<String>,
48 pub rows: Vec<Vec<String>>,
49 #[serde(default)]
50 pub header_color: Option<String>,
51 #[serde(default)]
52 pub row_colors: Option<Vec<String>>,
53 #[serde(default)]
54 pub border_color: Option<String>,
55 #[serde(default)]
56 pub header_text_color: Option<String>,
57 #[serde(default)]
59 pub column_widths: Option<Vec<f32>>,
60 #[serde(default)]
62 pub column_align: Option<Vec<ColumnAlign>>,
63 #[serde(default = "default_cell_padding")]
65 pub cell_padding: f32,
66 #[serde(default = "default_show_borders")]
68 pub show_borders: bool,
69 #[serde(flatten)]
70 pub timing: TimingConfig,
71 #[serde(default)]
72 pub style: CssStyle,
73 #[serde(default)]
74 pub timeline: Vec<TimelineStep>,
75 #[serde(default)]
76 pub stagger: Option<f32>,
77}
78
79rustmotion_core::impl_traits!(Table {
80 Animatable => animation,
81 Timed => timing,
82 Styled => style,
83});
84
85impl Table {
86 fn row_height(&self, font_size: f32) -> f32 {
87 font_size * 2.5
88 }
89
90 fn make_font(&self, bold: bool, font_size: f32) -> Option<skia_safe::Font> {
91 let font_style = if bold {
92 skia_safe::FontStyle::bold()
93 } else {
94 skia_safe::FontStyle::normal()
95 };
96
97 let family = self.style.font_family.as_deref().unwrap_or("Inter");
98 let typeface = typeface_with_fallback(family, font_style).ok()?;
99 Some(subpixel_font(typeface, font_size))
100 }
101
102 fn resolve_column_widths(&self, total_w: f32, font_size: f32) -> Vec<f32> {
103 let col_count = self.headers.len().max(1);
104 if let Some(widths) = &self.column_widths {
105 let mut result: Vec<f32> = widths.to_vec();
106 while result.len() < col_count {
107 let remaining = total_w - result.iter().sum::<f32>();
108 let remaining_cols = col_count - result.len();
109 result.push(remaining / remaining_cols as f32);
110 }
111 result.truncate(col_count);
112 return result;
113 }
114 let natural = self.natural_column_widths(font_size);
115 let natural_total: f32 = natural.iter().sum();
116 if natural_total <= 0.0 || total_w <= 0.0 {
117 return vec![total_w / col_count as f32; col_count];
118 }
119 let scale = total_w / natural_total;
120 natural.into_iter().map(|w| w * scale).collect()
121 }
122
123 pub(crate) fn natural_column_widths(&self, font_size: f32) -> Vec<f32> {
124 let col_count = self.headers.len().max(1);
125 let font_style = skia_safe::FontStyle::bold();
126 let family = self.style.font_family.as_deref().unwrap_or("Inter");
127 let Ok(typeface) = typeface_with_fallback(family, font_style) else {
128 let min_col_w = DEFAULT_FONT_SIZE * 8.0 + DEFAULT_CELL_PADDING * 2.0;
129 return vec![min_col_w; col_count];
130 };
131 let font = subpixel_font(typeface, font_size);
132 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
133 let cell_padding = self.cell_padding;
134
135 let mut col_widths: Vec<f32> = vec![0.0; col_count];
136 for (i, header) in self.headers.iter().enumerate() {
137 let w = measure_text_with_fallback(header, &font, &emoji_font, 0.0);
138 col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
139 }
140 for row in &self.rows {
141 for (i, cell) in row.iter().enumerate() {
142 if i >= col_count {
143 break;
144 }
145 let w = measure_text_with_fallback(cell, &font, &emoji_font, 0.0);
146 col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
147 }
148 }
149 col_widths
150 }
151
152 fn get_align(&self, col: usize) -> &ColumnAlign {
153 self.column_align
154 .as_ref()
155 .and_then(|a| a.get(col))
156 .unwrap_or(&ColumnAlign::Left)
157 }
158
159 fn align_text_x(&self, col: usize, cell_x: f32, cell_w: f32, text_w: f32) -> f32 {
160 let pad = self.cell_padding;
161 match self.get_align(col) {
162 ColumnAlign::Left => cell_x + pad,
163 ColumnAlign::Center => cell_x + (cell_w - text_w) / 2.0,
164 ColumnAlign::Right => cell_x + cell_w - text_w - pad,
165 }
166 }
167}
168
169impl Table {
170 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) {
171 let w = layout_w;
172 let font_size = self.style.font_size_px_ctx(
173 &crate::intrinsic::font_size_ctx(
174 ctx.video_width as f32,
175 ctx.video_height as f32,
176 w.max(0.0),
177 ),
178 14.0,
179 );
180 let col_count = self.headers.len().max(1);
181 let col_widths = self.resolve_column_widths(w, font_size);
182 let row_h = self.row_height(font_size);
183
184 let header_color = self.header_color.as_deref().unwrap_or("#374151");
185 let border_color = self.border_color.as_deref().unwrap_or("#4B5563");
186 let text_color = self.style.color_str_or("#FFFFFF");
187 let header_text_color = self.header_text_color.as_deref().unwrap_or("#FFFFFF");
188 let default_row_colors = vec!["#1F2937".to_string(), "#111827".to_string()];
189 let row_colors = self
190 .row_colors
191 .as_ref()
192 .filter(|c| !c.is_empty())
193 .unwrap_or(&default_row_colors);
194
195 let Some(header_font) = self.make_font(true, font_size) else {
196 return;
197 };
198 let Some(body_font) = self.make_font(false, font_size) else {
199 return;
200 };
201
202 let radius_px = self.style.border_radius_px_or(0.0);
203 let has_radius = radius_px > 0.0;
204 if has_radius {
205 let rect = Rect::from_xywh(0.0, 0.0, w, layout_h);
206 let rrect = skia_safe::RRect::new_rect_xy(rect, radius_px, radius_px);
207 canvas.save();
208 canvas.clip_rrect(rrect, skia_safe::ClipOp::Intersect, true);
209 }
210
211 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
212 let (_, header_metrics) = header_font.metrics();
213 let header_ascent = -header_metrics.ascent;
214
215 let mut header_bg = paint_from_hex(header_color);
216 header_bg.set_style(PaintStyle::Fill);
217 canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, row_h), &header_bg);
218
219 let mut header_text_paint = paint_from_hex(header_text_color);
220 header_text_paint.set_anti_alias(true);
221
222 let mut col_x = 0.0_f32;
223 for (i, header) in self.headers.iter().enumerate() {
224 let cw = col_widths.get(i).copied().unwrap_or(0.0);
225 let text_w = measure_text_with_fallback(header, &header_font, &emoji_font, 0.0);
226 let x = self.align_text_x(i, col_x, cw, text_w);
227 let y = (row_h - font_size) / 2.0 + header_ascent;
228
229 draw_text_with_fallback(
230 canvas,
231 header,
232 &header_font,
233 &emoji_font,
234 0.0,
235 x,
236 y,
237 &header_text_paint,
238 );
239 col_x += cw;
240 }
241
242 let (_, body_metrics) = body_font.metrics();
243 let body_ascent = -body_metrics.ascent;
244
245 let mut text_paint = paint_from_hex(text_color);
246 text_paint.set_anti_alias(true);
247
248 for (row_idx, row) in self.rows.iter().enumerate() {
249 let y_base = (row_idx + 1) as f32 * row_h;
250
251 let row_color_idx = row_idx % row_colors.len().max(1);
252 let row_bg_color = &row_colors[row_color_idx];
253 let mut row_bg = paint_from_hex(row_bg_color);
254 row_bg.set_style(PaintStyle::Fill);
255 canvas.draw_rect(Rect::from_xywh(0.0, y_base, w, row_h), &row_bg);
256
257 let mut cx = 0.0_f32;
258 for (col_idx, cell) in row.iter().enumerate() {
259 if col_idx >= col_count {
260 break;
261 }
262 let cw = col_widths.get(col_idx).copied().unwrap_or(0.0);
263 let text_w = measure_text_with_fallback(cell, &body_font, &emoji_font, 0.0);
264 let x = self.align_text_x(col_idx, cx, cw, text_w);
265 let y = y_base + (row_h - font_size) / 2.0 + body_ascent;
266
267 draw_text_with_fallback(
268 canvas,
269 cell,
270 &body_font,
271 &emoji_font,
272 0.0,
273 x,
274 y,
275 &text_paint,
276 );
277 cx += cw;
278 }
279 }
280
281 if self.show_borders {
282 let mut border_paint = paint_from_hex(border_color);
283 border_paint.set_style(PaintStyle::Stroke);
284 border_paint.set_stroke_width(1.0);
285
286 let total_h = (1 + self.rows.len()) as f32 * row_h;
287
288 for i in 0..=(self.rows.len() + 1) {
289 let y = i as f32 * row_h;
290 canvas.draw_line((0.0, y), (w, y), &border_paint);
291 }
292
293 let mut vx = 0.0_f32;
294 for i in 0..=col_count {
295 canvas.draw_line((vx, 0.0), (vx, total_h), &border_paint);
296 if i < col_count {
297 vx += col_widths.get(i).copied().unwrap_or(0.0);
298 }
299 }
300 }
301
302 if has_radius {
303 canvas.restore();
304 }
305 }
306}
307
308impl Painter for Table {
309 fn paint_content(
310 &self,
311 canvas: &Canvas,
312 layout: &BoxLayout,
313 _props: &AnimatedProperties,
314 ctx: &PaintCtx,
315 ) {
316 self.paint(canvas, layout.width, layout.height, ctx);
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use rustmotion_core::css::Length;
324
325 #[test]
326 fn rem_font_size_paints_visible_ink() {
327 let table = Table {
328 headers: vec!["A".to_string(), "B".to_string()],
329 rows: vec![vec!["1".to_string(), "2".to_string()]],
330 header_color: None,
331 row_colors: None,
332 border_color: None,
333 header_text_color: None,
334 column_widths: None,
335 column_align: None,
336 cell_padding: DEFAULT_CELL_PADDING,
337 show_borders: true,
338 timing: Default::default(),
339 style: CssStyle {
340 font_size: Some(Length::String("2rem".into())),
341 ..Default::default()
342 },
343 timeline: Vec::new(),
344 stagger: None,
345 };
346 const W: i32 = 400;
347 const H: i32 = 200;
348 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
349 let ctx = PaintCtx {
350 time: 0.0,
351 scenario_time: 0.0,
352 scene_duration: 1.0,
353 frame_index: 0,
354 fps: 30,
355 video_width: 400,
356 video_height: 200,
357 stagger_offset: 0.0,
358 };
359 {
360 let canvas = surface.canvas();
361 table.paint(canvas, W as f32, H as f32, &ctx);
362 }
363 let snapshot = surface.image_snapshot();
364 let info = skia_safe::ImageInfo::new(
365 (W, H),
366 skia_safe::ColorType::RGBA8888,
367 skia_safe::AlphaType::Premul,
368 None,
369 );
370 let mut buf = vec![0u8; (W * H * 4) as usize];
371 let ok = snapshot.read_pixels(
372 &info,
373 &mut buf,
374 (W * 4) as usize,
375 skia_safe::IPoint::new(0, 0),
376 skia_safe::image::CachingHint::Disallow,
377 );
378 assert!(ok, "pixel read should succeed");
379 let text_ink = buf
380 .as_chunks::<4>()
381 .0
382 .iter()
383 .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
384 .count();
385 assert!(
386 text_ink > 10,
387 "table at font-size: 2rem must paint visible header/cell text, got {text_ink} pixels"
388 );
389 }
390}