Skip to main content

egui_shadcn/
table.rs

1//! Table component - display tabular data with shadcn styling.
2//!
3//! # Example
4//! ```ignore
5//! table(ui, &theme, TableProps::default(), |ui, ctx| {
6//!     table_header(ui, ctx, |ui| {
7//!         table_row(ui, ctx, TableRowProps::new("head"), |ui| {
8//!             table_head(ui, ctx, TableCellProps::default(), |ui| {
9//!                 ui.label("Invoice");
10//!             });
11//!         });
12//!     });
13//! });
14//! ```
15
16use crate::theme::Theme;
17use egui::{
18    Align, Color32, CornerRadius, Frame, Layout, Margin, Response, Sense, Stroke, Ui, Vec2, vec2,
19};
20use std::hash::Hash;
21
22// =============================================================================
23// Table size + variant
24// =============================================================================
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27pub enum TableSize {
28    Size1,
29    #[default]
30    Size2,
31    Size3,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
35pub enum TableVariant {
36    #[default]
37    Default,
38    Muted,
39}
40
41// =============================================================================
42// Props
43// =============================================================================
44
45#[derive(Clone, Debug)]
46pub struct TableProps {
47    pub size: TableSize,
48    pub variant: TableVariant,
49}
50
51impl Default for TableProps {
52    fn default() -> Self {
53        Self {
54            size: TableSize::Size2,
55            variant: TableVariant::Default,
56        }
57    }
58}
59
60impl TableProps {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    pub fn size(mut self, size: TableSize) -> Self {
66        self.size = size;
67        self
68    }
69
70    pub fn variant(mut self, variant: TableVariant) -> Self {
71        self.variant = variant;
72        self
73    }
74}
75
76#[derive(Clone, Copy, Debug)]
77pub struct TableRowProps<IdSource> {
78    pub id_source: IdSource,
79    pub selected: bool,
80    pub hoverable: bool,
81}
82
83impl<IdSource> TableRowProps<IdSource> {
84    pub fn new(id_source: IdSource) -> Self {
85        Self {
86            id_source,
87            selected: false,
88            hoverable: true,
89        }
90    }
91
92    pub fn selected(mut self, selected: bool) -> Self {
93        self.selected = selected;
94        self
95    }
96
97    pub fn hoverable(mut self, hoverable: bool) -> Self {
98        self.hoverable = hoverable;
99        self
100    }
101}
102
103#[derive(Clone, Copy, Debug, Default)]
104pub struct TableCellProps {
105    pub checkbox: bool,
106    pub fill: bool,
107}
108
109impl TableCellProps {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    pub fn checkbox(mut self, checkbox: bool) -> Self {
115        self.checkbox = checkbox;
116        self
117    }
118
119    pub fn fill(mut self, fill: bool) -> Self {
120        self.fill = fill;
121        self
122    }
123}
124
125// =============================================================================
126// Table context + tokens
127// =============================================================================
128
129#[derive(Clone, Copy, Debug)]
130pub struct TableContext {
131    pub size: TableSize,
132    pub variant: TableVariant,
133    tokens: TableTokens,
134    metrics: TableMetrics,
135}
136
137#[derive(Clone, Copy, Debug)]
138struct TableTokens {
139    border: Color32,
140    text: Color32,
141    text_muted: Color32,
142    hover_bg: Color32,
143    selected_bg: Color32,
144    footer_bg: Color32,
145    container_bg: Color32,
146}
147
148#[derive(Clone, Copy, Debug)]
149struct TableMetrics {
150    row_height: f32,
151    cell_padding: Margin,
152    checkbox_padding: Margin,
153    caption_gap: f32,
154}
155
156fn table_tokens(theme: &Theme, variant: TableVariant) -> TableTokens {
157    let palette = &theme.palette;
158    let container_bg = match variant {
159        TableVariant::Default => Color32::TRANSPARENT,
160        TableVariant::Muted => palette.muted.gamma_multiply(0.2),
161    };
162    TableTokens {
163        border: palette.border,
164        text: palette.foreground,
165        text_muted: palette.muted_foreground,
166        hover_bg: palette.muted.gamma_multiply(0.5),
167        selected_bg: palette.muted.gamma_multiply(0.7),
168        footer_bg: palette.muted.gamma_multiply(0.5),
169        container_bg,
170    }
171}
172
173fn table_metrics(size: TableSize) -> TableMetrics {
174    match size {
175        TableSize::Size1 => TableMetrics {
176            row_height: 32.0,
177            cell_padding: Margin::symmetric(6, 4),
178            checkbox_padding: Margin::symmetric(6, 4),
179            caption_gap: 12.0,
180        },
181        TableSize::Size2 => TableMetrics {
182            row_height: 40.0,
183            cell_padding: Margin::symmetric(8, 6),
184            checkbox_padding: Margin::symmetric(8, 6),
185            caption_gap: 16.0,
186        },
187        TableSize::Size3 => TableMetrics {
188            row_height: 48.0,
189            cell_padding: Margin::symmetric(10, 8),
190            checkbox_padding: Margin::symmetric(10, 8),
191            caption_gap: 20.0,
192        },
193    }
194}
195
196// =============================================================================
197// Table API
198// =============================================================================
199
200pub fn table<R>(
201    ui: &mut Ui,
202    theme: &Theme,
203    props: TableProps,
204    add_contents: impl FnOnce(&mut Ui, &TableContext) -> R,
205) -> R {
206    let tokens = table_tokens(theme, props.variant);
207    let metrics = table_metrics(props.size);
208    let ctx = TableContext {
209        size: props.size,
210        variant: props.variant,
211        tokens,
212        metrics,
213    };
214
215    Frame::NONE
216        .fill(tokens.container_bg)
217        .show(ui, |table_ui| {
218            table_ui.visuals_mut().override_text_color = Some(tokens.text);
219            table_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
220            add_contents(table_ui, &ctx)
221        })
222        .inner
223}
224
225pub fn table_header<R>(
226    ui: &mut Ui,
227    _ctx: &TableContext,
228    add_contents: impl FnOnce(&mut Ui) -> R,
229) -> R {
230    ui.vertical(|header_ui| {
231        header_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
232        add_contents(header_ui)
233    })
234    .inner
235}
236
237pub fn table_body<R>(
238    ui: &mut Ui,
239    _ctx: &TableContext,
240    add_contents: impl FnOnce(&mut Ui) -> R,
241) -> R {
242    ui.vertical(|body_ui| {
243        body_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
244        add_contents(body_ui)
245    })
246    .inner
247}
248
249pub fn table_footer<R>(
250    ui: &mut Ui,
251    ctx: &TableContext,
252    add_contents: impl FnOnce(&mut Ui) -> R,
253) -> R {
254    Frame::NONE
255        .fill(ctx.tokens.footer_bg)
256        .show(ui, |footer_ui| {
257            footer_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
258            add_contents(footer_ui)
259        })
260        .inner
261}
262
263pub fn table_row<R, IdSource: Hash>(
264    ui: &mut Ui,
265    ctx: &TableContext,
266    props: TableRowProps<IdSource>,
267    add_contents: impl FnOnce(&mut Ui) -> R,
268) -> TableRowResponse<R> {
269    let row_height = ctx.metrics.row_height;
270    let desired_size = Vec2::new(ui.available_width(), row_height);
271    let row_id = ui.make_persistent_id(props.id_source);
272
273    let inner = ui.allocate_ui_with_layout(
274        desired_size,
275        Layout::left_to_right(Align::Center),
276        |row_ui| {
277            row_ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
278            let rect = row_ui.max_rect();
279            let response = row_ui.interact(rect, row_id, Sense::hover());
280            let hover = props.hoverable && response.hovered();
281            let fill = if props.selected {
282                ctx.tokens.selected_bg
283            } else if hover {
284                ctx.tokens.hover_bg
285            } else {
286                Color32::TRANSPARENT
287            };
288
289            if fill != Color32::TRANSPARENT {
290                row_ui
291                    .painter()
292                    .rect_filled(rect, CornerRadius::same(0), fill);
293            }
294            row_ui.painter().line_segment(
295                [rect.left_bottom(), rect.right_bottom()],
296                Stroke::new(1.0, ctx.tokens.border),
297            );
298
299            let contents = add_contents(row_ui);
300            (contents, response)
301        },
302    );
303
304    TableRowResponse {
305        inner: inner.inner.0,
306        response: inner.inner.1,
307    }
308}
309
310pub struct TableRowResponse<R> {
311    pub inner: R,
312    pub response: Response,
313}
314
315pub fn table_head<R>(
316    ui: &mut Ui,
317    ctx: &TableContext,
318    props: TableCellProps,
319    add_contents: impl FnOnce(&mut Ui) -> R,
320) -> R {
321    let padding = if props.checkbox {
322        ctx.metrics.checkbox_padding
323    } else {
324        ctx.metrics.cell_padding
325    };
326    let render = |cell_ui: &mut Ui| {
327        Frame::NONE
328            .inner_margin(padding)
329            .show(cell_ui, |inner_ui| {
330                inner_ui.visuals_mut().override_text_color = Some(ctx.tokens.text_muted);
331                inner_ui
332                    .with_layout(Layout::left_to_right(Align::Center), |inner_ui| {
333                        add_contents(inner_ui)
334                    })
335                    .inner
336            })
337            .inner
338    };
339
340    if props.fill {
341        let desired = vec2(ui.available_width(), ui.available_height());
342        ui.allocate_ui_with_layout(desired, Layout::left_to_right(Align::Center), |cell_ui| {
343            render(cell_ui)
344        })
345        .inner
346    } else {
347        render(ui)
348    }
349}
350
351pub fn table_cell<R>(
352    ui: &mut Ui,
353    ctx: &TableContext,
354    props: TableCellProps,
355    add_contents: impl FnOnce(&mut Ui) -> R,
356) -> R {
357    let padding = if props.checkbox {
358        ctx.metrics.checkbox_padding
359    } else {
360        ctx.metrics.cell_padding
361    };
362    let render = |cell_ui: &mut Ui| {
363        Frame::NONE
364            .inner_margin(padding)
365            .show(cell_ui, |inner_ui| {
366                inner_ui
367                    .with_layout(Layout::left_to_right(Align::Center), |inner_ui| {
368                        add_contents(inner_ui)
369                    })
370                    .inner
371            })
372            .inner
373    };
374
375    if props.fill {
376        let desired = vec2(ui.available_width(), ui.available_height());
377        ui.allocate_ui_with_layout(desired, Layout::left_to_right(Align::Center), |cell_ui| {
378            render(cell_ui)
379        })
380        .inner
381    } else {
382        render(ui)
383    }
384}
385
386pub fn table_caption(ui: &mut Ui, ctx: &TableContext, text: &str) -> Response {
387    ui.add_space(ctx.metrics.caption_gap);
388    ui.label(egui::RichText::new(text).color(ctx.tokens.text_muted))
389}