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 selection_mode: self.selection_mode,
1707 selection: self.row_selection.clone(),
1708 cell_selection: self.cell_selection.clone(),
1709 scroll_y: self.scroll_y.clone(),
1710 max_scroll_y: self.max_scroll_y.clone(),
1711 viewport_height: self.viewport_height.clone(),
1712 body_bounds: self.body_bounds.clone(),
1713 row_metrics: self.row_metrics.clone(),
1714 tab_traversal: self.tab_traversal,
1715 editing_cell: self.editing_cell.clone(),
1716 display_col_to_id,
1717 display_col_triggers,
1718 on_cell_edit_request: self.on_cell_edit_request.clone(),
1719 on_row_activate: self.on_row_activate.clone(),
1720 type_ahead: self.type_ahead.clone(),
1721 type_ahead_label,
1722 type_ahead_timeout: self.type_ahead_timeout,
1723 column_widths: self.column_widths.clone(),
1724 pane_boundaries: *self.pane_boundaries.borrow(),
1725 scroll_x: self.scroll_x.clone(),
1726 max_scroll_x: self.max_scroll_x.clone(),
1727 middle_viewport_width: self.middle_viewport_width.clone(),
1728 };
1729
1730 let mut shared_key = keyboard::build_key_handler(key_cfg);
1735 let reorderable_kbd = self.reorderable;
1736 let source_kbd = self.source.clone();
1737 let focused_kbd = self.focused_cell.clone();
1738 let sel_kbd = self.row_selection.clone();
1739 let sort_kbd = self.sort_signal.clone();
1740 let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1741 ctx: &mut EventContext|
1742 -> EventResponse {
1743 use teksilo_core::event::{Key, WidgetEvent};
1744 if reorderable_kbd
1745 && sort_kbd.get().is_none()
1746 && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1747 && modifiers.alt()
1748 && matches!(key, Key::ArrowUp | Key::ArrowDown)
1749 {
1750 let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1751 sel_kbd
1752 .as_ref()
1753 .and_then(|s| s.selected_indices().first().copied())
1754 });
1755 if let Some(flat_idx) = row
1759 && let Some(new_flat) =
1760 source_kbd.keyboard_reorder(flat_idx, matches!(key, Key::ArrowDown))
1761 {
1762 let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1763 focused_kbd.set(Some((new_flat, col)));
1764 if let Some(ref s) = sel_kbd {
1765 s.select(new_flat);
1766 }
1767 return EventResponse::Handled;
1768 }
1769 }
1770 shared_key(event, ctx)
1771 };
1772
1773 let mut handlers = HandlerSet::new()
1774 .on_scroll({
1775 let overscroll_behavior = self.overscroll_behavior;
1776 move |event, _ctx| match event {
1777 teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1778 let (raw_dx, raw_dy) = match delta {
1779 teksilo_core::event::ScrollDelta::Lines { x, y } => {
1780 (x * line_height, y * line_height)
1781 }
1782 teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1783 };
1784 let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1787 (raw_dy, 0.0)
1788 } else {
1789 (raw_dx, raw_dy)
1790 };
1791
1792 let mut moved_any = false;
1793 if dy.abs() > 0.0 {
1794 let current = scroll_y_for_wheel.get();
1795 let max = max_scroll_for_wheel.get();
1796 let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1801 let (new_y, moved) =
1802 crate::common::scroll::scroll_clamp_axis(base, dy, max);
1803 if moved {
1804 if smooth_scrolling {
1805 scroll_y_for_wheel.animate_to(
1806 new_y,
1807 smooth_scroll_duration,
1808 Easing::EaseOut,
1809 );
1810 } else {
1811 scroll_y_for_wheel.set(new_y);
1812 }
1813 }
1814 moved_any |= moved;
1815 }
1816 if dx.abs() > 0.0 {
1817 let current = scroll_x_for_wheel.get();
1818 let max = max_scroll_x_for_wheel.get();
1819 let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1820 let (new_x, moved) =
1821 crate::common::scroll::scroll_clamp_axis(base, dx, max);
1822 if moved {
1823 if smooth_scrolling {
1824 scroll_x_for_wheel.animate_to(
1825 new_x,
1826 smooth_scroll_duration,
1827 Easing::EaseOut,
1828 );
1829 } else {
1830 scroll_x_for_wheel.set(new_x);
1831 }
1832 }
1833 moved_any |= moved;
1834 }
1835 crate::common::scroll::scroll_response(
1839 moved_any,
1840 overscroll_behavior == OverscrollBehavior::Contain,
1841 )
1842 }
1843 _ => EventResponse::Ignored,
1844 }
1845 })
1846 .on_key(key_handler)
1847 .clips_children(true)
1848 .focusable(true);
1849
1850 if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
1870 let my_model_id = self.model_id;
1871 let source_for_hover = self.source.clone();
1872 let metrics_for_hover = self.row_metrics.clone();
1873 let scroll_for_hover = self.scroll_y.clone();
1874 let header_h_for_hover = header_h;
1875 let feedback_for_hover = self.drop_feedback.clone();
1876 let sort_for_hover = self.sort_signal.clone();
1877 let reorderable_hover = self.reorderable;
1878 let export_for_hover = self.export.clone();
1879 let has_foreign_hook_hover = self.on_foreign_drop.is_some();
1880 let bounds_for_hover = self.body_bounds.clone();
1881 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1882 if payload.has_typed::<ColumnReorderDragData>() {
1890 feedback_for_hover.set(None);
1891 return teksilo_core::DropFeedback::NoFeedback;
1892 }
1893 let viz_width = bounds_for_hover.get().width.max(1.0);
1896 let count = source_for_hover.visible_count();
1897 if count == 0 {
1898 feedback_for_hover.set(None);
1899 return teksilo_core::DropFeedback::NoFeedback;
1900 }
1901 let rd = payload.get_typed::<RowDragData<T>>();
1902 let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
1903 let reorder_ok =
1904 is_same_view && reorderable_hover && sort_for_hover.get().is_none();
1905 let foreign_ok = !is_same_view
1909 && (has_foreign_hook_hover
1910 || export_for_hover.accepts_foreign_export(payload, my_model_id));
1911 if !reorder_ok && !foreign_ok {
1912 feedback_for_hover.set(None);
1913 return teksilo_core::DropFeedback::NoFeedback;
1914 }
1915 let scroll = scroll_for_hover.get().max(0.0);
1916 let content_y = position.y - header_h_for_hover + scroll;
1917 let (insertion_top, row_idx, row_top, row_h) = {
1918 let mut m = metrics_for_hover.borrow_mut();
1919 m.resize(count);
1920 let ins = m.insertion_index(content_y);
1921 let r = m.row_at(content_y);
1922 (m.row_top(ins), r, m.row_top(r), m.row_height(r))
1923 };
1924 let y_in_row = content_y - row_top;
1925 let third = (row_h / 3.0).max(f32::EPSILON);
1926 let drop_pos = if y_in_row < third {
1927 DropPosition::Before
1928 } else if y_in_row > 2.0 * third {
1929 DropPosition::After
1930 } else {
1931 DropPosition::Into
1932 };
1933 let (effective, depth) = if reorder_ok {
1941 match (source_for_hover.dnd.can_accept_fn)(
1942 payload,
1943 row_idx,
1944 drop_pos,
1945 my_model_id,
1946 ) {
1947 DropResponse::Reject => {
1948 if !foreign_ok {
1949 feedback_for_hover.set(None);
1950 return teksilo_core::DropFeedback::NoFeedback;
1951 }
1952 (DropPosition::Before, 0)
1953 }
1954 DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1955 DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1956 }
1957 } else {
1958 (DropPosition::Before, 0)
1960 };
1961 if effective == DropPosition::Into {
1962 let top = row_top - scroll;
1963 feedback_for_hover.set(Some(DropViz::Rect {
1964 top,
1965 height: row_h,
1966 width: viz_width,
1967 depth,
1968 }));
1969 teksilo_core::DropFeedback::HighlightRect {
1970 rect: Rect::new(0.0, top, viz_width, row_h),
1971 color: drop_into_tint(),
1972 }
1973 } else {
1974 let insertion_y = insertion_top - scroll;
1975 feedback_for_hover.set(Some(DropViz::Line {
1976 y: insertion_y,
1977 width: viz_width,
1978 depth,
1979 }));
1980 teksilo_core::DropFeedback::InsertionLine {
1981 y: insertion_y,
1982 width: viz_width,
1983 }
1984 }
1985 });
1986
1987 let drop_model_id = self.model_id;
1988 let source_for_drop = self.source.clone();
1989 let metrics_for_drop = self.row_metrics.clone();
1990 let scroll_for_drop = self.scroll_y.clone();
1991 let header_h_for_drop = header_h;
1992 let feedback_for_drop = self.drop_feedback.clone();
1993 let sort_for_drop = self.sort_signal.clone();
1994 let reorderable_drop = self.reorderable;
1995 let on_foreign_for_drop = self.on_foreign_drop.clone();
1996 let proxy_for_foreign_hook = self.proxy.clone();
1997 let export_for_drop = self.export.clone();
1998 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1999 feedback_for_drop.set(None);
2000 if payload.has_typed::<ColumnReorderDragData>() {
2004 return false;
2005 }
2006 let count = source_for_drop.visible_count();
2007 if count == 0 {
2008 return false;
2009 }
2010 let scroll = scroll_for_drop.get().max(0.0);
2011 let content_y = position.y - header_h_for_drop + scroll;
2012 let (flat_idx, row_top, row_h, ins) = {
2013 let mut m = metrics_for_drop.borrow_mut();
2014 m.resize(count);
2015 let idx = m.row_at(content_y);
2016 let ins = m.insertion_index(content_y);
2017 (idx, m.row_top(idx), m.row_height(idx), ins)
2018 };
2019 let y_in_row = content_y - row_top;
2020 let third = (row_h / 3.0).max(f32::EPSILON);
2021 let drop_pos = if y_in_row < third {
2022 DropPosition::Before
2023 } else if y_in_row > 2.0 * third {
2024 DropPosition::After
2025 } else {
2026 DropPosition::Into
2027 };
2028 let is_same_view = payload
2029 .get_typed::<RowDragData<T>>()
2030 .is_some_and(|rd| rd.source == drop_model_id);
2031 if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
2032 return false;
2033 }
2034 if (reorderable_drop || !is_same_view)
2039 && (source_for_drop.dnd.accept_drop_fn)(
2040 &payload,
2041 flat_idx,
2042 drop_pos,
2043 drop_model_id,
2044 )
2045 {
2046 if is_same_view {
2047 export_for_drop.note_self_reorder();
2048 }
2049 return true;
2050 }
2051 if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
2054 return true;
2055 }
2056 if let Some(ref hook) = on_foreign_for_drop
2060 && let Some(ref p) = proxy_for_foreign_hook
2061 && let Some(node) = p.visible_node_id(flat_idx)
2062 {
2063 return hook(&payload, node, drop_pos, ctx);
2064 }
2065 false
2066 });
2067
2068 let feedback_for_leave = self.drop_feedback.clone();
2069 handlers = handlers.on_drag_leave(move |_ctx| {
2070 feedback_for_leave.set(None);
2071 });
2072
2073 let scroll_for_tick = self.scroll_y.clone();
2074 let max_scroll_for_tick = self.max_scroll_y.clone();
2075 let viewport_for_tick = self.viewport_height.clone();
2076 let header_h_for_tick = header_h;
2077 handlers = handlers.on_drag_tick(move |pos, _ctx| {
2078 const EDGE: f32 = 32.0;
2081 const MAX_VELOCITY: f32 = 12.0;
2082 let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2083 let y = pos.y - header_h_for_tick;
2084 let above = (EDGE - y).max(0.0);
2085 let below = (y - (body_h - EDGE)).max(0.0);
2086 let delta = if above > 0.0 {
2087 -(above / EDGE) * MAX_VELOCITY
2088 } else if below > 0.0 {
2089 (below / EDGE) * MAX_VELOCITY
2090 } else {
2091 0.0
2092 };
2093 if delta.abs() > 0.01 {
2094 let max = max_scroll_for_tick.get();
2095 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2096 scroll_for_tick.set(new_y);
2097 }
2098 });
2099 }
2100
2101 handlers = self.export.install_completion(handlers);
2111
2112 ctx.apply_self_handlers(handlers);
2113
2114 self.header_row_id = None;
2117 self.body_pane_id = None;
2118 self.scrollbar_id = None;
2119 self.h_scrollbar_id = None;
2120 self.empty_id = None;
2121
2122 if self.show_header {
2124 *self.resize_state.borrow_mut() = None;
2128 self.resize_target.set(None);
2129 self.resize_preview_x.set(None);
2130
2131 let boundaries = *self.pane_boundaries.borrow();
2132 let resize_columns: ColumnResizeTable = Rc::new(
2133 display_indices
2134 .iter()
2135 .map(|&i| {
2136 let c = &self.columns[i];
2137 ColumnResizeInfo {
2138 id: c.id.clone(),
2139 min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2140 max_width: c.max_width,
2141 resizable: c.resizable,
2142 }
2143 })
2144 .collect(),
2145 );
2146 let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2147 let active_sort = self.sort_signal.get();
2148 for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2149 let col = &self.columns[col_idx];
2150 let current_sort = active_sort
2151 .as_ref()
2152 .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2153 let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2154 let cell = HeaderCell::new(HeaderCellSpec {
2155 col_id: col.id.clone(),
2156 label: col.header_label.resolve_now(),
2157 col_index_1based: display_pos + 1,
2158 sortable: col.sortable,
2159 reorderable: col.reorderable,
2160 filterable: col.filterable,
2161 resize_grip: cp::RESIZE_HANDLE_WIDTH,
2162 filter_zone_width,
2163 current_sort,
2164 width_index: display_pos,
2165 pane_boundaries: boundaries,
2166 resize_columns: resize_columns.clone(),
2167 resize_policy: self.column_resize_policy,
2168 resize_state: self.resize_state.clone(),
2169 resize_target: self.resize_target.clone(),
2170 resize_preview_x: self.resize_preview_x.clone(),
2171 table_id: self.table_id,
2172 sort_signal: self.sort_signal.clone(),
2173 column_widths_signal: self.column_widths_signal.clone(),
2174 column_widths: self.column_widths.clone(),
2175 filters_signal: self.filters_signal.clone(),
2176 });
2177 cell_ids.push(ctx.add(cell));
2178 }
2179 let header_row = HeaderRow::new(
2180 cell_ids,
2181 self.column_widths.clone(),
2182 cp::GRID_LINE_THICKNESS,
2183 *self.pane_boundaries.borrow(),
2184 self.scroll_x.clone(),
2185 );
2186 let header_row_id = ctx.add(header_row);
2196 attach_header_reorder_handlers(
2197 ctx,
2198 header_row_id,
2199 self.table_id,
2200 self.column_widths.clone(),
2201 self.display_indices.clone(),
2202 self.pane_boundaries.clone(),
2203 self.column_order_signal.clone(),
2204 self.column_pinning_signal.clone(),
2205 self.columns.iter().map(|c| c.id.clone()).collect(),
2206 self.header_strip_width.clone(),
2207 self.scroll_x.clone(),
2208 );
2209 self.header_row_id = Some(header_row_id);
2210 }
2211
2212 let row_count = self.source.visible_count();
2217
2218 let (vis_start, vis_end) = self.visible_range();
2226 (self.source.dnd.request_window_fn)(vis_start..vis_end);
2227 if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2228 (self.source.dnd.fetch_more_fn)();
2229 }
2230
2231 if row_count > 0 {
2232 let pane = body_pane::TreeBodyPane::<T> {
2233 source: self.source.clone(),
2234 editing_anchor: self.editing_anchor.clone(),
2235 columns: self.columns.clone(),
2236 display_indices: self.display_indices.clone(),
2237 column_widths: self.column_widths.clone(),
2238 pane_boundaries: *self.pane_boundaries.borrow(),
2239 scroll_x: self.scroll_x.clone(),
2240 tree_display_pos,
2241 indent_per_level,
2242 row_metrics: self.row_metrics.clone(),
2243 selection_mode: self.selection_mode,
2244 selection: self.row_selection.clone(),
2245 cell_selection: self.cell_selection.clone(),
2246 scroll_y: self.scroll_y.clone(),
2247 viewport_height: self.viewport_height.clone(),
2248 editing_cell: self.editing_cell.clone(),
2249 focused_cell: self.focused_cell.clone(),
2250 reorderable: self.reorderable,
2251 model_id: self.model_id,
2252 export: self.export.clone(),
2253 drag_anchor: ctx.self_id(),
2254 on_row_activate: self.on_row_activate.clone(),
2255 activate_on: self.activate_on,
2256 edit_triggers: self.edit_triggers,
2257 on_cell_edit_request: self.on_cell_edit_request.clone(),
2258 on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2259 version: self.pane_version.clone(),
2260 prev_built_start: self.pane_built_start.clone(),
2261 prev_built_end: self.pane_built_end.clone(),
2262 total_refresh: self.pane_total_refresh.clone(),
2263 row_entries: Vec::new(),
2264 row_map: self.row_map.clone(),
2265 cell_map: self.cell_map.clone(),
2266 };
2267 self.body_pane_id = Some(ctx.add(pane));
2268 if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
2272 &self.on_cell_edit_dismissed,
2273 &self.editing_cell,
2274 &Rc::new(
2275 display_indices
2276 .iter()
2277 .map(|&i| self.columns[i].id.clone())
2278 .collect::<Vec<_>>(),
2279 ),
2280 ) {
2281 ctx.apply_self_handlers(handlers);
2282 }
2283 } else if let Some(ref f) = self.empty_view {
2284 self.empty_id = Some(ctx.add_boxed(f()));
2286 }
2287
2288 if self.show_internal_scrollbars {
2290 let sb = ScrollBar::new(
2291 ScrollBarOrientation::Vertical,
2292 self.scroll_y.clone(),
2293 self.max_scroll_y.clone(),
2294 self.viewport_ratio_y.clone(),
2295 )
2296 .visual(match self.scroll_bar_style {
2297 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2298 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2299 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2300 });
2301 self.scrollbar_id = Some(ctx.add(sb));
2302
2303 let hsb = ScrollBar::new(
2305 ScrollBarOrientation::Horizontal,
2306 self.scroll_x.clone(),
2307 self.max_scroll_x.clone(),
2308 self.viewport_ratio_x.clone(),
2309 )
2310 .visual(match self.scroll_bar_style {
2311 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2312 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2313 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2314 });
2315 self.h_scrollbar_id = Some(ctx.add(hsb));
2316 }
2317
2318 let mut children: Vec<WidgetId> = Vec::new();
2322 if let Some(id) = self.body_pane_id {
2323 children.push(id);
2324 }
2325 if let Some(id) = self.empty_id {
2326 children.push(id);
2327 }
2328 if let Some(id) = self.scrollbar_id {
2329 children.push(id);
2330 }
2331 if let Some(id) = self.h_scrollbar_id {
2332 children.push(id);
2333 }
2334 if let Some(id) = self.header_row_id {
2335 children.push(id);
2336 }
2337 let _ = (header_h, row_h);
2338 children
2339 }
2340
2341 fn layout_response(
2342 &self,
2343 proposal: SizeProposal,
2344 _ctx: &LayoutContext,
2345 ) -> teksilo_core::widget::LayoutResponse {
2346 let size = crate::common::viewport::viewport_size(
2350 proposal,
2351 &self.viewport_height,
2352 Size::new(400.0, 300.0),
2353 );
2354 if proposal.height.is_some() {
2355 self.laid_out.set(true);
2359 }
2360 size.into()
2361 }
2362
2363 fn place_children(
2364 &self,
2365 bounds: Rect,
2366 _proposal: SizeProposal,
2367 children: &mut [WidgetPlacement],
2368 ctx: &LayoutContext,
2369 ) {
2370 if children.is_empty() {
2371 return;
2372 }
2373 let rtl = ctx.is_rtl();
2374 let header_h = self.effective_header_height();
2375 let body_height_provisional = (bounds.height - header_h).max(0.0);
2376
2377 let total_height = self
2381 .row_metrics
2382 .borrow_mut()
2383 .total_height(self.source.visible_count());
2384 let needs_v_scrollbar =
2385 self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2386 let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2389 let body_width = if reserves_v_bar {
2390 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2391 } else {
2392 bounds.width
2393 };
2394 let band_left = if rtl && reserves_v_bar {
2398 bounds.x + SCROLLBAR_THICKNESS
2399 } else {
2400 bounds.x
2401 };
2402 let scrollbar_x = if rtl {
2403 bounds.x
2404 } else {
2405 bounds.x + bounds.width - SCROLLBAR_THICKNESS
2406 };
2407 self.header_strip_width.set(body_width);
2410
2411 let overrides = self.column_widths_signal.get();
2412 let display = self.display_indices.borrow().clone();
2413 let widths = layout::ColumnSolver::resolve_in_order(
2414 &self.columns,
2415 &display,
2416 body_width,
2417 cp::MIN_COLUMN_WIDTH_DEFAULT,
2418 &overrides,
2419 );
2420
2421 let boundaries = *self.pane_boundaries.borrow();
2423 let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2424 let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2425 let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2426 self.max_scroll_x.set(max_x);
2427 self.middle_viewport_width.set(middle_viewport_w);
2428 let x_ratio = if middle_content_w > 0.0 {
2429 (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2430 } else {
2431 1.0
2432 };
2433 self.viewport_ratio_x.set(x_ratio);
2434 {
2435 let current = self.scroll_x.get();
2436 let clamped = current.clamp(0.0, max_x);
2437 if (clamped - current).abs() > 0.001 {
2438 self.scroll_x.set(clamped);
2439 }
2440 }
2441
2442 *self.column_widths.borrow_mut() = widths;
2443
2444 let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2445 let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2446 let body_height = if reserves_h_bar {
2447 (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2448 } else {
2449 body_height_provisional
2450 };
2451
2452 let max_y = (total_height - body_height).max(0.0);
2453 self.max_scroll_y.set(max_y);
2454 let y_ratio = if total_height > 0.0 {
2455 (body_height / total_height).clamp(0.0, 1.0)
2456 } else {
2457 1.0
2458 };
2459 self.viewport_ratio_y.set(y_ratio);
2460 self.clamp_scroll();
2461
2462 let body_origin_y = bounds.y + header_h;
2463 self.body_bounds
2465 .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2466
2467 let mut next = 0;
2468
2469 if self.body_pane_id.is_some() {
2472 if let Some(child) = children.get_mut(next) {
2473 child.origin = Point::new(band_left, body_origin_y);
2474 child.size = Size::new(body_width, body_height);
2475 }
2476 next += 1;
2477 }
2478
2479 if self.empty_id.is_some() {
2481 if let Some(child) = children.get_mut(next) {
2482 child.origin = Point::new(band_left, body_origin_y);
2483 child.size = Size::new(body_width, body_height);
2484 }
2485 next += 1;
2486 }
2487
2488 if self.scrollbar_id.is_some() {
2490 if let Some(child) = children.get_mut(next) {
2491 if needs_v_scrollbar {
2492 child.origin = Point::new(scrollbar_x, body_origin_y);
2493 child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2494 } else {
2495 child.origin = bounds.origin();
2496 child.size = Size::ZERO;
2497 }
2498 }
2499 next += 1;
2500 }
2501
2502 if self.h_scrollbar_id.is_some() {
2504 if let Some(child) = children.get_mut(next) {
2505 if needs_h_scrollbar {
2506 let h_x = if rtl {
2507 band_left + trailing_w
2508 } else {
2509 band_left + leading_w
2510 };
2511 child.origin = Point::new(h_x, body_origin_y + body_height);
2512 child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2513 } else {
2514 child.origin = bounds.origin();
2515 child.size = Size::ZERO;
2516 }
2517 }
2518 next += 1;
2519 }
2520
2521 if self.header_row_id.is_some()
2524 && let Some(child) = children.get_mut(next)
2525 {
2526 child.origin = Point::new(band_left, bounds.y);
2527 child.size = Size::new(body_width, header_h);
2528 }
2529 }
2530
2531 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2532 let header_h = self.effective_header_height();
2533 let colors = &ctx.theme.colors;
2534 let scroll_y = self.scroll_y.get();
2535 let body_origin_y = bounds.y + header_h;
2536 let body_height = (bounds.height - header_h).max(0.0);
2537 let widths = self.column_widths.borrow();
2538 let body_width = widths.iter().sum::<f32>();
2539 let body_width_for_paint = if body_width > 0.0 {
2540 body_width.min(bounds.width)
2541 } else {
2542 bounds.width
2543 };
2544 let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2546 let content_left = if rtl {
2547 bounds.x + bounds.width - body_width_for_paint
2548 } else {
2549 bounds.x
2550 };
2551
2552 let row_count = self.source.visible_count();
2555 let (first_visible, last_visible) =
2556 self.row_metrics
2557 .borrow_mut()
2558 .visible_range(scroll_y, body_height, row_count, 0);
2559
2560 canvas.set_clip(Rect::new(
2566 content_left,
2567 body_origin_y,
2568 body_width_for_paint,
2569 body_height,
2570 ));
2571
2572 if self.alternating_rows {
2573 let mut m = self.row_metrics.borrow_mut();
2574 for row_idx in first_visible..last_visible {
2575 if row_idx % 2 == 1 {
2576 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2577 let h = m.row_height(row_idx);
2578 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2579 canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2580 }
2581 }
2582 }
2583
2584 if let Some(ref sel) = self.row_selection
2585 && matches!(
2586 self.selection_mode,
2587 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2588 )
2589 {
2590 let bg = if self.view_focused.get() && ctx.window_active {
2594 SurfaceRole::Selected.resolve(colors)
2595 } else {
2596 SurfaceRole::SelectedInactive.resolve(colors)
2597 };
2598 let mut m = self.row_metrics.borrow_mut();
2599 for row_idx in sel.selected_indices() {
2600 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2601 let h = m.row_height(row_idx);
2602 if y + h < body_origin_y || y > body_origin_y + body_height {
2603 continue;
2604 }
2605 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2606 canvas.fill_rect(rect, bg);
2607 }
2608 }
2609
2610 let line_color = BorderRole::Divider.resolve(colors);
2611 let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2612 if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2613 let mut m = self.row_metrics.borrow_mut();
2614 for row_idx in first_visible..last_visible {
2615 let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2616 let y = body_origin_y + bottom - scroll_y - line_w;
2617 let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2618 canvas.fill_rect(rect, line_color);
2619 }
2620 }
2621
2622 let boundaries = *self.pane_boundaries.borrow();
2625 let scroll_x = self.scroll_x.get();
2626 let content_bounds = Rect::new(
2627 content_left,
2628 body_origin_y,
2629 body_width_for_paint,
2630 body_height,
2631 );
2632 let (leading_rect, middle_rect, trailing_rect) =
2633 layout::band_rects(content_bounds, &widths, boundaries, rtl);
2634
2635 if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2636 let leading_end = boundaries.leading_count.min(widths.len());
2637 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2638 crate::table_view::draw_pane_dividers(
2639 canvas,
2640 leading_rect,
2641 &widths[..leading_end],
2642 0.0,
2643 rtl,
2644 line_color,
2645 line_w,
2646 );
2647 crate::table_view::draw_pane_dividers(
2648 canvas,
2649 middle_rect,
2650 &widths[leading_end..middle_end],
2651 scroll_x,
2652 rtl,
2653 line_color,
2654 line_w,
2655 );
2656 crate::table_view::draw_pane_dividers(
2657 canvas,
2658 trailing_rect,
2659 &widths[middle_end..],
2660 0.0,
2661 rtl,
2662 line_color,
2663 line_w,
2664 );
2665 }
2666
2667 if self.view_focused.get()
2670 && self.focus_visible.get()
2671 && let Some((focus_row, focus_col)) = self.focused_cell.get()
2672 && focus_col < widths.len()
2673 && let Some(x_off) = layout::column_logical_x(
2674 &widths,
2675 boundaries,
2676 scroll_x,
2677 body_width_for_paint,
2678 focus_col,
2679 )
2680 {
2681 let cell_w = widths[focus_col];
2682 let (focus_top, focus_h) = {
2683 let mut m = self.row_metrics.borrow_mut();
2684 (m.row_top(focus_row), m.row_height(focus_row))
2685 };
2686 let y = body_origin_y + focus_top - scroll_y;
2687 if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2688 let pane_rect = if focus_col < boundaries.leading_count {
2689 leading_rect
2690 } else if focus_col >= boundaries.middle_end {
2691 trailing_rect
2692 } else {
2693 middle_rect
2694 };
2695 canvas.set_clip(pane_rect);
2696 let inset = cp::FOCUS_RING_INSET;
2697 let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2698 let ring_color = BorderRole::Focused.resolve(colors);
2699 let rx = if rtl {
2700 content_left + body_width_for_paint - x_off - cell_w + inset
2701 } else {
2702 content_left + x_off + inset
2703 };
2704 let ry = y + inset;
2705 let rw = (cell_w - inset * 2.0).max(0.0);
2706 let rh = (focus_h - inset * 2.0).max(0.0);
2707 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2708 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2709 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2710 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2711 canvas.clear_clip();
2712 }
2713 }
2714
2715 let drop_indent_origin = |depth: usize| -> f32 {
2726 let step = self.effective_indent();
2727 let tree_decl = self.tree_column_decl_index();
2728 let tree_slot = self
2729 .display_indices
2730 .borrow()
2731 .iter()
2732 .position(|&i| i == tree_decl)
2733 .unwrap_or(0);
2734 let col_x = layout::column_logical_x(
2735 &widths,
2736 boundaries,
2737 scroll_x,
2738 body_width_for_paint,
2739 tree_slot,
2740 )
2741 .unwrap_or(0.0);
2742 (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
2743 };
2744 match self.drop_feedback.get() {
2745 Some(DropViz::Line { y, depth, .. }) => {
2746 let recipe = ctx
2747 .theme
2748 .style_slots
2749 .list_container
2750 .as_ref()
2751 .map(|s| s.insertion())
2752 .unwrap_or_default();
2753 let line_color = recipe.role.resolve(colors);
2754 let thickness = recipe.thickness;
2755 let line_y = body_origin_y + y - thickness * 0.5;
2756 let indent = drop_indent_origin(depth);
2757 let x = if rtl {
2760 content_left
2761 } else {
2762 content_left + indent
2763 };
2764 canvas.fill_rect(
2765 Rect::new(x, line_y, body_width_for_paint - indent, thickness),
2766 line_color,
2767 );
2768 }
2769 Some(DropViz::Rect {
2774 top, height, depth, ..
2775 }) => {
2776 let into = ctx
2777 .theme
2778 .style_slots
2779 .list_container
2780 .as_ref()
2781 .map(|s| s.drop_into())
2782 .unwrap_or_default();
2783 let color = into.role.resolve(colors);
2784 let indent = drop_indent_origin(depth);
2785 let x = if rtl {
2786 content_left
2787 } else {
2788 content_left + indent
2789 };
2790 let rect = Rect::new(
2791 x + into.inset,
2792 body_origin_y + top + into.inset,
2793 (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
2794 (height - into.inset * 2.0).max(0.0),
2795 );
2796 let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
2797 canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
2798 canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
2799 }
2800 None => {}
2801 }
2802
2803 canvas.clear_clip();
2804
2805 let nothing_indicated = self.focused_cell.get().is_none()
2809 && self
2810 .row_selection
2811 .as_ref()
2812 .is_none_or(|s| s.selected_indices().is_empty())
2813 && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2814 if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2815 let inset = 1.0_f32;
2816 let rect = Rect::new(
2817 bounds.x + inset,
2818 bounds.y + inset,
2819 (bounds.width - inset * 2.0).max(0.0),
2820 (bounds.height - inset * 2.0).max(0.0),
2821 );
2822 canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2823 }
2824
2825 if let Some(x) = self.resize_preview_x.get() {
2827 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2828 canvas.fill_rect(
2829 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2830 BorderRole::Focused.resolve(colors),
2831 );
2832 }
2833 }
2834
2835 fn context_menu_key_target(&self) -> Option<WidgetId> {
2849 let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2850 self.row_selection
2851 .as_ref()
2852 .and_then(|s| s.selected_indices().first().copied())
2853 })?;
2854 let map = self.row_map.borrow();
2855 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2856 }
2857
2858 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2859 builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
2860 if self
2873 .row_selection
2874 .as_ref()
2875 .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
2876 {
2877 builder.set_multiselectable(true);
2878 }
2879
2880 if let Some(ref label) = self.a11y_label {
2881 builder.set_name(label.resolve_now());
2882 }
2883 let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
2884 let col_count = self.columns.len();
2885 let n = builder.inner_mut();
2886 n.set_row_count(row_count);
2887 n.set_column_count(col_count);
2888
2889 if let Some((row, col)) = self.focused_cell.get()
2896 && let Some(cell_id) = self.realized_cell(row, col)
2897 {
2898 builder.set_active_descendant(widget_id_to_node_id(cell_id));
2899 }
2900 }
2901
2902 fn as_any(&self) -> Option<&dyn std::any::Any> {
2903 Some(self)
2904 }
2905
2906 fn children(&self) -> Vec<WidgetId> {
2907 let mut out: Vec<WidgetId> = Vec::new();
2910 if let Some(id) = self.body_pane_id {
2911 out.push(id);
2912 }
2913 if let Some(id) = self.empty_id {
2914 out.push(id);
2915 }
2916 if let Some(id) = self.scrollbar_id {
2917 out.push(id);
2918 }
2919 if let Some(id) = self.h_scrollbar_id {
2920 out.push(id);
2921 }
2922 if let Some(id) = self.header_row_id {
2923 out.push(id);
2924 }
2925 out
2926 }
2927
2928 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2929 let out: Vec<WidgetId> = [
2933 self.header_row_id,
2934 self.body_pane_id,
2935 self.empty_id,
2936 self.scrollbar_id,
2937 self.h_scrollbar_id,
2938 ]
2939 .into_iter()
2940 .flatten()
2941 .collect();
2942 if out.is_empty() { None } else { Some(out) }
2943 }
2944
2945 fn clips_children(&self) -> bool {
2946 true
2947 }
2948}
2949
2950#[cfg(test)]
2951mod tests {
2952 use super::*;
2953 use crate::table_view::column::{CellContext, ColumnWidth};
2954 use teksilo_canvas::SizeProposal;
2955 use teksilo_core::accesskit::Role;
2956 use teksilo_core::widget_tree::WidgetTree;
2957 use teksilo_data::{SortFilterTreeModel, TreeFilterMode, TreeModel};
2958 use teksilo_i18n::lit;
2959
2960 fn sample_tree() -> TreeModel<&'static str> {
2961 let t = TreeModel::new();
2962 let docs = t.insert_root(0, "docs");
2963 t.insert_child(docs, 0, "readme");
2964 t.insert_child(docs, 1, "guide");
2965 let src = t.insert_root(1, "src");
2966 t.insert_child(src, 0, "main.rs");
2967 t
2968 }
2969
2970 fn name_col() -> Column<&'static str> {
2971 Column::<&str>::new("name", lit!("Name"), |row, _: &CellContext| {
2972 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
2973 })
2974 .width(ColumnWidth::Flex(1.0))
2975 }
2976
2977 fn size_col() -> Column<&'static str> {
2978 Column::<&str>::new("size", lit!("Size"), |_row, _: &CellContext| {
2979 Box::new(crate::primitives::TextWidget::new(lit!("0")))
2980 })
2981 .width(ColumnWidth::Fixed(60.0))
2982 }
2983
2984 #[test]
2985 fn row_selection_click_repaints_immediately_without_expand_collapse() {
2986 use teksilo_canvas::Point;
2993 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2994 use teksilo_data::{SelectionMode, SelectionModel};
2995 let proxy = SortFilterTreeModel::new(sample_tree());
2996 let selection = SelectionModel::new(SelectionMode::Single);
2997 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2998 tree.add(
2999 TreeTableView::from_projection(proxy.clone())
3000 .add_column(name_col())
3001 .selection_mode(TableSelectionMode::SingleRow)
3002 .selection(selection.clone())
3003 .row_height(20.0),
3004 );
3005 tree.layout(SizeProposal {
3006 width: Some(400.0),
3007 height: Some(200.0),
3008 });
3009 assert_eq!(selection.selected_indices().len(), 0);
3011 let header_h = cp::HEADER_HEIGHT;
3014 let click_y = header_h + 10.0;
3015 tree.dispatch_event(WidgetEvent::PointerDown {
3016 position: Point::new(40.0, click_y),
3017 button: PointerButton::Primary,
3018 modifiers: Modifiers::NONE,
3019 });
3020 tree.dispatch_event(WidgetEvent::PointerUp {
3021 position: Point::new(40.0, click_y),
3022 button: PointerButton::Primary,
3023 modifiers: Modifiers::NONE,
3024 });
3025 assert_eq!(selection.selected_indices(), vec![0]);
3027 tree.layout(SizeProposal {
3034 width: Some(400.0),
3035 height: Some(200.0),
3036 });
3037 assert_eq!(selection.selected_indices(), vec![0]);
3038 }
3039
3040 #[test]
3041 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
3042 use teksilo_core::event::{Key, Modifiers};
3049 use teksilo_data::{SelectionMode, SelectionModel};
3050
3051 for (key, want, what) in [
3052 (
3053 Key::ArrowDown,
3054 0usize,
3055 "first ArrowDown enters at the first visible row",
3056 ),
3057 (
3058 Key::ArrowUp,
3059 3usize,
3060 "first ArrowUp enters at the last visible row",
3061 ),
3062 ] {
3063 let t = TreeModel::new();
3064 t.insert_root(0, "a");
3065 t.insert_root(1, "b");
3066 t.insert_root(2, "c");
3067 t.insert_root(3, "d");
3068 let proxy = SortFilterTreeModel::new(t);
3069 let selection = SelectionModel::new(SelectionMode::Single);
3070 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3071 let id = tree.add(
3072 TreeTableView::from_projection(proxy.clone())
3073 .add_column(name_col())
3074 .selection_mode(TableSelectionMode::SingleRow)
3075 .selection(selection.clone())
3076 .row_height(20.0),
3077 );
3078 tree.layout(SizeProposal {
3079 width: Some(400.0),
3080 height: Some(200.0),
3081 });
3082 tree.focus(id);
3083 assert_eq!(proxy.visible_count(), 4, "four flat roots");
3084 assert!(
3085 selection.selected_indices().is_empty(),
3086 "precondition: no cursor, nothing selected"
3087 );
3088
3089 tree.press_key(key, Modifiers::NONE);
3090 assert_eq!(selection.selected_indices(), vec![want], "{what}");
3091 }
3092 }
3093
3094 #[test]
3095 fn expanded_children_are_reachable_by_the_first_arrow() {
3096 use teksilo_core::event::{Key, Modifiers};
3102 use teksilo_data::{SelectionMode, SelectionModel};
3103
3104 let proxy = SortFilterTreeModel::new(sample_tree()); let selection = SelectionModel::new(SelectionMode::Single);
3106 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3107 let id = tree.add(
3108 TreeTableView::from_projection(proxy.clone())
3109 .add_column(name_col())
3110 .selection_mode(TableSelectionMode::SingleRow)
3111 .selection(selection.clone())
3112 .row_height(20.0),
3113 );
3114 tree.layout(SizeProposal {
3115 width: Some(400.0),
3116 height: Some(200.0),
3117 });
3118 tree.focus(id);
3119
3120 let last = proxy.visible_count() - 1;
3121 tree.press_key(Key::ArrowUp, Modifiers::NONE);
3122 assert_eq!(
3123 selection.selected_indices(),
3124 vec![last],
3125 "first ArrowUp enters at the last VISIBLE row, whatever the hierarchy shows"
3126 );
3127 }
3128
3129 #[test]
3130 fn row_click_moves_focus_so_arrow_nav_resumes_there() {
3131 use teksilo_canvas::Point;
3137 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
3138 use teksilo_data::{SelectionMode, SelectionModel};
3139 let t = TreeModel::new();
3140 t.insert_root(0, "a");
3141 t.insert_root(1, "b");
3142 t.insert_root(2, "c");
3143 t.insert_root(3, "d");
3144 let proxy = SortFilterTreeModel::new(t);
3145 let selection = SelectionModel::new(SelectionMode::Single);
3146 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3147 let id = tree.add(
3148 TreeTableView::from_projection(proxy.clone())
3149 .add_column(name_col())
3150 .selection_mode(TableSelectionMode::SingleRow)
3151 .selection(selection.clone())
3152 .row_height(20.0),
3153 );
3154 tree.layout(SizeProposal {
3155 width: Some(400.0),
3156 height: Some(200.0),
3157 });
3158 tree.focus(id);
3159 assert_eq!(proxy.visible_count(), 4, "four flat roots");
3160
3161 let click_y = cp::HEADER_HEIGHT + 1.0 * 20.0 + 10.0;
3163 tree.dispatch_event(WidgetEvent::PointerDown {
3164 position: Point::new(40.0, click_y),
3165 button: PointerButton::Primary,
3166 modifiers: Modifiers::NONE,
3167 });
3168 tree.dispatch_event(WidgetEvent::PointerUp {
3169 position: Point::new(40.0, click_y),
3170 button: PointerButton::Primary,
3171 modifiers: Modifiers::NONE,
3172 });
3173 assert_eq!(
3174 selection.selected_indices(),
3175 vec![1],
3176 "click selects flat row 1"
3177 );
3178
3179 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3181 assert_eq!(
3182 selection.selected_indices(),
3183 vec![2],
3184 "ArrowDown after a click resumes from the clicked row (1 → 2)"
3185 );
3186 }
3187
3188 #[test]
3189 fn role_is_treegrid() {
3190 let proxy = SortFilterTreeModel::new(sample_tree());
3191 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3192 let id = tree.add(
3193 TreeTableView::from_projection(proxy)
3194 .add_column(name_col())
3195 .add_column(size_col())
3196 .row_height(20.0),
3197 );
3198 tree.layout(SizeProposal {
3199 width: Some(400.0),
3200 height: Some(200.0),
3201 });
3202 let info = tree.accessibility_node(id);
3203 assert_eq!(info.role(), Role::TreeGrid);
3204 }
3205
3206 #[test]
3207 fn initial_state_shows_only_roots() {
3208 let proxy = SortFilterTreeModel::new(sample_tree());
3209 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3210 let _id = tree.add(
3211 TreeTableView::from_projection(proxy.clone())
3212 .add_column(name_col())
3213 .row_height(20.0),
3214 );
3215 tree.layout(SizeProposal {
3216 width: Some(400.0),
3217 height: Some(200.0),
3218 });
3219 assert_eq!(proxy.visible_count(), 2); }
3221
3222 #[test]
3223 fn expand_via_widget_reveals_children() {
3224 let proxy = SortFilterTreeModel::new(sample_tree());
3225 let docs = proxy.tree().root(0);
3226 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3227 let id = tree.add(
3228 TreeTableView::from_projection(proxy.clone())
3229 .add_column(name_col())
3230 .row_height(20.0),
3231 );
3232 tree.layout(SizeProposal {
3233 width: Some(400.0),
3234 height: Some(200.0),
3235 });
3236 {
3237 let any = tree.widget_as_any(id).unwrap();
3238 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3239 tt.expand(docs);
3240 }
3241 assert_eq!(proxy.visible_count(), 4); }
3243
3244 #[test]
3245 fn arrow_right_expands_and_left_collapses_on_tree_column() {
3246 let proxy = SortFilterTreeModel::new(sample_tree());
3247 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3248 let id = tree.add(
3249 TreeTableView::from_projection(proxy.clone())
3250 .add_column(name_col())
3251 .row_height(20.0),
3252 );
3253 tree.layout(SizeProposal {
3254 width: Some(400.0),
3255 height: Some(200.0),
3256 });
3257 tree.focus(id);
3258 {
3259 let any = tree.widget_as_any(id).unwrap();
3260 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3261 tt.set_focused_cell(0, 0);
3262 }
3263 tree.press_key(
3266 teksilo_core::event::Key::ArrowRight,
3267 teksilo_core::event::Modifiers::NONE,
3268 );
3269 assert_eq!(proxy.visible_count(), 4);
3270 tree.press_key(
3272 teksilo_core::event::Key::ArrowLeft,
3273 teksilo_core::event::Modifiers::NONE,
3274 );
3275 assert_eq!(proxy.visible_count(), 2);
3276 }
3277
3278 fn slice_rows() -> Vec<teksilo_data::TreeRow<u64, &'static str>> {
3281 use teksilo_data::TreeRow;
3282 vec![
3283 TreeRow::new(1, "docs", 0),
3284 TreeRow::new(2, "readme", 1),
3285 TreeRow::new(3, "guide", 1),
3286 TreeRow::new(4, "src", 0),
3287 ]
3288 }
3289
3290 fn external_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
3291 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3292 slice.set_source(slice_rows);
3293 slice.reload();
3294 slice
3295 }
3296
3297 #[test]
3298 fn from_source_renders_an_external_tree_without_a_tree_model() {
3299 let slice = external_slice();
3303 slice.expand(&1);
3304 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3305 let id = tree.add(
3306 TreeTableView::from_source(slice.clone())
3307 .add_column(name_col())
3308 .row_height(20.0),
3309 );
3310 tree.layout(SizeProposal {
3311 width: Some(400.0),
3312 height: Some(200.0),
3313 });
3314 assert_eq!(slice.visible_count(), 4, "docs + 2 children + src");
3315
3316 let any = tree.widget_as_any(id).unwrap();
3317 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3318 assert!(
3319 tt.projection().is_none(),
3320 "a source-backed view has no TreeModel projection to expose"
3321 );
3322 assert!(tt.body_pane_id.is_some(), "rows rendered from the source");
3323 }
3324
3325 #[test]
3326 fn from_source_keyed_selection_survives_a_full_resource() {
3327 let slice = external_slice();
3331 slice.expand(&1);
3332 let keyed = KeyedSelectionModel::<u64>::new(teksilo_data::SelectionMode::Multi);
3333 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3334 let _id = tree.add(
3335 TreeTableView::from_source_keyed(slice.clone(), keyed.clone())
3336 .add_column(name_col())
3337 .row_height(20.0),
3338 );
3339 tree.layout(SizeProposal {
3340 width: Some(400.0),
3341 height: Some(200.0),
3342 });
3343
3344 keyed.select(3); assert!(keyed.is_selected(&3));
3346
3347 slice.reload();
3349 assert!(
3350 keyed.is_selected(&3),
3351 "a domain-keyed selection must survive a re-source"
3352 );
3353 }
3354
3355 #[test]
3356 fn from_source_supports_drag_reorder_like_the_tree_view() {
3357 use std::cell::RefCell;
3361 use std::rc::Rc;
3362 use teksilo_canvas::Point;
3363
3364 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3366 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3367 {
3368 let order = order.clone();
3369 slice.set_source(move || {
3370 let names: std::collections::HashMap<u64, &'static str> =
3371 [(1, "docs"), (4, "src")].into_iter().collect();
3372 order
3373 .borrow()
3374 .iter()
3375 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3376 .collect()
3377 });
3378 }
3379 {
3380 let order = order.clone();
3381 slice.set_reorder(move |dragged, target, _pos| {
3383 let mut o = order.borrow_mut();
3384 let Some(from) = o.iter().position(|k| *k == dragged) else {
3385 return false;
3386 };
3387 let item = o.remove(from);
3388 let to = o
3389 .iter()
3390 .position(|k| *k == target)
3391 .map_or(o.len(), |i| i + 1);
3392 o.insert(to, item);
3393 true
3394 });
3395 }
3396 slice.set_drag_policy(|_| teksilo_data::DragEligibility::CanDrag);
3399 slice.reload();
3400 assert_eq!(*order.borrow(), vec![1, 4], "docs, src");
3401
3402 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3403 tree.add(
3404 TreeTableView::from_source(slice.clone())
3405 .add_column(name_col())
3406 .reorderable(true)
3407 .row_height(20.0),
3408 );
3409 tree.layout(SizeProposal {
3410 width: Some(400.0),
3411 height: Some(300.0),
3412 });
3413
3414 let h = cp::HEADER_HEIGHT;
3416 drag(
3417 &mut tree,
3418 Point::new(40.0, h + 10.0),
3419 Point::new(40.0, h + 38.0),
3420 );
3421 assert_eq!(
3422 *order.borrow(),
3423 vec![4, 1],
3424 "the source applied the reorder: src now precedes docs"
3425 );
3426 }
3427
3428 #[test]
3429 fn a_source_that_forbids_dragging_a_row_is_honored() {
3430 use std::cell::RefCell;
3433 use std::rc::Rc;
3434 use teksilo_canvas::Point;
3435
3436 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3437 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3438 {
3439 let order = order.clone();
3440 slice.set_source(move || {
3441 let names: std::collections::HashMap<u64, &'static str> =
3442 [(1, "docs"), (4, "src")].into_iter().collect();
3443 order
3444 .borrow()
3445 .iter()
3446 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3447 .collect()
3448 });
3449 }
3450 {
3451 let order = order.clone();
3452 slice.set_reorder(move |dragged, target, _pos| {
3453 let mut o = order.borrow_mut();
3454 let Some(from) = o.iter().position(|k| *k == dragged) else {
3455 return false;
3456 };
3457 let item = o.remove(from);
3458 let to = o
3459 .iter()
3460 .position(|k| *k == target)
3461 .map_or(o.len(), |i| i + 1);
3462 o.insert(to, item);
3463 true
3464 });
3465 }
3466 slice.set_drag_policy(|k| {
3468 if *k == 1 {
3469 teksilo_data::DragEligibility::NoDrag
3470 } else {
3471 teksilo_data::DragEligibility::CanDrag
3472 }
3473 });
3474 slice.reload();
3475
3476 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3477 tree.add(
3478 TreeTableView::from_source(slice.clone())
3479 .add_column(name_col())
3480 .reorderable(true)
3481 .row_height(20.0),
3482 );
3483 tree.layout(SizeProposal {
3484 width: Some(400.0),
3485 height: Some(300.0),
3486 });
3487
3488 let h = cp::HEADER_HEIGHT;
3489 drag(
3490 &mut tree,
3491 Point::new(40.0, h + 10.0),
3492 Point::new(40.0, h + 38.0),
3493 );
3494 assert_eq!(
3495 *order.borrow(),
3496 vec![1, 4],
3497 "a NoDrag row must not move, even onto a valid target"
3498 );
3499 }
3500
3501 #[test]
3502 fn drop_on_the_middle_third_reparents_into_the_target() {
3503 use teksilo_canvas::Point;
3506 let proxy = SortFilterTreeModel::new(sample_tree());
3507 proxy.collapse_all(); let docs = proxy.tree().root(0);
3509 let src = proxy.tree().root(1);
3510 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3511 tree.add(
3512 TreeTableView::from_projection(proxy.clone())
3513 .add_column(name_col())
3514 .reorderable(true)
3515 .row_height(20.0),
3516 );
3517 tree.layout(SizeProposal {
3518 width: Some(400.0),
3519 height: Some(300.0),
3520 });
3521 let h = cp::HEADER_HEIGHT;
3522 drag(
3525 &mut tree,
3526 Point::new(40.0, h + 10.0),
3527 Point::new(40.0, h + 30.0),
3528 );
3529 assert_eq!(proxy.tree().root_count(), 1, "docs is no longer a root");
3530 assert_eq!(
3531 proxy.tree().parent(docs),
3532 Some(src),
3533 "docs became a child of src"
3534 );
3535 }
3536
3537 #[test]
3538 fn the_into_box_is_inset_and_the_insertion_line_is_indented() {
3539 use teksilo_canvas::{DrawCommand, Point, ShapeKind};
3544 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3545
3546 let proxy = SortFilterTreeModel::new(sample_tree());
3547 proxy.expand_all(); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3549 tree.add(
3550 TreeTableView::from_projection(proxy.clone())
3551 .add_column(name_col())
3552 .reorderable(true)
3553 .row_height(20.0),
3554 );
3555 tree.layout(SizeProposal {
3556 width: Some(400.0),
3557 height: Some(300.0),
3558 });
3559 let h = cp::HEADER_HEIGHT;
3560
3561 let start = Point::new(40.0, h + 90.0);
3564 tree.dispatch_event(WidgetEvent::PointerDown {
3565 position: start,
3566 button: PointerButton::Primary,
3567 modifiers: Modifiers::NONE,
3568 });
3569 tree.dispatch_event(WidgetEvent::PointerMove {
3570 position: Point::new(52.0, start.y),
3571 });
3572
3573 tree.dispatch_event(WidgetEvent::PointerMove {
3575 position: Point::new(52.0, h + 38.0),
3576 });
3577 let frame = tree.render();
3578 let line_recipe = teksilo_core::styles::ListInsertionRecipe::default();
3579 let lines: Vec<_> = frame
3583 .decorations
3584 .iter()
3585 .filter(|d| (d.rect[3] - line_recipe.thickness).abs() < 0.01 && d.rect[2] > 100.0)
3586 .collect();
3587 assert_eq!(lines.len(), 1, "exactly one insertion line, got {lines:?}");
3588 assert!(
3589 lines[0].rect[0] >= line_recipe.indent_step,
3590 "the After line must start one indent step in for a depth-1 target, \
3591 got x = {} (step {})",
3592 lines[0].rect[0],
3593 line_recipe.indent_step
3594 );
3595
3596 tree.dispatch_event(WidgetEvent::PointerMove {
3598 position: Point::new(52.0, h + 10.0),
3599 });
3600 let frame = tree.render();
3601 let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
3602 let boxes: Vec<_> = frame
3603 .draw_order
3604 .iter()
3605 .filter_map(|c| match c {
3606 DrawCommand::Shape(i) => frame.shapes.get(*i),
3607 _ => None,
3608 })
3609 .filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
3610 .filter(|s| (s.screen[3] - (20.0 - recipe.inset * 2.0)).abs() < 0.01)
3611 .collect();
3612 assert!(
3613 !boxes.is_empty(),
3614 "no inset rounded box for the Into hover; shapes = {:?}",
3615 frame.shapes.iter().map(|s| s.screen).collect::<Vec<_>>()
3616 );
3617 assert!(
3620 boxes
3621 .iter()
3622 .all(|s| (s.screen[1] - (h + recipe.inset)).abs() < 0.01),
3623 "the Into box must be inset from the row's top edge ({}), got {:?}",
3624 h,
3625 boxes.iter().map(|s| s.screen).collect::<Vec<_>>()
3626 );
3627 assert!(
3628 boxes.iter().any(|s| s.stroke_width > 0.0)
3629 && boxes.iter().any(|s| s.stroke_width == 0.0),
3630 "the Into box needs both a wash and an outline"
3631 );
3632 }
3633
3634 #[test]
3635 fn an_active_sort_suppresses_drag_reorder() {
3636 use teksilo_canvas::Point;
3640 let proxy = SortFilterTreeModel::new(sample_tree())
3641 .with_comparator("name", |a: &&'static str, b: &&'static str| a.cmp(b));
3642 proxy.collapse_all();
3643 let docs = proxy.tree().root(0);
3644 let src = proxy.tree().root(1);
3645 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3646 let id = tree.add(
3647 TreeTableView::from_projection(proxy.clone())
3648 .add_column(name_col())
3649 .reorderable(true)
3650 .row_height(20.0),
3651 );
3652 tree.layout(SizeProposal {
3653 width: Some(400.0),
3654 height: Some(300.0),
3655 });
3656 {
3657 let any = tree.widget_as_any(id).unwrap();
3658 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3659 tt.set_sort(Some("name"), SortDirection::Ascending);
3660 }
3661 tree.layout(SizeProposal {
3662 width: Some(400.0),
3663 height: Some(300.0),
3664 });
3665
3666 let h = cp::HEADER_HEIGHT;
3667 drag(
3668 &mut tree,
3669 Point::new(40.0, h + 10.0),
3670 Point::new(40.0, h + 38.0),
3671 );
3672 assert_eq!(
3673 proxy.tree().root(0),
3674 docs,
3675 "structure unchanged while sorted"
3676 );
3677 assert_eq!(
3678 proxy.tree().root(1),
3679 src,
3680 "structure unchanged while sorted"
3681 );
3682 }
3683
3684 #[test]
3685 fn an_open_cell_editor_follows_its_row_and_closes_if_the_row_vanishes() {
3686 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3690 let all: Vec<u64> = vec![1, 2, 3];
3691 slice.set_source(move || {
3692 let names: std::collections::HashMap<u64, &'static str> =
3693 [(1, "one"), (2, "two"), (3, "three")].into_iter().collect();
3694 all.iter()
3695 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3696 .collect()
3697 });
3698 slice.reload();
3699
3700 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3701 let id = tree.add(
3702 TreeTableView::from_source(slice.clone())
3703 .add_column(name_col())
3704 .row_height(20.0),
3705 );
3706 let proposal = SizeProposal {
3707 width: Some(400.0),
3708 height: Some(200.0),
3709 };
3710 tree.layout(proposal);
3711
3712 {
3714 let any = tree.widget_as_any(id).unwrap();
3715 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3716 tt.begin_edit(2, "name");
3717 assert_eq!(tt.editing_cell_signal().get(), Some((2, 0)));
3718 }
3719 tree.layout(proposal); let fewer: Vec<u64> = vec![2, 3];
3723 slice.set_source(move || {
3724 let names: std::collections::HashMap<u64, &'static str> =
3725 [(2, "two"), (3, "three")].into_iter().collect();
3726 fewer
3727 .iter()
3728 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3729 .collect()
3730 });
3731 slice.reload();
3732 tree.layout(proposal);
3733 {
3734 let any = tree.widget_as_any(id).unwrap();
3735 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3736 assert_eq!(
3737 tt.editing_cell_signal().get(),
3738 Some((1, 0)),
3739 "the editor must follow its row to index 1, not stay on index 2"
3740 );
3741 }
3742
3743 let last: Vec<u64> = vec![2];
3745 slice.set_source(move || {
3746 last.iter()
3747 .map(|k| teksilo_data::TreeRow::new(*k, "two", 0))
3748 .collect()
3749 });
3750 slice.reload();
3751 tree.layout(proposal);
3752 let any = tree.widget_as_any(id).unwrap();
3753 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3754 assert_eq!(
3755 tt.editing_cell_signal().get(),
3756 None,
3757 "the editor must close when its row is gone"
3758 );
3759 }
3760
3761 #[test]
3762 fn default_selection_mode_is_multi_row() {
3763 use teksilo_core::event::{Key, Modifiers};
3767 let proxy = SortFilterTreeModel::new(wide_tree(10));
3768 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3769 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3770 let id = tree.add(
3771 TreeTableView::from_projection(proxy)
3772 .add_column(name_col())
3773 .selection(selection.clone())
3774 .row_height(20.0),
3775 );
3776 tree.layout(SizeProposal {
3777 width: Some(400.0),
3778 height: Some(200.0),
3779 });
3780 tree.focus(id);
3781 {
3782 let any = tree.widget_as_any(id).unwrap();
3783 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3784 tt.set_focused_cell(0, 0);
3785 }
3786 selection.select(0);
3787 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3788 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3789 assert_eq!(
3790 selection.selection_signal().get().len(),
3791 3,
3792 "default mode must extend a multi-row selection"
3793 );
3794 }
3795
3796 #[test]
3797 fn ctrl_arrow_moves_cursor_without_touching_selection() {
3798 use teksilo_core::event::{Key, Modifiers};
3803 let proxy = SortFilterTreeModel::new(wide_tree(5));
3804 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3805 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3806 let id = tree.add(
3807 TreeTableView::from_projection(proxy)
3808 .add_column(name_col())
3809 .selection(selection.clone())
3810 .row_height(20.0),
3811 );
3812 tree.layout(SizeProposal {
3813 width: Some(400.0),
3814 height: Some(200.0),
3815 });
3816 tree.focus(id);
3817 {
3818 let any = tree.widget_as_any(id).unwrap();
3819 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3820 tt.set_focused_cell(0, 0);
3821 }
3822 selection.select(0);
3823
3824 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3825 {
3826 let any = tree.widget_as_any(id).unwrap();
3827 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3828 assert_eq!(
3829 tt.focused_cell_signal().get(),
3830 Some((1, 0)),
3831 "cursor advances"
3832 );
3833 }
3834 assert_eq!(
3835 selection.selected_indices(),
3836 vec![0],
3837 "Ctrl+Arrow must not touch selection"
3838 );
3839
3840 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3842 {
3843 let any = tree.widget_as_any(id).unwrap();
3844 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3845 assert_eq!(tt.focused_cell_signal().get(), Some((2, 0)));
3846 }
3847 assert_eq!(
3848 selection.selected_indices(),
3849 vec![2],
3850 "plain Arrow selects the row it lands on"
3851 );
3852 }
3853
3854 #[test]
3855 fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
3856 use teksilo_core::event::{Key, Modifiers};
3857 let proxy = SortFilterTreeModel::new(wide_tree(5));
3858 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3859 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3860 let id = tree.add(
3861 TreeTableView::from_projection(proxy)
3862 .add_column(name_col())
3863 .selection(selection.clone())
3864 .row_height(20.0),
3865 );
3866 tree.layout(SizeProposal {
3867 width: Some(400.0),
3868 height: Some(200.0),
3869 });
3870 tree.focus(id);
3871 {
3872 let any = tree.widget_as_any(id).unwrap();
3873 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3874 tt.set_focused_cell(0, 0);
3875 }
3876 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3877 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3878 assert!(selection.selected_indices().is_empty());
3879
3880 tree.press_key(Key::Space, Modifiers::CTRL);
3881 assert_eq!(
3882 selection.selected_indices(),
3883 vec![2],
3884 "Ctrl+Space toggles the focused row on"
3885 );
3886
3887 tree.press_key(Key::Space, Modifiers::CTRL);
3888 assert!(
3889 selection.selected_indices().is_empty(),
3890 "Ctrl+Space toggles it back off"
3891 );
3892 }
3893
3894 #[test]
3895 fn empty_view_renders_when_the_tree_has_no_rows() {
3896 let proxy = SortFilterTreeModel::new(TreeModel::<&'static str>::new());
3897 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3898 let id = tree.add(
3899 TreeTableView::from_projection(proxy)
3900 .add_column(name_col())
3901 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("Nothing here"))))
3902 .row_height(20.0),
3903 );
3904 tree.layout(SizeProposal {
3905 width: Some(400.0),
3906 height: Some(200.0),
3907 });
3908 let any = tree.widget_as_any(id).unwrap();
3909 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3910 assert!(tt.empty_id.is_some(), "placeholder should be built");
3911 assert!(tt.body_pane_id.is_none(), "no body pane for zero rows");
3912 }
3913
3914 #[test]
3915 fn empty_view_appears_when_live_rows_drop_to_zero() {
3916 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3921 let needle = t.to_string();
3922 Box::new(move |r: &&'static str| r.contains(&needle))
3923 });
3924 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3925 let id = tree.add(
3926 TreeTableView::from_projection(proxy.clone())
3927 .add_column(name_col())
3928 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3929 .row_height(20.0),
3930 );
3931 let proposal = SizeProposal {
3932 width: Some(400.0),
3933 height: Some(200.0),
3934 };
3935 tree.layout(proposal);
3936 {
3937 let any = tree.widget_as_any(id).unwrap();
3938 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3939 assert!(tt.body_pane_id.is_some(), "starts with a body pane");
3940 assert!(tt.empty_id.is_none(), "no placeholder while rows exist");
3941 }
3942
3943 proxy.set_filter("name", "zzz-no-such-row");
3944 tree.layout(proposal);
3945 assert_eq!(proxy.visible_count(), 0);
3946 let any = tree.widget_as_any(id).unwrap();
3947 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3948 assert!(
3949 tt.empty_id.is_some(),
3950 "placeholder must appear once rows drop to zero"
3951 );
3952 assert!(tt.body_pane_id.is_none(), "stale body pane must be gone");
3953 }
3954
3955 #[test]
3956 fn empty_view_renders_when_a_filter_matches_nothing() {
3957 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3960 let needle = t.to_string();
3961 Box::new(move |r: &&'static str| r.contains(&needle))
3962 });
3963 proxy.set_filter("name", "zzz-no-such-row");
3964 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3965 let id = tree.add(
3966 TreeTableView::from_projection(proxy.clone())
3967 .add_column(name_col())
3968 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3969 .row_height(20.0),
3970 );
3971 tree.layout(SizeProposal {
3972 width: Some(400.0),
3973 height: Some(200.0),
3974 });
3975 assert_eq!(proxy.visible_count(), 0);
3976 let any = tree.widget_as_any(id).unwrap();
3977 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3978 assert!(tt.empty_id.is_some());
3979 }
3980
3981 #[test]
3982 fn scroll_to_row_and_ensure_row_visible_move_the_offset() {
3983 let proxy = SortFilterTreeModel::new(wide_tree(100));
3984 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3985 let id = tree.add(
3986 TreeTableView::from_projection(proxy)
3987 .add_column(name_col())
3988 .row_height(20.0),
3989 );
3990 tree.layout(SizeProposal {
3991 width: Some(400.0),
3992 height: Some(200.0),
3993 });
3994 let any = tree.widget_as_any(id).unwrap();
3995 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3996
3997 tt.scroll_to_row(50);
3999 assert!((tt.scroll_y_signal().get() - 1000.0).abs() < 1.0);
4000
4001 let before = tt.scroll_y_signal().get();
4003 tt.ensure_row_visible(51);
4004 assert!((tt.scroll_y_signal().get() - before).abs() < f32::EPSILON);
4005
4006 tt.ensure_row_visible(10);
4008 assert!((tt.scroll_y_signal().get() - 200.0).abs() < 1.0);
4009 }
4010
4011 #[test]
4012 fn begin_edit_resolves_a_column_id_and_end_edit_clears() {
4013 let proxy = SortFilterTreeModel::new(sample_tree());
4014 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4015 let id = tree.add(
4016 TreeTableView::from_projection(proxy)
4017 .add_column(name_col())
4018 .add_column(size_col())
4019 .row_height(20.0),
4020 );
4021 tree.layout(SizeProposal {
4022 width: Some(400.0),
4023 height: Some(200.0),
4024 });
4025 let any = tree.widget_as_any(id).unwrap();
4026 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4027
4028 tt.begin_edit(1, "size");
4029 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4030 tt.end_edit();
4031 assert_eq!(tt.editing_cell_signal().get(), None);
4032
4033 tt.begin_edit(0, "no-such-column");
4035 assert_eq!(tt.editing_cell_signal().get(), None);
4036
4037 tt.begin_edit(9999, "name");
4041 assert_eq!(tt.editing_cell_signal().get(), None);
4042
4043 tt.begin_edit(1, "size");
4045 tt.begin_edit(9999, "size");
4046 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4047 }
4048
4049 #[test]
4050 fn begin_edit_resolves_before_the_view_is_mounted() {
4051 let proxy = SortFilterTreeModel::new(sample_tree());
4063 let view = TreeTableView::from_projection(proxy)
4064 .add_column(name_col())
4065 .add_column(size_col().pinned(PinnedSide::Leading))
4066 .row_height(20.0);
4067
4068 view.begin_edit(1, "name");
4069 assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
4070
4071 view.end_edit();
4073 view.begin_edit(0, "no-such-column");
4074 assert_eq!(view.editing_cell_signal().get(), None);
4075 view.begin_edit(9999, "name");
4076 assert_eq!(view.editing_cell_signal().get(), None);
4077
4078 view.begin_edit(1, "name");
4081 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4082 let id = tree.add(view);
4083 tree.layout(SizeProposal {
4084 width: Some(400.0),
4085 height: Some(200.0),
4086 });
4087 let tt = tree
4088 .widget_as_any(id)
4089 .unwrap()
4090 .downcast_ref::<TreeTableView<&'static str>>()
4091 .unwrap();
4092 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4093 }
4094
4095 #[test]
4096 fn column_imperatives_write_their_signals() {
4097 let proxy = SortFilterTreeModel::new(sample_tree());
4098 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4099 let id = tree.add(
4100 TreeTableView::from_projection(proxy)
4101 .add_column(name_col())
4102 .add_column(size_col())
4103 .row_height(20.0),
4104 );
4105 tree.layout(SizeProposal {
4106 width: Some(400.0),
4107 height: Some(200.0),
4108 });
4109 let any = tree.widget_as_any(id).unwrap();
4110 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4111
4112 tt.set_column_width("name", 123.0);
4113 assert_eq!(tt.column_widths_signal().get().get("name"), Some(&123.0));
4114 tt.set_column_width("name", 0.0);
4116 assert!(!tt.column_widths_signal().get().contains_key("name"));
4117
4118 assert_eq!(
4121 tt.display_order(),
4122 vec![0, 1],
4123 "declaration order initially"
4124 );
4125
4126 tt.set_column_order(vec!["size".into(), "name".into()]);
4127 assert_eq!(tt.column_order_signal().get(), vec!["size", "name"]);
4128 assert_eq!(
4129 tt.display_order(),
4130 vec![1, 0],
4131 "set_column_order must reorder the display, not only the signal"
4132 );
4133
4134 tt.set_column_pinning("name", PinnedSide::Leading);
4137 assert_eq!(
4138 tt.column_pinning_signal().get().get("name"),
4139 Some(&PinnedSide::Leading)
4140 );
4141 assert_eq!(
4142 tt.display_order(),
4143 vec![0, 1],
4144 "set_column_pinning must pull the pinned column back to the front"
4145 );
4146 tt.set_column_pinning("name", PinnedSide::None);
4147 assert!(!tt.column_pinning_signal().get().contains_key("name"));
4148 assert_eq!(
4149 tt.display_order(),
4150 vec![1, 0],
4151 "clearing the pin restores the order list's arrangement"
4152 );
4153
4154 tt.set_sort(Some("name"), SortDirection::Ascending);
4155 assert!(tt.sort_signal().get().is_some());
4156 tt.clear_sort();
4157 assert_eq!(tt.sort_signal().get(), None);
4158 }
4159
4160 #[test]
4171 fn column_pinning_remaps_focused_cell_to_follow_its_column() {
4172 let proxy = SortFilterTreeModel::new(sample_tree());
4173 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4174 let id = tree.add(
4175 TreeTableView::from_projection(proxy)
4176 .add_column(name_col())
4177 .add_column(size_col())
4178 .row_height(20.0),
4179 );
4180 tree.layout(SizeProposal {
4181 width: Some(400.0),
4182 height: Some(200.0),
4183 });
4184 tree.focus(id);
4185 {
4186 let any = tree.widget_as_any(id).unwrap();
4187 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4188 tt.set_focused_cell(0, 1); tt.set_column_pinning("size", PinnedSide::Leading);
4193 }
4194 tree.layout(SizeProposal {
4195 width: Some(400.0),
4196 height: Some(200.0),
4197 });
4198 let any = tree.widget_as_any(id).unwrap();
4199 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4200 assert_eq!(
4201 tt.focused_cell_signal().get(),
4202 Some((0, 0)),
4203 "focus must follow `size` to its new display position"
4204 );
4205 }
4206
4207 #[test]
4208 fn column_pinning_remaps_editing_cell_to_follow_its_column() {
4209 let proxy = SortFilterTreeModel::new(sample_tree());
4210 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4211 let id = tree.add(
4212 TreeTableView::from_projection(proxy)
4213 .add_column(name_col())
4214 .add_column(size_col())
4215 .row_height(20.0),
4216 );
4217 tree.layout(SizeProposal {
4218 width: Some(400.0),
4219 height: Some(200.0),
4220 });
4221 {
4222 let any = tree.widget_as_any(id).unwrap();
4223 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4224 tt.begin_edit(0, "size"); assert_eq!(tt.editing_cell_signal().get(), Some((0, 1)));
4226 tt.set_column_pinning("size", PinnedSide::Leading);
4227 }
4228 tree.layout(SizeProposal {
4229 width: Some(400.0),
4230 height: Some(200.0),
4231 });
4232 let any = tree.widget_as_any(id).unwrap();
4233 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4234 assert_eq!(
4235 tt.editing_cell_signal().get(),
4236 Some((0, 0)),
4237 "the open editor must follow `size` to its new display \
4238 position, not relabel onto whatever column now sits at \
4239 position 1"
4240 );
4241 }
4242
4243 #[test]
4244 fn column_pinning_remaps_cell_selection_to_follow_its_column() {
4245 let proxy = SortFilterTreeModel::new(sample_tree());
4246 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4247 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4248 let id = tree.add(
4249 TreeTableView::from_projection(proxy)
4250 .add_column(name_col())
4251 .add_column(size_col())
4252 .row_height(20.0)
4253 .selection_mode(TableSelectionMode::MultiCell)
4254 .cell_selection(cs.clone()),
4255 );
4256 tree.layout(SizeProposal {
4257 width: Some(400.0),
4258 height: Some(200.0),
4259 });
4260 cs.select(0, 1); {
4262 let any = tree.widget_as_any(id).unwrap();
4263 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4264 tt.set_column_pinning("size", PinnedSide::Leading);
4265 }
4266 tree.layout(SizeProposal {
4267 width: Some(400.0),
4268 height: Some(200.0),
4269 });
4270 assert!(
4271 cs.is_selected(0, 0),
4272 "selection must follow `size` to its new display position"
4273 );
4274 assert!(!cs.is_selected(0, 1));
4275 }
4276
4277 #[test]
4278 fn collapsing_a_node_above_a_selected_cell_clears_stale_cell_selection() {
4279 let proxy = SortFilterTreeModel::new(sample_tree());
4285 let docs = proxy.tree().root(0);
4286 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4287 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4288 let id = tree.add(
4289 TreeTableView::from_projection(proxy.clone())
4290 .add_column(name_col())
4291 .row_height(20.0)
4292 .selection_mode(TableSelectionMode::MultiCell)
4293 .cell_selection(cs.clone()),
4294 );
4295 tree.layout(SizeProposal {
4296 width: Some(400.0),
4297 height: Some(200.0),
4298 });
4299 {
4300 let any = tree.widget_as_any(id).unwrap();
4301 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4302 tt.expand(docs);
4303 }
4304 tree.layout(SizeProposal {
4305 width: Some(400.0),
4306 height: Some(200.0),
4307 });
4308 assert_eq!(proxy.visible_count(), 4); cs.select(3, 0); {
4311 let any = tree.widget_as_any(id).unwrap();
4312 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4313 tt.collapse(docs);
4314 }
4315 tree.layout(SizeProposal {
4316 width: Some(400.0),
4317 height: Some(200.0),
4318 });
4319 assert_eq!(proxy.visible_count(), 2); assert_eq!(
4321 cs.count(),
4322 0,
4323 "a stale (row, col) surviving the collapse must be dropped, not \
4324 silently point at whatever node now sits at flat row 3"
4325 );
4326 }
4327
4328 #[test]
4329 fn content_only_update_leaves_cell_selection_untouched() {
4330 let model = sample_tree();
4334 let proxy = SortFilterTreeModel::new(model);
4335 let docs = proxy.tree().root(0);
4336 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4337 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4338 tree.add(
4339 TreeTableView::from_projection(proxy.clone())
4340 .add_column(name_col())
4341 .row_height(20.0)
4342 .selection_mode(TableSelectionMode::MultiCell)
4343 .cell_selection(cs.clone()),
4344 );
4345 tree.layout(SizeProposal {
4346 width: Some(400.0),
4347 height: Some(200.0),
4348 });
4349 cs.select(0, 0); proxy.tree().update(docs, "docs-renamed");
4352 tree.layout(SizeProposal {
4353 width: Some(400.0),
4354 height: Some(200.0),
4355 });
4356 assert!(
4357 cs.is_selected(0, 0),
4358 "a content-only update must leave an unrelated selection alone"
4359 );
4360 }
4361
4362 #[test]
4365 fn focused_cell_sets_active_descendant_to_the_cell_node() {
4366 let proxy = SortFilterTreeModel::new(sample_tree());
4367 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4368 let id = tree.add(
4369 TreeTableView::from_projection(proxy)
4370 .add_column(name_col())
4371 .add_column(size_col())
4372 .row_height(20.0),
4373 );
4374 tree.layout(SizeProposal {
4375 width: Some(400.0),
4376 height: Some(200.0),
4377 });
4378 tree.focus(id);
4379 {
4380 let any = tree.widget_as_any(id).unwrap();
4381 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4382 tt.set_focused_cell(0, 1);
4383 }
4384 let update = tree.sync_accessibility();
4385 let root_node_id = widget_id_to_node_id(id);
4386 let root_node = update
4387 .nodes
4388 .iter()
4389 .find(|(nid, _)| *nid == root_node_id)
4390 .map(|(_, n)| n)
4391 .expect("root node present in the AT tree");
4392 let active = root_node
4393 .active_descendant()
4394 .expect("a focused cell must set active_descendant");
4395 let cell_node = update
4396 .nodes
4397 .iter()
4398 .find(|(nid, _)| *nid == active)
4399 .map(|(_, n)| n)
4400 .expect("active_descendant must reference a node present in the TreeUpdate");
4401 assert_eq!(cell_node.role(), Role::Cell);
4402 }
4403
4404 #[test]
4405 fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
4406 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4407 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4408 let id = tree.add(
4409 TreeTableView::from_projection(proxy)
4410 .add_column(name_col())
4411 .row_height(20.0),
4412 );
4413 tree.layout(SizeProposal {
4414 width: Some(400.0),
4415 height: Some(200.0),
4416 });
4417 tree.focus(id);
4418 {
4419 let any = tree.widget_as_any(id).unwrap();
4420 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4421 tt.set_focused_cell(1, 0);
4422 }
4423 let root_node_id = widget_id_to_node_id(id);
4424 let update = tree.sync_accessibility();
4425 let active_before = update
4426 .nodes
4427 .iter()
4428 .find(|(nid, _)| *nid == root_node_id)
4429 .and_then(|(_, n)| n.active_descendant());
4430 assert!(active_before.is_some(), "row 1 is realized initially");
4431
4432 let signal = {
4437 let any = tree.widget_as_any(id).unwrap();
4438 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4439 tt.scroll_y_signal().clone()
4440 };
4441 signal.set(2000.0);
4442 tree.request_frame();
4443 tree.layout(SizeProposal {
4444 width: Some(400.0),
4445 height: Some(200.0),
4446 });
4447
4448 let update = tree.sync_accessibility();
4449 let active_after = update
4450 .nodes
4451 .iter()
4452 .find(|(nid, _)| *nid == root_node_id)
4453 .and_then(|(_, n)| n.active_descendant());
4454 assert_eq!(
4455 active_after, None,
4456 "a focused cell that scrolled out of realization must not leave \
4457 a stale active_descendant pointing at a destroyed node"
4458 );
4459 }
4460
4461 #[test]
4474 fn taking_focus_reveals_the_cursor_row() {
4475 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4476 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4477 let id = tree.add(
4478 TreeTableView::from_projection(proxy)
4479 .add_column(name_col())
4480 .row_height(20.0),
4481 );
4482 let viewport = SizeProposal {
4483 width: Some(400.0),
4484 height: Some(200.0),
4485 };
4486 tree.layout(viewport);
4487
4488 {
4492 let any = tree.widget_as_any(id).unwrap();
4493 any.downcast_ref::<TreeTableView<&'static str>>()
4494 .unwrap()
4495 .set_focused_cell(500, 0);
4496 }
4497 tree.request_frame();
4498 tree.layout(viewport);
4499
4500 let root_node_id = widget_id_to_node_id(id);
4501 let nominated = |tree: &mut WidgetTree| -> Option<(Role, Option<usize>)> {
4505 let update = tree.sync_accessibility();
4506 let active = update
4507 .nodes
4508 .iter()
4509 .find(|(nid, _)| *nid == root_node_id)
4510 .and_then(|(_, n)| n.active_descendant())?;
4511 update
4512 .nodes
4513 .iter()
4514 .find(|(nid, _)| *nid == active)
4515 .map(|(_, n)| (n.role(), n.row_index()))
4516 };
4517
4518 assert_eq!(
4519 nominated(&mut tree),
4520 None,
4521 "row 500 starts far outside the realized window, which is the case \
4522 this is about"
4523 );
4524
4525 tree.focus(id);
4526 tree.layout(viewport);
4527
4528 assert_eq!(
4529 nominated(&mut tree),
4530 Some((Role::Cell, Some(501))),
4533 "taking focus has to bring the cursor's row into the realized \
4534 window, or nothing in the tree can be told about it"
4535 );
4536 }
4537
4538 #[test]
4547 fn taking_focus_reveals_the_selected_row_when_no_cell_has_been_navigated_to() {
4548 use teksilo_data::{SelectionMode, SelectionModel};
4549
4550 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4551 let sel = SelectionModel::new(SelectionMode::Single);
4552 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4553 let id = tree.add(
4554 TreeTableView::from_projection(proxy)
4555 .add_column(name_col())
4556 .row_height(20.0)
4557 .selection_mode(TableSelectionMode::SingleRow)
4558 .selection(sel.clone()),
4559 );
4560 let viewport = SizeProposal {
4561 width: Some(400.0),
4562 height: Some(200.0),
4563 };
4564 tree.layout(viewport);
4565
4566 sel.select(500);
4567 tree.request_frame();
4568 tree.layout(viewport);
4569
4570 let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
4571 tree.sync_accessibility()
4572 .nodes
4573 .iter()
4574 .filter(|(_, n)| n.role() == Role::Row && n.is_selected() == Some(true))
4575 .filter_map(|(_, n)| n.row_index())
4576 .collect()
4577 };
4578
4579 assert!(
4580 selected_rows(&mut tree).is_empty(),
4581 "row 500 starts far outside the realized window, which is the case \
4582 this is about"
4583 );
4584
4585 tree.focus(id);
4586 tree.layout(viewport);
4587
4588 assert_eq!(
4589 selected_rows(&mut tree),
4590 vec![501],
4591 "taking focus has to realize the selected row, or there is no node \
4592 carrying `selected` for a screen reader to find"
4593 );
4594 }
4595
4596 #[test]
4618 fn the_revealed_row_is_a_visible_index_not_a_position_in_the_unflattened_tree() {
4619 use teksilo_core::accessibility::node_id_to_widget_id;
4620
4621 let model = TreeModel::new();
4622 let mut to_collapse = Vec::new();
4623 let mut needle = None;
4624 for r in 0..40usize {
4625 let root = model.insert_root(r, "root");
4626 if r < 20 {
4627 to_collapse.push(root);
4628 }
4629 for c in 0..9usize {
4630 let label = if r == 30 && c == 4 { "needle" } else { "leaf" };
4631 let child = model.insert_child(root, c, label);
4632 if r == 30 && c == 4 {
4633 needle = Some(child);
4634 }
4635 }
4636 }
4637 let needle = needle.expect("the needle inserted");
4638
4639 let proxy = SortFilterTreeModel::new(model);
4640 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4641 let id = tree.add(
4642 TreeTableView::from_projection(proxy.clone())
4643 .add_column(name_col())
4644 .row_height(20.0),
4645 );
4646 let viewport = SizeProposal {
4647 width: Some(400.0),
4648 height: Some(200.0),
4649 };
4650 tree.layout(viewport);
4651 {
4652 let any = tree.widget_as_any(id).unwrap();
4653 any.downcast_ref::<TreeTableView<&'static str>>()
4654 .unwrap()
4655 .expand_all();
4656 }
4657 tree.layout(viewport);
4658
4659 let flat_of = |node| {
4660 (0..proxy.visible_count())
4661 .find(|&i| proxy.visible_node_id(i) == Some(node))
4662 .expect("the node is visible")
4663 };
4664 let flat_expanded = flat_of(needle);
4665
4666 {
4667 let any = tree.widget_as_any(id).unwrap();
4668 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4669 for root in to_collapse {
4670 tt.collapse(root);
4671 }
4672 }
4673 tree.layout(viewport);
4674
4675 let flat = flat_of(needle);
4676 assert_eq!(
4677 flat,
4678 flat_expanded - 180,
4679 "collapsing the first twenty roots has to move the needle up the \
4680 flat order without moving it in the tree, or this test proves \
4681 nothing"
4682 );
4683
4684 {
4685 let any = tree.widget_as_any(id).unwrap();
4686 any.downcast_ref::<TreeTableView<&'static str>>()
4687 .unwrap()
4688 .set_focused_cell(flat, 0);
4689 }
4690 tree.request_frame();
4691 tree.layout(viewport);
4692 tree.focus(id);
4693 tree.layout(viewport);
4694
4695 let root_node_id = widget_id_to_node_id(id);
4696 let update = tree.sync_accessibility();
4697 let active = update
4698 .nodes
4699 .iter()
4700 .find(|(nid, _)| *nid == root_node_id)
4701 .and_then(|(_, n)| n.active_descendant())
4702 .expect(
4703 "taking focus has to bring the cursor's row into the realized \
4704 window, or nothing in the tree can be told about it",
4705 );
4706 let cell = update
4707 .nodes
4708 .iter()
4709 .find(|(nid, _)| *nid == active)
4710 .map(|(_, n)| n)
4711 .expect("active_descendant must reference a node in the TreeUpdate");
4712 assert_eq!(cell.row_index(), Some(flat + 1));
4713
4714 let mut q = vec![node_id_to_widget_id(active)];
4717 let mut names = Vec::new();
4718 while let Some(w) = q.pop() {
4719 if let Some(name) = tree.accessibility_node(w).name() {
4720 names.push(name.to_string());
4721 }
4722 for c in tree.children(w) {
4723 q.push(c);
4724 }
4725 }
4726 assert!(
4727 names.iter().any(|n| n == "needle"),
4728 "the revealed row must be the node the cursor was put on, got {names:?}"
4729 );
4730 }
4731
4732 #[test]
4733 fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
4734 use std::cell::RefCell;
4741 use std::ops::Range;
4742 use teksilo_data::{FlatEntry, RowState};
4743
4744 struct Windowed {
4745 total: usize,
4746 requested: Rc<RefCell<Vec<Range<usize>>>>,
4747 version: Signal<u64>,
4748 }
4749 impl TreeDataSource for Windowed {
4750 type Item = &'static str;
4751 type Key = usize;
4752 fn visible_count(&self) -> usize {
4753 self.total
4754 }
4755 fn with_entry<R>(
4756 &self,
4757 _i: usize,
4758 _f: impl FnOnce(&&'static str, &FlatEntry<usize>) -> R,
4759 ) -> Option<R> {
4760 None }
4762 fn key_at(&self, i: usize) -> Option<usize> {
4763 (i < self.total).then_some(i)
4764 }
4765 fn flat_index_of(&self, key: &usize) -> Option<usize> {
4766 (*key < self.total).then_some(*key)
4767 }
4768 fn parent(&self, _key: &usize) -> Option<usize> {
4769 None
4770 }
4771 fn child_keys(&self, _key: &usize) -> Vec<usize> {
4772 vec![]
4773 }
4774 fn version_signal(&self) -> Signal<u64> {
4775 self.version.clone()
4776 }
4777 fn is_expanded(&self, _key: &usize) -> bool {
4778 false
4779 }
4780 fn set_expanded(&self, _key: &usize, _expanded: bool) {}
4781 fn row_state(&self, _flat_index: usize) -> RowState {
4782 RowState::Loading
4783 }
4784 fn request_window(&self, range: Range<usize>) {
4785 self.requested.borrow_mut().push(range);
4786 }
4787 }
4788
4789 let requested = Rc::new(RefCell::new(Vec::new()));
4790 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4791 let id = tree.add(
4792 TreeTableView::from_source(Windowed {
4793 total: 1000,
4794 requested: requested.clone(),
4795 version: Signal::new(0),
4796 })
4797 .add_column(name_col())
4798 .show_header(false)
4799 .row_height(30.0),
4800 );
4801 tree.layout(SizeProposal {
4802 width: Some(400.0),
4803 height: Some(300.0),
4804 });
4805
4806 let body_pane = tree.children(id)[0];
4810 let placeholder_rows = tree.children(body_pane).len();
4811 assert!(
4812 placeholder_rows >= 10,
4813 "loading rows must render as placeholders, got {placeholder_rows}"
4814 );
4815 assert!(
4817 !requested.borrow().is_empty(),
4818 "request_window must be called for the visible range"
4819 );
4820 }
4821
4822 #[test]
4823 fn arrow_expand_collapse_follows_a_non_leading_tree_column() {
4824 use teksilo_core::event::{Key, Modifiers};
4829 let proxy = SortFilterTreeModel::new(sample_tree());
4830 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4831 let id = tree.add(
4832 TreeTableView::from_projection(proxy.clone())
4833 .add_column(name_col())
4834 .add_column(size_col())
4835 .tree_column("size")
4836 .row_height(20.0),
4837 );
4838 tree.layout(SizeProposal {
4839 width: Some(400.0),
4840 height: Some(200.0),
4841 });
4842 tree.focus(id);
4843
4844 {
4847 let any = tree.widget_as_any(id).unwrap();
4848 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4849 tt.set_focused_cell(0, 0);
4850 }
4851 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4852 assert_eq!(
4853 proxy.visible_count(),
4854 2,
4855 "ArrowRight off the tree column must not expand"
4856 );
4857
4858 {
4860 let any = tree.widget_as_any(id).unwrap();
4861 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4862 tt.set_focused_cell(0, 1);
4863 }
4864 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4865 assert_eq!(
4866 proxy.visible_count(),
4867 4,
4868 "docs expands to reveal 2 children"
4869 );
4870 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
4871 assert_eq!(proxy.visible_count(), 2, "docs collapses again");
4872 }
4873
4874 #[test]
4875 fn arrow_nav_scroll_follows_focused_row() {
4876 use teksilo_core::event::{Key, Modifiers};
4882 let proxy = SortFilterTreeModel::new(wide_tree(100));
4883 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4884 let id = tree.add(
4885 TreeTableView::from_projection(proxy)
4886 .add_column(name_col())
4887 .row_height(20.0),
4888 );
4889 let proposal = SizeProposal {
4890 width: Some(400.0),
4891 height: Some(200.0),
4892 };
4893 tree.layout(proposal);
4894 tree.focus(id);
4895 let read_scroll = |tree: &WidgetTree| {
4896 let any = tree.widget_as_any(id).unwrap();
4897 any.downcast_ref::<TreeTableView<&'static str>>()
4898 .unwrap()
4899 .scroll_y_signal()
4900 .get()
4901 };
4902 let read_focus = |tree: &WidgetTree| {
4903 let any = tree.widget_as_any(id).unwrap();
4904 any.downcast_ref::<TreeTableView<&'static str>>()
4905 .unwrap()
4906 .focused_cell_signal()
4907 .get()
4908 };
4909 {
4910 let any = tree.widget_as_any(id).unwrap();
4911 any.downcast_ref::<TreeTableView<&'static str>>()
4912 .unwrap()
4913 .set_focused_cell(0, 0);
4914 }
4915 assert_eq!(read_scroll(&tree), 0.0, "starts at top");
4916
4917 for _ in 0..20 {
4918 tree.press_key(Key::ArrowDown, Modifiers::NONE);
4919 tree.layout(proposal);
4920 }
4921 assert_eq!(read_focus(&tree), Some((20, 0)));
4922 assert!(
4923 read_scroll(&tree) > 200.0,
4924 "arrow-down nav must scroll to reveal row 20, got {}",
4925 read_scroll(&tree)
4926 );
4927
4928 tree.press_key(Key::Home, Modifiers::COMMAND);
4930 tree.layout(proposal);
4931 assert_eq!(read_focus(&tree), Some((0, 0)));
4932 assert_eq!(read_scroll(&tree), 0.0, "Ctrl+Home scrolls to top");
4933 }
4934
4935 #[test]
4936 fn type_ahead_jumps_to_matching_row() {
4937 use teksilo_core::event::{Key, Modifiers};
4938 let model = TreeModel::new();
4939 model.insert_root(0, "Apple");
4940 model.insert_root(1, "Banana");
4941 model.insert_root(2, "Cherry");
4942 model.insert_root(3, "Cranberry");
4943 let proxy = SortFilterTreeModel::new(model);
4944 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4945 let id = tree.add(
4946 TreeTableView::from_projection(proxy)
4947 .add_column(name_col())
4948 .row_height(20.0)
4949 .type_ahead_label(|s: &&'static str| s.to_string()),
4950 );
4951 tree.layout(SizeProposal {
4952 width: Some(400.0),
4953 height: Some(200.0),
4954 });
4955 tree.focus(id);
4956 let read_focus = |tree: &WidgetTree| {
4957 let any = tree.widget_as_any(id).unwrap();
4958 any.downcast_ref::<TreeTableView<&'static str>>()
4959 .unwrap()
4960 .focused_cell_signal()
4961 .get()
4962 };
4963 {
4964 let any = tree.widget_as_any(id).unwrap();
4965 any.downcast_ref::<TreeTableView<&'static str>>()
4966 .unwrap()
4967 .set_focused_cell(0, 0);
4968 }
4969 tree.press_key(Key::C, Modifiers::NONE);
4970 assert_eq!(read_focus(&tree), Some((2, 0)), "'c' → Cherry");
4971 tree.press_key(Key::R, Modifiers::NONE);
4972 assert_eq!(read_focus(&tree), Some((3, 0)), "'cr' → Cranberry");
4973 }
4974
4975 #[test]
4976 fn ctrl_tab_escapes_the_cell_grid() {
4977 use crate::primitives::{TextWidget, VStack};
4978 use teksilo_core::event::{Key, Modifiers};
4979 use teksilo_core::widget_builder::WidgetBuilder;
4980
4981 let proxy = SortFilterTreeModel::new(wide_tree(5));
4982 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4983 let id = tree.add(
4984 TreeTableView::from_projection(proxy)
4985 .add_column(name_col())
4986 .row_height(20.0),
4987 );
4988 let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
4989 let _root = tree.add(VStack::new().add_child(id).add_child(sink));
4990 tree.layout(SizeProposal {
4991 width: Some(400.0),
4992 height: Some(200.0),
4993 });
4994 let read_focus = |tree: &WidgetTree| {
4995 let any = tree.widget_as_any(id).unwrap();
4996 any.downcast_ref::<TreeTableView<&'static str>>()
4997 .unwrap()
4998 .focused_cell_signal()
4999 .get()
5000 };
5001 tree.focus(id);
5002 {
5003 let any = tree.widget_as_any(id).unwrap();
5004 any.downcast_ref::<TreeTableView<&'static str>>()
5005 .unwrap()
5006 .set_focused_cell(0, 0);
5007 }
5008 let before = read_focus(&tree);
5009 tree.press_key(Key::Tab, Modifiers::CTRL);
5010 assert_eq!(
5011 read_focus(&tree),
5012 before,
5013 "Ctrl+Tab must not navigate cells"
5014 );
5015 assert_eq!(
5016 tree.focused(),
5017 Some(sink),
5018 "Ctrl+Tab moves focus out of the tree-table"
5019 );
5020 }
5021
5022 #[test]
5023 fn rows_carry_role_row_with_level_indicator() {
5024 let proxy = SortFilterTreeModel::new(sample_tree());
5025 let docs = proxy.tree().root(0);
5026 proxy.expand(docs);
5027 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5028 let id = tree.add(
5029 TreeTableView::from_projection(proxy)
5030 .add_column(name_col())
5031 .row_height(20.0),
5032 );
5033 tree.layout(SizeProposal {
5034 width: Some(400.0),
5035 height: Some(200.0),
5036 });
5037 let mut q = vec![id];
5039 let mut row_count = 0;
5040 while let Some(n) = q.pop() {
5041 if tree.accessibility_node(n).role() == Role::Row {
5042 row_count += 1;
5043 }
5044 for c in tree.children(n) {
5045 q.push(c);
5046 }
5047 }
5048 assert!(
5050 row_count >= 5,
5051 "expected at least 5 Role::Row nodes, got {row_count}"
5052 );
5053 }
5054
5055 #[test]
5056 fn filter_mode_keep_ancestors_works_via_proxy() {
5057 let proxy = SortFilterTreeModel::new(sample_tree())
5058 .filter_mode(TreeFilterMode::KeepAncestors)
5059 .with_predicate("name", |t| {
5060 let needle = t.to_string();
5061 Box::new(move |row: &&str| row.contains(&needle))
5062 });
5063 proxy.expand_all();
5064 proxy.set_filter("name", "main");
5065 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5066 let _id = tree.add(
5067 TreeTableView::from_projection(proxy.clone())
5068 .add_column(name_col())
5069 .row_height(20.0),
5070 );
5071 tree.layout(SizeProposal {
5072 width: Some(400.0),
5073 height: Some(200.0),
5074 });
5075 assert_eq!(proxy.visible_count(), 2);
5077 }
5078
5079 #[test]
5080 fn collapse_all_then_expand_all_round_trips() {
5081 let proxy = SortFilterTreeModel::new(sample_tree());
5082 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5083 let id = tree.add(
5084 TreeTableView::from_projection(proxy.clone())
5085 .add_column(name_col())
5086 .row_height(20.0),
5087 );
5088 tree.layout(SizeProposal {
5089 width: Some(400.0),
5090 height: Some(200.0),
5091 });
5092 {
5093 let any = tree.widget_as_any(id).unwrap();
5094 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5095 tt.expand_all();
5096 }
5097 assert_eq!(proxy.visible_count(), 5);
5098 {
5099 let any = tree.widget_as_any(id).unwrap();
5100 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5101 tt.collapse_all();
5102 }
5103 assert_eq!(proxy.visible_count(), 2);
5104 }
5105
5106 #[test]
5107 fn rows_report_sibling_position_and_size_among_siblings() {
5108 let proxy = SortFilterTreeModel::new(sample_tree());
5116 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5117 let id = tree.add(
5118 TreeTableView::from_projection(proxy.clone())
5119 .add_column(name_col())
5120 .row_height(20.0),
5121 );
5122 tree.layout(SizeProposal {
5123 width: Some(400.0),
5124 height: Some(400.0),
5125 });
5126 {
5127 let any = tree.widget_as_any(id).unwrap();
5128 any.downcast_ref::<TreeTableView<&'static str>>()
5129 .unwrap()
5130 .expand_all();
5131 }
5132 tree.layout(SizeProposal {
5133 width: Some(400.0),
5134 height: Some(400.0),
5135 });
5136 assert_eq!(proxy.visible_count(), 5);
5137
5138 let mut rows: Vec<WidgetId> = Vec::new();
5143 let mut q = vec![id];
5144 while let Some(n) = q.pop() {
5145 if tree.accessibility_node(n).role() == Role::Row {
5146 rows.push(n);
5147 }
5148 for c in tree.children(n) {
5149 q.push(c);
5150 }
5151 }
5152 rows.sort_by(|a, b| tree.bounds(*a).y.partial_cmp(&tree.bounds(*b).y).unwrap());
5153 assert_eq!(rows.len(), 6, "header + five body rows");
5154 let body_rows = &rows[1..];
5155
5156 let update = tree.sync_accessibility();
5160 let find = |wid: WidgetId| -> &teksilo_core::accesskit::Node {
5161 let nid = widget_id_to_node_id(wid);
5162 update
5163 .nodes
5164 .iter()
5165 .find(|(n, _)| *n == nid)
5166 .map(|(_, n)| n)
5167 .expect("row must be in the a11y tree")
5168 };
5169 let positions: Vec<usize> = body_rows
5170 .iter()
5171 .map(|&r| find(r).position_in_set().expect("position_in_set"))
5172 .collect();
5173 assert_eq!(
5177 positions,
5178 vec![0, 0, 1, 1, 0],
5179 "docs(1st) readme(1st) guide(2nd) src(2nd) main.rs(1st)"
5180 );
5181 for &r in body_rows {
5186 assert_eq!(
5187 find(r).size_of_set(),
5188 None,
5189 "a per-sibling count is unrepresentable and must not be faked"
5190 );
5191 }
5192 }
5193
5194 #[test]
5195 fn row_count_in_a11y_includes_header() {
5196 let proxy = SortFilterTreeModel::new(sample_tree());
5197 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5198 let id = tree.add(
5199 TreeTableView::from_projection(proxy)
5200 .add_column(name_col())
5201 .add_column(size_col())
5202 .row_height(20.0),
5203 );
5204 tree.layout(SizeProposal {
5205 width: Some(400.0),
5206 height: Some(200.0),
5207 });
5208 let info = tree.accessibility_node(id);
5209 assert_eq!(info.role(), Role::TreeGrid);
5210 let mut q = vec![id];
5214 let mut rows = 0;
5215 while let Some(n) = q.pop() {
5216 if tree.accessibility_node(n).role() == Role::Row {
5217 rows += 1;
5218 }
5219 for c in tree.children(n) {
5220 q.push(c);
5221 }
5222 }
5223 assert_eq!(rows, 3); }
5225
5226 fn wide_tree(n: u32) -> TreeModel<&'static str> {
5230 let t = TreeModel::new();
5231 for i in 0..n {
5232 t.insert_root(i as usize, "node");
5233 }
5234 t
5235 }
5236
5237 fn row_bounds(tree: &WidgetTree, root: WidgetId) -> Vec<teksilo_canvas::Rect> {
5239 let mut q = vec![root];
5240 let mut out = Vec::new();
5241 while let Some(n) = q.pop() {
5242 if tree.accessibility_node(n).role() == Role::Row {
5243 out.push(tree.bounds(n));
5244 }
5245 for c in tree.children(n) {
5246 q.push(c);
5247 }
5248 }
5249 out
5250 }
5251
5252 #[test]
5253 fn rtl_swaps_tree_expand_collapse_keys() {
5254 use teksilo_core::environment::LayoutDirection;
5255 use teksilo_core::event::{Key, Modifiers};
5256
5257 let proxy = SortFilterTreeModel::new(sample_tree());
5258 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5259 let table = tree.add(
5260 TreeTableView::from_projection(proxy.clone())
5261 .add_column(name_col())
5262 .row_height(20.0),
5263 );
5264 tree.layout(SizeProposal {
5265 width: Some(400.0),
5266 height: Some(200.0),
5267 });
5268 assert_eq!(proxy.visible_count(), 2);
5270
5271 tree.set_layout_direction(LayoutDirection::RightToLeft);
5272 tree.focus(table);
5273 {
5274 let any = tree.widget_as_any(table).unwrap();
5275 any.downcast_ref::<TreeTableView<&'static str>>()
5276 .unwrap()
5277 .set_focused_cell(0, 0);
5278 }
5279
5280 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5283 assert_eq!(
5284 proxy.visible_count(),
5285 4,
5286 "RTL ArrowLeft on the tree column should expand docs"
5287 );
5288 tree.press_key(Key::ArrowRight, Modifiers::NONE);
5289 assert_eq!(
5290 proxy.visible_count(),
5291 2,
5292 "RTL ArrowRight on the tree column should collapse docs"
5293 );
5294 }
5295
5296 #[test]
5297 fn rtl_tree_band_shifts_for_left_scrollbar() {
5298 use teksilo_core::environment::LayoutDirection;
5299 let proxy = SortFilterTreeModel::new(wide_tree(50));
5303 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5304 let table = tree.add(
5305 TreeTableView::from_projection(proxy)
5306 .add_column(name_col())
5307 .row_height(20.0),
5308 );
5309 tree.layout(SizeProposal {
5310 width: Some(400.0),
5311 height: Some(200.0),
5312 });
5313 tree.set_layout_direction(LayoutDirection::RightToLeft);
5314 tree.layout(SizeProposal {
5315 width: Some(400.0),
5316 height: Some(200.0),
5317 });
5318
5319 let table_bounds = tree.bounds(table);
5320 let body_row = row_bounds(&tree, table)
5322 .into_iter()
5323 .filter(|r| r.y > table_bounds.y + 5.0)
5324 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5325 .expect("a body row");
5326 assert!(
5327 (body_row.x - SCROLLBAR_THICKNESS).abs() < 0.5,
5328 "RTL body row should start at SCROLLBAR_THICKNESS, got x={}",
5329 body_row.x
5330 );
5331 let mut tree2 = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5333 let proxy2 = SortFilterTreeModel::new(wide_tree(50));
5334 let table2 = tree2.add(
5335 TreeTableView::from_projection(proxy2)
5336 .add_column(name_col())
5337 .row_height(20.0),
5338 );
5339 tree2.layout(SizeProposal {
5340 width: Some(400.0),
5341 height: Some(200.0),
5342 });
5343 let tb2 = tree2.bounds(table2);
5344 let body_row2 = row_bounds(&tree2, table2)
5345 .into_iter()
5346 .filter(|r| r.y > tb2.y + 5.0)
5347 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5348 .expect("a body row");
5349 assert!(body_row2.x.abs() < 0.5, "LTR body row x={}", body_row2.x);
5350 }
5351
5352 fn nested_tree_table_fixture(
5358 inner: OverscrollBehavior,
5359 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
5360 use crate::ScrollArea;
5361 use crate::primitives::{FixedSize, TextWidget, VStack};
5362 let model = TreeModel::new();
5363 for i in 0..40 {
5364 model.insert_root(i, "row");
5365 }
5366 let proxy = SortFilterTreeModel::new(model);
5367 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5368 let tt = TreeTableView::from_projection(proxy)
5369 .add_column(name_col())
5370 .show_header(false)
5371 .row_height(20.0)
5372 .overscroll_behavior(inner);
5373 let inner_y = tt.scroll_y_signal().clone();
5374 let tt_id = tree.add(tt);
5375 let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tt_id));
5376 let filler = tree.add(
5377 FixedSize::new()
5378 .width(220.0)
5379 .height(300.0)
5380 .child(TextWidget::new(lit!(""))),
5381 );
5382 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
5383 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
5384 let outer_y = outer.scroll_y_signal().clone();
5385 let _outer = tree.add(outer);
5386 tree.layout(SizeProposal {
5387 width: Some(220.0),
5388 height: Some(150.0),
5389 });
5390 (tree, inner_y, outer_y)
5391 }
5392
5393 #[test]
5394 fn nested_tree_table_chains_to_outer_at_boundary() {
5395 use teksilo_canvas::Point;
5396 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5397 let (mut tree, inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Chain);
5398 tree.pointer_move(Point::new(50.0, 40.0));
5399 tree.dispatch_event(WidgetEvent::Scroll {
5400 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5401 modifiers: Modifiers::NONE,
5402 });
5403 tree.layout(SizeProposal {
5404 width: Some(220.0),
5405 height: Some(150.0),
5406 });
5407 let inner_bottom = inner_y.get();
5408 assert!(
5409 inner_bottom > 0.0,
5410 "inner tree-table should scroll down; got {inner_bottom}"
5411 );
5412 tree.pointer_move(Point::new(50.0, 40.0));
5414 tree.dispatch_event(WidgetEvent::Scroll {
5415 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5416 modifiers: Modifiers::NONE,
5417 });
5418 tree.layout(SizeProposal {
5419 width: Some(220.0),
5420 height: Some(150.0),
5421 });
5422 assert!(
5423 (inner_y.get() - inner_bottom).abs() < 0.01,
5424 "inner stays clamped at bottom"
5425 );
5426 assert!(
5427 outer_y.get() > 0.01,
5428 "outer must scroll because the inner chained the boundary"
5429 );
5430 }
5431
5432 #[test]
5433 fn nested_tree_table_contain_blocks_chaining() {
5434 use teksilo_canvas::Point;
5435 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5436 let (mut tree, _inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Contain);
5437 tree.pointer_move(Point::new(50.0, 40.0));
5438 tree.dispatch_event(WidgetEvent::Scroll {
5439 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5440 modifiers: Modifiers::NONE,
5441 });
5442 tree.layout(SizeProposal {
5443 width: Some(220.0),
5444 height: Some(150.0),
5445 });
5446 tree.pointer_move(Point::new(50.0, 40.0));
5447 tree.dispatch_event(WidgetEvent::Scroll {
5448 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5449 modifiers: Modifiers::NONE,
5450 });
5451 tree.layout(SizeProposal {
5452 width: Some(220.0),
5453 height: Some(150.0),
5454 });
5455 assert!(
5456 outer_y.get() < 0.01,
5457 "Contain must prevent chaining: outer stays put"
5458 );
5459 }
5460
5461 fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
5464 let mut walker = vec![root];
5465 let mut n = 0;
5466 while let Some(id) = walker.pop() {
5467 if tree.accessibility_node(id).role() == role {
5468 n += 1;
5469 }
5470 for c in tree.children(id) {
5471 walker.push(c);
5472 }
5473 }
5474 n
5475 }
5476
5477 fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
5480 let mut walker = vec![root];
5481 let mut spans = Vec::new();
5482 while let Some(id) = walker.pop() {
5483 if tree.accessibility_node(id).role() == Role::Row {
5484 let b = tree.bounds(id);
5485 spans.push((b.y, b.height));
5486 }
5487 for c in tree.children(id) {
5488 walker.push(c);
5489 }
5490 }
5491 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5492 spans
5493 }
5494
5495 #[test]
5496 fn rows_rebuild_during_scrollbar_thumb_drag() {
5497 let model = TreeModel::new();
5501 for i in 0..500 {
5502 model.insert_root(i, "root");
5503 }
5504 let proxy = SortFilterTreeModel::new(model);
5505 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5506 let table = tree.add(
5507 TreeTableView::from_projection(proxy.clone())
5508 .add_column(name_col())
5509 .row_height(20.0),
5510 );
5511 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
5512 &mut tree,
5513 table,
5514 400.0,
5515 200.0,
5516 cp::HEADER_HEIGHT,
5517 "TreeTableView",
5518 |t| {
5519 let mut n = 0;
5520 let mut walker = vec![table];
5521 while let Some(id) = walker.pop() {
5522 if t.accessibility_node(id).role() == Role::Row {
5523 let b = t.bounds(id);
5524 if b.y >= 0.0 && b.y < 200.0 {
5525 n += 1;
5526 }
5527 }
5528 for c in t.children(id) {
5529 walker.push(c);
5530 }
5531 }
5532 n
5533 },
5534 );
5535 }
5536
5537 #[test]
5538 fn exact_row_height_fn_positions_tree_rows() {
5539 let heights = [60.0_f32, 20.0, 40.0];
5540 let proxy = SortFilterTreeModel::new(sample_tree());
5541 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5542 let table = tree.add(
5543 TreeTableView::from_projection(proxy)
5544 .add_column(name_col())
5545 .show_header(false)
5546 .row_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
5547 );
5548 tree.layout(SizeProposal {
5549 width: Some(400.0),
5550 height: Some(300.0),
5551 });
5552
5553 let spans = row_spans(&tree, table);
5555 assert_eq!(spans.len(), 2);
5556 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
5557 assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
5558 }
5559
5560 #[test]
5561 fn auto_row_height_measures_tree_cells() {
5562 #[derive(Debug)]
5563 struct FixedLeaf(f32, f32);
5564 impl Widget for FixedLeaf {
5565 fn layout_response(
5566 &self,
5567 _proposal: SizeProposal,
5568 _ctx: &LayoutContext,
5569 ) -> teksilo_core::widget::LayoutResponse {
5570 Size::new(self.0, self.1).into()
5571 }
5572 }
5573 let col = Column::<&str>::new("name", lit!("Name"), |_row, _: &CellContext| {
5574 Box::new(FixedLeaf(50.0, 30.0))
5575 })
5576 .width(ColumnWidth::Flex(1.0));
5577 let proxy = SortFilterTreeModel::new(sample_tree());
5578 let docs = proxy.tree().root(0);
5579 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5580 let table = tree.add(
5581 TreeTableView::from_projection(proxy.clone())
5582 .add_column(col)
5583 .show_header(false)
5584 .auto_row_height(50.0),
5585 );
5586 tree.layout(SizeProposal {
5587 width: Some(400.0),
5588 height: Some(300.0),
5589 });
5590 tree.layout(SizeProposal {
5591 width: Some(400.0),
5592 height: Some(300.0),
5593 });
5594
5595 let spans = row_spans(&tree, table);
5597 assert!(
5598 (spans[1].0 - 30.0).abs() < 0.01,
5599 "row 1 should sit at measured 30, got {}",
5600 spans[1].0
5601 );
5602
5603 proxy.expand(docs);
5607 tree.layout(SizeProposal {
5608 width: Some(400.0),
5609 height: Some(300.0),
5610 });
5611 tree.layout(SizeProposal {
5612 width: Some(400.0),
5613 height: Some(300.0),
5614 });
5615 let spans = row_spans(&tree, table);
5616 assert_eq!(spans.len(), 4); assert!(
5618 (spans[1].0 - 30.0).abs() < 0.01,
5619 "measured row 0 must survive the expand, got {}",
5620 spans[1].0
5621 );
5622 }
5623
5624 fn drag(tree: &mut WidgetTree, from: teksilo_canvas::Point, to: teksilo_canvas::Point) {
5629 use teksilo_canvas::Point;
5630 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
5631 tree.dispatch_event(WidgetEvent::PointerDown {
5632 position: from,
5633 button: PointerButton::Primary,
5634 modifiers: Modifiers::NONE,
5635 });
5636 tree.dispatch_event(WidgetEvent::PointerMove {
5637 position: Point::new(from.x + 10.0, from.y),
5638 });
5639 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
5640 tree.dispatch_event(WidgetEvent::PointerUp {
5641 position: to,
5642 button: PointerButton::Primary,
5643 modifiers: Modifiers::NONE,
5644 });
5645 }
5646
5647 #[test]
5648 fn drag_reorders_roots_after() {
5649 use teksilo_canvas::Point;
5650 let proxy = SortFilterTreeModel::new(sample_tree());
5651 proxy.collapse_all(); let docs = proxy.tree().root(0);
5653 let src = proxy.tree().root(1);
5654 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5655 tree.add(
5656 TreeTableView::from_projection(proxy.clone())
5657 .add_column(name_col())
5658 .reorderable(true)
5659 .row_height(20.0),
5660 );
5661 tree.layout(SizeProposal {
5662 width: Some(400.0),
5663 height: Some(300.0),
5664 });
5665 let h = cp::HEADER_HEIGHT;
5666 drag(
5669 &mut tree,
5670 Point::new(40.0, h + 10.0),
5671 Point::new(40.0, h + 38.0),
5672 );
5673 assert_eq!(proxy.tree().root_count(), 2);
5674 assert_eq!(proxy.tree().root(0), src, "src becomes the first root");
5675 assert_eq!(proxy.tree().root(1), docs, "docs moves after src");
5676 }
5677
5678 #[test]
5679 fn drag_into_own_descendant_is_refused() {
5680 use teksilo_canvas::Point;
5681 let proxy = SortFilterTreeModel::new(sample_tree());
5682 proxy.expand_all(); let docs = proxy.tree().root(0);
5684 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5685 tree.add(
5686 TreeTableView::from_projection(proxy.clone())
5687 .add_column(name_col())
5688 .reorderable(true)
5689 .row_height(20.0),
5690 );
5691 tree.layout(SizeProposal {
5692 width: Some(400.0),
5693 height: Some(300.0),
5694 });
5695 let h = cp::HEADER_HEIGHT;
5696 drag(
5699 &mut tree,
5700 Point::new(40.0, h + 10.0),
5701 Point::new(40.0, h + 30.0),
5702 );
5703 assert_eq!(proxy.tree().parent(docs), None, "docs stays a root");
5704 assert_eq!(proxy.tree().root_count(), 2);
5705 }
5706
5707 #[test]
5708 fn reorder_is_suppressed_while_sorted() {
5709 use teksilo_canvas::Point;
5710 let proxy = SortFilterTreeModel::new(sample_tree());
5711 proxy.collapse_all();
5712 let docs = proxy.tree().root(0);
5713 let src = proxy.tree().root(1);
5714 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5715 let id = tree.add(
5716 TreeTableView::from_projection(proxy.clone())
5717 .add_column(name_col())
5718 .reorderable(true)
5719 .row_height(20.0),
5720 );
5721 tree.layout(SizeProposal {
5722 width: Some(400.0),
5723 height: Some(300.0),
5724 });
5725 tree.widget_as_any(id)
5728 .and_then(|a| a.downcast_ref::<TreeTableView<&str>>())
5729 .expect("TreeTableView")
5730 .set_sort(Some("name"), teksilo_data::SortDirection::Ascending);
5731 let h = cp::HEADER_HEIGHT;
5732 drag(
5733 &mut tree,
5734 Point::new(40.0, h + 10.0),
5735 Point::new(40.0, h + 38.0),
5736 );
5737 assert_eq!(proxy.tree().root(0), docs, "docs unchanged while sorted");
5738 assert_eq!(proxy.tree().root(1), src, "src unchanged while sorted");
5739 }
5740
5741 #[test]
5742 fn keyed_selection_survives_collapse() {
5743 use teksilo_data::{KeyedSelectionModel, SelectionMode};
5748 let proxy = SortFilterTreeModel::new(sample_tree());
5749 proxy.expand_all();
5750 let docs = proxy.tree().root(0);
5751 let readme = proxy.tree().children(docs)[0];
5752 let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
5753 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5754 tree.add(
5755 TreeTableView::from_projection(proxy.clone())
5756 .add_column(name_col())
5757 .selection_mode(TableSelectionMode::MultiRow)
5758 .keyed_selection(keyed.clone())
5759 .row_height(20.0),
5760 );
5761 tree.layout(SizeProposal {
5762 width: Some(400.0),
5763 height: Some(300.0),
5764 });
5765
5766 keyed.select(readme);
5767 assert!(keyed.is_selected(&readme));
5768
5769 proxy.collapse(docs);
5772 assert!(
5773 keyed.is_selected(&readme),
5774 "a collapsed-but-present node stays selected by identity"
5775 );
5776
5777 proxy.expand(docs);
5779 assert!(keyed.is_selected(&readme));
5780 }
5781
5782 fn tt_flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
5798 let mut out = Vec::new();
5799 for id in ids {
5800 if matches!(
5801 tree.accessibility_node(id).role(),
5802 Role::GenericContainer | Role::Unknown
5803 ) {
5804 out.extend(tt_flatten_through_bands(tree, tree.children(id)));
5805 } else {
5806 out.push(id);
5807 }
5808 }
5809 out
5810 }
5811
5812 fn tt_first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5816 let mut walker = vec![root];
5817 while let Some(id) = walker.pop() {
5818 if tree.accessibility_node(id).role() == Role::Row {
5819 let flat = tt_flatten_through_bands(tree, tree.children(id));
5820 if flat
5821 .iter()
5822 .any(|&c| tree.accessibility_node(c).role() == Role::Cell)
5823 {
5824 return id;
5825 }
5826 }
5827 for c in tree.children(id) {
5828 walker.push(c);
5829 }
5830 }
5831 panic!("no body Role::Row found");
5832 }
5833
5834 fn tt_header_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5835 let mut walker = vec![root];
5836 while let Some(id) = walker.pop() {
5837 if tree.accessibility_node(id).role() == Role::Row {
5838 let flat = tt_flatten_through_bands(tree, tree.children(id));
5839 if !flat.is_empty()
5840 && flat
5841 .iter()
5842 .all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
5843 {
5844 return id;
5845 }
5846 }
5847 for c in tree.children(id) {
5848 walker.push(c);
5849 }
5850 }
5851 panic!("no header Role::Row found");
5852 }
5853
5854 fn tt_body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5855 tt_flatten_through_bands(tree, tree.children(tt_first_body_row_id(tree, root)))
5856 }
5857
5858 fn tt_header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5859 tt_flatten_through_bands(tree, tree.children(tt_header_row_id(tree, root)))
5860 }
5861
5862 fn build_tt_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
5866 let proxy = SortFilterTreeModel::new(sample_tree());
5867 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5868 let id = tree.add(
5869 TreeTableView::from_projection(proxy)
5870 .add_column(
5871 Column::<&'static str>::new("lead", lit!("Lead"), |row, _: &CellContext| {
5872 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5873 })
5874 .width(ColumnWidth::Fixed(60.0))
5875 .pinned(PinnedSide::Leading),
5876 )
5877 .add_column(
5878 Column::<&'static str>::new("mid", lit!("Mid"), |row, _: &CellContext| {
5879 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5880 })
5881 .width(ColumnWidth::Fixed(middle_w)),
5882 )
5883 .add_column(
5884 Column::<&'static str>::new("trail", lit!("Trail"), |_row, _: &CellContext| {
5885 Box::new(crate::primitives::TextWidget::new(lit!("x")))
5886 })
5887 .width(ColumnWidth::Fixed(60.0))
5888 .pinned(PinnedSide::Trailing),
5889 )
5890 .row_height(20.0),
5891 );
5892 tree.layout(SizeProposal {
5893 width: Some(table_w),
5894 height: Some(200.0),
5895 });
5896 (tree, id)
5897 }
5898
5899 fn build_tt_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
5901 let proxy = SortFilterTreeModel::new(sample_tree());
5902 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5903 let mut tv = TreeTableView::from_projection(proxy);
5904 for i in 0..n {
5905 let col_id = format!("c{i}");
5906 tv = tv.add_column(
5907 Column::<&'static str>::new(
5908 col_id.clone(),
5909 lit!(col_id.clone()),
5910 |row, _: &CellContext| Box::new(crate::primitives::TextWidget::new(lit!(*row))),
5911 )
5912 .width(ColumnWidth::Fixed(col_w)),
5913 );
5914 }
5915 let id = tree.add(tv.row_height(20.0));
5916 tree.layout(SizeProposal {
5917 width: Some(table_w),
5918 height: Some(200.0),
5919 });
5920 (tree, id)
5921 }
5922
5923 fn tt_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5924 tree.widget_as_any(id)
5925 .unwrap()
5926 .downcast_ref::<TreeTableView<&'static str>>()
5927 .unwrap()
5928 .scroll_x_signal()
5929 .get()
5930 }
5931
5932 fn tt_max_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5933 tree.widget_as_any(id)
5934 .unwrap()
5935 .downcast_ref::<TreeTableView<&'static str>>()
5936 .unwrap()
5937 .max_scroll_x_signal()
5938 .get()
5939 }
5940
5941 fn tt_set_scroll_x(tree: &WidgetTree, id: WidgetId, x: f32) {
5942 tree.widget_as_any(id)
5943 .unwrap()
5944 .downcast_ref::<TreeTableView<&'static str>>()
5945 .unwrap()
5946 .scroll_x_signal()
5947 .set(x);
5948 }
5949
5950 #[test]
5951 fn tt_scroll_x_clamps_after_the_pane_widens() {
5952 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 3, 300.0);
5953 let max = tt_max_scroll_x(&tree, id);
5954 assert!(max > 0.0, "columns must overflow the narrow table");
5955 tt_set_scroll_x(&tree, id, max);
5956 assert_eq!(tt_scroll_x(&tree, id), max);
5957
5958 tree.layout(SizeProposal {
5959 width: Some(700.0),
5960 height: Some(200.0),
5961 });
5962 assert_eq!(tt_max_scroll_x(&tree, id), 0.0, "content now fits");
5963 assert_eq!(
5964 tt_scroll_x(&tree, id),
5965 0.0,
5966 "scroll_x must clamp down with the new (smaller) max_scroll_x"
5967 );
5968 }
5969
5970 #[test]
5971 fn tt_pinned_columns_keep_their_bands_under_scroll() {
5972 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
5973
5974 let cells0 = tt_body_row_cells(&tree, id);
5975 assert_eq!(cells0.len(), 3, "lead, mid, trail");
5976 let lead_x0 = tree.bounds(cells0[0]).x;
5977 let mid_x0 = tree.bounds(cells0[1]).x;
5978 let trail_x0 = tree.bounds(cells0[2]).x;
5979
5980 let tree_row_a11y = tt_first_body_row_id(&tree, id);
5985 let body_row = tree.children(tree_row_a11y)[0];
5986 let raw_bands = tree.children(body_row);
5987 assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
5988 assert!(!tree.widget_clips_children(raw_bands[0]));
5989 assert!(
5990 tree.widget_clips_children(raw_bands[1]),
5991 "the Middle band must clip"
5992 );
5993 assert!(!tree.widget_clips_children(raw_bands[2]));
5994
5995 let max = tt_max_scroll_x(&tree, id);
5996 assert!(max > 0.0);
5997 tt_set_scroll_x(&tree, id, 50.0_f32.min(max));
5998 tree.layout(SizeProposal {
5999 width: Some(200.0),
6000 height: Some(200.0),
6001 });
6002
6003 let cells1 = tt_body_row_cells(&tree, id);
6004 assert_eq!(tree.bounds(cells1[0]).x, lead_x0, "Leading never moves");
6005 assert_eq!(tree.bounds(cells1[2]).x, trail_x0, "Trailing never moves");
6006 let mid_x1 = tree.bounds(cells1[1]).x;
6007 assert!(
6008 (mid_x1 - (mid_x0 - 50.0)).abs() < 0.5,
6009 "the Middle column shifts left by exactly scroll_x: got {mid_x1}, want ~{}",
6010 mid_x0 - 50.0
6011 );
6012 }
6013
6014 #[test]
6015 fn tt_header_and_body_x_offsets_agree_under_scroll() {
6016 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
6017 tt_set_scroll_x(&tree, id, 37.0);
6018 tree.layout(SizeProposal {
6019 width: Some(200.0),
6020 height: Some(200.0),
6021 });
6022
6023 let header_cells = tt_header_row_cells(&tree, id);
6024 let body_cells = tt_body_row_cells(&tree, id);
6025 assert_eq!(header_cells.len(), body_cells.len());
6026 for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
6027 let hx = tree.bounds(h).x;
6028 let bx = tree.bounds(b).x;
6029 assert!(
6030 (hx - bx).abs() < 0.01,
6031 "column {i}: header x {hx} must equal body x {bx}"
6032 );
6033 }
6034 }
6035
6036 #[test]
6037 fn tt_shift_wheel_scrolls_horizontally() {
6038 use teksilo_canvas::Point;
6039 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
6040 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 4, 300.0);
6041 tree.pointer_move(Point::new(50.0, 60.0));
6042 tree.dispatch_event(WidgetEvent::Scroll {
6043 delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
6044 modifiers: Modifiers::SHIFT,
6045 });
6046 tree.layout(SizeProposal {
6047 width: Some(300.0),
6048 height: Some(200.0),
6049 });
6050 assert!(
6051 tt_scroll_x(&tree, id) > 0.0,
6052 "Shift+wheel must remap a vertical-only wheel to horizontal scroll"
6053 );
6054 let any = tree.widget_as_any(id).unwrap();
6055 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6056 assert_eq!(
6057 tt.scroll_y_signal().get(),
6058 0.0,
6059 "Shift+wheel must not also scroll vertically"
6060 );
6061 }
6062
6063 #[test]
6064 fn tt_ensure_col_visible_follows_focus_in_both_directions() {
6065 use teksilo_core::event::{Key, Modifiers};
6066 let (mut tree, id) = build_tt_wide_unpinned_table(150.0, 5, 300.0);
6067 tree.focus(id);
6068 {
6069 let any = tree.widget_as_any(id).unwrap();
6070 any.downcast_ref::<TreeTableView<&'static str>>()
6071 .unwrap()
6072 .set_focused_cell(0, 0);
6073 }
6074 assert_eq!(tt_scroll_x(&tree, id), 0.0);
6075
6076 tree.press_key(Key::End, Modifiers::NONE);
6077 {
6078 let any = tree.widget_as_any(id).unwrap();
6079 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6080 assert_eq!(tt.focused_cell_signal().get(), Some((0, 4)));
6081 }
6082 assert!(
6083 tt_scroll_x(&tree, id) > 0.0,
6084 "ensure-column-visible must scroll right to reveal column 4"
6085 );
6086
6087 tree.press_key(Key::Home, Modifiers::NONE);
6088 {
6089 let any = tree.widget_as_any(id).unwrap();
6090 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6091 assert_eq!(tt.focused_cell_signal().get(), Some((0, 0)));
6092 }
6093 assert_eq!(
6094 tt_scroll_x(&tree, id),
6095 0.0,
6096 "ensure-column-visible must scroll left back to 0 for column 0"
6097 );
6098 }
6099
6100 fn reorder_col(id: &'static str, width: f32) -> Column<&'static str> {
6122 Column::<&'static str>::new(id, lit!(id), |row, _: &CellContext| {
6123 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6124 })
6125 .width(ColumnWidth::Fixed(width))
6126 }
6127
6128 fn build_tt_reorder_table() -> (WidgetTree, WidgetId, SortFilterTreeModel<&'static str>) {
6132 let proxy = SortFilterTreeModel::new(sample_tree());
6133 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6134 let id = tree.add(
6135 TreeTableView::from_projection(proxy.clone())
6136 .add_column(reorder_col("a", 60.0))
6137 .add_column(reorder_col("b", 70.0))
6138 .add_column(reorder_col("c", 80.0))
6139 .add_column(reorder_col("d", 90.0))
6140 .row_height(20.0),
6141 );
6142 tree.layout(SizeProposal {
6143 width: Some(400.0),
6144 height: Some(200.0),
6145 });
6146 (tree, id, proxy)
6147 }
6148
6149 fn tt_subtree_has_twist_arrow(tree: &WidgetTree, id: WidgetId) -> bool {
6156 if tree.widget_type_name(id) == Some("teksilo_widgets::primitives::twist_arrow::TwistArrow")
6157 {
6158 return true;
6159 }
6160 tree.children(id)
6161 .into_iter()
6162 .any(|c| tt_subtree_has_twist_arrow(tree, c))
6163 }
6164
6165 #[test]
6166 fn header_drag_reorders_column_before_an_earlier_sibling() {
6167 let (mut tree, id, _proxy) = build_tt_reorder_table();
6170 let header = tt_header_row_cells(&tree, id);
6171 assert_eq!(header.len(), 4);
6172 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
6175
6176 {
6177 let any = tree.widget_as_any(id).unwrap();
6178 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6179 assert_eq!(
6180 tt.column_order_signal().get(),
6181 vec![
6182 "a".to_string(),
6183 "d".to_string(),
6184 "b".to_string(),
6185 "c".to_string()
6186 ],
6187 "dropping \"d\" before \"b\" must write [a, d, b, c]"
6188 );
6189 assert_eq!(
6190 tt.column_pinning_signal().get().get("d"),
6191 None,
6192 "a mid-band drop must not pin the moved column"
6193 );
6194 }
6195
6196 tree.layout(SizeProposal {
6200 width: Some(400.0),
6201 height: Some(200.0),
6202 });
6203 let after = tt_header_row_cells(&tree, id);
6204 let widths: Vec<f32> = after.iter().map(|&c| tree.bounds(c).width).collect();
6205 assert!(
6206 widths
6207 .iter()
6208 .zip([60.0, 90.0, 70.0, 80.0])
6209 .all(|(&w, want)| (w - want).abs() < 0.5),
6210 "header cells must reflow to widths [60, 90, 70, 80], got {widths:?}"
6211 );
6212 }
6213
6214 #[test]
6215 fn header_drag_to_the_leading_edge_pins_the_dropped_column() {
6216 let (mut tree, id, _proxy) = build_tt_reorder_table();
6221 let header = tt_header_row_cells(&tree, id);
6222 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(5.0, from.y); drag(&mut tree, from, to);
6225
6226 let any = tree.widget_as_any(id).unwrap();
6227 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6228 assert_eq!(
6229 tt.column_order_signal().get(),
6230 vec![
6231 "d".to_string(),
6232 "a".to_string(),
6233 "b".to_string(),
6234 "c".to_string()
6235 ],
6236 );
6237 assert_eq!(
6238 tt.column_pinning_signal().get().get("d").copied(),
6239 Some(PinnedSide::Leading),
6240 "dropping at the leading edge must pin the column, same as TableView"
6241 );
6242 }
6243
6244 #[test]
6245 fn header_drag_reorder_remaps_focused_and_editing_cell_to_follow_their_columns() {
6246 let (mut tree, id, _proxy) = build_tt_reorder_table();
6252 {
6253 let any = tree.widget_as_any(id).unwrap();
6254 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6255 tt.set_focused_cell(0, 1); tt.begin_edit(0, "d"); }
6258
6259 let header = tt_header_row_cells(&tree, id);
6260 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
6263 tree.layout(SizeProposal {
6264 width: Some(400.0),
6265 height: Some(200.0),
6266 });
6267
6268 let any = tree.widget_as_any(id).unwrap();
6269 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6270 assert_eq!(
6271 tt.column_order_signal().get(),
6272 vec![
6273 "a".to_string(),
6274 "d".to_string(),
6275 "b".to_string(),
6276 "c".to_string()
6277 ],
6278 );
6279 assert_eq!(
6280 tt.focused_cell_signal().get(),
6281 Some((0, 2)),
6282 "focus must follow \"b\" to its new display position"
6283 );
6284 assert_eq!(
6285 tt.editing_cell_signal().get(),
6286 Some((0, 1)),
6287 "the open editor must follow \"d\" to its new display position"
6288 );
6289 }
6290
6291 #[test]
6292 fn header_drag_moves_the_tree_column_and_twist_follows() {
6293 let (mut tree, id, proxy) = build_tt_reorder_table();
6302 let header = tt_header_row_cells(&tree, id);
6303 let from = tree.bounds(header[0]).center(); let to = teksilo_canvas::Point::new(220.0, from.y); drag(&mut tree, from, to);
6306
6307 {
6308 let any = tree.widget_as_any(id).unwrap();
6309 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6310 assert_eq!(
6311 tt.column_order_signal().get(),
6312 vec![
6313 "b".to_string(),
6314 "c".to_string(),
6315 "a".to_string(),
6316 "d".to_string()
6317 ],
6318 );
6319 assert_eq!(
6320 tt.column_pinning_signal().get().get("a"),
6321 None,
6322 "a mid-band drop must not pin the tree column either"
6323 );
6324 }
6325 tree.layout(SizeProposal {
6326 width: Some(400.0),
6327 height: Some(200.0),
6328 });
6329
6330 let body = tt_body_row_cells(&tree, id);
6331 assert_eq!(body.len(), 4);
6332 assert!(
6333 !tt_subtree_has_twist_arrow(&tree, body[0]),
6334 "\"b\" is no longer the tree column"
6335 );
6336 assert!(
6337 !tt_subtree_has_twist_arrow(&tree, body[1]),
6338 "\"c\" is no longer the tree column"
6339 );
6340 assert!(
6341 tt_subtree_has_twist_arrow(&tree, body[2]),
6342 "the twist must follow \"a\" to its new display position"
6343 );
6344 assert!(
6345 !tt_subtree_has_twist_arrow(&tree, body[3]),
6346 "\"d\" is not the tree column"
6347 );
6348
6349 use teksilo_core::event::{Key, Modifiers};
6351 tree.focus(id);
6352 {
6353 let any = tree.widget_as_any(id).unwrap();
6354 any.downcast_ref::<TreeTableView<&'static str>>()
6355 .unwrap()
6356 .set_focused_cell(0, 2); }
6358 tree.press_key(Key::ArrowRight, Modifiers::NONE);
6359 assert_eq!(
6360 proxy.visible_count(),
6361 4,
6362 "ArrowRight on the relocated tree column must expand \"docs\""
6363 );
6364 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
6365 assert_eq!(proxy.visible_count(), 2, "and ArrowLeft collapses it again");
6366 }
6367
6368 #[test]
6369 fn header_drag_from_a_different_table_is_rejected() {
6370 use crate::primitives::{FixedSize, HStack};
6376 let proxy1 = SortFilterTreeModel::new(sample_tree());
6377 let proxy2 = SortFilterTreeModel::new(sample_tree());
6378 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6379
6380 let tt1 = TreeTableView::from_projection(proxy1)
6381 .add_column(reorder_col("x", 100.0))
6382 .add_column(reorder_col("y", 100.0))
6383 .row_height(20.0);
6384 let order1 = tt1.column_order_signal().clone();
6385 let id1 = tree.add(tt1);
6386 let tt2 = TreeTableView::from_projection(proxy2)
6387 .add_column(reorder_col("x", 100.0))
6388 .add_column(reorder_col("y", 100.0))
6389 .row_height(20.0);
6390 let order2 = tt2.column_order_signal().clone();
6391 let id2 = tree.add(tt2);
6392
6393 let fixed1 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id1));
6394 let fixed2 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id2));
6395 tree.add(HStack::new().add_child(fixed1).add_child(fixed2));
6396 tree.layout(SizeProposal {
6397 width: Some(400.0),
6398 height: Some(150.0),
6399 });
6400
6401 let from = tree.bounds(tt_header_row_cells(&tree, id1)[0]).center();
6404 let to = teksilo_canvas::Point::new(250.0, from.y); drag(&mut tree, from, to);
6406
6407 assert!(order1.get().is_empty(), "tt1's own order must be untouched");
6408 assert!(
6409 order2.get().is_empty(),
6410 "tt2 must reject a drop whose payload names a different table_id"
6411 );
6412 }
6413
6414 #[test]
6415 fn header_drag_released_over_the_body_does_not_trigger_foreign_row_drop() {
6416 use std::cell::Cell;
6426 let foreign_fired = Rc::new(Cell::new(false));
6427 let flag = foreign_fired.clone();
6428 let proxy = SortFilterTreeModel::new(sample_tree());
6429 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6430 let id = tree.add(
6431 TreeTableView::from_projection(proxy)
6432 .add_column(name_col())
6433 .add_column(size_col())
6434 .on_foreign_drop(move |_payload, _node, _pos, _ctx| {
6435 flag.set(true);
6436 true
6437 })
6438 .row_height(20.0),
6439 );
6440 tree.layout(SizeProposal {
6441 width: Some(400.0),
6442 height: Some(200.0),
6443 });
6444
6445 let header = tt_header_row_cells(&tree, id);
6446 let from = tree.bounds(header[0]).center();
6447 let to = teksilo_canvas::Point::new(from.x, cp::HEADER_HEIGHT + 10.0); drag(&mut tree, from, to);
6449
6450 assert!(
6451 !foreign_fired.get(),
6452 "a column-reorder drag must never reach on_foreign_drop"
6453 );
6454 let any = tree.widget_as_any(id).unwrap();
6455 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6456 assert!(
6457 tt.column_order_signal().get().is_empty(),
6458 "no header drop occurred either — the release point was outside the header strip"
6459 );
6460 }
6461
6462 #[test]
6463 fn header_drag_insertion_is_scroll_aware() {
6464 let (mut tree, id) = build_tt_wide_unpinned_table(100.0, 4, 200.0);
6469 let max = tt_max_scroll_x(&tree, id);
6470 assert!(max > 0.0, "4×100px columns must overflow a 200px viewport");
6471 tt_set_scroll_x(&tree, id, max); tree.layout(SizeProposal {
6473 width: Some(200.0),
6474 height: Some(200.0),
6475 });
6476
6477 let header = tt_header_row_cells(&tree, id);
6483 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(10.0, from.y);
6485 drag(&mut tree, from, to);
6486
6487 let any = tree.widget_as_any(id).unwrap();
6488 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6489 assert_eq!(
6490 tt.column_order_signal().get(),
6491 vec![
6492 "c0".to_string(),
6493 "c1".to_string(),
6494 "c3".to_string(),
6495 "c2".to_string()
6496 ],
6497 "\"c3\" must land before \"c2\" (scroll-aware), not before \"c0\""
6498 );
6499 }
6500
6501 fn tt_resize_table() -> (WidgetTree, WidgetId) {
6509 let proxy = SortFilterTreeModel::new(sample_tree());
6512 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6513 let id = tree.add(
6514 TreeTableView::from_projection(proxy)
6515 .add_column(name_col())
6516 .add_column(size_col())
6517 .row_height(20.0)
6518 .show_internal_scrollbars(false),
6519 );
6520 tree.layout(SizeProposal {
6521 width: Some(400.0),
6522 height: Some(200.0),
6523 });
6524 (tree, id)
6525 }
6526
6527 fn tt_overrides(tree: &WidgetTree, id: WidgetId) -> std::collections::HashMap<String, f32> {
6528 let any = tree.widget_as_any(id).unwrap();
6529 any.downcast_ref::<TreeTableView<&'static str>>()
6530 .unwrap()
6531 .column_widths_signal()
6532 .get()
6533 }
6534
6535 #[test]
6536 fn tt_grip_reaches_into_the_next_column() {
6537 use teksilo_canvas::Point;
6538 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6539 let (mut tree, id) = tt_resize_table();
6540 let y = cp::HEADER_HEIGHT * 0.5;
6541 tree.dispatch_event(WidgetEvent::PointerDown {
6543 position: Point::new(341.0, y),
6544 button: PointerButton::Primary,
6545 modifiers: Modifiers::NONE,
6546 });
6547 tree.dispatch_event(WidgetEvent::PointerMove {
6548 position: Point::new(311.0, y),
6549 });
6550 tree.dispatch_event(WidgetEvent::PointerUp {
6551 position: Point::new(311.0, y),
6552 button: PointerButton::Primary,
6553 modifiers: Modifiers::NONE,
6554 });
6555 let w = tt_overrides(&tree, id);
6556 assert!(
6557 (w.get("name").copied().unwrap_or(0.0) - 310.0).abs() < 0.5,
6558 "dragging the divider left from `size` must shrink `name` from 340 \
6559 to 310; got {w:?}"
6560 );
6561 }
6562
6563 #[test]
6564 fn tt_header_strip_paints_column_separators() {
6565 let (mut tree, _id) = tt_resize_table();
6566 let frame = tree.render();
6567 let found = frame.decorations.iter().any(|d| {
6568 let [x, y, w, h] = d.rect;
6569 (x - 339.0).abs() < 0.6
6570 && w <= 1.5
6571 && y.abs() < 0.6
6572 && (h - cp::HEADER_HEIGHT).abs() < 0.6
6573 });
6574 assert!(
6575 found,
6576 "expected a header separator at the name/size divider (x≈339); \
6577 decorations={:?}",
6578 frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
6579 );
6580 }
6581
6582 #[test]
6583 fn tree_column_chrome_is_clipped_to_its_column() {
6584 let (tree, id) = tt_resize_table();
6591 let mut walker = vec![id];
6594 let mut checked = false;
6595 while let Some(node) = walker.pop() {
6596 if tree.accessibility_node(node).role() == Role::Cell {
6597 let kids = tree.children(node);
6598 if let Some(&wrapper) = kids.first()
6599 && tree.widget_clips_children(wrapper)
6600 {
6601 checked = true;
6602 break;
6603 }
6604 }
6605 for c in tree.children(node) {
6606 walker.push(c);
6607 }
6608 }
6609 assert!(
6610 checked,
6611 "the tree column's indent + twist wrapper must clip its children"
6612 );
6613 }
6614
6615 fn editable_name_col() -> Column<&'static str> {
6618 Column::<&str>::new("name", lit!("Name"), |row, cx: &CellContext| {
6619 if cx.is_editing {
6620 Box::new(crate::text_input::TextInput::new(Signal::new(
6621 (*row).to_string(),
6622 )))
6623 } else {
6624 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6625 }
6626 })
6627 .width(ColumnWidth::Flex(1.0))
6628 .editable(true)
6629 }
6630
6631 fn three_row_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
6632 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
6633 slice.set_source(move || {
6634 [(1_u64, "one"), (2, "two"), (3, "three")]
6635 .into_iter()
6636 .map(|(k, n)| teksilo_data::TreeRow::new(k, n, 0))
6637 .collect()
6638 });
6639 slice.reload();
6640 slice
6641 }
6642
6643 fn double_click_at(tree: &mut WidgetTree, at: Point) {
6646 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6647 for _ in 0..2 {
6648 tree.dispatch_event(WidgetEvent::PointerDown {
6649 position: at,
6650 button: PointerButton::Primary,
6651 modifiers: Modifiers::NONE,
6652 });
6653 tree.dispatch_event(WidgetEvent::PointerUp {
6654 position: at,
6655 button: PointerButton::Primary,
6656 modifiers: Modifiers::NONE,
6657 });
6658 }
6659 }
6660
6661 #[test]
6670 fn opening_a_cell_editor_moves_the_keyboard_into_it() {
6671 let slice = three_row_slice();
6672 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6673 let id = tree.add(
6674 TreeTableView::from_source(slice)
6675 .add_column(editable_name_col())
6676 .row_height(20.0),
6677 );
6678 let proposal = SizeProposal {
6679 width: Some(400.0),
6680 height: Some(200.0),
6681 };
6682 tree.layout(proposal);
6683 tree.focus(id);
6684
6685 {
6686 let any = tree.widget_as_any(id).unwrap();
6687 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6688 tt.begin_edit(1, "name");
6689 }
6690 tree.layout(proposal);
6691
6692 let focused = tree.focused().expect("something must hold focus");
6693 assert_ne!(
6694 focused, id,
6695 "focus is still on the table, not in the editor"
6696 );
6697 let cell = {
6698 let any = tree.widget_as_any(id).unwrap();
6699 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6700 tt.realized_cell(1, 0).expect("the edited cell is realized")
6701 };
6702 assert!(
6703 tree.is_descendant_of(focused, cell),
6704 "focus must land inside the edited cell, not on {:?}",
6705 tree.widget_type_name(focused)
6706 );
6707 }
6708
6709 #[test]
6718 fn an_open_editor_still_holds_the_keyboard_after_the_pane_rebuilds() {
6719 let slice = three_row_slice();
6720 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6721 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6722 let id = tree.add(
6723 TreeTableView::from_source(slice)
6724 .selection(selection.clone())
6725 .add_column(editable_name_col())
6726 .row_height(20.0),
6727 );
6728 let proposal = SizeProposal {
6729 width: Some(400.0),
6730 height: Some(200.0),
6731 };
6732 tree.layout(proposal);
6733 {
6734 let any = tree.widget_as_any(id).unwrap();
6735 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6736 tt.begin_edit(1, "name");
6737 }
6738 tree.layout(proposal);
6739 tree.focused().expect("the editor took focus");
6740
6741 selection.select(2);
6742 tree.layout(proposal);
6743
6744 let focused = tree.focused().expect("focus survived the rebuild");
6745 assert_ne!(focused, id, "the rebuild dropped focus back onto the table");
6746 let cell = {
6747 let any = tree.widget_as_any(id).unwrap();
6748 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6749 tt.realized_cell(1, 0)
6750 .expect("the edited cell is still realized")
6751 };
6752 assert!(
6753 tree.is_descendant_of(focused, cell),
6754 "focus must still be inside the edited cell, not on {:?}",
6755 tree.widget_type_name(focused)
6756 );
6757 }
6758
6759 #[test]
6765 fn a_double_click_on_an_editable_cell_opens_its_editor() {
6766 let (mut tree, id, seen, _) = click_probe(EditTriggers::DOUBLE_CLICK);
6767 let cell = realized(&tree, id, 1, 0);
6768 let at = tree.bounds(cell).center();
6769 double_click_at(&mut tree, at);
6770
6771 assert_eq!(
6772 seen.borrow().as_slice(),
6773 &[(1, "name".to_string())],
6774 "a double-click on an editable cell must request its editor"
6775 );
6776 let any = tree.widget_as_any(id).unwrap();
6777 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6778 assert_eq!(tt.editing_cell_signal().get(), Some((1, 0)));
6779 }
6780
6781 #[test]
6784 fn a_single_click_opens_the_editor_when_the_column_asks_for_it() {
6785 let (mut tree, id, seen, _) = click_probe(EditTriggers::SINGLE_CLICK);
6786 let cell = realized(&tree, id, 1, 0);
6787 tree.click(cell);
6788
6789 assert_eq!(
6790 seen.borrow().as_slice(),
6791 &[(1, "name".to_string())],
6792 "one click on a SINGLE_CLICK column must request its editor"
6793 );
6794 }
6795
6796 #[test]
6800 fn a_click_opens_nothing_when_the_column_asks_for_no_click_trigger() {
6801 let (mut tree, id, seen, _) = click_probe(EditTriggers::F2);
6802 let cell = realized(&tree, id, 1, 0);
6803 tree.click(cell);
6804 let at = tree.bounds(cell).center();
6805 double_click_at(&mut tree, at);
6806
6807 assert!(
6808 seen.borrow().is_empty(),
6809 "an F2-only column opened an editor from a click: {:?}",
6810 seen.borrow()
6811 );
6812 }
6813
6814 #[test]
6829 fn editing_a_cell_by_double_click_does_not_also_activate_the_row() {
6830 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6831 let cell = realized(&tree, id, 1, 0);
6832 let at = tree.bounds(cell).center();
6833 double_click_at(&mut tree, at);
6834 assert_eq!(
6835 activated.get(),
6836 0,
6837 "double-clicking an editable cell opened the item as well as the editor"
6838 );
6839 }
6840
6841 #[test]
6845 fn a_double_click_off_an_editable_cell_still_activates_the_row() {
6846 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6847 let cell = realized(&tree, id, 1, 1);
6848 let at = tree.bounds(cell).center();
6849 double_click_at(&mut tree, at);
6850 assert_eq!(
6851 activated.get(),
6852 1,
6853 "a double-click away from an editable cell must still activate the row"
6854 );
6855 }
6856
6857 #[test]
6866 fn a_double_click_editable_cell_still_lets_its_row_select_on_one_click() {
6867 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6868 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6869 let id = tree.add(
6870 TreeTableView::from_source(three_row_slice())
6871 .selection(selection.clone())
6872 .add_column(editable_name_col().edit_triggers(EditTriggers::DOUBLE_CLICK))
6873 .row_height(20.0)
6874 .on_cell_edit_request(|_row, _col, _ctx| {}),
6875 );
6876 tree.layout(SizeProposal {
6877 width: Some(400.0),
6878 height: Some(200.0),
6879 });
6880
6881 let cell = realized(&tree, id, 1, 0);
6882 tree.click(cell);
6883 assert!(
6884 selection.is_selected(1),
6885 "one click on a double-click-editable cell must still select its row"
6886 );
6887 }
6888
6889 #[test]
6893 fn a_single_click_editable_cell_claims_the_press_from_row_selection() {
6894 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6895 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6896 let id = tree.add(
6897 TreeTableView::from_source(three_row_slice())
6898 .selection(selection.clone())
6899 .add_column(editable_name_col().edit_triggers(EditTriggers::SINGLE_CLICK))
6900 .add_column(size_col())
6901 .row_height(20.0)
6902 .on_cell_edit_request(|_row, _col, _ctx| {}),
6903 );
6904 tree.layout(SizeProposal {
6905 width: Some(400.0),
6906 height: Some(200.0),
6907 });
6908
6909 let editable = realized(&tree, id, 1, 0);
6910 tree.click(editable);
6911 assert!(
6912 !selection.is_selected(1),
6913 "a SINGLE_CLICK cell's click must go to the editor, not to selection"
6914 );
6915
6916 let plain = realized(&tree, id, 2, 1);
6919 tree.click(plain);
6920 assert!(
6921 selection.is_selected(2),
6922 "a click on a non-editing column must still select its row"
6923 );
6924 }
6925
6926 fn realized(tree: &WidgetTree, id: WidgetId, row: usize, col: usize) -> WidgetId {
6928 let any = tree.widget_as_any(id).unwrap();
6929 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6930 tt.realized_cell(row, col)
6931 .unwrap_or_else(|| panic!("cell ({row}, {col}) is not realized"))
6932 }
6933
6934 #[allow(clippy::type_complexity)]
6938 fn click_probe(
6939 triggers: EditTriggers,
6940 ) -> (
6941 WidgetTree,
6942 WidgetId,
6943 Rc<RefCell<Vec<(usize, String)>>>,
6944 Rc<Cell<usize>>,
6945 ) {
6946 let seen: Rc<RefCell<Vec<(usize, String)>>> = Rc::new(RefCell::new(Vec::new()));
6947 let sink = seen.clone();
6948 let activated: Rc<Cell<usize>> = Rc::new(Cell::new(0));
6949 let counter = activated.clone();
6950 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6951 let id = tree.add(
6952 TreeTableView::from_source(three_row_slice())
6953 .add_column(editable_name_col().edit_triggers(triggers))
6954 .add_column(size_col())
6955 .row_height(20.0)
6956 .on_cell_edit_request(move |row, col, _ctx| {
6957 sink.borrow_mut().push((row, col.to_string()));
6958 })
6959 .on_row_activate(move |_row, _ctx| counter.set(counter.get() + 1)),
6960 );
6961 tree.layout(SizeProposal {
6962 width: Some(400.0),
6963 height: Some(200.0),
6964 });
6965 (tree, id, seen, activated)
6966 }
6967}