1use crate::button::MaterialButton;
2use crate::theme::get_global_color;
3use egui::{
4 ecolor::Color32,
5 epaint::{CornerRadius, Stroke},
6 FontFamily, FontId, Id, Rect, Response, Sense, Ui, Vec2, Widget, WidgetText,
7};
8use std::collections::{HashMap, HashSet};
9
10#[derive(Clone, Debug)]
12pub struct DataTableTheme {
13 pub decoration: Option<Color32>,
14 pub heading_row_color: Option<Color32>,
15 pub heading_row_height: Option<f32>,
16 pub heading_text_style: Option<(FontId, Color32)>,
17 pub data_row_color: Option<Color32>,
18 pub data_row_min_height: Option<f32>,
19 pub data_row_max_height: Option<f32>,
20 pub data_text_style: Option<(FontId, Color32)>,
21 pub horizontal_margin: Option<f32>,
22 pub column_spacing: Option<f32>,
23 pub divider_thickness: Option<f32>,
24 pub divider_color: Option<Color32>,
25 pub checkbox_horizontal_margin: Option<f32>,
26 pub border_stroke: Option<Stroke>,
27 pub sort_active_color: Option<Color32>,
28 pub sort_inactive_color: Option<Color32>,
29 pub selected_row_color: Option<Color32>,
30 pub show_bottom_border: bool,
31 pub show_checkbox_column: bool,
32}
33
34impl Default for DataTableTheme {
35 fn default() -> Self {
36 Self {
37 decoration: None,
38 heading_row_color: None,
39 heading_row_height: Some(56.0),
40 heading_text_style: None,
41 data_row_color: None,
42 data_row_min_height: Some(52.0),
43 data_row_max_height: None,
44 data_text_style: None,
45 horizontal_margin: Some(24.0),
46 column_spacing: Some(56.0),
47 divider_thickness: Some(1.0),
48 divider_color: None,
49 checkbox_horizontal_margin: Some(16.0),
50 border_stroke: None,
51 sort_active_color: None,
52 sort_inactive_color: None,
53 selected_row_color: None,
54 show_bottom_border: true,
55 show_checkbox_column: true,
56 }
57 }
58}
59
60#[derive(Clone, Debug, PartialEq)]
62pub enum ColumnWidth {
63 Fixed(f32),
64 Flex(f32),
65}
66
67impl Default for ColumnWidth {
68 fn default() -> Self {
69 ColumnWidth::Fixed(100.0)
70 }
71}
72
73#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
78pub struct DataTableState {
79 pub selected_rows: Vec<bool>,
81 pub header_checkbox: bool,
83 pub column_sorts: HashMap<String, SortDirection>,
85 pub sorted_column: Option<usize>,
87 pub sort_direction: SortDirection,
89 pub editing_rows: std::collections::HashSet<usize>,
91 pub edit_data: HashMap<usize, Vec<String>>,
93}
94
95#[derive(Debug)]
100pub struct DataTableResponse {
101 pub response: Response,
103 pub selected_rows: Vec<bool>,
105 pub header_checkbox: bool,
107 pub column_clicked: Option<usize>,
109 pub sort_state: (Option<usize>, SortDirection),
111 pub row_actions: Vec<RowAction>,
113}
114
115#[derive(Debug, Clone)]
117pub enum RowAction {
118 Edit(usize),
120 Delete(usize),
122 Save(usize),
124 Cancel(usize),
126}
127
128pub trait DataTableSource {
130 fn row_count(&self) -> usize;
131 fn get_row(&self, index: usize) -> Option<DataTableRow<'_>>;
132 fn is_row_count_approximate(&self) -> bool {
133 false
134 }
135 fn selected_row_count(&self) -> usize {
136 0
137 }
138}
139
140#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
163pub struct MaterialDataTable<'a> {
164 columns: Vec<DataTableColumn>,
165 rows: Vec<DataTableRow<'a>>,
166 id: Option<Id>,
167 allow_selection: bool,
168 sticky_header: bool,
169 progress_visible: bool,
170 corner_radius: CornerRadius,
171 sorted_column: Option<usize>,
172 sort_direction: SortDirection,
173 default_row_height: f32,
174 theme: DataTableTheme,
175 row_hover_states: HashMap<usize, bool>,
176}
177
178#[derive(Clone, Debug, PartialEq)]
179pub enum VAlign {
180 Top,
181 Center,
182 Bottom,
183}
184
185#[derive(Clone, Debug, PartialEq)]
186pub enum HAlign {
187 Left,
188 Center,
189 Right,
190}
191
192impl Default for VAlign {
193 fn default() -> Self {
194 VAlign::Center
195 }
196}
197
198impl Default for HAlign {
199 fn default() -> Self {
200 HAlign::Left
201 }
202}
203
204#[derive(Clone)]
205pub struct DataTableColumn {
206 pub title: String,
208 pub header_widget: Option<std::sync::Arc<dyn Fn(&mut Ui) + Send + Sync>>,
210 pub width: f32,
212 pub numeric: bool,
214 pub sortable: bool,
216 pub sort_direction: Option<SortDirection>,
218 pub h_align: HAlign,
220 pub v_align: VAlign,
222 pub tooltip: Option<String>,
224 pub heading_alignment: Option<HAlign>,
226 pub column_width: ColumnWidth,
228}
229
230#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
231pub enum SortDirection {
232 Ascending,
233 Descending,
234}
235
236impl Default for SortDirection {
237 fn default() -> Self {
238 SortDirection::Ascending
239 }
240}
241
242pub enum CellContent {
243 Text(WidgetText),
244 Widget(std::sync::Arc<dyn Fn(&mut Ui) + Send + Sync>),
245}
246
247pub struct DataTableCell {
248 pub content: CellContent,
249 pub h_align: Option<HAlign>,
250 pub v_align: Option<VAlign>,
251 pub placeholder: bool,
252 pub show_edit_icon: bool,
253}
254
255impl DataTableCell {
256 pub fn text(text: impl Into<WidgetText>) -> Self {
257 Self {
258 content: CellContent::Text(text.into()),
259 h_align: None,
260 v_align: None,
261 placeholder: false,
262 show_edit_icon: false,
263 }
264 }
265
266 pub fn widget<F>(f: F) -> Self
267 where
268 F: Fn(&mut Ui) + Send + Sync + 'static,
269 {
270 Self {
271 content: CellContent::Widget(std::sync::Arc::new(f)),
272 h_align: None,
273 v_align: None,
274 placeholder: false,
275 show_edit_icon: false,
276 }
277 }
278
279 pub fn h_align(mut self, align: HAlign) -> Self {
280 self.h_align = Some(align);
281 self
282 }
283
284 pub fn v_align(mut self, align: VAlign) -> Self {
285 self.v_align = Some(align);
286 self
287 }
288
289 pub fn placeholder(mut self, is_placeholder: bool) -> Self {
290 self.placeholder = is_placeholder;
291 self
292 }
293
294 pub fn show_edit_icon(mut self, show: bool) -> Self {
295 self.show_edit_icon = show;
296 self
297 }
298}
299
300pub struct DataTableRow<'a> {
301 cells: Vec<DataTableCell>,
302 selected: bool,
303 readonly: bool,
304 id: Option<String>,
305 color: Option<Color32>,
306 on_hover: bool,
307 _phantom: std::marker::PhantomData<&'a ()>,
308}
309
310impl<'a> DataTableRow<'a> {
311 pub fn new() -> Self {
312 Self {
313 cells: Vec::new(),
314 selected: false,
315 readonly: false,
316 id: None,
317 color: None,
318 on_hover: true,
319 _phantom: std::marker::PhantomData,
320 }
321 }
322
323 pub fn cell(mut self, text: impl Into<WidgetText>) -> Self {
325 self.cells.push(DataTableCell::text(text));
326 self
327 }
328
329 pub fn custom_cell(mut self, cell: DataTableCell) -> Self {
331 self.cells.push(cell);
332 self
333 }
334
335 pub fn widget_cell<F>(mut self, f: F) -> Self
337 where
338 F: Fn(&mut Ui) + Send + Sync + 'static,
339 {
340 self.cells.push(DataTableCell::widget(f));
341 self
342 }
343
344 pub fn selected(mut self, selected: bool) -> Self {
345 self.selected = selected;
346 self
347 }
348
349 pub fn readonly(mut self, readonly: bool) -> Self {
350 self.readonly = readonly;
351 self
352 }
353
354 pub fn id(mut self, id: impl Into<String>) -> Self {
355 self.id = Some(id.into());
356 self
357 }
358
359 pub fn color(mut self, color: Color32) -> Self {
360 self.color = Some(color);
361 self
362 }
363
364 pub fn on_hover(mut self, hover: bool) -> Self {
365 self.on_hover = hover;
366 self
367 }
368}
369
370impl<'a> MaterialDataTable<'a> {
371 pub fn new() -> Self {
373 Self {
374 columns: Vec::new(),
375 rows: Vec::new(),
376 id: None,
377 allow_selection: false,
378 sticky_header: false,
379 progress_visible: false,
380 corner_radius: CornerRadius::from(4.0),
381 sorted_column: None,
382 sort_direction: SortDirection::Ascending,
383 default_row_height: 52.0,
384 theme: DataTableTheme::default(),
385 row_hover_states: HashMap::new(),
386 }
387 }
388
389 pub fn sort_by(mut self, column_index: usize, direction: SortDirection) -> Self {
391 self.sorted_column = Some(column_index);
392 self.sort_direction = direction;
393 self
394 }
395
396 pub fn get_sort_state(&self) -> (Option<usize>, SortDirection) {
398 (self.sorted_column, self.sort_direction.clone())
399 }
400
401 pub fn column(mut self, title: impl Into<String>, width: f32, numeric: bool) -> Self {
403 self.columns.push(DataTableColumn {
404 title: title.into(),
405 header_widget: None,
406 width,
407 numeric,
408 sortable: true, sort_direction: None,
410 h_align: if numeric { HAlign::Right } else { HAlign::Left },
411 v_align: VAlign::Center,
412 tooltip: None,
413 heading_alignment: None,
414 column_width: ColumnWidth::Fixed(width),
415 });
416 self
417 }
418
419 pub fn sortable_column(mut self, title: impl Into<String>, width: f32, numeric: bool) -> Self {
421 self.columns.push(DataTableColumn {
422 title: title.into(),
423 header_widget: None,
424 width,
425 numeric,
426 sortable: true,
427 sort_direction: None,
428 h_align: if numeric { HAlign::Right } else { HAlign::Left },
429 v_align: VAlign::Center,
430 tooltip: None,
431 heading_alignment: None,
432 column_width: ColumnWidth::Fixed(width),
433 });
434 self
435 }
436
437 pub fn sortable_column_with_align(
438 mut self,
439 title: impl Into<String>,
440 width: f32,
441 numeric: bool,
442 h_align: HAlign,
443 v_align: VAlign,
444 ) -> Self {
445 self.columns.push(DataTableColumn {
446 title: title.into(),
447 header_widget: None,
448 width,
449 numeric,
450 sortable: true,
451 sort_direction: None,
452 h_align,
453 v_align,
454 tooltip: None,
455 heading_alignment: None,
456 column_width: ColumnWidth::Fixed(width),
457 });
458 self
459 }
460
461 pub fn column_with_align(
463 mut self,
464 title: impl Into<String>,
465 width: f32,
466 numeric: bool,
467 h_align: HAlign,
468 v_align: VAlign,
469 ) -> Self {
470 self.columns.push(DataTableColumn {
471 title: title.into(),
472 header_widget: None,
473 width,
474 numeric,
475 sortable: true,
476 sort_direction: None,
477 h_align,
478 v_align,
479 tooltip: None,
480 heading_alignment: None,
481 column_width: ColumnWidth::Fixed(width),
482 });
483 self
484 }
485
486 pub fn row<F>(mut self, f: F) -> Self
488 where
489 F: FnOnce(DataTableRow<'a>) -> DataTableRow<'a>,
490 {
491 let row = f(DataTableRow::new());
492 self.rows.push(row);
493 self
494 }
495
496 pub fn id(mut self, id: impl Into<Id>) -> Self {
498 self.id = Some(id.into());
499 self
500 }
501
502 pub fn allow_selection(mut self, allow: bool) -> Self {
504 self.allow_selection = allow;
505 self
506 }
507
508 pub fn sticky_header(mut self, sticky: bool) -> Self {
510 self.sticky_header = sticky;
511 self
512 }
513
514 pub fn show_progress(mut self, show: bool) -> Self {
516 self.progress_visible = show;
517 self
518 }
519
520 pub fn corner_radius(mut self, corner_radius: impl Into<CornerRadius>) -> Self {
522 self.corner_radius = corner_radius.into();
523 self
524 }
525
526 pub fn default_row_height(mut self, height: f32) -> Self {
528 self.default_row_height = height;
529 self
530 }
531
532 pub fn theme(mut self, theme: DataTableTheme) -> Self {
534 self.theme = theme;
535 self
536 }
537
538 fn get_table_style(&self) -> (Color32, Stroke) {
539 let md_surface = self.theme.decoration.unwrap_or_else(|| get_global_color("surface"));
540 let md_outline = get_global_color("outline");
541 let border_stroke = self.theme.border_stroke.unwrap_or_else(|| Stroke::new(1.0, md_outline));
542 (md_surface, border_stroke)
543 }
544
545 pub fn show(self, ui: &mut Ui) -> DataTableResponse {
547 let (background_color, border_stroke) = self.get_table_style();
548
549 let table_id = self.id.unwrap_or_else(|| {
551 use std::collections::hash_map::DefaultHasher;
552 use std::hash::{Hash, Hasher};
553 let mut hasher = DefaultHasher::new();
554
555 for col in &self.columns {
557 col.title.hash(&mut hasher);
558 col.width.to_bits().hash(&mut hasher);
559 }
560 for (i, row) in self.rows.iter().take(3).enumerate() {
561 i.hash(&mut hasher);
562 for cell in &row.cells {
563 match &cell.content {
564 CellContent::Text(t) => t.text().hash(&mut hasher),
565 CellContent::Widget(_) => "widget".hash(&mut hasher),
566 }
567 }
568 }
569 self.rows.len().hash(&mut hasher);
570 Id::new(format!("datatable_{}", hasher.finish()))
571 });
572
573 let mut state: DataTableState =
575 ui.data_mut(|d| d.get_persisted(table_id).unwrap_or_default());
576
577 if let Some(external_editing_state) = ui.memory(|mem| {
579 mem.data
580 .get_temp::<(HashSet<usize>, HashMap<usize, Vec<String>>)>(
581 table_id.with("external_edit_state"),
582 )
583 }) {
584 state.editing_rows = external_editing_state.0;
585 state.edit_data = external_editing_state.1;
586 }
587
588 if state.sorted_column.is_none() && self.sorted_column.is_some() {
590 state.sorted_column = self.sorted_column;
591 state.sort_direction = self.sort_direction.clone();
592 }
593
594 if state.selected_rows.len() != self.rows.len() {
596 state.selected_rows.resize(self.rows.len(), false);
597 }
598
599 for (i, row) in self.rows.iter().enumerate() {
601 if i < state.selected_rows.len() {
602 state.selected_rows[i] = row.selected;
603 }
604 }
605
606 let MaterialDataTable {
607 columns,
608 mut rows,
609 allow_selection,
610 sticky_header: _,
611 progress_visible,
612 corner_radius,
613 default_row_height,
614 theme,
615 ..
616 } = self;
617
618 if let Some(sort_col_idx) = state.sorted_column {
620 if let Some(sort_column) = columns.get(sort_col_idx) {
621 rows.sort_by(|a, b| {
622 let cell_a_text = a
623 .cells
624 .get(sort_col_idx)
625 .and_then(|c| match &c.content {
626 CellContent::Text(t) => Some(t.text()),
627 CellContent::Widget(_) => None,
628 })
629 .unwrap_or("");
630 let cell_b_text = b
631 .cells
632 .get(sort_col_idx)
633 .and_then(|c| match &c.content {
634 CellContent::Text(t) => Some(t.text()),
635 CellContent::Widget(_) => None,
636 })
637 .unwrap_or("");
638
639 let comparison = if sort_column.numeric {
640 let a_num: f64 = cell_a_text.trim_start_matches('$').parse().unwrap_or(0.0);
642 let b_num: f64 = cell_b_text.trim_start_matches('$').parse().unwrap_or(0.0);
643 a_num
644 .partial_cmp(&b_num)
645 .unwrap_or(std::cmp::Ordering::Equal)
646 } else {
647 cell_a_text.cmp(cell_b_text)
649 };
650
651 match state.sort_direction {
652 SortDirection::Ascending => comparison,
653 SortDirection::Descending => comparison.reverse(),
654 }
655 });
656 }
657 }
658
659 let checkbox_width = if allow_selection && theme.show_checkbox_column { 48.0 } else { 0.0 };
661 let total_width = checkbox_width + columns.iter().map(|col| col.width).sum::<f32>();
662 let min_row_height = theme.data_row_min_height.unwrap_or(default_row_height);
663 let min_header_height = theme.heading_row_height.unwrap_or(56.0);
664
665 let mut header_height: f32 = min_header_height;
667 for column in &columns {
668 let available_width = column.width - 48.0; let header_font = FontId::new(16.0, FontFamily::Proportional);
670
671 let galley = ui.fonts(|f| {
672 f.layout_job(egui::text::LayoutJob {
673 text: column.title.clone(),
674 sections: vec![egui::text::LayoutSection {
675 leading_space: 0.0,
676 byte_range: 0..column.title.len(),
677 format: egui::TextFormat {
678 font_id: header_font,
679 color: get_global_color("onSurface"),
680 ..Default::default()
681 },
682 }],
683 wrap: egui::text::TextWrapping {
684 max_width: available_width,
685 ..Default::default()
686 },
687 break_on_newline: true,
688 halign: egui::Align::LEFT,
689 justify: false,
690 first_row_min_height: 0.0,
691 round_output_to_gui: true,
692 })
693 });
694
695 let content_height: f32 = galley.size().y + 16.0; header_height = header_height.max(content_height);
697 }
698
699 let mut row_heights = Vec::new();
701 for row in &rows {
702 let mut max_height: f32 = min_row_height;
703 for (cell_idx, cell) in row.cells.iter().enumerate() {
704 if let Some(column) = columns.get(cell_idx) {
705 match &cell.content {
706 CellContent::Text(cell_text) => {
707 let available_width = column.width - 32.0;
708 let cell_font = FontId::new(14.0, FontFamily::Proportional);
709
710 let galley = ui.fonts(|f| {
711 f.layout_job(egui::text::LayoutJob {
712 text: cell_text.text().to_string(),
713 sections: vec![egui::text::LayoutSection {
714 leading_space: 0.0,
715 byte_range: 0..cell_text.text().len(),
716 format: egui::TextFormat {
717 font_id: cell_font,
718 color: get_global_color("onSurface"),
719 ..Default::default()
720 },
721 }],
722 wrap: egui::text::TextWrapping {
723 max_width: available_width,
724 ..Default::default()
725 },
726 break_on_newline: true,
727 halign: egui::Align::LEFT, justify: false,
729 first_row_min_height: 0.0,
730 round_output_to_gui: true,
731 })
732 });
733
734 let content_height: f32 = galley.size().y + 16.0; max_height = max_height.max(content_height);
736 }
737 CellContent::Widget(_) => {
738 }
741 }
742 }
743 }
744 row_heights.push(max_height);
745 }
746
747 let total_height = header_height + row_heights.iter().sum::<f32>();
748
749 let mut all_row_actions: Vec<RowAction> = Vec::new();
751
752 let surface = get_global_color("surface");
754 let on_surface = get_global_color("onSurface");
755 let primary = get_global_color("primary");
756
757 let mut style = (*ui.ctx().style()).clone();
758 style.visuals.widgets.noninteractive.bg_fill = surface;
759 style.visuals.widgets.inactive.bg_fill = surface;
760 style.visuals.widgets.hovered.bg_fill =
761 egui::Color32::from_rgba_premultiplied(primary.r(), primary.g(), primary.b(), 20);
762 style.visuals.widgets.active.bg_fill =
763 egui::Color32::from_rgba_premultiplied(primary.r(), primary.g(), primary.b(), 40);
764 style.visuals.selection.bg_fill = primary;
765 style.visuals.widgets.noninteractive.fg_stroke.color = on_surface;
766 style.visuals.widgets.inactive.fg_stroke.color = on_surface;
767 style.visuals.widgets.hovered.fg_stroke.color = on_surface;
768 style.visuals.widgets.active.fg_stroke.color = on_surface;
769 style.visuals.striped = true;
770 style.visuals.faint_bg_color = egui::Color32::from_rgba_premultiplied(
771 on_surface.r(),
772 on_surface.g(),
773 on_surface.b(),
774 10,
775 );
776 ui.ctx().set_style(style);
777
778 let desired_size = Vec2::new(total_width, total_height);
779 let response = ui.allocate_response(desired_size, Sense::click());
780 let rect = response.rect;
781
782 if ui.is_rect_visible(rect) {
783 ui.painter()
785 .rect_filled(rect, corner_radius, background_color);
786 ui.painter().rect_stroke(
787 rect,
788 corner_radius,
789 border_stroke,
790 egui::epaint::StrokeKind::Outside,
791 );
792
793 let mut current_y = rect.min.y;
794
795 let header_rect = Rect::from_min_size(rect.min, Vec2::new(total_width, header_height));
797 let header_bg = theme.heading_row_color.unwrap_or_else(|| get_global_color("surfaceVariant"));
798 ui.painter()
799 .rect_filled(header_rect, CornerRadius::ZERO, header_bg);
800
801 let mut current_x = rect.min.x;
802
803 if allow_selection && theme.show_checkbox_column {
805 let checkbox_rect = Rect::from_min_size(
806 egui::pos2(current_x, current_y),
807 Vec2::new(checkbox_width, header_height),
808 );
809
810 let checkbox_center = checkbox_rect.center();
811 let checkbox_size = Vec2::splat(18.0);
812 let checkbox_inner_rect = Rect::from_center_size(checkbox_center, checkbox_size);
813
814 let checkbox_color = if state.header_checkbox {
815 get_global_color("primary")
816 } else {
817 Color32::TRANSPARENT
818 };
819
820 ui.painter().rect_filled(
821 checkbox_inner_rect,
822 CornerRadius::from(2.0),
823 checkbox_color,
824 );
825 ui.painter().rect_stroke(
826 checkbox_inner_rect,
827 CornerRadius::from(2.0),
828 Stroke::new(2.0, get_global_color("outline")),
829 egui::epaint::StrokeKind::Outside,
830 );
831
832 if state.header_checkbox {
833 let check_points = [
835 checkbox_inner_rect.min + Vec2::new(4.0, 9.0),
836 checkbox_inner_rect.min + Vec2::new(8.0, 13.0),
837 checkbox_inner_rect.min + Vec2::new(14.0, 5.0),
838 ];
839 ui.painter().line_segment(
840 [check_points[0], check_points[1]],
841 Stroke::new(2.0, Color32::WHITE),
842 );
843 ui.painter().line_segment(
844 [check_points[1], check_points[2]],
845 Stroke::new(2.0, Color32::WHITE),
846 );
847 }
848
849 let header_checkbox_id = table_id.with("header_checkbox");
851 let checkbox_response =
852 ui.interact(checkbox_inner_rect, header_checkbox_id, Sense::click());
853 if checkbox_response.clicked() {
854 state.header_checkbox = !state.header_checkbox;
855 for (idx, selected) in state.selected_rows.iter_mut().enumerate() {
857 if let Some(row) = rows.get(idx) {
858 if !row.readonly {
859 *selected = state.header_checkbox;
860 }
861 }
862 }
863 }
864
865 current_x += checkbox_width;
866 }
867
868 for (col_idx, column) in columns.iter().enumerate() {
870 let col_rect = Rect::from_min_size(
871 egui::pos2(current_x, current_y),
872 Vec2::new(column.width, header_height),
873 );
874
875 let available_width = column.width - 48.0; let header_font = FontId::new(16.0, FontFamily::Proportional);
878
879 let galley = ui.fonts(|f| {
880 f.layout_job(egui::text::LayoutJob {
881 text: column.title.clone(),
882 sections: vec![egui::text::LayoutSection {
883 leading_space: 0.0,
884 byte_range: 0..column.title.len(),
885 format: egui::TextFormat {
886 font_id: header_font,
887 color: get_global_color("onSurface"),
888 ..Default::default()
889 },
890 }],
891 wrap: egui::text::TextWrapping {
892 max_width: available_width,
893 ..Default::default()
894 },
895 break_on_newline: true,
896 halign: egui::Align::LEFT,
897 justify: false,
898 first_row_min_height: 0.0,
899 round_output_to_gui: true,
900 })
901 });
902
903 let text_pos = egui::pos2(
904 current_x + 16.0,
905 current_y + (header_height - galley.size().y) / 2.0,
906 );
907
908 ui.painter()
909 .galley(text_pos, galley, get_global_color("onSurface"));
910
911 if column.sortable {
913 let header_click_id = table_id.with(format!("column_header_{}", col_idx));
914 let mut header_response = ui.interact(col_rect, header_click_id, Sense::click());
915
916 if let Some(ref tooltip) = column.tooltip {
918 header_response = header_response.on_hover_text(tooltip);
919 }
920
921 if header_response.clicked() {
922 if state.sorted_column == Some(col_idx) {
924 state.sort_direction = match state.sort_direction {
926 SortDirection::Ascending => SortDirection::Descending,
927 SortDirection::Descending => SortDirection::Ascending,
928 };
929 } else {
930 state.sorted_column = Some(col_idx);
932 state.sort_direction = SortDirection::Ascending;
933 }
934 ui.memory_mut(|mem| {
935 mem.data
936 .insert_temp(table_id.with("column_clicked"), Some(col_idx));
937 });
938 }
939
940 let icon_pos = egui::pos2(
941 current_x + column.width - 32.0,
942 current_y + (header_height - 24.0) / 2.0,
943 );
944 let icon_rect = Rect::from_min_size(icon_pos, Vec2::splat(24.0));
945
946 let is_sorted = state.sorted_column == Some(col_idx);
948 let sort_direction = if is_sorted {
949 Some(&state.sort_direction)
950 } else {
951 None
952 };
953
954 let arrow_color = if is_sorted {
956 theme.sort_active_color.unwrap_or_else(|| get_global_color("primary")) } else {
958 theme.sort_inactive_color.unwrap_or_else(|| get_global_color("onSurfaceVariant"))
959 };
960
961 let center = icon_rect.center();
962
963 match sort_direction {
965 Some(SortDirection::Ascending) => {
966 let points = [
968 center + Vec2::new(0.0, -6.0), center + Vec2::new(-5.0, 4.0), center + Vec2::new(5.0, 4.0), ];
972 ui.painter().line_segment(
973 [points[0], points[1]],
974 Stroke::new(2.0, arrow_color),
975 );
976 ui.painter().line_segment(
977 [points[1], points[2]],
978 Stroke::new(2.0, arrow_color),
979 );
980 ui.painter().line_segment(
981 [points[2], points[0]],
982 Stroke::new(2.0, arrow_color),
983 );
984 }
985 Some(SortDirection::Descending) => {
986 let points = [
988 center + Vec2::new(0.0, 6.0), center + Vec2::new(-5.0, -4.0), center + Vec2::new(5.0, -4.0), ];
992 ui.painter().line_segment(
993 [points[0], points[1]],
994 Stroke::new(2.0, arrow_color),
995 );
996 ui.painter().line_segment(
997 [points[1], points[2]],
998 Stroke::new(2.0, arrow_color),
999 );
1000 ui.painter().line_segment(
1001 [points[2], points[0]],
1002 Stroke::new(2.0, arrow_color),
1003 );
1004 }
1005 None => {
1006 let light_color = arrow_color.gamma_multiply(0.5);
1008 let up_points = [
1010 center + Vec2::new(0.0, -8.0),
1011 center + Vec2::new(-3.0, -2.0),
1012 center + Vec2::new(3.0, -2.0),
1013 ];
1014 ui.painter().line_segment(
1015 [up_points[0], up_points[1]],
1016 Stroke::new(1.0, light_color),
1017 );
1018 ui.painter().line_segment(
1019 [up_points[1], up_points[2]],
1020 Stroke::new(1.0, light_color),
1021 );
1022 ui.painter().line_segment(
1023 [up_points[2], up_points[0]],
1024 Stroke::new(1.0, light_color),
1025 );
1026
1027 let down_points = [
1029 center + Vec2::new(0.0, 8.0),
1030 center + Vec2::new(-3.0, 2.0),
1031 center + Vec2::new(3.0, 2.0),
1032 ];
1033 ui.painter().line_segment(
1034 [down_points[0], down_points[1]],
1035 Stroke::new(1.0, light_color),
1036 );
1037 ui.painter().line_segment(
1038 [down_points[1], down_points[2]],
1039 Stroke::new(1.0, light_color),
1040 );
1041 ui.painter().line_segment(
1042 [down_points[2], down_points[0]],
1043 Stroke::new(1.0, light_color),
1044 );
1045 }
1046 }
1047 }
1048
1049 current_x += column.width;
1050 }
1051
1052 current_y += header_height;
1053
1054 for (row_idx, row) in rows.iter().enumerate() {
1056 let row_height = row_heights.get(row_idx).copied().unwrap_or(min_row_height);
1057 let row_rect = Rect::from_min_size(
1058 egui::pos2(rect.min.x, current_y),
1059 Vec2::new(total_width, row_height),
1060 );
1061
1062 let row_selected = state.selected_rows.get(row_idx).copied().unwrap_or(false);
1063
1064 let row_bg = if let Some(custom_color) = row.color {
1066 custom_color
1067 } else if row_selected {
1068 theme.selected_row_color.unwrap_or_else(|| get_global_color("primaryContainer"))
1069 } else if row.readonly {
1070 let surface_variant = get_global_color("surfaceVariant");
1072 Color32::from_rgba_premultiplied(
1073 surface_variant.r(),
1074 surface_variant.g(),
1075 surface_variant.b(),
1076 (surface_variant.a() as f32 * 0.3) as u8,
1077 )
1078 } else if row_idx % 2 == 1 {
1079 theme.data_row_color.unwrap_or_else(|| get_global_color("surfaceVariant"))
1080 } else {
1081 background_color
1082 };
1083
1084 ui.painter()
1085 .rect_filled(row_rect, CornerRadius::ZERO, row_bg);
1086
1087 if row_idx < rows.len() - 1 || theme.show_bottom_border {
1089 let divider_y = current_y + row_height;
1090 let divider_thickness = theme.divider_thickness.unwrap_or(1.0);
1091 let divider_color = theme.divider_color.unwrap_or_else(|| get_global_color("outlineVariant"));
1092 ui.painter().line_segment(
1093 [
1094 egui::pos2(rect.min.x, divider_y),
1095 egui::pos2(rect.min.x + total_width, divider_y),
1096 ],
1097 Stroke::new(divider_thickness, divider_color),
1098 );
1099 }
1100
1101 current_x = rect.min.x;
1102
1103 if allow_selection && theme.show_checkbox_column {
1105 let checkbox_rect = Rect::from_min_size(
1106 egui::pos2(current_x, current_y),
1107 Vec2::new(checkbox_width, row_height),
1108 );
1109
1110 let checkbox_center = checkbox_rect.center();
1111 let checkbox_size = Vec2::splat(18.0);
1112 let checkbox_inner_rect =
1113 Rect::from_center_size(checkbox_center, checkbox_size);
1114
1115 let checkbox_color = if row_selected {
1116 get_global_color("primary")
1117 } else {
1118 Color32::TRANSPARENT
1119 };
1120
1121 let border_color = if row.readonly {
1122 get_global_color("outline").linear_multiply(0.5) } else {
1124 get_global_color("outline")
1125 };
1126
1127 ui.painter().rect_filled(
1128 checkbox_inner_rect,
1129 CornerRadius::from(2.0),
1130 checkbox_color,
1131 );
1132 ui.painter().rect_stroke(
1133 checkbox_inner_rect,
1134 CornerRadius::from(2.0),
1135 Stroke::new(2.0, border_color),
1136 egui::epaint::StrokeKind::Outside,
1137 );
1138
1139 if row_selected {
1140 let check_points = [
1142 checkbox_inner_rect.min + Vec2::new(4.0, 9.0),
1143 checkbox_inner_rect.min + Vec2::new(8.0, 13.0),
1144 checkbox_inner_rect.min + Vec2::new(14.0, 5.0),
1145 ];
1146 ui.painter().line_segment(
1147 [check_points[0], check_points[1]],
1148 Stroke::new(2.0, Color32::WHITE),
1149 );
1150 ui.painter().line_segment(
1151 [check_points[1], check_points[2]],
1152 Stroke::new(2.0, Color32::WHITE),
1153 );
1154 }
1155
1156 let row_checkbox_id = table_id.with(format!("row_checkbox_{}", row_idx));
1158 let checkbox_response =
1159 ui.interact(checkbox_inner_rect, row_checkbox_id, Sense::click());
1160 if checkbox_response.clicked() && !row.readonly {
1161 if let Some(selected) = state.selected_rows.get_mut(row_idx) {
1162 *selected = !*selected;
1163 }
1164
1165 let non_readonly_indices: Vec<usize> = rows
1168 .iter()
1169 .enumerate()
1170 .filter(|(_, row)| !row.readonly)
1171 .map(|(idx, _)| idx)
1172 .collect();
1173
1174 if !non_readonly_indices.is_empty() {
1175 let all_non_readonly_selected = non_readonly_indices
1176 .iter()
1177 .all(|&idx| state.selected_rows.get(idx).copied().unwrap_or(false));
1178 let none_non_readonly_selected =
1179 non_readonly_indices.iter().all(|&idx| {
1180 !state.selected_rows.get(idx).copied().unwrap_or(false)
1181 });
1182 state.header_checkbox =
1183 all_non_readonly_selected && !none_non_readonly_selected;
1184 }
1185 }
1186
1187 current_x += checkbox_width;
1188 }
1189
1190 let mut row_actions: Vec<RowAction> = Vec::new();
1192
1193 for (cell_idx, cell) in row.cells.iter().enumerate() {
1195 if let Some(column) = columns.get(cell_idx) {
1196 let _cell_rect = Rect::from_min_size(
1197 egui::pos2(current_x, current_y),
1198 Vec2::new(column.width, row_height),
1199 );
1200
1201 let is_row_editing = state.editing_rows.contains(&row_idx);
1202 let is_actions_column = column.title == "Actions";
1203
1204 if is_actions_column {
1205 let button_rect = Rect::from_min_size(
1207 egui::pos2(current_x + 8.0, current_y + (row_height - 32.0) / 2.0),
1208 Vec2::new(column.width - 16.0, 32.0),
1209 );
1210
1211 ui.scope_builder(egui::UiBuilder::new().max_rect(button_rect), |ui| {
1212 egui::ScrollArea::horizontal()
1213 .id_salt(format!("actions_scroll_{}", row_idx))
1214 .auto_shrink([false, true])
1215 .show(ui, |ui| {
1216 ui.horizontal(|ui| {
1217 if is_row_editing {
1218 if ui.add(MaterialButton::filled("Save").small()).clicked() {
1219 row_actions.push(RowAction::Save(row_idx));
1220 }
1221 if ui.add(MaterialButton::filled("Cancel").small()).clicked() {
1222 row_actions.push(RowAction::Cancel(row_idx));
1223 }
1224 } else {
1225 if ui.add(MaterialButton::filled("Edit").small()).clicked() {
1226 row_actions.push(RowAction::Edit(row_idx));
1227 }
1228 if ui.add(MaterialButton::filled("Delete").small()).clicked() {
1229 row_actions.push(RowAction::Delete(row_idx));
1230 }
1231 }
1232 });
1233 });
1234 });
1235 } else if is_row_editing {
1236 let edit_rect = Rect::from_min_size(
1238 egui::pos2(current_x + 8.0, current_y + (row_height - 24.0) / 2.0),
1239 Vec2::new(column.width - 16.0, 24.0),
1240 );
1241
1242 let edit_data = state.edit_data.entry(row_idx).or_insert_with(|| {
1244 row.cells
1245 .iter()
1246 .map(|c| match &c.content {
1247 CellContent::Text(t) => t.text().to_string(),
1248 CellContent::Widget(_) => String::new(),
1249 })
1250 .collect()
1251 });
1252
1253 if edit_data.len() <= cell_idx {
1255 edit_data.resize(cell_idx + 1, String::new());
1256 }
1257
1258 let edit_text = &mut edit_data[cell_idx];
1259
1260 ui.scope_builder(egui::UiBuilder::new().max_rect(edit_rect), |ui| {
1261 ui.add(
1262 egui::TextEdit::singleline(edit_text)
1263 .desired_width(column.width - 16.0),
1264 );
1265 });
1266 } else {
1267 let h_align = cell.h_align.as_ref().unwrap_or(&column.h_align);
1269 let v_align = cell.v_align.as_ref().unwrap_or(&column.v_align);
1270
1271 match &cell.content {
1272 CellContent::Text(cell_text) => {
1273 let available_width = column.width - 32.0; let cell_font = if let Some((ref font_id, _)) = theme.data_text_style {
1276 font_id.clone()
1277 } else {
1278 FontId::new(14.0, FontFamily::Proportional)
1279 };
1280
1281 let text_color = if cell.placeholder {
1282 let base_color = get_global_color("onSurface");
1283 Color32::from_rgba_premultiplied(
1284 base_color.r(),
1285 base_color.g(),
1286 base_color.b(),
1287 (base_color.a() as f32 * 0.6) as u8,
1288 )
1289 } else if let Some((_, ref color)) = theme.data_text_style {
1290 *color
1291 } else {
1292 get_global_color("onSurface")
1293 };
1294
1295 let galley = ui.fonts(|f| {
1296 f.layout_job(egui::text::LayoutJob {
1297 text: cell_text.text().to_string(),
1298 sections: vec![egui::text::LayoutSection {
1299 leading_space: 0.0,
1300 byte_range: 0..cell_text.text().len(),
1301 format: egui::TextFormat {
1302 font_id: cell_font,
1303 color: text_color,
1304 ..Default::default()
1305 },
1306 }],
1307 wrap: egui::text::TextWrapping {
1308 max_width: available_width,
1309 ..Default::default()
1310 },
1311 break_on_newline: true,
1312 halign: egui::Align::LEFT, justify: false,
1314 first_row_min_height: 0.0,
1315 round_output_to_gui: true,
1316 })
1317 });
1318
1319 let text_x = match h_align {
1321 HAlign::Left => current_x + 16.0,
1322 HAlign::Center => {
1323 current_x + (column.width - galley.size().x) / 2.0
1324 }
1325 HAlign::Right => {
1326 current_x + column.width - 16.0 - galley.size().x
1327 }
1328 };
1329
1330 let text_y = match v_align {
1332 VAlign::Top => current_y + 8.0,
1333 VAlign::Center => {
1334 current_y + (row_height - galley.size().y) / 2.0
1335 }
1336 VAlign::Bottom => {
1337 current_y + row_height - galley.size().y - 8.0
1338 }
1339 };
1340
1341 let text_pos = egui::pos2(text_x, text_y);
1342 ui.painter().galley(
1343 text_pos,
1344 galley,
1345 text_color,
1346 );
1347
1348 if cell.show_edit_icon {
1350 let icon_size = 16.0;
1351 let icon_x = current_x + column.width - icon_size - 8.0;
1352 let icon_y = current_y + (row_height - icon_size) / 2.0;
1353 let icon_rect = Rect::from_min_size(
1354 egui::pos2(icon_x, icon_y),
1355 Vec2::splat(icon_size),
1356 );
1357 let icon_color = get_global_color("onSurfaceVariant");
1359 ui.painter().line_segment(
1360 [
1361 icon_rect.left_top() + Vec2::new(4.0, 10.0),
1362 icon_rect.left_top() + Vec2::new(10.0, 4.0),
1363 ],
1364 Stroke::new(1.5, icon_color),
1365 );
1366 ui.painter().line_segment(
1367 [
1368 icon_rect.left_top() + Vec2::new(2.0, 12.0),
1369 icon_rect.left_top() + Vec2::new(4.0, 10.0),
1370 ],
1371 Stroke::new(1.5, icon_color),
1372 );
1373 }
1374 }
1375 CellContent::Widget(widget_fn) => {
1376 let padding = 8.0;
1379 let available_width = column.width - 2.0 * padding;
1380 let available_height = row_height - 2.0 * padding;
1381
1382 let widget_rect = match (h_align, v_align) {
1384 (HAlign::Left, VAlign::Top) => Rect::from_min_size(
1385 egui::pos2(current_x + padding, current_y + padding),
1386 Vec2::new(available_width, available_height),
1387 ),
1388 (HAlign::Center, VAlign::Center) => Rect::from_min_size(
1389 egui::pos2(current_x + padding, current_y + padding),
1390 Vec2::new(available_width, available_height),
1391 ),
1392 (HAlign::Right, VAlign::Center) => Rect::from_min_size(
1393 egui::pos2(current_x + padding, current_y + padding),
1394 Vec2::new(available_width, available_height),
1395 ),
1396 _ => Rect::from_min_size(
1397 egui::pos2(current_x + padding, current_y + padding),
1398 Vec2::new(available_width, available_height),
1399 ),
1400 };
1401
1402 ui.scope_builder(
1403 egui::UiBuilder::new().max_rect(widget_rect),
1404 |ui| {
1405 match h_align {
1407 HAlign::Left => ui.with_layout(
1408 egui::Layout::left_to_right(egui::Align::Min),
1409 |ui| {
1410 widget_fn(ui);
1411 },
1412 ),
1413 HAlign::Center => ui.with_layout(
1414 egui::Layout::left_to_right(
1415 egui::Align::Center,
1416 ),
1417 |ui| {
1418 widget_fn(ui);
1419 },
1420 ),
1421 HAlign::Right => ui.with_layout(
1422 egui::Layout::right_to_left(egui::Align::Min),
1423 |ui| {
1424 widget_fn(ui);
1425 },
1426 ),
1427 };
1428 },
1429 );
1430 }
1431 }
1432 }
1433
1434 current_x += column.width;
1435 }
1436 }
1437
1438 all_row_actions.extend(row_actions);
1440
1441 current_y += row_height;
1442 }
1443
1444 if progress_visible {
1446 let scrim_color = Color32::from_rgba_unmultiplied(255, 255, 255, 128);
1447 ui.painter().rect_filled(rect, corner_radius, scrim_color);
1448
1449 let progress_rect = Rect::from_min_size(
1451 egui::pos2(rect.min.x, rect.min.y + header_height),
1452 Vec2::new(total_width, 4.0),
1453 );
1454
1455 let progress_color = get_global_color("primary");
1456 ui.painter()
1457 .rect_filled(progress_rect, CornerRadius::ZERO, progress_color);
1458 }
1459 }
1460
1461 ui.data_mut(|d| d.insert_persisted(table_id, state.clone()));
1463
1464 ui.memory_mut(|mem| {
1466 mem.data.insert_temp(
1467 table_id.with("external_edit_state"),
1468 (state.editing_rows.clone(), state.edit_data.clone()),
1469 );
1470 });
1471
1472 let column_clicked = ui
1474 .memory(|mem| {
1475 mem.data
1476 .get_temp::<Option<usize>>(table_id.with("column_clicked"))
1477 })
1478 .flatten();
1479
1480 ui.memory_mut(|mem| {
1482 mem.data
1483 .remove::<Option<usize>>(table_id.with("column_clicked"));
1484 });
1485
1486 DataTableResponse {
1487 response,
1488 selected_rows: state.selected_rows,
1489 header_checkbox: state.header_checkbox,
1490 column_clicked,
1491 sort_state: (state.sorted_column, state.sort_direction.clone()),
1492 row_actions: all_row_actions,
1493 }
1494 }
1495}
1496
1497impl<'a> Default for MaterialDataTable<'a> {
1498 fn default() -> Self {
1499 Self::new()
1500 }
1501}
1502
1503impl Widget for MaterialDataTable<'_> {
1504 fn ui(self, ui: &mut Ui) -> Response {
1505 self.show(ui).response
1506 }
1507}
1508
1509pub fn data_table() -> MaterialDataTable<'static> {
1511 MaterialDataTable::new()
1512}