Skip to main content

aetna_core/widgets/
table.rs

1//! Table — shadcn-shaped table anatomy.
2//!
3//! The boring path mirrors the common web component shape:
4//! `table([table_header([table_row([...])]), table_body([...])])`.
5//! Rows carry the theme-facing table metrics; `table_header` promotes
6//! direct `table_row` children from body-row metrics to header metrics.
7
8use std::panic::Location;
9
10use super::text::text;
11use crate::metrics::MetricsRole;
12use crate::tokens;
13use crate::tree::*;
14
15#[track_caller]
16pub fn table<I, E>(children: I) -> El
17where
18    I: IntoIterator<Item = E>,
19    E: Into<El>,
20{
21    El::new(Kind::Custom("table"))
22        .at_loc(Location::caller())
23        .children(children)
24        .axis(Axis::Column)
25        .width(Size::Fill(1.0))
26        .height(Size::Hug)
27        .align(Align::Stretch)
28        .clip()
29}
30
31#[track_caller]
32pub fn table_header<I, E>(rows: I) -> El
33where
34    I: IntoIterator<Item = E>,
35    E: Into<El>,
36{
37    let mut header = El::new(Kind::Custom("table_header"))
38        .at_loc(Location::caller())
39        .children(rows)
40        .axis(Axis::Column)
41        .width(Size::Fill(1.0))
42        .height(Size::Hug)
43        .align(Align::Stretch);
44
45    // Promote `table_row(...)` children from body-row metrics to header
46    // metrics. Table chrome lives on the cells, so rows stay hug-height
47    // and stretch their children vertically.
48    for row in &mut header.children {
49        if row.metrics_role == Some(MetricsRole::TableRow) {
50            row.metrics_role = Some(MetricsRole::TableHeader);
51            if !row.explicit_radius {
52                row.radius = crate::tree::Corners::ZERO;
53            }
54        }
55    }
56
57    header
58}
59
60#[track_caller]
61pub fn table_body<I, E>(rows: I) -> El
62where
63    I: IntoIterator<Item = E>,
64    E: Into<El>,
65{
66    El::new(Kind::Custom("table_body"))
67        .at_loc(Location::caller())
68        .children(rows)
69        .axis(Axis::Column)
70        .width(Size::Fill(1.0))
71        .height(Size::Hug)
72        .align(Align::Stretch)
73}
74
75#[track_caller]
76pub fn table_row<I, E>(cells: I) -> El
77where
78    I: IntoIterator<Item = E>,
79    E: Into<El>,
80{
81    row(cells)
82        .at_loc(Location::caller())
83        .metrics_role(MetricsRole::TableRow)
84        .width(Size::Fill(1.0))
85        .height(Size::Hug)
86        .align(Align::Stretch)
87        .default_gap(0.0)
88        .default_radius(0.0)
89}
90
91#[track_caller]
92pub fn table_head(label: impl Into<String>) -> El {
93    table_head_el(text(label))
94}
95
96#[track_caller]
97pub fn table_head_el(content: impl Into<El>) -> El {
98    let mut el = content
99        .into()
100        .at_loc(Location::caller())
101        .ellipsis()
102        .width(Size::Fill(1.0))
103        .height(Size::Hug)
104        .padding(Sides::xy(tokens::SPACE_3, tokens::SPACE_2))
105        .fill(tokens::MUTED)
106        .stroke(tokens::BORDER)
107        .radius(0.0);
108    apply_head_style(&mut el);
109    el
110}
111
112#[track_caller]
113pub fn table_cell(content: impl Into<El>) -> El {
114    content
115        .into()
116        .at_loc(Location::caller())
117        .ellipsis()
118        .width(Size::Fill(1.0))
119        .height(Size::Hug)
120        .padding(Sides::xy(tokens::SPACE_3, tokens::SPACE_2))
121        .stroke(tokens::BORDER)
122        .radius(0.0)
123}
124
125fn apply_head_style(el: &mut El) {
126    if el.kind == Kind::Text {
127        el.text_role = TextRole::Caption;
128        if el.font_weight == FontWeight::Regular {
129            el.font_weight = FontWeight::Medium;
130        }
131        el.text_color = Some(tokens::MUTED_FOREGROUND);
132    }
133    for child in &mut el.children {
134        apply_head_style(child);
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn table_header_promotes_direct_table_rows() {
144        let header = table_header([table_row([table_head("Name")])]);
145
146        assert_eq!(header.children.len(), 1);
147        assert_eq!(
148            header.children[0].metrics_role,
149            Some(MetricsRole::TableHeader)
150        );
151        assert_eq!(header.children[0].align, Align::Stretch);
152    }
153
154    #[test]
155    fn table_head_el_styles_rich_text_children() {
156        let head = table_head_el(text_runs([text("Rich "), text("head").bold()]));
157
158        assert_eq!(head.kind, Kind::Inlines);
159        assert_eq!(head.children[0].text_role, TextRole::Caption);
160        assert_eq!(head.children[0].font_weight, FontWeight::Medium);
161        assert_eq!(head.children[1].text_role, TextRole::Caption);
162        assert_eq!(head.children[1].font_weight, FontWeight::Bold);
163        assert_eq!(head.children[1].text.as_deref(), Some("head"));
164    }
165
166    #[test]
167    fn table_cells_carry_grid_chrome() {
168        let body = table_cell(text("Ada"));
169        assert_eq!(body.padding, Sides::xy(tokens::SPACE_3, tokens::SPACE_2));
170        assert_eq!(body.stroke, Some(tokens::BORDER));
171        assert_eq!(body.stroke_width, 1.0);
172        assert_eq!(body.radius, Corners::ZERO);
173
174        let head = table_head("Name");
175        assert_eq!(head.fill, Some(tokens::MUTED));
176        assert_eq!(head.stroke, Some(tokens::BORDER));
177    }
178
179    #[test]
180    fn table_header_text_emits_glyph_run_after_layout() {
181        use crate::Rect;
182        use crate::draw_ops::draw_ops;
183        use crate::ir::DrawOp;
184        use crate::layout::layout;
185        use crate::state::UiState;
186
187        let mut tree = table([
188            table_header([table_row([table_head("Name"), table_head("Role")])]),
189            table_body([table_row([
190                table_cell(text("Ada")),
191                table_cell(text("dev")),
192            ])]),
193        ]);
194        let mut state = UiState::new();
195        layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 320.0, 200.0));
196
197        let ops = draw_ops(&tree, &state);
198        assert!(
199            ops.iter().any(|op| matches!(
200                op,
201                DrawOp::GlyphRun { text, .. } if text == "Name"
202            )),
203            "expected header text to be painted; ops were {ops:?}"
204        );
205        let border_quads = ops
206            .iter()
207            .filter(|op| matches!(op, DrawOp::Quad { id, .. } if id.contains("text")))
208            .count();
209        assert!(
210            border_quads >= 4,
211            "expected cell chrome quads for the table cells, got {border_quads}"
212        );
213    }
214}