Skip to main content

egui_shadcn/
data_table.rs

1//! Data table component with sorting, filtering, selection, and pagination.
2
3use crate::button::{Button, ButtonSize, ButtonVariant};
4use crate::checkbox::{CheckboxCycle, CheckboxProps, CheckboxState, checkbox_with_props};
5use crate::dropdown_menu::{
6    DropdownMenuCheckboxItemProps, DropdownMenuProps, DropdownMenuTriggerProps, dropdown_menu,
7    dropdown_menu_checkbox_item, dropdown_menu_trigger,
8};
9use crate::input::Input;
10use crate::pagination::{
11    PaginationLinkProps, PaginationProps, pagination, pagination_content, pagination_ellipsis,
12    pagination_item, pagination_link, pagination_next, pagination_previous,
13};
14use crate::table::{
15    TableCellProps, TableProps, TableRowProps, table, table_body, table_cell, table_head,
16    table_header, table_row,
17};
18use crate::theme::Theme;
19use egui::{Align, Direction, Id, Label, Layout, RichText, Sense, Ui, WidgetText};
20use std::cmp::Ordering;
21use std::collections::HashSet;
22use std::fmt;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum SortDirection {
26    Asc,
27    Desc,
28}
29
30#[derive(Clone, Debug, PartialEq)]
31pub enum SortValue {
32    Str(String),
33    Num(f64),
34    Bool(bool),
35}
36
37impl fmt::Display for SortValue {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            SortValue::Str(value) => write!(f, "{value}"),
41            SortValue::Num(value) => write!(f, "{value}"),
42            SortValue::Bool(value) => write!(f, "{value}"),
43        }
44    }
45}
46
47impl SortValue {
48    fn cmp(&self, other: &Self) -> Ordering {
49        match (self, other) {
50            (SortValue::Num(a), SortValue::Num(b)) => a.total_cmp(b),
51            (SortValue::Bool(a), SortValue::Bool(b)) => a.cmp(b),
52            (SortValue::Str(a), SortValue::Str(b)) => a.to_lowercase().cmp(&b.to_lowercase()),
53            _ => self
54                .to_string()
55                .to_lowercase()
56                .cmp(&other.to_string().to_lowercase()),
57        }
58    }
59}
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum DataTableAlign {
62    #[default]
63    Left,
64    Center,
65    Right,
66}
67
68#[allow(clippy::type_complexity)]
69pub struct DataTableColumn<'a, T> {
70    pub id: String,
71    pub label: String,
72    pub header: WidgetText,
73    pub cell: Box<dyn Fn(&mut Ui, &T) + 'a>,
74    pub sort_value: Option<Box<dyn Fn(&T) -> SortValue + 'a>>,
75    pub filter_value: Option<Box<dyn Fn(&T) -> String + 'a>>,
76    pub hideable: bool,
77    pub width: Option<f32>,
78    pub align: DataTableAlign,
79}
80
81impl<'a, T> DataTableColumn<'a, T> {
82    pub fn new(
83        id: impl Into<String>,
84        header: impl Into<String>,
85        cell: impl Fn(&mut Ui, &T) + 'a,
86    ) -> Self {
87        let label = header.into();
88        let header_text = WidgetText::from(RichText::new(label.clone()).strong());
89        Self {
90            id: id.into(),
91            label,
92            header: header_text,
93            cell: Box::new(cell),
94            sort_value: None,
95            filter_value: None,
96            hideable: true,
97            width: None,
98            align: DataTableAlign::Left,
99        }
100    }
101
102    pub fn header(mut self, header: impl Into<WidgetText>) -> Self {
103        self.header = header.into();
104        self
105    }
106
107    pub fn sort_by(mut self, sort_value: impl Fn(&T) -> SortValue + 'a) -> Self {
108        self.sort_value = Some(Box::new(sort_value));
109        self
110    }
111
112    pub fn filter_by(mut self, filter_value: impl Fn(&T) -> String + 'a) -> Self {
113        self.filter_value = Some(Box::new(filter_value));
114        self
115    }
116
117    pub fn hideable(mut self, hideable: bool) -> Self {
118        self.hideable = hideable;
119        self
120    }
121
122    pub fn width(mut self, width: f32) -> Self {
123        self.width = Some(width);
124        self
125    }
126
127    pub fn align(mut self, align: DataTableAlign) -> Self {
128        self.align = align;
129        self
130    }
131}
132
133#[allow(clippy::type_complexity)]
134pub struct DataTableProps<'a, T> {
135    pub id_source: Id,
136    pub columns: Vec<DataTableColumn<'a, T>>,
137    pub data: &'a [T],
138    pub page_size: usize,
139    pub filter_placeholder: &'a str,
140    pub filter_fn: Option<Box<dyn Fn(&T, &str) -> bool + 'a>>,
141    pub enable_selection: bool,
142    pub show_column_toggle: bool,
143}
144
145impl<'a, T> DataTableProps<'a, T> {
146    pub fn new(id_source: Id, columns: Vec<DataTableColumn<'a, T>>, data: &'a [T]) -> Self {
147        Self {
148            id_source,
149            columns,
150            data,
151            page_size: 10,
152            filter_placeholder: "Filter...",
153            filter_fn: None,
154            enable_selection: true,
155            show_column_toggle: true,
156        }
157    }
158
159    pub fn page_size(mut self, page_size: usize) -> Self {
160        self.page_size = page_size;
161        self
162    }
163
164    pub fn filter_placeholder(mut self, placeholder: &'a str) -> Self {
165        self.filter_placeholder = placeholder;
166        self
167    }
168
169    pub fn filter_fn(mut self, filter_fn: impl Fn(&T, &str) -> bool + 'a) -> Self {
170        self.filter_fn = Some(Box::new(filter_fn));
171        self
172    }
173
174    pub fn enable_selection(mut self, enable: bool) -> Self {
175        self.enable_selection = enable;
176        self
177    }
178
179    pub fn show_column_toggle(mut self, show: bool) -> Self {
180        self.show_column_toggle = show;
181        self
182    }
183}
184
185#[derive(Clone, Debug, Default)]
186struct DataTableState {
187    page: usize,
188    page_size: usize,
189    filter: String,
190    sort: Option<(usize, SortDirection)>,
191    column_visibility: Vec<bool>,
192    selected: HashSet<usize>,
193}
194
195#[derive(Clone, Debug)]
196pub struct DataTableResponse {
197    pub selected: Vec<usize>,
198    pub filtered_rows: usize,
199    pub total_rows: usize,
200    pub page: usize,
201    pub page_count: usize,
202}
203
204enum PageItem {
205    Page(usize),
206    Ellipsis,
207}
208
209fn pagination_items(current: usize, total: usize) -> Vec<PageItem> {
210    if total <= 7 {
211        return (1..=total).map(PageItem::Page).collect();
212    }
213
214    let mut items = Vec::new();
215    items.push(PageItem::Page(1));
216
217    let mut start = current.saturating_sub(1).max(2);
218    let mut end = (current + 1).min(total.saturating_sub(1));
219
220    if current <= 3 {
221        start = 2;
222        end = 4;
223    } else if current >= total.saturating_sub(2) {
224        start = total.saturating_sub(3);
225        end = total.saturating_sub(1);
226    }
227
228    if start > 2 {
229        items.push(PageItem::Ellipsis);
230    }
231
232    for page in start..=end {
233        items.push(PageItem::Page(page));
234    }
235
236    if end < total.saturating_sub(1) {
237        items.push(PageItem::Ellipsis);
238    }
239
240    items.push(PageItem::Page(total));
241    items
242}
243
244pub fn data_table<'a, T>(
245    ui: &mut Ui,
246    theme: &Theme,
247    props: DataTableProps<'a, T>,
248) -> DataTableResponse
249where
250    T: 'a,
251{
252    let state_id = ui.make_persistent_id(props.id_source);
253    let mut state = ui
254        .ctx()
255        .data(|data| data.get_temp::<DataTableState>(state_id))
256        .unwrap_or_default();
257
258    if state.page == 0 {
259        state.page = 1;
260    }
261
262    if state.page_size != props.page_size && props.page_size > 0 {
263        state.page_size = props.page_size;
264        state.page = 1;
265    } else if state.page_size == 0 {
266        state.page_size = 10;
267    }
268
269    if state.column_visibility.len() != props.columns.len() {
270        state.column_visibility = vec![true; props.columns.len()];
271    }
272
273    state.selected.retain(|index| *index < props.data.len());
274
275    let mut filter_changed = false;
276    ui.horizontal(|ui| {
277        let filter_response = Input::new(state_id.with("filter"))
278            .placeholder(props.filter_placeholder)
279            .width(240.0)
280            .show(ui, theme, &mut state.filter);
281        filter_changed = filter_response.changed();
282
283        if props.show_column_toggle {
284            ui.add_space(12.0);
285            let trigger = dropdown_menu_trigger(
286                ui,
287                DropdownMenuTriggerProps::new(state_id.with("columns-trigger")),
288                |ui| {
289                    Button::new("Columns")
290                        .variant(ButtonVariant::Outline)
291                        .size(ButtonSize::Sm)
292                        .show(ui, theme)
293                },
294            );
295
296            let visible_count = state
297                .column_visibility
298                .iter()
299                .filter(|visible| **visible)
300                .count();
301            let _ = dropdown_menu(
302                ui,
303                theme,
304                DropdownMenuProps::new(&trigger.response),
305                |menu_ui| {
306                    for (index, column) in props.columns.iter().enumerate() {
307                        if !column.hideable {
308                            continue;
309                        }
310                        let is_visible = state.column_visibility[index];
311                        let disabled = is_visible && visible_count == 1;
312                        let response = dropdown_menu_checkbox_item(
313                            menu_ui,
314                            theme,
315                            DropdownMenuCheckboxItemProps::new(&column.label, is_visible)
316                                .disabled(disabled),
317                        );
318                        if response.clicked() && !disabled {
319                            state.column_visibility[index] = !is_visible;
320                        }
321                    }
322                },
323            );
324        }
325    });
326    if filter_changed {
327        state.page = 1;
328    }
329
330    let mut indices: Vec<usize> = (0..props.data.len()).collect();
331    let filter_query = state.filter.trim();
332    if !filter_query.is_empty() || props.filter_fn.is_some() {
333        let query_lower = filter_query.to_lowercase();
334        let has_column_filters = props
335            .columns
336            .iter()
337            .any(|column| column.filter_value.is_some());
338        indices.retain(|index| {
339            let row = &props.data[*index];
340            if let Some(filter_fn) = props.filter_fn.as_ref() {
341                return filter_fn(row, filter_query);
342            }
343            if filter_query.is_empty() {
344                return true;
345            }
346            if !has_column_filters {
347                return true;
348            }
349            props.columns.iter().any(|column| {
350                column.filter_value.as_ref().is_some_and(|filter_value| {
351                    let value = filter_value(row);
352                    value.to_lowercase().contains(&query_lower)
353                })
354            })
355        });
356    }
357    if let Some(((_, direction), sort_fn)) = state.sort.and_then(|s| {
358        props
359            .columns
360            .get(s.0)
361            .and_then(|c| c.sort_value.as_ref())
362            .map(|f| (s, f))
363    }) {
364        indices.sort_by(|a, b| {
365            let a_val = sort_fn(&props.data[*a]);
366            let b_val = sort_fn(&props.data[*b]);
367            let ordering = a_val.cmp(&b_val);
368            match direction {
369                SortDirection::Asc => ordering,
370                SortDirection::Desc => ordering.reverse(),
371            }
372        });
373    }
374
375    let total_rows = indices.len();
376    let page_size = state.page_size.max(1);
377    let total_pages = total_rows.div_ceil(page_size);
378    let total_pages = total_pages.max(1);
379    if state.page > total_pages {
380        state.page = total_pages;
381    }
382    let start = (state.page - 1) * page_size;
383    let end = (start + page_size).min(total_rows);
384    let page_indices = if start < end {
385        indices[start..end].to_vec()
386    } else {
387        Vec::new()
388    };
389
390    table(ui, theme, TableProps::new(), |ui, ctx| {
391        table_header(ui, ctx, |ui| {
392            table_row(
393                ui,
394                ctx,
395                TableRowProps::new("header").hoverable(false),
396                |ui| {
397                    if props.enable_selection {
398                        let selected_on_page = page_indices
399                            .iter()
400                            .filter(|index| state.selected.contains(index))
401                            .count();
402                        let mut header_state = if page_indices.is_empty() {
403                            CheckboxState::Unchecked
404                        } else if selected_on_page == page_indices.len() {
405                            CheckboxState::Checked
406                        } else if selected_on_page == 0 {
407                            CheckboxState::Unchecked
408                        } else {
409                            CheckboxState::Indeterminate
410                        };
411                        let mut clicked = false;
412                        let enabled = !page_indices.is_empty();
413                        table_head(ui, ctx, TableCellProps::new().checkbox(true), |cell_ui| {
414                            let response = checkbox_with_props(
415                                cell_ui,
416                                theme,
417                                &mut header_state,
418                                "",
419                                CheckboxProps::default()
420                                    .cycle(CheckboxCycle::Binary)
421                                    .enabled(enabled),
422                            );
423                            clicked = response.clicked();
424                        });
425                        if clicked {
426                            if selected_on_page == page_indices.len() {
427                                for index in page_indices.iter() {
428                                    state.selected.remove(index);
429                                }
430                            } else {
431                                for index in page_indices.iter().copied() {
432                                    state.selected.insert(index);
433                                }
434                            }
435                        }
436                    }
437                    for (index, column) in props.columns.iter().enumerate() {
438                        if !state.column_visibility.get(index).copied().unwrap_or(true) {
439                            continue;
440                        }
441                        let sortable = column.sort_value.is_some();
442                        let current_sort = state.sort.and_then(|(sorted_index, dir)| {
443                            if sorted_index == index {
444                                Some(dir)
445                            } else {
446                                None
447                            }
448                        });
449                        let indicator = match current_sort {
450                            Some(SortDirection::Asc) => Some("^"),
451                            Some(SortDirection::Desc) => Some("v"),
452                            None => None,
453                        };
454                        let mut clicked = false;
455                        table_head(ui, ctx, TableCellProps::new(), |cell_ui| {
456                            if let Some(width) = column.width {
457                                cell_ui.set_min_width(width);
458                            }
459                            let layout = match column.align {
460                                DataTableAlign::Left => Layout::left_to_right(Align::Center),
461                                DataTableAlign::Center => {
462                                    Layout::centered_and_justified(Direction::LeftToRight)
463                                }
464                                DataTableAlign::Right => Layout::right_to_left(Align::Center),
465                            };
466                            cell_ui.with_layout(layout, |inner_ui| {
467                                let mut response = inner_ui.add(
468                                    Label::new(column.header.clone()).sense(if sortable {
469                                        Sense::click()
470                                    } else {
471                                        Sense::hover()
472                                    }),
473                                );
474                                if sortable {
475                                    response =
476                                        response.on_hover_cursor(egui::CursorIcon::PointingHand);
477                                }
478                                clicked = response.clicked();
479                                if let Some(indicator) = indicator {
480                                    inner_ui.label(
481                                        RichText::new(indicator)
482                                            .color(theme.palette.muted_foreground)
483                                            .size(12.0),
484                                    );
485                                }
486                            });
487                        });
488                        if clicked && sortable {
489                            state.sort = match state.sort {
490                                Some((sorted_index, SortDirection::Asc))
491                                    if sorted_index == index =>
492                                {
493                                    Some((index, SortDirection::Desc))
494                                }
495                                Some((sorted_index, SortDirection::Desc))
496                                    if sorted_index == index =>
497                                {
498                                    None
499                                }
500                                _ => Some((index, SortDirection::Asc)),
501                            };
502                            state.page = 1;
503                        }
504                    }
505                },
506            );
507        });
508
509        table_body(ui, ctx, |ui| {
510            if indices.is_empty() {
511                table_row(ui, ctx, TableRowProps::new("empty"), |row_ui| {
512                    table_cell(row_ui, ctx, TableCellProps::new().fill(true), |cell_ui| {
513                        cell_ui.label("No results.");
514                    });
515                });
516                return;
517            }
518
519            for index in page_indices.iter().copied() {
520                let row = &props.data[index];
521                let is_selected = state.selected.contains(&index);
522                table_row(
523                    ui,
524                    ctx,
525                    TableRowProps::new(index).selected(is_selected),
526                    |row_ui| {
527                        if props.enable_selection {
528                            let mut row_state = CheckboxState::from(is_selected);
529                            let response = table_cell(
530                                row_ui,
531                                ctx,
532                                TableCellProps::new().checkbox(true),
533                                |cell_ui| {
534                                    checkbox_with_props(
535                                        cell_ui,
536                                        theme,
537                                        &mut row_state,
538                                        "",
539                                        CheckboxProps::default(),
540                                    )
541                                },
542                            );
543                            if response.clicked() {
544                                if is_selected {
545                                    state.selected.remove(&index);
546                                } else {
547                                    state.selected.insert(index);
548                                }
549                            }
550                        }
551                        for (col_index, column) in props.columns.iter().enumerate() {
552                            if !state
553                                .column_visibility
554                                .get(col_index)
555                                .copied()
556                                .unwrap_or(true)
557                            {
558                                continue;
559                            }
560                            table_cell(row_ui, ctx, TableCellProps::new(), |cell_ui| {
561                                if let Some(width) = column.width {
562                                    cell_ui.set_min_width(width);
563                                }
564                                let layout = match column.align {
565                                    DataTableAlign::Left => Layout::left_to_right(Align::Center),
566                                    DataTableAlign::Center => {
567                                        Layout::centered_and_justified(Direction::LeftToRight)
568                                    }
569                                    DataTableAlign::Right => Layout::right_to_left(Align::Center),
570                                };
571                                cell_ui.with_layout(layout, |inner_ui| {
572                                    (column.cell)(inner_ui, row);
573                                });
574                            });
575                        }
576                    },
577                );
578            }
579        });
580    });
581
582    if total_pages > 1 {
583        ui.add_space(12.0);
584        pagination(
585            ui,
586            PaginationProps::new(total_pages, &mut state.page),
587            |ui, props| {
588                pagination_content(ui, |ui| {
589                    pagination_item(ui, |ui| pagination_previous(ui, theme, props));
590                    for item in pagination_items(*props.current_page, total_pages) {
591                        match item {
592                            PageItem::Page(page) => {
593                                pagination_item(ui, |ui| {
594                                    pagination_link(
595                                        ui,
596                                        theme,
597                                        props,
598                                        PaginationLinkProps::new(page, page.to_string())
599                                            .active(page == *props.current_page),
600                                    )
601                                });
602                            }
603                            PageItem::Ellipsis => {
604                                pagination_item(ui, |ui| pagination_ellipsis(ui, theme));
605                            }
606                        }
607                    }
608                    pagination_item(ui, |ui| pagination_next(ui, theme, props));
609                });
610            },
611        );
612    }
613
614    ui.ctx()
615        .data_mut(|data| data.insert_temp(state_id, state.clone()));
616
617    DataTableResponse {
618        selected: state.selected.iter().copied().collect(),
619        filtered_rows: indices.len(),
620        total_rows: props.data.len(),
621        page: state.page,
622        page_count: total_pages,
623    }
624}