1use std::rc::Rc;
11
12use frus_core::{Color, Insets, Path, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
13use frus_layout::{Align, Dimension, Justify, Style};
14
15use crate::flex::Flex;
16use crate::icons::{IconData, Icons};
17use crate::interaction::{Key, KeyResponse, Status};
18use crate::list::ListView;
19use crate::scroll::{Axis, SingleChildScrollView};
20use crate::stack::Stack;
21use crate::theme::Theme;
22use crate::widget::{CellFn, Widget};
23
24type ReorderSpec<Msg> = (usize, usize, Rc<dyn Fn(usize, usize) -> Msg>);
27
28type RowWidgets<Msg> = Rc<dyn Fn(usize) -> Vec<Box<dyn Widget<Msg>>>>;
30
31const ROW_H: f32 = 34.0;
32const PAD_X: f32 = 10.0;
33
34fn heading_style(theme: Option<&Theme>) -> ResolvedTextStyle {
42 theme
43 .and_then(|t| t.widgets.table.heading_text_style)
44 .unwrap_or_else(|| crate::theme::type_scale(theme).title_small)
45 .resolved()
46}
47
48fn data_style(theme: Option<&Theme>) -> ResolvedTextStyle {
50 theme
51 .and_then(|t| t.widgets.table.data_text_style)
52 .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
53 .resolved()
54}
55
56fn cell_text_style(header: bool, theme: Option<&Theme>) -> ResolvedTextStyle {
58 if header {
59 heading_style(theme)
60 } else {
61 data_style(theme)
62 }
63}
64
65const ROW_GAP: f32 = 2.0;
67const CHECK_W: f32 = 40.0;
69const BOX: f32 = 18.0;
71const HANDLE_W: f32 = 8.0;
73
74fn cell_background(
76 header: bool,
77 selected: bool,
78 clickable: bool,
79 theme: &Theme,
80 status: &Status,
81) -> Color {
82 let base = if header {
83 theme.surface.lerp(theme.on_surface, 0.06)
84 } else if selected {
85 theme.surface.lerp(theme.primary, 0.16)
86 } else {
87 theme.surface
88 };
89 if clickable {
90 theme.state_layer(base, theme.on_surface, status)
91 } else {
92 base
93 }
94}
95
96fn cell_style(width: Dimension, header: bool, theme: Option<&Theme>) -> Style {
105 let flex_grow = if matches!(width, Dimension::Length(_)) {
106 0.0
107 } else {
108 1.0
109 };
110 Style {
111 width,
112 height: Dimension::Auto,
113 min_height: Dimension::Length(frus_text::line_box(
117 ROW_H,
118 &cell_text_style(header, theme),
119 0.0,
120 )),
121 flex_grow,
122 ..Default::default()
123 }
124}
125
126const ICON: f32 = 16.0;
128const ICON_GAP: f32 = 6.0;
130
131struct Cell<Msg> {
133 label: String,
134 width: Dimension,
135 header: bool,
136 selected: bool,
137 row: Option<usize>,
140 icon: Option<IconData>,
142 sort: Option<bool>,
144 message: Option<Msg>,
145 reorder: Option<ReorderSpec<Msg>>,
148 action: Vec<Box<dyn Widget<Msg>>>,
153}
154
155impl<Msg> Cell<Msg> {
156 fn sizing(&self, theme: Option<&Theme>) -> Style {
157 let base = cell_style(self.width, self.header, theme);
158 if self.action.is_empty() {
161 base
162 } else {
163 Style {
164 justify: Justify::End,
165 align: Align::Center,
166 padding: Insets::new(0.0, PAD_X, 0.0, 0.0),
167 ..base
168 }
169 }
170 }
171}
172
173impl<Msg: Clone> Widget<Msg> for Cell<Msg> {
174 fn style(&self) -> Style {
175 self.sizing(None)
176 }
177
178 fn style_themed(&self, theme: &Theme) -> Style {
179 self.sizing(Some(theme))
180 }
181
182 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
183 &self.action
184 }
185
186 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
187 let o = status.opacity;
188 let clickable = self.message.is_some();
189 let bg = cell_background(self.header, self.selected, clickable, theme, &status);
190 if self.header || self.selected || bg != theme.surface {
191 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
192 }
193
194 let color = if self.header {
195 theme.muted
196 } else {
197 theme.on_surface
198 };
199 let style = cell_text_style(self.header, Some(theme));
203 let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
204
205 let mut text_x = bounds.x + PAD_X;
207 if let Some(icon) = self.icon {
208 let iy = bounds.y + (bounds.height - ICON) * 0.5;
209 let path = icon.placed(ICON, text_x, iy, theme.direction);
210 scene.fill_path(&path, color.fade(o));
211 text_x += ICON + ICON_GAP;
212 }
213 scene.text(
214 Point::new(text_x, ty),
215 self.label.clone(),
216 &style,
217 color.fade(o),
218 );
219
220 if let (true, Some(ascending)) = (self.header, self.sort) {
221 let lw = frus_text::measure_resolved(&self.label, &style).width;
224 let cx = text_x + lw + 8.0;
225 let cy = bounds.y + bounds.height * 0.5;
226 let (w, h) = (4.0, 4.0);
227 let tri = if ascending {
228 Path::new()
229 .move_to(Point::new(cx, cy - h))
230 .line_to(Point::new(cx - w, cy + h))
231 .line_to(Point::new(cx + w, cy + h))
232 .close()
233 } else {
234 Path::new()
235 .move_to(Point::new(cx, cy + h))
236 .line_to(Point::new(cx - w, cy - h))
237 .line_to(Point::new(cx + w, cy - h))
238 .close()
239 };
240 scene.fill_path(&tri, theme.on_surface.fade(o));
241 }
242 }
243
244 fn on_click(&self) -> Option<Msg> {
245 self.message.clone()
246 }
247
248 fn focusable(&self) -> bool {
249 self.header && self.message.is_some()
252 }
253
254 fn reorder_index(&self) -> Option<usize> {
255 self.reorder.as_ref().map(|(col, _, _)| *col)
256 }
257
258 fn on_reorder(&self, to: usize) -> Option<Msg> {
259 self.reorder.as_ref().map(|(col, _, cb)| cb(*col, to))
260 }
261
262 fn announce(&self) -> Option<String> {
263 if self.header && self.message.is_some() {
266 let ascending = !matches!(self.sort, Some(true));
267 let dir = if ascending { "ascending" } else { "descending" };
268 return Some(format!("Sorted by {} {}", self.label, dir));
269 }
270 if let (false, Some(row), true) = (self.header, self.row, self.message.is_some()) {
272 let verb = if self.selected {
273 "deselected"
274 } else {
275 "selected"
276 };
277 return Some(format!("Row {} {}", row + 1, verb));
278 }
279 None
280 }
281
282 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
283 if !self.header {
286 return None;
287 }
288 let mut sem =
289 frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
290 if let Some((col, columns, _)) = &self.reorder {
291 sem = sem.value(format!("column {} of {}", col + 1, columns));
292 }
293 Some(sem)
294 }
295
296 fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
297 let Some((col, columns, cb)) = self.reorder.as_ref() else {
300 return KeyResponse::Ignored;
301 };
302 let to = match key {
303 Key::Left { word: true, .. } => col.checked_sub(1),
304 Key::Right { word: true, .. } if col + 1 < *columns => Some(col + 1),
305 _ => return KeyResponse::Ignored,
306 };
307 match to {
308 Some(to) => KeyResponse::Handled(Some(cb(*col, to))),
309 None => KeyResponse::Ignored, }
311 }
312}
313
314struct CheckCell<Msg> {
316 checked: bool,
317 indeterminate: bool,
320 header: bool,
321 selected: bool,
322 message: Option<Msg>,
323}
324
325impl<Msg: Clone> Widget<Msg> for CheckCell<Msg> {
326 fn style(&self) -> Style {
327 cell_style(Dimension::Length(CHECK_W), self.header, None)
328 }
329
330 fn style_themed(&self, theme: &Theme) -> Style {
331 cell_style(Dimension::Length(CHECK_W), self.header, Some(theme))
332 }
333
334 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
335 &[]
336 }
337
338 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
339 let o = status.opacity;
340 let clickable = self.message.is_some();
341 let bg = cell_background(self.header, self.selected, clickable, theme, &status);
342 if self.header || self.selected || bg != theme.surface {
343 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
344 }
345
346 let bx = bounds.x + (bounds.width - BOX) * 0.5;
347 let by = bounds.y + (bounds.height - BOX) * 0.5;
348 let box_rect = Rect::new(bx, by, BOX, BOX);
349 if self.checked {
350 scene.draw_rect(
351 box_rect,
352 theme.primary.fade(o),
353 4.0,
354 0.0,
355 Color::TRANSPARENT,
356 );
357 let tick = BOX - 4.0;
359 let inset = (BOX - tick) * 0.5;
360 let path = Icons::CHECK.placed(tick, bx + inset, by + inset, theme.direction);
361 scene.fill_path(&path, theme.on_primary.fade(o));
362 } else if self.indeterminate {
363 scene.draw_rect(
365 box_rect,
366 theme.primary.fade(o),
367 4.0,
368 0.0,
369 Color::TRANSPARENT,
370 );
371 let dash = Rect::new(bx + 4.0, by + BOX * 0.5 - 1.0, BOX - 8.0, 2.0);
372 scene.draw_rect(dash, theme.on_primary.fade(o), 1.0, 0.0, Color::TRANSPARENT);
373 } else {
374 scene.draw_rect(box_rect, Color::TRANSPARENT, 4.0, 1.5, theme.muted.fade(o));
375 }
376 }
377
378 fn on_click(&self) -> Option<Msg> {
379 self.message.clone()
380 }
381
382 fn focusable(&self) -> bool {
383 self.message.is_some()
384 }
385
386 fn announce(&self) -> Option<String> {
387 self.message.as_ref()?;
390 let selecting = !self.checked;
391 let words = crate::localizations::of();
392 Some(match (self.header, selecting) {
393 (true, true) => words.all_rows_selected_label().into(),
394 (true, false) => words.all_rows_deselected_label().into(),
395 (false, true) => words.row_selected_label().into(),
396 (false, false) => words.row_deselected_label().into(),
397 })
398 }
399}
400
401pub type CellFactory<Msg> = std::rc::Rc<dyn Fn() -> Box<dyn Widget<Msg>>>;
405
406enum RowKind<Msg> {
408 Text(Vec<String>),
409 Widgets(Vec<CellFactory<Msg>>),
410}
411
412#[derive(Clone)]
416enum VirtualBuild<Msg> {
417 Text(std::rc::Rc<dyn Fn(usize) -> Vec<String>>),
418 Widgets(RowWidgets<Msg>),
419}
420
421struct WidgetCell<Msg> {
424 width: Dimension,
425 selected: bool,
426 header: bool,
428 row: usize,
430 message: Option<Msg>,
431 content: Vec<Box<dyn Widget<Msg>>>,
432}
433
434impl<Msg> WidgetCell<Msg> {
435 fn sizing(&self, theme: Option<&Theme>) -> Style {
436 Style {
437 align: Align::Center,
438 padding: Insets::new(0.0, PAD_X, 0.0, PAD_X),
439 ..cell_style(self.width, false, theme)
442 }
443 }
444}
445
446impl<Msg: Clone> Widget<Msg> for WidgetCell<Msg> {
447 fn style(&self) -> Style {
448 self.sizing(None)
449 }
450
451 fn style_themed(&self, theme: &Theme) -> Style {
452 self.sizing(Some(theme))
453 }
454
455 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
456 &self.content
457 }
458
459 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
460 let o = status.opacity;
461 let clickable = self.message.is_some();
462 let bg = cell_background(self.header, self.selected, clickable, theme, &status);
463 if self.header || self.selected || bg != theme.surface {
464 scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
465 }
466 }
467
468 fn on_click(&self) -> Option<Msg> {
469 self.message.clone()
470 }
471
472 fn announce(&self) -> Option<String> {
473 self.message.as_ref()?;
475 let verb = if self.selected {
476 "deselected"
477 } else {
478 "selected"
479 };
480 Some(format!("Row {} {}", self.row + 1, verb))
481 }
482}
483
484const FROZEN_SHADOW_W: f32 = 8.0;
486
487struct FrozenShadow {
491 width: f32,
493 height: f32,
494 left: Option<f32>,
496 right: Option<f32>,
498}
499
500impl<Msg: Clone> Widget<Msg> for FrozenShadow {
501 fn style(&self) -> Style {
502 Style {
503 width: Dimension::Length(self.width),
504 height: Dimension::Length(self.height),
505 ..Default::default()
506 }
507 }
508
509 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
510 &[]
511 }
512
513 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
514 let o = status.opacity;
515 let dark = theme.scheme.scrim.with_alpha(0.28 * o);
516 let clear = theme.scheme.scrim.with_alpha(0.0);
517 if let Some(x) = self.left {
518 scene.gradient_rect(
519 Rect::new(bounds.x + x, bounds.y, FROZEN_SHADOW_W, bounds.height),
520 dark,
521 clear,
522 [1.0, 0.0],
523 0.0,
524 0.0,
525 Color::TRANSPARENT,
526 );
527 }
528 if let Some(x) = self.right {
529 scene.gradient_rect(
530 Rect::new(
531 bounds.x + x - FROZEN_SHADOW_W,
532 bounds.y,
533 FROZEN_SHADOW_W,
534 bounds.height,
535 ),
536 clear,
537 dark,
538 [1.0, 0.0],
539 0.0,
540 0.0,
541 Color::TRANSPARENT,
542 );
543 }
544 }
545
546 fn on_click(&self) -> Option<Msg> {
547 None
548 }
549}
550
551struct Spacer {
555 width: f32,
556 height: f32,
557}
558
559impl<Msg: Clone> Widget<Msg> for Spacer {
560 fn style(&self) -> Style {
561 Style {
562 width: Dimension::Length(self.width),
563 height: Dimension::Length(self.height),
564 ..Default::default()
565 }
566 }
567
568 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
569 &[]
570 }
571
572 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
573
574 fn on_click(&self) -> Option<Msg> {
575 None
576 }
577}
578
579struct ResizeHandle<Msg> {
583 col: usize,
584 height: f32,
585 on_resize: Rc<dyn Fn(usize, f32) -> Msg>,
586}
587
588impl<Msg: Clone> Widget<Msg> for ResizeHandle<Msg> {
589 fn style(&self) -> Style {
590 Style {
591 width: Dimension::Length(HANDLE_W),
592 height: Dimension::Length(self.height),
593 ..Default::default()
594 }
595 }
596
597 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
598 &[]
599 }
600
601 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
602 let o = status.opacity;
603 let t = status.hover_progress;
606 let color = theme.border.lerp(theme.primary, t);
607 let lw = 1.0 + t; let x = bounds.x + (HANDLE_W - lw) * 0.5;
609 scene.draw_rect(
610 Rect::new(x, bounds.y, lw, bounds.height),
611 color.fade(o),
612 0.0,
613 0.0,
614 Color::TRANSPARENT,
615 );
616 }
617
618 fn on_click(&self) -> Option<Msg> {
619 None
620 }
621
622 fn draggable(&self) -> bool {
623 true
624 }
625
626 fn on_drag_delta(&self, dx: f32) -> Option<Msg> {
627 if dx == 0.0 {
629 None
630 } else {
631 Some((self.on_resize)(self.col, dx))
632 }
633 }
634}
635
636pub struct Table<Msg = crate::callback::Callback> {
638 columns: usize,
639 headers: Vec<String>,
640 header_icons: Vec<Option<IconData>>,
642 header_widgets: Vec<CellFactory<Msg>>,
646 header_actions: Vec<Option<CellFactory<Msg>>>,
649 rows: Vec<RowKind<Msg>>,
650 widths: Vec<f32>,
652 total_width: Option<f32>,
653 sort: Option<(usize, bool)>,
654 selected: Vec<usize>,
655 on_sort: Option<Box<dyn Fn(usize) -> Msg>>,
656 on_select: Option<Rc<dyn Fn(usize) -> Msg>>,
657 on_check: Option<Rc<dyn Fn(usize) -> Msg>>,
658 on_check_all: Option<Msg>,
659 on_resize: Option<Rc<dyn Fn(usize, f32) -> Msg>>,
662 on_reorder: Option<Rc<dyn Fn(usize, usize) -> Msg>>,
665 virtual_data: Option<(usize, f32, VirtualBuild<Msg>)>,
669 frozen: (usize, usize),
672 heading_text_style: Option<TextStyle>,
673 data_text_style: Option<TextStyle>,
674 root: Box<dyn Widget<Msg>>,
675}
676
677fn col_dimension(widths: &[f32], c: usize) -> Dimension {
680 match widths.get(c).copied().unwrap_or(0.0) {
681 w if w > 0.0 => Dimension::Length(w),
682 _ => Dimension::Auto,
683 }
684}
685
686impl<Msg: Clone + 'static> Table<Msg> {
687 pub fn new(columns: usize) -> Self {
689 let columns = columns.max(1);
690 Self {
691 columns,
692 headers: Vec::new(),
693 header_icons: Vec::new(),
694 header_widgets: Vec::new(),
695 header_actions: Vec::new(),
696 rows: Vec::new(),
697 widths: vec![0.0; columns],
698 total_width: None,
699 sort: None,
700 selected: Vec::new(),
701 on_sort: None,
702 on_select: None,
703 on_check: None,
704 on_check_all: None,
705 on_resize: None,
706 on_reorder: None,
707 virtual_data: None,
708 frozen: (0, 0),
709 heading_text_style: None,
710 data_text_style: None,
711 root: Box::new(Flex::<Msg>::column().gap(ROW_GAP)),
712 }
713 }
714
715 #[must_use]
722 pub fn heading_text_style(mut self, style: TextStyle) -> Self {
723 self.heading_text_style = Some(style);
724 self
725 }
726
727 #[must_use]
730 pub fn data_text_style(mut self, style: TextStyle) -> Self {
731 self.data_text_style = Some(style);
732 self
733 }
734
735 pub fn header(mut self, labels: &[&str]) -> Self {
737 self.headers = labels.iter().map(|s| s.to_string()).collect();
738 self.rebuild();
739 self
740 }
741
742 pub fn widget_header(mut self, cells: Vec<CellFn<Msg>>) -> Self {
749 self.header_widgets = cells.into_iter().map(std::rc::Rc::from).collect();
750 self.headers.clear();
751 self.rebuild();
752 self
753 }
754
755 pub fn header_icons(mut self, icons: &[Option<IconData>]) -> Self {
759 self.header_icons = icons.to_vec();
760 self.rebuild();
761 self
762 }
763
764 pub fn header_action(
775 mut self,
776 col: usize,
777 make: impl Fn() -> Box<dyn Widget<Msg>> + 'static,
778 ) -> Self {
779 if col < self.columns {
780 if self.header_actions.len() <= col {
781 self.header_actions.resize_with(col + 1, || None);
782 }
783 self.header_actions[col] = Some(std::rc::Rc::new(make));
784 }
785 self.rebuild();
786 self
787 }
788
789 pub fn row(mut self, cells: &[&str]) -> Self {
791 self.rows
792 .push(RowKind::Text(cells.iter().map(|s| s.to_string()).collect()));
793 self.rebuild();
794 self
795 }
796
797 pub fn widget_row(mut self, cells: Vec<CellFn<Msg>>) -> Self {
806 self.rows.push(RowKind::Widgets(
807 cells.into_iter().map(std::rc::Rc::from).collect(),
808 ));
809 self.rebuild();
810 self
811 }
812
813 pub fn width(mut self, width: f32) -> Self {
816 self.total_width = Some(width);
817 self.rebuild();
818 self
819 }
820
821 pub fn column_widths(mut self, widths: &[f32]) -> Self {
824 for (i, w) in widths.iter().enumerate().take(self.columns) {
825 self.widths[i] = *w;
826 }
827 self.rebuild();
828 self
829 }
830
831 pub fn on_sort<R: crate::callback::IntoMsg<Msg>>(
833 mut self,
834 on_sort: impl Fn(usize) -> R + 'static,
835 ) -> Self {
836 self.on_sort = Some(Box::new(crate::callback::handler1(on_sort)));
837 self.rebuild();
838 self
839 }
840
841 pub fn sorted(mut self, column: usize, ascending: bool) -> Self {
843 self.sort = Some((column, ascending));
844 self.rebuild();
845 self
846 }
847
848 pub fn on_select_row<R: crate::callback::IntoMsg<Msg>>(
850 mut self,
851 on_select: impl Fn(usize) -> R + 'static,
852 ) -> Self {
853 self.on_select = Some(Rc::new(crate::callback::handler1(on_select)));
854 self.rebuild();
855 self
856 }
857
858 pub fn checkboxes(
862 mut self,
863 on_check: impl Fn(usize) -> Msg + 'static,
864 on_check_all: Msg,
865 ) -> Self {
866 self.on_check = Some(Rc::new(on_check));
867 self.on_check_all = Some(on_check_all);
868 self.rebuild();
869 self
870 }
871
872 pub fn frozen_columns(mut self, n: usize) -> Self {
879 self.frozen.0 = n;
880 self.rebuild();
881 self
882 }
883
884 pub fn frozen_columns_right(mut self, m: usize) -> Self {
889 self.frozen.1 = m;
890 self.rebuild();
891 self
892 }
893
894 pub fn virtual_rows(
905 mut self,
906 count: usize,
907 viewport_height: f32,
908 build: impl Fn(usize) -> Vec<String> + 'static,
909 ) -> Self {
910 self.virtual_data = Some((count, viewport_height, VirtualBuild::Text(Rc::new(build))));
911 self.rows.clear();
912 self.rebuild();
913 self
914 }
915
916 pub fn virtual_widget_rows(
921 mut self,
922 count: usize,
923 viewport_height: f32,
924 build: impl Fn(usize) -> Vec<Box<dyn Widget<Msg>>> + 'static,
925 ) -> Self {
926 self.virtual_data = Some((
927 count,
928 viewport_height,
929 VirtualBuild::Widgets(Rc::new(build)),
930 ));
931 self.rows.clear();
932 self.rebuild();
933 self
934 }
935
936 pub fn on_resize<R: crate::callback::IntoMsg<Msg>>(
943 mut self,
944 on_resize: impl Fn(usize, f32) -> R + 'static,
945 ) -> Self {
946 self.on_resize = Some(Rc::new(crate::callback::handler2(on_resize)));
947 self.rebuild();
948 self
949 }
950
951 pub fn on_reorder<R: crate::callback::IntoMsg<Msg>>(
956 mut self,
957 on_reorder: impl Fn(usize, usize) -> R + 'static,
958 ) -> Self {
959 self.on_reorder = Some(Rc::new(crate::callback::handler2(on_reorder)));
960 self.rebuild();
961 self
962 }
963
964 pub fn selected(mut self, rows: &[usize]) -> Self {
966 self.selected = rows.to_vec();
967 self.rebuild();
968 self
969 }
970
971 fn col_width(&self, c: usize) -> Dimension {
973 col_dimension(&self.widths, c)
974 }
975
976 fn new_row(&self) -> Flex<Msg> {
978 let row = Flex::row().gap(ROW_GAP);
979 match self.total_width {
980 Some(w) => row.width(w),
981 None => row,
982 }
983 }
984
985 fn row_count(&self) -> usize {
988 self.virtual_data
989 .as_ref()
990 .map(|(count, _, _)| *count)
991 .unwrap_or(self.rows.len())
992 }
993
994 fn selected_count(&self) -> usize {
998 let n = self.row_count();
999 self.selected.iter().filter(|&&r| r < n).count()
1000 }
1001
1002 fn all_selected(&self) -> bool {
1004 let n = self.row_count();
1005 n > 0 && self.selected_count() == n
1006 }
1007
1008 fn some_selected(&self) -> bool {
1010 let s = self.selected_count();
1011 s > 0 && s < self.row_count()
1012 }
1013
1014 fn resize_overlay(&self, total_h: f32) -> Option<Flex<Msg>> {
1017 let on_resize = self.on_resize.as_ref()?;
1018 if !(0..self.columns).all(|c| self.widths.get(c).copied().unwrap_or(0.0) > 0.0) {
1020 return None;
1021 }
1022 let base = if self.on_check.is_some() {
1023 CHECK_W + ROW_GAP
1024 } else {
1025 0.0
1026 };
1027 let mut row = Flex::row();
1029 let mut consumed = 0.0f32;
1030 let mut edge = base;
1031 for c in 0..self.columns {
1032 edge += self.widths[c]; if c + 1 < self.columns {
1034 let handle_left = edge - HANDLE_W * 0.5;
1035 row = row
1036 .child(Spacer {
1037 width: (handle_left - consumed).max(0.0),
1038 height: total_h,
1039 })
1040 .child(ResizeHandle {
1041 col: c,
1042 height: total_h,
1043 on_resize: on_resize.clone(),
1044 });
1045 consumed = handle_left + HANDLE_W;
1046 }
1047 edge += ROW_GAP; }
1049 Some(row)
1050 }
1051
1052 fn frozen_header_cell(&self, c: usize) -> Cell<Msg> {
1055 let sort = self.sort.filter(|(col, _)| *col == c).map(|(_, asc)| asc);
1056 Cell {
1057 label: self.headers.get(c).cloned().unwrap_or_default(),
1058 width: self.col_width(c),
1059 header: true,
1060 selected: false,
1061 row: None,
1062 icon: self.header_icons.get(c).copied().flatten(),
1063 sort,
1064 message: self.on_sort.as_ref().map(|f| f(c)),
1065 reorder: None,
1066 action: Vec::new(),
1067 }
1068 }
1069
1070 fn frozen_block(
1073 &self,
1074 cols: std::ops::Range<usize>,
1075 w: f32,
1076 header_present: bool,
1077 ) -> Flex<Msg> {
1078 let mut col = Flex::column().gap(ROW_GAP).width(w);
1079 if header_present {
1080 let mut hr = Flex::row().gap(ROW_GAP).width(w);
1081 for c in cols.clone() {
1082 hr = hr.child(self.frozen_header_cell(c));
1083 }
1084 col = col.child(hr);
1085 }
1086 for (r, row) in self.rows.iter().enumerate() {
1087 let RowKind::Text(cells) = row else { continue };
1088 let selected = self.selected.contains(&r);
1089 let mut rr = Flex::row().gap(ROW_GAP).width(w);
1090 for c in cols.clone() {
1091 rr = rr.child(Cell {
1092 label: cells.get(c).cloned().unwrap_or_default(),
1093 width: self.col_width(c),
1094 header: false,
1095 selected,
1096 row: Some(r),
1097 icon: None,
1098 sort: None,
1099 message: self.on_select.as_ref().map(|f| f(r)),
1100 reorder: None,
1101 action: Vec::new(),
1102 });
1103 }
1104 col = col.child(rr);
1105 }
1106 col
1107 }
1108
1109 fn build_frozen(&self) -> Option<Box<dyn Widget<Msg>>> {
1115 let (left, right) = self.frozen;
1116 if (left == 0 && right == 0) || self.virtual_data.is_some() || self.on_check.is_some() {
1117 return None;
1118 }
1119 if left + right >= self.columns {
1120 return None;
1121 }
1122 let total_width = self.total_width?;
1123 if !(0..self.columns).all(|c| self.widths.get(c).copied().unwrap_or(0.0) > 0.0) {
1124 return None;
1125 }
1126 if self.rows.iter().any(|r| !matches!(r, RowKind::Text(_))) {
1128 return None;
1129 }
1130
1131 let span = |a: usize, b: usize| -> f32 {
1132 if b <= a {
1133 return 0.0;
1134 }
1135 let cols: f32 = (a..b).map(|c| self.widths[c]).sum();
1136 cols + ROW_GAP * ((b - a) - 1) as f32
1137 };
1138 let mid_end = self.columns - right;
1139 let (left_w, mid_w, right_w) = (
1140 span(0, left),
1141 span(left, mid_end),
1142 span(mid_end, self.columns),
1143 );
1144
1145 let header_present = !self.headers.is_empty();
1146 let n_rows = header_present as usize + self.rows.len();
1147 let total_h = if n_rows > 0 {
1148 n_rows as f32 * ROW_H + (n_rows as f32 - 1.0) * ROW_GAP
1149 } else {
1150 0.0
1151 };
1152
1153 let gaps = (left > 0) as usize + (right > 0) as usize;
1155 let viewport_w = (total_width - left_w - right_w - gaps as f32 * ROW_GAP).max(0.0);
1156
1157 let mut row = Flex::row().gap(ROW_GAP).width(total_width);
1158 if left > 0 {
1159 row = row.child(self.frozen_block(0..left, left_w, header_present));
1160 }
1161 row = row.child(
1162 SingleChildScrollView::new()
1163 .axis(Axis::Horizontal)
1164 .width(viewport_w)
1165 .height(total_h)
1166 .child(self.frozen_block(left..mid_end, mid_w, header_present)),
1167 );
1168 if right > 0 {
1169 row = row.child(self.frozen_block(mid_end..self.columns, right_w, header_present));
1170 }
1171
1172 let shadow = FrozenShadow {
1174 width: total_width,
1175 height: total_h,
1176 left: (left > 0).then_some(left_w),
1177 right: (right > 0).then_some(total_width - right_w),
1178 };
1179 Some(Box::new(
1180 Stack::new()
1181 .width(total_width)
1182 .height(total_h)
1183 .layer(row)
1184 .layer(shadow),
1185 ))
1186 }
1187
1188 fn rebuild(&mut self) {
1189 if let Some(root) = self.build_frozen() {
1191 self.root = root;
1192 return;
1193 }
1194
1195 let checks = self.on_check.is_some();
1196 let mut col = Flex::column().gap(ROW_GAP);
1197
1198 let widget_headers = !self.header_widgets.is_empty();
1200 if !self.headers.is_empty() || widget_headers || checks {
1201 let mut hrow = self.new_row();
1202 if checks {
1203 hrow = hrow.child(CheckCell {
1204 checked: self.all_selected(),
1205 indeterminate: self.some_selected(),
1206 header: true,
1207 selected: false,
1208 message: self.on_check_all.clone(),
1209 });
1210 }
1211 if widget_headers {
1212 for (c, make) in self.header_widgets.iter().enumerate() {
1215 hrow = hrow.child(WidgetCell {
1216 width: self.col_width(c),
1217 selected: false,
1218 header: true,
1219 row: 0,
1220 message: None,
1221 content: vec![make()],
1222 });
1223 }
1224 } else {
1225 for (c, label) in self.headers.iter().enumerate() {
1226 let sort = self.sort.filter(|(col, _)| *col == c).map(|(_, asc)| asc);
1227 let message = self.on_sort.as_ref().map(|f| f(c));
1228 let reorder = self
1229 .on_reorder
1230 .as_ref()
1231 .map(|cb| (c, self.columns, cb.clone()));
1232 let action = self
1233 .header_actions
1234 .get(c)
1235 .and_then(|a| a.as_ref())
1236 .map(|make| vec![make()])
1237 .unwrap_or_default();
1238 hrow = hrow.child(Cell {
1239 label: label.clone(),
1240 width: self.col_width(c),
1241 header: true,
1242 selected: false,
1243 row: None,
1244 icon: self.header_icons.get(c).copied().flatten(),
1245 sort,
1246 message,
1247 reorder,
1248 action,
1249 });
1250 }
1251 }
1252 col = col.child(hrow);
1253 }
1254
1255 if let Some((count, viewport_height, build)) = self.virtual_data.clone() {
1259 let columns = self.columns;
1260 let widths = self.widths.clone();
1261 let total_width = self.total_width;
1262 let selected = self.selected.clone();
1263 let on_select = self.on_select.clone();
1264 let on_check = self.on_check.clone();
1265 let list = ListView::new(count, ROW_H, move |i| {
1266 let is_selected = selected.contains(&i);
1267 let message = on_select.as_ref().map(|f| f(i));
1268 let mut row = Flex::row().gap(ROW_GAP);
1269 if let Some(w) = total_width {
1270 row = row.width(w);
1271 }
1272 if let Some(check) = &on_check {
1274 row = row.child(CheckCell {
1275 checked: is_selected,
1276 indeterminate: false,
1277 header: false,
1278 selected: is_selected,
1279 message: Some(check(i)),
1280 });
1281 }
1282 match &build {
1283 VirtualBuild::Text(f) => {
1284 let cells = f(i);
1285 for c in 0..columns {
1286 row = row.child(Cell {
1287 label: cells.get(c).cloned().unwrap_or_default(),
1288 width: col_dimension(&widths, c),
1289 header: false,
1290 selected: is_selected,
1291 row: Some(i),
1292 icon: None,
1293 sort: None,
1294 message: message.clone(),
1295 reorder: None,
1296 action: Vec::new(),
1297 });
1298 }
1299 }
1300 VirtualBuild::Widgets(f) => {
1301 for (c, widget) in f(i).into_iter().enumerate().take(columns) {
1302 row = row.child(WidgetCell {
1303 width: col_dimension(&widths, c),
1304 selected: is_selected,
1305 header: false,
1306 row: i,
1307 message: message.clone(),
1308 content: vec![widget],
1309 });
1310 }
1311 }
1312 }
1313 row
1314 });
1315 let list = match total_width {
1316 Some(w) => list.width(w).height(viewport_height),
1317 None => list.height(viewport_height),
1318 };
1319 col = col.child(list);
1320 self.root = Box::new(col);
1321 return;
1322 }
1323
1324 for (r, row) in self.rows.iter().enumerate() {
1326 let selected = self.selected.contains(&r);
1327 let mut drow = self.new_row();
1328 if checks {
1329 drow = drow.child(CheckCell {
1330 checked: selected,
1331 indeterminate: false,
1332 header: false,
1333 selected,
1334 message: self.on_check.as_ref().map(|f| f(r)),
1335 });
1336 }
1337 match row {
1338 RowKind::Text(cells) => {
1339 for (c, label) in cells.iter().enumerate() {
1340 let message = self.on_select.as_ref().map(|f| f(r));
1341 drow = drow.child(Cell {
1342 label: label.clone(),
1343 width: self.col_width(c),
1344 header: false,
1345 selected,
1346 row: Some(r),
1347 icon: None,
1348 sort: None,
1349 message,
1350 reorder: None,
1351 action: Vec::new(),
1352 });
1353 }
1354 }
1355 RowKind::Widgets(factories) => {
1356 for (c, make) in factories.iter().enumerate() {
1357 drow = drow.child(WidgetCell {
1358 width: self.col_width(c),
1359 selected,
1360 header: false,
1361 row: r,
1362 message: self.on_select.as_ref().map(|f| f(r)),
1363 content: vec![make()],
1364 });
1365 }
1366 }
1367 }
1368 col = col.child(drow);
1369 }
1370
1371 let header_present = !self.headers.is_empty() || widget_headers || checks;
1376 let n = header_present as usize + self.rows.len();
1377 let total_h = if n > 0 {
1378 n as f32 * ROW_H + (n as f32 - 1.0) * ROW_GAP
1379 } else {
1380 0.0
1381 };
1382
1383 self.root = match self.resize_overlay(total_h) {
1384 Some(handles) => {
1385 let base = if checks { CHECK_W + ROW_GAP } else { 0.0 };
1386 let cols_w: f32 = (0..self.columns).map(|c| self.widths[c]).sum();
1387 let computed = base + cols_w + ROW_GAP * self.columns.saturating_sub(1) as f32;
1388 let total_w = self.total_width.unwrap_or(computed);
1389 Box::new(
1390 Stack::new()
1391 .width(total_w)
1392 .height(total_h)
1393 .layer(col)
1394 .layer(handles),
1395 )
1396 }
1397 None => Box::new(col),
1398 };
1399 }
1400}
1401
1402impl<Msg: Clone> Widget<Msg> for Table<Msg> {
1403 fn style(&self) -> Style {
1404 Widget::<Msg>::style(&self.root)
1405 }
1406
1407 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1408 Widget::<Msg>::children(&self.root)
1409 }
1410
1411 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
1412
1413 fn on_click(&self) -> Option<Msg> {
1414 None
1415 }
1416
1417 fn stack(&self) -> bool {
1418 Widget::<Msg>::stack(&self.root)
1421 }
1422
1423 fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
1427 if self.heading_text_style.is_none() && self.data_text_style.is_none() {
1428 return None;
1429 }
1430 let mut theme = inherited.clone();
1431 if let Some(style) = self.heading_text_style {
1432 theme.widgets.table.heading_text_style = Some(style);
1433 }
1434 if let Some(style) = self.data_text_style {
1435 theme.widgets.table.data_text_style = Some(style);
1436 }
1437 Some(Box::new(theme))
1438 }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444 use crate::{build_ui, Runtime, Size};
1445 use frus_core::{Point, Primitive};
1446
1447 #[test]
1448 fn header_and_rows_produce_rows_of_cells() {
1449 let table = Table::<()>::new(2)
1450 .header(&["Nom", "Note"])
1451 .row(&["Ada", "5"])
1452 .row(&["Bob", "3"]);
1453 assert_eq!(Widget::<()>::children(&table).len(), 3);
1455 assert_eq!(Widget::<()>::children(&table)[0].children().len(), 2);
1457
1458 let ui = build_ui(
1459 &table,
1460 Size::new(240.0, 200.0),
1461 &Runtime::default(),
1462 &Theme::default(),
1463 );
1464 let has = |t: &str| {
1465 ui.scene()
1466 .primitives()
1467 .iter()
1468 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1469 };
1470 assert!(has("Nom") && has("Ada") && has("Bob"));
1471 }
1472
1473 #[derive(Clone, Debug, PartialEq)]
1474 enum Msg {
1475 Sort(usize),
1476 Select(usize),
1477 Check(usize),
1478 CheckAll,
1479 Resize(usize, f32),
1480 Reorder(usize, usize),
1481 Filter,
1482 }
1483
1484 #[test]
1485 fn header_click_sorts_and_row_click_selects() {
1486 let table = Table::<Msg>::new(2)
1487 .width(240.0)
1488 .header(&["Name", "Score"])
1489 .on_sort(Msg::Sort)
1490 .on_select_row(Msg::Select)
1491 .row(&["Ada", "5"])
1492 .row(&["Bob", "3"]);
1493 let ui = build_ui(
1494 &table,
1495 Size::new(240.0, 200.0),
1496 &Runtime::default(),
1497 &Theme::default(),
1498 );
1499 let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
1500 assert_eq!(click(180.0, ROW_H * 0.5), Some(Msg::Sort(1)));
1501 assert_eq!(click(30.0, ROW_H * 2.5), Some(Msg::Select(1)));
1502 }
1503
1504 #[test]
1505 fn checkbox_column_toggles_rows_and_all() {
1506 let table = Table::<Msg>::new(2)
1507 .width(280.0)
1508 .header(&["Name", "Score"])
1509 .checkboxes(Msg::Check, Msg::CheckAll)
1510 .selected(&[0])
1511 .row(&["Ada", "5"])
1512 .row(&["Bob", "3"]);
1513 assert_eq!(Widget::<Msg>::children(&table)[0].children().len(), 3);
1515
1516 let ui = build_ui(
1517 &table,
1518 Size::new(280.0, 200.0),
1519 &Runtime::default(),
1520 &Theme::default(),
1521 );
1522 let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
1523 assert_eq!(click(CHECK_W * 0.5, ROW_H * 0.5), Some(Msg::CheckAll));
1525 assert_eq!(click(CHECK_W * 0.5, ROW_H * 2.5), Some(Msg::Check(1)));
1527 }
1528
1529 #[test]
1530 fn select_all_is_indeterminate_on_partial_selection() {
1531 let header_check = |sel: &[usize]| {
1533 let table = Table::<Msg>::new(2)
1534 .header(&["A", "B"])
1535 .checkboxes(Msg::Check, Msg::CheckAll)
1536 .selected(sel)
1537 .row(&["x", "1"])
1538 .row(&["y", "2"]);
1539 let row0 = &Widget::<Msg>::children(&table)[0];
1540 let _ = row0;
1543 (table.all_selected(), table.some_selected())
1544 };
1545 assert_eq!(header_check(&[]), (false, false), "nothing checked");
1546 assert_eq!(header_check(&[0]), (false, true), "partial → indeterminate");
1547 assert_eq!(header_check(&[0, 1]), (true, false), "all checked");
1548 }
1549
1550 #[test]
1551 fn only_headers_take_keyboard_focus() {
1552 let table = Table::<Msg>::new(2)
1553 .width(240.0)
1554 .header(&["Name", "Score"])
1555 .on_sort(Msg::Sort)
1556 .on_select_row(Msg::Select)
1557 .row(&["Ada", "5"]);
1558 let ui = build_ui(
1559 &table,
1560 Size::new(240.0, 200.0),
1561 &Runtime::default(),
1562 &Theme::default(),
1563 );
1564 let first = ui.focus_next(None, true);
1567 let mut count = 0;
1568 let mut cur = first;
1569 while let Some(id) = cur {
1570 count += 1;
1571 let next = ui.focus_next(Some(id), true);
1572 if next == first || count > 10 {
1573 break;
1574 }
1575 cur = next;
1576 }
1577 assert_eq!(count, 2, "only the 2 headers take focus (got {count})");
1578 }
1579
1580 #[test]
1581 fn resize_handle_emits_accumulating_delta() {
1582 let table = Table::<Msg>::new(2)
1583 .column_widths(&[100.0, 100.0])
1584 .header(&["A", "B"])
1585 .on_resize(Msg::Resize)
1586 .row(&["x", "y"]);
1587 assert_eq!(Widget::<Msg>::children(&table).len(), 2);
1589 let overlay = &Widget::<Msg>::children(&table)[1];
1590 let handle = overlay
1592 .children()
1593 .iter()
1594 .find(|c| c.draggable())
1595 .expect("a resize handle");
1596 assert_eq!(handle.on_drag_delta(12.0), Some(Msg::Resize(0, 12.0)));
1598 assert_eq!(handle.on_drag_delta(0.0), None);
1599
1600 let ui = build_ui(
1602 &table,
1603 Size::new(220.0, 120.0),
1604 &Runtime::default(),
1605 &Theme::default(),
1606 );
1607 assert!(
1608 ui.draggable_at(Point::new(100.0, 20.0)).is_some(),
1609 "the handle can be grabbed at the edge"
1610 );
1611 let flex = Table::<Msg>::new(2)
1613 .header(&["A", "B"])
1614 .on_resize(Msg::Resize)
1615 .row(&["x", "y"]);
1616 assert_eq!(
1618 Widget::<Msg>::children(&flex).len(),
1619 2,
1620 "flexible columns: no handle layer"
1621 );
1622 assert!(
1623 Widget::<Msg>::children(&flex)
1624 .iter()
1625 .all(|r| r.children().iter().all(|c| !c.draggable())),
1626 "no handle without fixed widths",
1627 );
1628 }
1629
1630 #[test]
1631 fn reorderable_headers_expose_index_and_message() {
1632 let table = Table::<Msg>::new(3)
1633 .width(300.0)
1634 .header(&["A", "B", "C"])
1635 .on_sort(Msg::Sort)
1636 .on_reorder(Msg::Reorder)
1637 .row(&["x", "y", "z"]);
1638 let hrow = &Widget::<Msg>::children(&table)[0];
1639 let cells = hrow.children();
1640 assert_eq!(cells[0].reorder_index(), Some(0));
1642 assert_eq!(cells[2].reorder_index(), Some(2));
1643 assert_eq!(cells[0].on_reorder(2), Some(Msg::Reorder(0, 2)));
1644 assert_eq!(cells[1].on_click(), Some(Msg::Sort(1)));
1646 let drow = &Widget::<Msg>::children(&table)[1];
1648 assert_eq!(drow.children()[0].reorder_index(), None);
1649 }
1650
1651 #[test]
1652 fn ctrl_arrows_reorder_focused_header() {
1653 use crate::interaction::{Key, KeyResponse};
1654 let table = Table::<Msg>::new(3)
1655 .width(300.0)
1656 .header(&["A", "B", "C"])
1657 .on_sort(Msg::Sort)
1658 .on_reorder(Msg::Reorder)
1659 .row(&["x", "y", "z"]);
1660 let cells = Widget::<Msg>::children(&table)[0].children();
1661 let ctrl_left = Key::Left {
1662 shift: false,
1663 word: true,
1664 };
1665 let ctrl_right = Key::Right {
1666 shift: false,
1667 word: true,
1668 };
1669 assert_eq!(
1671 cells[1].on_key(&ctrl_left),
1672 KeyResponse::Handled(Some(Msg::Reorder(1, 0)))
1673 );
1674 assert_eq!(
1675 cells[1].on_key(&ctrl_right),
1676 KeyResponse::Handled(Some(Msg::Reorder(1, 2)))
1677 );
1678 assert_eq!(cells[0].on_key(&ctrl_left), KeyResponse::Ignored);
1680 assert_eq!(cells[2].on_key(&ctrl_right), KeyResponse::Ignored);
1681 assert_eq!(
1683 cells[1].on_key(&Key::Left {
1684 shift: false,
1685 word: false
1686 }),
1687 KeyResponse::Ignored
1688 );
1689 }
1690
1691 #[test]
1692 fn reorderable_header_is_announced_with_position() {
1693 let table = Table::<Msg>::new(3)
1694 .width(300.0)
1695 .header(&["A", "B", "C"])
1696 .on_sort(Msg::Sort)
1697 .on_reorder(Msg::Reorder)
1698 .row(&["x", "y", "z"]);
1699 let sem = Widget::<Msg>::children(&table)[0].children()[1]
1700 .semantics()
1701 .expect("an announced header");
1702 assert_eq!(sem.label.as_deref(), Some("B"));
1703 assert_eq!(sem.value.as_deref(), Some("column 2 of 3"));
1704 assert!(Widget::<Msg>::children(&table)[1].children()[0]
1706 .semantics()
1707 .is_none());
1708 }
1709
1710 #[test]
1711 fn widget_rows_embed_arbitrary_widgets() {
1712 use crate::Text;
1713 let table = Table::<Msg>::new(2)
1714 .width(240.0)
1715 .header(&["Name", "Tag"])
1716 .on_select_row(Msg::Select)
1717 .widget_row(vec![
1718 Box::new(|| Box::new(Text::new("Ada"))),
1719 Box::new(|| Box::new(Text::new("admin"))),
1720 ]);
1721 let rows = Widget::<Msg>::children(&table);
1723 assert_eq!(rows.len(), 2);
1724 let drow = &rows[1];
1725 assert_eq!(drow.children().len(), 2);
1726 assert_eq!(
1727 drow.children()[0].children().len(),
1728 1,
1729 "the cell holds a widget"
1730 );
1731
1732 let ui = build_ui(
1734 &table,
1735 Size::new(240.0, 120.0),
1736 &Runtime::default(),
1737 &Theme::default(),
1738 );
1739 let has_admin = ui
1740 .scene()
1741 .primitives()
1742 .iter()
1743 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "admin"));
1744 assert!(has_admin, "the cell widget is painted");
1745 let click = ui
1746 .hit(Point::new(30.0, ROW_H * 1.5))
1747 .and_then(|id| ui.msg_for(id));
1748 assert_eq!(click, Some(Msg::Select(0)), "the widget row is selectable");
1749 }
1750
1751 #[test]
1752 fn header_icon_shifts_label_and_paints() {
1753 let name_x = |icons: bool| {
1755 let mut t = Table::<Msg>::new(2).width(240.0).header(&["Name", "Score"]);
1756 if icons {
1757 t = t.header_icons(&[Some(Icons::STAR), None]);
1758 }
1759 let ui = build_ui(
1760 &t,
1761 Size::new(240.0, 100.0),
1762 &Runtime::default(),
1763 &Theme::default(),
1764 );
1765 ui.scene()
1766 .primitives()
1767 .iter()
1768 .find_map(|p| match p {
1769 Primitive::Text { text, position, .. } if text == "Name" => Some(position.x),
1770 _ => None,
1771 })
1772 .unwrap()
1773 };
1774 let (plain, iconed) = (name_x(false), name_x(true));
1775 assert!(
1776 iconed >= plain + ICON,
1777 "the label is shifted behind the icon: {iconed} vs {plain}"
1778 );
1779 }
1781
1782 #[test]
1783 fn sort_and_selection_are_announced() {
1784 let unsorted = Table::<Msg>::new(2)
1786 .header(&["Name", "Score"])
1787 .on_sort(Msg::Sort);
1788 let head =
1789 |t: &Table<Msg>, c: usize| Widget::<Msg>::children(t)[0].children()[c].announce();
1790 assert_eq!(
1791 head(&unsorted, 0).as_deref(),
1792 Some("Sorted by Name ascending")
1793 );
1794 let asc = Table::<Msg>::new(2)
1796 .header(&["Name", "Score"])
1797 .on_sort(Msg::Sort)
1798 .sorted(0, true);
1799 assert_eq!(head(&asc, 0).as_deref(), Some("Sorted by Name descending"));
1800
1801 let table = Table::<Msg>::new(2)
1803 .header(&["Name", "Score"])
1804 .checkboxes(Msg::Check, Msg::CheckAll)
1805 .selected(&[0])
1806 .row(&["Ada", "5"])
1807 .row(&["Bob", "3"]);
1808 let all = Widget::<Msg>::children(&table)[0].children()[0].announce();
1810 assert_eq!(all.as_deref(), Some("All rows selected"));
1811 let row0 = Widget::<Msg>::children(&table)[1].children()[0].announce();
1813 let row1 = Widget::<Msg>::children(&table)[2].children()[0].announce();
1814 assert_eq!(row0.as_deref(), Some("Row deselected"));
1815 assert_eq!(row1.as_deref(), Some("Row selected"));
1816 }
1817
1818 #[test]
1819 fn header_action_widget_captures_its_click() {
1820 use frus_core::Color;
1821 struct Btn;
1823 impl Widget<Msg> for Btn {
1824 fn style(&self) -> Style {
1825 Style {
1826 width: Dimension::Length(24.0),
1827 height: Dimension::Length(24.0),
1828 ..Default::default()
1829 }
1830 }
1831 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1832 &[]
1833 }
1834 fn paint(&self, bounds: Rect, _s: Status, t: &Theme, scene: &mut Scene) {
1835 scene.draw_rect(bounds, t.primary, 4.0, 0.0, Color::TRANSPARENT);
1836 }
1837 fn on_click(&self) -> Option<Msg> {
1838 Some(Msg::Filter)
1839 }
1840 }
1841 let table = Table::<Msg>::new(2)
1842 .width(240.0)
1843 .header(&["Name", "Score"])
1844 .on_sort(Msg::Sort)
1845 .header_action(1, || Box::new(Btn));
1846 assert_eq!(
1848 Widget::<Msg>::children(&table)[0].children()[1]
1849 .children()
1850 .len(),
1851 1,
1852 "column 1's header carries the action",
1853 );
1854 let ui = build_ui(
1855 &table,
1856 Size::new(240.0, 100.0),
1857 &Runtime::default(),
1858 &Theme::default(),
1859 );
1860 let click = |x: f32| {
1861 ui.hit(Point::new(x, ROW_H * 0.5))
1862 .and_then(|id| ui.msg_for(id))
1863 };
1864 assert_eq!(
1866 click(218.0),
1867 Some(Msg::Filter),
1868 "the button captures its click"
1869 );
1870 assert_eq!(
1871 click(130.0),
1872 Some(Msg::Sort(1)),
1873 "the rest of the header sorts"
1874 );
1875 }
1876
1877 #[test]
1878 fn row_click_selection_is_announced() {
1879 let table = Table::<Msg>::new(2)
1881 .header(&["Name", "Tag"])
1882 .on_select_row(Msg::Select)
1883 .selected(&[1])
1884 .row(&["Ada", "admin"])
1885 .widget_row(vec![
1886 Box::new(|| Box::new(crate::Text::new("Bob"))),
1887 Box::new(|| Box::new(crate::Text::new("editor"))),
1888 ]);
1889 let rows = Widget::<Msg>::children(&table);
1890 assert_eq!(
1892 rows[1].children()[0].announce().as_deref(),
1893 Some("Row 1 selected")
1894 );
1895 assert_eq!(
1897 rows[2].children()[0].announce().as_deref(),
1898 Some("Row 2 deselected")
1899 );
1900 let plain = Table::<Msg>::new(1).header(&["N"]).row(&["x"]);
1902 assert_eq!(
1903 Widget::<Msg>::children(&plain)[1].children()[0].announce(),
1904 None
1905 );
1906 }
1907
1908 #[test]
1909 fn widget_row_grows_to_tall_content() {
1910 use frus_core::Color;
1911 struct Tall(f32);
1913 impl Widget<Msg> for Tall {
1914 fn style(&self) -> Style {
1915 Style {
1916 width: Dimension::Length(40.0),
1917 height: Dimension::Length(self.0),
1918 ..Default::default()
1919 }
1920 }
1921 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1922 &[]
1923 }
1924 fn paint(&self, bounds: Rect, _s: Status, _t: &Theme, scene: &mut Scene) {
1925 scene.fill_rect(bounds, Color::rgb(1.0, 0.0, 0.0));
1926 }
1927 fn on_click(&self) -> Option<Msg> {
1928 None
1929 }
1930 }
1931 let tall = 60.0;
1932 let table = Table::<Msg>::new(2)
1933 .width(240.0)
1934 .header(&["A", "B"])
1935 .widget_row(vec![
1936 Box::new(move || Box::new(Tall(tall))),
1937 Box::new(|| Box::new(crate::Text::new("x"))),
1938 ]);
1939 let ui = build_ui(
1940 &table,
1941 Size::new(240.0, 200.0),
1942 &Runtime::default(),
1943 &Theme::default(),
1944 );
1945 let bar_h = ui.scene().primitives().iter().find_map(|p| match p {
1948 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
1949 Some(rect.height)
1950 }
1951 _ => None,
1952 });
1953 assert!(
1954 bar_h.unwrap_or(0.0) >= tall - 1.0,
1955 "the row follows the tall content: {bar_h:?}"
1956 );
1957 assert!(
1958 tall - 1.0 > ROW_H,
1959 "the content does exceed the nominal height"
1960 );
1961 }
1962
1963 #[test]
1964 fn header_action_menu_opens_as_column_menu() {
1965 use crate::{Button, PopupMenuButton};
1966 let table = Table::<Msg>::new(2)
1970 .width(240.0)
1971 .header(&["Name", "Score"])
1972 .on_sort(Msg::Sort)
1973 .header_action(1, || {
1974 Box::new(
1975 PopupMenuButton::new(
1976 Button::new("...").on_press(Msg::Filter),
1977 true,
1978 Msg::CheckAll,
1979 )
1980 .item("Sort ascending", Msg::Sort(1))
1981 .item("Sort descending", Msg::Sort(1)),
1982 )
1983 });
1984 let ui = build_ui(
1985 &table,
1986 Size::new(240.0, 200.0),
1987 &Runtime::default(),
1988 &Theme::default(),
1989 );
1990 assert_eq!(
1992 ui.top_dismiss(),
1993 Some(Msg::CheckAll),
1994 "the column menu's overlay is collected"
1995 );
1996 let painted = |t: &str| {
1998 ui.scene()
1999 .primitives()
2000 .iter()
2001 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2002 };
2003 assert!(
2004 painted("Sort ascending"),
2005 "the column menu floats and paints"
2006 );
2007 }
2008
2009 #[test]
2010 fn widget_header_hosts_arbitrary_header_widgets() {
2011 use crate::{Button, Text};
2012 let table = Table::<Msg>::new(2)
2013 .width(240.0)
2014 .widget_header(vec![
2015 Box::new(|| Box::new(Text::new("Name"))),
2016 Box::new(|| Box::new(Button::new("Sort").on_press(Msg::Sort(1)))),
2017 ])
2018 .row(&["Ada", "5"]);
2019 let rows = Widget::<Msg>::children(&table);
2021 assert_eq!(rows[0].children().len(), 2);
2022 assert_eq!(
2023 rows[0].children()[0].children().len(),
2024 1,
2025 "the widget header holds its widget"
2026 );
2027
2028 let ui = build_ui(
2029 &table,
2030 Size::new(240.0, 120.0),
2031 &Runtime::default(),
2032 &Theme::default(),
2033 );
2034 let painted = |t: &str| {
2036 ui.scene()
2037 .primitives()
2038 .iter()
2039 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2040 };
2041 assert!(
2042 painted("Name") && painted("Sort"),
2043 "the header widgets are painted"
2044 );
2045 let click = ui
2047 .hit(Point::new(180.0, ROW_H * 0.5))
2048 .and_then(|id| ui.msg_for(id));
2049 assert_eq!(
2050 click,
2051 Some(Msg::Sort(1)),
2052 "the app wires the sorting into its own header widget"
2053 );
2054 }
2055
2056 #[test]
2057 fn virtual_table_builds_only_visible_rows() {
2058 use std::cell::Cell as StdCell;
2059 use std::rc::Rc as StdRc;
2060 let built = StdRc::new(StdCell::new(0usize));
2061 let counter = built.clone();
2062 let table = Table::<Msg>::new(2)
2063 .width(200.0)
2064 .header(&["Name", "Score"])
2065 .on_select_row(Msg::Select)
2066 .virtual_rows(5000, 200.0, move |i| {
2067 counter.set(counter.get() + 1);
2068 vec![format!("R{i}"), format!("{i}")]
2069 });
2070 let ui = build_ui(
2071 &table,
2072 Size::new(200.0, 260.0),
2073 &Runtime::default(),
2074 &Theme::default(),
2075 );
2076 assert!(
2078 built.get() < 20,
2079 "only the visible rows are built: {}",
2080 built.get()
2081 );
2082 assert!(
2083 built.get() >= 5,
2084 "at least the visible window: {}",
2085 built.get()
2086 );
2087 let has = |t: &str| {
2089 ui.scene()
2090 .primitives()
2091 .iter()
2092 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2093 };
2094 assert!(has("Name"), "the header is pinned above the list");
2095 assert!(has("R0"), "the first virtualised row is built");
2096 let maxes = ui.scrollable_maxes();
2098 assert_eq!(maxes.len(), 1, "one scrollable viewport");
2099 assert_eq!(
2100 maxes[0].2,
2101 5000.0 * ROW_H - 200.0,
2102 "the scroll bound = the total content"
2103 );
2104 let click = ui
2106 .hit(Point::new(20.0, ROW_H + 15.0))
2107 .and_then(|id| ui.msg_for(id));
2108 assert!(
2109 matches!(click, Some(Msg::Select(_))),
2110 "a virtualised row is clickable: {click:?}"
2111 );
2112 }
2113
2114 #[test]
2115 fn frozen_columns_split_into_pinned_and_scrolling_blocks() {
2116 let table = Table::<Msg>::new(3)
2117 .width(240.0)
2118 .column_widths(&[80.0, 120.0, 120.0])
2119 .header(&["Name", "A", "B"])
2120 .on_sort(Msg::Sort)
2121 .on_select_row(Msg::Select)
2122 .frozen_columns(1)
2123 .row(&["Ada", "1", "2"])
2124 .row(&["Bob", "3", "4"]);
2125 let ui = build_ui(
2127 &table,
2128 Size::new(240.0, 160.0),
2129 &Runtime::default(),
2130 &Theme::default(),
2131 );
2132 let maxes = ui.scrollable_maxes();
2134 assert_eq!(maxes.len(), 1, "one scrollable area");
2135 assert!(maxes[0].1 > 0.0, "max horizontal > 0 : {:?}", maxes[0]);
2136 let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
2139 assert_eq!(
2140 click(40.0, ROW_H * 1.5),
2141 Some(Msg::Select(0)),
2142 "a frozen cell selects"
2143 );
2144 assert_eq!(
2145 click(90.0, ROW_H * 0.5),
2146 Some(Msg::Sort(1)),
2147 "a scrolling header sorts"
2148 );
2149 }
2150
2151 #[test]
2152 fn freezing_both_edges_pins_left_and_right_columns() {
2153 let table = Table::<Msg>::new(4)
2155 .width(260.0)
2156 .column_widths(&[70.0, 120.0, 120.0, 70.0])
2157 .header(&["Name", "A", "B", "Act"])
2158 .on_sort(Msg::Sort)
2159 .on_select_row(Msg::Select)
2160 .frozen_columns(1)
2161 .frozen_columns_right(1)
2162 .row(&["Ada", "1", "2", "x"]);
2163 let ui = build_ui(
2164 &table,
2165 Size::new(260.0, 120.0),
2166 &Runtime::default(),
2167 &Theme::default(),
2168 );
2169 let maxes = ui.scrollable_maxes();
2171 assert_eq!(maxes.len(), 1);
2172 assert!(maxes[0].1 > 0.0, "the middle scrolls: {:?}", maxes[0]);
2173 let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
2175 let right = click(225.0, ROW_H * 0.5);
2176 assert_eq!(
2177 right,
2178 Some(Msg::Sort(3)),
2179 "the column frozen on the right: {right:?}"
2180 );
2181 assert_eq!(
2183 click(30.0, ROW_H * 0.5),
2184 Some(Msg::Sort(0)),
2185 "the column frozen on the left"
2186 );
2187 }
2188
2189 #[test]
2190 fn virtual_table_supports_checkboxes() {
2191 let table = Table::<Msg>::new(2)
2192 .width(240.0)
2193 .header(&["Name", "Score"])
2194 .checkboxes(Msg::Check, Msg::CheckAll)
2195 .selected(&[0])
2196 .virtual_rows(1000, 200.0, |i| vec![format!("R{i}"), format!("{i}")]);
2197 let ui = build_ui(
2198 &table,
2199 Size::new(240.0, 260.0),
2200 &Runtime::default(),
2201 &Theme::default(),
2202 );
2203 let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
2204 assert_eq!(click(CHECK_W * 0.5, ROW_H * 0.5), Some(Msg::CheckAll));
2206 let row_check = click(CHECK_W * 0.5, ROW_H + 15.0);
2208 assert!(
2209 matches!(row_check, Some(Msg::Check(_))),
2210 "a virtualised row's box: {row_check:?}"
2211 );
2212 }
2213
2214 #[test]
2215 fn virtual_widget_rows_builds_only_visible() {
2216 use crate::Text;
2217 use std::cell::Cell as StdCell;
2218 use std::rc::Rc as StdRc;
2219 let built = StdRc::new(StdCell::new(0usize));
2220 let counter = built.clone();
2221 let table = Table::<Msg>::new(1)
2222 .width(200.0)
2223 .header(&["Item"])
2224 .on_select_row(Msg::Select)
2225 .virtual_widget_rows(3000, 200.0, move |i| {
2226 counter.set(counter.get() + 1);
2227 vec![Box::new(Text::new(format!("W{i}"))) as Box<dyn Widget<Msg>>]
2228 });
2229 let ui = build_ui(
2230 &table,
2231 Size::new(200.0, 260.0),
2232 &Runtime::default(),
2233 &Theme::default(),
2234 );
2235 assert!(
2236 built.get() < 20 && built.get() >= 5,
2237 "only the visible ones: {}",
2238 built.get()
2239 );
2240 let has = |t: &str| {
2241 ui.scene()
2242 .primitives()
2243 .iter()
2244 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2245 };
2246 assert!(has("Item"), "the header is pinned");
2247 assert!(has("W0"), "the first row's widget is built");
2248 let click = ui
2250 .hit(Point::new(20.0, ROW_H + 15.0))
2251 .and_then(|id| ui.msg_for(id));
2252 assert!(
2253 matches!(click, Some(Msg::Select(_))),
2254 "the widget row is clickable: {click:?}"
2255 );
2256 }
2257
2258 #[test]
2259 fn fixed_column_width_is_applied() {
2260 let table = Table::<()>::new(2)
2261 .width(300.0)
2262 .column_widths(&[80.0]) .header(&["A", "B"])
2264 .row(&["x", "y"]);
2265 let ui = build_ui(
2266 &table,
2267 Size::new(300.0, 100.0),
2268 &Runtime::default(),
2269 &Theme::default(),
2270 );
2271 let text_x = |t: &str| {
2273 ui.scene().primitives().iter().find_map(|p| match p {
2274 Primitive::Text { text, position, .. } if text == t => Some(position.x),
2275 _ => None,
2276 })
2277 };
2278 let (ax, bx) = (text_x("A").unwrap(), text_x("B").unwrap());
2279 assert!(bx >= ax + 80.0, "a fixed column of 80: bx={bx} ax={ax}");
2280 }
2281}