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