1use 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}