Skip to main content

iced_shadcn/
data_table.rs

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