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}