1mod body_pane;
60
61use std::cell::{Cell, RefCell};
62use std::collections::HashMap;
63use std::rc::Rc;
64use std::time::Duration;
65
66use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
67
68use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
69use teksilo_core::binding::BindingLevel;
70use teksilo_core::build_context::BuildContext;
71use teksilo_core::drag_payload::DragPayload;
72use teksilo_core::event::EventResponse;
73use teksilo_core::signal::{Prop, Signal};
74use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
75use teksilo_core::widget_builder::HandlerSet;
76use teksilo_core::widget_id::WidgetId;
77use teksilo_data::{
78 DropPosition, KeyedSelectionModel, NodeId, SelectionModel, SortDirection, SortFilterTreeModel,
79 TreeFilterMode, TreeModel,
80};
81use teksilo_i18n::LocalizedString;
82use teksilo_tokens::{BorderRole, Easing, SurfaceRole};
83
84use crate::styles::recipe_table_style as cp;
85
86use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
87use crate::common::scroll::OverscrollBehavior;
88use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
89use crate::data_views::{DropViz, drop_into_tint};
90use crate::scroll_area::ScrollBarMode;
91use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
92use crate::table_view::ColumnReorderDragData;
93use crate::table_view::body::SharedColumnWidths;
94use crate::table_view::column::{
95 Column, ColumnResizePolicy, EditTriggers, GridLines, PinnedSide, TabTraversal,
96};
97use crate::table_view::header::{
98 ColumnResizeInfo, ColumnResizeTable, HeaderCell, HeaderCellSpec, HeaderRow, ResizeStateHandle,
99 attach_header_reorder_handlers,
100};
101use crate::table_view::imperative;
102use crate::table_view::keyboard;
103use crate::table_view::layout;
104use crate::table_view::row_navigator::RowNavigator;
105use crate::table_view::selection::{CellSelectionModel, TableSelectionMode};
106use crate::tree_source::TreeSource;
107use teksilo_data::{DropResponse, TreeDataSource};
108
109const BUFFER_ROWS: usize = 5;
110const SCROLLBAR_THICKNESS: f32 = 12.0;
111
112pub(crate) struct TreeNavigator<T: 'static> {
119 source: Rc<TreeSource<T>>,
120}
121
122impl<T: 'static> TreeNavigator<T> {
123 pub(crate) fn new(source: Rc<TreeSource<T>>) -> Self {
124 Self { source }
125 }
126}
127
128impl<T: 'static> RowNavigator for TreeNavigator<T> {
129 fn row_count(&self) -> usize {
130 self.source.visible_count()
131 }
132
133 fn depth(&self, row: usize) -> Option<usize> {
134 self.source.meta(row).map(|m| m.depth)
135 }
136
137 fn has_children(&self, row: usize) -> bool {
138 self.source
139 .meta(row)
140 .map(|m| m.has_children)
141 .unwrap_or(false)
142 }
143
144 fn is_expanded(&self, row: usize) -> bool {
145 self.source
146 .meta(row)
147 .map(|m| m.is_expanded)
148 .unwrap_or(false)
149 }
150
151 fn toggle_expanded(&self, row: usize) {
152 self.source.toggle_at(row);
153 }
154}
155
156pub struct TreeTableView<T: 'static> {
158 source: Rc<TreeSource<T>>,
162 proxy: Option<SortFilterTreeModel<T>>,
168
169 columns: Vec<Column<T>>,
170 tree_column_id: Option<String>,
173 indent_per_level: Option<f32>,
174 row_height: Option<f32>,
175 height_source: HeightSource,
177 row_metrics: SharedRowMetrics,
180 header_height: Option<f32>,
181 show_header: bool,
182 selection_mode: TableSelectionMode,
183 row_selection: Option<RowSelection>,
186 cell_selection: Option<CellSelectionModel>,
187 alternating_rows: bool,
188 grid_lines: GridLines,
189 a11y_label: Option<LocalizedString>,
190 show_internal_scrollbars: bool,
191 column_resize_policy: ColumnResizePolicy,
192 tab_traversal: TabTraversal,
193 edit_triggers: EditTriggers,
194 #[allow(clippy::type_complexity)]
195 on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
196 on_cell_edit_dismissed: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
197 #[allow(clippy::type_complexity)]
198 on_row_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
199
200 smooth_scrolling: bool,
205 smooth_scroll_duration: Duration,
207
208 scroll_bar_style: ScrollBarMode,
212
213 scroll_y: Signal<f32>,
215 max_scroll_y: Signal<f32>,
216 overscroll_behavior: OverscrollBehavior,
218 viewport_ratio_y: Signal<f32>,
219 scroll_x: Signal<f32>,
222 max_scroll_x: Signal<f32>,
223 viewport_ratio_x: Signal<f32>,
224 sort_signal: Signal<Option<(String, SortDirection)>>,
225 column_widths_signal: Signal<HashMap<String, f32>>,
226 column_order_signal: Signal<Vec<String>>,
227 column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
228 filters_signal: Signal<HashMap<String, String>>,
229 focused_cell: Signal<Option<(usize, usize)>>,
230 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
234 editing_cell: Signal<Option<(usize, usize)>>,
235 #[allow(clippy::type_complexity)]
238 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
239 type_ahead_timeout: Duration,
241 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
243 #[allow(clippy::type_complexity)]
246 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
247 laid_out: Rc<Cell<bool>>,
251 editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
254
255 header_row_id: Option<WidgetId>,
257 body_pane_id: Option<WidgetId>,
258 scrollbar_id: Option<WidgetId>,
259 h_scrollbar_id: Option<WidgetId>,
262 empty_id: Option<WidgetId>,
263 pane_version: Signal<u64>,
267 pane_built_start: Rc<Cell<usize>>,
268 pane_built_end: Rc<Cell<usize>>,
269 pane_total_refresh: Signal<u64>,
273
274 reorderable: bool,
279 drop_feedback: Signal<Option<DropViz>>,
282
283 activate_on: crate::data_views::ActivateOn,
285
286 view_focused: Signal<bool>,
291 focus_visible: Signal<bool>,
294
295 column_widths: SharedColumnWidths,
297 display_indices: Rc<RefCell<Vec<usize>>>,
298 pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
301 cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
307 viewport_height: Rc<Cell<f32>>,
308 middle_viewport_width: Rc<Cell<f32>>,
311 body_bounds: Rc<Cell<Rect>>,
316 resize_state: ResizeStateHandle,
317 resize_target: Signal<Option<usize>>,
322 resize_preview_x: Signal<Option<f32>>,
326 header_strip_width: Rc<Cell<f32>>,
330 table_id: usize,
333
334 model_id: ViewId,
338
339 export: crate::data_views::RowExport<T>,
349 #[allow(clippy::type_complexity)]
361 on_foreign_drop:
362 Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
363
364 enabled: Prop<bool>,
369}
370
371impl<T: 'static> TreeTableView<T> {
372 pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
375 let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
376 Self::assemble(source, Some(proxy))
377 }
378
379 pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
400 Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
401 }
402
403 pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
410 source: S,
411 keyed: KeyedSelectionModel<S::Key>,
412 ) -> Self
413 where
414 S::Key: teksilo_data::ItemKey,
415 {
416 let s = Rc::new(source);
417 let key_at = {
418 let s = s.clone();
419 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
420 };
421 let len = {
422 let s = s.clone();
423 Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
424 };
425 let contains = {
426 let s = s.clone();
427 Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
428 };
429 let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
430 view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
431 view
432 }
433
434 fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
435 use std::sync::atomic::{AtomicUsize, Ordering};
436 static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
437 let table_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
438 Self {
439 source,
440 proxy,
441 columns: Vec::new(),
442 tree_column_id: None,
443 indent_per_level: None,
444 row_height: None,
445 height_source: HeightSource::Uniform,
446 row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
447 header_height: None,
448 show_header: true,
449 selection_mode: TableSelectionMode::default(),
450 row_selection: None,
451 cell_selection: None,
452 alternating_rows: false,
453 grid_lines: GridLines::None,
454 a11y_label: None,
455 show_internal_scrollbars: true,
456 column_resize_policy: ColumnResizePolicy::default(),
457 tab_traversal: TabTraversal::default(),
458 edit_triggers: EditTriggers::default(),
459 on_cell_edit_request: None,
460 on_cell_edit_dismissed: None,
461 on_row_activate: None,
462 reorderable: false,
463 drop_feedback: Signal::new(None),
464 activate_on: crate::data_views::ActivateOn::default(),
465 smooth_scrolling: true,
466 smooth_scroll_duration: Duration::from_millis(150),
467 scroll_bar_style: ScrollBarMode::Permanent,
468 scroll_y: Signal::new_animated(0.0),
469 max_scroll_y: Signal::new(0.0),
470 overscroll_behavior: OverscrollBehavior::default(),
471 viewport_ratio_y: Signal::new(1.0),
472 scroll_x: Signal::new_animated(0.0),
473 max_scroll_x: Signal::new(0.0),
474 viewport_ratio_x: Signal::new(1.0),
475 sort_signal: Signal::new(None),
476 column_widths_signal: Signal::new(HashMap::new()),
477 column_order_signal: Signal::new(Vec::new()),
478 column_pinning_signal: Signal::new(HashMap::new()),
479 filters_signal: Signal::new(HashMap::new()),
480 focused_cell: Signal::new(None),
481 row_map: Rc::new(RefCell::new(Vec::new())),
482 type_ahead_label: None,
483 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
484 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
485 view_focused: Signal::new(true),
487 focus_visible: Signal::new(false),
488 editing_cell: Signal::new(None),
489 empty_view: None,
490 laid_out: Rc::new(Cell::new(false)),
491 editing_anchor: Rc::new(RefCell::new(None)),
492 header_row_id: None,
493 body_pane_id: None,
494 scrollbar_id: None,
495 h_scrollbar_id: None,
496 empty_id: None,
497 pane_version: Signal::new(0_u64),
498 pane_built_start: Rc::new(Cell::new(0)),
499 pane_built_end: Rc::new(Cell::new(0)),
500 pane_total_refresh: Signal::new(0_u64),
501 column_widths: Rc::new(RefCell::new(Vec::new())),
502 display_indices: Rc::new(RefCell::new(Vec::new())),
503 pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
504 cell_map: Rc::new(RefCell::new(Vec::new())),
505 viewport_height: Rc::new(Cell::new(600.0)),
506 middle_viewport_width: Rc::new(Cell::new(600.0)),
507 body_bounds: Rc::new(Cell::new(Rect::ZERO)),
508 resize_state: Rc::new(RefCell::new(None)),
509 resize_target: Signal::new(None),
510 resize_preview_x: Signal::new(None),
511 header_strip_width: Rc::new(Cell::new(0.0)),
512 table_id,
513 model_id: ViewId::next(ViewKind::TreeTable),
514 export: crate::data_views::RowExport::default(),
515 on_foreign_drop: None,
516 enabled: Prop::Static(true),
517 }
518 }
519
520 pub fn new(model: TreeModel<T>) -> Self {
524 Self::from_projection(SortFilterTreeModel::new(model))
525 }
526
527 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
532 self.enabled = enabled.into();
533 self
534 }
535
536 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
540 self.overscroll_behavior = behavior;
541 self
542 }
543
544 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
547 self.smooth_scrolling = enabled;
548 self
549 }
550
551 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
559 self.type_ahead_label = Some(Rc::new(label));
560 self
561 }
562
563 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
566 self.type_ahead_timeout = timeout;
567 self
568 }
569
570 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
572 self.smooth_scroll_duration = duration;
573 self
574 }
575
576 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
580 self.scroll_bar_style = style;
581 self
582 }
583
584 pub fn add_column(mut self, col: Column<T>) -> Self {
587 self.columns.push(col);
588 self
589 }
590
591 pub fn reorderable(mut self, enabled: bool) -> Self {
603 self.reorderable = enabled;
604 self
605 }
606
607 pub fn exportable(mut self, mode: DragTransferMode) -> Self
626 where
627 T: Clone,
628 {
629 self.export.set_exportable(mode);
630 self
631 }
632
633 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
641 where
642 T: Clone,
643 {
644 self.export.set_export_external(f);
645 self
646 }
647
648 pub fn on_rows_transferred_out(
657 mut self,
658 f: impl Fn(&[usize], &mut EventContext) + 'static,
659 ) -> Self {
660 self.export.set_on_rows_transferred_out(f);
661 self
662 }
663
664 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
670 self.export.accept_foreign_rows = accept;
671 self
672 }
673
674 pub fn on_rows_received(
678 mut self,
679 f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
680 ) -> Self {
681 self.export.set_on_rows_received(f);
682 self
683 }
684
685 pub fn on_foreign_drop(
703 mut self,
704 f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
705 ) -> Self {
706 self.on_foreign_drop = Some(Rc::new(f));
707 self
708 }
709
710 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
714 self.activate_on = mode;
715 self
716 }
717
718 pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
720 self.columns.extend(cols);
721 self
722 }
723
724 pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
727 self.tree_column_id = Some(col_id.into());
728 self
729 }
730
731 pub fn indent_per_level(mut self, px: f32) -> Self {
734 self.indent_per_level = Some(px);
735 self
736 }
737
738 fn remake_metrics(&self) {
741 *self.row_metrics.borrow_mut() = self
742 .height_source
743 .make_metrics(self.effective_row_height(), 0.0);
744 }
745
746 pub fn row_height(mut self, height: f32) -> Self {
752 self.row_height = Some(height);
753 self.height_source = HeightSource::Uniform;
754 self.remake_metrics();
755 self
756 }
757
758 pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
764 self.height_source = HeightSource::Exact(Rc::new(f));
765 self.remake_metrics();
766 self
767 }
768
769 pub fn auto_row_height(mut self, estimated: f32) -> Self {
777 self.height_source = HeightSource::Auto { estimated };
778 self.remake_metrics();
779 self
780 }
781
782 pub fn header_height(mut self, height: f32) -> Self {
784 self.header_height = Some(height);
785 self
786 }
787
788 pub fn show_header(mut self, visible: bool) -> Self {
790 self.show_header = visible;
791 self
792 }
793
794 pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
797 self.selection_mode = mode;
798 self
799 }
800
801 pub fn selection(mut self, sel: SelectionModel) -> Self {
806 self.row_selection = Some(RowSelection::from_index(sel));
807 self
808 }
809
810 pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
820 let Some(proxy) = self.proxy.clone() else {
821 return self;
822 };
823 let key_at = {
824 let p = proxy.clone();
825 Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
826 };
827 let len = {
828 let p = proxy.clone();
829 Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
830 };
831 let contains = {
834 let p = proxy;
835 Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
836 as Rc<dyn Fn(&NodeId) -> bool>
837 };
838 self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
839 self
840 }
841
842 pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
845 self.cell_selection = Some(sel);
846 self
847 }
848
849 pub fn alternating_rows(mut self, enabled: bool) -> Self {
852 self.alternating_rows = enabled;
853 self
854 }
855
856 pub fn grid_lines(mut self, kind: GridLines) -> Self {
858 self.grid_lines = kind;
859 self
860 }
861
862 pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
865 self.a11y_label = Some(label.into());
866 self
867 }
868
869 pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
873 self.show_internal_scrollbars = show;
874 self
875 }
876
877 pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
880 self.column_resize_policy = policy;
881 self
882 }
883
884 pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
886 self.tab_traversal = mode;
887 self
888 }
889
890 pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
893 self.edit_triggers = trigger;
894 self
895 }
896
897 pub fn on_cell_edit_request(
901 mut self,
902 f: impl Fn(usize, &str, &mut EventContext) + 'static,
903 ) -> Self {
904 self.on_cell_edit_request = Some(Rc::new(f));
905 self
906 }
907
908 pub fn on_cell_edit_dismissed(
926 mut self,
927 f: impl Fn(usize, &str, &mut EventContext) + 'static,
928 ) -> Self {
929 self.on_cell_edit_dismissed = Some(Rc::new(f));
930 self
931 }
932
933 pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
936 self.on_row_activate = Some(Rc::new(f));
937 self
938 }
939
940 pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
945 if let Some(p) = &self.proxy {
946 let _ = p.clone().filter_mode(mode);
947 }
948 self
949 }
950
951 pub fn scroll_y_signal(&self) -> &Signal<f32> {
955 &self.scroll_y
956 }
957
958 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
960 &self.max_scroll_y
961 }
962
963 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
965 &self.viewport_ratio_y
966 }
967
968 pub fn scroll_x_signal(&self) -> &Signal<f32> {
971 &self.scroll_x
972 }
973
974 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
977 &self.max_scroll_x
978 }
979
980 pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
982 &self.viewport_ratio_x
983 }
984
985 pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1001 &self.sort_signal
1002 }
1003
1004 pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1018 &self.filters_signal
1019 }
1020
1021 pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1023 &self.column_widths_signal
1024 }
1025
1026 pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1028 &self.column_order_signal
1029 }
1030
1031 pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1033 &self.focused_cell
1034 }
1035
1036 pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1038 &self.editing_cell
1039 }
1040
1041 fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1047 self.cell_map
1048 .borrow()
1049 .iter()
1050 .find(|&&(pos, _)| pos == (row, col))
1051 .map(|&(_, id)| id)
1052 }
1053
1054 pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1061 self.proxy.as_ref()
1062 }
1063
1064 pub fn expand(&self, node: NodeId) {
1068 if let Some(p) = &self.proxy {
1069 p.expand(node);
1070 }
1071 }
1072
1073 pub fn collapse(&self, node: NodeId) {
1075 if let Some(p) = &self.proxy {
1076 p.collapse(node);
1077 }
1078 }
1079
1080 pub fn toggle(&self, node: NodeId) {
1082 if let Some(p) = &self.proxy {
1083 p.toggle(node);
1084 }
1085 }
1086
1087 pub fn expand_all(&self) {
1089 if let Some(p) = &self.proxy {
1090 p.expand_all();
1091 }
1092 }
1093
1094 pub fn collapse_all(&self) {
1096 if let Some(p) = &self.proxy {
1097 p.collapse_all();
1098 }
1099 }
1100
1101 pub fn set_focused_cell(&self, row: usize, col: usize) {
1103 self.focused_cell.set(Some((row, col)));
1104 }
1105
1106 pub fn clear_focused_cell(&self) {
1108 self.focused_cell.set(None);
1109 }
1110
1111 pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1116 imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1117 }
1118
1119 pub fn set_filter(&self, col_id: &str, text: &str) {
1121 imperative::set_filter(&self.filters_signal, col_id, text);
1122 }
1123
1124 pub fn clear_filters(&self) {
1125 imperative::set_if_changed(&self.filters_signal, HashMap::new());
1126 }
1127
1128 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1131 self.empty_view = Some(Rc::new(f));
1132 self
1133 }
1134
1135 pub fn clear_sort(&self) {
1137 imperative::set_if_changed(&self.sort_signal, None);
1138 }
1139
1140 pub fn scroll_to_row(&self, row: usize) {
1143 if !self.laid_out.get() {
1144 return;
1145 }
1146 imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1147 }
1148
1149 pub fn ensure_row_visible(&self, row: usize) {
1152 imperative::ensure_row_visible(
1153 row,
1154 &self.row_metrics,
1155 &self.scroll_y,
1156 &self.max_scroll_y,
1157 self.viewport_height.get(),
1158 self.laid_out.get(),
1159 );
1160 }
1161
1162 fn reveal_current_row_on_focus(&self, ctx: &mut BuildContext) {
1206 let focused_cell = self.focused_cell.clone();
1207 let selection = self.row_selection.clone();
1208 let row_metrics = self.row_metrics.clone();
1209 let scroll_y = self.scroll_y.clone();
1210 let max_scroll_y = self.max_scroll_y.clone();
1211 let viewport_height = self.viewport_height.clone();
1212 let laid_out = self.laid_out.clone();
1213
1214 ctx.effect(&self.view_focused, move |focused| {
1215 if !*focused {
1216 return;
1217 }
1218 let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1219 selection
1220 .as_ref()
1221 .and_then(|s| s.selected_indices().first().copied())
1222 }) else {
1223 return;
1224 };
1225 imperative::ensure_row_visible(
1226 row,
1227 &row_metrics,
1228 &scroll_y,
1229 &max_scroll_y,
1230 viewport_height.get(),
1231 laid_out.get(),
1232 );
1233 });
1234 }
1235
1236 pub fn set_column_width(&self, col_id: &str, width: f32) {
1240 imperative::set_column_width(&self.column_widths_signal, col_id, width);
1241 }
1242
1243 pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1251 imperative::set_column_widths(&self.column_widths_signal, widths);
1252 }
1253
1254 pub fn set_column_order(&self, order: Vec<String>) {
1257 imperative::set_if_changed(&self.column_order_signal, order);
1258 }
1259
1260 pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1263 &self.column_pinning_signal
1264 }
1265
1266 pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1269 imperative::set_column_pinning(&self.column_pinning_signal, col_id, side);
1270 }
1271
1272 pub fn begin_edit(&self, row: usize, col_id: &str) {
1284 let cached = self.display_indices.borrow();
1285 let recomputed;
1286 let display: &[usize] = if cached.is_empty() {
1287 recomputed = self.display_order();
1288 &recomputed
1289 } else {
1290 &cached
1291 };
1292 if let Some(target) = imperative::resolve_edit_target(
1293 row,
1294 col_id,
1295 &self.columns,
1296 display,
1297 self.source.visible_count(),
1298 ) {
1299 drop(cached);
1300 self.editing_cell.set(Some(target));
1301 }
1302 }
1303
1304 pub fn end_edit(&self) {
1306 self.editing_cell.set(None);
1307 }
1308
1309 fn effective_row_height(&self) -> f32 {
1312 self.row_height.unwrap_or(cp::ROW_HEIGHT)
1313 }
1314
1315 fn effective_header_height(&self) -> f32 {
1316 if self.show_header {
1317 self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1318 } else {
1319 0.0
1320 }
1321 }
1322
1323 fn effective_indent(&self) -> f32 {
1324 self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1325 }
1326
1327 fn tree_column_decl_index(&self) -> usize {
1330 if let Some(ref id) = self.tree_column_id {
1331 for (i, col) in self.columns.iter().enumerate() {
1332 if &col.id == id {
1333 return i;
1334 }
1335 }
1336 }
1337 0
1338 }
1339
1340 fn display_order(&self) -> Vec<usize> {
1341 let order_signal = self.column_order_signal.get();
1342 let mut order_map: HashMap<&str, usize> = HashMap::new();
1343 for (i, id) in order_signal.iter().enumerate() {
1344 order_map.insert(id.as_str(), i);
1345 }
1346 let mut leading: Vec<usize> = Vec::new();
1347 let mut middle: Vec<usize> = Vec::new();
1348 let mut trailing: Vec<usize> = Vec::new();
1349 for (i, col) in self.columns.iter().enumerate() {
1350 let pinning = self
1351 .column_pinning_signal
1352 .get()
1353 .get(&col.id)
1354 .copied()
1355 .unwrap_or(col.pinned);
1356 match pinning {
1357 PinnedSide::Leading => leading.push(i),
1358 PinnedSide::None => middle.push(i),
1359 PinnedSide::Trailing => trailing.push(i),
1360 }
1361 }
1362 const FALLBACK_BASE: usize = usize::MAX / 2;
1363 let cols = &self.columns;
1364 let key_for = |i: usize| {
1365 order_map
1366 .get(cols[i].id.as_str())
1367 .copied()
1368 .unwrap_or(FALLBACK_BASE + i)
1369 };
1370 leading.sort_by_key(|&i| key_for(i));
1371 middle.sort_by_key(|&i| key_for(i));
1372 trailing.sort_by_key(|&i| key_for(i));
1373 let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1374 out.extend(leading);
1375 let leading_count = out.len();
1376 out.extend(middle);
1377 let middle_end = out.len();
1378 out.extend(trailing);
1379 *self.pane_boundaries.borrow_mut() =
1383 crate::table_view::PaneBoundaries::new(leading_count, middle_end);
1384 out
1385 }
1386
1387 fn clamp_scroll(&self) {
1388 let max = self.max_scroll_y.get();
1389 let current = self.scroll_y.get();
1390 let clamped = current.clamp(0.0, max);
1391 if (clamped - current).abs() > 0.001 {
1392 self.scroll_y.set(clamped);
1393 }
1394 }
1395
1396 fn visible_range(&self) -> (usize, usize) {
1401 self.row_metrics.borrow_mut().visible_range(
1402 self.scroll_y.get(),
1403 self.viewport_height.get(),
1404 self.source.visible_count(),
1405 BUFFER_ROWS,
1406 )
1407 }
1408}
1409
1410impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1412 f.debug_struct("TreeTableView")
1413 .field("rows", &self.source.visible_count())
1414 .field("columns", &self.columns.len())
1415 .field("tree_column", &self.tree_column_id)
1416 .field("scroll_bar_style", &self.scroll_bar_style)
1417 .finish()
1418 }
1419}
1420
1421impl<T: 'static> Widget for TreeTableView<T> {
1422 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1423 let self_id = ctx.self_id();
1424 ctx.enabled_when(self_id, self.enabled.clone());
1425
1426 let row_h = self.effective_row_height();
1427 let header_h = self.effective_header_height();
1428 let indent_per_level = self.effective_indent();
1429
1430 let version = ctx.signal(0_u64);
1431 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1432
1433 self.scroll_y.bind_to(
1434 ctx.self_id(),
1435 ctx.binding_registry(),
1436 BindingLevel::Relayout,
1437 );
1438 ctx.register_animated_signal(&self.scroll_y);
1439
1440 self.scroll_x.bind_to(
1441 ctx.self_id(),
1442 ctx.binding_registry(),
1443 BindingLevel::Relayout,
1444 );
1445 ctx.register_animated_signal(&self.scroll_x);
1446
1447 self.pane_total_refresh.bind_to(
1452 ctx.self_id(),
1453 ctx.binding_registry(),
1454 BindingLevel::Relayout,
1455 );
1456
1457 self.column_widths_signal.bind_to(
1458 ctx.self_id(),
1459 ctx.binding_registry(),
1460 BindingLevel::Relayout,
1461 );
1462 self.resize_preview_x.bind_to(
1465 ctx.self_id(),
1466 ctx.binding_registry(),
1467 BindingLevel::RepaintOnly,
1468 );
1469
1470 {
1474 let resize_state = self.resize_state.clone();
1475 let resize_target = self.resize_target.clone();
1476 let resize_preview_x = self.resize_preview_x.clone();
1477 ctx.effect(&ctx.window_active_signal(), move |active| {
1478 if !*active && resize_state.borrow().is_some() {
1479 *resize_state.borrow_mut() = None;
1480 resize_target.set(None);
1481 resize_preview_x.set(None);
1482 }
1483 });
1484 }
1485 self.focused_cell.bind_to(
1486 ctx.self_id(),
1487 ctx.binding_registry(),
1488 BindingLevel::RepaintOnly,
1489 );
1490 self.focused_cell.bind_to(
1495 ctx.self_id(),
1496 ctx.binding_registry(),
1497 BindingLevel::AccessibilityOnly,
1498 );
1499
1500 self.view_focused = ctx.begin_view_focus();
1510 ctx.end_view_focus();
1511 self.focus_visible = ctx.focus_visible();
1512 self.reveal_current_row_on_focus(ctx);
1513 self.view_focused.bind_to(
1514 ctx.self_id(),
1515 ctx.binding_registry(),
1516 BindingLevel::RepaintOnly,
1517 );
1518 self.focus_visible.bind_to(
1519 ctx.self_id(),
1520 ctx.binding_registry(),
1521 BindingLevel::RepaintOnly,
1522 );
1523 self.drop_feedback.bind_to(
1526 ctx.self_id(),
1527 ctx.binding_registry(),
1528 BindingLevel::RepaintOnly,
1529 );
1530
1531 let v_for_proj = version.clone();
1537 let proj_ver = Rc::new(Cell::new(0_u64));
1538 let prev_visible_count = Rc::new(Cell::new(self.source.visible_count()));
1539 ctx.effect(&self.source.version_signal(), {
1540 let metrics = self.row_metrics.clone();
1541 let src = self.source.clone();
1542 let row_sel = self.row_selection.clone();
1543 let cell_sel = self.cell_selection.clone();
1544 let prev_visible_count = prev_visible_count.clone();
1545 move |_| {
1546 metrics
1547 .borrow_mut()
1548 .apply_divergence(src.first_changed_index(), src.visible_count());
1549 if let Some(ref rs) = row_sel {
1552 rs.prune();
1553 }
1554 let new_visible_count = src.visible_count();
1568 if let Some(ref cs) = cell_sel
1569 && new_visible_count != prev_visible_count.get()
1570 {
1571 cs.clear();
1572 }
1573 prev_visible_count.set(new_visible_count);
1574 let next = proj_ver.get() + 1;
1575 proj_ver.set(next);
1576 v_for_proj.set(next);
1577 }
1578 });
1579
1580 let v_for_sort = version.clone();
1591 let sv = Rc::new(Cell::new(0_u64));
1592 ctx.effect(&self.sort_signal, move |_| {
1593 let next = sv.get() + 1;
1594 sv.set(next);
1595 v_for_sort.set(next);
1596 });
1597 let v_for_order = version.clone();
1598 let ov = Rc::new(Cell::new(0_u64));
1599 ctx.effect(&self.column_order_signal, move |_| {
1600 let next = ov.get() + 1;
1601 ov.set(next);
1602 v_for_order.set(next);
1603 });
1604 let v_for_pin = version.clone();
1605 let pv = Rc::new(Cell::new(0_u64));
1606 ctx.effect(&self.column_pinning_signal, move |_| {
1607 let next = pv.get() + 1;
1608 pv.set(next);
1609 v_for_pin.set(next);
1610 });
1611 let display_indices = self.display_order();
1618
1619 {
1626 let old_display = self.display_indices.borrow();
1627 if !old_display.is_empty() {
1628 let old_to_new: Vec<Option<usize>> = old_display
1629 .iter()
1630 .map(|&decl_idx| {
1631 let id = &self.columns[decl_idx].id;
1632 display_indices
1633 .iter()
1634 .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
1635 })
1636 .collect();
1637 drop(old_display);
1638 imperative::remap_cell_state(
1639 &self.focused_cell,
1640 &self.editing_cell,
1641 self.cell_selection.as_ref(),
1642 &old_to_new,
1643 );
1644 }
1645 }
1646 *self.display_indices.borrow_mut() = display_indices.clone();
1647 let tree_decl = self.tree_column_decl_index();
1648 let tree_display_pos = display_indices
1649 .iter()
1650 .position(|&i| i == tree_decl)
1651 .unwrap_or(0);
1652
1653 let scroll_y_for_wheel = self.scroll_y.clone();
1655 let max_scroll_for_wheel = self.max_scroll_y.clone();
1656 let scroll_x_for_wheel = self.scroll_x.clone();
1657 let max_scroll_x_for_wheel = self.max_scroll_x.clone();
1658 let line_height = row_h;
1659 let smooth_scrolling = self.smooth_scrolling;
1660 let smooth_scroll_duration = self.smooth_scroll_duration;
1661
1662 let column_ids_in_display_order: Vec<String> = display_indices
1663 .iter()
1664 .map(|&i| self.columns[i].id.clone())
1665 .collect();
1666 let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
1667 let ids = column_ids_in_display_order;
1668 Rc::new(move |pos| ids.get(pos).cloned())
1669 };
1670 let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
1674 let view_triggers = self.edit_triggers;
1675 let per_display_column: Vec<EditTriggers> = display_indices
1676 .iter()
1677 .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
1678 .collect();
1679 Rc::new(move |pos| {
1680 per_display_column
1681 .get(pos)
1682 .copied()
1683 .unwrap_or(EditTriggers::NONE)
1684 })
1685 };
1686
1687 let navigator: Rc<dyn RowNavigator> = Rc::new(TreeNavigator::new(self.source.clone()));
1688 let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
1691 self.type_ahead_label.clone().map(|user| {
1692 let src = self.source.clone();
1693 Rc::new(move |i: usize| src.with_row_str(i, &|item| user(item)))
1694 as Rc<dyn Fn(usize) -> Option<String>>
1695 });
1696
1697 let key_cfg = keyboard::KeyHandlerConfig {
1698 navigator,
1699 col_count: display_indices.len().max(1),
1700 tree_column_display_pos: tree_display_pos,
1705 focused_cell: self.focused_cell.clone(),
1706 cell_map: self.cell_map.clone(),
1707 selection_mode: self.selection_mode,
1708 selection: self.row_selection.clone(),
1709 cell_selection: self.cell_selection.clone(),
1710 scroll_y: self.scroll_y.clone(),
1711 max_scroll_y: self.max_scroll_y.clone(),
1712 viewport_height: self.viewport_height.clone(),
1713 body_bounds: self.body_bounds.clone(),
1714 row_metrics: self.row_metrics.clone(),
1715 tab_traversal: self.tab_traversal,
1716 editing_cell: self.editing_cell.clone(),
1717 display_col_to_id,
1718 display_col_triggers,
1719 on_cell_edit_request: self.on_cell_edit_request.clone(),
1720 on_row_activate: self.on_row_activate.clone(),
1721 type_ahead: self.type_ahead.clone(),
1722 type_ahead_label,
1723 type_ahead_timeout: self.type_ahead_timeout,
1724 column_widths: self.column_widths.clone(),
1725 pane_boundaries: *self.pane_boundaries.borrow(),
1726 scroll_x: self.scroll_x.clone(),
1727 max_scroll_x: self.max_scroll_x.clone(),
1728 middle_viewport_width: self.middle_viewport_width.clone(),
1729 };
1730
1731 let mut shared_key = keyboard::build_key_handler(key_cfg);
1736 let reorderable_kbd = self.reorderable;
1737 let source_kbd = self.source.clone();
1738 let focused_kbd = self.focused_cell.clone();
1739 let sel_kbd = self.row_selection.clone();
1740 let sort_kbd = self.sort_signal.clone();
1741 let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1742 ctx: &mut EventContext|
1743 -> EventResponse {
1744 use teksilo_core::event::{Key, WidgetEvent};
1745 if reorderable_kbd
1746 && sort_kbd.get().is_none()
1747 && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1748 && modifiers.alt()
1749 && matches!(key, Key::ArrowUp | Key::ArrowDown)
1750 {
1751 let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1752 sel_kbd
1753 .as_ref()
1754 .and_then(|s| s.selected_indices().first().copied())
1755 });
1756 if let Some(flat_idx) = row
1760 && let Some(new_flat) =
1761 source_kbd.keyboard_reorder(flat_idx, matches!(key, Key::ArrowDown))
1762 {
1763 let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1764 focused_kbd.set(Some((new_flat, col)));
1765 if let Some(ref s) = sel_kbd {
1766 s.select(new_flat);
1767 }
1768 return EventResponse::Handled;
1769 }
1770 }
1771 shared_key(event, ctx)
1772 };
1773
1774 let mut handlers = HandlerSet::new()
1775 .on_scroll({
1776 let overscroll_behavior = self.overscroll_behavior;
1777 move |event, _ctx| match event {
1778 teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1779 let (raw_dx, raw_dy) = match delta {
1780 teksilo_core::event::ScrollDelta::Lines { x, y } => {
1781 (x * line_height, y * line_height)
1782 }
1783 teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1784 };
1785 let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1788 (raw_dy, 0.0)
1789 } else {
1790 (raw_dx, raw_dy)
1791 };
1792
1793 let mut moved_any = false;
1794 if dy.abs() > 0.0 {
1795 let current = scroll_y_for_wheel.get();
1796 let max = max_scroll_for_wheel.get();
1797 let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1802 let (new_y, moved) =
1803 crate::common::scroll::scroll_clamp_axis(base, dy, max);
1804 if moved {
1805 if smooth_scrolling {
1806 scroll_y_for_wheel.animate_to(
1807 new_y,
1808 smooth_scroll_duration,
1809 Easing::EaseOut,
1810 );
1811 } else {
1812 scroll_y_for_wheel.set(new_y);
1813 }
1814 }
1815 moved_any |= moved;
1816 }
1817 if dx.abs() > 0.0 {
1818 let current = scroll_x_for_wheel.get();
1819 let max = max_scroll_x_for_wheel.get();
1820 let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1821 let (new_x, moved) =
1822 crate::common::scroll::scroll_clamp_axis(base, dx, max);
1823 if moved {
1824 if smooth_scrolling {
1825 scroll_x_for_wheel.animate_to(
1826 new_x,
1827 smooth_scroll_duration,
1828 Easing::EaseOut,
1829 );
1830 } else {
1831 scroll_x_for_wheel.set(new_x);
1832 }
1833 }
1834 moved_any |= moved;
1835 }
1836 crate::common::scroll::scroll_response(
1840 moved_any,
1841 overscroll_behavior == OverscrollBehavior::Contain,
1842 )
1843 }
1844 _ => EventResponse::Ignored,
1845 }
1846 })
1847 .on_key(key_handler)
1848 .clips_children(true)
1849 .focusable(true);
1850
1851 if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
1871 let my_model_id = self.model_id;
1872 let source_for_hover = self.source.clone();
1873 let metrics_for_hover = self.row_metrics.clone();
1874 let scroll_for_hover = self.scroll_y.clone();
1875 let header_h_for_hover = header_h;
1876 let feedback_for_hover = self.drop_feedback.clone();
1877 let sort_for_hover = self.sort_signal.clone();
1878 let reorderable_hover = self.reorderable;
1879 let export_for_hover = self.export.clone();
1880 let has_foreign_hook_hover = self.on_foreign_drop.is_some();
1881 let bounds_for_hover = self.body_bounds.clone();
1882 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1883 if payload.has_typed::<ColumnReorderDragData>() {
1891 feedback_for_hover.set(None);
1892 return teksilo_core::DropFeedback::NoFeedback;
1893 }
1894 let viz_width = bounds_for_hover.get().width.max(1.0);
1897 let count = source_for_hover.visible_count();
1898 if count == 0 {
1899 feedback_for_hover.set(None);
1900 return teksilo_core::DropFeedback::NoFeedback;
1901 }
1902 let rd = payload.get_typed::<RowDragData<T>>();
1903 let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
1904 let reorder_ok =
1905 is_same_view && reorderable_hover && sort_for_hover.get().is_none();
1906 let foreign_ok = !is_same_view
1910 && (has_foreign_hook_hover
1911 || export_for_hover.accepts_foreign_export(payload, my_model_id));
1912 if !reorder_ok && !foreign_ok {
1913 feedback_for_hover.set(None);
1914 return teksilo_core::DropFeedback::NoFeedback;
1915 }
1916 let scroll = scroll_for_hover.get().max(0.0);
1917 let content_y = position.y - header_h_for_hover + scroll;
1918 let (insertion_top, row_idx, row_top, row_h) = {
1919 let mut m = metrics_for_hover.borrow_mut();
1920 m.resize(count);
1921 let ins = m.insertion_index(content_y);
1922 let r = m.row_at(content_y);
1923 (m.row_top(ins), r, m.row_top(r), m.row_height(r))
1924 };
1925 let y_in_row = content_y - row_top;
1926 let third = (row_h / 3.0).max(f32::EPSILON);
1927 let drop_pos = if y_in_row < third {
1928 DropPosition::Before
1929 } else if y_in_row > 2.0 * third {
1930 DropPosition::After
1931 } else {
1932 DropPosition::Into
1933 };
1934 let (effective, depth) = if reorder_ok {
1942 match (source_for_hover.dnd.can_accept_fn)(
1943 payload,
1944 row_idx,
1945 drop_pos,
1946 my_model_id,
1947 ) {
1948 DropResponse::Reject => {
1949 if !foreign_ok {
1950 feedback_for_hover.set(None);
1951 return teksilo_core::DropFeedback::NoFeedback;
1952 }
1953 (DropPosition::Before, 0)
1954 }
1955 DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1956 DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1957 }
1958 } else {
1959 (DropPosition::Before, 0)
1961 };
1962 if effective == DropPosition::Into {
1963 let top = row_top - scroll;
1964 feedback_for_hover.set(Some(DropViz::Rect {
1965 top,
1966 height: row_h,
1967 width: viz_width,
1968 depth,
1969 }));
1970 teksilo_core::DropFeedback::HighlightRect {
1971 rect: Rect::new(0.0, top, viz_width, row_h),
1972 color: drop_into_tint(),
1973 }
1974 } else {
1975 let insertion_y = insertion_top - scroll;
1976 feedback_for_hover.set(Some(DropViz::Line {
1977 y: insertion_y,
1978 width: viz_width,
1979 depth,
1980 }));
1981 teksilo_core::DropFeedback::InsertionLine {
1982 y: insertion_y,
1983 width: viz_width,
1984 }
1985 }
1986 });
1987
1988 let drop_model_id = self.model_id;
1989 let source_for_drop = self.source.clone();
1990 let metrics_for_drop = self.row_metrics.clone();
1991 let scroll_for_drop = self.scroll_y.clone();
1992 let header_h_for_drop = header_h;
1993 let feedback_for_drop = self.drop_feedback.clone();
1994 let sort_for_drop = self.sort_signal.clone();
1995 let reorderable_drop = self.reorderable;
1996 let on_foreign_for_drop = self.on_foreign_drop.clone();
1997 let proxy_for_foreign_hook = self.proxy.clone();
1998 let export_for_drop = self.export.clone();
1999 handlers = handlers.on_drop(move |mut payload, position, ctx| {
2000 feedback_for_drop.set(None);
2001 if payload.has_typed::<ColumnReorderDragData>() {
2005 return false;
2006 }
2007 let count = source_for_drop.visible_count();
2008 if count == 0 {
2009 return false;
2010 }
2011 let scroll = scroll_for_drop.get().max(0.0);
2012 let content_y = position.y - header_h_for_drop + scroll;
2013 let (flat_idx, row_top, row_h, ins) = {
2014 let mut m = metrics_for_drop.borrow_mut();
2015 m.resize(count);
2016 let idx = m.row_at(content_y);
2017 let ins = m.insertion_index(content_y);
2018 (idx, m.row_top(idx), m.row_height(idx), ins)
2019 };
2020 let y_in_row = content_y - row_top;
2021 let third = (row_h / 3.0).max(f32::EPSILON);
2022 let drop_pos = if y_in_row < third {
2023 DropPosition::Before
2024 } else if y_in_row > 2.0 * third {
2025 DropPosition::After
2026 } else {
2027 DropPosition::Into
2028 };
2029 let is_same_view = payload
2030 .get_typed::<RowDragData<T>>()
2031 .is_some_and(|rd| rd.source == drop_model_id);
2032 if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
2033 return false;
2034 }
2035 if (reorderable_drop || !is_same_view)
2040 && (source_for_drop.dnd.accept_drop_fn)(
2041 &payload,
2042 flat_idx,
2043 drop_pos,
2044 drop_model_id,
2045 )
2046 {
2047 if is_same_view {
2048 export_for_drop.note_self_reorder();
2049 }
2050 return true;
2051 }
2052 if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
2055 return true;
2056 }
2057 if let Some(ref hook) = on_foreign_for_drop
2061 && let Some(ref p) = proxy_for_foreign_hook
2062 && let Some(node) = p.visible_node_id(flat_idx)
2063 {
2064 return hook(&payload, node, drop_pos, ctx);
2065 }
2066 false
2067 });
2068
2069 let feedback_for_leave = self.drop_feedback.clone();
2070 handlers = handlers.on_drag_leave(move |_ctx| {
2071 feedback_for_leave.set(None);
2072 });
2073
2074 let scroll_for_tick = self.scroll_y.clone();
2075 let max_scroll_for_tick = self.max_scroll_y.clone();
2076 let viewport_for_tick = self.viewport_height.clone();
2077 let header_h_for_tick = header_h;
2078 handlers = handlers.on_drag_tick(move |pos, _ctx| {
2079 const EDGE: f32 = 32.0;
2082 const MAX_VELOCITY: f32 = 12.0;
2083 let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2084 let y = pos.y - header_h_for_tick;
2085 let above = (EDGE - y).max(0.0);
2086 let below = (y - (body_h - EDGE)).max(0.0);
2087 let delta = if above > 0.0 {
2088 -(above / EDGE) * MAX_VELOCITY
2089 } else if below > 0.0 {
2090 (below / EDGE) * MAX_VELOCITY
2091 } else {
2092 0.0
2093 };
2094 if delta.abs() > 0.01 {
2095 let max = max_scroll_for_tick.get();
2096 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2097 scroll_for_tick.set(new_y);
2098 }
2099 });
2100 }
2101
2102 handlers = self.export.install_completion(handlers);
2112
2113 ctx.apply_self_handlers(handlers);
2114
2115 self.header_row_id = None;
2118 self.body_pane_id = None;
2119 self.scrollbar_id = None;
2120 self.h_scrollbar_id = None;
2121 self.empty_id = None;
2122
2123 if self.show_header {
2125 *self.resize_state.borrow_mut() = None;
2129 self.resize_target.set(None);
2130 self.resize_preview_x.set(None);
2131
2132 let boundaries = *self.pane_boundaries.borrow();
2133 let resize_columns: ColumnResizeTable = Rc::new(
2134 display_indices
2135 .iter()
2136 .map(|&i| {
2137 let c = &self.columns[i];
2138 ColumnResizeInfo {
2139 id: c.id.clone(),
2140 min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2141 max_width: c.max_width,
2142 resizable: c.resizable,
2143 }
2144 })
2145 .collect(),
2146 );
2147 let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2148 let active_sort = self.sort_signal.get();
2149 for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2150 let col = &self.columns[col_idx];
2151 let current_sort = active_sort
2152 .as_ref()
2153 .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2154 let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2155 let cell = HeaderCell::new(HeaderCellSpec {
2156 col_id: col.id.clone(),
2157 label: col.header_label.resolve_now(),
2158 col_index_1based: display_pos + 1,
2159 sortable: col.sortable,
2160 reorderable: col.reorderable,
2161 filterable: col.filterable,
2162 resize_grip: cp::RESIZE_HANDLE_WIDTH,
2163 filter_zone_width,
2164 current_sort,
2165 width_index: display_pos,
2166 pane_boundaries: boundaries,
2167 resize_columns: resize_columns.clone(),
2168 resize_policy: self.column_resize_policy,
2169 resize_state: self.resize_state.clone(),
2170 resize_target: self.resize_target.clone(),
2171 resize_preview_x: self.resize_preview_x.clone(),
2172 table_id: self.table_id,
2173 sort_signal: self.sort_signal.clone(),
2174 column_widths_signal: self.column_widths_signal.clone(),
2175 column_widths: self.column_widths.clone(),
2176 filters_signal: self.filters_signal.clone(),
2177 });
2178 cell_ids.push(ctx.add(cell));
2179 }
2180 let header_row = HeaderRow::new(
2181 cell_ids,
2182 self.column_widths.clone(),
2183 cp::GRID_LINE_THICKNESS,
2184 *self.pane_boundaries.borrow(),
2185 self.scroll_x.clone(),
2186 );
2187 let header_row_id = ctx.add(header_row);
2197 attach_header_reorder_handlers(
2198 ctx,
2199 header_row_id,
2200 self.table_id,
2201 self.column_widths.clone(),
2202 self.display_indices.clone(),
2203 self.pane_boundaries.clone(),
2204 self.column_order_signal.clone(),
2205 self.column_pinning_signal.clone(),
2206 self.columns.iter().map(|c| c.id.clone()).collect(),
2207 self.header_strip_width.clone(),
2208 self.scroll_x.clone(),
2209 );
2210 self.header_row_id = Some(header_row_id);
2211 }
2212
2213 let row_count = self.source.visible_count();
2218
2219 let (vis_start, vis_end) = self.visible_range();
2227 (self.source.dnd.request_window_fn)(vis_start..vis_end);
2228 if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2229 (self.source.dnd.fetch_more_fn)();
2230 }
2231
2232 if row_count > 0 {
2233 let pane = body_pane::TreeBodyPane::<T> {
2234 source: self.source.clone(),
2235 editing_anchor: self.editing_anchor.clone(),
2236 columns: self.columns.clone(),
2237 display_indices: self.display_indices.clone(),
2238 column_widths: self.column_widths.clone(),
2239 pane_boundaries: *self.pane_boundaries.borrow(),
2240 scroll_x: self.scroll_x.clone(),
2241 tree_display_pos,
2242 indent_per_level,
2243 row_metrics: self.row_metrics.clone(),
2244 selection_mode: self.selection_mode,
2245 selection: self.row_selection.clone(),
2246 cell_selection: self.cell_selection.clone(),
2247 scroll_y: self.scroll_y.clone(),
2248 viewport_height: self.viewport_height.clone(),
2249 editing_cell: self.editing_cell.clone(),
2250 focused_cell: self.focused_cell.clone(),
2251 reorderable: self.reorderable,
2252 model_id: self.model_id,
2253 export: self.export.clone(),
2254 drag_anchor: ctx.self_id(),
2255 on_row_activate: self.on_row_activate.clone(),
2256 activate_on: self.activate_on,
2257 edit_triggers: self.edit_triggers,
2258 on_cell_edit_request: self.on_cell_edit_request.clone(),
2259 on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2260 version: self.pane_version.clone(),
2261 prev_built_start: self.pane_built_start.clone(),
2262 prev_built_end: self.pane_built_end.clone(),
2263 total_refresh: self.pane_total_refresh.clone(),
2264 row_entries: Vec::new(),
2265 row_map: self.row_map.clone(),
2266 cell_map: self.cell_map.clone(),
2267 };
2268 self.body_pane_id = Some(ctx.add(pane));
2269 if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
2273 &self.on_cell_edit_dismissed,
2274 &self.editing_cell,
2275 &Rc::new(
2276 display_indices
2277 .iter()
2278 .map(|&i| self.columns[i].id.clone())
2279 .collect::<Vec<_>>(),
2280 ),
2281 ) {
2282 ctx.apply_self_handlers(handlers);
2283 }
2284 } else if let Some(ref f) = self.empty_view {
2285 self.empty_id = Some(ctx.add_boxed(f()));
2287 }
2288
2289 if self.show_internal_scrollbars {
2291 let sb = ScrollBar::new(
2292 ScrollBarOrientation::Vertical,
2293 self.scroll_y.clone(),
2294 self.max_scroll_y.clone(),
2295 self.viewport_ratio_y.clone(),
2296 )
2297 .visual(match self.scroll_bar_style {
2298 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2299 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2300 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2301 });
2302 self.scrollbar_id = Some(ctx.add(sb));
2303
2304 let hsb = ScrollBar::new(
2306 ScrollBarOrientation::Horizontal,
2307 self.scroll_x.clone(),
2308 self.max_scroll_x.clone(),
2309 self.viewport_ratio_x.clone(),
2310 )
2311 .visual(match self.scroll_bar_style {
2312 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2313 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2314 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2315 });
2316 self.h_scrollbar_id = Some(ctx.add(hsb));
2317 }
2318
2319 let mut children: Vec<WidgetId> = Vec::new();
2323 if let Some(id) = self.body_pane_id {
2324 children.push(id);
2325 }
2326 if let Some(id) = self.empty_id {
2327 children.push(id);
2328 }
2329 if let Some(id) = self.scrollbar_id {
2330 children.push(id);
2331 }
2332 if let Some(id) = self.h_scrollbar_id {
2333 children.push(id);
2334 }
2335 if let Some(id) = self.header_row_id {
2336 children.push(id);
2337 }
2338 let _ = (header_h, row_h);
2339 children
2340 }
2341
2342 fn layout_response(
2343 &self,
2344 proposal: SizeProposal,
2345 _ctx: &LayoutContext,
2346 ) -> teksilo_core::widget::LayoutResponse {
2347 let size = crate::common::viewport::viewport_size(
2351 proposal,
2352 &self.viewport_height,
2353 Size::new(400.0, 300.0),
2354 );
2355 if proposal.height.is_some() {
2356 self.laid_out.set(true);
2360 }
2361 size.into()
2362 }
2363
2364 fn place_children(
2365 &self,
2366 bounds: Rect,
2367 _proposal: SizeProposal,
2368 children: &mut [WidgetPlacement],
2369 ctx: &LayoutContext,
2370 ) {
2371 if children.is_empty() {
2372 return;
2373 }
2374 let rtl = ctx.is_rtl();
2375 let header_h = self.effective_header_height();
2376 let body_height_provisional = (bounds.height - header_h).max(0.0);
2377
2378 let total_height = self
2382 .row_metrics
2383 .borrow_mut()
2384 .total_height(self.source.visible_count());
2385 let needs_v_scrollbar =
2386 self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2387 let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2390 let body_width = if reserves_v_bar {
2391 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2392 } else {
2393 bounds.width
2394 };
2395 let band_left = if rtl && reserves_v_bar {
2399 bounds.x + SCROLLBAR_THICKNESS
2400 } else {
2401 bounds.x
2402 };
2403 let scrollbar_x = if rtl {
2404 bounds.x
2405 } else {
2406 bounds.x + bounds.width - SCROLLBAR_THICKNESS
2407 };
2408 self.header_strip_width.set(body_width);
2411
2412 let overrides = self.column_widths_signal.get();
2413 let display = self.display_indices.borrow().clone();
2414 let widths = layout::ColumnSolver::resolve_in_order(
2415 &self.columns,
2416 &display,
2417 body_width,
2418 cp::MIN_COLUMN_WIDTH_DEFAULT,
2419 &overrides,
2420 );
2421
2422 let boundaries = *self.pane_boundaries.borrow();
2424 let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2425 let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2426 let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2427 self.max_scroll_x.set(max_x);
2428 self.middle_viewport_width.set(middle_viewport_w);
2429 let x_ratio = if middle_content_w > 0.0 {
2430 (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2431 } else {
2432 1.0
2433 };
2434 self.viewport_ratio_x.set(x_ratio);
2435 {
2436 let current = self.scroll_x.get();
2437 let clamped = current.clamp(0.0, max_x);
2438 if (clamped - current).abs() > 0.001 {
2439 self.scroll_x.set(clamped);
2440 }
2441 }
2442
2443 *self.column_widths.borrow_mut() = widths;
2444
2445 let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2446 let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2447 let body_height = if reserves_h_bar {
2448 (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2449 } else {
2450 body_height_provisional
2451 };
2452
2453 let max_y = (total_height - body_height).max(0.0);
2454 self.max_scroll_y.set(max_y);
2455 let y_ratio = if total_height > 0.0 {
2456 (body_height / total_height).clamp(0.0, 1.0)
2457 } else {
2458 1.0
2459 };
2460 self.viewport_ratio_y.set(y_ratio);
2461 self.clamp_scroll();
2462
2463 let body_origin_y = bounds.y + header_h;
2464 self.body_bounds
2466 .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2467
2468 let mut next = 0;
2469
2470 if self.body_pane_id.is_some() {
2473 if let Some(child) = children.get_mut(next) {
2474 child.origin = Point::new(band_left, body_origin_y);
2475 child.size = Size::new(body_width, body_height);
2476 }
2477 next += 1;
2478 }
2479
2480 if self.empty_id.is_some() {
2482 if let Some(child) = children.get_mut(next) {
2483 child.origin = Point::new(band_left, body_origin_y);
2484 child.size = Size::new(body_width, body_height);
2485 }
2486 next += 1;
2487 }
2488
2489 if self.scrollbar_id.is_some() {
2491 if let Some(child) = children.get_mut(next) {
2492 if needs_v_scrollbar {
2493 child.origin = Point::new(scrollbar_x, body_origin_y);
2494 child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2495 } else {
2496 child.origin = bounds.origin();
2497 child.size = Size::ZERO;
2498 }
2499 }
2500 next += 1;
2501 }
2502
2503 if self.h_scrollbar_id.is_some() {
2505 if let Some(child) = children.get_mut(next) {
2506 if needs_h_scrollbar {
2507 let h_x = if rtl {
2508 band_left + trailing_w
2509 } else {
2510 band_left + leading_w
2511 };
2512 child.origin = Point::new(h_x, body_origin_y + body_height);
2513 child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2514 } else {
2515 child.origin = bounds.origin();
2516 child.size = Size::ZERO;
2517 }
2518 }
2519 next += 1;
2520 }
2521
2522 if self.header_row_id.is_some()
2525 && let Some(child) = children.get_mut(next)
2526 {
2527 child.origin = Point::new(band_left, bounds.y);
2528 child.size = Size::new(body_width, header_h);
2529 }
2530 }
2531
2532 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2533 let header_h = self.effective_header_height();
2534 let colors = &ctx.theme.colors;
2535 let scroll_y = self.scroll_y.get();
2536 let body_origin_y = bounds.y + header_h;
2537 let body_height = (bounds.height - header_h).max(0.0);
2538 let widths = self.column_widths.borrow();
2539 let body_width = widths.iter().sum::<f32>();
2540 let body_width_for_paint = if body_width > 0.0 {
2541 body_width.min(bounds.width)
2542 } else {
2543 bounds.width
2544 };
2545 let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2547 let content_left = if rtl {
2548 bounds.x + bounds.width - body_width_for_paint
2549 } else {
2550 bounds.x
2551 };
2552
2553 let row_count = self.source.visible_count();
2556 let (first_visible, last_visible) =
2557 self.row_metrics
2558 .borrow_mut()
2559 .visible_range(scroll_y, body_height, row_count, 0);
2560
2561 canvas.set_clip(Rect::new(
2567 content_left,
2568 body_origin_y,
2569 body_width_for_paint,
2570 body_height,
2571 ));
2572
2573 if self.alternating_rows {
2574 let mut m = self.row_metrics.borrow_mut();
2575 for row_idx in first_visible..last_visible {
2576 if row_idx % 2 == 1 {
2577 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2578 let h = m.row_height(row_idx);
2579 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2580 canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2581 }
2582 }
2583 }
2584
2585 if let Some(ref sel) = self.row_selection
2586 && matches!(
2587 self.selection_mode,
2588 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2589 )
2590 {
2591 let bg = if self.view_focused.get() && ctx.window_active {
2595 SurfaceRole::Selected.resolve(colors)
2596 } else {
2597 SurfaceRole::SelectedInactive.resolve(colors)
2598 };
2599 let mut m = self.row_metrics.borrow_mut();
2600 for row_idx in sel.selected_indices() {
2601 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2602 let h = m.row_height(row_idx);
2603 if y + h < body_origin_y || y > body_origin_y + body_height {
2604 continue;
2605 }
2606 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2607 canvas.fill_rect(rect, bg);
2608 }
2609 }
2610
2611 let line_color = BorderRole::Divider.resolve(colors);
2612 let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2613 if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2614 let mut m = self.row_metrics.borrow_mut();
2615 for row_idx in first_visible..last_visible {
2616 let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2617 let y = body_origin_y + bottom - scroll_y - line_w;
2618 let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2619 canvas.fill_rect(rect, line_color);
2620 }
2621 }
2622
2623 let boundaries = *self.pane_boundaries.borrow();
2626 let scroll_x = self.scroll_x.get();
2627 let content_bounds = Rect::new(
2628 content_left,
2629 body_origin_y,
2630 body_width_for_paint,
2631 body_height,
2632 );
2633 let (leading_rect, middle_rect, trailing_rect) =
2634 layout::band_rects(content_bounds, &widths, boundaries, rtl);
2635
2636 if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2637 let leading_end = boundaries.leading_count.min(widths.len());
2638 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2639 crate::table_view::draw_pane_dividers(
2640 canvas,
2641 leading_rect,
2642 &widths[..leading_end],
2643 0.0,
2644 rtl,
2645 line_color,
2646 line_w,
2647 );
2648 crate::table_view::draw_pane_dividers(
2649 canvas,
2650 middle_rect,
2651 &widths[leading_end..middle_end],
2652 scroll_x,
2653 rtl,
2654 line_color,
2655 line_w,
2656 );
2657 crate::table_view::draw_pane_dividers(
2658 canvas,
2659 trailing_rect,
2660 &widths[middle_end..],
2661 0.0,
2662 rtl,
2663 line_color,
2664 line_w,
2665 );
2666 }
2667
2668 if self.view_focused.get()
2671 && self.focus_visible.get()
2672 && let Some((focus_row, focus_col)) = self.focused_cell.get()
2673 && focus_col < widths.len()
2674 && let Some(x_off) = layout::column_logical_x(
2675 &widths,
2676 boundaries,
2677 scroll_x,
2678 body_width_for_paint,
2679 focus_col,
2680 )
2681 {
2682 let cell_w = widths[focus_col];
2683 let (focus_top, focus_h) = {
2684 let mut m = self.row_metrics.borrow_mut();
2685 (m.row_top(focus_row), m.row_height(focus_row))
2686 };
2687 let y = body_origin_y + focus_top - scroll_y;
2688 if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2689 let pane_rect = if focus_col < boundaries.leading_count {
2690 leading_rect
2691 } else if focus_col >= boundaries.middle_end {
2692 trailing_rect
2693 } else {
2694 middle_rect
2695 };
2696 canvas.set_clip(pane_rect);
2697 let inset = cp::FOCUS_RING_INSET;
2698 let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2699 let ring_color = BorderRole::Focused.resolve(colors);
2700 let rx = if rtl {
2701 content_left + body_width_for_paint - x_off - cell_w + inset
2702 } else {
2703 content_left + x_off + inset
2704 };
2705 let ry = y + inset;
2706 let rw = (cell_w - inset * 2.0).max(0.0);
2707 let rh = (focus_h - inset * 2.0).max(0.0);
2708 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2709 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2710 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2711 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2712 canvas.clear_clip();
2713 }
2714 }
2715
2716 let drop_indent_origin = |depth: usize| -> f32 {
2727 let step = self.effective_indent();
2728 let tree_decl = self.tree_column_decl_index();
2729 let tree_slot = self
2730 .display_indices
2731 .borrow()
2732 .iter()
2733 .position(|&i| i == tree_decl)
2734 .unwrap_or(0);
2735 let col_x = layout::column_logical_x(
2736 &widths,
2737 boundaries,
2738 scroll_x,
2739 body_width_for_paint,
2740 tree_slot,
2741 )
2742 .unwrap_or(0.0);
2743 (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
2744 };
2745 match self.drop_feedback.get() {
2746 Some(DropViz::Line { y, depth, .. }) => {
2747 let recipe = ctx
2748 .theme
2749 .style_slots
2750 .list_container
2751 .as_ref()
2752 .map(|s| s.insertion())
2753 .unwrap_or_default();
2754 let line_color = recipe.role.resolve(colors);
2755 let thickness = recipe.thickness;
2756 let line_y = body_origin_y + y - thickness * 0.5;
2757 let indent = drop_indent_origin(depth);
2758 let x = if rtl {
2761 content_left
2762 } else {
2763 content_left + indent
2764 };
2765 canvas.fill_rect(
2766 Rect::new(x, line_y, body_width_for_paint - indent, thickness),
2767 line_color,
2768 );
2769 }
2770 Some(DropViz::Rect {
2775 top, height, depth, ..
2776 }) => {
2777 let into = ctx
2778 .theme
2779 .style_slots
2780 .list_container
2781 .as_ref()
2782 .map(|s| s.drop_into())
2783 .unwrap_or_default();
2784 let color = into.role.resolve(colors);
2785 let indent = drop_indent_origin(depth);
2786 let x = if rtl {
2787 content_left
2788 } else {
2789 content_left + indent
2790 };
2791 let rect = Rect::new(
2792 x + into.inset,
2793 body_origin_y + top + into.inset,
2794 (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
2795 (height - into.inset * 2.0).max(0.0),
2796 );
2797 let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
2798 canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
2799 canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
2800 }
2801 None => {}
2802 }
2803
2804 canvas.clear_clip();
2805
2806 let nothing_indicated = self.focused_cell.get().is_none()
2810 && self
2811 .row_selection
2812 .as_ref()
2813 .is_none_or(|s| s.selected_indices().is_empty())
2814 && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2815 if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2816 let inset = 1.0_f32;
2817 let rect = Rect::new(
2818 bounds.x + inset,
2819 bounds.y + inset,
2820 (bounds.width - inset * 2.0).max(0.0),
2821 (bounds.height - inset * 2.0).max(0.0),
2822 );
2823 canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2824 }
2825
2826 if let Some(x) = self.resize_preview_x.get() {
2828 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2829 canvas.fill_rect(
2830 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2831 BorderRole::Focused.resolve(colors),
2832 );
2833 }
2834 }
2835
2836 fn context_menu_key_target(&self) -> Option<WidgetId> {
2850 let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2851 self.row_selection
2852 .as_ref()
2853 .and_then(|s| s.selected_indices().first().copied())
2854 })?;
2855 let map = self.row_map.borrow();
2856 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2857 }
2858
2859 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2860 builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
2861 if self
2874 .row_selection
2875 .as_ref()
2876 .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
2877 {
2878 builder.set_multiselectable(true);
2879 }
2880
2881 if let Some(ref label) = self.a11y_label {
2882 builder.set_name(label.resolve_now());
2883 }
2884 let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
2885 let col_count = self.columns.len();
2886 let n = builder.inner_mut();
2887 n.set_row_count(row_count);
2888 n.set_column_count(col_count);
2889
2890 if let Some((row, col)) = self.focused_cell.get()
2897 && let Some(cell_id) = self.realized_cell(row, col)
2898 {
2899 builder.set_active_descendant(widget_id_to_node_id(cell_id));
2900 }
2901 }
2902
2903 fn as_any(&self) -> Option<&dyn std::any::Any> {
2904 Some(self)
2905 }
2906
2907 fn children(&self) -> Vec<WidgetId> {
2908 let mut out: Vec<WidgetId> = Vec::new();
2911 if let Some(id) = self.body_pane_id {
2912 out.push(id);
2913 }
2914 if let Some(id) = self.empty_id {
2915 out.push(id);
2916 }
2917 if let Some(id) = self.scrollbar_id {
2918 out.push(id);
2919 }
2920 if let Some(id) = self.h_scrollbar_id {
2921 out.push(id);
2922 }
2923 if let Some(id) = self.header_row_id {
2924 out.push(id);
2925 }
2926 out
2927 }
2928
2929 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2930 let out: Vec<WidgetId> = [
2934 self.header_row_id,
2935 self.body_pane_id,
2936 self.empty_id,
2937 self.scrollbar_id,
2938 self.h_scrollbar_id,
2939 ]
2940 .into_iter()
2941 .flatten()
2942 .collect();
2943 if out.is_empty() { None } else { Some(out) }
2944 }
2945
2946 fn clips_children(&self) -> bool {
2947 true
2948 }
2949}
2950
2951#[cfg(test)]
2952mod tests {
2953 use super::*;
2954 use crate::table_view::column::{CellContext, ColumnWidth};
2955 use teksilo_canvas::SizeProposal;
2956 use teksilo_core::accesskit::Role;
2957 use teksilo_core::widget_tree::WidgetTree;
2958 use teksilo_data::{SortFilterTreeModel, TreeFilterMode, TreeModel};
2959 use teksilo_i18n::lit;
2960
2961 fn sample_tree() -> TreeModel<&'static str> {
2962 let t = TreeModel::new();
2963 let docs = t.insert_root(0, "docs");
2964 t.insert_child(docs, 0, "readme");
2965 t.insert_child(docs, 1, "guide");
2966 let src = t.insert_root(1, "src");
2967 t.insert_child(src, 0, "main.rs");
2968 t
2969 }
2970
2971 fn name_col() -> Column<&'static str> {
2972 Column::<&str>::new("name", lit!("Name"), |row, _: &CellContext| {
2973 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
2974 })
2975 .width(ColumnWidth::Flex(1.0))
2976 }
2977
2978 fn size_col() -> Column<&'static str> {
2979 Column::<&str>::new("size", lit!("Size"), |_row, _: &CellContext| {
2980 Box::new(crate::primitives::TextWidget::new(lit!("0")))
2981 })
2982 .width(ColumnWidth::Fixed(60.0))
2983 }
2984
2985 #[test]
2986 fn row_selection_click_repaints_immediately_without_expand_collapse() {
2987 use teksilo_canvas::Point;
2994 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2995 use teksilo_data::{SelectionMode, SelectionModel};
2996 let proxy = SortFilterTreeModel::new(sample_tree());
2997 let selection = SelectionModel::new(SelectionMode::Single);
2998 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2999 tree.add(
3000 TreeTableView::from_projection(proxy.clone())
3001 .add_column(name_col())
3002 .selection_mode(TableSelectionMode::SingleRow)
3003 .selection(selection.clone())
3004 .row_height(20.0),
3005 );
3006 tree.layout(SizeProposal {
3007 width: Some(400.0),
3008 height: Some(200.0),
3009 });
3010 assert_eq!(selection.selected_indices().len(), 0);
3012 let header_h = cp::HEADER_HEIGHT;
3015 let click_y = header_h + 10.0;
3016 tree.dispatch_event(WidgetEvent::PointerDown {
3017 position: Point::new(40.0, click_y),
3018 button: PointerButton::Primary,
3019 modifiers: Modifiers::NONE,
3020 });
3021 tree.dispatch_event(WidgetEvent::PointerUp {
3022 position: Point::new(40.0, click_y),
3023 button: PointerButton::Primary,
3024 modifiers: Modifiers::NONE,
3025 });
3026 assert_eq!(selection.selected_indices(), vec![0]);
3028 tree.layout(SizeProposal {
3035 width: Some(400.0),
3036 height: Some(200.0),
3037 });
3038 assert_eq!(selection.selected_indices(), vec![0]);
3039 }
3040
3041 #[test]
3042 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
3043 use teksilo_core::event::{Key, Modifiers};
3050 use teksilo_data::{SelectionMode, SelectionModel};
3051
3052 for (key, want, what) in [
3053 (
3054 Key::ArrowDown,
3055 0usize,
3056 "first ArrowDown enters at the first visible row",
3057 ),
3058 (
3059 Key::ArrowUp,
3060 3usize,
3061 "first ArrowUp enters at the last visible row",
3062 ),
3063 ] {
3064 let t = TreeModel::new();
3065 t.insert_root(0, "a");
3066 t.insert_root(1, "b");
3067 t.insert_root(2, "c");
3068 t.insert_root(3, "d");
3069 let proxy = SortFilterTreeModel::new(t);
3070 let selection = SelectionModel::new(SelectionMode::Single);
3071 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3072 let id = tree.add(
3073 TreeTableView::from_projection(proxy.clone())
3074 .add_column(name_col())
3075 .selection_mode(TableSelectionMode::SingleRow)
3076 .selection(selection.clone())
3077 .row_height(20.0),
3078 );
3079 tree.layout(SizeProposal {
3080 width: Some(400.0),
3081 height: Some(200.0),
3082 });
3083 tree.focus(id);
3084 assert_eq!(proxy.visible_count(), 4, "four flat roots");
3085 assert!(
3086 selection.selected_indices().is_empty(),
3087 "precondition: no cursor, nothing selected"
3088 );
3089
3090 tree.press_key(key, Modifiers::NONE);
3091 assert_eq!(selection.selected_indices(), vec![want], "{what}");
3092 }
3093 }
3094
3095 #[test]
3096 fn expanded_children_are_reachable_by_the_first_arrow() {
3097 use teksilo_core::event::{Key, Modifiers};
3103 use teksilo_data::{SelectionMode, SelectionModel};
3104
3105 let proxy = SortFilterTreeModel::new(sample_tree()); let selection = SelectionModel::new(SelectionMode::Single);
3107 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3108 let id = tree.add(
3109 TreeTableView::from_projection(proxy.clone())
3110 .add_column(name_col())
3111 .selection_mode(TableSelectionMode::SingleRow)
3112 .selection(selection.clone())
3113 .row_height(20.0),
3114 );
3115 tree.layout(SizeProposal {
3116 width: Some(400.0),
3117 height: Some(200.0),
3118 });
3119 tree.focus(id);
3120
3121 let last = proxy.visible_count() - 1;
3122 tree.press_key(Key::ArrowUp, Modifiers::NONE);
3123 assert_eq!(
3124 selection.selected_indices(),
3125 vec![last],
3126 "first ArrowUp enters at the last VISIBLE row, whatever the hierarchy shows"
3127 );
3128 }
3129
3130 #[test]
3131 fn row_click_moves_focus_so_arrow_nav_resumes_there() {
3132 use teksilo_canvas::Point;
3138 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
3139 use teksilo_data::{SelectionMode, SelectionModel};
3140 let t = TreeModel::new();
3141 t.insert_root(0, "a");
3142 t.insert_root(1, "b");
3143 t.insert_root(2, "c");
3144 t.insert_root(3, "d");
3145 let proxy = SortFilterTreeModel::new(t);
3146 let selection = SelectionModel::new(SelectionMode::Single);
3147 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3148 let id = tree.add(
3149 TreeTableView::from_projection(proxy.clone())
3150 .add_column(name_col())
3151 .selection_mode(TableSelectionMode::SingleRow)
3152 .selection(selection.clone())
3153 .row_height(20.0),
3154 );
3155 tree.layout(SizeProposal {
3156 width: Some(400.0),
3157 height: Some(200.0),
3158 });
3159 tree.focus(id);
3160 assert_eq!(proxy.visible_count(), 4, "four flat roots");
3161
3162 let click_y = cp::HEADER_HEIGHT + 1.0 * 20.0 + 10.0;
3164 tree.dispatch_event(WidgetEvent::PointerDown {
3165 position: Point::new(40.0, click_y),
3166 button: PointerButton::Primary,
3167 modifiers: Modifiers::NONE,
3168 });
3169 tree.dispatch_event(WidgetEvent::PointerUp {
3170 position: Point::new(40.0, click_y),
3171 button: PointerButton::Primary,
3172 modifiers: Modifiers::NONE,
3173 });
3174 assert_eq!(
3175 selection.selected_indices(),
3176 vec![1],
3177 "click selects flat row 1"
3178 );
3179
3180 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3182 assert_eq!(
3183 selection.selected_indices(),
3184 vec![2],
3185 "ArrowDown after a click resumes from the clicked row (1 → 2)"
3186 );
3187 }
3188
3189 #[test]
3190 fn role_is_treegrid() {
3191 let proxy = SortFilterTreeModel::new(sample_tree());
3192 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3193 let id = tree.add(
3194 TreeTableView::from_projection(proxy)
3195 .add_column(name_col())
3196 .add_column(size_col())
3197 .row_height(20.0),
3198 );
3199 tree.layout(SizeProposal {
3200 width: Some(400.0),
3201 height: Some(200.0),
3202 });
3203 let info = tree.accessibility_node(id);
3204 assert_eq!(info.role(), Role::TreeGrid);
3205 }
3206
3207 #[test]
3208 fn initial_state_shows_only_roots() {
3209 let proxy = SortFilterTreeModel::new(sample_tree());
3210 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3211 let _id = tree.add(
3212 TreeTableView::from_projection(proxy.clone())
3213 .add_column(name_col())
3214 .row_height(20.0),
3215 );
3216 tree.layout(SizeProposal {
3217 width: Some(400.0),
3218 height: Some(200.0),
3219 });
3220 assert_eq!(proxy.visible_count(), 2); }
3222
3223 #[test]
3224 fn expand_via_widget_reveals_children() {
3225 let proxy = SortFilterTreeModel::new(sample_tree());
3226 let docs = proxy.tree().root(0);
3227 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3228 let id = tree.add(
3229 TreeTableView::from_projection(proxy.clone())
3230 .add_column(name_col())
3231 .row_height(20.0),
3232 );
3233 tree.layout(SizeProposal {
3234 width: Some(400.0),
3235 height: Some(200.0),
3236 });
3237 {
3238 let any = tree.widget_as_any(id).unwrap();
3239 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3240 tt.expand(docs);
3241 }
3242 assert_eq!(proxy.visible_count(), 4); }
3244
3245 #[test]
3246 fn ctrl_home_keeps_the_column_in_a_treegrid() {
3247 use teksilo_core::event::{Key, Modifiers};
3248 let proxy = SortFilterTreeModel::new(sample_tree());
3253 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3254 let id = tree.add(
3255 TreeTableView::from_projection(proxy.clone())
3256 .add_column(name_col())
3257 .add_column(size_col())
3258 .row_height(20.0)
3259 .selection_mode(TableSelectionMode::MultiCell)
3260 .cell_selection(CellSelectionModel::new(TableSelectionMode::MultiCell)),
3261 );
3262 tree.layout(SizeProposal {
3263 width: Some(400.0),
3264 height: Some(200.0),
3265 });
3266 tree.focus(id);
3267 let read = |tree: &WidgetTree| {
3268 let any = tree.widget_as_any(id).unwrap();
3269 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3270 tt.focused_cell_signal().get()
3271 };
3272 {
3273 let any = tree.widget_as_any(id).unwrap();
3274 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3275 tt.set_focused_cell(1, 1);
3276 }
3277
3278 tree.press_key(Key::Home, Modifiers::COMMAND);
3279 assert_eq!(read(&tree), Some((0, 1)), "column 1 is kept");
3280 tree.press_key(Key::End, Modifiers::COMMAND);
3281 assert_eq!(read(&tree), Some((1, 1)), "and kept going the other way");
3282 }
3283
3284 #[test]
3285 fn a_row_answers_the_expand_action_it_advertises() {
3286 use teksilo_core::accesskit::Action;
3287 let proxy = SortFilterTreeModel::new(sample_tree());
3292 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3293 let id = tree.add(
3294 TreeTableView::from_projection(proxy.clone())
3295 .add_column(name_col())
3296 .row_height(20.0),
3297 );
3298 tree.layout(SizeProposal {
3299 width: Some(400.0),
3300 height: Some(200.0),
3301 });
3302 assert_eq!(proxy.visible_count(), 2, "roots only");
3303
3304 let row = {
3307 let mut found = None;
3308 let mut walker = vec![id];
3309 while let Some(w) = walker.pop() {
3310 let node = tree.accessibility_node(w);
3314 if node.role() == teksilo_core::accesskit::Role::Row
3315 && node.actions().contains(&Action::Expand)
3316 {
3317 found = Some(w);
3318 break;
3319 }
3320 for c in tree.children(w) {
3321 walker.push(c);
3322 }
3323 }
3324 found.expect("a body row advertising Action::Expand")
3325 };
3326
3327 let mut ops = teksilo_core::window::NoopWindowOps;
3328 let handled = tree.dispatch_access_action(
3329 teksilo_core::accessibility::widget_id_to_node_id(row),
3330 Action::Expand,
3331 None,
3332 &mut ops,
3333 );
3334 assert!(handled, "the advertised Expand action must be serviced");
3335 assert!(
3339 proxy.visible_count() > 2,
3340 "the row actually opened, rather than reporting success and doing nothing"
3341 );
3342 }
3343
3344 #[test]
3345 fn a_leaf_row_advertises_no_expand() {
3346 use teksilo_core::accesskit::Action;
3347 let proxy = SortFilterTreeModel::new(sample_tree());
3353 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3354 let id = tree.add(
3355 TreeTableView::from_projection(proxy.clone())
3356 .add_column(name_col())
3357 .row_height(20.0),
3358 );
3359 tree.layout(SizeProposal {
3360 width: Some(400.0),
3361 height: Some(200.0),
3362 });
3363 tree.focus(id);
3365 {
3366 let any = tree.widget_as_any(id).unwrap();
3367 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3368 tt.set_focused_cell(0, 0);
3369 }
3370 tree.press_key(
3371 teksilo_core::event::Key::ArrowRight,
3372 teksilo_core::event::Modifiers::NONE,
3373 );
3374 tree.layout(SizeProposal {
3375 width: Some(400.0),
3376 height: Some(200.0),
3377 });
3378 assert_eq!(proxy.visible_count(), 4);
3379
3380 let mut rows_with_expand = 0;
3383 let mut walker = vec![id];
3384 while let Some(w) = walker.pop() {
3385 let node = tree.accessibility_node(w);
3386 if node.role() == teksilo_core::accesskit::Role::Row
3387 && node.actions().contains(&Action::Expand)
3388 {
3389 rows_with_expand += 1;
3390 }
3391 for c in tree.children(w) {
3392 walker.push(c);
3393 }
3394 }
3395 assert_eq!(
3396 rows_with_expand, 2,
3397 "only docs and src have children; readme and guide must not \
3398 advertise an Expand they cannot perform"
3399 );
3400 }
3401
3402 #[test]
3403 fn a_tree_table_is_one_tab_stop_and_space_checks_the_focused_cell() {
3404 use crate::table_view::CellContext;
3405 use teksilo_core::event::{Key, Modifiers};
3406
3407 let checked = teksilo_core::signal::Signal::new(false);
3408 let ck = checked.clone();
3409 let proxy = SortFilterTreeModel::new(sample_tree());
3410 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3411 let id = tree.add(
3412 TreeTableView::from_projection(proxy)
3413 .add_column(name_col())
3414 .add_column(Column::<&str>::new(
3415 "done",
3416 lit!("Done"),
3417 move |_r, _: &CellContext| Box::new(crate::Checkbox::new(ck.clone())),
3418 ))
3419 .row_height(20.0),
3420 );
3421 let p = SizeProposal {
3422 width: Some(400.0),
3423 height: Some(200.0),
3424 };
3425 tree.layout(p);
3426 let stops = tree.tab_stops_within(id);
3427 assert_eq!(stops.len(), 1, "one Tab stop; got {}", stops.len());
3428
3429 tree.focus(id);
3430 {
3431 let any = tree.widget_as_any(id).unwrap();
3432 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3433 tt.set_focused_cell(0, 1); }
3435 tree.press_key(Key::Space, Modifiers::NONE);
3436 tree.layout(p);
3437 assert!(checked.get(), "Space reaches the focused cell's checkbox");
3438 }
3439
3440 #[test]
3441 fn left_on_a_leaf_ascends_to_the_parent() {
3442 use teksilo_core::event::{Key, Modifiers};
3443 let proxy = SortFilterTreeModel::new(sample_tree());
3444 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3445 let id = tree.add(
3446 TreeTableView::from_projection(proxy.clone())
3447 .add_column(name_col())
3448 .row_height(20.0),
3449 );
3450 tree.layout(SizeProposal {
3451 width: Some(400.0),
3452 height: Some(200.0),
3453 });
3454 tree.focus(id);
3455 let focus_at = |tree: &WidgetTree, row: usize| {
3456 let any = tree.widget_as_any(id).unwrap();
3457 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3458 tt.set_focused_cell(row, 0);
3459 };
3460 let read = |tree: &WidgetTree| {
3461 let any = tree.widget_as_any(id).unwrap();
3462 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3463 tt.focused_cell_signal().get()
3464 };
3465
3466 focus_at(&tree, 0);
3467 tree.press_key(Key::ArrowRight, Modifiers::NONE); assert_eq!(proxy.visible_count(), 4);
3469
3470 focus_at(&tree, 1);
3473 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
3474 assert_eq!(read(&tree), Some((0, 0)), "back on docs");
3475 assert_eq!(proxy.visible_count(), 4, "and nothing collapsed on the way");
3476
3477 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
3479 assert_eq!(proxy.visible_count(), 2);
3480 }
3481
3482 #[test]
3483 fn asterisk_expands_the_subtree_of_a_tree_table_row() {
3484 use teksilo_core::event::{Key, Modifiers};
3485 let proxy = SortFilterTreeModel::new(sample_tree());
3486 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3487 let id = tree.add(
3488 TreeTableView::from_projection(proxy.clone())
3489 .add_column(name_col())
3490 .row_height(20.0),
3491 );
3492 tree.layout(SizeProposal {
3493 width: Some(400.0),
3494 height: Some(200.0),
3495 });
3496 tree.focus(id);
3497 {
3498 let any = tree.widget_as_any(id).unwrap();
3499 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3500 tt.set_focused_cell(0, 0);
3501 }
3502 assert_eq!(proxy.visible_count(), 2, "roots only");
3503
3504 tree.press_key(Key::Character('*'), Modifiers::NONE);
3505 assert_eq!(proxy.visible_count(), 4, "docs and its two children");
3506
3507 tree.press_key(Key::Character('-'), Modifiers::NONE);
3509 assert_eq!(proxy.visible_count(), 2);
3510 }
3511
3512 #[test]
3513 fn a_shifted_arrow_does_not_expand_a_tree_table_row() {
3514 use teksilo_core::event::{Key, Modifiers};
3515 let proxy = SortFilterTreeModel::new(sample_tree());
3516 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3517 let id = tree.add(
3518 TreeTableView::from_projection(proxy.clone())
3519 .add_column(name_col())
3520 .row_height(20.0),
3521 );
3522 tree.layout(SizeProposal {
3523 width: Some(400.0),
3524 height: Some(200.0),
3525 });
3526 tree.focus(id);
3527 {
3528 let any = tree.widget_as_any(id).unwrap();
3529 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3530 tt.set_focused_cell(0, 0);
3531 }
3532 tree.press_key(Key::ArrowRight, Modifiers::SHIFT);
3533 assert_eq!(proxy.visible_count(), 2, "Shift belongs to the selection");
3534 }
3535
3536 #[test]
3537 fn arrow_right_expands_and_left_collapses_on_tree_column() {
3538 let proxy = SortFilterTreeModel::new(sample_tree());
3539 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3540 let id = tree.add(
3541 TreeTableView::from_projection(proxy.clone())
3542 .add_column(name_col())
3543 .row_height(20.0),
3544 );
3545 tree.layout(SizeProposal {
3546 width: Some(400.0),
3547 height: Some(200.0),
3548 });
3549 tree.focus(id);
3550 {
3551 let any = tree.widget_as_any(id).unwrap();
3552 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3553 tt.set_focused_cell(0, 0);
3554 }
3555 tree.press_key(
3558 teksilo_core::event::Key::ArrowRight,
3559 teksilo_core::event::Modifiers::NONE,
3560 );
3561 assert_eq!(proxy.visible_count(), 4);
3562 tree.press_key(
3564 teksilo_core::event::Key::ArrowLeft,
3565 teksilo_core::event::Modifiers::NONE,
3566 );
3567 assert_eq!(proxy.visible_count(), 2);
3568 }
3569
3570 fn slice_rows() -> Vec<teksilo_data::TreeRow<u64, &'static str>> {
3573 use teksilo_data::TreeRow;
3574 vec![
3575 TreeRow::new(1, "docs", 0),
3576 TreeRow::new(2, "readme", 1),
3577 TreeRow::new(3, "guide", 1),
3578 TreeRow::new(4, "src", 0),
3579 ]
3580 }
3581
3582 fn external_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
3583 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3584 slice.set_source(slice_rows);
3585 slice.reload();
3586 slice
3587 }
3588
3589 #[test]
3590 fn from_source_renders_an_external_tree_without_a_tree_model() {
3591 let slice = external_slice();
3595 slice.expand(&1);
3596 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3597 let id = tree.add(
3598 TreeTableView::from_source(slice.clone())
3599 .add_column(name_col())
3600 .row_height(20.0),
3601 );
3602 tree.layout(SizeProposal {
3603 width: Some(400.0),
3604 height: Some(200.0),
3605 });
3606 assert_eq!(slice.visible_count(), 4, "docs + 2 children + src");
3607
3608 let any = tree.widget_as_any(id).unwrap();
3609 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3610 assert!(
3611 tt.projection().is_none(),
3612 "a source-backed view has no TreeModel projection to expose"
3613 );
3614 assert!(tt.body_pane_id.is_some(), "rows rendered from the source");
3615 }
3616
3617 #[test]
3618 fn from_source_keyed_selection_survives_a_full_resource() {
3619 let slice = external_slice();
3623 slice.expand(&1);
3624 let keyed = KeyedSelectionModel::<u64>::new(teksilo_data::SelectionMode::Multi);
3625 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3626 let _id = tree.add(
3627 TreeTableView::from_source_keyed(slice.clone(), keyed.clone())
3628 .add_column(name_col())
3629 .row_height(20.0),
3630 );
3631 tree.layout(SizeProposal {
3632 width: Some(400.0),
3633 height: Some(200.0),
3634 });
3635
3636 keyed.select(3); assert!(keyed.is_selected(&3));
3638
3639 slice.reload();
3641 assert!(
3642 keyed.is_selected(&3),
3643 "a domain-keyed selection must survive a re-source"
3644 );
3645 }
3646
3647 #[test]
3648 fn from_source_supports_drag_reorder_like_the_tree_view() {
3649 use std::cell::RefCell;
3653 use std::rc::Rc;
3654 use teksilo_canvas::Point;
3655
3656 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3658 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3659 {
3660 let order = order.clone();
3661 slice.set_source(move || {
3662 let names: std::collections::HashMap<u64, &'static str> =
3663 [(1, "docs"), (4, "src")].into_iter().collect();
3664 order
3665 .borrow()
3666 .iter()
3667 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3668 .collect()
3669 });
3670 }
3671 {
3672 let order = order.clone();
3673 slice.set_reorder(move |dragged, target, _pos| {
3675 let mut o = order.borrow_mut();
3676 let Some(from) = o.iter().position(|k| *k == dragged) else {
3677 return false;
3678 };
3679 let item = o.remove(from);
3680 let to = o
3681 .iter()
3682 .position(|k| *k == target)
3683 .map_or(o.len(), |i| i + 1);
3684 o.insert(to, item);
3685 true
3686 });
3687 }
3688 slice.set_drag_policy(|_| teksilo_data::DragEligibility::CanDrag);
3691 slice.reload();
3692 assert_eq!(*order.borrow(), vec![1, 4], "docs, src");
3693
3694 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3695 tree.add(
3696 TreeTableView::from_source(slice.clone())
3697 .add_column(name_col())
3698 .reorderable(true)
3699 .row_height(20.0),
3700 );
3701 tree.layout(SizeProposal {
3702 width: Some(400.0),
3703 height: Some(300.0),
3704 });
3705
3706 let h = cp::HEADER_HEIGHT;
3708 drag(
3709 &mut tree,
3710 Point::new(40.0, h + 10.0),
3711 Point::new(40.0, h + 38.0),
3712 );
3713 assert_eq!(
3714 *order.borrow(),
3715 vec![4, 1],
3716 "the source applied the reorder: src now precedes docs"
3717 );
3718 }
3719
3720 #[test]
3721 fn a_source_that_forbids_dragging_a_row_is_honored() {
3722 use std::cell::RefCell;
3725 use std::rc::Rc;
3726 use teksilo_canvas::Point;
3727
3728 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3729 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3730 {
3731 let order = order.clone();
3732 slice.set_source(move || {
3733 let names: std::collections::HashMap<u64, &'static str> =
3734 [(1, "docs"), (4, "src")].into_iter().collect();
3735 order
3736 .borrow()
3737 .iter()
3738 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3739 .collect()
3740 });
3741 }
3742 {
3743 let order = order.clone();
3744 slice.set_reorder(move |dragged, target, _pos| {
3745 let mut o = order.borrow_mut();
3746 let Some(from) = o.iter().position(|k| *k == dragged) else {
3747 return false;
3748 };
3749 let item = o.remove(from);
3750 let to = o
3751 .iter()
3752 .position(|k| *k == target)
3753 .map_or(o.len(), |i| i + 1);
3754 o.insert(to, item);
3755 true
3756 });
3757 }
3758 slice.set_drag_policy(|k| {
3760 if *k == 1 {
3761 teksilo_data::DragEligibility::NoDrag
3762 } else {
3763 teksilo_data::DragEligibility::CanDrag
3764 }
3765 });
3766 slice.reload();
3767
3768 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3769 tree.add(
3770 TreeTableView::from_source(slice.clone())
3771 .add_column(name_col())
3772 .reorderable(true)
3773 .row_height(20.0),
3774 );
3775 tree.layout(SizeProposal {
3776 width: Some(400.0),
3777 height: Some(300.0),
3778 });
3779
3780 let h = cp::HEADER_HEIGHT;
3781 drag(
3782 &mut tree,
3783 Point::new(40.0, h + 10.0),
3784 Point::new(40.0, h + 38.0),
3785 );
3786 assert_eq!(
3787 *order.borrow(),
3788 vec![1, 4],
3789 "a NoDrag row must not move, even onto a valid target"
3790 );
3791 }
3792
3793 #[test]
3794 fn drop_on_the_middle_third_reparents_into_the_target() {
3795 use teksilo_canvas::Point;
3798 let proxy = SortFilterTreeModel::new(sample_tree());
3799 proxy.collapse_all(); let docs = proxy.tree().root(0);
3801 let src = proxy.tree().root(1);
3802 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3803 tree.add(
3804 TreeTableView::from_projection(proxy.clone())
3805 .add_column(name_col())
3806 .reorderable(true)
3807 .row_height(20.0),
3808 );
3809 tree.layout(SizeProposal {
3810 width: Some(400.0),
3811 height: Some(300.0),
3812 });
3813 let h = cp::HEADER_HEIGHT;
3814 drag(
3817 &mut tree,
3818 Point::new(40.0, h + 10.0),
3819 Point::new(40.0, h + 30.0),
3820 );
3821 assert_eq!(proxy.tree().root_count(), 1, "docs is no longer a root");
3822 assert_eq!(
3823 proxy.tree().parent(docs),
3824 Some(src),
3825 "docs became a child of src"
3826 );
3827 }
3828
3829 #[test]
3830 fn the_into_box_is_inset_and_the_insertion_line_is_indented() {
3831 use teksilo_canvas::{DrawCommand, Point, ShapeKind};
3836 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3837
3838 let proxy = SortFilterTreeModel::new(sample_tree());
3839 proxy.expand_all(); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3841 tree.add(
3842 TreeTableView::from_projection(proxy.clone())
3843 .add_column(name_col())
3844 .reorderable(true)
3845 .row_height(20.0),
3846 );
3847 tree.layout(SizeProposal {
3848 width: Some(400.0),
3849 height: Some(300.0),
3850 });
3851 let h = cp::HEADER_HEIGHT;
3852
3853 let start = Point::new(40.0, h + 90.0);
3856 tree.dispatch_event(WidgetEvent::PointerDown {
3857 position: start,
3858 button: PointerButton::Primary,
3859 modifiers: Modifiers::NONE,
3860 });
3861 tree.dispatch_event(WidgetEvent::PointerMove {
3862 position: Point::new(52.0, start.y),
3863 });
3864
3865 tree.dispatch_event(WidgetEvent::PointerMove {
3867 position: Point::new(52.0, h + 38.0),
3868 });
3869 let frame = tree.render();
3870 let line_recipe = teksilo_core::styles::ListInsertionRecipe::default();
3871 let lines: Vec<_> = frame
3875 .decorations
3876 .iter()
3877 .filter(|d| (d.rect[3] - line_recipe.thickness).abs() < 0.01 && d.rect[2] > 100.0)
3878 .collect();
3879 assert_eq!(lines.len(), 1, "exactly one insertion line, got {lines:?}");
3880 assert!(
3881 lines[0].rect[0] >= line_recipe.indent_step,
3882 "the After line must start one indent step in for a depth-1 target, \
3883 got x = {} (step {})",
3884 lines[0].rect[0],
3885 line_recipe.indent_step
3886 );
3887
3888 tree.dispatch_event(WidgetEvent::PointerMove {
3890 position: Point::new(52.0, h + 10.0),
3891 });
3892 let frame = tree.render();
3893 let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
3894 let boxes: Vec<_> = frame
3895 .draw_order
3896 .iter()
3897 .filter_map(|c| match c {
3898 DrawCommand::Shape(i) => frame.shapes.get(*i),
3899 _ => None,
3900 })
3901 .filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
3902 .filter(|s| (s.screen[3] - (20.0 - recipe.inset * 2.0)).abs() < 0.01)
3903 .collect();
3904 assert!(
3905 !boxes.is_empty(),
3906 "no inset rounded box for the Into hover; shapes = {:?}",
3907 frame.shapes.iter().map(|s| s.screen).collect::<Vec<_>>()
3908 );
3909 assert!(
3912 boxes
3913 .iter()
3914 .all(|s| (s.screen[1] - (h + recipe.inset)).abs() < 0.01),
3915 "the Into box must be inset from the row's top edge ({}), got {:?}",
3916 h,
3917 boxes.iter().map(|s| s.screen).collect::<Vec<_>>()
3918 );
3919 assert!(
3920 boxes.iter().any(|s| s.stroke_width > 0.0)
3921 && boxes.iter().any(|s| s.stroke_width == 0.0),
3922 "the Into box needs both a wash and an outline"
3923 );
3924 }
3925
3926 #[test]
3927 fn an_active_sort_suppresses_drag_reorder() {
3928 use teksilo_canvas::Point;
3932 let proxy = SortFilterTreeModel::new(sample_tree())
3933 .with_comparator("name", |a: &&'static str, b: &&'static str| a.cmp(b));
3934 proxy.collapse_all();
3935 let docs = proxy.tree().root(0);
3936 let src = proxy.tree().root(1);
3937 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3938 let id = tree.add(
3939 TreeTableView::from_projection(proxy.clone())
3940 .add_column(name_col())
3941 .reorderable(true)
3942 .row_height(20.0),
3943 );
3944 tree.layout(SizeProposal {
3945 width: Some(400.0),
3946 height: Some(300.0),
3947 });
3948 {
3949 let any = tree.widget_as_any(id).unwrap();
3950 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3951 tt.set_sort(Some("name"), SortDirection::Ascending);
3952 }
3953 tree.layout(SizeProposal {
3954 width: Some(400.0),
3955 height: Some(300.0),
3956 });
3957
3958 let h = cp::HEADER_HEIGHT;
3959 drag(
3960 &mut tree,
3961 Point::new(40.0, h + 10.0),
3962 Point::new(40.0, h + 38.0),
3963 );
3964 assert_eq!(
3965 proxy.tree().root(0),
3966 docs,
3967 "structure unchanged while sorted"
3968 );
3969 assert_eq!(
3970 proxy.tree().root(1),
3971 src,
3972 "structure unchanged while sorted"
3973 );
3974 }
3975
3976 #[test]
3977 fn an_open_cell_editor_follows_its_row_and_closes_if_the_row_vanishes() {
3978 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3982 let all: Vec<u64> = vec![1, 2, 3];
3983 slice.set_source(move || {
3984 let names: std::collections::HashMap<u64, &'static str> =
3985 [(1, "one"), (2, "two"), (3, "three")].into_iter().collect();
3986 all.iter()
3987 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3988 .collect()
3989 });
3990 slice.reload();
3991
3992 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3993 let id = tree.add(
3994 TreeTableView::from_source(slice.clone())
3995 .add_column(name_col())
3996 .row_height(20.0),
3997 );
3998 let proposal = SizeProposal {
3999 width: Some(400.0),
4000 height: Some(200.0),
4001 };
4002 tree.layout(proposal);
4003
4004 {
4006 let any = tree.widget_as_any(id).unwrap();
4007 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4008 tt.begin_edit(2, "name");
4009 assert_eq!(tt.editing_cell_signal().get(), Some((2, 0)));
4010 }
4011 tree.layout(proposal); let fewer: Vec<u64> = vec![2, 3];
4015 slice.set_source(move || {
4016 let names: std::collections::HashMap<u64, &'static str> =
4017 [(2, "two"), (3, "three")].into_iter().collect();
4018 fewer
4019 .iter()
4020 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
4021 .collect()
4022 });
4023 slice.reload();
4024 tree.layout(proposal);
4025 {
4026 let any = tree.widget_as_any(id).unwrap();
4027 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4028 assert_eq!(
4029 tt.editing_cell_signal().get(),
4030 Some((1, 0)),
4031 "the editor must follow its row to index 1, not stay on index 2"
4032 );
4033 }
4034
4035 let last: Vec<u64> = vec![2];
4037 slice.set_source(move || {
4038 last.iter()
4039 .map(|k| teksilo_data::TreeRow::new(*k, "two", 0))
4040 .collect()
4041 });
4042 slice.reload();
4043 tree.layout(proposal);
4044 let any = tree.widget_as_any(id).unwrap();
4045 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4046 assert_eq!(
4047 tt.editing_cell_signal().get(),
4048 None,
4049 "the editor must close when its row is gone"
4050 );
4051 }
4052
4053 #[test]
4054 fn default_selection_mode_is_multi_row() {
4055 use teksilo_core::event::{Key, Modifiers};
4059 let proxy = SortFilterTreeModel::new(wide_tree(10));
4060 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
4061 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4062 let id = tree.add(
4063 TreeTableView::from_projection(proxy)
4064 .add_column(name_col())
4065 .selection(selection.clone())
4066 .row_height(20.0),
4067 );
4068 tree.layout(SizeProposal {
4069 width: Some(400.0),
4070 height: Some(200.0),
4071 });
4072 tree.focus(id);
4073 {
4074 let any = tree.widget_as_any(id).unwrap();
4075 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4076 tt.set_focused_cell(0, 0);
4077 }
4078 selection.select(0);
4079 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
4080 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
4081 assert_eq!(
4082 selection.selection_signal().get().len(),
4083 3,
4084 "default mode must extend a multi-row selection"
4085 );
4086 }
4087
4088 #[test]
4089 fn ctrl_arrow_moves_cursor_without_touching_selection() {
4090 use teksilo_core::event::{Key, Modifiers};
4095 let proxy = SortFilterTreeModel::new(wide_tree(5));
4096 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
4097 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4098 let id = tree.add(
4099 TreeTableView::from_projection(proxy)
4100 .add_column(name_col())
4101 .selection(selection.clone())
4102 .row_height(20.0),
4103 );
4104 tree.layout(SizeProposal {
4105 width: Some(400.0),
4106 height: Some(200.0),
4107 });
4108 tree.focus(id);
4109 {
4110 let any = tree.widget_as_any(id).unwrap();
4111 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4112 tt.set_focused_cell(0, 0);
4113 }
4114 selection.select(0);
4115
4116 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
4117 {
4118 let any = tree.widget_as_any(id).unwrap();
4119 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4120 assert_eq!(
4121 tt.focused_cell_signal().get(),
4122 Some((1, 0)),
4123 "cursor advances"
4124 );
4125 }
4126 assert_eq!(
4127 selection.selected_indices(),
4128 vec![0],
4129 "Ctrl+Arrow must not touch selection"
4130 );
4131
4132 tree.press_key(Key::ArrowDown, Modifiers::NONE);
4134 {
4135 let any = tree.widget_as_any(id).unwrap();
4136 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4137 assert_eq!(tt.focused_cell_signal().get(), Some((2, 0)));
4138 }
4139 assert_eq!(
4140 selection.selected_indices(),
4141 vec![2],
4142 "plain Arrow selects the row it lands on"
4143 );
4144 }
4145
4146 #[test]
4147 fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
4148 use teksilo_core::event::{Key, Modifiers};
4149 let proxy = SortFilterTreeModel::new(wide_tree(5));
4150 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
4151 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4152 let id = tree.add(
4153 TreeTableView::from_projection(proxy)
4154 .add_column(name_col())
4155 .selection(selection.clone())
4156 .row_height(20.0),
4157 );
4158 tree.layout(SizeProposal {
4159 width: Some(400.0),
4160 height: Some(200.0),
4161 });
4162 tree.focus(id);
4163 {
4164 let any = tree.widget_as_any(id).unwrap();
4165 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4166 tt.set_focused_cell(0, 0);
4167 }
4168 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
4169 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
4170 assert!(selection.selected_indices().is_empty());
4171
4172 tree.press_key(Key::Space, Modifiers::CTRL);
4173 assert_eq!(
4174 selection.selected_indices(),
4175 vec![2],
4176 "Ctrl+Space toggles the focused row on"
4177 );
4178
4179 tree.press_key(Key::Space, Modifiers::CTRL);
4180 assert!(
4181 selection.selected_indices().is_empty(),
4182 "Ctrl+Space toggles it back off"
4183 );
4184 }
4185
4186 #[test]
4187 fn empty_view_renders_when_the_tree_has_no_rows() {
4188 let proxy = SortFilterTreeModel::new(TreeModel::<&'static str>::new());
4189 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4190 let id = tree.add(
4191 TreeTableView::from_projection(proxy)
4192 .add_column(name_col())
4193 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("Nothing here"))))
4194 .row_height(20.0),
4195 );
4196 tree.layout(SizeProposal {
4197 width: Some(400.0),
4198 height: Some(200.0),
4199 });
4200 let any = tree.widget_as_any(id).unwrap();
4201 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4202 assert!(tt.empty_id.is_some(), "placeholder should be built");
4203 assert!(tt.body_pane_id.is_none(), "no body pane for zero rows");
4204 }
4205
4206 #[test]
4207 fn empty_view_appears_when_live_rows_drop_to_zero() {
4208 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
4213 let needle = t.to_string();
4214 Box::new(move |r: &&'static str| r.contains(&needle))
4215 });
4216 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4217 let id = tree.add(
4218 TreeTableView::from_projection(proxy.clone())
4219 .add_column(name_col())
4220 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
4221 .row_height(20.0),
4222 );
4223 let proposal = SizeProposal {
4224 width: Some(400.0),
4225 height: Some(200.0),
4226 };
4227 tree.layout(proposal);
4228 {
4229 let any = tree.widget_as_any(id).unwrap();
4230 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4231 assert!(tt.body_pane_id.is_some(), "starts with a body pane");
4232 assert!(tt.empty_id.is_none(), "no placeholder while rows exist");
4233 }
4234
4235 proxy.set_filter("name", "zzz-no-such-row");
4236 tree.layout(proposal);
4237 assert_eq!(proxy.visible_count(), 0);
4238 let any = tree.widget_as_any(id).unwrap();
4239 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4240 assert!(
4241 tt.empty_id.is_some(),
4242 "placeholder must appear once rows drop to zero"
4243 );
4244 assert!(tt.body_pane_id.is_none(), "stale body pane must be gone");
4245 }
4246
4247 #[test]
4248 fn empty_view_renders_when_a_filter_matches_nothing() {
4249 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
4252 let needle = t.to_string();
4253 Box::new(move |r: &&'static str| r.contains(&needle))
4254 });
4255 proxy.set_filter("name", "zzz-no-such-row");
4256 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4257 let id = tree.add(
4258 TreeTableView::from_projection(proxy.clone())
4259 .add_column(name_col())
4260 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
4261 .row_height(20.0),
4262 );
4263 tree.layout(SizeProposal {
4264 width: Some(400.0),
4265 height: Some(200.0),
4266 });
4267 assert_eq!(proxy.visible_count(), 0);
4268 let any = tree.widget_as_any(id).unwrap();
4269 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4270 assert!(tt.empty_id.is_some());
4271 }
4272
4273 #[test]
4274 fn scroll_to_row_and_ensure_row_visible_move_the_offset() {
4275 let proxy = SortFilterTreeModel::new(wide_tree(100));
4276 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4277 let id = tree.add(
4278 TreeTableView::from_projection(proxy)
4279 .add_column(name_col())
4280 .row_height(20.0),
4281 );
4282 tree.layout(SizeProposal {
4283 width: Some(400.0),
4284 height: Some(200.0),
4285 });
4286 let any = tree.widget_as_any(id).unwrap();
4287 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4288
4289 tt.scroll_to_row(50);
4291 assert!((tt.scroll_y_signal().get() - 1000.0).abs() < 1.0);
4292
4293 let before = tt.scroll_y_signal().get();
4295 tt.ensure_row_visible(51);
4296 assert!((tt.scroll_y_signal().get() - before).abs() < f32::EPSILON);
4297
4298 tt.ensure_row_visible(10);
4300 assert!((tt.scroll_y_signal().get() - 200.0).abs() < 1.0);
4301 }
4302
4303 #[test]
4304 fn begin_edit_resolves_a_column_id_and_end_edit_clears() {
4305 let proxy = SortFilterTreeModel::new(sample_tree());
4306 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4307 let id = tree.add(
4308 TreeTableView::from_projection(proxy)
4309 .add_column(name_col())
4310 .add_column(size_col())
4311 .row_height(20.0),
4312 );
4313 tree.layout(SizeProposal {
4314 width: Some(400.0),
4315 height: Some(200.0),
4316 });
4317 let any = tree.widget_as_any(id).unwrap();
4318 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4319
4320 tt.begin_edit(1, "size");
4321 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4322 tt.end_edit();
4323 assert_eq!(tt.editing_cell_signal().get(), None);
4324
4325 tt.begin_edit(0, "no-such-column");
4327 assert_eq!(tt.editing_cell_signal().get(), None);
4328
4329 tt.begin_edit(9999, "name");
4333 assert_eq!(tt.editing_cell_signal().get(), None);
4334
4335 tt.begin_edit(1, "size");
4337 tt.begin_edit(9999, "size");
4338 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4339 }
4340
4341 #[test]
4342 fn begin_edit_resolves_before_the_view_is_mounted() {
4343 let proxy = SortFilterTreeModel::new(sample_tree());
4355 let view = TreeTableView::from_projection(proxy)
4356 .add_column(name_col())
4357 .add_column(size_col().pinned(PinnedSide::Leading))
4358 .row_height(20.0);
4359
4360 view.begin_edit(1, "name");
4361 assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
4362
4363 view.end_edit();
4365 view.begin_edit(0, "no-such-column");
4366 assert_eq!(view.editing_cell_signal().get(), None);
4367 view.begin_edit(9999, "name");
4368 assert_eq!(view.editing_cell_signal().get(), None);
4369
4370 view.begin_edit(1, "name");
4373 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4374 let id = tree.add(view);
4375 tree.layout(SizeProposal {
4376 width: Some(400.0),
4377 height: Some(200.0),
4378 });
4379 let tt = tree
4380 .widget_as_any(id)
4381 .unwrap()
4382 .downcast_ref::<TreeTableView<&'static str>>()
4383 .unwrap();
4384 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4385 }
4386
4387 #[test]
4388 fn column_imperatives_write_their_signals() {
4389 let proxy = SortFilterTreeModel::new(sample_tree());
4390 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4391 let id = tree.add(
4392 TreeTableView::from_projection(proxy)
4393 .add_column(name_col())
4394 .add_column(size_col())
4395 .row_height(20.0),
4396 );
4397 tree.layout(SizeProposal {
4398 width: Some(400.0),
4399 height: Some(200.0),
4400 });
4401 let any = tree.widget_as_any(id).unwrap();
4402 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4403
4404 tt.set_column_width("name", 123.0);
4405 assert_eq!(tt.column_widths_signal().get().get("name"), Some(&123.0));
4406 tt.set_column_width("name", 0.0);
4408 assert!(!tt.column_widths_signal().get().contains_key("name"));
4409
4410 assert_eq!(
4413 tt.display_order(),
4414 vec![0, 1],
4415 "declaration order initially"
4416 );
4417
4418 tt.set_column_order(vec!["size".into(), "name".into()]);
4419 assert_eq!(tt.column_order_signal().get(), vec!["size", "name"]);
4420 assert_eq!(
4421 tt.display_order(),
4422 vec![1, 0],
4423 "set_column_order must reorder the display, not only the signal"
4424 );
4425
4426 tt.set_column_pinning("name", PinnedSide::Leading);
4429 assert_eq!(
4430 tt.column_pinning_signal().get().get("name"),
4431 Some(&PinnedSide::Leading)
4432 );
4433 assert_eq!(
4434 tt.display_order(),
4435 vec![0, 1],
4436 "set_column_pinning must pull the pinned column back to the front"
4437 );
4438 tt.set_column_pinning("name", PinnedSide::None);
4439 assert!(!tt.column_pinning_signal().get().contains_key("name"));
4440 assert_eq!(
4441 tt.display_order(),
4442 vec![1, 0],
4443 "clearing the pin restores the order list's arrangement"
4444 );
4445
4446 tt.set_sort(Some("name"), SortDirection::Ascending);
4447 assert!(tt.sort_signal().get().is_some());
4448 tt.clear_sort();
4449 assert_eq!(tt.sort_signal().get(), None);
4450 }
4451
4452 #[test]
4463 fn column_pinning_remaps_focused_cell_to_follow_its_column() {
4464 let proxy = SortFilterTreeModel::new(sample_tree());
4465 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4466 let id = tree.add(
4467 TreeTableView::from_projection(proxy)
4468 .add_column(name_col())
4469 .add_column(size_col())
4470 .row_height(20.0),
4471 );
4472 tree.layout(SizeProposal {
4473 width: Some(400.0),
4474 height: Some(200.0),
4475 });
4476 tree.focus(id);
4477 {
4478 let any = tree.widget_as_any(id).unwrap();
4479 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4480 tt.set_focused_cell(0, 1); tt.set_column_pinning("size", PinnedSide::Leading);
4485 }
4486 tree.layout(SizeProposal {
4487 width: Some(400.0),
4488 height: Some(200.0),
4489 });
4490 let any = tree.widget_as_any(id).unwrap();
4491 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4492 assert_eq!(
4493 tt.focused_cell_signal().get(),
4494 Some((0, 0)),
4495 "focus must follow `size` to its new display position"
4496 );
4497 }
4498
4499 #[test]
4500 fn column_pinning_remaps_editing_cell_to_follow_its_column() {
4501 let proxy = SortFilterTreeModel::new(sample_tree());
4502 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4503 let id = tree.add(
4504 TreeTableView::from_projection(proxy)
4505 .add_column(name_col())
4506 .add_column(size_col())
4507 .row_height(20.0),
4508 );
4509 tree.layout(SizeProposal {
4510 width: Some(400.0),
4511 height: Some(200.0),
4512 });
4513 {
4514 let any = tree.widget_as_any(id).unwrap();
4515 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4516 tt.begin_edit(0, "size"); assert_eq!(tt.editing_cell_signal().get(), Some((0, 1)));
4518 tt.set_column_pinning("size", PinnedSide::Leading);
4519 }
4520 tree.layout(SizeProposal {
4521 width: Some(400.0),
4522 height: Some(200.0),
4523 });
4524 let any = tree.widget_as_any(id).unwrap();
4525 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4526 assert_eq!(
4527 tt.editing_cell_signal().get(),
4528 Some((0, 0)),
4529 "the open editor must follow `size` to its new display \
4530 position, not relabel onto whatever column now sits at \
4531 position 1"
4532 );
4533 }
4534
4535 #[test]
4536 fn column_pinning_remaps_cell_selection_to_follow_its_column() {
4537 let proxy = SortFilterTreeModel::new(sample_tree());
4538 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4539 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4540 let id = tree.add(
4541 TreeTableView::from_projection(proxy)
4542 .add_column(name_col())
4543 .add_column(size_col())
4544 .row_height(20.0)
4545 .selection_mode(TableSelectionMode::MultiCell)
4546 .cell_selection(cs.clone()),
4547 );
4548 tree.layout(SizeProposal {
4549 width: Some(400.0),
4550 height: Some(200.0),
4551 });
4552 cs.select(0, 1); {
4554 let any = tree.widget_as_any(id).unwrap();
4555 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4556 tt.set_column_pinning("size", PinnedSide::Leading);
4557 }
4558 tree.layout(SizeProposal {
4559 width: Some(400.0),
4560 height: Some(200.0),
4561 });
4562 assert!(
4563 cs.is_selected(0, 0),
4564 "selection must follow `size` to its new display position"
4565 );
4566 assert!(!cs.is_selected(0, 1));
4567 }
4568
4569 #[test]
4570 fn collapsing_a_node_above_a_selected_cell_clears_stale_cell_selection() {
4571 let proxy = SortFilterTreeModel::new(sample_tree());
4577 let docs = proxy.tree().root(0);
4578 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4579 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4580 let id = tree.add(
4581 TreeTableView::from_projection(proxy.clone())
4582 .add_column(name_col())
4583 .row_height(20.0)
4584 .selection_mode(TableSelectionMode::MultiCell)
4585 .cell_selection(cs.clone()),
4586 );
4587 tree.layout(SizeProposal {
4588 width: Some(400.0),
4589 height: Some(200.0),
4590 });
4591 {
4592 let any = tree.widget_as_any(id).unwrap();
4593 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4594 tt.expand(docs);
4595 }
4596 tree.layout(SizeProposal {
4597 width: Some(400.0),
4598 height: Some(200.0),
4599 });
4600 assert_eq!(proxy.visible_count(), 4); cs.select(3, 0); {
4603 let any = tree.widget_as_any(id).unwrap();
4604 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4605 tt.collapse(docs);
4606 }
4607 tree.layout(SizeProposal {
4608 width: Some(400.0),
4609 height: Some(200.0),
4610 });
4611 assert_eq!(proxy.visible_count(), 2); assert_eq!(
4613 cs.count(),
4614 0,
4615 "a stale (row, col) surviving the collapse must be dropped, not \
4616 silently point at whatever node now sits at flat row 3"
4617 );
4618 }
4619
4620 #[test]
4621 fn content_only_update_leaves_cell_selection_untouched() {
4622 let model = sample_tree();
4626 let proxy = SortFilterTreeModel::new(model);
4627 let docs = proxy.tree().root(0);
4628 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4629 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4630 tree.add(
4631 TreeTableView::from_projection(proxy.clone())
4632 .add_column(name_col())
4633 .row_height(20.0)
4634 .selection_mode(TableSelectionMode::MultiCell)
4635 .cell_selection(cs.clone()),
4636 );
4637 tree.layout(SizeProposal {
4638 width: Some(400.0),
4639 height: Some(200.0),
4640 });
4641 cs.select(0, 0); proxy.tree().update(docs, "docs-renamed");
4644 tree.layout(SizeProposal {
4645 width: Some(400.0),
4646 height: Some(200.0),
4647 });
4648 assert!(
4649 cs.is_selected(0, 0),
4650 "a content-only update must leave an unrelated selection alone"
4651 );
4652 }
4653
4654 #[test]
4657 fn focused_cell_sets_active_descendant_to_the_cell_node() {
4658 let proxy = SortFilterTreeModel::new(sample_tree());
4659 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4660 let id = tree.add(
4661 TreeTableView::from_projection(proxy)
4662 .add_column(name_col())
4663 .add_column(size_col())
4664 .row_height(20.0),
4665 );
4666 tree.layout(SizeProposal {
4667 width: Some(400.0),
4668 height: Some(200.0),
4669 });
4670 tree.focus(id);
4671 {
4672 let any = tree.widget_as_any(id).unwrap();
4673 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4674 tt.set_focused_cell(0, 1);
4675 }
4676 let update = tree.sync_accessibility();
4677 let root_node_id = widget_id_to_node_id(id);
4678 let root_node = update
4679 .nodes
4680 .iter()
4681 .find(|(nid, _)| *nid == root_node_id)
4682 .map(|(_, n)| n)
4683 .expect("root node present in the AT tree");
4684 let active = root_node
4685 .active_descendant()
4686 .expect("a focused cell must set active_descendant");
4687 let cell_node = update
4688 .nodes
4689 .iter()
4690 .find(|(nid, _)| *nid == active)
4691 .map(|(_, n)| n)
4692 .expect("active_descendant must reference a node present in the TreeUpdate");
4693 assert_eq!(cell_node.role(), Role::Cell);
4694 }
4695
4696 #[test]
4697 fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
4698 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4699 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4700 let id = tree.add(
4701 TreeTableView::from_projection(proxy)
4702 .add_column(name_col())
4703 .row_height(20.0),
4704 );
4705 tree.layout(SizeProposal {
4706 width: Some(400.0),
4707 height: Some(200.0),
4708 });
4709 tree.focus(id);
4710 {
4711 let any = tree.widget_as_any(id).unwrap();
4712 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4713 tt.set_focused_cell(1, 0);
4714 }
4715 let root_node_id = widget_id_to_node_id(id);
4716 let update = tree.sync_accessibility();
4717 let active_before = update
4718 .nodes
4719 .iter()
4720 .find(|(nid, _)| *nid == root_node_id)
4721 .and_then(|(_, n)| n.active_descendant());
4722 assert!(active_before.is_some(), "row 1 is realized initially");
4723
4724 let signal = {
4729 let any = tree.widget_as_any(id).unwrap();
4730 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4731 tt.scroll_y_signal().clone()
4732 };
4733 signal.set(2000.0);
4734 tree.request_frame();
4735 tree.layout(SizeProposal {
4736 width: Some(400.0),
4737 height: Some(200.0),
4738 });
4739
4740 let update = tree.sync_accessibility();
4741 let active_after = update
4742 .nodes
4743 .iter()
4744 .find(|(nid, _)| *nid == root_node_id)
4745 .and_then(|(_, n)| n.active_descendant());
4746 assert_eq!(
4747 active_after, None,
4748 "a focused cell that scrolled out of realization must not leave \
4749 a stale active_descendant pointing at a destroyed node"
4750 );
4751 }
4752
4753 #[test]
4766 fn taking_focus_reveals_the_cursor_row() {
4767 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4768 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4769 let id = tree.add(
4770 TreeTableView::from_projection(proxy)
4771 .add_column(name_col())
4772 .row_height(20.0),
4773 );
4774 let viewport = SizeProposal {
4775 width: Some(400.0),
4776 height: Some(200.0),
4777 };
4778 tree.layout(viewport);
4779
4780 {
4784 let any = tree.widget_as_any(id).unwrap();
4785 any.downcast_ref::<TreeTableView<&'static str>>()
4786 .unwrap()
4787 .set_focused_cell(500, 0);
4788 }
4789 tree.request_frame();
4790 tree.layout(viewport);
4791
4792 let root_node_id = widget_id_to_node_id(id);
4793 let nominated = |tree: &mut WidgetTree| -> Option<(Role, Option<usize>)> {
4797 let update = tree.sync_accessibility();
4798 let active = update
4799 .nodes
4800 .iter()
4801 .find(|(nid, _)| *nid == root_node_id)
4802 .and_then(|(_, n)| n.active_descendant())?;
4803 update
4804 .nodes
4805 .iter()
4806 .find(|(nid, _)| *nid == active)
4807 .map(|(_, n)| (n.role(), n.row_index()))
4808 };
4809
4810 assert_eq!(
4811 nominated(&mut tree),
4812 None,
4813 "row 500 starts far outside the realized window, which is the case \
4814 this is about"
4815 );
4816
4817 tree.focus(id);
4818 tree.layout(viewport);
4819
4820 assert_eq!(
4821 nominated(&mut tree),
4822 Some((Role::Cell, Some(501))),
4825 "taking focus has to bring the cursor's row into the realized \
4826 window, or nothing in the tree can be told about it"
4827 );
4828 }
4829
4830 #[test]
4839 fn taking_focus_reveals_the_selected_row_when_no_cell_has_been_navigated_to() {
4840 use teksilo_data::{SelectionMode, SelectionModel};
4841
4842 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4843 let sel = SelectionModel::new(SelectionMode::Single);
4844 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4845 let id = tree.add(
4846 TreeTableView::from_projection(proxy)
4847 .add_column(name_col())
4848 .row_height(20.0)
4849 .selection_mode(TableSelectionMode::SingleRow)
4850 .selection(sel.clone()),
4851 );
4852 let viewport = SizeProposal {
4853 width: Some(400.0),
4854 height: Some(200.0),
4855 };
4856 tree.layout(viewport);
4857
4858 sel.select(500);
4859 tree.request_frame();
4860 tree.layout(viewport);
4861
4862 let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
4863 tree.sync_accessibility()
4864 .nodes
4865 .iter()
4866 .filter(|(_, n)| n.role() == Role::Row && n.is_selected() == Some(true))
4867 .filter_map(|(_, n)| n.row_index())
4868 .collect()
4869 };
4870
4871 assert!(
4872 selected_rows(&mut tree).is_empty(),
4873 "row 500 starts far outside the realized window, which is the case \
4874 this is about"
4875 );
4876
4877 tree.focus(id);
4878 tree.layout(viewport);
4879
4880 assert_eq!(
4881 selected_rows(&mut tree),
4882 vec![501],
4883 "taking focus has to realize the selected row, or there is no node \
4884 carrying `selected` for a screen reader to find"
4885 );
4886 }
4887
4888 #[test]
4910 fn the_revealed_row_is_a_visible_index_not_a_position_in_the_unflattened_tree() {
4911 use teksilo_core::accessibility::node_id_to_widget_id;
4912
4913 let model = TreeModel::new();
4914 let mut to_collapse = Vec::new();
4915 let mut needle = None;
4916 for r in 0..40usize {
4917 let root = model.insert_root(r, "root");
4918 if r < 20 {
4919 to_collapse.push(root);
4920 }
4921 for c in 0..9usize {
4922 let label = if r == 30 && c == 4 { "needle" } else { "leaf" };
4923 let child = model.insert_child(root, c, label);
4924 if r == 30 && c == 4 {
4925 needle = Some(child);
4926 }
4927 }
4928 }
4929 let needle = needle.expect("the needle inserted");
4930
4931 let proxy = SortFilterTreeModel::new(model);
4932 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4933 let id = tree.add(
4934 TreeTableView::from_projection(proxy.clone())
4935 .add_column(name_col())
4936 .row_height(20.0),
4937 );
4938 let viewport = SizeProposal {
4939 width: Some(400.0),
4940 height: Some(200.0),
4941 };
4942 tree.layout(viewport);
4943 {
4944 let any = tree.widget_as_any(id).unwrap();
4945 any.downcast_ref::<TreeTableView<&'static str>>()
4946 .unwrap()
4947 .expand_all();
4948 }
4949 tree.layout(viewport);
4950
4951 let flat_of = |node| {
4952 (0..proxy.visible_count())
4953 .find(|&i| proxy.visible_node_id(i) == Some(node))
4954 .expect("the node is visible")
4955 };
4956 let flat_expanded = flat_of(needle);
4957
4958 {
4959 let any = tree.widget_as_any(id).unwrap();
4960 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4961 for root in to_collapse {
4962 tt.collapse(root);
4963 }
4964 }
4965 tree.layout(viewport);
4966
4967 let flat = flat_of(needle);
4968 assert_eq!(
4969 flat,
4970 flat_expanded - 180,
4971 "collapsing the first twenty roots has to move the needle up the \
4972 flat order without moving it in the tree, or this test proves \
4973 nothing"
4974 );
4975
4976 {
4977 let any = tree.widget_as_any(id).unwrap();
4978 any.downcast_ref::<TreeTableView<&'static str>>()
4979 .unwrap()
4980 .set_focused_cell(flat, 0);
4981 }
4982 tree.request_frame();
4983 tree.layout(viewport);
4984 tree.focus(id);
4985 tree.layout(viewport);
4986
4987 let root_node_id = widget_id_to_node_id(id);
4988 let update = tree.sync_accessibility();
4989 let active = update
4990 .nodes
4991 .iter()
4992 .find(|(nid, _)| *nid == root_node_id)
4993 .and_then(|(_, n)| n.active_descendant())
4994 .expect(
4995 "taking focus has to bring the cursor's row into the realized \
4996 window, or nothing in the tree can be told about it",
4997 );
4998 let cell = update
4999 .nodes
5000 .iter()
5001 .find(|(nid, _)| *nid == active)
5002 .map(|(_, n)| n)
5003 .expect("active_descendant must reference a node in the TreeUpdate");
5004 assert_eq!(cell.row_index(), Some(flat + 1));
5005
5006 let mut q = vec![node_id_to_widget_id(active)];
5009 let mut names = Vec::new();
5010 while let Some(w) = q.pop() {
5011 if let Some(name) = tree.accessibility_node(w).name() {
5012 names.push(name.to_string());
5013 }
5014 for c in tree.children(w) {
5015 q.push(c);
5016 }
5017 }
5018 assert!(
5019 names.iter().any(|n| n == "needle"),
5020 "the revealed row must be the node the cursor was put on, got {names:?}"
5021 );
5022 }
5023
5024 #[test]
5025 fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
5026 use std::cell::RefCell;
5033 use std::ops::Range;
5034 use teksilo_data::{FlatEntry, RowState};
5035
5036 struct Windowed {
5037 total: usize,
5038 requested: Rc<RefCell<Vec<Range<usize>>>>,
5039 version: Signal<u64>,
5040 }
5041 impl TreeDataSource for Windowed {
5042 type Item = &'static str;
5043 type Key = usize;
5044 fn visible_count(&self) -> usize {
5045 self.total
5046 }
5047 fn with_entry<R>(
5048 &self,
5049 _i: usize,
5050 _f: impl FnOnce(&&'static str, &FlatEntry<usize>) -> R,
5051 ) -> Option<R> {
5052 None }
5054 fn key_at(&self, i: usize) -> Option<usize> {
5055 (i < self.total).then_some(i)
5056 }
5057 fn flat_index_of(&self, key: &usize) -> Option<usize> {
5058 (*key < self.total).then_some(*key)
5059 }
5060 fn parent(&self, _key: &usize) -> Option<usize> {
5061 None
5062 }
5063 fn child_keys(&self, _key: &usize) -> Vec<usize> {
5064 vec![]
5065 }
5066 fn version_signal(&self) -> Signal<u64> {
5067 self.version.clone()
5068 }
5069 fn is_expanded(&self, _key: &usize) -> bool {
5070 false
5071 }
5072 fn set_expanded(&self, _key: &usize, _expanded: bool) {}
5073 fn row_state(&self, _flat_index: usize) -> RowState {
5074 RowState::Loading
5075 }
5076 fn request_window(&self, range: Range<usize>) {
5077 self.requested.borrow_mut().push(range);
5078 }
5079 }
5080
5081 let requested = Rc::new(RefCell::new(Vec::new()));
5082 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5083 let id = tree.add(
5084 TreeTableView::from_source(Windowed {
5085 total: 1000,
5086 requested: requested.clone(),
5087 version: Signal::new(0),
5088 })
5089 .add_column(name_col())
5090 .show_header(false)
5091 .row_height(30.0),
5092 );
5093 tree.layout(SizeProposal {
5094 width: Some(400.0),
5095 height: Some(300.0),
5096 });
5097
5098 let body_pane = tree.children(id)[0];
5102 let placeholder_rows = tree.children(body_pane).len();
5103 assert!(
5104 placeholder_rows >= 10,
5105 "loading rows must render as placeholders, got {placeholder_rows}"
5106 );
5107 assert!(
5109 !requested.borrow().is_empty(),
5110 "request_window must be called for the visible range"
5111 );
5112 }
5113
5114 #[test]
5115 fn arrow_expand_collapse_follows_a_non_leading_tree_column() {
5116 use teksilo_core::event::{Key, Modifiers};
5121 let proxy = SortFilterTreeModel::new(sample_tree());
5122 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5123 let id = tree.add(
5124 TreeTableView::from_projection(proxy.clone())
5125 .add_column(name_col())
5126 .add_column(size_col())
5127 .tree_column("size")
5128 .row_height(20.0),
5129 );
5130 tree.layout(SizeProposal {
5131 width: Some(400.0),
5132 height: Some(200.0),
5133 });
5134 tree.focus(id);
5135
5136 {
5139 let any = tree.widget_as_any(id).unwrap();
5140 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5141 tt.set_focused_cell(0, 0);
5142 }
5143 tree.press_key(Key::ArrowRight, Modifiers::NONE);
5144 assert_eq!(
5145 proxy.visible_count(),
5146 2,
5147 "ArrowRight off the tree column must not expand"
5148 );
5149
5150 {
5152 let any = tree.widget_as_any(id).unwrap();
5153 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5154 tt.set_focused_cell(0, 1);
5155 }
5156 tree.press_key(Key::ArrowRight, Modifiers::NONE);
5157 assert_eq!(
5158 proxy.visible_count(),
5159 4,
5160 "docs expands to reveal 2 children"
5161 );
5162 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5163 assert_eq!(proxy.visible_count(), 2, "docs collapses again");
5164 }
5165
5166 #[test]
5167 fn arrow_nav_scroll_follows_focused_row() {
5168 use teksilo_core::event::{Key, Modifiers};
5174 let proxy = SortFilterTreeModel::new(wide_tree(100));
5175 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5176 let id = tree.add(
5177 TreeTableView::from_projection(proxy)
5178 .add_column(name_col())
5179 .row_height(20.0),
5180 );
5181 let proposal = SizeProposal {
5182 width: Some(400.0),
5183 height: Some(200.0),
5184 };
5185 tree.layout(proposal);
5186 tree.focus(id);
5187 let read_scroll = |tree: &WidgetTree| {
5188 let any = tree.widget_as_any(id).unwrap();
5189 any.downcast_ref::<TreeTableView<&'static str>>()
5190 .unwrap()
5191 .scroll_y_signal()
5192 .get()
5193 };
5194 let read_focus = |tree: &WidgetTree| {
5195 let any = tree.widget_as_any(id).unwrap();
5196 any.downcast_ref::<TreeTableView<&'static str>>()
5197 .unwrap()
5198 .focused_cell_signal()
5199 .get()
5200 };
5201 {
5202 let any = tree.widget_as_any(id).unwrap();
5203 any.downcast_ref::<TreeTableView<&'static str>>()
5204 .unwrap()
5205 .set_focused_cell(0, 0);
5206 }
5207 assert_eq!(read_scroll(&tree), 0.0, "starts at top");
5208
5209 for _ in 0..20 {
5210 tree.press_key(Key::ArrowDown, Modifiers::NONE);
5211 tree.layout(proposal);
5212 }
5213 assert_eq!(read_focus(&tree), Some((20, 0)));
5214 assert!(
5215 read_scroll(&tree) > 200.0,
5216 "arrow-down nav must scroll to reveal row 20, got {}",
5217 read_scroll(&tree)
5218 );
5219
5220 tree.press_key(Key::Home, Modifiers::COMMAND);
5222 tree.layout(proposal);
5223 assert_eq!(read_focus(&tree), Some((0, 0)));
5224 assert_eq!(read_scroll(&tree), 0.0, "Ctrl+Home scrolls to top");
5225 }
5226
5227 #[test]
5228 fn type_ahead_jumps_to_matching_row() {
5229 use teksilo_core::event::{Key, Modifiers};
5230 let model = TreeModel::new();
5231 model.insert_root(0, "Apple");
5232 model.insert_root(1, "Banana");
5233 model.insert_root(2, "Cherry");
5234 model.insert_root(3, "Cranberry");
5235 let proxy = SortFilterTreeModel::new(model);
5236 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5237 let id = tree.add(
5238 TreeTableView::from_projection(proxy)
5239 .add_column(name_col())
5240 .row_height(20.0)
5241 .type_ahead_label(|s: &&'static str| s.to_string()),
5242 );
5243 tree.layout(SizeProposal {
5244 width: Some(400.0),
5245 height: Some(200.0),
5246 });
5247 tree.focus(id);
5248 let read_focus = |tree: &WidgetTree| {
5249 let any = tree.widget_as_any(id).unwrap();
5250 any.downcast_ref::<TreeTableView<&'static str>>()
5251 .unwrap()
5252 .focused_cell_signal()
5253 .get()
5254 };
5255 {
5256 let any = tree.widget_as_any(id).unwrap();
5257 any.downcast_ref::<TreeTableView<&'static str>>()
5258 .unwrap()
5259 .set_focused_cell(0, 0);
5260 }
5261 tree.press_key(Key::C, Modifiers::NONE);
5262 assert_eq!(read_focus(&tree), Some((2, 0)), "'c' → Cherry");
5263 tree.press_key(Key::R, Modifiers::NONE);
5264 assert_eq!(read_focus(&tree), Some((3, 0)), "'cr' → Cranberry");
5265 }
5266
5267 #[test]
5268 fn ctrl_tab_escapes_the_cell_grid() {
5269 use crate::primitives::{TextWidget, VStack};
5270 use teksilo_core::event::{Key, Modifiers};
5271 use teksilo_core::widget_builder::WidgetBuilder;
5272
5273 let proxy = SortFilterTreeModel::new(wide_tree(5));
5274 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5275 let id = tree.add(
5276 TreeTableView::from_projection(proxy)
5277 .add_column(name_col())
5278 .row_height(20.0),
5279 );
5280 let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
5281 let _root = tree.add(VStack::new().add_child(id).add_child(sink));
5282 tree.layout(SizeProposal {
5283 width: Some(400.0),
5284 height: Some(200.0),
5285 });
5286 let read_focus = |tree: &WidgetTree| {
5287 let any = tree.widget_as_any(id).unwrap();
5288 any.downcast_ref::<TreeTableView<&'static str>>()
5289 .unwrap()
5290 .focused_cell_signal()
5291 .get()
5292 };
5293 tree.focus(id);
5294 {
5295 let any = tree.widget_as_any(id).unwrap();
5296 any.downcast_ref::<TreeTableView<&'static str>>()
5297 .unwrap()
5298 .set_focused_cell(0, 0);
5299 }
5300 let before = read_focus(&tree);
5301 tree.press_key(Key::Tab, Modifiers::CTRL);
5302 assert_eq!(
5303 read_focus(&tree),
5304 before,
5305 "Ctrl+Tab must not navigate cells"
5306 );
5307 assert_eq!(
5308 tree.focused(),
5309 Some(sink),
5310 "Ctrl+Tab moves focus out of the tree-table"
5311 );
5312 }
5313
5314 #[test]
5315 fn rows_carry_role_row_with_level_indicator() {
5316 let proxy = SortFilterTreeModel::new(sample_tree());
5317 let docs = proxy.tree().root(0);
5318 proxy.expand(docs);
5319 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5320 let id = tree.add(
5321 TreeTableView::from_projection(proxy)
5322 .add_column(name_col())
5323 .row_height(20.0),
5324 );
5325 tree.layout(SizeProposal {
5326 width: Some(400.0),
5327 height: Some(200.0),
5328 });
5329 let mut q = vec![id];
5331 let mut row_count = 0;
5332 while let Some(n) = q.pop() {
5333 if tree.accessibility_node(n).role() == Role::Row {
5334 row_count += 1;
5335 }
5336 for c in tree.children(n) {
5337 q.push(c);
5338 }
5339 }
5340 assert!(
5342 row_count >= 5,
5343 "expected at least 5 Role::Row nodes, got {row_count}"
5344 );
5345 }
5346
5347 #[test]
5348 fn filter_mode_keep_ancestors_works_via_proxy() {
5349 let proxy = SortFilterTreeModel::new(sample_tree())
5350 .filter_mode(TreeFilterMode::KeepAncestors)
5351 .with_predicate("name", |t| {
5352 let needle = t.to_string();
5353 Box::new(move |row: &&str| row.contains(&needle))
5354 });
5355 proxy.expand_all();
5356 proxy.set_filter("name", "main");
5357 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5358 let _id = tree.add(
5359 TreeTableView::from_projection(proxy.clone())
5360 .add_column(name_col())
5361 .row_height(20.0),
5362 );
5363 tree.layout(SizeProposal {
5364 width: Some(400.0),
5365 height: Some(200.0),
5366 });
5367 assert_eq!(proxy.visible_count(), 2);
5369 }
5370
5371 #[test]
5372 fn collapse_all_then_expand_all_round_trips() {
5373 let proxy = SortFilterTreeModel::new(sample_tree());
5374 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5375 let id = tree.add(
5376 TreeTableView::from_projection(proxy.clone())
5377 .add_column(name_col())
5378 .row_height(20.0),
5379 );
5380 tree.layout(SizeProposal {
5381 width: Some(400.0),
5382 height: Some(200.0),
5383 });
5384 {
5385 let any = tree.widget_as_any(id).unwrap();
5386 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5387 tt.expand_all();
5388 }
5389 assert_eq!(proxy.visible_count(), 5);
5390 {
5391 let any = tree.widget_as_any(id).unwrap();
5392 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5393 tt.collapse_all();
5394 }
5395 assert_eq!(proxy.visible_count(), 2);
5396 }
5397
5398 #[test]
5399 fn rows_report_sibling_position_and_size_among_siblings() {
5400 let proxy = SortFilterTreeModel::new(sample_tree());
5408 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5409 let id = tree.add(
5410 TreeTableView::from_projection(proxy.clone())
5411 .add_column(name_col())
5412 .row_height(20.0),
5413 );
5414 tree.layout(SizeProposal {
5415 width: Some(400.0),
5416 height: Some(400.0),
5417 });
5418 {
5419 let any = tree.widget_as_any(id).unwrap();
5420 any.downcast_ref::<TreeTableView<&'static str>>()
5421 .unwrap()
5422 .expand_all();
5423 }
5424 tree.layout(SizeProposal {
5425 width: Some(400.0),
5426 height: Some(400.0),
5427 });
5428 assert_eq!(proxy.visible_count(), 5);
5429
5430 let mut rows: Vec<WidgetId> = Vec::new();
5435 let mut q = vec![id];
5436 while let Some(n) = q.pop() {
5437 if tree.accessibility_node(n).role() == Role::Row {
5438 rows.push(n);
5439 }
5440 for c in tree.children(n) {
5441 q.push(c);
5442 }
5443 }
5444 rows.sort_by(|a, b| tree.bounds(*a).y.partial_cmp(&tree.bounds(*b).y).unwrap());
5445 assert_eq!(rows.len(), 6, "header + five body rows");
5446 let body_rows = &rows[1..];
5447
5448 let update = tree.sync_accessibility();
5452 let find = |wid: WidgetId| -> &teksilo_core::accesskit::Node {
5453 let nid = widget_id_to_node_id(wid);
5454 update
5455 .nodes
5456 .iter()
5457 .find(|(n, _)| *n == nid)
5458 .map(|(_, n)| n)
5459 .expect("row must be in the a11y tree")
5460 };
5461 let positions: Vec<usize> = body_rows
5462 .iter()
5463 .map(|&r| find(r).position_in_set().expect("position_in_set"))
5464 .collect();
5465 assert_eq!(
5469 positions,
5470 vec![0, 0, 1, 1, 0],
5471 "docs(1st) readme(1st) guide(2nd) src(2nd) main.rs(1st)"
5472 );
5473 for &r in body_rows {
5478 assert_eq!(
5479 find(r).size_of_set(),
5480 None,
5481 "a per-sibling count is unrepresentable and must not be faked"
5482 );
5483 }
5484 }
5485
5486 #[test]
5487 fn row_count_in_a11y_includes_header() {
5488 let proxy = SortFilterTreeModel::new(sample_tree());
5489 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5490 let id = tree.add(
5491 TreeTableView::from_projection(proxy)
5492 .add_column(name_col())
5493 .add_column(size_col())
5494 .row_height(20.0),
5495 );
5496 tree.layout(SizeProposal {
5497 width: Some(400.0),
5498 height: Some(200.0),
5499 });
5500 let info = tree.accessibility_node(id);
5501 assert_eq!(info.role(), Role::TreeGrid);
5502 let mut q = vec![id];
5506 let mut rows = 0;
5507 while let Some(n) = q.pop() {
5508 if tree.accessibility_node(n).role() == Role::Row {
5509 rows += 1;
5510 }
5511 for c in tree.children(n) {
5512 q.push(c);
5513 }
5514 }
5515 assert_eq!(rows, 3); }
5517
5518 fn wide_tree(n: u32) -> TreeModel<&'static str> {
5522 let t = TreeModel::new();
5523 for i in 0..n {
5524 t.insert_root(i as usize, "node");
5525 }
5526 t
5527 }
5528
5529 fn row_bounds(tree: &WidgetTree, root: WidgetId) -> Vec<teksilo_canvas::Rect> {
5531 let mut q = vec![root];
5532 let mut out = Vec::new();
5533 while let Some(n) = q.pop() {
5534 if tree.accessibility_node(n).role() == Role::Row {
5535 out.push(tree.bounds(n));
5536 }
5537 for c in tree.children(n) {
5538 q.push(c);
5539 }
5540 }
5541 out
5542 }
5543
5544 #[test]
5545 fn rtl_swaps_tree_expand_collapse_keys() {
5546 use teksilo_core::environment::LayoutDirection;
5547 use teksilo_core::event::{Key, Modifiers};
5548
5549 let proxy = SortFilterTreeModel::new(sample_tree());
5550 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5551 let table = tree.add(
5552 TreeTableView::from_projection(proxy.clone())
5553 .add_column(name_col())
5554 .row_height(20.0),
5555 );
5556 tree.layout(SizeProposal {
5557 width: Some(400.0),
5558 height: Some(200.0),
5559 });
5560 assert_eq!(proxy.visible_count(), 2);
5562
5563 tree.set_layout_direction(LayoutDirection::RightToLeft);
5564 tree.focus(table);
5565 {
5566 let any = tree.widget_as_any(table).unwrap();
5567 any.downcast_ref::<TreeTableView<&'static str>>()
5568 .unwrap()
5569 .set_focused_cell(0, 0);
5570 }
5571
5572 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5575 assert_eq!(
5576 proxy.visible_count(),
5577 4,
5578 "RTL ArrowLeft on the tree column should expand docs"
5579 );
5580 tree.press_key(Key::ArrowRight, Modifiers::NONE);
5581 assert_eq!(
5582 proxy.visible_count(),
5583 2,
5584 "RTL ArrowRight on the tree column should collapse docs"
5585 );
5586 }
5587
5588 #[test]
5589 fn rtl_tree_band_shifts_for_left_scrollbar() {
5590 use teksilo_core::environment::LayoutDirection;
5591 let proxy = SortFilterTreeModel::new(wide_tree(50));
5595 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5596 let table = tree.add(
5597 TreeTableView::from_projection(proxy)
5598 .add_column(name_col())
5599 .row_height(20.0),
5600 );
5601 tree.layout(SizeProposal {
5602 width: Some(400.0),
5603 height: Some(200.0),
5604 });
5605 tree.set_layout_direction(LayoutDirection::RightToLeft);
5606 tree.layout(SizeProposal {
5607 width: Some(400.0),
5608 height: Some(200.0),
5609 });
5610
5611 let table_bounds = tree.bounds(table);
5612 let body_row = row_bounds(&tree, table)
5614 .into_iter()
5615 .filter(|r| r.y > table_bounds.y + 5.0)
5616 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5617 .expect("a body row");
5618 assert!(
5619 (body_row.x - SCROLLBAR_THICKNESS).abs() < 0.5,
5620 "RTL body row should start at SCROLLBAR_THICKNESS, got x={}",
5621 body_row.x
5622 );
5623 let mut tree2 = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5625 let proxy2 = SortFilterTreeModel::new(wide_tree(50));
5626 let table2 = tree2.add(
5627 TreeTableView::from_projection(proxy2)
5628 .add_column(name_col())
5629 .row_height(20.0),
5630 );
5631 tree2.layout(SizeProposal {
5632 width: Some(400.0),
5633 height: Some(200.0),
5634 });
5635 let tb2 = tree2.bounds(table2);
5636 let body_row2 = row_bounds(&tree2, table2)
5637 .into_iter()
5638 .filter(|r| r.y > tb2.y + 5.0)
5639 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5640 .expect("a body row");
5641 assert!(body_row2.x.abs() < 0.5, "LTR body row x={}", body_row2.x);
5642 }
5643
5644 fn nested_tree_table_fixture(
5650 inner: OverscrollBehavior,
5651 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
5652 use crate::ScrollArea;
5653 use crate::primitives::{FixedSize, TextWidget, VStack};
5654 let model = TreeModel::new();
5655 for i in 0..40 {
5656 model.insert_root(i, "row");
5657 }
5658 let proxy = SortFilterTreeModel::new(model);
5659 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5660 let tt = TreeTableView::from_projection(proxy)
5661 .add_column(name_col())
5662 .show_header(false)
5663 .row_height(20.0)
5664 .overscroll_behavior(inner);
5665 let inner_y = tt.scroll_y_signal().clone();
5666 let tt_id = tree.add(tt);
5667 let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tt_id));
5668 let filler = tree.add(
5669 FixedSize::new()
5670 .width(220.0)
5671 .height(300.0)
5672 .child(TextWidget::new(lit!(""))),
5673 );
5674 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
5675 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
5676 let outer_y = outer.scroll_y_signal().clone();
5677 let _outer = tree.add(outer);
5678 tree.layout(SizeProposal {
5679 width: Some(220.0),
5680 height: Some(150.0),
5681 });
5682 (tree, inner_y, outer_y)
5683 }
5684
5685 #[test]
5686 fn nested_tree_table_chains_to_outer_at_boundary() {
5687 use teksilo_canvas::Point;
5688 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5689 let (mut tree, inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Chain);
5690 tree.pointer_move(Point::new(50.0, 40.0));
5691 tree.dispatch_event(WidgetEvent::Scroll {
5692 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5693 modifiers: Modifiers::NONE,
5694 });
5695 tree.layout(SizeProposal {
5696 width: Some(220.0),
5697 height: Some(150.0),
5698 });
5699 let inner_bottom = inner_y.get();
5700 assert!(
5701 inner_bottom > 0.0,
5702 "inner tree-table should scroll down; got {inner_bottom}"
5703 );
5704 tree.pointer_move(Point::new(50.0, 40.0));
5706 tree.dispatch_event(WidgetEvent::Scroll {
5707 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5708 modifiers: Modifiers::NONE,
5709 });
5710 tree.layout(SizeProposal {
5711 width: Some(220.0),
5712 height: Some(150.0),
5713 });
5714 assert!(
5715 (inner_y.get() - inner_bottom).abs() < 0.01,
5716 "inner stays clamped at bottom"
5717 );
5718 assert!(
5719 outer_y.get() > 0.01,
5720 "outer must scroll because the inner chained the boundary"
5721 );
5722 }
5723
5724 #[test]
5725 fn nested_tree_table_contain_blocks_chaining() {
5726 use teksilo_canvas::Point;
5727 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5728 let (mut tree, _inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Contain);
5729 tree.pointer_move(Point::new(50.0, 40.0));
5730 tree.dispatch_event(WidgetEvent::Scroll {
5731 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5732 modifiers: Modifiers::NONE,
5733 });
5734 tree.layout(SizeProposal {
5735 width: Some(220.0),
5736 height: Some(150.0),
5737 });
5738 tree.pointer_move(Point::new(50.0, 40.0));
5739 tree.dispatch_event(WidgetEvent::Scroll {
5740 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5741 modifiers: Modifiers::NONE,
5742 });
5743 tree.layout(SizeProposal {
5744 width: Some(220.0),
5745 height: Some(150.0),
5746 });
5747 assert!(
5748 outer_y.get() < 0.01,
5749 "Contain must prevent chaining: outer stays put"
5750 );
5751 }
5752
5753 fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
5756 let mut walker = vec![root];
5757 let mut n = 0;
5758 while let Some(id) = walker.pop() {
5759 if tree.accessibility_node(id).role() == role {
5760 n += 1;
5761 }
5762 for c in tree.children(id) {
5763 walker.push(c);
5764 }
5765 }
5766 n
5767 }
5768
5769 fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
5772 let mut walker = vec![root];
5773 let mut spans = Vec::new();
5774 while let Some(id) = walker.pop() {
5775 if tree.accessibility_node(id).role() == Role::Row {
5776 let b = tree.bounds(id);
5777 spans.push((b.y, b.height));
5778 }
5779 for c in tree.children(id) {
5780 walker.push(c);
5781 }
5782 }
5783 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5784 spans
5785 }
5786
5787 #[test]
5788 fn rows_rebuild_during_scrollbar_thumb_drag() {
5789 let model = TreeModel::new();
5793 for i in 0..500 {
5794 model.insert_root(i, "root");
5795 }
5796 let proxy = SortFilterTreeModel::new(model);
5797 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5798 let table = tree.add(
5799 TreeTableView::from_projection(proxy.clone())
5800 .add_column(name_col())
5801 .row_height(20.0),
5802 );
5803 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
5804 &mut tree,
5805 table,
5806 400.0,
5807 200.0,
5808 cp::HEADER_HEIGHT,
5809 "TreeTableView",
5810 |t| {
5811 let mut n = 0;
5812 let mut walker = vec![table];
5813 while let Some(id) = walker.pop() {
5814 if t.accessibility_node(id).role() == Role::Row {
5815 let b = t.bounds(id);
5816 if b.y >= 0.0 && b.y < 200.0 {
5817 n += 1;
5818 }
5819 }
5820 for c in t.children(id) {
5821 walker.push(c);
5822 }
5823 }
5824 n
5825 },
5826 );
5827 }
5828
5829 #[test]
5830 fn exact_row_height_fn_positions_tree_rows() {
5831 let heights = [60.0_f32, 20.0, 40.0];
5832 let proxy = SortFilterTreeModel::new(sample_tree());
5833 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5834 let table = tree.add(
5835 TreeTableView::from_projection(proxy)
5836 .add_column(name_col())
5837 .show_header(false)
5838 .row_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
5839 );
5840 tree.layout(SizeProposal {
5841 width: Some(400.0),
5842 height: Some(300.0),
5843 });
5844
5845 let spans = row_spans(&tree, table);
5847 assert_eq!(spans.len(), 2);
5848 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
5849 assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
5850 }
5851
5852 #[test]
5853 fn auto_row_height_measures_tree_cells() {
5854 #[derive(Debug)]
5855 struct FixedLeaf(f32, f32);
5856 impl Widget for FixedLeaf {
5857 fn layout_response(
5858 &self,
5859 _proposal: SizeProposal,
5860 _ctx: &LayoutContext,
5861 ) -> teksilo_core::widget::LayoutResponse {
5862 Size::new(self.0, self.1).into()
5863 }
5864 }
5865 let col = Column::<&str>::new("name", lit!("Name"), |_row, _: &CellContext| {
5866 Box::new(FixedLeaf(50.0, 30.0))
5867 })
5868 .width(ColumnWidth::Flex(1.0));
5869 let proxy = SortFilterTreeModel::new(sample_tree());
5870 let docs = proxy.tree().root(0);
5871 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5872 let table = tree.add(
5873 TreeTableView::from_projection(proxy.clone())
5874 .add_column(col)
5875 .show_header(false)
5876 .auto_row_height(50.0),
5877 );
5878 tree.layout(SizeProposal {
5879 width: Some(400.0),
5880 height: Some(300.0),
5881 });
5882 tree.layout(SizeProposal {
5883 width: Some(400.0),
5884 height: Some(300.0),
5885 });
5886
5887 let spans = row_spans(&tree, table);
5889 assert!(
5890 (spans[1].0 - 30.0).abs() < 0.01,
5891 "row 1 should sit at measured 30, got {}",
5892 spans[1].0
5893 );
5894
5895 proxy.expand(docs);
5899 tree.layout(SizeProposal {
5900 width: Some(400.0),
5901 height: Some(300.0),
5902 });
5903 tree.layout(SizeProposal {
5904 width: Some(400.0),
5905 height: Some(300.0),
5906 });
5907 let spans = row_spans(&tree, table);
5908 assert_eq!(spans.len(), 4); assert!(
5910 (spans[1].0 - 30.0).abs() < 0.01,
5911 "measured row 0 must survive the expand, got {}",
5912 spans[1].0
5913 );
5914 }
5915
5916 fn drag(tree: &mut WidgetTree, from: teksilo_canvas::Point, to: teksilo_canvas::Point) {
5921 use teksilo_canvas::Point;
5922 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
5923 tree.dispatch_event(WidgetEvent::PointerDown {
5924 position: from,
5925 button: PointerButton::Primary,
5926 modifiers: Modifiers::NONE,
5927 });
5928 tree.dispatch_event(WidgetEvent::PointerMove {
5929 position: Point::new(from.x + 10.0, from.y),
5930 });
5931 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
5932 tree.dispatch_event(WidgetEvent::PointerUp {
5933 position: to,
5934 button: PointerButton::Primary,
5935 modifiers: Modifiers::NONE,
5936 });
5937 }
5938
5939 #[test]
5940 fn drag_reorders_roots_after() {
5941 use teksilo_canvas::Point;
5942 let proxy = SortFilterTreeModel::new(sample_tree());
5943 proxy.collapse_all(); let docs = proxy.tree().root(0);
5945 let src = proxy.tree().root(1);
5946 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5947 tree.add(
5948 TreeTableView::from_projection(proxy.clone())
5949 .add_column(name_col())
5950 .reorderable(true)
5951 .row_height(20.0),
5952 );
5953 tree.layout(SizeProposal {
5954 width: Some(400.0),
5955 height: Some(300.0),
5956 });
5957 let h = cp::HEADER_HEIGHT;
5958 drag(
5961 &mut tree,
5962 Point::new(40.0, h + 10.0),
5963 Point::new(40.0, h + 38.0),
5964 );
5965 assert_eq!(proxy.tree().root_count(), 2);
5966 assert_eq!(proxy.tree().root(0), src, "src becomes the first root");
5967 assert_eq!(proxy.tree().root(1), docs, "docs moves after src");
5968 }
5969
5970 #[test]
5971 fn drag_into_own_descendant_is_refused() {
5972 use teksilo_canvas::Point;
5973 let proxy = SortFilterTreeModel::new(sample_tree());
5974 proxy.expand_all(); let docs = proxy.tree().root(0);
5976 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5977 tree.add(
5978 TreeTableView::from_projection(proxy.clone())
5979 .add_column(name_col())
5980 .reorderable(true)
5981 .row_height(20.0),
5982 );
5983 tree.layout(SizeProposal {
5984 width: Some(400.0),
5985 height: Some(300.0),
5986 });
5987 let h = cp::HEADER_HEIGHT;
5988 drag(
5991 &mut tree,
5992 Point::new(40.0, h + 10.0),
5993 Point::new(40.0, h + 30.0),
5994 );
5995 assert_eq!(proxy.tree().parent(docs), None, "docs stays a root");
5996 assert_eq!(proxy.tree().root_count(), 2);
5997 }
5998
5999 #[test]
6000 fn reorder_is_suppressed_while_sorted() {
6001 use teksilo_canvas::Point;
6002 let proxy = SortFilterTreeModel::new(sample_tree());
6003 proxy.collapse_all();
6004 let docs = proxy.tree().root(0);
6005 let src = proxy.tree().root(1);
6006 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6007 let id = tree.add(
6008 TreeTableView::from_projection(proxy.clone())
6009 .add_column(name_col())
6010 .reorderable(true)
6011 .row_height(20.0),
6012 );
6013 tree.layout(SizeProposal {
6014 width: Some(400.0),
6015 height: Some(300.0),
6016 });
6017 tree.widget_as_any(id)
6020 .and_then(|a| a.downcast_ref::<TreeTableView<&str>>())
6021 .expect("TreeTableView")
6022 .set_sort(Some("name"), teksilo_data::SortDirection::Ascending);
6023 let h = cp::HEADER_HEIGHT;
6024 drag(
6025 &mut tree,
6026 Point::new(40.0, h + 10.0),
6027 Point::new(40.0, h + 38.0),
6028 );
6029 assert_eq!(proxy.tree().root(0), docs, "docs unchanged while sorted");
6030 assert_eq!(proxy.tree().root(1), src, "src unchanged while sorted");
6031 }
6032
6033 #[test]
6034 fn keyed_selection_survives_collapse() {
6035 use teksilo_data::{KeyedSelectionModel, SelectionMode};
6040 let proxy = SortFilterTreeModel::new(sample_tree());
6041 proxy.expand_all();
6042 let docs = proxy.tree().root(0);
6043 let readme = proxy.tree().children(docs)[0];
6044 let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
6045 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6046 tree.add(
6047 TreeTableView::from_projection(proxy.clone())
6048 .add_column(name_col())
6049 .selection_mode(TableSelectionMode::MultiRow)
6050 .keyed_selection(keyed.clone())
6051 .row_height(20.0),
6052 );
6053 tree.layout(SizeProposal {
6054 width: Some(400.0),
6055 height: Some(300.0),
6056 });
6057
6058 keyed.select(readme);
6059 assert!(keyed.is_selected(&readme));
6060
6061 proxy.collapse(docs);
6064 assert!(
6065 keyed.is_selected(&readme),
6066 "a collapsed-but-present node stays selected by identity"
6067 );
6068
6069 proxy.expand(docs);
6071 assert!(keyed.is_selected(&readme));
6072 }
6073
6074 fn tt_flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
6090 let mut out = Vec::new();
6091 for id in ids {
6092 if matches!(
6093 tree.accessibility_node(id).role(),
6094 Role::GenericContainer | Role::Unknown
6095 ) {
6096 out.extend(tt_flatten_through_bands(tree, tree.children(id)));
6097 } else {
6098 out.push(id);
6099 }
6100 }
6101 out
6102 }
6103
6104 fn tt_first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
6108 let mut walker = vec![root];
6109 while let Some(id) = walker.pop() {
6110 if tree.accessibility_node(id).role() == Role::Row {
6111 let flat = tt_flatten_through_bands(tree, tree.children(id));
6112 if flat
6113 .iter()
6114 .any(|&c| tree.accessibility_node(c).role() == Role::Cell)
6115 {
6116 return id;
6117 }
6118 }
6119 for c in tree.children(id) {
6120 walker.push(c);
6121 }
6122 }
6123 panic!("no body Role::Row found");
6124 }
6125
6126 fn tt_header_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
6127 let mut walker = vec![root];
6128 while let Some(id) = walker.pop() {
6129 if tree.accessibility_node(id).role() == Role::Row {
6130 let flat = tt_flatten_through_bands(tree, tree.children(id));
6131 if !flat.is_empty()
6132 && flat
6133 .iter()
6134 .all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
6135 {
6136 return id;
6137 }
6138 }
6139 for c in tree.children(id) {
6140 walker.push(c);
6141 }
6142 }
6143 panic!("no header Role::Row found");
6144 }
6145
6146 fn tt_body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
6147 tt_flatten_through_bands(tree, tree.children(tt_first_body_row_id(tree, root)))
6148 }
6149
6150 fn tt_header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
6151 tt_flatten_through_bands(tree, tree.children(tt_header_row_id(tree, root)))
6152 }
6153
6154 fn build_tt_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
6158 let proxy = SortFilterTreeModel::new(sample_tree());
6159 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6160 let id = tree.add(
6161 TreeTableView::from_projection(proxy)
6162 .add_column(
6163 Column::<&'static str>::new("lead", lit!("Lead"), |row, _: &CellContext| {
6164 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6165 })
6166 .width(ColumnWidth::Fixed(60.0))
6167 .pinned(PinnedSide::Leading),
6168 )
6169 .add_column(
6170 Column::<&'static str>::new("mid", lit!("Mid"), |row, _: &CellContext| {
6171 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6172 })
6173 .width(ColumnWidth::Fixed(middle_w)),
6174 )
6175 .add_column(
6176 Column::<&'static str>::new("trail", lit!("Trail"), |_row, _: &CellContext| {
6177 Box::new(crate::primitives::TextWidget::new(lit!("x")))
6178 })
6179 .width(ColumnWidth::Fixed(60.0))
6180 .pinned(PinnedSide::Trailing),
6181 )
6182 .row_height(20.0),
6183 );
6184 tree.layout(SizeProposal {
6185 width: Some(table_w),
6186 height: Some(200.0),
6187 });
6188 (tree, id)
6189 }
6190
6191 fn build_tt_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
6193 let proxy = SortFilterTreeModel::new(sample_tree());
6194 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6195 let mut tv = TreeTableView::from_projection(proxy);
6196 for i in 0..n {
6197 let col_id = format!("c{i}");
6198 tv = tv.add_column(
6199 Column::<&'static str>::new(
6200 col_id.clone(),
6201 lit!(col_id.clone()),
6202 |row, _: &CellContext| Box::new(crate::primitives::TextWidget::new(lit!(*row))),
6203 )
6204 .width(ColumnWidth::Fixed(col_w)),
6205 );
6206 }
6207 let id = tree.add(
6209 tv.row_height(20.0)
6210 .selection_mode(TableSelectionMode::MultiCell)
6211 .cell_selection(CellSelectionModel::new(TableSelectionMode::MultiCell)),
6212 );
6213 tree.layout(SizeProposal {
6214 width: Some(table_w),
6215 height: Some(200.0),
6216 });
6217 (tree, id)
6218 }
6219
6220 fn tt_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
6221 tree.widget_as_any(id)
6222 .unwrap()
6223 .downcast_ref::<TreeTableView<&'static str>>()
6224 .unwrap()
6225 .scroll_x_signal()
6226 .get()
6227 }
6228
6229 fn tt_max_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
6230 tree.widget_as_any(id)
6231 .unwrap()
6232 .downcast_ref::<TreeTableView<&'static str>>()
6233 .unwrap()
6234 .max_scroll_x_signal()
6235 .get()
6236 }
6237
6238 fn tt_set_scroll_x(tree: &WidgetTree, id: WidgetId, x: f32) {
6239 tree.widget_as_any(id)
6240 .unwrap()
6241 .downcast_ref::<TreeTableView<&'static str>>()
6242 .unwrap()
6243 .scroll_x_signal()
6244 .set(x);
6245 }
6246
6247 #[test]
6248 fn tt_scroll_x_clamps_after_the_pane_widens() {
6249 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 3, 300.0);
6250 let max = tt_max_scroll_x(&tree, id);
6251 assert!(max > 0.0, "columns must overflow the narrow table");
6252 tt_set_scroll_x(&tree, id, max);
6253 assert_eq!(tt_scroll_x(&tree, id), max);
6254
6255 tree.layout(SizeProposal {
6256 width: Some(700.0),
6257 height: Some(200.0),
6258 });
6259 assert_eq!(tt_max_scroll_x(&tree, id), 0.0, "content now fits");
6260 assert_eq!(
6261 tt_scroll_x(&tree, id),
6262 0.0,
6263 "scroll_x must clamp down with the new (smaller) max_scroll_x"
6264 );
6265 }
6266
6267 #[test]
6268 fn tt_pinned_columns_keep_their_bands_under_scroll() {
6269 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
6270
6271 let cells0 = tt_body_row_cells(&tree, id);
6272 assert_eq!(cells0.len(), 3, "lead, mid, trail");
6273 let lead_x0 = tree.bounds(cells0[0]).x;
6274 let mid_x0 = tree.bounds(cells0[1]).x;
6275 let trail_x0 = tree.bounds(cells0[2]).x;
6276
6277 let tree_row_a11y = tt_first_body_row_id(&tree, id);
6282 let body_row = tree.children(tree_row_a11y)[0];
6283 let raw_bands = tree.children(body_row);
6284 assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
6285 assert!(!tree.widget_clips_children(raw_bands[0]));
6286 assert!(
6287 tree.widget_clips_children(raw_bands[1]),
6288 "the Middle band must clip"
6289 );
6290 assert!(!tree.widget_clips_children(raw_bands[2]));
6291
6292 let max = tt_max_scroll_x(&tree, id);
6293 assert!(max > 0.0);
6294 tt_set_scroll_x(&tree, id, 50.0_f32.min(max));
6295 tree.layout(SizeProposal {
6296 width: Some(200.0),
6297 height: Some(200.0),
6298 });
6299
6300 let cells1 = tt_body_row_cells(&tree, id);
6301 assert_eq!(tree.bounds(cells1[0]).x, lead_x0, "Leading never moves");
6302 assert_eq!(tree.bounds(cells1[2]).x, trail_x0, "Trailing never moves");
6303 let mid_x1 = tree.bounds(cells1[1]).x;
6304 assert!(
6305 (mid_x1 - (mid_x0 - 50.0)).abs() < 0.5,
6306 "the Middle column shifts left by exactly scroll_x: got {mid_x1}, want ~{}",
6307 mid_x0 - 50.0
6308 );
6309 }
6310
6311 #[test]
6312 fn tt_header_and_body_x_offsets_agree_under_scroll() {
6313 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
6314 tt_set_scroll_x(&tree, id, 37.0);
6315 tree.layout(SizeProposal {
6316 width: Some(200.0),
6317 height: Some(200.0),
6318 });
6319
6320 let header_cells = tt_header_row_cells(&tree, id);
6321 let body_cells = tt_body_row_cells(&tree, id);
6322 assert_eq!(header_cells.len(), body_cells.len());
6323 for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
6324 let hx = tree.bounds(h).x;
6325 let bx = tree.bounds(b).x;
6326 assert!(
6327 (hx - bx).abs() < 0.01,
6328 "column {i}: header x {hx} must equal body x {bx}"
6329 );
6330 }
6331 }
6332
6333 #[test]
6334 fn tt_shift_wheel_scrolls_horizontally() {
6335 use teksilo_canvas::Point;
6336 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
6337 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 4, 300.0);
6338 tree.pointer_move(Point::new(50.0, 60.0));
6339 tree.dispatch_event(WidgetEvent::Scroll {
6340 delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
6341 modifiers: Modifiers::SHIFT,
6342 });
6343 tree.layout(SizeProposal {
6344 width: Some(300.0),
6345 height: Some(200.0),
6346 });
6347 assert!(
6348 tt_scroll_x(&tree, id) > 0.0,
6349 "Shift+wheel must remap a vertical-only wheel to horizontal scroll"
6350 );
6351 let any = tree.widget_as_any(id).unwrap();
6352 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6353 assert_eq!(
6354 tt.scroll_y_signal().get(),
6355 0.0,
6356 "Shift+wheel must not also scroll vertically"
6357 );
6358 }
6359
6360 #[test]
6361 fn tt_ensure_col_visible_follows_focus_in_both_directions() {
6362 use teksilo_core::event::{Key, Modifiers};
6363 let (mut tree, id) = build_tt_wide_unpinned_table(150.0, 5, 300.0);
6364 tree.focus(id);
6365 {
6366 let any = tree.widget_as_any(id).unwrap();
6367 any.downcast_ref::<TreeTableView<&'static str>>()
6368 .unwrap()
6369 .set_focused_cell(0, 0);
6370 }
6371 assert_eq!(tt_scroll_x(&tree, id), 0.0);
6372
6373 tree.press_key(Key::End, Modifiers::NONE);
6374 {
6375 let any = tree.widget_as_any(id).unwrap();
6376 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6377 assert_eq!(tt.focused_cell_signal().get(), Some((0, 4)));
6378 }
6379 assert!(
6380 tt_scroll_x(&tree, id) > 0.0,
6381 "ensure-column-visible must scroll right to reveal column 4"
6382 );
6383
6384 tree.press_key(Key::Home, Modifiers::NONE);
6385 {
6386 let any = tree.widget_as_any(id).unwrap();
6387 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6388 assert_eq!(tt.focused_cell_signal().get(), Some((0, 0)));
6389 }
6390 assert_eq!(
6391 tt_scroll_x(&tree, id),
6392 0.0,
6393 "ensure-column-visible must scroll left back to 0 for column 0"
6394 );
6395 }
6396
6397 fn reorder_col(id: &'static str, width: f32) -> Column<&'static str> {
6419 Column::<&'static str>::new(id, lit!(id), |row, _: &CellContext| {
6420 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6421 })
6422 .width(ColumnWidth::Fixed(width))
6423 }
6424
6425 fn build_tt_reorder_table() -> (WidgetTree, WidgetId, SortFilterTreeModel<&'static str>) {
6429 let proxy = SortFilterTreeModel::new(sample_tree());
6430 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6431 let id = tree.add(
6432 TreeTableView::from_projection(proxy.clone())
6433 .add_column(reorder_col("a", 60.0))
6434 .add_column(reorder_col("b", 70.0))
6435 .add_column(reorder_col("c", 80.0))
6436 .add_column(reorder_col("d", 90.0))
6437 .row_height(20.0),
6438 );
6439 tree.layout(SizeProposal {
6440 width: Some(400.0),
6441 height: Some(200.0),
6442 });
6443 (tree, id, proxy)
6444 }
6445
6446 fn tt_subtree_has_twist_arrow(tree: &WidgetTree, id: WidgetId) -> bool {
6453 if tree.widget_type_name(id) == Some("teksilo_widgets::primitives::twist_arrow::TwistArrow")
6454 {
6455 return true;
6456 }
6457 tree.children(id)
6458 .into_iter()
6459 .any(|c| tt_subtree_has_twist_arrow(tree, c))
6460 }
6461
6462 #[test]
6463 fn header_drag_reorders_column_before_an_earlier_sibling() {
6464 let (mut tree, id, _proxy) = build_tt_reorder_table();
6467 let header = tt_header_row_cells(&tree, id);
6468 assert_eq!(header.len(), 4);
6469 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
6472
6473 {
6474 let any = tree.widget_as_any(id).unwrap();
6475 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6476 assert_eq!(
6477 tt.column_order_signal().get(),
6478 vec![
6479 "a".to_string(),
6480 "d".to_string(),
6481 "b".to_string(),
6482 "c".to_string()
6483 ],
6484 "dropping \"d\" before \"b\" must write [a, d, b, c]"
6485 );
6486 assert_eq!(
6487 tt.column_pinning_signal().get().get("d"),
6488 None,
6489 "a mid-band drop must not pin the moved column"
6490 );
6491 }
6492
6493 tree.layout(SizeProposal {
6497 width: Some(400.0),
6498 height: Some(200.0),
6499 });
6500 let after = tt_header_row_cells(&tree, id);
6501 let widths: Vec<f32> = after.iter().map(|&c| tree.bounds(c).width).collect();
6502 assert!(
6503 widths
6504 .iter()
6505 .zip([60.0, 90.0, 70.0, 80.0])
6506 .all(|(&w, want)| (w - want).abs() < 0.5),
6507 "header cells must reflow to widths [60, 90, 70, 80], got {widths:?}"
6508 );
6509 }
6510
6511 #[test]
6512 fn header_drag_to_the_leading_edge_pins_the_dropped_column() {
6513 let (mut tree, id, _proxy) = build_tt_reorder_table();
6518 let header = tt_header_row_cells(&tree, id);
6519 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(5.0, from.y); drag(&mut tree, from, to);
6522
6523 let any = tree.widget_as_any(id).unwrap();
6524 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6525 assert_eq!(
6526 tt.column_order_signal().get(),
6527 vec![
6528 "d".to_string(),
6529 "a".to_string(),
6530 "b".to_string(),
6531 "c".to_string()
6532 ],
6533 );
6534 assert_eq!(
6535 tt.column_pinning_signal().get().get("d").copied(),
6536 Some(PinnedSide::Leading),
6537 "dropping at the leading edge must pin the column, same as TableView"
6538 );
6539 }
6540
6541 #[test]
6542 fn header_drag_reorder_remaps_focused_and_editing_cell_to_follow_their_columns() {
6543 let (mut tree, id, _proxy) = build_tt_reorder_table();
6549 {
6550 let any = tree.widget_as_any(id).unwrap();
6551 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6552 tt.set_focused_cell(0, 1); tt.begin_edit(0, "d"); }
6555
6556 let header = tt_header_row_cells(&tree, id);
6557 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
6560 tree.layout(SizeProposal {
6561 width: Some(400.0),
6562 height: Some(200.0),
6563 });
6564
6565 let any = tree.widget_as_any(id).unwrap();
6566 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6567 assert_eq!(
6568 tt.column_order_signal().get(),
6569 vec![
6570 "a".to_string(),
6571 "d".to_string(),
6572 "b".to_string(),
6573 "c".to_string()
6574 ],
6575 );
6576 assert_eq!(
6577 tt.focused_cell_signal().get(),
6578 Some((0, 2)),
6579 "focus must follow \"b\" to its new display position"
6580 );
6581 assert_eq!(
6582 tt.editing_cell_signal().get(),
6583 Some((0, 1)),
6584 "the open editor must follow \"d\" to its new display position"
6585 );
6586 }
6587
6588 #[test]
6589 fn header_drag_moves_the_tree_column_and_twist_follows() {
6590 let (mut tree, id, proxy) = build_tt_reorder_table();
6599 let header = tt_header_row_cells(&tree, id);
6600 let from = tree.bounds(header[0]).center(); let to = teksilo_canvas::Point::new(220.0, from.y); drag(&mut tree, from, to);
6603
6604 {
6605 let any = tree.widget_as_any(id).unwrap();
6606 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6607 assert_eq!(
6608 tt.column_order_signal().get(),
6609 vec![
6610 "b".to_string(),
6611 "c".to_string(),
6612 "a".to_string(),
6613 "d".to_string()
6614 ],
6615 );
6616 assert_eq!(
6617 tt.column_pinning_signal().get().get("a"),
6618 None,
6619 "a mid-band drop must not pin the tree column either"
6620 );
6621 }
6622 tree.layout(SizeProposal {
6623 width: Some(400.0),
6624 height: Some(200.0),
6625 });
6626
6627 let body = tt_body_row_cells(&tree, id);
6628 assert_eq!(body.len(), 4);
6629 assert!(
6630 !tt_subtree_has_twist_arrow(&tree, body[0]),
6631 "\"b\" is no longer the tree column"
6632 );
6633 assert!(
6634 !tt_subtree_has_twist_arrow(&tree, body[1]),
6635 "\"c\" is no longer the tree column"
6636 );
6637 assert!(
6638 tt_subtree_has_twist_arrow(&tree, body[2]),
6639 "the twist must follow \"a\" to its new display position"
6640 );
6641 assert!(
6642 !tt_subtree_has_twist_arrow(&tree, body[3]),
6643 "\"d\" is not the tree column"
6644 );
6645
6646 use teksilo_core::event::{Key, Modifiers};
6648 tree.focus(id);
6649 {
6650 let any = tree.widget_as_any(id).unwrap();
6651 any.downcast_ref::<TreeTableView<&'static str>>()
6652 .unwrap()
6653 .set_focused_cell(0, 2); }
6655 tree.press_key(Key::ArrowRight, Modifiers::NONE);
6656 assert_eq!(
6657 proxy.visible_count(),
6658 4,
6659 "ArrowRight on the relocated tree column must expand \"docs\""
6660 );
6661 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
6662 assert_eq!(proxy.visible_count(), 2, "and ArrowLeft collapses it again");
6663 }
6664
6665 #[test]
6666 fn header_drag_from_a_different_table_is_rejected() {
6667 use crate::primitives::{FixedSize, HStack};
6673 let proxy1 = SortFilterTreeModel::new(sample_tree());
6674 let proxy2 = SortFilterTreeModel::new(sample_tree());
6675 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6676
6677 let tt1 = TreeTableView::from_projection(proxy1)
6678 .add_column(reorder_col("x", 100.0))
6679 .add_column(reorder_col("y", 100.0))
6680 .row_height(20.0);
6681 let order1 = tt1.column_order_signal().clone();
6682 let id1 = tree.add(tt1);
6683 let tt2 = TreeTableView::from_projection(proxy2)
6684 .add_column(reorder_col("x", 100.0))
6685 .add_column(reorder_col("y", 100.0))
6686 .row_height(20.0);
6687 let order2 = tt2.column_order_signal().clone();
6688 let id2 = tree.add(tt2);
6689
6690 let fixed1 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id1));
6691 let fixed2 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id2));
6692 tree.add(HStack::new().add_child(fixed1).add_child(fixed2));
6693 tree.layout(SizeProposal {
6694 width: Some(400.0),
6695 height: Some(150.0),
6696 });
6697
6698 let from = tree.bounds(tt_header_row_cells(&tree, id1)[0]).center();
6701 let to = teksilo_canvas::Point::new(250.0, from.y); drag(&mut tree, from, to);
6703
6704 assert!(order1.get().is_empty(), "tt1's own order must be untouched");
6705 assert!(
6706 order2.get().is_empty(),
6707 "tt2 must reject a drop whose payload names a different table_id"
6708 );
6709 }
6710
6711 #[test]
6712 fn header_drag_released_over_the_body_does_not_trigger_foreign_row_drop() {
6713 use std::cell::Cell;
6723 let foreign_fired = Rc::new(Cell::new(false));
6724 let flag = foreign_fired.clone();
6725 let proxy = SortFilterTreeModel::new(sample_tree());
6726 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6727 let id = tree.add(
6728 TreeTableView::from_projection(proxy)
6729 .add_column(name_col())
6730 .add_column(size_col())
6731 .on_foreign_drop(move |_payload, _node, _pos, _ctx| {
6732 flag.set(true);
6733 true
6734 })
6735 .row_height(20.0),
6736 );
6737 tree.layout(SizeProposal {
6738 width: Some(400.0),
6739 height: Some(200.0),
6740 });
6741
6742 let header = tt_header_row_cells(&tree, id);
6743 let from = tree.bounds(header[0]).center();
6744 let to = teksilo_canvas::Point::new(from.x, cp::HEADER_HEIGHT + 10.0); drag(&mut tree, from, to);
6746
6747 assert!(
6748 !foreign_fired.get(),
6749 "a column-reorder drag must never reach on_foreign_drop"
6750 );
6751 let any = tree.widget_as_any(id).unwrap();
6752 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6753 assert!(
6754 tt.column_order_signal().get().is_empty(),
6755 "no header drop occurred either — the release point was outside the header strip"
6756 );
6757 }
6758
6759 #[test]
6760 fn header_drag_insertion_is_scroll_aware() {
6761 let (mut tree, id) = build_tt_wide_unpinned_table(100.0, 4, 200.0);
6766 let max = tt_max_scroll_x(&tree, id);
6767 assert!(max > 0.0, "4×100px columns must overflow a 200px viewport");
6768 tt_set_scroll_x(&tree, id, max); tree.layout(SizeProposal {
6770 width: Some(200.0),
6771 height: Some(200.0),
6772 });
6773
6774 let header = tt_header_row_cells(&tree, id);
6780 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(10.0, from.y);
6782 drag(&mut tree, from, to);
6783
6784 let any = tree.widget_as_any(id).unwrap();
6785 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6786 assert_eq!(
6787 tt.column_order_signal().get(),
6788 vec![
6789 "c0".to_string(),
6790 "c1".to_string(),
6791 "c3".to_string(),
6792 "c2".to_string()
6793 ],
6794 "\"c3\" must land before \"c2\" (scroll-aware), not before \"c0\""
6795 );
6796 }
6797
6798 fn tt_resize_table() -> (WidgetTree, WidgetId) {
6806 let proxy = SortFilterTreeModel::new(sample_tree());
6809 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6810 let id = tree.add(
6811 TreeTableView::from_projection(proxy)
6812 .add_column(name_col())
6813 .add_column(size_col())
6814 .row_height(20.0)
6815 .show_internal_scrollbars(false),
6816 );
6817 tree.layout(SizeProposal {
6818 width: Some(400.0),
6819 height: Some(200.0),
6820 });
6821 (tree, id)
6822 }
6823
6824 fn tt_overrides(tree: &WidgetTree, id: WidgetId) -> std::collections::HashMap<String, f32> {
6825 let any = tree.widget_as_any(id).unwrap();
6826 any.downcast_ref::<TreeTableView<&'static str>>()
6827 .unwrap()
6828 .column_widths_signal()
6829 .get()
6830 }
6831
6832 #[test]
6833 fn tt_grip_reaches_into_the_next_column() {
6834 use teksilo_canvas::Point;
6835 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6836 let (mut tree, id) = tt_resize_table();
6837 let y = cp::HEADER_HEIGHT * 0.5;
6838 tree.dispatch_event(WidgetEvent::PointerDown {
6840 position: Point::new(341.0, y),
6841 button: PointerButton::Primary,
6842 modifiers: Modifiers::NONE,
6843 });
6844 tree.dispatch_event(WidgetEvent::PointerMove {
6845 position: Point::new(311.0, y),
6846 });
6847 tree.dispatch_event(WidgetEvent::PointerUp {
6848 position: Point::new(311.0, y),
6849 button: PointerButton::Primary,
6850 modifiers: Modifiers::NONE,
6851 });
6852 let w = tt_overrides(&tree, id);
6853 assert!(
6854 (w.get("name").copied().unwrap_or(0.0) - 310.0).abs() < 0.5,
6855 "dragging the divider left from `size` must shrink `name` from 340 \
6856 to 310; got {w:?}"
6857 );
6858 }
6859
6860 #[test]
6861 fn tt_header_strip_paints_column_separators() {
6862 let (mut tree, _id) = tt_resize_table();
6863 let frame = tree.render();
6864 let found = frame.decorations.iter().any(|d| {
6865 let [x, y, w, h] = d.rect;
6866 (x - 339.0).abs() < 0.6
6867 && w <= 1.5
6868 && y.abs() < 0.6
6869 && (h - cp::HEADER_HEIGHT).abs() < 0.6
6870 });
6871 assert!(
6872 found,
6873 "expected a header separator at the name/size divider (x≈339); \
6874 decorations={:?}",
6875 frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
6876 );
6877 }
6878
6879 #[test]
6880 fn tree_column_chrome_is_clipped_to_its_column() {
6881 let (tree, id) = tt_resize_table();
6888 let mut walker = vec![id];
6891 let mut checked = false;
6892 while let Some(node) = walker.pop() {
6893 if tree.accessibility_node(node).role() == Role::Cell {
6894 let kids = tree.children(node);
6895 if let Some(&wrapper) = kids.first()
6896 && tree.widget_clips_children(wrapper)
6897 {
6898 checked = true;
6899 break;
6900 }
6901 }
6902 for c in tree.children(node) {
6903 walker.push(c);
6904 }
6905 }
6906 assert!(
6907 checked,
6908 "the tree column's indent + twist wrapper must clip its children"
6909 );
6910 }
6911
6912 fn editable_name_col() -> Column<&'static str> {
6915 Column::<&str>::new("name", lit!("Name"), |row, cx: &CellContext| {
6916 if cx.is_editing {
6917 Box::new(crate::text_input::TextInput::new(Signal::new(
6918 (*row).to_string(),
6919 )))
6920 } else {
6921 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6922 }
6923 })
6924 .width(ColumnWidth::Flex(1.0))
6925 .editable(true)
6926 }
6927
6928 fn three_row_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
6929 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
6930 slice.set_source(move || {
6931 [(1_u64, "one"), (2, "two"), (3, "three")]
6932 .into_iter()
6933 .map(|(k, n)| teksilo_data::TreeRow::new(k, n, 0))
6934 .collect()
6935 });
6936 slice.reload();
6937 slice
6938 }
6939
6940 fn double_click_at(tree: &mut WidgetTree, at: Point) {
6943 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6944 for _ in 0..2 {
6945 tree.dispatch_event(WidgetEvent::PointerDown {
6946 position: at,
6947 button: PointerButton::Primary,
6948 modifiers: Modifiers::NONE,
6949 });
6950 tree.dispatch_event(WidgetEvent::PointerUp {
6951 position: at,
6952 button: PointerButton::Primary,
6953 modifiers: Modifiers::NONE,
6954 });
6955 }
6956 }
6957
6958 #[test]
6967 fn opening_a_cell_editor_moves_the_keyboard_into_it() {
6968 let slice = three_row_slice();
6969 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6970 let id = tree.add(
6971 TreeTableView::from_source(slice)
6972 .add_column(editable_name_col())
6973 .row_height(20.0),
6974 );
6975 let proposal = SizeProposal {
6976 width: Some(400.0),
6977 height: Some(200.0),
6978 };
6979 tree.layout(proposal);
6980 tree.focus(id);
6981
6982 {
6983 let any = tree.widget_as_any(id).unwrap();
6984 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6985 tt.begin_edit(1, "name");
6986 }
6987 tree.layout(proposal);
6988
6989 let focused = tree.focused().expect("something must hold focus");
6990 assert_ne!(
6991 focused, id,
6992 "focus is still on the table, not in the editor"
6993 );
6994 let cell = {
6995 let any = tree.widget_as_any(id).unwrap();
6996 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6997 tt.realized_cell(1, 0).expect("the edited cell is realized")
6998 };
6999 assert!(
7000 tree.is_descendant_of(focused, cell),
7001 "focus must land inside the edited cell, not on {:?}",
7002 tree.widget_type_name(focused)
7003 );
7004 }
7005
7006 #[test]
7015 fn an_open_editor_still_holds_the_keyboard_after_the_pane_rebuilds() {
7016 let slice = three_row_slice();
7017 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
7018 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7019 let id = tree.add(
7020 TreeTableView::from_source(slice)
7021 .selection(selection.clone())
7022 .add_column(editable_name_col())
7023 .row_height(20.0),
7024 );
7025 let proposal = SizeProposal {
7026 width: Some(400.0),
7027 height: Some(200.0),
7028 };
7029 tree.layout(proposal);
7030 {
7031 let any = tree.widget_as_any(id).unwrap();
7032 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7033 tt.begin_edit(1, "name");
7034 }
7035 tree.layout(proposal);
7036 tree.focused().expect("the editor took focus");
7037
7038 selection.select(2);
7039 tree.layout(proposal);
7040
7041 let focused = tree.focused().expect("focus survived the rebuild");
7042 assert_ne!(focused, id, "the rebuild dropped focus back onto the table");
7043 let cell = {
7044 let any = tree.widget_as_any(id).unwrap();
7045 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7046 tt.realized_cell(1, 0)
7047 .expect("the edited cell is still realized")
7048 };
7049 assert!(
7050 tree.is_descendant_of(focused, cell),
7051 "focus must still be inside the edited cell, not on {:?}",
7052 tree.widget_type_name(focused)
7053 );
7054 }
7055
7056 #[test]
7062 fn a_double_click_on_an_editable_cell_opens_its_editor() {
7063 let (mut tree, id, seen, _) = click_probe(EditTriggers::DOUBLE_CLICK);
7064 let cell = realized(&tree, id, 1, 0);
7065 let at = tree.bounds(cell).center();
7066 double_click_at(&mut tree, at);
7067
7068 assert_eq!(
7069 seen.borrow().as_slice(),
7070 &[(1, "name".to_string())],
7071 "a double-click on an editable cell must request its editor"
7072 );
7073 let any = tree.widget_as_any(id).unwrap();
7074 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7075 assert_eq!(tt.editing_cell_signal().get(), Some((1, 0)));
7076 }
7077
7078 #[test]
7081 fn a_single_click_opens_the_editor_when_the_column_asks_for_it() {
7082 let (mut tree, id, seen, _) = click_probe(EditTriggers::SINGLE_CLICK);
7083 let cell = realized(&tree, id, 1, 0);
7084 tree.click(cell);
7085
7086 assert_eq!(
7087 seen.borrow().as_slice(),
7088 &[(1, "name".to_string())],
7089 "one click on a SINGLE_CLICK column must request its editor"
7090 );
7091 }
7092
7093 #[test]
7097 fn a_click_opens_nothing_when_the_column_asks_for_no_click_trigger() {
7098 let (mut tree, id, seen, _) = click_probe(EditTriggers::F2);
7099 let cell = realized(&tree, id, 1, 0);
7100 tree.click(cell);
7101 let at = tree.bounds(cell).center();
7102 double_click_at(&mut tree, at);
7103
7104 assert!(
7105 seen.borrow().is_empty(),
7106 "an F2-only column opened an editor from a click: {:?}",
7107 seen.borrow()
7108 );
7109 }
7110
7111 #[test]
7126 fn editing_a_cell_by_double_click_does_not_also_activate_the_row() {
7127 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
7128 let cell = realized(&tree, id, 1, 0);
7129 let at = tree.bounds(cell).center();
7130 double_click_at(&mut tree, at);
7131 assert_eq!(
7132 activated.get(),
7133 0,
7134 "double-clicking an editable cell opened the item as well as the editor"
7135 );
7136 }
7137
7138 #[test]
7142 fn a_double_click_off_an_editable_cell_still_activates_the_row() {
7143 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
7144 let cell = realized(&tree, id, 1, 1);
7145 let at = tree.bounds(cell).center();
7146 double_click_at(&mut tree, at);
7147 assert_eq!(
7148 activated.get(),
7149 1,
7150 "a double-click away from an editable cell must still activate the row"
7151 );
7152 }
7153
7154 #[test]
7163 fn a_double_click_editable_cell_still_lets_its_row_select_on_one_click() {
7164 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
7165 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7166 let id = tree.add(
7167 TreeTableView::from_source(three_row_slice())
7168 .selection(selection.clone())
7169 .add_column(editable_name_col().edit_triggers(EditTriggers::DOUBLE_CLICK))
7170 .row_height(20.0)
7171 .on_cell_edit_request(|_row, _col, _ctx| {}),
7172 );
7173 tree.layout(SizeProposal {
7174 width: Some(400.0),
7175 height: Some(200.0),
7176 });
7177
7178 let cell = realized(&tree, id, 1, 0);
7179 tree.click(cell);
7180 assert!(
7181 selection.is_selected(1),
7182 "one click on a double-click-editable cell must still select its row"
7183 );
7184 }
7185
7186 #[test]
7190 fn a_single_click_editable_cell_claims_the_press_from_row_selection() {
7191 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
7192 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7193 let id = tree.add(
7194 TreeTableView::from_source(three_row_slice())
7195 .selection(selection.clone())
7196 .add_column(editable_name_col().edit_triggers(EditTriggers::SINGLE_CLICK))
7197 .add_column(size_col())
7198 .row_height(20.0)
7199 .on_cell_edit_request(|_row, _col, _ctx| {}),
7200 );
7201 tree.layout(SizeProposal {
7202 width: Some(400.0),
7203 height: Some(200.0),
7204 });
7205
7206 let editable = realized(&tree, id, 1, 0);
7207 tree.click(editable);
7208 assert!(
7209 !selection.is_selected(1),
7210 "a SINGLE_CLICK cell's click must go to the editor, not to selection"
7211 );
7212
7213 let plain = realized(&tree, id, 2, 1);
7216 tree.click(plain);
7217 assert!(
7218 selection.is_selected(2),
7219 "a click on a non-editing column must still select its row"
7220 );
7221 }
7222
7223 fn realized(tree: &WidgetTree, id: WidgetId, row: usize, col: usize) -> WidgetId {
7225 let any = tree.widget_as_any(id).unwrap();
7226 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7227 tt.realized_cell(row, col)
7228 .unwrap_or_else(|| panic!("cell ({row}, {col}) is not realized"))
7229 }
7230
7231 #[allow(clippy::type_complexity)]
7235 fn click_probe(
7236 triggers: EditTriggers,
7237 ) -> (
7238 WidgetTree,
7239 WidgetId,
7240 Rc<RefCell<Vec<(usize, String)>>>,
7241 Rc<Cell<usize>>,
7242 ) {
7243 let seen: Rc<RefCell<Vec<(usize, String)>>> = Rc::new(RefCell::new(Vec::new()));
7244 let sink = seen.clone();
7245 let activated: Rc<Cell<usize>> = Rc::new(Cell::new(0));
7246 let counter = activated.clone();
7247 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7248 let id = tree.add(
7249 TreeTableView::from_source(three_row_slice())
7250 .add_column(editable_name_col().edit_triggers(triggers))
7251 .add_column(size_col())
7252 .row_height(20.0)
7253 .on_cell_edit_request(move |row, col, _ctx| {
7254 sink.borrow_mut().push((row, col.to_string()));
7255 })
7256 .on_row_activate(move |_row, _ctx| counter.set(counter.get() + 1)),
7257 );
7258 tree.layout(SizeProposal {
7259 width: Some(400.0),
7260 height: Some(200.0),
7261 });
7262 (tree, id, seen, activated)
7263 }
7264}