Skip to main content

rosace_widgets/tree/
table.rs

1//! `Table` (D115/Phase 32 Step 1) — a LAYOUT table: per-column sizing
2//! (auto/fixed/flex) with row alignment, the widget-tree analogue of an
3//! HTML `<table>` used purely for arrangement.
4//!
5//! Deliberately distinct from the future `DataTable` (the data-grid that
6//! renders headers/sorting on top of a layout primitive like this one —
7//! see PHASE_32.md's Out of Scope note). `Table` knows nothing about
8//! data: rows are plain widget lists.
9//!
10//! Cells align top-left within their resolved column width; row height is
11//! the tallest cell of that row.
12
13use rosace_core::types::{Point, Rect, Size};
14use rosace_layout::Constraints;
15use rosace_render::Color;
16
17use super::{avail_w, BoxedWidget, Children, LayoutCtx, PaintCtx, Widget};
18
19/// How a [`Table`] column resolves its width.
20#[derive(Clone, Copy, Debug, PartialEq)]
21enum ColumnSizing {
22    /// Width = the widest intrinsic (loose-measured) cell of the column.
23    Auto,
24    /// Width = a fixed number of logical px.
25    Fixed(f32),
26    /// Width = this factor's share of the space left after fixed/auto
27    /// columns and gaps.
28    Flex(f32),
29}
30
31/// Width policy for one [`Table`] column — construct via
32/// [`TableColumn::auto`] / [`TableColumn::fixed`] / [`TableColumn::flex`].
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct TableColumn {
35    sizing: ColumnSizing,
36}
37
38impl TableColumn {
39    /// Column sized to its widest cell (each cell measured with loose
40    /// constraints — its intrinsic width).
41    pub fn auto() -> Self { Self { sizing: ColumnSizing::Auto } }
42    /// Column with a fixed pixel width.
43    pub fn fixed(px: f32) -> Self { Self { sizing: ColumnSizing::Fixed(px.max(0.0)) } }
44    /// Column taking `factor`'s share of the leftover width (after fixed +
45    /// auto columns and spacing). With an unbounded available width, flex
46    /// columns fall back to their intrinsic (auto) width — there is no
47    /// finite leftover to share.
48    pub fn flex(factor: f32) -> Self { Self { sizing: ColumnSizing::Flex(factor.max(0.0)) } }
49}
50
51/// A layout table: declare columns with [`Table::column`], then add rows of
52/// widgets with [`Table::row`]. Rows shorter than the column list leave the
53/// remaining cells empty; extra cells beyond the column list are ignored.
54pub struct Table {
55    columns: Vec<TableColumn>,
56    /// All cells, flattened row-major; `row_lens` records each row's length.
57    cells: Vec<BoxedWidget>,
58    row_lens: Vec<usize>,
59    h_spacing: f32,
60    v_spacing: f32,
61    /// Uniform padding inside every cell (logical px).
62    cell_padding: f32,
63    /// Zebra striping: fill for every ODD row (1, 3, 5, …).
64    row_background: Option<Color>,
65    /// Hairline between rows; `0.0` = none.
66    divider_width: f32,
67    divider_color: Option<Color>,
68}
69
70impl Table {
71    /// An empty table — add columns and rows with the builders below.
72    pub fn new() -> Self {
73        Self {
74            columns: Vec::new(),
75            cells: Vec::new(),
76            row_lens: Vec::new(),
77            h_spacing: 8.0,
78            v_spacing: 8.0,
79            cell_padding: 0.0,
80            row_background: None,
81            divider_width: 0.0,
82            divider_color: None,
83        }
84    }
85    /// Append one column definition.
86    pub fn column(mut self, c: TableColumn) -> Self { self.columns.push(c); self }
87    /// Append several column definitions.
88    pub fn columns(mut self, cs: Vec<TableColumn>) -> Self { self.columns.extend(cs); self }
89    /// Append a row of cell widgets (one per column, left to right).
90    pub fn row(mut self, cells: Vec<BoxedWidget>) -> Self {
91        self.row_lens.push(cells.len());
92        self.cells.extend(cells);
93        self
94    }
95    /// `count` rows, each built by calling `builder(i)` for its cell
96    /// widgets (one per column, left to right) — the same convenience
97    /// constructor `Grid::builder`/`Carousel::builder`/`ListView::builder`
98    /// have. Eager, not virtualized.
99    pub fn row_builder(mut self, count: usize, builder: impl Fn(usize) -> Vec<BoxedWidget>) -> Self {
100        for i in 0..count {
101            self = self.row(builder(i));
102        }
103        self
104    }
105    /// Horizontal gap between columns / vertical gap between rows.
106    pub fn spacing(mut self, h: f32, v: f32) -> Self {
107        self.h_spacing = h.max(0.0);
108        self.v_spacing = v.max(0.0);
109        self
110    }
111    /// Uniform padding inside every cell (logical px).
112    pub fn cell_padding(mut self, p: f32) -> Self { self.cell_padding = p.max(0.0); self }
113    /// Zebra striping: fill every odd row (1, 3, 5, …) with `c`.
114    pub fn row_background(mut self, c: Color) -> Self { self.row_background = Some(c); self }
115    /// Draw a hairline of `width` px between rows (centered in the
116    /// vertical gap). Color defaults to the theme's `outline`.
117    pub fn divider(mut self, width: f32) -> Self { self.divider_width = width.max(0.0); self }
118    /// Override the divider hairline color.
119    pub fn divider_color(mut self, c: Color) -> Self { self.divider_color = Some(c); self }
120
121    /// Range of `self.cells` belonging to row `r`.
122    fn row_range(&self, r: usize) -> std::ops::Range<usize> {
123        let start: usize = self.row_lens[..r].iter().sum();
124        start..start + self.row_lens[r]
125    }
126
127    /// Cell widget at (row, col), if that row has one.
128    fn cell(&self, row: usize, col: usize) -> Option<&BoxedWidget> {
129        let range = self.row_range(row);
130        if col < self.row_lens[row] { self.cells.get(range.start + col) } else { None }
131    }
132
133    /// Resolve every column's width for `total_w` available px.
134    ///
135    /// fixed = as declared; auto = widest loose-measured cell + padding;
136    /// flex = share of the leftover (intrinsic width when `total_w` is
137    /// unbounded — documented on [`TableColumn::flex`]).
138    fn resolve_columns(&self, ctx: &LayoutCtx, total_w: f32) -> Vec<f32> {
139        let n = self.columns.len();
140        let gaps = self.h_spacing * n.saturating_sub(1) as f32;
141        let pad2 = self.cell_padding * 2.0;
142        let bounded = total_w.is_finite();
143        let measure_w = if bounded { total_w } else { f32::MAX };
144
145        // Intrinsic width of column `i` = widest cell, loose-measured.
146        let intrinsic = |i: usize| -> f32 {
147            let mut w = 0.0f32;
148            for row in 0..self.row_lens.len() {
149                if let Some(cell) = self.cell(row, i) {
150                    let s = cell.layout(&ctx.with_constraints(
151                        Constraints::loose(measure_w, f32::INFINITY),
152                    ));
153                    w = w.max(s.width);
154                }
155            }
156            w + pad2
157        };
158
159        let mut widths = vec![0.0f32; n];
160        let mut flex_sum = 0.0f32;
161        let mut used = 0.0f32;
162        for (i, col) in self.columns.iter().enumerate() {
163            match col.sizing {
164                ColumnSizing::Fixed(px) => { widths[i] = px; used += px; }
165                ColumnSizing::Auto => { widths[i] = intrinsic(i); used += widths[i]; }
166                ColumnSizing::Flex(_) if !bounded => {
167                    // No finite leftover to share — intrinsic fallback.
168                    widths[i] = intrinsic(i);
169                    used += widths[i];
170                }
171                ColumnSizing::Flex(f) => flex_sum += f,
172            }
173        }
174        if bounded && flex_sum > 0.0 {
175            let leftover = (total_w - used - gaps).max(0.0);
176            for (i, col) in self.columns.iter().enumerate() {
177                if let ColumnSizing::Flex(f) = col.sizing {
178                    widths[i] = leftover * (f / flex_sum);
179                }
180            }
181        }
182        widths
183    }
184
185    /// Per-row heights at the given resolved column widths: the tallest
186    /// cell of the row (measured at the column's content width) + padding.
187    fn row_heights(&self, ctx: &LayoutCtx, widths: &[f32]) -> Vec<f32> {
188        let pad2 = self.cell_padding * 2.0;
189        (0..self.row_lens.len())
190            .map(|row| {
191                let mut h = 0.0f32;
192                for (col, w) in widths.iter().enumerate() {
193                    if let Some(cell) = self.cell(row, col) {
194                        let s = cell.layout(&ctx.with_constraints(
195                            Constraints::loose((w - pad2).max(0.0), f32::INFINITY),
196                        ));
197                        h = h.max(s.height);
198                    }
199                }
200                h + pad2
201            })
202            .collect()
203    }
204
205    /// Content size for a given available width.
206    fn content_size(&self, ctx: &LayoutCtx, total_w: f32) -> Size {
207        let widths = self.resolve_columns(ctx, total_w);
208        let heights = self.row_heights(ctx, &widths);
209        let gaps_w = self.h_spacing * self.columns.len().saturating_sub(1) as f32;
210        let gaps_h = self.v_spacing * heights.len().saturating_sub(1) as f32;
211        Size {
212            width: widths.iter().sum::<f32>() + gaps_w,
213            height: heights.iter().sum::<f32>() + gaps_h,
214        }
215    }
216
217    /// Whether any column is flex-sized (→ the table claims the full
218    /// available width, like `Grid`/`Wrap` do).
219    fn has_flex(&self) -> bool {
220        self.columns.iter().any(|c| matches!(c.sizing, ColumnSizing::Flex(_)))
221    }
222}
223
224impl Default for Table {
225    fn default() -> Self { Self::new() }
226}
227
228impl Widget for Table {
229    fn children(&self) -> Children<'_> { Children::Many(&self.cells) }
230
231    fn layout(&self, ctx: &LayoutCtx) -> Size {
232        let w = avail_w(ctx.constraints);
233        let content = self.content_size(ctx, w);
234        let width = if self.has_flex() && w.is_finite() { w } else { content.width };
235        ctx.constraints.constrain(Size { width, height: content.height })
236    }
237
238    fn paint(&self, ctx: &mut PaintCtx) {
239        // Hoisted theme reads (the borrow must end before mutable painting).
240        let divider = self
241            .divider_color
242            .unwrap_or_else(|| ctx.tc(ctx.theme.colors.outline));
243
244        let r = ctx.rect;
245        // Scoped so the immutable layout borrow ends before mutable painting.
246        let (widths, heights) = {
247            let lctx = ctx.layout_ctx(Constraints::loose(r.size.width, f32::INFINITY));
248            let widths = self.resolve_columns(&lctx, r.size.width);
249            let heights = self.row_heights(&lctx, &widths);
250            (widths, heights)
251        };
252
253        let pad = self.cell_padding;
254        let mut y = r.origin.y;
255        for (row, row_h) in heights.iter().enumerate() {
256            // Zebra stripe on odd rows.
257            if row % 2 == 1 {
258                if let Some(bg) = self.row_background {
259                    ctx.fill_rect(
260                        Rect {
261                            origin: Point { x: r.origin.x, y },
262                            size: Size { width: r.size.width, height: *row_h },
263                        },
264                        bg,
265                    );
266                }
267            }
268
269            let mut x = r.origin.x;
270            for (col, w) in widths.iter().enumerate() {
271                if let Some(cell) = self.cell(row, col) {
272                    let content_w = (w - pad * 2.0).max(0.0);
273                    let s = cell.layout(&ctx.layout_ctx(
274                        Constraints::loose(content_w, f32::INFINITY),
275                    ));
276                    // Top-left alignment within the cell.
277                    let rect = Rect {
278                        origin: Point { x: x + pad, y: y + pad },
279                        size: Size { width: s.width.min(content_w), height: s.height },
280                    };
281                    cell.paint(&mut ctx.child(rect));
282                }
283                x += w + self.h_spacing;
284            }
285
286            y += row_h;
287            // Divider centered in the vertical gap after every row but the last.
288            if row + 1 < heights.len() {
289                if self.divider_width > 0.0 {
290                    let dy = y + ((self.v_spacing - self.divider_width) / 2.0).max(0.0);
291                    ctx.fill_rect(
292                        Rect {
293                            origin: Point { x: r.origin.x, y: dy },
294                            size: Size { width: r.size.width, height: self.divider_width },
295                        },
296                        divider,
297                    );
298                }
299                y += self.v_spacing;
300            }
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// A leaf reporting a fixed size regardless of constraints.
310    struct FixedCell(f32, f32);
311    impl Widget for FixedCell {
312        fn layout(&self, _ctx: &LayoutCtx) -> Size {
313            Size { width: self.0, height: self.1 }
314        }
315        fn paint(&self, _ctx: &mut PaintCtx) {}
316    }
317
318    fn boxed(w: f32, h: f32) -> BoxedWidget { Box::new(FixedCell(w, h)) }
319
320    fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
321        (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
322    }
323
324    #[test]
325    fn fixed_auto_and_flex_columns_resolve_in_a_300px_width() {
326        // fixed(100) + auto (widest cell 50) + flex(1) with 10px gaps:
327        // leftover = 300 - 100 - 50 - 2*10 = 130.
328        let table = Table::new()
329            .column(TableColumn::fixed(100.0))
330            .column(TableColumn::auto())
331            .column(TableColumn::flex(1.0))
332            .spacing(10.0, 0.0)
333            .row(vec![boxed(40.0, 20.0), boxed(50.0, 30.0), boxed(10.0, 10.0)])
334            .row(vec![boxed(80.0, 15.0), boxed(30.0, 12.0), boxed(10.0, 10.0)]);
335        let (font, theme) = test_env();
336        let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
337        let widths = table.resolve_columns(&ctx, 300.0);
338        assert_eq!(widths, vec![100.0, 50.0, 130.0]);
339        // Flex column present → table claims the full available width.
340        assert_eq!(table.layout(&ctx).width, 300.0);
341    }
342
343    #[test]
344    fn two_flex_columns_share_leftover_by_factor() {
345        // fixed(60) + flex(1) + flex(3), no gaps: leftover = 240 → 60/180.
346        let table = Table::new()
347            .column(TableColumn::fixed(60.0))
348            .column(TableColumn::flex(1.0))
349            .column(TableColumn::flex(3.0))
350            .spacing(0.0, 0.0)
351            .row(vec![boxed(10.0, 10.0), boxed(10.0, 10.0), boxed(10.0, 10.0)]);
352        let (font, theme) = test_env();
353        let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
354        assert_eq!(table.resolve_columns(&ctx, 300.0), vec![60.0, 60.0, 180.0]);
355    }
356
357    #[test]
358    fn row_height_is_the_tallest_cell_of_each_row() {
359        let table = Table::new()
360            .column(TableColumn::fixed(100.0))
361            .column(TableColumn::fixed(100.0))
362            .spacing(0.0, 10.0)
363            .row(vec![boxed(40.0, 20.0), boxed(50.0, 44.0)])
364            .row(vec![boxed(40.0, 16.0), boxed(50.0, 8.0)]);
365        let (font, theme) = test_env();
366        let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
367        let heights = table.row_heights(&ctx, &[100.0, 100.0]);
368        assert_eq!(heights, vec![44.0, 16.0]);
369        // Total = 44 + 10 (v_spacing) + 16.
370        assert_eq!(table.layout(&ctx).height, 70.0);
371    }
372
373    #[test]
374    fn cell_padding_grows_auto_columns_and_row_heights() {
375        let table = Table::new()
376            .column(TableColumn::auto())
377            .cell_padding(6.0)
378            .row(vec![boxed(50.0, 20.0)]);
379        let (font, theme) = test_env();
380        let ctx = LayoutCtx::new(Constraints::loose(300.0, 1000.0), &font, &theme);
381        assert_eq!(table.resolve_columns(&ctx, 300.0), vec![62.0]);
382        assert_eq!(table.layout(&ctx).height, 32.0);
383        // No flex column → content width, not the full 300.
384        assert_eq!(table.layout(&ctx).width, 62.0);
385    }
386}