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 pub fn set_column_width(&self, col_id: &str, width: f32) {
1166 imperative::set_column_width(&self.column_widths_signal, col_id, width);
1167 }
1168
1169 pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1177 imperative::set_column_widths(&self.column_widths_signal, widths);
1178 }
1179
1180 pub fn set_column_order(&self, order: Vec<String>) {
1183 imperative::set_if_changed(&self.column_order_signal, order);
1184 }
1185
1186 pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1189 &self.column_pinning_signal
1190 }
1191
1192 pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1195 imperative::set_column_pinning(&self.column_pinning_signal, col_id, side);
1196 }
1197
1198 pub fn begin_edit(&self, row: usize, col_id: &str) {
1210 let cached = self.display_indices.borrow();
1211 let recomputed;
1212 let display: &[usize] = if cached.is_empty() {
1213 recomputed = self.display_order();
1214 &recomputed
1215 } else {
1216 &cached
1217 };
1218 if let Some(target) = imperative::resolve_edit_target(
1219 row,
1220 col_id,
1221 &self.columns,
1222 display,
1223 self.source.visible_count(),
1224 ) {
1225 drop(cached);
1226 self.editing_cell.set(Some(target));
1227 }
1228 }
1229
1230 pub fn end_edit(&self) {
1232 self.editing_cell.set(None);
1233 }
1234
1235 fn effective_row_height(&self) -> f32 {
1238 self.row_height.unwrap_or(cp::ROW_HEIGHT)
1239 }
1240
1241 fn effective_header_height(&self) -> f32 {
1242 if self.show_header {
1243 self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1244 } else {
1245 0.0
1246 }
1247 }
1248
1249 fn effective_indent(&self) -> f32 {
1250 self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1251 }
1252
1253 fn tree_column_decl_index(&self) -> usize {
1256 if let Some(ref id) = self.tree_column_id {
1257 for (i, col) in self.columns.iter().enumerate() {
1258 if &col.id == id {
1259 return i;
1260 }
1261 }
1262 }
1263 0
1264 }
1265
1266 fn display_order(&self) -> Vec<usize> {
1267 let order_signal = self.column_order_signal.get();
1268 let mut order_map: HashMap<&str, usize> = HashMap::new();
1269 for (i, id) in order_signal.iter().enumerate() {
1270 order_map.insert(id.as_str(), i);
1271 }
1272 let mut leading: Vec<usize> = Vec::new();
1273 let mut middle: Vec<usize> = Vec::new();
1274 let mut trailing: Vec<usize> = Vec::new();
1275 for (i, col) in self.columns.iter().enumerate() {
1276 let pinning = self
1277 .column_pinning_signal
1278 .get()
1279 .get(&col.id)
1280 .copied()
1281 .unwrap_or(col.pinned);
1282 match pinning {
1283 PinnedSide::Leading => leading.push(i),
1284 PinnedSide::None => middle.push(i),
1285 PinnedSide::Trailing => trailing.push(i),
1286 }
1287 }
1288 const FALLBACK_BASE: usize = usize::MAX / 2;
1289 let cols = &self.columns;
1290 let key_for = |i: usize| {
1291 order_map
1292 .get(cols[i].id.as_str())
1293 .copied()
1294 .unwrap_or(FALLBACK_BASE + i)
1295 };
1296 leading.sort_by_key(|&i| key_for(i));
1297 middle.sort_by_key(|&i| key_for(i));
1298 trailing.sort_by_key(|&i| key_for(i));
1299 let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1300 out.extend(leading);
1301 let leading_count = out.len();
1302 out.extend(middle);
1303 let middle_end = out.len();
1304 out.extend(trailing);
1305 *self.pane_boundaries.borrow_mut() =
1309 crate::table_view::PaneBoundaries::new(leading_count, middle_end);
1310 out
1311 }
1312
1313 fn clamp_scroll(&self) {
1314 let max = self.max_scroll_y.get();
1315 let current = self.scroll_y.get();
1316 let clamped = current.clamp(0.0, max);
1317 if (clamped - current).abs() > 0.001 {
1318 self.scroll_y.set(clamped);
1319 }
1320 }
1321
1322 fn visible_range(&self) -> (usize, usize) {
1327 self.row_metrics.borrow_mut().visible_range(
1328 self.scroll_y.get(),
1329 self.viewport_height.get(),
1330 self.source.visible_count(),
1331 BUFFER_ROWS,
1332 )
1333 }
1334}
1335
1336impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1338 f.debug_struct("TreeTableView")
1339 .field("rows", &self.source.visible_count())
1340 .field("columns", &self.columns.len())
1341 .field("tree_column", &self.tree_column_id)
1342 .field("scroll_bar_style", &self.scroll_bar_style)
1343 .finish()
1344 }
1345}
1346
1347impl<T: 'static> Widget for TreeTableView<T> {
1348 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1349 let self_id = ctx.self_id();
1350 ctx.enabled_when(self_id, self.enabled.clone());
1351
1352 let row_h = self.effective_row_height();
1353 let header_h = self.effective_header_height();
1354 let indent_per_level = self.effective_indent();
1355
1356 let version = ctx.signal(0_u64);
1357 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1358
1359 self.scroll_y.bind_to(
1360 ctx.self_id(),
1361 ctx.binding_registry(),
1362 BindingLevel::Relayout,
1363 );
1364 ctx.register_animated_signal(&self.scroll_y);
1365
1366 self.scroll_x.bind_to(
1367 ctx.self_id(),
1368 ctx.binding_registry(),
1369 BindingLevel::Relayout,
1370 );
1371 ctx.register_animated_signal(&self.scroll_x);
1372
1373 self.pane_total_refresh.bind_to(
1378 ctx.self_id(),
1379 ctx.binding_registry(),
1380 BindingLevel::Relayout,
1381 );
1382
1383 self.column_widths_signal.bind_to(
1384 ctx.self_id(),
1385 ctx.binding_registry(),
1386 BindingLevel::Relayout,
1387 );
1388 self.resize_preview_x.bind_to(
1391 ctx.self_id(),
1392 ctx.binding_registry(),
1393 BindingLevel::RepaintOnly,
1394 );
1395
1396 {
1400 let resize_state = self.resize_state.clone();
1401 let resize_target = self.resize_target.clone();
1402 let resize_preview_x = self.resize_preview_x.clone();
1403 ctx.effect(&ctx.window_active_signal(), move |active| {
1404 if !*active && resize_state.borrow().is_some() {
1405 *resize_state.borrow_mut() = None;
1406 resize_target.set(None);
1407 resize_preview_x.set(None);
1408 }
1409 });
1410 }
1411 self.focused_cell.bind_to(
1412 ctx.self_id(),
1413 ctx.binding_registry(),
1414 BindingLevel::RepaintOnly,
1415 );
1416 self.focused_cell.bind_to(
1421 ctx.self_id(),
1422 ctx.binding_registry(),
1423 BindingLevel::AccessibilityOnly,
1424 );
1425
1426 self.view_focused = ctx.begin_view_focus();
1436 ctx.end_view_focus();
1437 self.focus_visible = ctx.focus_visible();
1438 self.view_focused.bind_to(
1439 ctx.self_id(),
1440 ctx.binding_registry(),
1441 BindingLevel::RepaintOnly,
1442 );
1443 self.focus_visible.bind_to(
1444 ctx.self_id(),
1445 ctx.binding_registry(),
1446 BindingLevel::RepaintOnly,
1447 );
1448 self.drop_feedback.bind_to(
1451 ctx.self_id(),
1452 ctx.binding_registry(),
1453 BindingLevel::RepaintOnly,
1454 );
1455
1456 let v_for_proj = version.clone();
1462 let proj_ver = Rc::new(Cell::new(0_u64));
1463 let prev_visible_count = Rc::new(Cell::new(self.source.visible_count()));
1464 ctx.effect(&self.source.version_signal(), {
1465 let metrics = self.row_metrics.clone();
1466 let src = self.source.clone();
1467 let row_sel = self.row_selection.clone();
1468 let cell_sel = self.cell_selection.clone();
1469 let prev_visible_count = prev_visible_count.clone();
1470 move |_| {
1471 metrics
1472 .borrow_mut()
1473 .apply_divergence(src.first_changed_index(), src.visible_count());
1474 if let Some(ref rs) = row_sel {
1477 rs.prune();
1478 }
1479 let new_visible_count = src.visible_count();
1493 if let Some(ref cs) = cell_sel
1494 && new_visible_count != prev_visible_count.get()
1495 {
1496 cs.clear();
1497 }
1498 prev_visible_count.set(new_visible_count);
1499 let next = proj_ver.get() + 1;
1500 proj_ver.set(next);
1501 v_for_proj.set(next);
1502 }
1503 });
1504
1505 let v_for_sort = version.clone();
1516 let sv = Rc::new(Cell::new(0_u64));
1517 ctx.effect(&self.sort_signal, move |_| {
1518 let next = sv.get() + 1;
1519 sv.set(next);
1520 v_for_sort.set(next);
1521 });
1522 let v_for_order = version.clone();
1523 let ov = Rc::new(Cell::new(0_u64));
1524 ctx.effect(&self.column_order_signal, move |_| {
1525 let next = ov.get() + 1;
1526 ov.set(next);
1527 v_for_order.set(next);
1528 });
1529 let v_for_pin = version.clone();
1530 let pv = Rc::new(Cell::new(0_u64));
1531 ctx.effect(&self.column_pinning_signal, move |_| {
1532 let next = pv.get() + 1;
1533 pv.set(next);
1534 v_for_pin.set(next);
1535 });
1536 let display_indices = self.display_order();
1543
1544 {
1551 let old_display = self.display_indices.borrow();
1552 if !old_display.is_empty() {
1553 let old_to_new: Vec<Option<usize>> = old_display
1554 .iter()
1555 .map(|&decl_idx| {
1556 let id = &self.columns[decl_idx].id;
1557 display_indices
1558 .iter()
1559 .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
1560 })
1561 .collect();
1562 drop(old_display);
1563 imperative::remap_cell_state(
1564 &self.focused_cell,
1565 &self.editing_cell,
1566 self.cell_selection.as_ref(),
1567 &old_to_new,
1568 );
1569 }
1570 }
1571 *self.display_indices.borrow_mut() = display_indices.clone();
1572 let tree_decl = self.tree_column_decl_index();
1573 let tree_display_pos = display_indices
1574 .iter()
1575 .position(|&i| i == tree_decl)
1576 .unwrap_or(0);
1577
1578 let scroll_y_for_wheel = self.scroll_y.clone();
1580 let max_scroll_for_wheel = self.max_scroll_y.clone();
1581 let scroll_x_for_wheel = self.scroll_x.clone();
1582 let max_scroll_x_for_wheel = self.max_scroll_x.clone();
1583 let line_height = row_h;
1584 let smooth_scrolling = self.smooth_scrolling;
1585 let smooth_scroll_duration = self.smooth_scroll_duration;
1586
1587 let column_ids_in_display_order: Vec<String> = display_indices
1588 .iter()
1589 .map(|&i| self.columns[i].id.clone())
1590 .collect();
1591 let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
1592 let ids = column_ids_in_display_order;
1593 Rc::new(move |pos| ids.get(pos).cloned())
1594 };
1595 let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
1599 let view_triggers = self.edit_triggers;
1600 let per_display_column: Vec<EditTriggers> = display_indices
1601 .iter()
1602 .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
1603 .collect();
1604 Rc::new(move |pos| {
1605 per_display_column
1606 .get(pos)
1607 .copied()
1608 .unwrap_or(EditTriggers::NONE)
1609 })
1610 };
1611
1612 let navigator: Rc<dyn RowNavigator> = Rc::new(TreeNavigator::new(self.source.clone()));
1613 let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
1616 self.type_ahead_label.clone().map(|user| {
1617 let src = self.source.clone();
1618 Rc::new(move |i: usize| src.with_row_str(i, &|item| user(item)))
1619 as Rc<dyn Fn(usize) -> Option<String>>
1620 });
1621
1622 let key_cfg = keyboard::KeyHandlerConfig {
1623 navigator,
1624 col_count: display_indices.len().max(1),
1625 tree_column_display_pos: tree_display_pos,
1630 focused_cell: self.focused_cell.clone(),
1631 selection_mode: self.selection_mode,
1632 selection: self.row_selection.clone(),
1633 cell_selection: self.cell_selection.clone(),
1634 scroll_y: self.scroll_y.clone(),
1635 max_scroll_y: self.max_scroll_y.clone(),
1636 viewport_height: self.viewport_height.clone(),
1637 body_bounds: self.body_bounds.clone(),
1638 row_metrics: self.row_metrics.clone(),
1639 tab_traversal: self.tab_traversal,
1640 editing_cell: self.editing_cell.clone(),
1641 display_col_to_id,
1642 display_col_triggers,
1643 on_cell_edit_request: self.on_cell_edit_request.clone(),
1644 on_row_activate: self.on_row_activate.clone(),
1645 type_ahead: self.type_ahead.clone(),
1646 type_ahead_label,
1647 type_ahead_timeout: self.type_ahead_timeout,
1648 column_widths: self.column_widths.clone(),
1649 pane_boundaries: *self.pane_boundaries.borrow(),
1650 scroll_x: self.scroll_x.clone(),
1651 max_scroll_x: self.max_scroll_x.clone(),
1652 middle_viewport_width: self.middle_viewport_width.clone(),
1653 };
1654
1655 let mut shared_key = keyboard::build_key_handler(key_cfg);
1660 let reorderable_kbd = self.reorderable;
1661 let source_kbd = self.source.clone();
1662 let focused_kbd = self.focused_cell.clone();
1663 let sel_kbd = self.row_selection.clone();
1664 let sort_kbd = self.sort_signal.clone();
1665 let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1666 ctx: &mut EventContext|
1667 -> EventResponse {
1668 use teksilo_core::event::{Key, WidgetEvent};
1669 if reorderable_kbd
1670 && sort_kbd.get().is_none()
1671 && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1672 && modifiers.alt()
1673 && matches!(key, Key::ArrowUp | Key::ArrowDown)
1674 {
1675 let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1676 sel_kbd
1677 .as_ref()
1678 .and_then(|s| s.selected_indices().first().copied())
1679 });
1680 if let Some(flat_idx) = row
1684 && let Some(new_flat) =
1685 source_kbd.keyboard_reorder(flat_idx, matches!(key, Key::ArrowDown))
1686 {
1687 let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1688 focused_kbd.set(Some((new_flat, col)));
1689 if let Some(ref s) = sel_kbd {
1690 s.select(new_flat);
1691 }
1692 return EventResponse::Handled;
1693 }
1694 }
1695 shared_key(event, ctx)
1696 };
1697
1698 let mut handlers = HandlerSet::new()
1699 .on_scroll({
1700 let overscroll_behavior = self.overscroll_behavior;
1701 move |event, _ctx| match event {
1702 teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1703 let (raw_dx, raw_dy) = match delta {
1704 teksilo_core::event::ScrollDelta::Lines { x, y } => {
1705 (x * line_height, y * line_height)
1706 }
1707 teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1708 };
1709 let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1712 (raw_dy, 0.0)
1713 } else {
1714 (raw_dx, raw_dy)
1715 };
1716
1717 let mut moved_any = false;
1718 if dy.abs() > 0.0 {
1719 let current = scroll_y_for_wheel.get();
1720 let max = max_scroll_for_wheel.get();
1721 let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1726 let (new_y, moved) =
1727 crate::common::scroll::scroll_clamp_axis(base, dy, max);
1728 if moved {
1729 if smooth_scrolling {
1730 scroll_y_for_wheel.animate_to(
1731 new_y,
1732 smooth_scroll_duration,
1733 Easing::EaseOut,
1734 );
1735 } else {
1736 scroll_y_for_wheel.set(new_y);
1737 }
1738 }
1739 moved_any |= moved;
1740 }
1741 if dx.abs() > 0.0 {
1742 let current = scroll_x_for_wheel.get();
1743 let max = max_scroll_x_for_wheel.get();
1744 let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1745 let (new_x, moved) =
1746 crate::common::scroll::scroll_clamp_axis(base, dx, max);
1747 if moved {
1748 if smooth_scrolling {
1749 scroll_x_for_wheel.animate_to(
1750 new_x,
1751 smooth_scroll_duration,
1752 Easing::EaseOut,
1753 );
1754 } else {
1755 scroll_x_for_wheel.set(new_x);
1756 }
1757 }
1758 moved_any |= moved;
1759 }
1760 crate::common::scroll::scroll_response(
1764 moved_any,
1765 overscroll_behavior == OverscrollBehavior::Contain,
1766 )
1767 }
1768 _ => EventResponse::Ignored,
1769 }
1770 })
1771 .on_key(key_handler)
1772 .clips_children(true)
1773 .focusable(true);
1774
1775 if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
1795 let my_model_id = self.model_id;
1796 let source_for_hover = self.source.clone();
1797 let metrics_for_hover = self.row_metrics.clone();
1798 let scroll_for_hover = self.scroll_y.clone();
1799 let header_h_for_hover = header_h;
1800 let feedback_for_hover = self.drop_feedback.clone();
1801 let sort_for_hover = self.sort_signal.clone();
1802 let reorderable_hover = self.reorderable;
1803 let export_for_hover = self.export.clone();
1804 let has_foreign_hook_hover = self.on_foreign_drop.is_some();
1805 let bounds_for_hover = self.body_bounds.clone();
1806 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1807 if payload.has_typed::<ColumnReorderDragData>() {
1815 feedback_for_hover.set(None);
1816 return teksilo_core::DropFeedback::NoFeedback;
1817 }
1818 let viz_width = bounds_for_hover.get().width.max(1.0);
1821 let count = source_for_hover.visible_count();
1822 if count == 0 {
1823 feedback_for_hover.set(None);
1824 return teksilo_core::DropFeedback::NoFeedback;
1825 }
1826 let rd = payload.get_typed::<RowDragData<T>>();
1827 let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
1828 let reorder_ok =
1829 is_same_view && reorderable_hover && sort_for_hover.get().is_none();
1830 let foreign_ok = !is_same_view
1834 && (has_foreign_hook_hover
1835 || export_for_hover.accepts_foreign_export(payload, my_model_id));
1836 if !reorder_ok && !foreign_ok {
1837 feedback_for_hover.set(None);
1838 return teksilo_core::DropFeedback::NoFeedback;
1839 }
1840 let scroll = scroll_for_hover.get().max(0.0);
1841 let content_y = position.y - header_h_for_hover + scroll;
1842 let (insertion_top, row_idx, row_top, row_h) = {
1843 let mut m = metrics_for_hover.borrow_mut();
1844 m.resize(count);
1845 let ins = m.insertion_index(content_y);
1846 let r = m.row_at(content_y);
1847 (m.row_top(ins), r, m.row_top(r), m.row_height(r))
1848 };
1849 let y_in_row = content_y - row_top;
1850 let third = (row_h / 3.0).max(f32::EPSILON);
1851 let drop_pos = if y_in_row < third {
1852 DropPosition::Before
1853 } else if y_in_row > 2.0 * third {
1854 DropPosition::After
1855 } else {
1856 DropPosition::Into
1857 };
1858 let (effective, depth) = if reorder_ok {
1866 match (source_for_hover.dnd.can_accept_fn)(
1867 payload,
1868 row_idx,
1869 drop_pos,
1870 my_model_id,
1871 ) {
1872 DropResponse::Reject => {
1873 if !foreign_ok {
1874 feedback_for_hover.set(None);
1875 return teksilo_core::DropFeedback::NoFeedback;
1876 }
1877 (DropPosition::Before, 0)
1878 }
1879 DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1880 DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1881 }
1882 } else {
1883 (DropPosition::Before, 0)
1885 };
1886 if effective == DropPosition::Into {
1887 let top = row_top - scroll;
1888 feedback_for_hover.set(Some(DropViz::Rect {
1889 top,
1890 height: row_h,
1891 width: viz_width,
1892 depth,
1893 }));
1894 teksilo_core::DropFeedback::HighlightRect {
1895 rect: Rect::new(0.0, top, viz_width, row_h),
1896 color: drop_into_tint(),
1897 }
1898 } else {
1899 let insertion_y = insertion_top - scroll;
1900 feedback_for_hover.set(Some(DropViz::Line {
1901 y: insertion_y,
1902 width: viz_width,
1903 depth,
1904 }));
1905 teksilo_core::DropFeedback::InsertionLine {
1906 y: insertion_y,
1907 width: viz_width,
1908 }
1909 }
1910 });
1911
1912 let drop_model_id = self.model_id;
1913 let source_for_drop = self.source.clone();
1914 let metrics_for_drop = self.row_metrics.clone();
1915 let scroll_for_drop = self.scroll_y.clone();
1916 let header_h_for_drop = header_h;
1917 let feedback_for_drop = self.drop_feedback.clone();
1918 let sort_for_drop = self.sort_signal.clone();
1919 let reorderable_drop = self.reorderable;
1920 let on_foreign_for_drop = self.on_foreign_drop.clone();
1921 let proxy_for_foreign_hook = self.proxy.clone();
1922 let export_for_drop = self.export.clone();
1923 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1924 feedback_for_drop.set(None);
1925 if payload.has_typed::<ColumnReorderDragData>() {
1929 return false;
1930 }
1931 let count = source_for_drop.visible_count();
1932 if count == 0 {
1933 return false;
1934 }
1935 let scroll = scroll_for_drop.get().max(0.0);
1936 let content_y = position.y - header_h_for_drop + scroll;
1937 let (flat_idx, row_top, row_h, ins) = {
1938 let mut m = metrics_for_drop.borrow_mut();
1939 m.resize(count);
1940 let idx = m.row_at(content_y);
1941 let ins = m.insertion_index(content_y);
1942 (idx, m.row_top(idx), m.row_height(idx), ins)
1943 };
1944 let y_in_row = content_y - row_top;
1945 let third = (row_h / 3.0).max(f32::EPSILON);
1946 let drop_pos = if y_in_row < third {
1947 DropPosition::Before
1948 } else if y_in_row > 2.0 * third {
1949 DropPosition::After
1950 } else {
1951 DropPosition::Into
1952 };
1953 let is_same_view = payload
1954 .get_typed::<RowDragData<T>>()
1955 .is_some_and(|rd| rd.source == drop_model_id);
1956 if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
1957 return false;
1958 }
1959 if (reorderable_drop || !is_same_view)
1964 && (source_for_drop.dnd.accept_drop_fn)(
1965 &payload,
1966 flat_idx,
1967 drop_pos,
1968 drop_model_id,
1969 )
1970 {
1971 if is_same_view {
1972 export_for_drop.note_self_reorder();
1973 }
1974 return true;
1975 }
1976 if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
1979 return true;
1980 }
1981 if let Some(ref hook) = on_foreign_for_drop
1985 && let Some(ref p) = proxy_for_foreign_hook
1986 && let Some(node) = p.visible_node_id(flat_idx)
1987 {
1988 return hook(&payload, node, drop_pos, ctx);
1989 }
1990 false
1991 });
1992
1993 let feedback_for_leave = self.drop_feedback.clone();
1994 handlers = handlers.on_drag_leave(move |_ctx| {
1995 feedback_for_leave.set(None);
1996 });
1997
1998 let scroll_for_tick = self.scroll_y.clone();
1999 let max_scroll_for_tick = self.max_scroll_y.clone();
2000 let viewport_for_tick = self.viewport_height.clone();
2001 let header_h_for_tick = header_h;
2002 handlers = handlers.on_drag_tick(move |pos, _ctx| {
2003 const EDGE: f32 = 32.0;
2006 const MAX_VELOCITY: f32 = 12.0;
2007 let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2008 let y = pos.y - header_h_for_tick;
2009 let above = (EDGE - y).max(0.0);
2010 let below = (y - (body_h - EDGE)).max(0.0);
2011 let delta = if above > 0.0 {
2012 -(above / EDGE) * MAX_VELOCITY
2013 } else if below > 0.0 {
2014 (below / EDGE) * MAX_VELOCITY
2015 } else {
2016 0.0
2017 };
2018 if delta.abs() > 0.01 {
2019 let max = max_scroll_for_tick.get();
2020 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2021 scroll_for_tick.set(new_y);
2022 }
2023 });
2024 }
2025
2026 handlers = self.export.install_completion(handlers);
2036
2037 ctx.apply_self_handlers(handlers);
2038
2039 self.header_row_id = None;
2042 self.body_pane_id = None;
2043 self.scrollbar_id = None;
2044 self.h_scrollbar_id = None;
2045 self.empty_id = None;
2046
2047 if self.show_header {
2049 *self.resize_state.borrow_mut() = None;
2053 self.resize_target.set(None);
2054 self.resize_preview_x.set(None);
2055
2056 let boundaries = *self.pane_boundaries.borrow();
2057 let resize_columns: ColumnResizeTable = Rc::new(
2058 display_indices
2059 .iter()
2060 .map(|&i| {
2061 let c = &self.columns[i];
2062 ColumnResizeInfo {
2063 id: c.id.clone(),
2064 min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2065 max_width: c.max_width,
2066 resizable: c.resizable,
2067 }
2068 })
2069 .collect(),
2070 );
2071 let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2072 let active_sort = self.sort_signal.get();
2073 for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2074 let col = &self.columns[col_idx];
2075 let current_sort = active_sort
2076 .as_ref()
2077 .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2078 let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2079 let cell = HeaderCell::new(HeaderCellSpec {
2080 col_id: col.id.clone(),
2081 label: col.header_label.resolve_now(),
2082 col_index_1based: display_pos + 1,
2083 sortable: col.sortable,
2084 reorderable: col.reorderable,
2085 filterable: col.filterable,
2086 resize_grip: cp::RESIZE_HANDLE_WIDTH,
2087 filter_zone_width,
2088 current_sort,
2089 width_index: display_pos,
2090 pane_boundaries: boundaries,
2091 resize_columns: resize_columns.clone(),
2092 resize_policy: self.column_resize_policy,
2093 resize_state: self.resize_state.clone(),
2094 resize_target: self.resize_target.clone(),
2095 resize_preview_x: self.resize_preview_x.clone(),
2096 table_id: self.table_id,
2097 sort_signal: self.sort_signal.clone(),
2098 column_widths_signal: self.column_widths_signal.clone(),
2099 column_widths: self.column_widths.clone(),
2100 filters_signal: self.filters_signal.clone(),
2101 });
2102 cell_ids.push(ctx.add(cell));
2103 }
2104 let header_row = HeaderRow::new(
2105 cell_ids,
2106 self.column_widths.clone(),
2107 cp::GRID_LINE_THICKNESS,
2108 *self.pane_boundaries.borrow(),
2109 self.scroll_x.clone(),
2110 );
2111 let header_row_id = ctx.add(header_row);
2121 attach_header_reorder_handlers(
2122 ctx,
2123 header_row_id,
2124 self.table_id,
2125 self.column_widths.clone(),
2126 self.display_indices.clone(),
2127 self.pane_boundaries.clone(),
2128 self.column_order_signal.clone(),
2129 self.column_pinning_signal.clone(),
2130 self.columns.iter().map(|c| c.id.clone()).collect(),
2131 self.header_strip_width.clone(),
2132 self.scroll_x.clone(),
2133 );
2134 self.header_row_id = Some(header_row_id);
2135 }
2136
2137 let row_count = self.source.visible_count();
2142
2143 let (vis_start, vis_end) = self.visible_range();
2151 (self.source.dnd.request_window_fn)(vis_start..vis_end);
2152 if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2153 (self.source.dnd.fetch_more_fn)();
2154 }
2155
2156 if row_count > 0 {
2157 let pane = body_pane::TreeBodyPane::<T> {
2158 source: self.source.clone(),
2159 editing_anchor: self.editing_anchor.clone(),
2160 columns: self.columns.clone(),
2161 display_indices: self.display_indices.clone(),
2162 column_widths: self.column_widths.clone(),
2163 pane_boundaries: *self.pane_boundaries.borrow(),
2164 scroll_x: self.scroll_x.clone(),
2165 tree_display_pos,
2166 indent_per_level,
2167 row_metrics: self.row_metrics.clone(),
2168 selection_mode: self.selection_mode,
2169 selection: self.row_selection.clone(),
2170 cell_selection: self.cell_selection.clone(),
2171 scroll_y: self.scroll_y.clone(),
2172 viewport_height: self.viewport_height.clone(),
2173 editing_cell: self.editing_cell.clone(),
2174 focused_cell: self.focused_cell.clone(),
2175 reorderable: self.reorderable,
2176 model_id: self.model_id,
2177 export: self.export.clone(),
2178 drag_anchor: ctx.self_id(),
2179 on_row_activate: self.on_row_activate.clone(),
2180 activate_on: self.activate_on,
2181 edit_triggers: self.edit_triggers,
2182 on_cell_edit_request: self.on_cell_edit_request.clone(),
2183 on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2184 version: self.pane_version.clone(),
2185 prev_built_start: self.pane_built_start.clone(),
2186 prev_built_end: self.pane_built_end.clone(),
2187 total_refresh: self.pane_total_refresh.clone(),
2188 row_entries: Vec::new(),
2189 row_map: self.row_map.clone(),
2190 cell_map: self.cell_map.clone(),
2191 };
2192 self.body_pane_id = Some(ctx.add(pane));
2193 if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
2197 &self.on_cell_edit_dismissed,
2198 &self.editing_cell,
2199 &Rc::new(
2200 display_indices
2201 .iter()
2202 .map(|&i| self.columns[i].id.clone())
2203 .collect::<Vec<_>>(),
2204 ),
2205 ) {
2206 ctx.apply_self_handlers(handlers);
2207 }
2208 } else if let Some(ref f) = self.empty_view {
2209 self.empty_id = Some(ctx.add_boxed(f()));
2211 }
2212
2213 if self.show_internal_scrollbars {
2215 let sb = ScrollBar::new(
2216 ScrollBarOrientation::Vertical,
2217 self.scroll_y.clone(),
2218 self.max_scroll_y.clone(),
2219 self.viewport_ratio_y.clone(),
2220 )
2221 .visual(match self.scroll_bar_style {
2222 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2223 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2224 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2225 });
2226 self.scrollbar_id = Some(ctx.add(sb));
2227
2228 let hsb = ScrollBar::new(
2230 ScrollBarOrientation::Horizontal,
2231 self.scroll_x.clone(),
2232 self.max_scroll_x.clone(),
2233 self.viewport_ratio_x.clone(),
2234 )
2235 .visual(match self.scroll_bar_style {
2236 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2237 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2238 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2239 });
2240 self.h_scrollbar_id = Some(ctx.add(hsb));
2241 }
2242
2243 let mut children: Vec<WidgetId> = Vec::new();
2247 if let Some(id) = self.body_pane_id {
2248 children.push(id);
2249 }
2250 if let Some(id) = self.empty_id {
2251 children.push(id);
2252 }
2253 if let Some(id) = self.scrollbar_id {
2254 children.push(id);
2255 }
2256 if let Some(id) = self.h_scrollbar_id {
2257 children.push(id);
2258 }
2259 if let Some(id) = self.header_row_id {
2260 children.push(id);
2261 }
2262 let _ = (header_h, row_h);
2263 children
2264 }
2265
2266 fn layout_response(
2267 &self,
2268 proposal: SizeProposal,
2269 _ctx: &LayoutContext,
2270 ) -> teksilo_core::widget::LayoutResponse {
2271 let size = crate::common::viewport::viewport_size(
2275 proposal,
2276 &self.viewport_height,
2277 Size::new(400.0, 300.0),
2278 );
2279 if proposal.height.is_some() {
2280 self.laid_out.set(true);
2284 }
2285 size.into()
2286 }
2287
2288 fn place_children(
2289 &self,
2290 bounds: Rect,
2291 _proposal: SizeProposal,
2292 children: &mut [WidgetPlacement],
2293 ctx: &LayoutContext,
2294 ) {
2295 if children.is_empty() {
2296 return;
2297 }
2298 let rtl = ctx.is_rtl();
2299 let header_h = self.effective_header_height();
2300 let body_height_provisional = (bounds.height - header_h).max(0.0);
2301
2302 let total_height = self
2306 .row_metrics
2307 .borrow_mut()
2308 .total_height(self.source.visible_count());
2309 let needs_v_scrollbar =
2310 self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2311 let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2314 let body_width = if reserves_v_bar {
2315 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2316 } else {
2317 bounds.width
2318 };
2319 let band_left = if rtl && reserves_v_bar {
2323 bounds.x + SCROLLBAR_THICKNESS
2324 } else {
2325 bounds.x
2326 };
2327 let scrollbar_x = if rtl {
2328 bounds.x
2329 } else {
2330 bounds.x + bounds.width - SCROLLBAR_THICKNESS
2331 };
2332 self.header_strip_width.set(body_width);
2335
2336 let overrides = self.column_widths_signal.get();
2337 let display = self.display_indices.borrow().clone();
2338 let widths = layout::ColumnSolver::resolve_in_order(
2339 &self.columns,
2340 &display,
2341 body_width,
2342 cp::MIN_COLUMN_WIDTH_DEFAULT,
2343 &overrides,
2344 );
2345
2346 let boundaries = *self.pane_boundaries.borrow();
2348 let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2349 let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2350 let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2351 self.max_scroll_x.set(max_x);
2352 self.middle_viewport_width.set(middle_viewport_w);
2353 let x_ratio = if middle_content_w > 0.0 {
2354 (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2355 } else {
2356 1.0
2357 };
2358 self.viewport_ratio_x.set(x_ratio);
2359 {
2360 let current = self.scroll_x.get();
2361 let clamped = current.clamp(0.0, max_x);
2362 if (clamped - current).abs() > 0.001 {
2363 self.scroll_x.set(clamped);
2364 }
2365 }
2366
2367 *self.column_widths.borrow_mut() = widths;
2368
2369 let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2370 let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2371 let body_height = if reserves_h_bar {
2372 (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2373 } else {
2374 body_height_provisional
2375 };
2376
2377 let max_y = (total_height - body_height).max(0.0);
2378 self.max_scroll_y.set(max_y);
2379 let y_ratio = if total_height > 0.0 {
2380 (body_height / total_height).clamp(0.0, 1.0)
2381 } else {
2382 1.0
2383 };
2384 self.viewport_ratio_y.set(y_ratio);
2385 self.clamp_scroll();
2386
2387 let body_origin_y = bounds.y + header_h;
2388 self.body_bounds
2390 .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2391
2392 let mut next = 0;
2393
2394 if self.body_pane_id.is_some() {
2397 if let Some(child) = children.get_mut(next) {
2398 child.origin = Point::new(band_left, body_origin_y);
2399 child.size = Size::new(body_width, body_height);
2400 }
2401 next += 1;
2402 }
2403
2404 if self.empty_id.is_some() {
2406 if let Some(child) = children.get_mut(next) {
2407 child.origin = Point::new(band_left, body_origin_y);
2408 child.size = Size::new(body_width, body_height);
2409 }
2410 next += 1;
2411 }
2412
2413 if self.scrollbar_id.is_some() {
2415 if let Some(child) = children.get_mut(next) {
2416 if needs_v_scrollbar {
2417 child.origin = Point::new(scrollbar_x, body_origin_y);
2418 child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2419 } else {
2420 child.origin = bounds.origin();
2421 child.size = Size::ZERO;
2422 }
2423 }
2424 next += 1;
2425 }
2426
2427 if self.h_scrollbar_id.is_some() {
2429 if let Some(child) = children.get_mut(next) {
2430 if needs_h_scrollbar {
2431 let h_x = if rtl {
2432 band_left + trailing_w
2433 } else {
2434 band_left + leading_w
2435 };
2436 child.origin = Point::new(h_x, body_origin_y + body_height);
2437 child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2438 } else {
2439 child.origin = bounds.origin();
2440 child.size = Size::ZERO;
2441 }
2442 }
2443 next += 1;
2444 }
2445
2446 if self.header_row_id.is_some()
2449 && let Some(child) = children.get_mut(next)
2450 {
2451 child.origin = Point::new(band_left, bounds.y);
2452 child.size = Size::new(body_width, header_h);
2453 }
2454 }
2455
2456 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2457 let header_h = self.effective_header_height();
2458 let colors = &ctx.theme.colors;
2459 let scroll_y = self.scroll_y.get();
2460 let body_origin_y = bounds.y + header_h;
2461 let body_height = (bounds.height - header_h).max(0.0);
2462 let widths = self.column_widths.borrow();
2463 let body_width = widths.iter().sum::<f32>();
2464 let body_width_for_paint = if body_width > 0.0 {
2465 body_width.min(bounds.width)
2466 } else {
2467 bounds.width
2468 };
2469 let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2471 let content_left = if rtl {
2472 bounds.x + bounds.width - body_width_for_paint
2473 } else {
2474 bounds.x
2475 };
2476
2477 let row_count = self.source.visible_count();
2480 let (first_visible, last_visible) =
2481 self.row_metrics
2482 .borrow_mut()
2483 .visible_range(scroll_y, body_height, row_count, 0);
2484
2485 canvas.set_clip(Rect::new(
2491 content_left,
2492 body_origin_y,
2493 body_width_for_paint,
2494 body_height,
2495 ));
2496
2497 if self.alternating_rows {
2498 let mut m = self.row_metrics.borrow_mut();
2499 for row_idx in first_visible..last_visible {
2500 if row_idx % 2 == 1 {
2501 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2502 let h = m.row_height(row_idx);
2503 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2504 canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2505 }
2506 }
2507 }
2508
2509 if let Some(ref sel) = self.row_selection
2510 && matches!(
2511 self.selection_mode,
2512 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2513 )
2514 {
2515 let bg = if self.view_focused.get() && ctx.window_active {
2519 SurfaceRole::Selected.resolve(colors)
2520 } else {
2521 SurfaceRole::SelectedInactive.resolve(colors)
2522 };
2523 let mut m = self.row_metrics.borrow_mut();
2524 for row_idx in sel.selected_indices() {
2525 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2526 let h = m.row_height(row_idx);
2527 if y + h < body_origin_y || y > body_origin_y + body_height {
2528 continue;
2529 }
2530 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2531 canvas.fill_rect(rect, bg);
2532 }
2533 }
2534
2535 let line_color = BorderRole::Divider.resolve(colors);
2536 let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2537 if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2538 let mut m = self.row_metrics.borrow_mut();
2539 for row_idx in first_visible..last_visible {
2540 let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2541 let y = body_origin_y + bottom - scroll_y - line_w;
2542 let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2543 canvas.fill_rect(rect, line_color);
2544 }
2545 }
2546
2547 let boundaries = *self.pane_boundaries.borrow();
2550 let scroll_x = self.scroll_x.get();
2551 let content_bounds = Rect::new(
2552 content_left,
2553 body_origin_y,
2554 body_width_for_paint,
2555 body_height,
2556 );
2557 let (leading_rect, middle_rect, trailing_rect) =
2558 layout::band_rects(content_bounds, &widths, boundaries, rtl);
2559
2560 if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2561 let leading_end = boundaries.leading_count.min(widths.len());
2562 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2563 crate::table_view::draw_pane_dividers(
2564 canvas,
2565 leading_rect,
2566 &widths[..leading_end],
2567 0.0,
2568 rtl,
2569 line_color,
2570 line_w,
2571 );
2572 crate::table_view::draw_pane_dividers(
2573 canvas,
2574 middle_rect,
2575 &widths[leading_end..middle_end],
2576 scroll_x,
2577 rtl,
2578 line_color,
2579 line_w,
2580 );
2581 crate::table_view::draw_pane_dividers(
2582 canvas,
2583 trailing_rect,
2584 &widths[middle_end..],
2585 0.0,
2586 rtl,
2587 line_color,
2588 line_w,
2589 );
2590 }
2591
2592 if self.view_focused.get()
2595 && self.focus_visible.get()
2596 && let Some((focus_row, focus_col)) = self.focused_cell.get()
2597 && focus_col < widths.len()
2598 && let Some(x_off) = layout::column_logical_x(
2599 &widths,
2600 boundaries,
2601 scroll_x,
2602 body_width_for_paint,
2603 focus_col,
2604 )
2605 {
2606 let cell_w = widths[focus_col];
2607 let (focus_top, focus_h) = {
2608 let mut m = self.row_metrics.borrow_mut();
2609 (m.row_top(focus_row), m.row_height(focus_row))
2610 };
2611 let y = body_origin_y + focus_top - scroll_y;
2612 if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2613 let pane_rect = if focus_col < boundaries.leading_count {
2614 leading_rect
2615 } else if focus_col >= boundaries.middle_end {
2616 trailing_rect
2617 } else {
2618 middle_rect
2619 };
2620 canvas.set_clip(pane_rect);
2621 let inset = cp::FOCUS_RING_INSET;
2622 let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2623 let ring_color = BorderRole::Focused.resolve(colors);
2624 let rx = if rtl {
2625 content_left + body_width_for_paint - x_off - cell_w + inset
2626 } else {
2627 content_left + x_off + inset
2628 };
2629 let ry = y + inset;
2630 let rw = (cell_w - inset * 2.0).max(0.0);
2631 let rh = (focus_h - inset * 2.0).max(0.0);
2632 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2633 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2634 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2635 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2636 canvas.clear_clip();
2637 }
2638 }
2639
2640 let drop_indent_origin = |depth: usize| -> f32 {
2651 let step = self.effective_indent();
2652 let tree_decl = self.tree_column_decl_index();
2653 let tree_slot = self
2654 .display_indices
2655 .borrow()
2656 .iter()
2657 .position(|&i| i == tree_decl)
2658 .unwrap_or(0);
2659 let col_x = layout::column_logical_x(
2660 &widths,
2661 boundaries,
2662 scroll_x,
2663 body_width_for_paint,
2664 tree_slot,
2665 )
2666 .unwrap_or(0.0);
2667 (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
2668 };
2669 match self.drop_feedback.get() {
2670 Some(DropViz::Line { y, depth, .. }) => {
2671 let recipe = ctx
2672 .theme
2673 .style_slots
2674 .list_container
2675 .as_ref()
2676 .map(|s| s.insertion())
2677 .unwrap_or_default();
2678 let line_color = recipe.role.resolve(colors);
2679 let thickness = recipe.thickness;
2680 let line_y = body_origin_y + y - thickness * 0.5;
2681 let indent = drop_indent_origin(depth);
2682 let x = if rtl {
2685 content_left
2686 } else {
2687 content_left + indent
2688 };
2689 canvas.fill_rect(
2690 Rect::new(x, line_y, body_width_for_paint - indent, thickness),
2691 line_color,
2692 );
2693 }
2694 Some(DropViz::Rect {
2699 top, height, depth, ..
2700 }) => {
2701 let into = ctx
2702 .theme
2703 .style_slots
2704 .list_container
2705 .as_ref()
2706 .map(|s| s.drop_into())
2707 .unwrap_or_default();
2708 let color = into.role.resolve(colors);
2709 let indent = drop_indent_origin(depth);
2710 let x = if rtl {
2711 content_left
2712 } else {
2713 content_left + indent
2714 };
2715 let rect = Rect::new(
2716 x + into.inset,
2717 body_origin_y + top + into.inset,
2718 (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
2719 (height - into.inset * 2.0).max(0.0),
2720 );
2721 let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
2722 canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
2723 canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
2724 }
2725 None => {}
2726 }
2727
2728 canvas.clear_clip();
2729
2730 let nothing_indicated = self.focused_cell.get().is_none()
2734 && self
2735 .row_selection
2736 .as_ref()
2737 .is_none_or(|s| s.selected_indices().is_empty())
2738 && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2739 if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2740 let inset = 1.0_f32;
2741 let rect = Rect::new(
2742 bounds.x + inset,
2743 bounds.y + inset,
2744 (bounds.width - inset * 2.0).max(0.0),
2745 (bounds.height - inset * 2.0).max(0.0),
2746 );
2747 canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2748 }
2749
2750 if let Some(x) = self.resize_preview_x.get() {
2752 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2753 canvas.fill_rect(
2754 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2755 BorderRole::Focused.resolve(colors),
2756 );
2757 }
2758 }
2759
2760 fn context_menu_key_target(&self) -> Option<WidgetId> {
2774 let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2775 self.row_selection
2776 .as_ref()
2777 .and_then(|s| s.selected_indices().first().copied())
2778 })?;
2779 let map = self.row_map.borrow();
2780 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2781 }
2782
2783 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2784 builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
2785 if let Some(ref label) = self.a11y_label {
2786 builder.set_name(label.resolve_now());
2787 }
2788 let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
2789 let col_count = self.columns.len();
2790 let n = builder.inner_mut();
2791 n.set_row_count(row_count);
2792 n.set_column_count(col_count);
2793
2794 if let Some((row, col)) = self.focused_cell.get()
2801 && let Some(cell_id) = self.realized_cell(row, col)
2802 {
2803 builder.set_active_descendant(widget_id_to_node_id(cell_id));
2804 }
2805 }
2806
2807 fn as_any(&self) -> Option<&dyn std::any::Any> {
2808 Some(self)
2809 }
2810
2811 fn children(&self) -> Vec<WidgetId> {
2812 let mut out: Vec<WidgetId> = Vec::new();
2815 if let Some(id) = self.body_pane_id {
2816 out.push(id);
2817 }
2818 if let Some(id) = self.empty_id {
2819 out.push(id);
2820 }
2821 if let Some(id) = self.scrollbar_id {
2822 out.push(id);
2823 }
2824 if let Some(id) = self.h_scrollbar_id {
2825 out.push(id);
2826 }
2827 if let Some(id) = self.header_row_id {
2828 out.push(id);
2829 }
2830 out
2831 }
2832
2833 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2834 let out: Vec<WidgetId> = [
2838 self.header_row_id,
2839 self.body_pane_id,
2840 self.empty_id,
2841 self.scrollbar_id,
2842 self.h_scrollbar_id,
2843 ]
2844 .into_iter()
2845 .flatten()
2846 .collect();
2847 if out.is_empty() { None } else { Some(out) }
2848 }
2849
2850 fn clips_children(&self) -> bool {
2851 true
2852 }
2853}
2854
2855#[cfg(test)]
2856mod tests {
2857 use super::*;
2858 use crate::table_view::column::{CellContext, ColumnWidth};
2859 use teksilo_canvas::SizeProposal;
2860 use teksilo_core::accesskit::Role;
2861 use teksilo_core::widget_tree::WidgetTree;
2862 use teksilo_data::{SortFilterTreeModel, TreeFilterMode, TreeModel};
2863 use teksilo_i18n::lit;
2864
2865 fn sample_tree() -> TreeModel<&'static str> {
2866 let t = TreeModel::new();
2867 let docs = t.insert_root(0, "docs");
2868 t.insert_child(docs, 0, "readme");
2869 t.insert_child(docs, 1, "guide");
2870 let src = t.insert_root(1, "src");
2871 t.insert_child(src, 0, "main.rs");
2872 t
2873 }
2874
2875 fn name_col() -> Column<&'static str> {
2876 Column::<&str>::new("name", lit!("Name"), |row, _: &CellContext| {
2877 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
2878 })
2879 .width(ColumnWidth::Flex(1.0))
2880 }
2881
2882 fn size_col() -> Column<&'static str> {
2883 Column::<&str>::new("size", lit!("Size"), |_row, _: &CellContext| {
2884 Box::new(crate::primitives::TextWidget::new(lit!("0")))
2885 })
2886 .width(ColumnWidth::Fixed(60.0))
2887 }
2888
2889 #[test]
2890 fn row_selection_click_repaints_immediately_without_expand_collapse() {
2891 use teksilo_canvas::Point;
2898 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2899 use teksilo_data::{SelectionMode, SelectionModel};
2900 let proxy = SortFilterTreeModel::new(sample_tree());
2901 let selection = SelectionModel::new(SelectionMode::Single);
2902 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2903 tree.add(
2904 TreeTableView::from_projection(proxy.clone())
2905 .add_column(name_col())
2906 .selection_mode(TableSelectionMode::SingleRow)
2907 .selection(selection.clone())
2908 .row_height(20.0),
2909 );
2910 tree.layout(SizeProposal {
2911 width: Some(400.0),
2912 height: Some(200.0),
2913 });
2914 assert_eq!(selection.selected_indices().len(), 0);
2916 let header_h = cp::HEADER_HEIGHT;
2919 let click_y = header_h + 10.0;
2920 tree.dispatch_event(WidgetEvent::PointerDown {
2921 position: Point::new(40.0, click_y),
2922 button: PointerButton::Primary,
2923 modifiers: Modifiers::NONE,
2924 });
2925 tree.dispatch_event(WidgetEvent::PointerUp {
2926 position: Point::new(40.0, click_y),
2927 button: PointerButton::Primary,
2928 modifiers: Modifiers::NONE,
2929 });
2930 assert_eq!(selection.selected_indices(), vec![0]);
2932 tree.layout(SizeProposal {
2939 width: Some(400.0),
2940 height: Some(200.0),
2941 });
2942 assert_eq!(selection.selected_indices(), vec![0]);
2943 }
2944
2945 #[test]
2946 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
2947 use teksilo_core::event::{Key, Modifiers};
2954 use teksilo_data::{SelectionMode, SelectionModel};
2955
2956 for (key, want, what) in [
2957 (
2958 Key::ArrowDown,
2959 0usize,
2960 "first ArrowDown enters at the first visible row",
2961 ),
2962 (
2963 Key::ArrowUp,
2964 3usize,
2965 "first ArrowUp enters at the last visible row",
2966 ),
2967 ] {
2968 let t = TreeModel::new();
2969 t.insert_root(0, "a");
2970 t.insert_root(1, "b");
2971 t.insert_root(2, "c");
2972 t.insert_root(3, "d");
2973 let proxy = SortFilterTreeModel::new(t);
2974 let selection = SelectionModel::new(SelectionMode::Single);
2975 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2976 let id = tree.add(
2977 TreeTableView::from_projection(proxy.clone())
2978 .add_column(name_col())
2979 .selection_mode(TableSelectionMode::SingleRow)
2980 .selection(selection.clone())
2981 .row_height(20.0),
2982 );
2983 tree.layout(SizeProposal {
2984 width: Some(400.0),
2985 height: Some(200.0),
2986 });
2987 tree.focus(id);
2988 assert_eq!(proxy.visible_count(), 4, "four flat roots");
2989 assert!(
2990 selection.selected_indices().is_empty(),
2991 "precondition: no cursor, nothing selected"
2992 );
2993
2994 tree.press_key(key, Modifiers::NONE);
2995 assert_eq!(selection.selected_indices(), vec![want], "{what}");
2996 }
2997 }
2998
2999 #[test]
3000 fn expanded_children_are_reachable_by_the_first_arrow() {
3001 use teksilo_core::event::{Key, Modifiers};
3007 use teksilo_data::{SelectionMode, SelectionModel};
3008
3009 let proxy = SortFilterTreeModel::new(sample_tree()); let selection = SelectionModel::new(SelectionMode::Single);
3011 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3012 let id = tree.add(
3013 TreeTableView::from_projection(proxy.clone())
3014 .add_column(name_col())
3015 .selection_mode(TableSelectionMode::SingleRow)
3016 .selection(selection.clone())
3017 .row_height(20.0),
3018 );
3019 tree.layout(SizeProposal {
3020 width: Some(400.0),
3021 height: Some(200.0),
3022 });
3023 tree.focus(id);
3024
3025 let last = proxy.visible_count() - 1;
3026 tree.press_key(Key::ArrowUp, Modifiers::NONE);
3027 assert_eq!(
3028 selection.selected_indices(),
3029 vec![last],
3030 "first ArrowUp enters at the last VISIBLE row, whatever the hierarchy shows"
3031 );
3032 }
3033
3034 #[test]
3035 fn row_click_moves_focus_so_arrow_nav_resumes_there() {
3036 use teksilo_canvas::Point;
3042 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
3043 use teksilo_data::{SelectionMode, SelectionModel};
3044 let t = TreeModel::new();
3045 t.insert_root(0, "a");
3046 t.insert_root(1, "b");
3047 t.insert_root(2, "c");
3048 t.insert_root(3, "d");
3049 let proxy = SortFilterTreeModel::new(t);
3050 let selection = SelectionModel::new(SelectionMode::Single);
3051 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3052 let id = tree.add(
3053 TreeTableView::from_projection(proxy.clone())
3054 .add_column(name_col())
3055 .selection_mode(TableSelectionMode::SingleRow)
3056 .selection(selection.clone())
3057 .row_height(20.0),
3058 );
3059 tree.layout(SizeProposal {
3060 width: Some(400.0),
3061 height: Some(200.0),
3062 });
3063 tree.focus(id);
3064 assert_eq!(proxy.visible_count(), 4, "four flat roots");
3065
3066 let click_y = cp::HEADER_HEIGHT + 1.0 * 20.0 + 10.0;
3068 tree.dispatch_event(WidgetEvent::PointerDown {
3069 position: Point::new(40.0, click_y),
3070 button: PointerButton::Primary,
3071 modifiers: Modifiers::NONE,
3072 });
3073 tree.dispatch_event(WidgetEvent::PointerUp {
3074 position: Point::new(40.0, click_y),
3075 button: PointerButton::Primary,
3076 modifiers: Modifiers::NONE,
3077 });
3078 assert_eq!(
3079 selection.selected_indices(),
3080 vec![1],
3081 "click selects flat row 1"
3082 );
3083
3084 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3086 assert_eq!(
3087 selection.selected_indices(),
3088 vec![2],
3089 "ArrowDown after a click resumes from the clicked row (1 → 2)"
3090 );
3091 }
3092
3093 #[test]
3094 fn role_is_treegrid() {
3095 let proxy = SortFilterTreeModel::new(sample_tree());
3096 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3097 let id = tree.add(
3098 TreeTableView::from_projection(proxy)
3099 .add_column(name_col())
3100 .add_column(size_col())
3101 .row_height(20.0),
3102 );
3103 tree.layout(SizeProposal {
3104 width: Some(400.0),
3105 height: Some(200.0),
3106 });
3107 let info = tree.accessibility_node(id);
3108 assert_eq!(info.role(), Role::TreeGrid);
3109 }
3110
3111 #[test]
3112 fn initial_state_shows_only_roots() {
3113 let proxy = SortFilterTreeModel::new(sample_tree());
3114 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3115 let _id = tree.add(
3116 TreeTableView::from_projection(proxy.clone())
3117 .add_column(name_col())
3118 .row_height(20.0),
3119 );
3120 tree.layout(SizeProposal {
3121 width: Some(400.0),
3122 height: Some(200.0),
3123 });
3124 assert_eq!(proxy.visible_count(), 2); }
3126
3127 #[test]
3128 fn expand_via_widget_reveals_children() {
3129 let proxy = SortFilterTreeModel::new(sample_tree());
3130 let docs = proxy.tree().root(0);
3131 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3132 let id = tree.add(
3133 TreeTableView::from_projection(proxy.clone())
3134 .add_column(name_col())
3135 .row_height(20.0),
3136 );
3137 tree.layout(SizeProposal {
3138 width: Some(400.0),
3139 height: Some(200.0),
3140 });
3141 {
3142 let any = tree.widget_as_any(id).unwrap();
3143 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3144 tt.expand(docs);
3145 }
3146 assert_eq!(proxy.visible_count(), 4); }
3148
3149 #[test]
3150 fn arrow_right_expands_and_left_collapses_on_tree_column() {
3151 let proxy = SortFilterTreeModel::new(sample_tree());
3152 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3153 let id = tree.add(
3154 TreeTableView::from_projection(proxy.clone())
3155 .add_column(name_col())
3156 .row_height(20.0),
3157 );
3158 tree.layout(SizeProposal {
3159 width: Some(400.0),
3160 height: Some(200.0),
3161 });
3162 tree.focus(id);
3163 {
3164 let any = tree.widget_as_any(id).unwrap();
3165 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3166 tt.set_focused_cell(0, 0);
3167 }
3168 tree.press_key(
3171 teksilo_core::event::Key::ArrowRight,
3172 teksilo_core::event::Modifiers::NONE,
3173 );
3174 assert_eq!(proxy.visible_count(), 4);
3175 tree.press_key(
3177 teksilo_core::event::Key::ArrowLeft,
3178 teksilo_core::event::Modifiers::NONE,
3179 );
3180 assert_eq!(proxy.visible_count(), 2);
3181 }
3182
3183 fn slice_rows() -> Vec<teksilo_data::TreeRow<u64, &'static str>> {
3186 use teksilo_data::TreeRow;
3187 vec![
3188 TreeRow::new(1, "docs", 0),
3189 TreeRow::new(2, "readme", 1),
3190 TreeRow::new(3, "guide", 1),
3191 TreeRow::new(4, "src", 0),
3192 ]
3193 }
3194
3195 fn external_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
3196 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3197 slice.set_source(slice_rows);
3198 slice.reload();
3199 slice
3200 }
3201
3202 #[test]
3203 fn from_source_renders_an_external_tree_without_a_tree_model() {
3204 let slice = external_slice();
3208 slice.expand(&1);
3209 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3210 let id = tree.add(
3211 TreeTableView::from_source(slice.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!(slice.visible_count(), 4, "docs + 2 children + src");
3220
3221 let any = tree.widget_as_any(id).unwrap();
3222 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3223 assert!(
3224 tt.projection().is_none(),
3225 "a source-backed view has no TreeModel projection to expose"
3226 );
3227 assert!(tt.body_pane_id.is_some(), "rows rendered from the source");
3228 }
3229
3230 #[test]
3231 fn from_source_keyed_selection_survives_a_full_resource() {
3232 let slice = external_slice();
3236 slice.expand(&1);
3237 let keyed = KeyedSelectionModel::<u64>::new(teksilo_data::SelectionMode::Multi);
3238 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3239 let _id = tree.add(
3240 TreeTableView::from_source_keyed(slice.clone(), keyed.clone())
3241 .add_column(name_col())
3242 .row_height(20.0),
3243 );
3244 tree.layout(SizeProposal {
3245 width: Some(400.0),
3246 height: Some(200.0),
3247 });
3248
3249 keyed.select(3); assert!(keyed.is_selected(&3));
3251
3252 slice.reload();
3254 assert!(
3255 keyed.is_selected(&3),
3256 "a domain-keyed selection must survive a re-source"
3257 );
3258 }
3259
3260 #[test]
3261 fn from_source_supports_drag_reorder_like_the_tree_view() {
3262 use std::cell::RefCell;
3266 use std::rc::Rc;
3267 use teksilo_canvas::Point;
3268
3269 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3271 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3272 {
3273 let order = order.clone();
3274 slice.set_source(move || {
3275 let names: std::collections::HashMap<u64, &'static str> =
3276 [(1, "docs"), (4, "src")].into_iter().collect();
3277 order
3278 .borrow()
3279 .iter()
3280 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3281 .collect()
3282 });
3283 }
3284 {
3285 let order = order.clone();
3286 slice.set_reorder(move |dragged, target, _pos| {
3288 let mut o = order.borrow_mut();
3289 let Some(from) = o.iter().position(|k| *k == dragged) else {
3290 return false;
3291 };
3292 let item = o.remove(from);
3293 let to = o
3294 .iter()
3295 .position(|k| *k == target)
3296 .map_or(o.len(), |i| i + 1);
3297 o.insert(to, item);
3298 true
3299 });
3300 }
3301 slice.set_drag_policy(|_| teksilo_data::DragEligibility::CanDrag);
3304 slice.reload();
3305 assert_eq!(*order.borrow(), vec![1, 4], "docs, src");
3306
3307 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3308 tree.add(
3309 TreeTableView::from_source(slice.clone())
3310 .add_column(name_col())
3311 .reorderable(true)
3312 .row_height(20.0),
3313 );
3314 tree.layout(SizeProposal {
3315 width: Some(400.0),
3316 height: Some(300.0),
3317 });
3318
3319 let h = cp::HEADER_HEIGHT;
3321 drag(
3322 &mut tree,
3323 Point::new(40.0, h + 10.0),
3324 Point::new(40.0, h + 38.0),
3325 );
3326 assert_eq!(
3327 *order.borrow(),
3328 vec![4, 1],
3329 "the source applied the reorder: src now precedes docs"
3330 );
3331 }
3332
3333 #[test]
3334 fn a_source_that_forbids_dragging_a_row_is_honored() {
3335 use std::cell::RefCell;
3338 use std::rc::Rc;
3339 use teksilo_canvas::Point;
3340
3341 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3342 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3343 {
3344 let order = order.clone();
3345 slice.set_source(move || {
3346 let names: std::collections::HashMap<u64, &'static str> =
3347 [(1, "docs"), (4, "src")].into_iter().collect();
3348 order
3349 .borrow()
3350 .iter()
3351 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3352 .collect()
3353 });
3354 }
3355 {
3356 let order = order.clone();
3357 slice.set_reorder(move |dragged, target, _pos| {
3358 let mut o = order.borrow_mut();
3359 let Some(from) = o.iter().position(|k| *k == dragged) else {
3360 return false;
3361 };
3362 let item = o.remove(from);
3363 let to = o
3364 .iter()
3365 .position(|k| *k == target)
3366 .map_or(o.len(), |i| i + 1);
3367 o.insert(to, item);
3368 true
3369 });
3370 }
3371 slice.set_drag_policy(|k| {
3373 if *k == 1 {
3374 teksilo_data::DragEligibility::NoDrag
3375 } else {
3376 teksilo_data::DragEligibility::CanDrag
3377 }
3378 });
3379 slice.reload();
3380
3381 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3382 tree.add(
3383 TreeTableView::from_source(slice.clone())
3384 .add_column(name_col())
3385 .reorderable(true)
3386 .row_height(20.0),
3387 );
3388 tree.layout(SizeProposal {
3389 width: Some(400.0),
3390 height: Some(300.0),
3391 });
3392
3393 let h = cp::HEADER_HEIGHT;
3394 drag(
3395 &mut tree,
3396 Point::new(40.0, h + 10.0),
3397 Point::new(40.0, h + 38.0),
3398 );
3399 assert_eq!(
3400 *order.borrow(),
3401 vec![1, 4],
3402 "a NoDrag row must not move, even onto a valid target"
3403 );
3404 }
3405
3406 #[test]
3407 fn drop_on_the_middle_third_reparents_into_the_target() {
3408 use teksilo_canvas::Point;
3411 let proxy = SortFilterTreeModel::new(sample_tree());
3412 proxy.collapse_all(); let docs = proxy.tree().root(0);
3414 let src = proxy.tree().root(1);
3415 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3416 tree.add(
3417 TreeTableView::from_projection(proxy.clone())
3418 .add_column(name_col())
3419 .reorderable(true)
3420 .row_height(20.0),
3421 );
3422 tree.layout(SizeProposal {
3423 width: Some(400.0),
3424 height: Some(300.0),
3425 });
3426 let h = cp::HEADER_HEIGHT;
3427 drag(
3430 &mut tree,
3431 Point::new(40.0, h + 10.0),
3432 Point::new(40.0, h + 30.0),
3433 );
3434 assert_eq!(proxy.tree().root_count(), 1, "docs is no longer a root");
3435 assert_eq!(
3436 proxy.tree().parent(docs),
3437 Some(src),
3438 "docs became a child of src"
3439 );
3440 }
3441
3442 #[test]
3443 fn the_into_box_is_inset_and_the_insertion_line_is_indented() {
3444 use teksilo_canvas::{DrawCommand, Point, ShapeKind};
3449 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3450
3451 let proxy = SortFilterTreeModel::new(sample_tree());
3452 proxy.expand_all(); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3454 tree.add(
3455 TreeTableView::from_projection(proxy.clone())
3456 .add_column(name_col())
3457 .reorderable(true)
3458 .row_height(20.0),
3459 );
3460 tree.layout(SizeProposal {
3461 width: Some(400.0),
3462 height: Some(300.0),
3463 });
3464 let h = cp::HEADER_HEIGHT;
3465
3466 let start = Point::new(40.0, h + 90.0);
3469 tree.dispatch_event(WidgetEvent::PointerDown {
3470 position: start,
3471 button: PointerButton::Primary,
3472 modifiers: Modifiers::NONE,
3473 });
3474 tree.dispatch_event(WidgetEvent::PointerMove {
3475 position: Point::new(52.0, start.y),
3476 });
3477
3478 tree.dispatch_event(WidgetEvent::PointerMove {
3480 position: Point::new(52.0, h + 38.0),
3481 });
3482 let frame = tree.render();
3483 let line_recipe = teksilo_core::styles::ListInsertionRecipe::default();
3484 let lines: Vec<_> = frame
3488 .decorations
3489 .iter()
3490 .filter(|d| (d.rect[3] - line_recipe.thickness).abs() < 0.01 && d.rect[2] > 100.0)
3491 .collect();
3492 assert_eq!(lines.len(), 1, "exactly one insertion line, got {lines:?}");
3493 assert!(
3494 lines[0].rect[0] >= line_recipe.indent_step,
3495 "the After line must start one indent step in for a depth-1 target, \
3496 got x = {} (step {})",
3497 lines[0].rect[0],
3498 line_recipe.indent_step
3499 );
3500
3501 tree.dispatch_event(WidgetEvent::PointerMove {
3503 position: Point::new(52.0, h + 10.0),
3504 });
3505 let frame = tree.render();
3506 let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
3507 let boxes: Vec<_> = frame
3508 .draw_order
3509 .iter()
3510 .filter_map(|c| match c {
3511 DrawCommand::Shape(i) => frame.shapes.get(*i),
3512 _ => None,
3513 })
3514 .filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
3515 .filter(|s| (s.screen[3] - (20.0 - recipe.inset * 2.0)).abs() < 0.01)
3516 .collect();
3517 assert!(
3518 !boxes.is_empty(),
3519 "no inset rounded box for the Into hover; shapes = {:?}",
3520 frame.shapes.iter().map(|s| s.screen).collect::<Vec<_>>()
3521 );
3522 assert!(
3525 boxes
3526 .iter()
3527 .all(|s| (s.screen[1] - (h + recipe.inset)).abs() < 0.01),
3528 "the Into box must be inset from the row's top edge ({}), got {:?}",
3529 h,
3530 boxes.iter().map(|s| s.screen).collect::<Vec<_>>()
3531 );
3532 assert!(
3533 boxes.iter().any(|s| s.stroke_width > 0.0)
3534 && boxes.iter().any(|s| s.stroke_width == 0.0),
3535 "the Into box needs both a wash and an outline"
3536 );
3537 }
3538
3539 #[test]
3540 fn an_active_sort_suppresses_drag_reorder() {
3541 use teksilo_canvas::Point;
3545 let proxy = SortFilterTreeModel::new(sample_tree())
3546 .with_comparator("name", |a: &&'static str, b: &&'static str| a.cmp(b));
3547 proxy.collapse_all();
3548 let docs = proxy.tree().root(0);
3549 let src = proxy.tree().root(1);
3550 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3551 let id = tree.add(
3552 TreeTableView::from_projection(proxy.clone())
3553 .add_column(name_col())
3554 .reorderable(true)
3555 .row_height(20.0),
3556 );
3557 tree.layout(SizeProposal {
3558 width: Some(400.0),
3559 height: Some(300.0),
3560 });
3561 {
3562 let any = tree.widget_as_any(id).unwrap();
3563 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3564 tt.set_sort(Some("name"), SortDirection::Ascending);
3565 }
3566 tree.layout(SizeProposal {
3567 width: Some(400.0),
3568 height: Some(300.0),
3569 });
3570
3571 let h = cp::HEADER_HEIGHT;
3572 drag(
3573 &mut tree,
3574 Point::new(40.0, h + 10.0),
3575 Point::new(40.0, h + 38.0),
3576 );
3577 assert_eq!(
3578 proxy.tree().root(0),
3579 docs,
3580 "structure unchanged while sorted"
3581 );
3582 assert_eq!(
3583 proxy.tree().root(1),
3584 src,
3585 "structure unchanged while sorted"
3586 );
3587 }
3588
3589 #[test]
3590 fn an_open_cell_editor_follows_its_row_and_closes_if_the_row_vanishes() {
3591 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3595 let all: Vec<u64> = vec![1, 2, 3];
3596 slice.set_source(move || {
3597 let names: std::collections::HashMap<u64, &'static str> =
3598 [(1, "one"), (2, "two"), (3, "three")].into_iter().collect();
3599 all.iter()
3600 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3601 .collect()
3602 });
3603 slice.reload();
3604
3605 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3606 let id = tree.add(
3607 TreeTableView::from_source(slice.clone())
3608 .add_column(name_col())
3609 .row_height(20.0),
3610 );
3611 let proposal = SizeProposal {
3612 width: Some(400.0),
3613 height: Some(200.0),
3614 };
3615 tree.layout(proposal);
3616
3617 {
3619 let any = tree.widget_as_any(id).unwrap();
3620 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3621 tt.begin_edit(2, "name");
3622 assert_eq!(tt.editing_cell_signal().get(), Some((2, 0)));
3623 }
3624 tree.layout(proposal); let fewer: Vec<u64> = vec![2, 3];
3628 slice.set_source(move || {
3629 let names: std::collections::HashMap<u64, &'static str> =
3630 [(2, "two"), (3, "three")].into_iter().collect();
3631 fewer
3632 .iter()
3633 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3634 .collect()
3635 });
3636 slice.reload();
3637 tree.layout(proposal);
3638 {
3639 let any = tree.widget_as_any(id).unwrap();
3640 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3641 assert_eq!(
3642 tt.editing_cell_signal().get(),
3643 Some((1, 0)),
3644 "the editor must follow its row to index 1, not stay on index 2"
3645 );
3646 }
3647
3648 let last: Vec<u64> = vec![2];
3650 slice.set_source(move || {
3651 last.iter()
3652 .map(|k| teksilo_data::TreeRow::new(*k, "two", 0))
3653 .collect()
3654 });
3655 slice.reload();
3656 tree.layout(proposal);
3657 let any = tree.widget_as_any(id).unwrap();
3658 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3659 assert_eq!(
3660 tt.editing_cell_signal().get(),
3661 None,
3662 "the editor must close when its row is gone"
3663 );
3664 }
3665
3666 #[test]
3667 fn default_selection_mode_is_multi_row() {
3668 use teksilo_core::event::{Key, Modifiers};
3672 let proxy = SortFilterTreeModel::new(wide_tree(10));
3673 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3674 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3675 let id = tree.add(
3676 TreeTableView::from_projection(proxy)
3677 .add_column(name_col())
3678 .selection(selection.clone())
3679 .row_height(20.0),
3680 );
3681 tree.layout(SizeProposal {
3682 width: Some(400.0),
3683 height: Some(200.0),
3684 });
3685 tree.focus(id);
3686 {
3687 let any = tree.widget_as_any(id).unwrap();
3688 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3689 tt.set_focused_cell(0, 0);
3690 }
3691 selection.select(0);
3692 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3693 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3694 assert_eq!(
3695 selection.selection_signal().get().len(),
3696 3,
3697 "default mode must extend a multi-row selection"
3698 );
3699 }
3700
3701 #[test]
3702 fn ctrl_arrow_moves_cursor_without_touching_selection() {
3703 use teksilo_core::event::{Key, Modifiers};
3708 let proxy = SortFilterTreeModel::new(wide_tree(5));
3709 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3710 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3711 let id = tree.add(
3712 TreeTableView::from_projection(proxy)
3713 .add_column(name_col())
3714 .selection(selection.clone())
3715 .row_height(20.0),
3716 );
3717 tree.layout(SizeProposal {
3718 width: Some(400.0),
3719 height: Some(200.0),
3720 });
3721 tree.focus(id);
3722 {
3723 let any = tree.widget_as_any(id).unwrap();
3724 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3725 tt.set_focused_cell(0, 0);
3726 }
3727 selection.select(0);
3728
3729 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3730 {
3731 let any = tree.widget_as_any(id).unwrap();
3732 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3733 assert_eq!(
3734 tt.focused_cell_signal().get(),
3735 Some((1, 0)),
3736 "cursor advances"
3737 );
3738 }
3739 assert_eq!(
3740 selection.selected_indices(),
3741 vec![0],
3742 "Ctrl+Arrow must not touch selection"
3743 );
3744
3745 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3747 {
3748 let any = tree.widget_as_any(id).unwrap();
3749 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3750 assert_eq!(tt.focused_cell_signal().get(), Some((2, 0)));
3751 }
3752 assert_eq!(
3753 selection.selected_indices(),
3754 vec![2],
3755 "plain Arrow selects the row it lands on"
3756 );
3757 }
3758
3759 #[test]
3760 fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
3761 use teksilo_core::event::{Key, Modifiers};
3762 let proxy = SortFilterTreeModel::new(wide_tree(5));
3763 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3764 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3765 let id = tree.add(
3766 TreeTableView::from_projection(proxy)
3767 .add_column(name_col())
3768 .selection(selection.clone())
3769 .row_height(20.0),
3770 );
3771 tree.layout(SizeProposal {
3772 width: Some(400.0),
3773 height: Some(200.0),
3774 });
3775 tree.focus(id);
3776 {
3777 let any = tree.widget_as_any(id).unwrap();
3778 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3779 tt.set_focused_cell(0, 0);
3780 }
3781 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3782 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3783 assert!(selection.selected_indices().is_empty());
3784
3785 tree.press_key(Key::Space, Modifiers::CTRL);
3786 assert_eq!(
3787 selection.selected_indices(),
3788 vec![2],
3789 "Ctrl+Space toggles the focused row on"
3790 );
3791
3792 tree.press_key(Key::Space, Modifiers::CTRL);
3793 assert!(
3794 selection.selected_indices().is_empty(),
3795 "Ctrl+Space toggles it back off"
3796 );
3797 }
3798
3799 #[test]
3800 fn empty_view_renders_when_the_tree_has_no_rows() {
3801 let proxy = SortFilterTreeModel::new(TreeModel::<&'static str>::new());
3802 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3803 let id = tree.add(
3804 TreeTableView::from_projection(proxy)
3805 .add_column(name_col())
3806 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("Nothing here"))))
3807 .row_height(20.0),
3808 );
3809 tree.layout(SizeProposal {
3810 width: Some(400.0),
3811 height: Some(200.0),
3812 });
3813 let any = tree.widget_as_any(id).unwrap();
3814 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3815 assert!(tt.empty_id.is_some(), "placeholder should be built");
3816 assert!(tt.body_pane_id.is_none(), "no body pane for zero rows");
3817 }
3818
3819 #[test]
3820 fn empty_view_appears_when_live_rows_drop_to_zero() {
3821 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3826 let needle = t.to_string();
3827 Box::new(move |r: &&'static str| r.contains(&needle))
3828 });
3829 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3830 let id = tree.add(
3831 TreeTableView::from_projection(proxy.clone())
3832 .add_column(name_col())
3833 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3834 .row_height(20.0),
3835 );
3836 let proposal = SizeProposal {
3837 width: Some(400.0),
3838 height: Some(200.0),
3839 };
3840 tree.layout(proposal);
3841 {
3842 let any = tree.widget_as_any(id).unwrap();
3843 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3844 assert!(tt.body_pane_id.is_some(), "starts with a body pane");
3845 assert!(tt.empty_id.is_none(), "no placeholder while rows exist");
3846 }
3847
3848 proxy.set_filter("name", "zzz-no-such-row");
3849 tree.layout(proposal);
3850 assert_eq!(proxy.visible_count(), 0);
3851 let any = tree.widget_as_any(id).unwrap();
3852 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3853 assert!(
3854 tt.empty_id.is_some(),
3855 "placeholder must appear once rows drop to zero"
3856 );
3857 assert!(tt.body_pane_id.is_none(), "stale body pane must be gone");
3858 }
3859
3860 #[test]
3861 fn empty_view_renders_when_a_filter_matches_nothing() {
3862 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3865 let needle = t.to_string();
3866 Box::new(move |r: &&'static str| r.contains(&needle))
3867 });
3868 proxy.set_filter("name", "zzz-no-such-row");
3869 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3870 let id = tree.add(
3871 TreeTableView::from_projection(proxy.clone())
3872 .add_column(name_col())
3873 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3874 .row_height(20.0),
3875 );
3876 tree.layout(SizeProposal {
3877 width: Some(400.0),
3878 height: Some(200.0),
3879 });
3880 assert_eq!(proxy.visible_count(), 0);
3881 let any = tree.widget_as_any(id).unwrap();
3882 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3883 assert!(tt.empty_id.is_some());
3884 }
3885
3886 #[test]
3887 fn scroll_to_row_and_ensure_row_visible_move_the_offset() {
3888 let proxy = SortFilterTreeModel::new(wide_tree(100));
3889 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3890 let id = tree.add(
3891 TreeTableView::from_projection(proxy)
3892 .add_column(name_col())
3893 .row_height(20.0),
3894 );
3895 tree.layout(SizeProposal {
3896 width: Some(400.0),
3897 height: Some(200.0),
3898 });
3899 let any = tree.widget_as_any(id).unwrap();
3900 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3901
3902 tt.scroll_to_row(50);
3904 assert!((tt.scroll_y_signal().get() - 1000.0).abs() < 1.0);
3905
3906 let before = tt.scroll_y_signal().get();
3908 tt.ensure_row_visible(51);
3909 assert!((tt.scroll_y_signal().get() - before).abs() < f32::EPSILON);
3910
3911 tt.ensure_row_visible(10);
3913 assert!((tt.scroll_y_signal().get() - 200.0).abs() < 1.0);
3914 }
3915
3916 #[test]
3917 fn begin_edit_resolves_a_column_id_and_end_edit_clears() {
3918 let proxy = SortFilterTreeModel::new(sample_tree());
3919 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3920 let id = tree.add(
3921 TreeTableView::from_projection(proxy)
3922 .add_column(name_col())
3923 .add_column(size_col())
3924 .row_height(20.0),
3925 );
3926 tree.layout(SizeProposal {
3927 width: Some(400.0),
3928 height: Some(200.0),
3929 });
3930 let any = tree.widget_as_any(id).unwrap();
3931 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3932
3933 tt.begin_edit(1, "size");
3934 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
3935 tt.end_edit();
3936 assert_eq!(tt.editing_cell_signal().get(), None);
3937
3938 tt.begin_edit(0, "no-such-column");
3940 assert_eq!(tt.editing_cell_signal().get(), None);
3941
3942 tt.begin_edit(9999, "name");
3946 assert_eq!(tt.editing_cell_signal().get(), None);
3947
3948 tt.begin_edit(1, "size");
3950 tt.begin_edit(9999, "size");
3951 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
3952 }
3953
3954 #[test]
3955 fn begin_edit_resolves_before_the_view_is_mounted() {
3956 let proxy = SortFilterTreeModel::new(sample_tree());
3968 let view = TreeTableView::from_projection(proxy)
3969 .add_column(name_col())
3970 .add_column(size_col().pinned(PinnedSide::Leading))
3971 .row_height(20.0);
3972
3973 view.begin_edit(1, "name");
3974 assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
3975
3976 view.end_edit();
3978 view.begin_edit(0, "no-such-column");
3979 assert_eq!(view.editing_cell_signal().get(), None);
3980 view.begin_edit(9999, "name");
3981 assert_eq!(view.editing_cell_signal().get(), None);
3982
3983 view.begin_edit(1, "name");
3986 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3987 let id = tree.add(view);
3988 tree.layout(SizeProposal {
3989 width: Some(400.0),
3990 height: Some(200.0),
3991 });
3992 let tt = tree
3993 .widget_as_any(id)
3994 .unwrap()
3995 .downcast_ref::<TreeTableView<&'static str>>()
3996 .unwrap();
3997 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
3998 }
3999
4000 #[test]
4001 fn column_imperatives_write_their_signals() {
4002 let proxy = SortFilterTreeModel::new(sample_tree());
4003 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4004 let id = tree.add(
4005 TreeTableView::from_projection(proxy)
4006 .add_column(name_col())
4007 .add_column(size_col())
4008 .row_height(20.0),
4009 );
4010 tree.layout(SizeProposal {
4011 width: Some(400.0),
4012 height: Some(200.0),
4013 });
4014 let any = tree.widget_as_any(id).unwrap();
4015 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4016
4017 tt.set_column_width("name", 123.0);
4018 assert_eq!(tt.column_widths_signal().get().get("name"), Some(&123.0));
4019 tt.set_column_width("name", 0.0);
4021 assert!(!tt.column_widths_signal().get().contains_key("name"));
4022
4023 assert_eq!(
4026 tt.display_order(),
4027 vec![0, 1],
4028 "declaration order initially"
4029 );
4030
4031 tt.set_column_order(vec!["size".into(), "name".into()]);
4032 assert_eq!(tt.column_order_signal().get(), vec!["size", "name"]);
4033 assert_eq!(
4034 tt.display_order(),
4035 vec![1, 0],
4036 "set_column_order must reorder the display, not only the signal"
4037 );
4038
4039 tt.set_column_pinning("name", PinnedSide::Leading);
4042 assert_eq!(
4043 tt.column_pinning_signal().get().get("name"),
4044 Some(&PinnedSide::Leading)
4045 );
4046 assert_eq!(
4047 tt.display_order(),
4048 vec![0, 1],
4049 "set_column_pinning must pull the pinned column back to the front"
4050 );
4051 tt.set_column_pinning("name", PinnedSide::None);
4052 assert!(!tt.column_pinning_signal().get().contains_key("name"));
4053 assert_eq!(
4054 tt.display_order(),
4055 vec![1, 0],
4056 "clearing the pin restores the order list's arrangement"
4057 );
4058
4059 tt.set_sort(Some("name"), SortDirection::Ascending);
4060 assert!(tt.sort_signal().get().is_some());
4061 tt.clear_sort();
4062 assert_eq!(tt.sort_signal().get(), None);
4063 }
4064
4065 #[test]
4076 fn column_pinning_remaps_focused_cell_to_follow_its_column() {
4077 let proxy = SortFilterTreeModel::new(sample_tree());
4078 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4079 let id = tree.add(
4080 TreeTableView::from_projection(proxy)
4081 .add_column(name_col())
4082 .add_column(size_col())
4083 .row_height(20.0),
4084 );
4085 tree.layout(SizeProposal {
4086 width: Some(400.0),
4087 height: Some(200.0),
4088 });
4089 tree.focus(id);
4090 {
4091 let any = tree.widget_as_any(id).unwrap();
4092 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4093 tt.set_focused_cell(0, 1); tt.set_column_pinning("size", PinnedSide::Leading);
4098 }
4099 tree.layout(SizeProposal {
4100 width: Some(400.0),
4101 height: Some(200.0),
4102 });
4103 let any = tree.widget_as_any(id).unwrap();
4104 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4105 assert_eq!(
4106 tt.focused_cell_signal().get(),
4107 Some((0, 0)),
4108 "focus must follow `size` to its new display position"
4109 );
4110 }
4111
4112 #[test]
4113 fn column_pinning_remaps_editing_cell_to_follow_its_column() {
4114 let proxy = SortFilterTreeModel::new(sample_tree());
4115 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4116 let id = tree.add(
4117 TreeTableView::from_projection(proxy)
4118 .add_column(name_col())
4119 .add_column(size_col())
4120 .row_height(20.0),
4121 );
4122 tree.layout(SizeProposal {
4123 width: Some(400.0),
4124 height: Some(200.0),
4125 });
4126 {
4127 let any = tree.widget_as_any(id).unwrap();
4128 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4129 tt.begin_edit(0, "size"); assert_eq!(tt.editing_cell_signal().get(), Some((0, 1)));
4131 tt.set_column_pinning("size", PinnedSide::Leading);
4132 }
4133 tree.layout(SizeProposal {
4134 width: Some(400.0),
4135 height: Some(200.0),
4136 });
4137 let any = tree.widget_as_any(id).unwrap();
4138 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4139 assert_eq!(
4140 tt.editing_cell_signal().get(),
4141 Some((0, 0)),
4142 "the open editor must follow `size` to its new display \
4143 position, not relabel onto whatever column now sits at \
4144 position 1"
4145 );
4146 }
4147
4148 #[test]
4149 fn column_pinning_remaps_cell_selection_to_follow_its_column() {
4150 let proxy = SortFilterTreeModel::new(sample_tree());
4151 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4152 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4153 let id = tree.add(
4154 TreeTableView::from_projection(proxy)
4155 .add_column(name_col())
4156 .add_column(size_col())
4157 .row_height(20.0)
4158 .selection_mode(TableSelectionMode::MultiCell)
4159 .cell_selection(cs.clone()),
4160 );
4161 tree.layout(SizeProposal {
4162 width: Some(400.0),
4163 height: Some(200.0),
4164 });
4165 cs.select(0, 1); {
4167 let any = tree.widget_as_any(id).unwrap();
4168 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4169 tt.set_column_pinning("size", PinnedSide::Leading);
4170 }
4171 tree.layout(SizeProposal {
4172 width: Some(400.0),
4173 height: Some(200.0),
4174 });
4175 assert!(
4176 cs.is_selected(0, 0),
4177 "selection must follow `size` to its new display position"
4178 );
4179 assert!(!cs.is_selected(0, 1));
4180 }
4181
4182 #[test]
4183 fn collapsing_a_node_above_a_selected_cell_clears_stale_cell_selection() {
4184 let proxy = SortFilterTreeModel::new(sample_tree());
4190 let docs = proxy.tree().root(0);
4191 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4192 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4193 let id = tree.add(
4194 TreeTableView::from_projection(proxy.clone())
4195 .add_column(name_col())
4196 .row_height(20.0)
4197 .selection_mode(TableSelectionMode::MultiCell)
4198 .cell_selection(cs.clone()),
4199 );
4200 tree.layout(SizeProposal {
4201 width: Some(400.0),
4202 height: Some(200.0),
4203 });
4204 {
4205 let any = tree.widget_as_any(id).unwrap();
4206 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4207 tt.expand(docs);
4208 }
4209 tree.layout(SizeProposal {
4210 width: Some(400.0),
4211 height: Some(200.0),
4212 });
4213 assert_eq!(proxy.visible_count(), 4); cs.select(3, 0); {
4216 let any = tree.widget_as_any(id).unwrap();
4217 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4218 tt.collapse(docs);
4219 }
4220 tree.layout(SizeProposal {
4221 width: Some(400.0),
4222 height: Some(200.0),
4223 });
4224 assert_eq!(proxy.visible_count(), 2); assert_eq!(
4226 cs.count(),
4227 0,
4228 "a stale (row, col) surviving the collapse must be dropped, not \
4229 silently point at whatever node now sits at flat row 3"
4230 );
4231 }
4232
4233 #[test]
4234 fn content_only_update_leaves_cell_selection_untouched() {
4235 let model = sample_tree();
4239 let proxy = SortFilterTreeModel::new(model);
4240 let docs = proxy.tree().root(0);
4241 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4242 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4243 tree.add(
4244 TreeTableView::from_projection(proxy.clone())
4245 .add_column(name_col())
4246 .row_height(20.0)
4247 .selection_mode(TableSelectionMode::MultiCell)
4248 .cell_selection(cs.clone()),
4249 );
4250 tree.layout(SizeProposal {
4251 width: Some(400.0),
4252 height: Some(200.0),
4253 });
4254 cs.select(0, 0); proxy.tree().update(docs, "docs-renamed");
4257 tree.layout(SizeProposal {
4258 width: Some(400.0),
4259 height: Some(200.0),
4260 });
4261 assert!(
4262 cs.is_selected(0, 0),
4263 "a content-only update must leave an unrelated selection alone"
4264 );
4265 }
4266
4267 #[test]
4270 fn focused_cell_sets_active_descendant_to_the_cell_node() {
4271 let proxy = SortFilterTreeModel::new(sample_tree());
4272 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4273 let id = tree.add(
4274 TreeTableView::from_projection(proxy)
4275 .add_column(name_col())
4276 .add_column(size_col())
4277 .row_height(20.0),
4278 );
4279 tree.layout(SizeProposal {
4280 width: Some(400.0),
4281 height: Some(200.0),
4282 });
4283 tree.focus(id);
4284 {
4285 let any = tree.widget_as_any(id).unwrap();
4286 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4287 tt.set_focused_cell(0, 1);
4288 }
4289 let update = tree.sync_accessibility();
4290 let root_node_id = widget_id_to_node_id(id);
4291 let root_node = update
4292 .nodes
4293 .iter()
4294 .find(|(nid, _)| *nid == root_node_id)
4295 .map(|(_, n)| n)
4296 .expect("root node present in the AT tree");
4297 let active = root_node
4298 .active_descendant()
4299 .expect("a focused cell must set active_descendant");
4300 let cell_node = update
4301 .nodes
4302 .iter()
4303 .find(|(nid, _)| *nid == active)
4304 .map(|(_, n)| n)
4305 .expect("active_descendant must reference a node present in the TreeUpdate");
4306 assert_eq!(cell_node.role(), Role::Cell);
4307 }
4308
4309 #[test]
4310 fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
4311 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4312 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4313 let id = tree.add(
4314 TreeTableView::from_projection(proxy)
4315 .add_column(name_col())
4316 .row_height(20.0),
4317 );
4318 tree.layout(SizeProposal {
4319 width: Some(400.0),
4320 height: Some(200.0),
4321 });
4322 tree.focus(id);
4323 {
4324 let any = tree.widget_as_any(id).unwrap();
4325 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4326 tt.set_focused_cell(1, 0);
4327 }
4328 let root_node_id = widget_id_to_node_id(id);
4329 let update = tree.sync_accessibility();
4330 let active_before = update
4331 .nodes
4332 .iter()
4333 .find(|(nid, _)| *nid == root_node_id)
4334 .and_then(|(_, n)| n.active_descendant());
4335 assert!(active_before.is_some(), "row 1 is realized initially");
4336
4337 let signal = {
4342 let any = tree.widget_as_any(id).unwrap();
4343 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4344 tt.scroll_y_signal().clone()
4345 };
4346 signal.set(2000.0);
4347 tree.request_frame();
4348 tree.layout(SizeProposal {
4349 width: Some(400.0),
4350 height: Some(200.0),
4351 });
4352
4353 let update = tree.sync_accessibility();
4354 let active_after = update
4355 .nodes
4356 .iter()
4357 .find(|(nid, _)| *nid == root_node_id)
4358 .and_then(|(_, n)| n.active_descendant());
4359 assert_eq!(
4360 active_after, None,
4361 "a focused cell that scrolled out of realization must not leave \
4362 a stale active_descendant pointing at a destroyed node"
4363 );
4364 }
4365
4366 #[test]
4367 fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
4368 use std::cell::RefCell;
4375 use std::ops::Range;
4376 use teksilo_data::{FlatEntry, RowState};
4377
4378 struct Windowed {
4379 total: usize,
4380 requested: Rc<RefCell<Vec<Range<usize>>>>,
4381 version: Signal<u64>,
4382 }
4383 impl TreeDataSource for Windowed {
4384 type Item = &'static str;
4385 type Key = usize;
4386 fn visible_count(&self) -> usize {
4387 self.total
4388 }
4389 fn with_entry<R>(
4390 &self,
4391 _i: usize,
4392 _f: impl FnOnce(&&'static str, &FlatEntry<usize>) -> R,
4393 ) -> Option<R> {
4394 None }
4396 fn key_at(&self, i: usize) -> Option<usize> {
4397 (i < self.total).then_some(i)
4398 }
4399 fn flat_index_of(&self, key: &usize) -> Option<usize> {
4400 (*key < self.total).then_some(*key)
4401 }
4402 fn parent(&self, _key: &usize) -> Option<usize> {
4403 None
4404 }
4405 fn child_keys(&self, _key: &usize) -> Vec<usize> {
4406 vec![]
4407 }
4408 fn version_signal(&self) -> Signal<u64> {
4409 self.version.clone()
4410 }
4411 fn is_expanded(&self, _key: &usize) -> bool {
4412 false
4413 }
4414 fn set_expanded(&self, _key: &usize, _expanded: bool) {}
4415 fn row_state(&self, _flat_index: usize) -> RowState {
4416 RowState::Loading
4417 }
4418 fn request_window(&self, range: Range<usize>) {
4419 self.requested.borrow_mut().push(range);
4420 }
4421 }
4422
4423 let requested = Rc::new(RefCell::new(Vec::new()));
4424 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4425 let id = tree.add(
4426 TreeTableView::from_source(Windowed {
4427 total: 1000,
4428 requested: requested.clone(),
4429 version: Signal::new(0),
4430 })
4431 .add_column(name_col())
4432 .show_header(false)
4433 .row_height(30.0),
4434 );
4435 tree.layout(SizeProposal {
4436 width: Some(400.0),
4437 height: Some(300.0),
4438 });
4439
4440 let body_pane = tree.children(id)[0];
4444 let placeholder_rows = tree.children(body_pane).len();
4445 assert!(
4446 placeholder_rows >= 10,
4447 "loading rows must render as placeholders, got {placeholder_rows}"
4448 );
4449 assert!(
4451 !requested.borrow().is_empty(),
4452 "request_window must be called for the visible range"
4453 );
4454 }
4455
4456 #[test]
4457 fn arrow_expand_collapse_follows_a_non_leading_tree_column() {
4458 use teksilo_core::event::{Key, Modifiers};
4463 let proxy = SortFilterTreeModel::new(sample_tree());
4464 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4465 let id = tree.add(
4466 TreeTableView::from_projection(proxy.clone())
4467 .add_column(name_col())
4468 .add_column(size_col())
4469 .tree_column("size")
4470 .row_height(20.0),
4471 );
4472 tree.layout(SizeProposal {
4473 width: Some(400.0),
4474 height: Some(200.0),
4475 });
4476 tree.focus(id);
4477
4478 {
4481 let any = tree.widget_as_any(id).unwrap();
4482 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4483 tt.set_focused_cell(0, 0);
4484 }
4485 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4486 assert_eq!(
4487 proxy.visible_count(),
4488 2,
4489 "ArrowRight off the tree column must not expand"
4490 );
4491
4492 {
4494 let any = tree.widget_as_any(id).unwrap();
4495 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4496 tt.set_focused_cell(0, 1);
4497 }
4498 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4499 assert_eq!(
4500 proxy.visible_count(),
4501 4,
4502 "docs expands to reveal 2 children"
4503 );
4504 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
4505 assert_eq!(proxy.visible_count(), 2, "docs collapses again");
4506 }
4507
4508 #[test]
4509 fn arrow_nav_scroll_follows_focused_row() {
4510 use teksilo_core::event::{Key, Modifiers};
4516 let proxy = SortFilterTreeModel::new(wide_tree(100));
4517 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4518 let id = tree.add(
4519 TreeTableView::from_projection(proxy)
4520 .add_column(name_col())
4521 .row_height(20.0),
4522 );
4523 let proposal = SizeProposal {
4524 width: Some(400.0),
4525 height: Some(200.0),
4526 };
4527 tree.layout(proposal);
4528 tree.focus(id);
4529 let read_scroll = |tree: &WidgetTree| {
4530 let any = tree.widget_as_any(id).unwrap();
4531 any.downcast_ref::<TreeTableView<&'static str>>()
4532 .unwrap()
4533 .scroll_y_signal()
4534 .get()
4535 };
4536 let read_focus = |tree: &WidgetTree| {
4537 let any = tree.widget_as_any(id).unwrap();
4538 any.downcast_ref::<TreeTableView<&'static str>>()
4539 .unwrap()
4540 .focused_cell_signal()
4541 .get()
4542 };
4543 {
4544 let any = tree.widget_as_any(id).unwrap();
4545 any.downcast_ref::<TreeTableView<&'static str>>()
4546 .unwrap()
4547 .set_focused_cell(0, 0);
4548 }
4549 assert_eq!(read_scroll(&tree), 0.0, "starts at top");
4550
4551 for _ in 0..20 {
4552 tree.press_key(Key::ArrowDown, Modifiers::NONE);
4553 tree.layout(proposal);
4554 }
4555 assert_eq!(read_focus(&tree), Some((20, 0)));
4556 assert!(
4557 read_scroll(&tree) > 200.0,
4558 "arrow-down nav must scroll to reveal row 20, got {}",
4559 read_scroll(&tree)
4560 );
4561
4562 tree.press_key(Key::Home, Modifiers::COMMAND);
4564 tree.layout(proposal);
4565 assert_eq!(read_focus(&tree), Some((0, 0)));
4566 assert_eq!(read_scroll(&tree), 0.0, "Ctrl+Home scrolls to top");
4567 }
4568
4569 #[test]
4570 fn type_ahead_jumps_to_matching_row() {
4571 use teksilo_core::event::{Key, Modifiers};
4572 let model = TreeModel::new();
4573 model.insert_root(0, "Apple");
4574 model.insert_root(1, "Banana");
4575 model.insert_root(2, "Cherry");
4576 model.insert_root(3, "Cranberry");
4577 let proxy = SortFilterTreeModel::new(model);
4578 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4579 let id = tree.add(
4580 TreeTableView::from_projection(proxy)
4581 .add_column(name_col())
4582 .row_height(20.0)
4583 .type_ahead_label(|s: &&'static str| s.to_string()),
4584 );
4585 tree.layout(SizeProposal {
4586 width: Some(400.0),
4587 height: Some(200.0),
4588 });
4589 tree.focus(id);
4590 let read_focus = |tree: &WidgetTree| {
4591 let any = tree.widget_as_any(id).unwrap();
4592 any.downcast_ref::<TreeTableView<&'static str>>()
4593 .unwrap()
4594 .focused_cell_signal()
4595 .get()
4596 };
4597 {
4598 let any = tree.widget_as_any(id).unwrap();
4599 any.downcast_ref::<TreeTableView<&'static str>>()
4600 .unwrap()
4601 .set_focused_cell(0, 0);
4602 }
4603 tree.press_key(Key::C, Modifiers::NONE);
4604 assert_eq!(read_focus(&tree), Some((2, 0)), "'c' → Cherry");
4605 tree.press_key(Key::R, Modifiers::NONE);
4606 assert_eq!(read_focus(&tree), Some((3, 0)), "'cr' → Cranberry");
4607 }
4608
4609 #[test]
4610 fn ctrl_tab_escapes_the_cell_grid() {
4611 use crate::primitives::{TextWidget, VStack};
4612 use teksilo_core::event::{Key, Modifiers};
4613 use teksilo_core::widget_builder::WidgetBuilder;
4614
4615 let proxy = SortFilterTreeModel::new(wide_tree(5));
4616 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4617 let id = tree.add(
4618 TreeTableView::from_projection(proxy)
4619 .add_column(name_col())
4620 .row_height(20.0),
4621 );
4622 let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
4623 let _root = tree.add(VStack::new().add_child(id).add_child(sink));
4624 tree.layout(SizeProposal {
4625 width: Some(400.0),
4626 height: Some(200.0),
4627 });
4628 let read_focus = |tree: &WidgetTree| {
4629 let any = tree.widget_as_any(id).unwrap();
4630 any.downcast_ref::<TreeTableView<&'static str>>()
4631 .unwrap()
4632 .focused_cell_signal()
4633 .get()
4634 };
4635 tree.focus(id);
4636 {
4637 let any = tree.widget_as_any(id).unwrap();
4638 any.downcast_ref::<TreeTableView<&'static str>>()
4639 .unwrap()
4640 .set_focused_cell(0, 0);
4641 }
4642 let before = read_focus(&tree);
4643 tree.press_key(Key::Tab, Modifiers::CTRL);
4644 assert_eq!(
4645 read_focus(&tree),
4646 before,
4647 "Ctrl+Tab must not navigate cells"
4648 );
4649 assert_eq!(
4650 tree.focused(),
4651 Some(sink),
4652 "Ctrl+Tab moves focus out of the tree-table"
4653 );
4654 }
4655
4656 #[test]
4657 fn rows_carry_role_row_with_level_indicator() {
4658 let proxy = SortFilterTreeModel::new(sample_tree());
4659 let docs = proxy.tree().root(0);
4660 proxy.expand(docs);
4661 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4662 let id = tree.add(
4663 TreeTableView::from_projection(proxy)
4664 .add_column(name_col())
4665 .row_height(20.0),
4666 );
4667 tree.layout(SizeProposal {
4668 width: Some(400.0),
4669 height: Some(200.0),
4670 });
4671 let mut q = vec![id];
4673 let mut row_count = 0;
4674 while let Some(n) = q.pop() {
4675 if tree.accessibility_node(n).role() == Role::Row {
4676 row_count += 1;
4677 }
4678 for c in tree.children(n) {
4679 q.push(c);
4680 }
4681 }
4682 assert!(
4684 row_count >= 5,
4685 "expected at least 5 Role::Row nodes, got {row_count}"
4686 );
4687 }
4688
4689 #[test]
4690 fn filter_mode_keep_ancestors_works_via_proxy() {
4691 let proxy = SortFilterTreeModel::new(sample_tree())
4692 .filter_mode(TreeFilterMode::KeepAncestors)
4693 .with_predicate("name", |t| {
4694 let needle = t.to_string();
4695 Box::new(move |row: &&str| row.contains(&needle))
4696 });
4697 proxy.expand_all();
4698 proxy.set_filter("name", "main");
4699 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4700 let _id = tree.add(
4701 TreeTableView::from_projection(proxy.clone())
4702 .add_column(name_col())
4703 .row_height(20.0),
4704 );
4705 tree.layout(SizeProposal {
4706 width: Some(400.0),
4707 height: Some(200.0),
4708 });
4709 assert_eq!(proxy.visible_count(), 2);
4711 }
4712
4713 #[test]
4714 fn collapse_all_then_expand_all_round_trips() {
4715 let proxy = SortFilterTreeModel::new(sample_tree());
4716 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4717 let id = tree.add(
4718 TreeTableView::from_projection(proxy.clone())
4719 .add_column(name_col())
4720 .row_height(20.0),
4721 );
4722 tree.layout(SizeProposal {
4723 width: Some(400.0),
4724 height: Some(200.0),
4725 });
4726 {
4727 let any = tree.widget_as_any(id).unwrap();
4728 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4729 tt.expand_all();
4730 }
4731 assert_eq!(proxy.visible_count(), 5);
4732 {
4733 let any = tree.widget_as_any(id).unwrap();
4734 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4735 tt.collapse_all();
4736 }
4737 assert_eq!(proxy.visible_count(), 2);
4738 }
4739
4740 #[test]
4741 fn rows_report_sibling_position_and_size_among_siblings() {
4742 let proxy = SortFilterTreeModel::new(sample_tree());
4750 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4751 let id = tree.add(
4752 TreeTableView::from_projection(proxy.clone())
4753 .add_column(name_col())
4754 .row_height(20.0),
4755 );
4756 tree.layout(SizeProposal {
4757 width: Some(400.0),
4758 height: Some(400.0),
4759 });
4760 {
4761 let any = tree.widget_as_any(id).unwrap();
4762 any.downcast_ref::<TreeTableView<&'static str>>()
4763 .unwrap()
4764 .expand_all();
4765 }
4766 tree.layout(SizeProposal {
4767 width: Some(400.0),
4768 height: Some(400.0),
4769 });
4770 assert_eq!(proxy.visible_count(), 5);
4771
4772 let mut rows: Vec<WidgetId> = Vec::new();
4777 let mut q = vec![id];
4778 while let Some(n) = q.pop() {
4779 if tree.accessibility_node(n).role() == Role::Row {
4780 rows.push(n);
4781 }
4782 for c in tree.children(n) {
4783 q.push(c);
4784 }
4785 }
4786 rows.sort_by(|a, b| tree.bounds(*a).y.partial_cmp(&tree.bounds(*b).y).unwrap());
4787 assert_eq!(rows.len(), 6, "header + five body rows");
4788 let body_rows = &rows[1..];
4789
4790 let update = tree.sync_accessibility();
4794 let find = |wid: WidgetId| -> &teksilo_core::accesskit::Node {
4795 let nid = widget_id_to_node_id(wid);
4796 update
4797 .nodes
4798 .iter()
4799 .find(|(n, _)| *n == nid)
4800 .map(|(_, n)| n)
4801 .expect("row must be in the a11y tree")
4802 };
4803 let positions: Vec<usize> = body_rows
4804 .iter()
4805 .map(|&r| find(r).position_in_set().expect("position_in_set"))
4806 .collect();
4807 assert_eq!(
4811 positions,
4812 vec![0, 0, 1, 1, 0],
4813 "docs(1st) readme(1st) guide(2nd) src(2nd) main.rs(1st)"
4814 );
4815 for &r in body_rows {
4820 assert_eq!(
4821 find(r).size_of_set(),
4822 None,
4823 "a per-sibling count is unrepresentable and must not be faked"
4824 );
4825 }
4826 }
4827
4828 #[test]
4829 fn row_count_in_a11y_includes_header() {
4830 let proxy = SortFilterTreeModel::new(sample_tree());
4831 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4832 let id = tree.add(
4833 TreeTableView::from_projection(proxy)
4834 .add_column(name_col())
4835 .add_column(size_col())
4836 .row_height(20.0),
4837 );
4838 tree.layout(SizeProposal {
4839 width: Some(400.0),
4840 height: Some(200.0),
4841 });
4842 let info = tree.accessibility_node(id);
4843 assert_eq!(info.role(), Role::TreeGrid);
4844 let mut q = vec![id];
4848 let mut rows = 0;
4849 while let Some(n) = q.pop() {
4850 if tree.accessibility_node(n).role() == Role::Row {
4851 rows += 1;
4852 }
4853 for c in tree.children(n) {
4854 q.push(c);
4855 }
4856 }
4857 assert_eq!(rows, 3); }
4859
4860 fn wide_tree(n: u32) -> TreeModel<&'static str> {
4864 let t = TreeModel::new();
4865 for i in 0..n {
4866 t.insert_root(i as usize, "node");
4867 }
4868 t
4869 }
4870
4871 fn row_bounds(tree: &WidgetTree, root: WidgetId) -> Vec<teksilo_canvas::Rect> {
4873 let mut q = vec![root];
4874 let mut out = Vec::new();
4875 while let Some(n) = q.pop() {
4876 if tree.accessibility_node(n).role() == Role::Row {
4877 out.push(tree.bounds(n));
4878 }
4879 for c in tree.children(n) {
4880 q.push(c);
4881 }
4882 }
4883 out
4884 }
4885
4886 #[test]
4887 fn rtl_swaps_tree_expand_collapse_keys() {
4888 use teksilo_core::environment::LayoutDirection;
4889 use teksilo_core::event::{Key, Modifiers};
4890
4891 let proxy = SortFilterTreeModel::new(sample_tree());
4892 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4893 let table = tree.add(
4894 TreeTableView::from_projection(proxy.clone())
4895 .add_column(name_col())
4896 .row_height(20.0),
4897 );
4898 tree.layout(SizeProposal {
4899 width: Some(400.0),
4900 height: Some(200.0),
4901 });
4902 assert_eq!(proxy.visible_count(), 2);
4904
4905 tree.set_layout_direction(LayoutDirection::RightToLeft);
4906 tree.focus(table);
4907 {
4908 let any = tree.widget_as_any(table).unwrap();
4909 any.downcast_ref::<TreeTableView<&'static str>>()
4910 .unwrap()
4911 .set_focused_cell(0, 0);
4912 }
4913
4914 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
4917 assert_eq!(
4918 proxy.visible_count(),
4919 4,
4920 "RTL ArrowLeft on the tree column should expand docs"
4921 );
4922 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4923 assert_eq!(
4924 proxy.visible_count(),
4925 2,
4926 "RTL ArrowRight on the tree column should collapse docs"
4927 );
4928 }
4929
4930 #[test]
4931 fn rtl_tree_band_shifts_for_left_scrollbar() {
4932 use teksilo_core::environment::LayoutDirection;
4933 let proxy = SortFilterTreeModel::new(wide_tree(50));
4937 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4938 let table = tree.add(
4939 TreeTableView::from_projection(proxy)
4940 .add_column(name_col())
4941 .row_height(20.0),
4942 );
4943 tree.layout(SizeProposal {
4944 width: Some(400.0),
4945 height: Some(200.0),
4946 });
4947 tree.set_layout_direction(LayoutDirection::RightToLeft);
4948 tree.layout(SizeProposal {
4949 width: Some(400.0),
4950 height: Some(200.0),
4951 });
4952
4953 let table_bounds = tree.bounds(table);
4954 let body_row = row_bounds(&tree, table)
4956 .into_iter()
4957 .filter(|r| r.y > table_bounds.y + 5.0)
4958 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
4959 .expect("a body row");
4960 assert!(
4961 (body_row.x - SCROLLBAR_THICKNESS).abs() < 0.5,
4962 "RTL body row should start at SCROLLBAR_THICKNESS, got x={}",
4963 body_row.x
4964 );
4965 let mut tree2 = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4967 let proxy2 = SortFilterTreeModel::new(wide_tree(50));
4968 let table2 = tree2.add(
4969 TreeTableView::from_projection(proxy2)
4970 .add_column(name_col())
4971 .row_height(20.0),
4972 );
4973 tree2.layout(SizeProposal {
4974 width: Some(400.0),
4975 height: Some(200.0),
4976 });
4977 let tb2 = tree2.bounds(table2);
4978 let body_row2 = row_bounds(&tree2, table2)
4979 .into_iter()
4980 .filter(|r| r.y > tb2.y + 5.0)
4981 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
4982 .expect("a body row");
4983 assert!(body_row2.x.abs() < 0.5, "LTR body row x={}", body_row2.x);
4984 }
4985
4986 fn nested_tree_table_fixture(
4992 inner: OverscrollBehavior,
4993 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
4994 use crate::ScrollArea;
4995 use crate::primitives::{FixedSize, TextWidget, VStack};
4996 let model = TreeModel::new();
4997 for i in 0..40 {
4998 model.insert_root(i, "row");
4999 }
5000 let proxy = SortFilterTreeModel::new(model);
5001 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5002 let tt = TreeTableView::from_projection(proxy)
5003 .add_column(name_col())
5004 .show_header(false)
5005 .row_height(20.0)
5006 .overscroll_behavior(inner);
5007 let inner_y = tt.scroll_y_signal().clone();
5008 let tt_id = tree.add(tt);
5009 let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tt_id));
5010 let filler = tree.add(
5011 FixedSize::new()
5012 .width(220.0)
5013 .height(300.0)
5014 .child(TextWidget::new(lit!(""))),
5015 );
5016 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
5017 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
5018 let outer_y = outer.scroll_y_signal().clone();
5019 let _outer = tree.add(outer);
5020 tree.layout(SizeProposal {
5021 width: Some(220.0),
5022 height: Some(150.0),
5023 });
5024 (tree, inner_y, outer_y)
5025 }
5026
5027 #[test]
5028 fn nested_tree_table_chains_to_outer_at_boundary() {
5029 use teksilo_canvas::Point;
5030 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5031 let (mut tree, inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Chain);
5032 tree.pointer_move(Point::new(50.0, 40.0));
5033 tree.dispatch_event(WidgetEvent::Scroll {
5034 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5035 modifiers: Modifiers::NONE,
5036 });
5037 tree.layout(SizeProposal {
5038 width: Some(220.0),
5039 height: Some(150.0),
5040 });
5041 let inner_bottom = inner_y.get();
5042 assert!(
5043 inner_bottom > 0.0,
5044 "inner tree-table should scroll down; got {inner_bottom}"
5045 );
5046 tree.pointer_move(Point::new(50.0, 40.0));
5048 tree.dispatch_event(WidgetEvent::Scroll {
5049 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5050 modifiers: Modifiers::NONE,
5051 });
5052 tree.layout(SizeProposal {
5053 width: Some(220.0),
5054 height: Some(150.0),
5055 });
5056 assert!(
5057 (inner_y.get() - inner_bottom).abs() < 0.01,
5058 "inner stays clamped at bottom"
5059 );
5060 assert!(
5061 outer_y.get() > 0.01,
5062 "outer must scroll because the inner chained the boundary"
5063 );
5064 }
5065
5066 #[test]
5067 fn nested_tree_table_contain_blocks_chaining() {
5068 use teksilo_canvas::Point;
5069 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5070 let (mut tree, _inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Contain);
5071 tree.pointer_move(Point::new(50.0, 40.0));
5072 tree.dispatch_event(WidgetEvent::Scroll {
5073 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5074 modifiers: Modifiers::NONE,
5075 });
5076 tree.layout(SizeProposal {
5077 width: Some(220.0),
5078 height: Some(150.0),
5079 });
5080 tree.pointer_move(Point::new(50.0, 40.0));
5081 tree.dispatch_event(WidgetEvent::Scroll {
5082 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5083 modifiers: Modifiers::NONE,
5084 });
5085 tree.layout(SizeProposal {
5086 width: Some(220.0),
5087 height: Some(150.0),
5088 });
5089 assert!(
5090 outer_y.get() < 0.01,
5091 "Contain must prevent chaining: outer stays put"
5092 );
5093 }
5094
5095 fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
5098 let mut walker = vec![root];
5099 let mut n = 0;
5100 while let Some(id) = walker.pop() {
5101 if tree.accessibility_node(id).role() == role {
5102 n += 1;
5103 }
5104 for c in tree.children(id) {
5105 walker.push(c);
5106 }
5107 }
5108 n
5109 }
5110
5111 fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
5114 let mut walker = vec![root];
5115 let mut spans = Vec::new();
5116 while let Some(id) = walker.pop() {
5117 if tree.accessibility_node(id).role() == Role::Row {
5118 let b = tree.bounds(id);
5119 spans.push((b.y, b.height));
5120 }
5121 for c in tree.children(id) {
5122 walker.push(c);
5123 }
5124 }
5125 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5126 spans
5127 }
5128
5129 #[test]
5130 fn rows_rebuild_during_scrollbar_thumb_drag() {
5131 let model = TreeModel::new();
5135 for i in 0..500 {
5136 model.insert_root(i, "root");
5137 }
5138 let proxy = SortFilterTreeModel::new(model);
5139 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5140 let table = tree.add(
5141 TreeTableView::from_projection(proxy.clone())
5142 .add_column(name_col())
5143 .row_height(20.0),
5144 );
5145 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
5146 &mut tree,
5147 table,
5148 400.0,
5149 200.0,
5150 cp::HEADER_HEIGHT,
5151 "TreeTableView",
5152 |t| {
5153 let mut n = 0;
5154 let mut walker = vec![table];
5155 while let Some(id) = walker.pop() {
5156 if t.accessibility_node(id).role() == Role::Row {
5157 let b = t.bounds(id);
5158 if b.y >= 0.0 && b.y < 200.0 {
5159 n += 1;
5160 }
5161 }
5162 for c in t.children(id) {
5163 walker.push(c);
5164 }
5165 }
5166 n
5167 },
5168 );
5169 }
5170
5171 #[test]
5172 fn exact_row_height_fn_positions_tree_rows() {
5173 let heights = [60.0_f32, 20.0, 40.0];
5174 let proxy = SortFilterTreeModel::new(sample_tree());
5175 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5176 let table = tree.add(
5177 TreeTableView::from_projection(proxy)
5178 .add_column(name_col())
5179 .show_header(false)
5180 .row_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
5181 );
5182 tree.layout(SizeProposal {
5183 width: Some(400.0),
5184 height: Some(300.0),
5185 });
5186
5187 let spans = row_spans(&tree, table);
5189 assert_eq!(spans.len(), 2);
5190 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
5191 assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
5192 }
5193
5194 #[test]
5195 fn auto_row_height_measures_tree_cells() {
5196 #[derive(Debug)]
5197 struct FixedLeaf(f32, f32);
5198 impl Widget for FixedLeaf {
5199 fn layout_response(
5200 &self,
5201 _proposal: SizeProposal,
5202 _ctx: &LayoutContext,
5203 ) -> teksilo_core::widget::LayoutResponse {
5204 Size::new(self.0, self.1).into()
5205 }
5206 }
5207 let col = Column::<&str>::new("name", lit!("Name"), |_row, _: &CellContext| {
5208 Box::new(FixedLeaf(50.0, 30.0))
5209 })
5210 .width(ColumnWidth::Flex(1.0));
5211 let proxy = SortFilterTreeModel::new(sample_tree());
5212 let docs = proxy.tree().root(0);
5213 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5214 let table = tree.add(
5215 TreeTableView::from_projection(proxy.clone())
5216 .add_column(col)
5217 .show_header(false)
5218 .auto_row_height(50.0),
5219 );
5220 tree.layout(SizeProposal {
5221 width: Some(400.0),
5222 height: Some(300.0),
5223 });
5224 tree.layout(SizeProposal {
5225 width: Some(400.0),
5226 height: Some(300.0),
5227 });
5228
5229 let spans = row_spans(&tree, table);
5231 assert!(
5232 (spans[1].0 - 30.0).abs() < 0.01,
5233 "row 1 should sit at measured 30, got {}",
5234 spans[1].0
5235 );
5236
5237 proxy.expand(docs);
5241 tree.layout(SizeProposal {
5242 width: Some(400.0),
5243 height: Some(300.0),
5244 });
5245 tree.layout(SizeProposal {
5246 width: Some(400.0),
5247 height: Some(300.0),
5248 });
5249 let spans = row_spans(&tree, table);
5250 assert_eq!(spans.len(), 4); assert!(
5252 (spans[1].0 - 30.0).abs() < 0.01,
5253 "measured row 0 must survive the expand, got {}",
5254 spans[1].0
5255 );
5256 }
5257
5258 fn drag(tree: &mut WidgetTree, from: teksilo_canvas::Point, to: teksilo_canvas::Point) {
5263 use teksilo_canvas::Point;
5264 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
5265 tree.dispatch_event(WidgetEvent::PointerDown {
5266 position: from,
5267 button: PointerButton::Primary,
5268 modifiers: Modifiers::NONE,
5269 });
5270 tree.dispatch_event(WidgetEvent::PointerMove {
5271 position: Point::new(from.x + 10.0, from.y),
5272 });
5273 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
5274 tree.dispatch_event(WidgetEvent::PointerUp {
5275 position: to,
5276 button: PointerButton::Primary,
5277 modifiers: Modifiers::NONE,
5278 });
5279 }
5280
5281 #[test]
5282 fn drag_reorders_roots_after() {
5283 use teksilo_canvas::Point;
5284 let proxy = SortFilterTreeModel::new(sample_tree());
5285 proxy.collapse_all(); let docs = proxy.tree().root(0);
5287 let src = proxy.tree().root(1);
5288 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5289 tree.add(
5290 TreeTableView::from_projection(proxy.clone())
5291 .add_column(name_col())
5292 .reorderable(true)
5293 .row_height(20.0),
5294 );
5295 tree.layout(SizeProposal {
5296 width: Some(400.0),
5297 height: Some(300.0),
5298 });
5299 let h = cp::HEADER_HEIGHT;
5300 drag(
5303 &mut tree,
5304 Point::new(40.0, h + 10.0),
5305 Point::new(40.0, h + 38.0),
5306 );
5307 assert_eq!(proxy.tree().root_count(), 2);
5308 assert_eq!(proxy.tree().root(0), src, "src becomes the first root");
5309 assert_eq!(proxy.tree().root(1), docs, "docs moves after src");
5310 }
5311
5312 #[test]
5313 fn drag_into_own_descendant_is_refused() {
5314 use teksilo_canvas::Point;
5315 let proxy = SortFilterTreeModel::new(sample_tree());
5316 proxy.expand_all(); let docs = proxy.tree().root(0);
5318 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5319 tree.add(
5320 TreeTableView::from_projection(proxy.clone())
5321 .add_column(name_col())
5322 .reorderable(true)
5323 .row_height(20.0),
5324 );
5325 tree.layout(SizeProposal {
5326 width: Some(400.0),
5327 height: Some(300.0),
5328 });
5329 let h = cp::HEADER_HEIGHT;
5330 drag(
5333 &mut tree,
5334 Point::new(40.0, h + 10.0),
5335 Point::new(40.0, h + 30.0),
5336 );
5337 assert_eq!(proxy.tree().parent(docs), None, "docs stays a root");
5338 assert_eq!(proxy.tree().root_count(), 2);
5339 }
5340
5341 #[test]
5342 fn reorder_is_suppressed_while_sorted() {
5343 use teksilo_canvas::Point;
5344 let proxy = SortFilterTreeModel::new(sample_tree());
5345 proxy.collapse_all();
5346 let docs = proxy.tree().root(0);
5347 let src = proxy.tree().root(1);
5348 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5349 let id = tree.add(
5350 TreeTableView::from_projection(proxy.clone())
5351 .add_column(name_col())
5352 .reorderable(true)
5353 .row_height(20.0),
5354 );
5355 tree.layout(SizeProposal {
5356 width: Some(400.0),
5357 height: Some(300.0),
5358 });
5359 tree.widget_as_any(id)
5362 .and_then(|a| a.downcast_ref::<TreeTableView<&str>>())
5363 .expect("TreeTableView")
5364 .set_sort(Some("name"), teksilo_data::SortDirection::Ascending);
5365 let h = cp::HEADER_HEIGHT;
5366 drag(
5367 &mut tree,
5368 Point::new(40.0, h + 10.0),
5369 Point::new(40.0, h + 38.0),
5370 );
5371 assert_eq!(proxy.tree().root(0), docs, "docs unchanged while sorted");
5372 assert_eq!(proxy.tree().root(1), src, "src unchanged while sorted");
5373 }
5374
5375 #[test]
5376 fn keyed_selection_survives_collapse() {
5377 use teksilo_data::{KeyedSelectionModel, SelectionMode};
5382 let proxy = SortFilterTreeModel::new(sample_tree());
5383 proxy.expand_all();
5384 let docs = proxy.tree().root(0);
5385 let readme = proxy.tree().children(docs)[0];
5386 let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
5387 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5388 tree.add(
5389 TreeTableView::from_projection(proxy.clone())
5390 .add_column(name_col())
5391 .selection_mode(TableSelectionMode::MultiRow)
5392 .keyed_selection(keyed.clone())
5393 .row_height(20.0),
5394 );
5395 tree.layout(SizeProposal {
5396 width: Some(400.0),
5397 height: Some(300.0),
5398 });
5399
5400 keyed.select(readme);
5401 assert!(keyed.is_selected(&readme));
5402
5403 proxy.collapse(docs);
5406 assert!(
5407 keyed.is_selected(&readme),
5408 "a collapsed-but-present node stays selected by identity"
5409 );
5410
5411 proxy.expand(docs);
5413 assert!(keyed.is_selected(&readme));
5414 }
5415
5416 fn tt_flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
5432 let mut out = Vec::new();
5433 for id in ids {
5434 if matches!(
5435 tree.accessibility_node(id).role(),
5436 Role::GenericContainer | Role::Unknown
5437 ) {
5438 out.extend(tt_flatten_through_bands(tree, tree.children(id)));
5439 } else {
5440 out.push(id);
5441 }
5442 }
5443 out
5444 }
5445
5446 fn tt_first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5450 let mut walker = vec![root];
5451 while let Some(id) = walker.pop() {
5452 if tree.accessibility_node(id).role() == Role::Row {
5453 let flat = tt_flatten_through_bands(tree, tree.children(id));
5454 if flat
5455 .iter()
5456 .any(|&c| tree.accessibility_node(c).role() == Role::Cell)
5457 {
5458 return id;
5459 }
5460 }
5461 for c in tree.children(id) {
5462 walker.push(c);
5463 }
5464 }
5465 panic!("no body Role::Row found");
5466 }
5467
5468 fn tt_header_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5469 let mut walker = vec![root];
5470 while let Some(id) = walker.pop() {
5471 if tree.accessibility_node(id).role() == Role::Row {
5472 let flat = tt_flatten_through_bands(tree, tree.children(id));
5473 if !flat.is_empty()
5474 && flat
5475 .iter()
5476 .all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
5477 {
5478 return id;
5479 }
5480 }
5481 for c in tree.children(id) {
5482 walker.push(c);
5483 }
5484 }
5485 panic!("no header Role::Row found");
5486 }
5487
5488 fn tt_body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5489 tt_flatten_through_bands(tree, tree.children(tt_first_body_row_id(tree, root)))
5490 }
5491
5492 fn tt_header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5493 tt_flatten_through_bands(tree, tree.children(tt_header_row_id(tree, root)))
5494 }
5495
5496 fn build_tt_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
5500 let proxy = SortFilterTreeModel::new(sample_tree());
5501 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5502 let id = tree.add(
5503 TreeTableView::from_projection(proxy)
5504 .add_column(
5505 Column::<&'static str>::new("lead", lit!("Lead"), |row, _: &CellContext| {
5506 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5507 })
5508 .width(ColumnWidth::Fixed(60.0))
5509 .pinned(PinnedSide::Leading),
5510 )
5511 .add_column(
5512 Column::<&'static str>::new("mid", lit!("Mid"), |row, _: &CellContext| {
5513 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5514 })
5515 .width(ColumnWidth::Fixed(middle_w)),
5516 )
5517 .add_column(
5518 Column::<&'static str>::new("trail", lit!("Trail"), |_row, _: &CellContext| {
5519 Box::new(crate::primitives::TextWidget::new(lit!("x")))
5520 })
5521 .width(ColumnWidth::Fixed(60.0))
5522 .pinned(PinnedSide::Trailing),
5523 )
5524 .row_height(20.0),
5525 );
5526 tree.layout(SizeProposal {
5527 width: Some(table_w),
5528 height: Some(200.0),
5529 });
5530 (tree, id)
5531 }
5532
5533 fn build_tt_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
5535 let proxy = SortFilterTreeModel::new(sample_tree());
5536 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5537 let mut tv = TreeTableView::from_projection(proxy);
5538 for i in 0..n {
5539 let col_id = format!("c{i}");
5540 tv = tv.add_column(
5541 Column::<&'static str>::new(
5542 col_id.clone(),
5543 lit!(col_id.clone()),
5544 |row, _: &CellContext| Box::new(crate::primitives::TextWidget::new(lit!(*row))),
5545 )
5546 .width(ColumnWidth::Fixed(col_w)),
5547 );
5548 }
5549 let id = tree.add(tv.row_height(20.0));
5550 tree.layout(SizeProposal {
5551 width: Some(table_w),
5552 height: Some(200.0),
5553 });
5554 (tree, id)
5555 }
5556
5557 fn tt_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5558 tree.widget_as_any(id)
5559 .unwrap()
5560 .downcast_ref::<TreeTableView<&'static str>>()
5561 .unwrap()
5562 .scroll_x_signal()
5563 .get()
5564 }
5565
5566 fn tt_max_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5567 tree.widget_as_any(id)
5568 .unwrap()
5569 .downcast_ref::<TreeTableView<&'static str>>()
5570 .unwrap()
5571 .max_scroll_x_signal()
5572 .get()
5573 }
5574
5575 fn tt_set_scroll_x(tree: &WidgetTree, id: WidgetId, x: f32) {
5576 tree.widget_as_any(id)
5577 .unwrap()
5578 .downcast_ref::<TreeTableView<&'static str>>()
5579 .unwrap()
5580 .scroll_x_signal()
5581 .set(x);
5582 }
5583
5584 #[test]
5585 fn tt_scroll_x_clamps_after_the_pane_widens() {
5586 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 3, 300.0);
5587 let max = tt_max_scroll_x(&tree, id);
5588 assert!(max > 0.0, "columns must overflow the narrow table");
5589 tt_set_scroll_x(&tree, id, max);
5590 assert_eq!(tt_scroll_x(&tree, id), max);
5591
5592 tree.layout(SizeProposal {
5593 width: Some(700.0),
5594 height: Some(200.0),
5595 });
5596 assert_eq!(tt_max_scroll_x(&tree, id), 0.0, "content now fits");
5597 assert_eq!(
5598 tt_scroll_x(&tree, id),
5599 0.0,
5600 "scroll_x must clamp down with the new (smaller) max_scroll_x"
5601 );
5602 }
5603
5604 #[test]
5605 fn tt_pinned_columns_keep_their_bands_under_scroll() {
5606 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
5607
5608 let cells0 = tt_body_row_cells(&tree, id);
5609 assert_eq!(cells0.len(), 3, "lead, mid, trail");
5610 let lead_x0 = tree.bounds(cells0[0]).x;
5611 let mid_x0 = tree.bounds(cells0[1]).x;
5612 let trail_x0 = tree.bounds(cells0[2]).x;
5613
5614 let tree_row_a11y = tt_first_body_row_id(&tree, id);
5619 let body_row = tree.children(tree_row_a11y)[0];
5620 let raw_bands = tree.children(body_row);
5621 assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
5622 assert!(!tree.widget_clips_children(raw_bands[0]));
5623 assert!(
5624 tree.widget_clips_children(raw_bands[1]),
5625 "the Middle band must clip"
5626 );
5627 assert!(!tree.widget_clips_children(raw_bands[2]));
5628
5629 let max = tt_max_scroll_x(&tree, id);
5630 assert!(max > 0.0);
5631 tt_set_scroll_x(&tree, id, 50.0_f32.min(max));
5632 tree.layout(SizeProposal {
5633 width: Some(200.0),
5634 height: Some(200.0),
5635 });
5636
5637 let cells1 = tt_body_row_cells(&tree, id);
5638 assert_eq!(tree.bounds(cells1[0]).x, lead_x0, "Leading never moves");
5639 assert_eq!(tree.bounds(cells1[2]).x, trail_x0, "Trailing never moves");
5640 let mid_x1 = tree.bounds(cells1[1]).x;
5641 assert!(
5642 (mid_x1 - (mid_x0 - 50.0)).abs() < 0.5,
5643 "the Middle column shifts left by exactly scroll_x: got {mid_x1}, want ~{}",
5644 mid_x0 - 50.0
5645 );
5646 }
5647
5648 #[test]
5649 fn tt_header_and_body_x_offsets_agree_under_scroll() {
5650 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
5651 tt_set_scroll_x(&tree, id, 37.0);
5652 tree.layout(SizeProposal {
5653 width: Some(200.0),
5654 height: Some(200.0),
5655 });
5656
5657 let header_cells = tt_header_row_cells(&tree, id);
5658 let body_cells = tt_body_row_cells(&tree, id);
5659 assert_eq!(header_cells.len(), body_cells.len());
5660 for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
5661 let hx = tree.bounds(h).x;
5662 let bx = tree.bounds(b).x;
5663 assert!(
5664 (hx - bx).abs() < 0.01,
5665 "column {i}: header x {hx} must equal body x {bx}"
5666 );
5667 }
5668 }
5669
5670 #[test]
5671 fn tt_shift_wheel_scrolls_horizontally() {
5672 use teksilo_canvas::Point;
5673 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5674 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 4, 300.0);
5675 tree.pointer_move(Point::new(50.0, 60.0));
5676 tree.dispatch_event(WidgetEvent::Scroll {
5677 delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
5678 modifiers: Modifiers::SHIFT,
5679 });
5680 tree.layout(SizeProposal {
5681 width: Some(300.0),
5682 height: Some(200.0),
5683 });
5684 assert!(
5685 tt_scroll_x(&tree, id) > 0.0,
5686 "Shift+wheel must remap a vertical-only wheel to horizontal scroll"
5687 );
5688 let any = tree.widget_as_any(id).unwrap();
5689 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5690 assert_eq!(
5691 tt.scroll_y_signal().get(),
5692 0.0,
5693 "Shift+wheel must not also scroll vertically"
5694 );
5695 }
5696
5697 #[test]
5698 fn tt_ensure_col_visible_follows_focus_in_both_directions() {
5699 use teksilo_core::event::{Key, Modifiers};
5700 let (mut tree, id) = build_tt_wide_unpinned_table(150.0, 5, 300.0);
5701 tree.focus(id);
5702 {
5703 let any = tree.widget_as_any(id).unwrap();
5704 any.downcast_ref::<TreeTableView<&'static str>>()
5705 .unwrap()
5706 .set_focused_cell(0, 0);
5707 }
5708 assert_eq!(tt_scroll_x(&tree, id), 0.0);
5709
5710 tree.press_key(Key::End, Modifiers::NONE);
5711 {
5712 let any = tree.widget_as_any(id).unwrap();
5713 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5714 assert_eq!(tt.focused_cell_signal().get(), Some((0, 4)));
5715 }
5716 assert!(
5717 tt_scroll_x(&tree, id) > 0.0,
5718 "ensure-column-visible must scroll right to reveal column 4"
5719 );
5720
5721 tree.press_key(Key::Home, Modifiers::NONE);
5722 {
5723 let any = tree.widget_as_any(id).unwrap();
5724 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5725 assert_eq!(tt.focused_cell_signal().get(), Some((0, 0)));
5726 }
5727 assert_eq!(
5728 tt_scroll_x(&tree, id),
5729 0.0,
5730 "ensure-column-visible must scroll left back to 0 for column 0"
5731 );
5732 }
5733
5734 fn reorder_col(id: &'static str, width: f32) -> Column<&'static str> {
5756 Column::<&'static str>::new(id, lit!(id), |row, _: &CellContext| {
5757 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5758 })
5759 .width(ColumnWidth::Fixed(width))
5760 }
5761
5762 fn build_tt_reorder_table() -> (WidgetTree, WidgetId, SortFilterTreeModel<&'static str>) {
5766 let proxy = SortFilterTreeModel::new(sample_tree());
5767 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5768 let id = tree.add(
5769 TreeTableView::from_projection(proxy.clone())
5770 .add_column(reorder_col("a", 60.0))
5771 .add_column(reorder_col("b", 70.0))
5772 .add_column(reorder_col("c", 80.0))
5773 .add_column(reorder_col("d", 90.0))
5774 .row_height(20.0),
5775 );
5776 tree.layout(SizeProposal {
5777 width: Some(400.0),
5778 height: Some(200.0),
5779 });
5780 (tree, id, proxy)
5781 }
5782
5783 fn tt_subtree_has_twist_arrow(tree: &WidgetTree, id: WidgetId) -> bool {
5790 if tree.widget_type_name(id) == Some("teksilo_widgets::primitives::twist_arrow::TwistArrow")
5791 {
5792 return true;
5793 }
5794 tree.children(id)
5795 .into_iter()
5796 .any(|c| tt_subtree_has_twist_arrow(tree, c))
5797 }
5798
5799 #[test]
5800 fn header_drag_reorders_column_before_an_earlier_sibling() {
5801 let (mut tree, id, _proxy) = build_tt_reorder_table();
5804 let header = tt_header_row_cells(&tree, id);
5805 assert_eq!(header.len(), 4);
5806 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
5809
5810 {
5811 let any = tree.widget_as_any(id).unwrap();
5812 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5813 assert_eq!(
5814 tt.column_order_signal().get(),
5815 vec![
5816 "a".to_string(),
5817 "d".to_string(),
5818 "b".to_string(),
5819 "c".to_string()
5820 ],
5821 "dropping \"d\" before \"b\" must write [a, d, b, c]"
5822 );
5823 assert_eq!(
5824 tt.column_pinning_signal().get().get("d"),
5825 None,
5826 "a mid-band drop must not pin the moved column"
5827 );
5828 }
5829
5830 tree.layout(SizeProposal {
5834 width: Some(400.0),
5835 height: Some(200.0),
5836 });
5837 let after = tt_header_row_cells(&tree, id);
5838 let widths: Vec<f32> = after.iter().map(|&c| tree.bounds(c).width).collect();
5839 assert!(
5840 widths
5841 .iter()
5842 .zip([60.0, 90.0, 70.0, 80.0])
5843 .all(|(&w, want)| (w - want).abs() < 0.5),
5844 "header cells must reflow to widths [60, 90, 70, 80], got {widths:?}"
5845 );
5846 }
5847
5848 #[test]
5849 fn header_drag_to_the_leading_edge_pins_the_dropped_column() {
5850 let (mut tree, id, _proxy) = build_tt_reorder_table();
5855 let header = tt_header_row_cells(&tree, id);
5856 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(5.0, from.y); drag(&mut tree, from, to);
5859
5860 let any = tree.widget_as_any(id).unwrap();
5861 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5862 assert_eq!(
5863 tt.column_order_signal().get(),
5864 vec![
5865 "d".to_string(),
5866 "a".to_string(),
5867 "b".to_string(),
5868 "c".to_string()
5869 ],
5870 );
5871 assert_eq!(
5872 tt.column_pinning_signal().get().get("d").copied(),
5873 Some(PinnedSide::Leading),
5874 "dropping at the leading edge must pin the column, same as TableView"
5875 );
5876 }
5877
5878 #[test]
5879 fn header_drag_reorder_remaps_focused_and_editing_cell_to_follow_their_columns() {
5880 let (mut tree, id, _proxy) = build_tt_reorder_table();
5886 {
5887 let any = tree.widget_as_any(id).unwrap();
5888 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5889 tt.set_focused_cell(0, 1); tt.begin_edit(0, "d"); }
5892
5893 let header = tt_header_row_cells(&tree, id);
5894 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
5897 tree.layout(SizeProposal {
5898 width: Some(400.0),
5899 height: Some(200.0),
5900 });
5901
5902 let any = tree.widget_as_any(id).unwrap();
5903 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5904 assert_eq!(
5905 tt.column_order_signal().get(),
5906 vec![
5907 "a".to_string(),
5908 "d".to_string(),
5909 "b".to_string(),
5910 "c".to_string()
5911 ],
5912 );
5913 assert_eq!(
5914 tt.focused_cell_signal().get(),
5915 Some((0, 2)),
5916 "focus must follow \"b\" to its new display position"
5917 );
5918 assert_eq!(
5919 tt.editing_cell_signal().get(),
5920 Some((0, 1)),
5921 "the open editor must follow \"d\" to its new display position"
5922 );
5923 }
5924
5925 #[test]
5926 fn header_drag_moves_the_tree_column_and_twist_follows() {
5927 let (mut tree, id, proxy) = build_tt_reorder_table();
5936 let header = tt_header_row_cells(&tree, id);
5937 let from = tree.bounds(header[0]).center(); let to = teksilo_canvas::Point::new(220.0, from.y); drag(&mut tree, from, to);
5940
5941 {
5942 let any = tree.widget_as_any(id).unwrap();
5943 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5944 assert_eq!(
5945 tt.column_order_signal().get(),
5946 vec![
5947 "b".to_string(),
5948 "c".to_string(),
5949 "a".to_string(),
5950 "d".to_string()
5951 ],
5952 );
5953 assert_eq!(
5954 tt.column_pinning_signal().get().get("a"),
5955 None,
5956 "a mid-band drop must not pin the tree column either"
5957 );
5958 }
5959 tree.layout(SizeProposal {
5960 width: Some(400.0),
5961 height: Some(200.0),
5962 });
5963
5964 let body = tt_body_row_cells(&tree, id);
5965 assert_eq!(body.len(), 4);
5966 assert!(
5967 !tt_subtree_has_twist_arrow(&tree, body[0]),
5968 "\"b\" is no longer the tree column"
5969 );
5970 assert!(
5971 !tt_subtree_has_twist_arrow(&tree, body[1]),
5972 "\"c\" is no longer the tree column"
5973 );
5974 assert!(
5975 tt_subtree_has_twist_arrow(&tree, body[2]),
5976 "the twist must follow \"a\" to its new display position"
5977 );
5978 assert!(
5979 !tt_subtree_has_twist_arrow(&tree, body[3]),
5980 "\"d\" is not the tree column"
5981 );
5982
5983 use teksilo_core::event::{Key, Modifiers};
5985 tree.focus(id);
5986 {
5987 let any = tree.widget_as_any(id).unwrap();
5988 any.downcast_ref::<TreeTableView<&'static str>>()
5989 .unwrap()
5990 .set_focused_cell(0, 2); }
5992 tree.press_key(Key::ArrowRight, Modifiers::NONE);
5993 assert_eq!(
5994 proxy.visible_count(),
5995 4,
5996 "ArrowRight on the relocated tree column must expand \"docs\""
5997 );
5998 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5999 assert_eq!(proxy.visible_count(), 2, "and ArrowLeft collapses it again");
6000 }
6001
6002 #[test]
6003 fn header_drag_from_a_different_table_is_rejected() {
6004 use crate::primitives::{FixedSize, HStack};
6010 let proxy1 = SortFilterTreeModel::new(sample_tree());
6011 let proxy2 = SortFilterTreeModel::new(sample_tree());
6012 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6013
6014 let tt1 = TreeTableView::from_projection(proxy1)
6015 .add_column(reorder_col("x", 100.0))
6016 .add_column(reorder_col("y", 100.0))
6017 .row_height(20.0);
6018 let order1 = tt1.column_order_signal().clone();
6019 let id1 = tree.add(tt1);
6020 let tt2 = TreeTableView::from_projection(proxy2)
6021 .add_column(reorder_col("x", 100.0))
6022 .add_column(reorder_col("y", 100.0))
6023 .row_height(20.0);
6024 let order2 = tt2.column_order_signal().clone();
6025 let id2 = tree.add(tt2);
6026
6027 let fixed1 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id1));
6028 let fixed2 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id2));
6029 tree.add(HStack::new().add_child(fixed1).add_child(fixed2));
6030 tree.layout(SizeProposal {
6031 width: Some(400.0),
6032 height: Some(150.0),
6033 });
6034
6035 let from = tree.bounds(tt_header_row_cells(&tree, id1)[0]).center();
6038 let to = teksilo_canvas::Point::new(250.0, from.y); drag(&mut tree, from, to);
6040
6041 assert!(order1.get().is_empty(), "tt1's own order must be untouched");
6042 assert!(
6043 order2.get().is_empty(),
6044 "tt2 must reject a drop whose payload names a different table_id"
6045 );
6046 }
6047
6048 #[test]
6049 fn header_drag_released_over_the_body_does_not_trigger_foreign_row_drop() {
6050 use std::cell::Cell;
6060 let foreign_fired = Rc::new(Cell::new(false));
6061 let flag = foreign_fired.clone();
6062 let proxy = SortFilterTreeModel::new(sample_tree());
6063 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6064 let id = tree.add(
6065 TreeTableView::from_projection(proxy)
6066 .add_column(name_col())
6067 .add_column(size_col())
6068 .on_foreign_drop(move |_payload, _node, _pos, _ctx| {
6069 flag.set(true);
6070 true
6071 })
6072 .row_height(20.0),
6073 );
6074 tree.layout(SizeProposal {
6075 width: Some(400.0),
6076 height: Some(200.0),
6077 });
6078
6079 let header = tt_header_row_cells(&tree, id);
6080 let from = tree.bounds(header[0]).center();
6081 let to = teksilo_canvas::Point::new(from.x, cp::HEADER_HEIGHT + 10.0); drag(&mut tree, from, to);
6083
6084 assert!(
6085 !foreign_fired.get(),
6086 "a column-reorder drag must never reach on_foreign_drop"
6087 );
6088 let any = tree.widget_as_any(id).unwrap();
6089 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6090 assert!(
6091 tt.column_order_signal().get().is_empty(),
6092 "no header drop occurred either — the release point was outside the header strip"
6093 );
6094 }
6095
6096 #[test]
6097 fn header_drag_insertion_is_scroll_aware() {
6098 let (mut tree, id) = build_tt_wide_unpinned_table(100.0, 4, 200.0);
6103 let max = tt_max_scroll_x(&tree, id);
6104 assert!(max > 0.0, "4×100px columns must overflow a 200px viewport");
6105 tt_set_scroll_x(&tree, id, max); tree.layout(SizeProposal {
6107 width: Some(200.0),
6108 height: Some(200.0),
6109 });
6110
6111 let header = tt_header_row_cells(&tree, id);
6117 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(10.0, from.y);
6119 drag(&mut tree, from, to);
6120
6121 let any = tree.widget_as_any(id).unwrap();
6122 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6123 assert_eq!(
6124 tt.column_order_signal().get(),
6125 vec![
6126 "c0".to_string(),
6127 "c1".to_string(),
6128 "c3".to_string(),
6129 "c2".to_string()
6130 ],
6131 "\"c3\" must land before \"c2\" (scroll-aware), not before \"c0\""
6132 );
6133 }
6134
6135 fn tt_resize_table() -> (WidgetTree, WidgetId) {
6143 let proxy = SortFilterTreeModel::new(sample_tree());
6146 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6147 let id = tree.add(
6148 TreeTableView::from_projection(proxy)
6149 .add_column(name_col())
6150 .add_column(size_col())
6151 .row_height(20.0)
6152 .show_internal_scrollbars(false),
6153 );
6154 tree.layout(SizeProposal {
6155 width: Some(400.0),
6156 height: Some(200.0),
6157 });
6158 (tree, id)
6159 }
6160
6161 fn tt_overrides(tree: &WidgetTree, id: WidgetId) -> std::collections::HashMap<String, f32> {
6162 let any = tree.widget_as_any(id).unwrap();
6163 any.downcast_ref::<TreeTableView<&'static str>>()
6164 .unwrap()
6165 .column_widths_signal()
6166 .get()
6167 }
6168
6169 #[test]
6170 fn tt_grip_reaches_into_the_next_column() {
6171 use teksilo_canvas::Point;
6172 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6173 let (mut tree, id) = tt_resize_table();
6174 let y = cp::HEADER_HEIGHT * 0.5;
6175 tree.dispatch_event(WidgetEvent::PointerDown {
6177 position: Point::new(341.0, y),
6178 button: PointerButton::Primary,
6179 modifiers: Modifiers::NONE,
6180 });
6181 tree.dispatch_event(WidgetEvent::PointerMove {
6182 position: Point::new(311.0, y),
6183 });
6184 tree.dispatch_event(WidgetEvent::PointerUp {
6185 position: Point::new(311.0, y),
6186 button: PointerButton::Primary,
6187 modifiers: Modifiers::NONE,
6188 });
6189 let w = tt_overrides(&tree, id);
6190 assert!(
6191 (w.get("name").copied().unwrap_or(0.0) - 310.0).abs() < 0.5,
6192 "dragging the divider left from `size` must shrink `name` from 340 \
6193 to 310; got {w:?}"
6194 );
6195 }
6196
6197 #[test]
6198 fn tt_header_strip_paints_column_separators() {
6199 let (mut tree, _id) = tt_resize_table();
6200 let frame = tree.render();
6201 let found = frame.decorations.iter().any(|d| {
6202 let [x, y, w, h] = d.rect;
6203 (x - 339.0).abs() < 0.6
6204 && w <= 1.5
6205 && y.abs() < 0.6
6206 && (h - cp::HEADER_HEIGHT).abs() < 0.6
6207 });
6208 assert!(
6209 found,
6210 "expected a header separator at the name/size divider (x≈339); \
6211 decorations={:?}",
6212 frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
6213 );
6214 }
6215
6216 #[test]
6217 fn tree_column_chrome_is_clipped_to_its_column() {
6218 let (tree, id) = tt_resize_table();
6225 let mut walker = vec![id];
6228 let mut checked = false;
6229 while let Some(node) = walker.pop() {
6230 if tree.accessibility_node(node).role() == Role::Cell {
6231 let kids = tree.children(node);
6232 if let Some(&wrapper) = kids.first()
6233 && tree.widget_clips_children(wrapper)
6234 {
6235 checked = true;
6236 break;
6237 }
6238 }
6239 for c in tree.children(node) {
6240 walker.push(c);
6241 }
6242 }
6243 assert!(
6244 checked,
6245 "the tree column's indent + twist wrapper must clip its children"
6246 );
6247 }
6248
6249 fn editable_name_col() -> Column<&'static str> {
6252 Column::<&str>::new("name", lit!("Name"), |row, cx: &CellContext| {
6253 if cx.is_editing {
6254 Box::new(crate::text_input::TextInput::new(Signal::new(
6255 (*row).to_string(),
6256 )))
6257 } else {
6258 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6259 }
6260 })
6261 .width(ColumnWidth::Flex(1.0))
6262 .editable(true)
6263 }
6264
6265 fn three_row_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
6266 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
6267 slice.set_source(move || {
6268 [(1_u64, "one"), (2, "two"), (3, "three")]
6269 .into_iter()
6270 .map(|(k, n)| teksilo_data::TreeRow::new(k, n, 0))
6271 .collect()
6272 });
6273 slice.reload();
6274 slice
6275 }
6276
6277 fn double_click_at(tree: &mut WidgetTree, at: Point) {
6280 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6281 for _ in 0..2 {
6282 tree.dispatch_event(WidgetEvent::PointerDown {
6283 position: at,
6284 button: PointerButton::Primary,
6285 modifiers: Modifiers::NONE,
6286 });
6287 tree.dispatch_event(WidgetEvent::PointerUp {
6288 position: at,
6289 button: PointerButton::Primary,
6290 modifiers: Modifiers::NONE,
6291 });
6292 }
6293 }
6294
6295 #[test]
6304 fn opening_a_cell_editor_moves_the_keyboard_into_it() {
6305 let slice = three_row_slice();
6306 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6307 let id = tree.add(
6308 TreeTableView::from_source(slice)
6309 .add_column(editable_name_col())
6310 .row_height(20.0),
6311 );
6312 let proposal = SizeProposal {
6313 width: Some(400.0),
6314 height: Some(200.0),
6315 };
6316 tree.layout(proposal);
6317 tree.focus(id);
6318
6319 {
6320 let any = tree.widget_as_any(id).unwrap();
6321 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6322 tt.begin_edit(1, "name");
6323 }
6324 tree.layout(proposal);
6325
6326 let focused = tree.focused().expect("something must hold focus");
6327 assert_ne!(
6328 focused, id,
6329 "focus is still on the table, not in the editor"
6330 );
6331 let cell = {
6332 let any = tree.widget_as_any(id).unwrap();
6333 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6334 tt.realized_cell(1, 0).expect("the edited cell is realized")
6335 };
6336 assert!(
6337 tree.is_descendant_of(focused, cell),
6338 "focus must land inside the edited cell, not on {:?}",
6339 tree.widget_type_name(focused)
6340 );
6341 }
6342
6343 #[test]
6352 fn an_open_editor_still_holds_the_keyboard_after_the_pane_rebuilds() {
6353 let slice = three_row_slice();
6354 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6355 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6356 let id = tree.add(
6357 TreeTableView::from_source(slice)
6358 .selection(selection.clone())
6359 .add_column(editable_name_col())
6360 .row_height(20.0),
6361 );
6362 let proposal = SizeProposal {
6363 width: Some(400.0),
6364 height: Some(200.0),
6365 };
6366 tree.layout(proposal);
6367 {
6368 let any = tree.widget_as_any(id).unwrap();
6369 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6370 tt.begin_edit(1, "name");
6371 }
6372 tree.layout(proposal);
6373 tree.focused().expect("the editor took focus");
6374
6375 selection.select(2);
6376 tree.layout(proposal);
6377
6378 let focused = tree.focused().expect("focus survived the rebuild");
6379 assert_ne!(focused, id, "the rebuild dropped focus back onto the table");
6380 let cell = {
6381 let any = tree.widget_as_any(id).unwrap();
6382 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6383 tt.realized_cell(1, 0)
6384 .expect("the edited cell is still realized")
6385 };
6386 assert!(
6387 tree.is_descendant_of(focused, cell),
6388 "focus must still be inside the edited cell, not on {:?}",
6389 tree.widget_type_name(focused)
6390 );
6391 }
6392
6393 #[test]
6399 fn a_double_click_on_an_editable_cell_opens_its_editor() {
6400 let (mut tree, id, seen, _) = click_probe(EditTriggers::DOUBLE_CLICK);
6401 let cell = realized(&tree, id, 1, 0);
6402 let at = tree.bounds(cell).center();
6403 double_click_at(&mut tree, at);
6404
6405 assert_eq!(
6406 seen.borrow().as_slice(),
6407 &[(1, "name".to_string())],
6408 "a double-click on an editable cell must request its editor"
6409 );
6410 let any = tree.widget_as_any(id).unwrap();
6411 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6412 assert_eq!(tt.editing_cell_signal().get(), Some((1, 0)));
6413 }
6414
6415 #[test]
6418 fn a_single_click_opens_the_editor_when_the_column_asks_for_it() {
6419 let (mut tree, id, seen, _) = click_probe(EditTriggers::SINGLE_CLICK);
6420 let cell = realized(&tree, id, 1, 0);
6421 tree.click(cell);
6422
6423 assert_eq!(
6424 seen.borrow().as_slice(),
6425 &[(1, "name".to_string())],
6426 "one click on a SINGLE_CLICK column must request its editor"
6427 );
6428 }
6429
6430 #[test]
6434 fn a_click_opens_nothing_when_the_column_asks_for_no_click_trigger() {
6435 let (mut tree, id, seen, _) = click_probe(EditTriggers::F2);
6436 let cell = realized(&tree, id, 1, 0);
6437 tree.click(cell);
6438 let at = tree.bounds(cell).center();
6439 double_click_at(&mut tree, at);
6440
6441 assert!(
6442 seen.borrow().is_empty(),
6443 "an F2-only column opened an editor from a click: {:?}",
6444 seen.borrow()
6445 );
6446 }
6447
6448 #[test]
6463 fn editing_a_cell_by_double_click_does_not_also_activate_the_row() {
6464 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6465 let cell = realized(&tree, id, 1, 0);
6466 let at = tree.bounds(cell).center();
6467 double_click_at(&mut tree, at);
6468 assert_eq!(
6469 activated.get(),
6470 0,
6471 "double-clicking an editable cell opened the item as well as the editor"
6472 );
6473 }
6474
6475 #[test]
6479 fn a_double_click_off_an_editable_cell_still_activates_the_row() {
6480 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6481 let cell = realized(&tree, id, 1, 1);
6482 let at = tree.bounds(cell).center();
6483 double_click_at(&mut tree, at);
6484 assert_eq!(
6485 activated.get(),
6486 1,
6487 "a double-click away from an editable cell must still activate the row"
6488 );
6489 }
6490
6491 #[test]
6500 fn a_double_click_editable_cell_still_lets_its_row_select_on_one_click() {
6501 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6502 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6503 let id = tree.add(
6504 TreeTableView::from_source(three_row_slice())
6505 .selection(selection.clone())
6506 .add_column(editable_name_col().edit_triggers(EditTriggers::DOUBLE_CLICK))
6507 .row_height(20.0)
6508 .on_cell_edit_request(|_row, _col, _ctx| {}),
6509 );
6510 tree.layout(SizeProposal {
6511 width: Some(400.0),
6512 height: Some(200.0),
6513 });
6514
6515 let cell = realized(&tree, id, 1, 0);
6516 tree.click(cell);
6517 assert!(
6518 selection.is_selected(1),
6519 "one click on a double-click-editable cell must still select its row"
6520 );
6521 }
6522
6523 #[test]
6527 fn a_single_click_editable_cell_claims_the_press_from_row_selection() {
6528 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6529 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6530 let id = tree.add(
6531 TreeTableView::from_source(three_row_slice())
6532 .selection(selection.clone())
6533 .add_column(editable_name_col().edit_triggers(EditTriggers::SINGLE_CLICK))
6534 .add_column(size_col())
6535 .row_height(20.0)
6536 .on_cell_edit_request(|_row, _col, _ctx| {}),
6537 );
6538 tree.layout(SizeProposal {
6539 width: Some(400.0),
6540 height: Some(200.0),
6541 });
6542
6543 let editable = realized(&tree, id, 1, 0);
6544 tree.click(editable);
6545 assert!(
6546 !selection.is_selected(1),
6547 "a SINGLE_CLICK cell's click must go to the editor, not to selection"
6548 );
6549
6550 let plain = realized(&tree, id, 2, 1);
6553 tree.click(plain);
6554 assert!(
6555 selection.is_selected(2),
6556 "a click on a non-editing column must still select its row"
6557 );
6558 }
6559
6560 fn realized(tree: &WidgetTree, id: WidgetId, row: usize, col: usize) -> WidgetId {
6562 let any = tree.widget_as_any(id).unwrap();
6563 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6564 tt.realized_cell(row, col)
6565 .unwrap_or_else(|| panic!("cell ({row}, {col}) is not realized"))
6566 }
6567
6568 #[allow(clippy::type_complexity)]
6572 fn click_probe(
6573 triggers: EditTriggers,
6574 ) -> (
6575 WidgetTree,
6576 WidgetId,
6577 Rc<RefCell<Vec<(usize, String)>>>,
6578 Rc<Cell<usize>>,
6579 ) {
6580 let seen: Rc<RefCell<Vec<(usize, String)>>> = Rc::new(RefCell::new(Vec::new()));
6581 let sink = seen.clone();
6582 let activated: Rc<Cell<usize>> = Rc::new(Cell::new(0));
6583 let counter = activated.clone();
6584 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6585 let id = tree.add(
6586 TreeTableView::from_source(three_row_slice())
6587 .add_column(editable_name_col().edit_triggers(triggers))
6588 .add_column(size_col())
6589 .row_height(20.0)
6590 .on_cell_edit_request(move |row, col, _ctx| {
6591 sink.borrow_mut().push((row, col.to_string()));
6592 })
6593 .on_row_activate(move |_row, _ctx| counter.set(counter.get() + 1)),
6594 );
6595 tree.layout(SizeProposal {
6596 width: Some(400.0),
6597 height: Some(200.0),
6598 });
6599 (tree, id, seen, activated)
6600 }
6601}