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;
27/// row_height = DEFAULT_FONT_SIZE * 2.5
28pub(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    /// Explicit column widths in pixels. If omitted, columns share width equally.
52    #[serde(default)]
53    pub column_widths: Option<Vec<f32>>,
54    /// Per-column text alignment.
55    #[serde(default)]
56    pub column_align: Option<Vec<ColumnAlign>>,
57    /// Cell padding in pixels (default: 12).
58    #[serde(default = "default_cell_padding")]
59    pub cell_padding: f32,
60    /// Show grid borders (default: true).
61    #[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    /// `font_size` is resolved once by the caller (`paint`, against a real
81    /// `LengthContext`) and passed in — this used to be a zero-argument
82    /// method independently re-deriving the value via the context-free
83    /// `font_size_px_or` at every call site (`row_height`, `make_font`,
84    /// `paint` itself), which is exactly the kind of duplicate computation
85    /// that let a relative unit silently diverge (lot B, wave S).
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    /// Resolve column widths: use explicit widths if provided, else equal distribution.
103    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            // Pad with equal-share for missing columns
108            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    /// Get alignment for a specific column.
121    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    /// Compute the x position for text given alignment, cell x, cell width, text width, and padding.
129    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        // Resolved once, against the real per-frame viewport (`rem`/`vw`/
143        // `vh` on `font-size` now resolve instead of silently dropping to
144        // 0px — lot B, wave S) and threaded through every call below that
145        // used to independently re-derive it via `font_size_px_or`.
146        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        // `"row_colors": []` deserializes to Some(vec![]), not None — a generator
164        // writes it to mean "no striping". Row painting indexes this slice, so an
165        // empty one has to fall back rather than reach the painter.
166        let row_colors = self
167            .row_colors
168            .as_ref()
169            .filter(|c| !c.is_empty())
170            .unwrap_or(&default_row_colors);
171
172        // Resolve fonts before the optional clip below so an early return on
173        // font failure keeps canvas save/restore balanced.
174        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        // Clip to rounded rect if border-radius is set
182        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        // Header row
192        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        // Data rows
224        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            // Row background
234            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            // Cell text
241            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        // Grid lines (only if show_borders is true)
266        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            // Horizontal lines
274            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            // Vertical lines
280            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    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
313
314    #[test]
315    fn rem_font_size_paints_visible_ink() {
316        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
317        // context-free `font_size_px_or`.
318        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        // Header text is white (#FFFFFF) on a #374151 header background —
371        // probe specifically for near-white text ink rather than any lit
372        // pixel (the header/row backgrounds paint regardless of font-size).
373        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}