Skip to main content

rustmotion_components/
table.rs

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/// Text alignment for table columns.
16#[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    /// Explicit column widths in pixels. If omitted, columns share width equally.
58    #[serde(default)]
59    pub column_widths: Option<Vec<f32>>,
60    /// Per-column text alignment.
61    #[serde(default)]
62    pub column_align: Option<Vec<ColumnAlign>>,
63    /// Cell padding in pixels (default: 12).
64    #[serde(default = "default_cell_padding")]
65    pub cell_padding: f32,
66    /// Show grid borders (default: true).
67    #[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(skia_safe::Font::from_typeface(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 = skia_safe::Font::from_typeface(typeface, font_size);
132        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(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| skia_safe::Font::from_typeface(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}