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 editing_cell: Signal<Option<(usize, usize)>>,
231 #[allow(clippy::type_complexity)]
234 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
235 type_ahead_timeout: Duration,
237 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
239 #[allow(clippy::type_complexity)]
242 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
243 laid_out: Rc<Cell<bool>>,
247 editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
250
251 header_row_id: Option<WidgetId>,
253 body_pane_id: Option<WidgetId>,
254 scrollbar_id: Option<WidgetId>,
255 h_scrollbar_id: Option<WidgetId>,
258 empty_id: Option<WidgetId>,
259 pane_version: Signal<u64>,
263 pane_built_start: Rc<Cell<usize>>,
264 pane_built_end: Rc<Cell<usize>>,
265 pane_total_refresh: Signal<u64>,
269
270 reorderable: bool,
275 drop_feedback: Signal<Option<DropViz>>,
278
279 activate_on: crate::data_views::ActivateOn,
281
282 view_focused: Signal<bool>,
287 focus_visible: Signal<bool>,
290
291 column_widths: SharedColumnWidths,
293 display_indices: Rc<RefCell<Vec<usize>>>,
294 pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
297 cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
303 viewport_height: Rc<Cell<f32>>,
304 middle_viewport_width: Rc<Cell<f32>>,
307 body_bounds: Rc<Cell<Rect>>,
312 resize_state: ResizeStateHandle,
313 resize_target: Signal<Option<usize>>,
318 resize_preview_x: Signal<Option<f32>>,
322 header_strip_width: Rc<Cell<f32>>,
326 table_id: usize,
329
330 model_id: ViewId,
334
335 export: crate::data_views::RowExport<T>,
345 #[allow(clippy::type_complexity)]
357 on_foreign_drop:
358 Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
359
360 enabled: Prop<bool>,
365}
366
367impl<T: 'static> TreeTableView<T> {
368 pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
371 let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
372 Self::assemble(source, Some(proxy))
373 }
374
375 pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
396 Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
397 }
398
399 pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
406 source: S,
407 keyed: KeyedSelectionModel<S::Key>,
408 ) -> Self
409 where
410 S::Key: teksilo_data::ItemKey,
411 {
412 let s = Rc::new(source);
413 let key_at = {
414 let s = s.clone();
415 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
416 };
417 let len = {
418 let s = s.clone();
419 Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
420 };
421 let contains = {
422 let s = s.clone();
423 Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
424 };
425 let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
426 view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
427 view
428 }
429
430 fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
431 use std::sync::atomic::{AtomicUsize, Ordering};
432 static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
433 let table_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
434 Self {
435 source,
436 proxy,
437 columns: Vec::new(),
438 tree_column_id: None,
439 indent_per_level: None,
440 row_height: None,
441 height_source: HeightSource::Uniform,
442 row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
443 header_height: None,
444 show_header: true,
445 selection_mode: TableSelectionMode::default(),
446 row_selection: None,
447 cell_selection: None,
448 alternating_rows: false,
449 grid_lines: GridLines::None,
450 a11y_label: None,
451 show_internal_scrollbars: true,
452 column_resize_policy: ColumnResizePolicy::default(),
453 tab_traversal: TabTraversal::default(),
454 edit_triggers: EditTriggers::default(),
455 on_cell_edit_request: None,
456 on_cell_edit_dismissed: None,
457 on_row_activate: None,
458 reorderable: false,
459 drop_feedback: Signal::new(None),
460 activate_on: crate::data_views::ActivateOn::default(),
461 smooth_scrolling: true,
462 smooth_scroll_duration: Duration::from_millis(150),
463 scroll_bar_style: ScrollBarMode::Permanent,
464 scroll_y: Signal::new_animated(0.0),
465 max_scroll_y: Signal::new(0.0),
466 overscroll_behavior: OverscrollBehavior::default(),
467 viewport_ratio_y: Signal::new(1.0),
468 scroll_x: Signal::new_animated(0.0),
469 max_scroll_x: Signal::new(0.0),
470 viewport_ratio_x: Signal::new(1.0),
471 sort_signal: Signal::new(None),
472 column_widths_signal: Signal::new(HashMap::new()),
473 column_order_signal: Signal::new(Vec::new()),
474 column_pinning_signal: Signal::new(HashMap::new()),
475 filters_signal: Signal::new(HashMap::new()),
476 focused_cell: Signal::new(None),
477 type_ahead_label: None,
478 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
479 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
480 view_focused: Signal::new(true),
482 focus_visible: Signal::new(false),
483 editing_cell: Signal::new(None),
484 empty_view: None,
485 laid_out: Rc::new(Cell::new(false)),
486 editing_anchor: Rc::new(RefCell::new(None)),
487 header_row_id: None,
488 body_pane_id: None,
489 scrollbar_id: None,
490 h_scrollbar_id: None,
491 empty_id: None,
492 pane_version: Signal::new(0_u64),
493 pane_built_start: Rc::new(Cell::new(0)),
494 pane_built_end: Rc::new(Cell::new(0)),
495 pane_total_refresh: Signal::new(0_u64),
496 column_widths: Rc::new(RefCell::new(Vec::new())),
497 display_indices: Rc::new(RefCell::new(Vec::new())),
498 pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
499 cell_map: Rc::new(RefCell::new(Vec::new())),
500 viewport_height: Rc::new(Cell::new(600.0)),
501 middle_viewport_width: Rc::new(Cell::new(600.0)),
502 body_bounds: Rc::new(Cell::new(Rect::ZERO)),
503 resize_state: Rc::new(RefCell::new(None)),
504 resize_target: Signal::new(None),
505 resize_preview_x: Signal::new(None),
506 header_strip_width: Rc::new(Cell::new(0.0)),
507 table_id,
508 model_id: ViewId::next(ViewKind::TreeTable),
509 export: crate::data_views::RowExport::default(),
510 on_foreign_drop: None,
511 enabled: Prop::Static(true),
512 }
513 }
514
515 pub fn new(model: TreeModel<T>) -> Self {
519 Self::from_projection(SortFilterTreeModel::new(model))
520 }
521
522 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
527 self.enabled = enabled.into();
528 self
529 }
530
531 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
535 self.overscroll_behavior = behavior;
536 self
537 }
538
539 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
542 self.smooth_scrolling = enabled;
543 self
544 }
545
546 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
554 self.type_ahead_label = Some(Rc::new(label));
555 self
556 }
557
558 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
561 self.type_ahead_timeout = timeout;
562 self
563 }
564
565 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
567 self.smooth_scroll_duration = duration;
568 self
569 }
570
571 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
575 self.scroll_bar_style = style;
576 self
577 }
578
579 pub fn add_column(mut self, col: Column<T>) -> Self {
582 self.columns.push(col);
583 self
584 }
585
586 pub fn reorderable(mut self, enabled: bool) -> Self {
598 self.reorderable = enabled;
599 self
600 }
601
602 pub fn exportable(mut self, mode: DragTransferMode) -> Self
621 where
622 T: Clone,
623 {
624 self.export.set_exportable(mode);
625 self
626 }
627
628 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
636 where
637 T: Clone,
638 {
639 self.export.set_export_external(f);
640 self
641 }
642
643 pub fn on_rows_transferred_out(
652 mut self,
653 f: impl Fn(&[usize], &mut EventContext) + 'static,
654 ) -> Self {
655 self.export.set_on_rows_transferred_out(f);
656 self
657 }
658
659 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
665 self.export.accept_foreign_rows = accept;
666 self
667 }
668
669 pub fn on_rows_received(
673 mut self,
674 f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
675 ) -> Self {
676 self.export.set_on_rows_received(f);
677 self
678 }
679
680 pub fn on_foreign_drop(
698 mut self,
699 f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
700 ) -> Self {
701 self.on_foreign_drop = Some(Rc::new(f));
702 self
703 }
704
705 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
709 self.activate_on = mode;
710 self
711 }
712
713 pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
715 self.columns.extend(cols);
716 self
717 }
718
719 pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
722 self.tree_column_id = Some(col_id.into());
723 self
724 }
725
726 pub fn indent_per_level(mut self, px: f32) -> Self {
729 self.indent_per_level = Some(px);
730 self
731 }
732
733 fn remake_metrics(&self) {
736 *self.row_metrics.borrow_mut() = self
737 .height_source
738 .make_metrics(self.effective_row_height(), 0.0);
739 }
740
741 pub fn row_height(mut self, height: f32) -> Self {
747 self.row_height = Some(height);
748 self.height_source = HeightSource::Uniform;
749 self.remake_metrics();
750 self
751 }
752
753 pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
759 self.height_source = HeightSource::Exact(Rc::new(f));
760 self.remake_metrics();
761 self
762 }
763
764 pub fn auto_row_height(mut self, estimated: f32) -> Self {
772 self.height_source = HeightSource::Auto { estimated };
773 self.remake_metrics();
774 self
775 }
776
777 pub fn header_height(mut self, height: f32) -> Self {
779 self.header_height = Some(height);
780 self
781 }
782
783 pub fn show_header(mut self, visible: bool) -> Self {
785 self.show_header = visible;
786 self
787 }
788
789 pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
792 self.selection_mode = mode;
793 self
794 }
795
796 pub fn selection(mut self, sel: SelectionModel) -> Self {
801 self.row_selection = Some(RowSelection::from_index(sel));
802 self
803 }
804
805 pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
815 let Some(proxy) = self.proxy.clone() else {
816 return self;
817 };
818 let key_at = {
819 let p = proxy.clone();
820 Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
821 };
822 let len = {
823 let p = proxy.clone();
824 Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
825 };
826 let contains = {
829 let p = proxy;
830 Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
831 as Rc<dyn Fn(&NodeId) -> bool>
832 };
833 self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
834 self
835 }
836
837 pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
840 self.cell_selection = Some(sel);
841 self
842 }
843
844 pub fn alternating_rows(mut self, enabled: bool) -> Self {
847 self.alternating_rows = enabled;
848 self
849 }
850
851 pub fn grid_lines(mut self, kind: GridLines) -> Self {
853 self.grid_lines = kind;
854 self
855 }
856
857 pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
860 self.a11y_label = Some(label.into());
861 self
862 }
863
864 pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
868 self.show_internal_scrollbars = show;
869 self
870 }
871
872 pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
875 self.column_resize_policy = policy;
876 self
877 }
878
879 pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
881 self.tab_traversal = mode;
882 self
883 }
884
885 pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
888 self.edit_triggers = trigger;
889 self
890 }
891
892 pub fn on_cell_edit_request(
896 mut self,
897 f: impl Fn(usize, &str, &mut EventContext) + 'static,
898 ) -> Self {
899 self.on_cell_edit_request = Some(Rc::new(f));
900 self
901 }
902
903 pub fn on_cell_edit_dismissed(
921 mut self,
922 f: impl Fn(usize, &str, &mut EventContext) + 'static,
923 ) -> Self {
924 self.on_cell_edit_dismissed = Some(Rc::new(f));
925 self
926 }
927
928 pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
931 self.on_row_activate = Some(Rc::new(f));
932 self
933 }
934
935 pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
940 if let Some(p) = &self.proxy {
941 let _ = p.clone().filter_mode(mode);
942 }
943 self
944 }
945
946 pub fn scroll_y_signal(&self) -> &Signal<f32> {
950 &self.scroll_y
951 }
952
953 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
955 &self.max_scroll_y
956 }
957
958 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
960 &self.viewport_ratio_y
961 }
962
963 pub fn scroll_x_signal(&self) -> &Signal<f32> {
966 &self.scroll_x
967 }
968
969 pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
972 &self.max_scroll_x
973 }
974
975 pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
977 &self.viewport_ratio_x
978 }
979
980 pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
996 &self.sort_signal
997 }
998
999 pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1013 &self.filters_signal
1014 }
1015
1016 pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1018 &self.column_widths_signal
1019 }
1020
1021 pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1023 &self.column_order_signal
1024 }
1025
1026 pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1028 &self.focused_cell
1029 }
1030
1031 pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1033 &self.editing_cell
1034 }
1035
1036 fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1042 self.cell_map
1043 .borrow()
1044 .iter()
1045 .find(|&&(pos, _)| pos == (row, col))
1046 .map(|&(_, id)| id)
1047 }
1048
1049 pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1056 self.proxy.as_ref()
1057 }
1058
1059 pub fn expand(&self, node: NodeId) {
1063 if let Some(p) = &self.proxy {
1064 p.expand(node);
1065 }
1066 }
1067
1068 pub fn collapse(&self, node: NodeId) {
1070 if let Some(p) = &self.proxy {
1071 p.collapse(node);
1072 }
1073 }
1074
1075 pub fn toggle(&self, node: NodeId) {
1077 if let Some(p) = &self.proxy {
1078 p.toggle(node);
1079 }
1080 }
1081
1082 pub fn expand_all(&self) {
1084 if let Some(p) = &self.proxy {
1085 p.expand_all();
1086 }
1087 }
1088
1089 pub fn collapse_all(&self) {
1091 if let Some(p) = &self.proxy {
1092 p.collapse_all();
1093 }
1094 }
1095
1096 pub fn set_focused_cell(&self, row: usize, col: usize) {
1098 self.focused_cell.set(Some((row, col)));
1099 }
1100
1101 pub fn clear_focused_cell(&self) {
1103 self.focused_cell.set(None);
1104 }
1105
1106 pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1111 imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1112 }
1113
1114 pub fn set_filter(&self, col_id: &str, text: &str) {
1116 imperative::set_filter(&self.filters_signal, col_id, text);
1117 }
1118
1119 pub fn clear_filters(&self) {
1120 imperative::set_if_changed(&self.filters_signal, HashMap::new());
1121 }
1122
1123 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1126 self.empty_view = Some(Rc::new(f));
1127 self
1128 }
1129
1130 pub fn clear_sort(&self) {
1132 imperative::set_if_changed(&self.sort_signal, None);
1133 }
1134
1135 pub fn scroll_to_row(&self, row: usize) {
1138 if !self.laid_out.get() {
1139 return;
1140 }
1141 imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1142 }
1143
1144 pub fn ensure_row_visible(&self, row: usize) {
1147 imperative::ensure_row_visible(
1148 row,
1149 &self.row_metrics,
1150 &self.scroll_y,
1151 &self.max_scroll_y,
1152 self.viewport_height.get(),
1153 self.laid_out.get(),
1154 );
1155 }
1156
1157 pub fn set_column_width(&self, col_id: &str, width: f32) {
1161 imperative::set_column_width(&self.column_widths_signal, col_id, width);
1162 }
1163
1164 pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1172 imperative::set_column_widths(&self.column_widths_signal, widths);
1173 }
1174
1175 pub fn set_column_order(&self, order: Vec<String>) {
1178 imperative::set_if_changed(&self.column_order_signal, order);
1179 }
1180
1181 pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1184 &self.column_pinning_signal
1185 }
1186
1187 pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1190 imperative::set_column_pinning(&self.column_pinning_signal, col_id, side);
1191 }
1192
1193 pub fn begin_edit(&self, row: usize, col_id: &str) {
1205 let cached = self.display_indices.borrow();
1206 let recomputed;
1207 let display: &[usize] = if cached.is_empty() {
1208 recomputed = self.display_order();
1209 &recomputed
1210 } else {
1211 &cached
1212 };
1213 if let Some(target) = imperative::resolve_edit_target(
1214 row,
1215 col_id,
1216 &self.columns,
1217 display,
1218 self.source.visible_count(),
1219 ) {
1220 drop(cached);
1221 self.editing_cell.set(Some(target));
1222 }
1223 }
1224
1225 pub fn end_edit(&self) {
1227 self.editing_cell.set(None);
1228 }
1229
1230 fn effective_row_height(&self) -> f32 {
1233 self.row_height.unwrap_or(cp::ROW_HEIGHT)
1234 }
1235
1236 fn effective_header_height(&self) -> f32 {
1237 if self.show_header {
1238 self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1239 } else {
1240 0.0
1241 }
1242 }
1243
1244 fn effective_indent(&self) -> f32 {
1245 self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1246 }
1247
1248 fn tree_column_decl_index(&self) -> usize {
1251 if let Some(ref id) = self.tree_column_id {
1252 for (i, col) in self.columns.iter().enumerate() {
1253 if &col.id == id {
1254 return i;
1255 }
1256 }
1257 }
1258 0
1259 }
1260
1261 fn display_order(&self) -> Vec<usize> {
1262 let order_signal = self.column_order_signal.get();
1263 let mut order_map: HashMap<&str, usize> = HashMap::new();
1264 for (i, id) in order_signal.iter().enumerate() {
1265 order_map.insert(id.as_str(), i);
1266 }
1267 let mut leading: Vec<usize> = Vec::new();
1268 let mut middle: Vec<usize> = Vec::new();
1269 let mut trailing: Vec<usize> = Vec::new();
1270 for (i, col) in self.columns.iter().enumerate() {
1271 let pinning = self
1272 .column_pinning_signal
1273 .get()
1274 .get(&col.id)
1275 .copied()
1276 .unwrap_or(col.pinned);
1277 match pinning {
1278 PinnedSide::Leading => leading.push(i),
1279 PinnedSide::None => middle.push(i),
1280 PinnedSide::Trailing => trailing.push(i),
1281 }
1282 }
1283 const FALLBACK_BASE: usize = usize::MAX / 2;
1284 let cols = &self.columns;
1285 let key_for = |i: usize| {
1286 order_map
1287 .get(cols[i].id.as_str())
1288 .copied()
1289 .unwrap_or(FALLBACK_BASE + i)
1290 };
1291 leading.sort_by_key(|&i| key_for(i));
1292 middle.sort_by_key(|&i| key_for(i));
1293 trailing.sort_by_key(|&i| key_for(i));
1294 let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1295 out.extend(leading);
1296 let leading_count = out.len();
1297 out.extend(middle);
1298 let middle_end = out.len();
1299 out.extend(trailing);
1300 *self.pane_boundaries.borrow_mut() =
1304 crate::table_view::PaneBoundaries::new(leading_count, middle_end);
1305 out
1306 }
1307
1308 fn clamp_scroll(&self) {
1309 let max = self.max_scroll_y.get();
1310 let current = self.scroll_y.get();
1311 let clamped = current.clamp(0.0, max);
1312 if (clamped - current).abs() > 0.001 {
1313 self.scroll_y.set(clamped);
1314 }
1315 }
1316
1317 fn visible_range(&self) -> (usize, usize) {
1322 self.row_metrics.borrow_mut().visible_range(
1323 self.scroll_y.get(),
1324 self.viewport_height.get(),
1325 self.source.visible_count(),
1326 BUFFER_ROWS,
1327 )
1328 }
1329}
1330
1331impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1333 f.debug_struct("TreeTableView")
1334 .field("rows", &self.source.visible_count())
1335 .field("columns", &self.columns.len())
1336 .field("tree_column", &self.tree_column_id)
1337 .field("scroll_bar_style", &self.scroll_bar_style)
1338 .finish()
1339 }
1340}
1341
1342impl<T: 'static> Widget for TreeTableView<T> {
1343 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1344 let self_id = ctx.self_id();
1345 ctx.enabled_when(self_id, self.enabled.clone());
1346
1347 let row_h = self.effective_row_height();
1348 let header_h = self.effective_header_height();
1349 let indent_per_level = self.effective_indent();
1350
1351 let version = ctx.signal(0_u64);
1352 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1353
1354 self.scroll_y.bind_to(
1355 ctx.self_id(),
1356 ctx.binding_registry(),
1357 BindingLevel::Relayout,
1358 );
1359 ctx.register_animated_signal(&self.scroll_y);
1360
1361 self.scroll_x.bind_to(
1362 ctx.self_id(),
1363 ctx.binding_registry(),
1364 BindingLevel::Relayout,
1365 );
1366 ctx.register_animated_signal(&self.scroll_x);
1367
1368 self.pane_total_refresh.bind_to(
1373 ctx.self_id(),
1374 ctx.binding_registry(),
1375 BindingLevel::Relayout,
1376 );
1377
1378 self.column_widths_signal.bind_to(
1379 ctx.self_id(),
1380 ctx.binding_registry(),
1381 BindingLevel::Relayout,
1382 );
1383 self.resize_preview_x.bind_to(
1386 ctx.self_id(),
1387 ctx.binding_registry(),
1388 BindingLevel::RepaintOnly,
1389 );
1390
1391 {
1395 let resize_state = self.resize_state.clone();
1396 let resize_target = self.resize_target.clone();
1397 let resize_preview_x = self.resize_preview_x.clone();
1398 ctx.effect(&ctx.window_active_signal(), move |active| {
1399 if !*active && resize_state.borrow().is_some() {
1400 *resize_state.borrow_mut() = None;
1401 resize_target.set(None);
1402 resize_preview_x.set(None);
1403 }
1404 });
1405 }
1406 self.focused_cell.bind_to(
1407 ctx.self_id(),
1408 ctx.binding_registry(),
1409 BindingLevel::RepaintOnly,
1410 );
1411 self.focused_cell.bind_to(
1416 ctx.self_id(),
1417 ctx.binding_registry(),
1418 BindingLevel::AccessibilityOnly,
1419 );
1420
1421 self.view_focused = ctx.begin_view_focus();
1431 ctx.end_view_focus();
1432 self.focus_visible = ctx.focus_visible();
1433 self.view_focused.bind_to(
1434 ctx.self_id(),
1435 ctx.binding_registry(),
1436 BindingLevel::RepaintOnly,
1437 );
1438 self.focus_visible.bind_to(
1439 ctx.self_id(),
1440 ctx.binding_registry(),
1441 BindingLevel::RepaintOnly,
1442 );
1443 self.drop_feedback.bind_to(
1446 ctx.self_id(),
1447 ctx.binding_registry(),
1448 BindingLevel::RepaintOnly,
1449 );
1450
1451 let v_for_proj = version.clone();
1457 let proj_ver = Rc::new(Cell::new(0_u64));
1458 let prev_visible_count = Rc::new(Cell::new(self.source.visible_count()));
1459 ctx.effect(&self.source.version_signal(), {
1460 let metrics = self.row_metrics.clone();
1461 let src = self.source.clone();
1462 let row_sel = self.row_selection.clone();
1463 let cell_sel = self.cell_selection.clone();
1464 let prev_visible_count = prev_visible_count.clone();
1465 move |_| {
1466 metrics
1467 .borrow_mut()
1468 .apply_divergence(src.first_changed_index(), src.visible_count());
1469 if let Some(ref rs) = row_sel {
1472 rs.prune();
1473 }
1474 let new_visible_count = src.visible_count();
1488 if let Some(ref cs) = cell_sel
1489 && new_visible_count != prev_visible_count.get()
1490 {
1491 cs.clear();
1492 }
1493 prev_visible_count.set(new_visible_count);
1494 let next = proj_ver.get() + 1;
1495 proj_ver.set(next);
1496 v_for_proj.set(next);
1497 }
1498 });
1499
1500 let v_for_sort = version.clone();
1511 let sv = Rc::new(Cell::new(0_u64));
1512 ctx.effect(&self.sort_signal, move |_| {
1513 let next = sv.get() + 1;
1514 sv.set(next);
1515 v_for_sort.set(next);
1516 });
1517 let v_for_order = version.clone();
1518 let ov = Rc::new(Cell::new(0_u64));
1519 ctx.effect(&self.column_order_signal, move |_| {
1520 let next = ov.get() + 1;
1521 ov.set(next);
1522 v_for_order.set(next);
1523 });
1524 let v_for_pin = version.clone();
1525 let pv = Rc::new(Cell::new(0_u64));
1526 ctx.effect(&self.column_pinning_signal, move |_| {
1527 let next = pv.get() + 1;
1528 pv.set(next);
1529 v_for_pin.set(next);
1530 });
1531 let display_indices = self.display_order();
1538
1539 {
1546 let old_display = self.display_indices.borrow();
1547 if !old_display.is_empty() {
1548 let old_to_new: Vec<Option<usize>> = old_display
1549 .iter()
1550 .map(|&decl_idx| {
1551 let id = &self.columns[decl_idx].id;
1552 display_indices
1553 .iter()
1554 .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
1555 })
1556 .collect();
1557 drop(old_display);
1558 imperative::remap_cell_state(
1559 &self.focused_cell,
1560 &self.editing_cell,
1561 self.cell_selection.as_ref(),
1562 &old_to_new,
1563 );
1564 }
1565 }
1566 *self.display_indices.borrow_mut() = display_indices.clone();
1567 let tree_decl = self.tree_column_decl_index();
1568 let tree_display_pos = display_indices
1569 .iter()
1570 .position(|&i| i == tree_decl)
1571 .unwrap_or(0);
1572
1573 let scroll_y_for_wheel = self.scroll_y.clone();
1575 let max_scroll_for_wheel = self.max_scroll_y.clone();
1576 let scroll_x_for_wheel = self.scroll_x.clone();
1577 let max_scroll_x_for_wheel = self.max_scroll_x.clone();
1578 let line_height = row_h;
1579 let smooth_scrolling = self.smooth_scrolling;
1580 let smooth_scroll_duration = self.smooth_scroll_duration;
1581
1582 let column_ids_in_display_order: Vec<String> = display_indices
1583 .iter()
1584 .map(|&i| self.columns[i].id.clone())
1585 .collect();
1586 let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
1587 let ids = column_ids_in_display_order;
1588 Rc::new(move |pos| ids.get(pos).cloned())
1589 };
1590 let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
1594 let view_triggers = self.edit_triggers;
1595 let per_display_column: Vec<EditTriggers> = display_indices
1596 .iter()
1597 .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
1598 .collect();
1599 Rc::new(move |pos| {
1600 per_display_column
1601 .get(pos)
1602 .copied()
1603 .unwrap_or(EditTriggers::NONE)
1604 })
1605 };
1606
1607 let navigator: Rc<dyn RowNavigator> = Rc::new(TreeNavigator::new(self.source.clone()));
1608 let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
1611 self.type_ahead_label.clone().map(|user| {
1612 let src = self.source.clone();
1613 Rc::new(move |i: usize| src.with_row_str(i, &|item| user(item)))
1614 as Rc<dyn Fn(usize) -> Option<String>>
1615 });
1616
1617 let key_cfg = keyboard::KeyHandlerConfig {
1618 navigator,
1619 col_count: display_indices.len().max(1),
1620 tree_column_display_pos: tree_display_pos,
1625 focused_cell: self.focused_cell.clone(),
1626 selection_mode: self.selection_mode,
1627 selection: self.row_selection.clone(),
1628 cell_selection: self.cell_selection.clone(),
1629 scroll_y: self.scroll_y.clone(),
1630 max_scroll_y: self.max_scroll_y.clone(),
1631 viewport_height: self.viewport_height.clone(),
1632 body_bounds: self.body_bounds.clone(),
1633 row_metrics: self.row_metrics.clone(),
1634 tab_traversal: self.tab_traversal,
1635 editing_cell: self.editing_cell.clone(),
1636 display_col_to_id,
1637 display_col_triggers,
1638 on_cell_edit_request: self.on_cell_edit_request.clone(),
1639 on_row_activate: self.on_row_activate.clone(),
1640 type_ahead: self.type_ahead.clone(),
1641 type_ahead_label,
1642 type_ahead_timeout: self.type_ahead_timeout,
1643 column_widths: self.column_widths.clone(),
1644 pane_boundaries: *self.pane_boundaries.borrow(),
1645 scroll_x: self.scroll_x.clone(),
1646 max_scroll_x: self.max_scroll_x.clone(),
1647 middle_viewport_width: self.middle_viewport_width.clone(),
1648 };
1649
1650 let mut shared_key = keyboard::build_key_handler(key_cfg);
1655 let reorderable_kbd = self.reorderable;
1656 let source_kbd = self.source.clone();
1657 let focused_kbd = self.focused_cell.clone();
1658 let sel_kbd = self.row_selection.clone();
1659 let sort_kbd = self.sort_signal.clone();
1660 let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1661 ctx: &mut EventContext|
1662 -> EventResponse {
1663 use teksilo_core::event::{Key, WidgetEvent};
1664 if reorderable_kbd
1665 && sort_kbd.get().is_none()
1666 && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1667 && modifiers.alt()
1668 && matches!(key, Key::ArrowUp | Key::ArrowDown)
1669 {
1670 let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1671 sel_kbd
1672 .as_ref()
1673 .and_then(|s| s.selected_indices().first().copied())
1674 });
1675 if let Some(flat_idx) = row
1679 && let Some(new_flat) =
1680 source_kbd.keyboard_reorder(flat_idx, matches!(key, Key::ArrowDown))
1681 {
1682 let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1683 focused_kbd.set(Some((new_flat, col)));
1684 if let Some(ref s) = sel_kbd {
1685 s.select(new_flat);
1686 }
1687 return EventResponse::Handled;
1688 }
1689 }
1690 shared_key(event, ctx)
1691 };
1692
1693 let mut handlers = HandlerSet::new()
1694 .on_scroll({
1695 let overscroll_behavior = self.overscroll_behavior;
1696 move |event, _ctx| match event {
1697 teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1698 let (raw_dx, raw_dy) = match delta {
1699 teksilo_core::event::ScrollDelta::Lines { x, y } => {
1700 (x * line_height, y * line_height)
1701 }
1702 teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1703 };
1704 let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1707 (raw_dy, 0.0)
1708 } else {
1709 (raw_dx, raw_dy)
1710 };
1711
1712 let mut moved_any = false;
1713 if dy.abs() > 0.0 {
1714 let current = scroll_y_for_wheel.get();
1715 let max = max_scroll_for_wheel.get();
1716 let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1721 let (new_y, moved) =
1722 crate::common::scroll::scroll_clamp_axis(base, dy, max);
1723 if moved {
1724 if smooth_scrolling {
1725 scroll_y_for_wheel.animate_to(
1726 new_y,
1727 smooth_scroll_duration,
1728 Easing::EaseOut,
1729 );
1730 } else {
1731 scroll_y_for_wheel.set(new_y);
1732 }
1733 }
1734 moved_any |= moved;
1735 }
1736 if dx.abs() > 0.0 {
1737 let current = scroll_x_for_wheel.get();
1738 let max = max_scroll_x_for_wheel.get();
1739 let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1740 let (new_x, moved) =
1741 crate::common::scroll::scroll_clamp_axis(base, dx, max);
1742 if moved {
1743 if smooth_scrolling {
1744 scroll_x_for_wheel.animate_to(
1745 new_x,
1746 smooth_scroll_duration,
1747 Easing::EaseOut,
1748 );
1749 } else {
1750 scroll_x_for_wheel.set(new_x);
1751 }
1752 }
1753 moved_any |= moved;
1754 }
1755 crate::common::scroll::scroll_response(
1759 moved_any,
1760 overscroll_behavior == OverscrollBehavior::Contain,
1761 )
1762 }
1763 _ => EventResponse::Ignored,
1764 }
1765 })
1766 .on_key(key_handler)
1767 .clips_children(true)
1768 .focusable(true);
1769
1770 if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
1790 let my_model_id = self.model_id;
1791 let source_for_hover = self.source.clone();
1792 let metrics_for_hover = self.row_metrics.clone();
1793 let scroll_for_hover = self.scroll_y.clone();
1794 let header_h_for_hover = header_h;
1795 let feedback_for_hover = self.drop_feedback.clone();
1796 let sort_for_hover = self.sort_signal.clone();
1797 let reorderable_hover = self.reorderable;
1798 let export_for_hover = self.export.clone();
1799 let has_foreign_hook_hover = self.on_foreign_drop.is_some();
1800 let bounds_for_hover = self.body_bounds.clone();
1801 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1802 if payload.has_typed::<ColumnReorderDragData>() {
1810 feedback_for_hover.set(None);
1811 return teksilo_core::DropFeedback::NoFeedback;
1812 }
1813 let viz_width = bounds_for_hover.get().width.max(1.0);
1816 let count = source_for_hover.visible_count();
1817 if count == 0 {
1818 feedback_for_hover.set(None);
1819 return teksilo_core::DropFeedback::NoFeedback;
1820 }
1821 let rd = payload.get_typed::<RowDragData<T>>();
1822 let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
1823 let reorder_ok =
1824 is_same_view && reorderable_hover && sort_for_hover.get().is_none();
1825 let foreign_ok = !is_same_view
1829 && (has_foreign_hook_hover
1830 || export_for_hover.accepts_foreign_export(payload, my_model_id));
1831 if !reorder_ok && !foreign_ok {
1832 feedback_for_hover.set(None);
1833 return teksilo_core::DropFeedback::NoFeedback;
1834 }
1835 let scroll = scroll_for_hover.get().max(0.0);
1836 let content_y = position.y - header_h_for_hover + scroll;
1837 let (insertion_top, row_idx, row_top, row_h) = {
1838 let mut m = metrics_for_hover.borrow_mut();
1839 m.resize(count);
1840 let ins = m.insertion_index(content_y);
1841 let r = m.row_at(content_y);
1842 (m.row_top(ins), r, m.row_top(r), m.row_height(r))
1843 };
1844 let y_in_row = content_y - row_top;
1845 let third = (row_h / 3.0).max(f32::EPSILON);
1846 let drop_pos = if y_in_row < third {
1847 DropPosition::Before
1848 } else if y_in_row > 2.0 * third {
1849 DropPosition::After
1850 } else {
1851 DropPosition::Into
1852 };
1853 let (effective, depth) = if reorder_ok {
1861 match (source_for_hover.dnd.can_accept_fn)(
1862 payload,
1863 row_idx,
1864 drop_pos,
1865 my_model_id,
1866 ) {
1867 DropResponse::Reject => {
1868 if !foreign_ok {
1869 feedback_for_hover.set(None);
1870 return teksilo_core::DropFeedback::NoFeedback;
1871 }
1872 (DropPosition::Before, 0)
1873 }
1874 DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1875 DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1876 }
1877 } else {
1878 (DropPosition::Before, 0)
1880 };
1881 if effective == DropPosition::Into {
1882 let top = row_top - scroll;
1883 feedback_for_hover.set(Some(DropViz::Rect {
1884 top,
1885 height: row_h,
1886 width: viz_width,
1887 depth,
1888 }));
1889 teksilo_core::DropFeedback::HighlightRect {
1890 rect: Rect::new(0.0, top, viz_width, row_h),
1891 color: drop_into_tint(),
1892 }
1893 } else {
1894 let insertion_y = insertion_top - scroll;
1895 feedback_for_hover.set(Some(DropViz::Line {
1896 y: insertion_y,
1897 width: viz_width,
1898 depth,
1899 }));
1900 teksilo_core::DropFeedback::InsertionLine {
1901 y: insertion_y,
1902 width: viz_width,
1903 }
1904 }
1905 });
1906
1907 let drop_model_id = self.model_id;
1908 let source_for_drop = self.source.clone();
1909 let metrics_for_drop = self.row_metrics.clone();
1910 let scroll_for_drop = self.scroll_y.clone();
1911 let header_h_for_drop = header_h;
1912 let feedback_for_drop = self.drop_feedback.clone();
1913 let sort_for_drop = self.sort_signal.clone();
1914 let reorderable_drop = self.reorderable;
1915 let on_foreign_for_drop = self.on_foreign_drop.clone();
1916 let proxy_for_foreign_hook = self.proxy.clone();
1917 let export_for_drop = self.export.clone();
1918 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1919 feedback_for_drop.set(None);
1920 if payload.has_typed::<ColumnReorderDragData>() {
1924 return false;
1925 }
1926 let count = source_for_drop.visible_count();
1927 if count == 0 {
1928 return false;
1929 }
1930 let scroll = scroll_for_drop.get().max(0.0);
1931 let content_y = position.y - header_h_for_drop + scroll;
1932 let (flat_idx, row_top, row_h, ins) = {
1933 let mut m = metrics_for_drop.borrow_mut();
1934 m.resize(count);
1935 let idx = m.row_at(content_y);
1936 let ins = m.insertion_index(content_y);
1937 (idx, m.row_top(idx), m.row_height(idx), ins)
1938 };
1939 let y_in_row = content_y - row_top;
1940 let third = (row_h / 3.0).max(f32::EPSILON);
1941 let drop_pos = if y_in_row < third {
1942 DropPosition::Before
1943 } else if y_in_row > 2.0 * third {
1944 DropPosition::After
1945 } else {
1946 DropPosition::Into
1947 };
1948 let is_same_view = payload
1949 .get_typed::<RowDragData<T>>()
1950 .is_some_and(|rd| rd.source == drop_model_id);
1951 if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
1952 return false;
1953 }
1954 if (reorderable_drop || !is_same_view)
1959 && (source_for_drop.dnd.accept_drop_fn)(
1960 &payload,
1961 flat_idx,
1962 drop_pos,
1963 drop_model_id,
1964 )
1965 {
1966 if is_same_view {
1967 export_for_drop.note_self_reorder();
1968 }
1969 return true;
1970 }
1971 if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
1974 return true;
1975 }
1976 if let Some(ref hook) = on_foreign_for_drop
1980 && let Some(ref p) = proxy_for_foreign_hook
1981 && let Some(node) = p.visible_node_id(flat_idx)
1982 {
1983 return hook(&payload, node, drop_pos, ctx);
1984 }
1985 false
1986 });
1987
1988 let feedback_for_leave = self.drop_feedback.clone();
1989 handlers = handlers.on_drag_leave(move |_ctx| {
1990 feedback_for_leave.set(None);
1991 });
1992
1993 let scroll_for_tick = self.scroll_y.clone();
1994 let max_scroll_for_tick = self.max_scroll_y.clone();
1995 let viewport_for_tick = self.viewport_height.clone();
1996 let header_h_for_tick = header_h;
1997 handlers = handlers.on_drag_tick(move |pos, _ctx| {
1998 const EDGE: f32 = 32.0;
2001 const MAX_VELOCITY: f32 = 12.0;
2002 let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2003 let y = pos.y - header_h_for_tick;
2004 let above = (EDGE - y).max(0.0);
2005 let below = (y - (body_h - EDGE)).max(0.0);
2006 let delta = if above > 0.0 {
2007 -(above / EDGE) * MAX_VELOCITY
2008 } else if below > 0.0 {
2009 (below / EDGE) * MAX_VELOCITY
2010 } else {
2011 0.0
2012 };
2013 if delta.abs() > 0.01 {
2014 let max = max_scroll_for_tick.get();
2015 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2016 scroll_for_tick.set(new_y);
2017 }
2018 });
2019 }
2020
2021 handlers = self.export.install_completion(handlers);
2031
2032 ctx.apply_self_handlers(handlers);
2033
2034 self.header_row_id = None;
2037 self.body_pane_id = None;
2038 self.scrollbar_id = None;
2039 self.h_scrollbar_id = None;
2040 self.empty_id = None;
2041
2042 if self.show_header {
2044 *self.resize_state.borrow_mut() = None;
2048 self.resize_target.set(None);
2049 self.resize_preview_x.set(None);
2050
2051 let boundaries = *self.pane_boundaries.borrow();
2052 let resize_columns: ColumnResizeTable = Rc::new(
2053 display_indices
2054 .iter()
2055 .map(|&i| {
2056 let c = &self.columns[i];
2057 ColumnResizeInfo {
2058 id: c.id.clone(),
2059 min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2060 max_width: c.max_width,
2061 resizable: c.resizable,
2062 }
2063 })
2064 .collect(),
2065 );
2066 let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2067 let active_sort = self.sort_signal.get();
2068 for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2069 let col = &self.columns[col_idx];
2070 let current_sort = active_sort
2071 .as_ref()
2072 .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2073 let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2074 let cell = HeaderCell::new(HeaderCellSpec {
2075 col_id: col.id.clone(),
2076 label: col.header_label.resolve_now(),
2077 col_index_1based: display_pos + 1,
2078 sortable: col.sortable,
2079 reorderable: col.reorderable,
2080 filterable: col.filterable,
2081 resize_grip: cp::RESIZE_HANDLE_WIDTH,
2082 filter_zone_width,
2083 current_sort,
2084 width_index: display_pos,
2085 pane_boundaries: boundaries,
2086 resize_columns: resize_columns.clone(),
2087 resize_policy: self.column_resize_policy,
2088 resize_state: self.resize_state.clone(),
2089 resize_target: self.resize_target.clone(),
2090 resize_preview_x: self.resize_preview_x.clone(),
2091 table_id: self.table_id,
2092 sort_signal: self.sort_signal.clone(),
2093 column_widths_signal: self.column_widths_signal.clone(),
2094 column_widths: self.column_widths.clone(),
2095 filters_signal: self.filters_signal.clone(),
2096 });
2097 cell_ids.push(ctx.add(cell));
2098 }
2099 let header_row = HeaderRow::new(
2100 cell_ids,
2101 self.column_widths.clone(),
2102 cp::GRID_LINE_THICKNESS,
2103 *self.pane_boundaries.borrow(),
2104 self.scroll_x.clone(),
2105 );
2106 let header_row_id = ctx.add(header_row);
2116 attach_header_reorder_handlers(
2117 ctx,
2118 header_row_id,
2119 self.table_id,
2120 self.column_widths.clone(),
2121 self.display_indices.clone(),
2122 self.pane_boundaries.clone(),
2123 self.column_order_signal.clone(),
2124 self.column_pinning_signal.clone(),
2125 self.columns.iter().map(|c| c.id.clone()).collect(),
2126 self.header_strip_width.clone(),
2127 self.scroll_x.clone(),
2128 );
2129 self.header_row_id = Some(header_row_id);
2130 }
2131
2132 let row_count = self.source.visible_count();
2137
2138 let (vis_start, vis_end) = self.visible_range();
2146 (self.source.dnd.request_window_fn)(vis_start..vis_end);
2147 if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2148 (self.source.dnd.fetch_more_fn)();
2149 }
2150
2151 if row_count > 0 {
2152 let pane = body_pane::TreeBodyPane::<T> {
2153 source: self.source.clone(),
2154 editing_anchor: self.editing_anchor.clone(),
2155 columns: self.columns.clone(),
2156 display_indices: self.display_indices.clone(),
2157 column_widths: self.column_widths.clone(),
2158 pane_boundaries: *self.pane_boundaries.borrow(),
2159 scroll_x: self.scroll_x.clone(),
2160 tree_display_pos,
2161 indent_per_level,
2162 row_metrics: self.row_metrics.clone(),
2163 selection_mode: self.selection_mode,
2164 selection: self.row_selection.clone(),
2165 cell_selection: self.cell_selection.clone(),
2166 scroll_y: self.scroll_y.clone(),
2167 viewport_height: self.viewport_height.clone(),
2168 editing_cell: self.editing_cell.clone(),
2169 focused_cell: self.focused_cell.clone(),
2170 reorderable: self.reorderable,
2171 model_id: self.model_id,
2172 export: self.export.clone(),
2173 drag_anchor: ctx.self_id(),
2174 on_row_activate: self.on_row_activate.clone(),
2175 activate_on: self.activate_on,
2176 edit_triggers: self.edit_triggers,
2177 on_cell_edit_request: self.on_cell_edit_request.clone(),
2178 on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2179 version: self.pane_version.clone(),
2180 prev_built_start: self.pane_built_start.clone(),
2181 prev_built_end: self.pane_built_end.clone(),
2182 total_refresh: self.pane_total_refresh.clone(),
2183 row_entries: Vec::new(),
2184 cell_map: self.cell_map.clone(),
2185 };
2186 self.body_pane_id = Some(ctx.add(pane));
2187 if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
2191 &self.on_cell_edit_dismissed,
2192 &self.editing_cell,
2193 &Rc::new(
2194 display_indices
2195 .iter()
2196 .map(|&i| self.columns[i].id.clone())
2197 .collect::<Vec<_>>(),
2198 ),
2199 ) {
2200 ctx.apply_self_handlers(handlers);
2201 }
2202 } else if let Some(ref f) = self.empty_view {
2203 self.empty_id = Some(ctx.add_boxed(f()));
2205 }
2206
2207 if self.show_internal_scrollbars {
2209 let sb = ScrollBar::new(
2210 ScrollBarOrientation::Vertical,
2211 self.scroll_y.clone(),
2212 self.max_scroll_y.clone(),
2213 self.viewport_ratio_y.clone(),
2214 )
2215 .visual(match self.scroll_bar_style {
2216 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2217 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2218 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2219 });
2220 self.scrollbar_id = Some(ctx.add(sb));
2221
2222 let hsb = ScrollBar::new(
2224 ScrollBarOrientation::Horizontal,
2225 self.scroll_x.clone(),
2226 self.max_scroll_x.clone(),
2227 self.viewport_ratio_x.clone(),
2228 )
2229 .visual(match self.scroll_bar_style {
2230 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2231 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2232 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2233 });
2234 self.h_scrollbar_id = Some(ctx.add(hsb));
2235 }
2236
2237 let mut children: Vec<WidgetId> = Vec::new();
2241 if let Some(id) = self.body_pane_id {
2242 children.push(id);
2243 }
2244 if let Some(id) = self.empty_id {
2245 children.push(id);
2246 }
2247 if let Some(id) = self.scrollbar_id {
2248 children.push(id);
2249 }
2250 if let Some(id) = self.h_scrollbar_id {
2251 children.push(id);
2252 }
2253 if let Some(id) = self.header_row_id {
2254 children.push(id);
2255 }
2256 let _ = (header_h, row_h);
2257 children
2258 }
2259
2260 fn layout_response(
2261 &self,
2262 proposal: SizeProposal,
2263 _ctx: &LayoutContext,
2264 ) -> teksilo_core::widget::LayoutResponse {
2265 let size = crate::common::viewport::viewport_size(
2269 proposal,
2270 &self.viewport_height,
2271 Size::new(400.0, 300.0),
2272 );
2273 if proposal.height.is_some() {
2274 self.laid_out.set(true);
2278 }
2279 size.into()
2280 }
2281
2282 fn place_children(
2283 &self,
2284 bounds: Rect,
2285 _proposal: SizeProposal,
2286 children: &mut [WidgetPlacement],
2287 ctx: &LayoutContext,
2288 ) {
2289 if children.is_empty() {
2290 return;
2291 }
2292 let rtl = ctx.is_rtl();
2293 let header_h = self.effective_header_height();
2294 let body_height_provisional = (bounds.height - header_h).max(0.0);
2295
2296 let total_height = self
2300 .row_metrics
2301 .borrow_mut()
2302 .total_height(self.source.visible_count());
2303 let needs_v_scrollbar =
2304 self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2305 let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2308 let body_width = if reserves_v_bar {
2309 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2310 } else {
2311 bounds.width
2312 };
2313 let band_left = if rtl && reserves_v_bar {
2317 bounds.x + SCROLLBAR_THICKNESS
2318 } else {
2319 bounds.x
2320 };
2321 let scrollbar_x = if rtl {
2322 bounds.x
2323 } else {
2324 bounds.x + bounds.width - SCROLLBAR_THICKNESS
2325 };
2326 self.header_strip_width.set(body_width);
2329
2330 let overrides = self.column_widths_signal.get();
2331 let display = self.display_indices.borrow().clone();
2332 let widths = layout::ColumnSolver::resolve_in_order(
2333 &self.columns,
2334 &display,
2335 body_width,
2336 cp::MIN_COLUMN_WIDTH_DEFAULT,
2337 &overrides,
2338 );
2339
2340 let boundaries = *self.pane_boundaries.borrow();
2342 let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2343 let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2344 let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2345 self.max_scroll_x.set(max_x);
2346 self.middle_viewport_width.set(middle_viewport_w);
2347 let x_ratio = if middle_content_w > 0.0 {
2348 (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2349 } else {
2350 1.0
2351 };
2352 self.viewport_ratio_x.set(x_ratio);
2353 {
2354 let current = self.scroll_x.get();
2355 let clamped = current.clamp(0.0, max_x);
2356 if (clamped - current).abs() > 0.001 {
2357 self.scroll_x.set(clamped);
2358 }
2359 }
2360
2361 *self.column_widths.borrow_mut() = widths;
2362
2363 let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2364 let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2365 let body_height = if reserves_h_bar {
2366 (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2367 } else {
2368 body_height_provisional
2369 };
2370
2371 let max_y = (total_height - body_height).max(0.0);
2372 self.max_scroll_y.set(max_y);
2373 let y_ratio = if total_height > 0.0 {
2374 (body_height / total_height).clamp(0.0, 1.0)
2375 } else {
2376 1.0
2377 };
2378 self.viewport_ratio_y.set(y_ratio);
2379 self.clamp_scroll();
2380
2381 let body_origin_y = bounds.y + header_h;
2382 self.body_bounds
2384 .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2385
2386 let mut next = 0;
2387
2388 if self.body_pane_id.is_some() {
2391 if let Some(child) = children.get_mut(next) {
2392 child.origin = Point::new(band_left, body_origin_y);
2393 child.size = Size::new(body_width, body_height);
2394 }
2395 next += 1;
2396 }
2397
2398 if self.empty_id.is_some() {
2400 if let Some(child) = children.get_mut(next) {
2401 child.origin = Point::new(band_left, body_origin_y);
2402 child.size = Size::new(body_width, body_height);
2403 }
2404 next += 1;
2405 }
2406
2407 if self.scrollbar_id.is_some() {
2409 if let Some(child) = children.get_mut(next) {
2410 if needs_v_scrollbar {
2411 child.origin = Point::new(scrollbar_x, body_origin_y);
2412 child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2413 } else {
2414 child.origin = bounds.origin();
2415 child.size = Size::ZERO;
2416 }
2417 }
2418 next += 1;
2419 }
2420
2421 if self.h_scrollbar_id.is_some() {
2423 if let Some(child) = children.get_mut(next) {
2424 if needs_h_scrollbar {
2425 let h_x = if rtl {
2426 band_left + trailing_w
2427 } else {
2428 band_left + leading_w
2429 };
2430 child.origin = Point::new(h_x, body_origin_y + body_height);
2431 child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2432 } else {
2433 child.origin = bounds.origin();
2434 child.size = Size::ZERO;
2435 }
2436 }
2437 next += 1;
2438 }
2439
2440 if self.header_row_id.is_some()
2443 && let Some(child) = children.get_mut(next)
2444 {
2445 child.origin = Point::new(band_left, bounds.y);
2446 child.size = Size::new(body_width, header_h);
2447 }
2448 }
2449
2450 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2451 let header_h = self.effective_header_height();
2452 let colors = &ctx.theme.colors;
2453 let scroll_y = self.scroll_y.get();
2454 let body_origin_y = bounds.y + header_h;
2455 let body_height = (bounds.height - header_h).max(0.0);
2456 let widths = self.column_widths.borrow();
2457 let body_width = widths.iter().sum::<f32>();
2458 let body_width_for_paint = if body_width > 0.0 {
2459 body_width.min(bounds.width)
2460 } else {
2461 bounds.width
2462 };
2463 let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2465 let content_left = if rtl {
2466 bounds.x + bounds.width - body_width_for_paint
2467 } else {
2468 bounds.x
2469 };
2470
2471 let row_count = self.source.visible_count();
2474 let (first_visible, last_visible) =
2475 self.row_metrics
2476 .borrow_mut()
2477 .visible_range(scroll_y, body_height, row_count, 0);
2478
2479 canvas.set_clip(Rect::new(
2485 content_left,
2486 body_origin_y,
2487 body_width_for_paint,
2488 body_height,
2489 ));
2490
2491 if self.alternating_rows {
2492 let mut m = self.row_metrics.borrow_mut();
2493 for row_idx in first_visible..last_visible {
2494 if row_idx % 2 == 1 {
2495 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2496 let h = m.row_height(row_idx);
2497 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2498 canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2499 }
2500 }
2501 }
2502
2503 if let Some(ref sel) = self.row_selection
2504 && matches!(
2505 self.selection_mode,
2506 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2507 )
2508 {
2509 let bg = if self.view_focused.get() && ctx.window_active {
2513 SurfaceRole::Selected.resolve(colors)
2514 } else {
2515 SurfaceRole::SelectedInactive.resolve(colors)
2516 };
2517 let mut m = self.row_metrics.borrow_mut();
2518 for row_idx in sel.selected_indices() {
2519 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2520 let h = m.row_height(row_idx);
2521 if y + h < body_origin_y || y > body_origin_y + body_height {
2522 continue;
2523 }
2524 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2525 canvas.fill_rect(rect, bg);
2526 }
2527 }
2528
2529 let line_color = BorderRole::Divider.resolve(colors);
2530 let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2531 if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2532 let mut m = self.row_metrics.borrow_mut();
2533 for row_idx in first_visible..last_visible {
2534 let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2535 let y = body_origin_y + bottom - scroll_y - line_w;
2536 let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2537 canvas.fill_rect(rect, line_color);
2538 }
2539 }
2540
2541 let boundaries = *self.pane_boundaries.borrow();
2544 let scroll_x = self.scroll_x.get();
2545 let content_bounds = Rect::new(
2546 content_left,
2547 body_origin_y,
2548 body_width_for_paint,
2549 body_height,
2550 );
2551 let (leading_rect, middle_rect, trailing_rect) =
2552 layout::band_rects(content_bounds, &widths, boundaries, rtl);
2553
2554 if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2555 let leading_end = boundaries.leading_count.min(widths.len());
2556 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2557 crate::table_view::draw_pane_dividers(
2558 canvas,
2559 leading_rect,
2560 &widths[..leading_end],
2561 0.0,
2562 rtl,
2563 line_color,
2564 line_w,
2565 );
2566 crate::table_view::draw_pane_dividers(
2567 canvas,
2568 middle_rect,
2569 &widths[leading_end..middle_end],
2570 scroll_x,
2571 rtl,
2572 line_color,
2573 line_w,
2574 );
2575 crate::table_view::draw_pane_dividers(
2576 canvas,
2577 trailing_rect,
2578 &widths[middle_end..],
2579 0.0,
2580 rtl,
2581 line_color,
2582 line_w,
2583 );
2584 }
2585
2586 if self.view_focused.get()
2589 && self.focus_visible.get()
2590 && let Some((focus_row, focus_col)) = self.focused_cell.get()
2591 && focus_col < widths.len()
2592 && let Some(x_off) = layout::column_logical_x(
2593 &widths,
2594 boundaries,
2595 scroll_x,
2596 body_width_for_paint,
2597 focus_col,
2598 )
2599 {
2600 let cell_w = widths[focus_col];
2601 let (focus_top, focus_h) = {
2602 let mut m = self.row_metrics.borrow_mut();
2603 (m.row_top(focus_row), m.row_height(focus_row))
2604 };
2605 let y = body_origin_y + focus_top - scroll_y;
2606 if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2607 let pane_rect = if focus_col < boundaries.leading_count {
2608 leading_rect
2609 } else if focus_col >= boundaries.middle_end {
2610 trailing_rect
2611 } else {
2612 middle_rect
2613 };
2614 canvas.set_clip(pane_rect);
2615 let inset = cp::FOCUS_RING_INSET;
2616 let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2617 let ring_color = BorderRole::Focused.resolve(colors);
2618 let rx = if rtl {
2619 content_left + body_width_for_paint - x_off - cell_w + inset
2620 } else {
2621 content_left + x_off + inset
2622 };
2623 let ry = y + inset;
2624 let rw = (cell_w - inset * 2.0).max(0.0);
2625 let rh = (focus_h - inset * 2.0).max(0.0);
2626 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2627 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2628 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2629 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2630 canvas.clear_clip();
2631 }
2632 }
2633
2634 let drop_indent_origin = |depth: usize| -> f32 {
2645 let step = self.effective_indent();
2646 let tree_decl = self.tree_column_decl_index();
2647 let tree_slot = self
2648 .display_indices
2649 .borrow()
2650 .iter()
2651 .position(|&i| i == tree_decl)
2652 .unwrap_or(0);
2653 let col_x = layout::column_logical_x(
2654 &widths,
2655 boundaries,
2656 scroll_x,
2657 body_width_for_paint,
2658 tree_slot,
2659 )
2660 .unwrap_or(0.0);
2661 (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
2662 };
2663 match self.drop_feedback.get() {
2664 Some(DropViz::Line { y, depth, .. }) => {
2665 let recipe = ctx
2666 .theme
2667 .style_slots
2668 .list_container
2669 .as_ref()
2670 .map(|s| s.insertion())
2671 .unwrap_or_default();
2672 let line_color = recipe.role.resolve(colors);
2673 let thickness = recipe.thickness;
2674 let line_y = body_origin_y + y - thickness * 0.5;
2675 let indent = drop_indent_origin(depth);
2676 let x = if rtl {
2679 content_left
2680 } else {
2681 content_left + indent
2682 };
2683 canvas.fill_rect(
2684 Rect::new(x, line_y, body_width_for_paint - indent, thickness),
2685 line_color,
2686 );
2687 }
2688 Some(DropViz::Rect {
2693 top, height, depth, ..
2694 }) => {
2695 let into = ctx
2696 .theme
2697 .style_slots
2698 .list_container
2699 .as_ref()
2700 .map(|s| s.drop_into())
2701 .unwrap_or_default();
2702 let color = into.role.resolve(colors);
2703 let indent = drop_indent_origin(depth);
2704 let x = if rtl {
2705 content_left
2706 } else {
2707 content_left + indent
2708 };
2709 let rect = Rect::new(
2710 x + into.inset,
2711 body_origin_y + top + into.inset,
2712 (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
2713 (height - into.inset * 2.0).max(0.0),
2714 );
2715 let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
2716 canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
2717 canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
2718 }
2719 None => {}
2720 }
2721
2722 canvas.clear_clip();
2723
2724 let nothing_indicated = self.focused_cell.get().is_none()
2728 && self
2729 .row_selection
2730 .as_ref()
2731 .is_none_or(|s| s.selected_indices().is_empty())
2732 && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2733 if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2734 let inset = 1.0_f32;
2735 let rect = Rect::new(
2736 bounds.x + inset,
2737 bounds.y + inset,
2738 (bounds.width - inset * 2.0).max(0.0),
2739 (bounds.height - inset * 2.0).max(0.0),
2740 );
2741 canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2742 }
2743
2744 if let Some(x) = self.resize_preview_x.get() {
2746 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2747 canvas.fill_rect(
2748 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2749 BorderRole::Focused.resolve(colors),
2750 );
2751 }
2752 }
2753
2754 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2755 builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
2756 if let Some(ref label) = self.a11y_label {
2757 builder.set_name(label.resolve_now());
2758 }
2759 let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
2760 let col_count = self.columns.len();
2761 let n = builder.inner_mut();
2762 n.set_row_count(row_count);
2763 n.set_column_count(col_count);
2764
2765 if let Some((row, col)) = self.focused_cell.get()
2772 && let Some(cell_id) = self.realized_cell(row, col)
2773 {
2774 builder.set_active_descendant(widget_id_to_node_id(cell_id));
2775 }
2776 }
2777
2778 fn as_any(&self) -> Option<&dyn std::any::Any> {
2779 Some(self)
2780 }
2781
2782 fn children(&self) -> Vec<WidgetId> {
2783 let mut out: Vec<WidgetId> = Vec::new();
2786 if let Some(id) = self.body_pane_id {
2787 out.push(id);
2788 }
2789 if let Some(id) = self.empty_id {
2790 out.push(id);
2791 }
2792 if let Some(id) = self.scrollbar_id {
2793 out.push(id);
2794 }
2795 if let Some(id) = self.h_scrollbar_id {
2796 out.push(id);
2797 }
2798 if let Some(id) = self.header_row_id {
2799 out.push(id);
2800 }
2801 out
2802 }
2803
2804 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2805 let out: Vec<WidgetId> = [
2809 self.header_row_id,
2810 self.body_pane_id,
2811 self.empty_id,
2812 self.scrollbar_id,
2813 self.h_scrollbar_id,
2814 ]
2815 .into_iter()
2816 .flatten()
2817 .collect();
2818 if out.is_empty() { None } else { Some(out) }
2819 }
2820
2821 fn clips_children(&self) -> bool {
2822 true
2823 }
2824}
2825
2826#[cfg(test)]
2827mod tests {
2828 use super::*;
2829 use crate::table_view::column::{CellContext, ColumnWidth};
2830 use teksilo_canvas::SizeProposal;
2831 use teksilo_core::accesskit::Role;
2832 use teksilo_core::widget_tree::WidgetTree;
2833 use teksilo_data::{SortFilterTreeModel, TreeFilterMode, TreeModel};
2834 use teksilo_i18n::lit;
2835
2836 fn sample_tree() -> TreeModel<&'static str> {
2837 let t = TreeModel::new();
2838 let docs = t.insert_root(0, "docs");
2839 t.insert_child(docs, 0, "readme");
2840 t.insert_child(docs, 1, "guide");
2841 let src = t.insert_root(1, "src");
2842 t.insert_child(src, 0, "main.rs");
2843 t
2844 }
2845
2846 fn name_col() -> Column<&'static str> {
2847 Column::<&str>::new("name", lit!("Name"), |row, _: &CellContext| {
2848 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
2849 })
2850 .width(ColumnWidth::Flex(1.0))
2851 }
2852
2853 fn size_col() -> Column<&'static str> {
2854 Column::<&str>::new("size", lit!("Size"), |_row, _: &CellContext| {
2855 Box::new(crate::primitives::TextWidget::new(lit!("0")))
2856 })
2857 .width(ColumnWidth::Fixed(60.0))
2858 }
2859
2860 #[test]
2861 fn row_selection_click_repaints_immediately_without_expand_collapse() {
2862 use teksilo_canvas::Point;
2869 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2870 use teksilo_data::{SelectionMode, SelectionModel};
2871 let proxy = SortFilterTreeModel::new(sample_tree());
2872 let selection = SelectionModel::new(SelectionMode::Single);
2873 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2874 tree.add(
2875 TreeTableView::from_projection(proxy.clone())
2876 .add_column(name_col())
2877 .selection_mode(TableSelectionMode::SingleRow)
2878 .selection(selection.clone())
2879 .row_height(20.0),
2880 );
2881 tree.layout(SizeProposal {
2882 width: Some(400.0),
2883 height: Some(200.0),
2884 });
2885 assert_eq!(selection.selected_indices().len(), 0);
2887 let header_h = cp::HEADER_HEIGHT;
2890 let click_y = header_h + 10.0;
2891 tree.dispatch_event(WidgetEvent::PointerDown {
2892 position: Point::new(40.0, click_y),
2893 button: PointerButton::Primary,
2894 modifiers: Modifiers::NONE,
2895 });
2896 tree.dispatch_event(WidgetEvent::PointerUp {
2897 position: Point::new(40.0, click_y),
2898 button: PointerButton::Primary,
2899 modifiers: Modifiers::NONE,
2900 });
2901 assert_eq!(selection.selected_indices(), vec![0]);
2903 tree.layout(SizeProposal {
2910 width: Some(400.0),
2911 height: Some(200.0),
2912 });
2913 assert_eq!(selection.selected_indices(), vec![0]);
2914 }
2915
2916 #[test]
2917 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
2918 use teksilo_core::event::{Key, Modifiers};
2925 use teksilo_data::{SelectionMode, SelectionModel};
2926
2927 for (key, want, what) in [
2928 (
2929 Key::ArrowDown,
2930 0usize,
2931 "first ArrowDown enters at the first visible row",
2932 ),
2933 (
2934 Key::ArrowUp,
2935 3usize,
2936 "first ArrowUp enters at the last visible row",
2937 ),
2938 ] {
2939 let t = TreeModel::new();
2940 t.insert_root(0, "a");
2941 t.insert_root(1, "b");
2942 t.insert_root(2, "c");
2943 t.insert_root(3, "d");
2944 let proxy = SortFilterTreeModel::new(t);
2945 let selection = SelectionModel::new(SelectionMode::Single);
2946 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2947 let id = tree.add(
2948 TreeTableView::from_projection(proxy.clone())
2949 .add_column(name_col())
2950 .selection_mode(TableSelectionMode::SingleRow)
2951 .selection(selection.clone())
2952 .row_height(20.0),
2953 );
2954 tree.layout(SizeProposal {
2955 width: Some(400.0),
2956 height: Some(200.0),
2957 });
2958 tree.focus(id);
2959 assert_eq!(proxy.visible_count(), 4, "four flat roots");
2960 assert!(
2961 selection.selected_indices().is_empty(),
2962 "precondition: no cursor, nothing selected"
2963 );
2964
2965 tree.press_key(key, Modifiers::NONE);
2966 assert_eq!(selection.selected_indices(), vec![want], "{what}");
2967 }
2968 }
2969
2970 #[test]
2971 fn expanded_children_are_reachable_by_the_first_arrow() {
2972 use teksilo_core::event::{Key, Modifiers};
2978 use teksilo_data::{SelectionMode, SelectionModel};
2979
2980 let proxy = SortFilterTreeModel::new(sample_tree()); let selection = SelectionModel::new(SelectionMode::Single);
2982 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2983 let id = tree.add(
2984 TreeTableView::from_projection(proxy.clone())
2985 .add_column(name_col())
2986 .selection_mode(TableSelectionMode::SingleRow)
2987 .selection(selection.clone())
2988 .row_height(20.0),
2989 );
2990 tree.layout(SizeProposal {
2991 width: Some(400.0),
2992 height: Some(200.0),
2993 });
2994 tree.focus(id);
2995
2996 let last = proxy.visible_count() - 1;
2997 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2998 assert_eq!(
2999 selection.selected_indices(),
3000 vec![last],
3001 "first ArrowUp enters at the last VISIBLE row, whatever the hierarchy shows"
3002 );
3003 }
3004
3005 #[test]
3006 fn row_click_moves_focus_so_arrow_nav_resumes_there() {
3007 use teksilo_canvas::Point;
3013 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
3014 use teksilo_data::{SelectionMode, SelectionModel};
3015 let t = TreeModel::new();
3016 t.insert_root(0, "a");
3017 t.insert_root(1, "b");
3018 t.insert_root(2, "c");
3019 t.insert_root(3, "d");
3020 let proxy = SortFilterTreeModel::new(t);
3021 let selection = SelectionModel::new(SelectionMode::Single);
3022 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3023 let id = tree.add(
3024 TreeTableView::from_projection(proxy.clone())
3025 .add_column(name_col())
3026 .selection_mode(TableSelectionMode::SingleRow)
3027 .selection(selection.clone())
3028 .row_height(20.0),
3029 );
3030 tree.layout(SizeProposal {
3031 width: Some(400.0),
3032 height: Some(200.0),
3033 });
3034 tree.focus(id);
3035 assert_eq!(proxy.visible_count(), 4, "four flat roots");
3036
3037 let click_y = cp::HEADER_HEIGHT + 1.0 * 20.0 + 10.0;
3039 tree.dispatch_event(WidgetEvent::PointerDown {
3040 position: Point::new(40.0, click_y),
3041 button: PointerButton::Primary,
3042 modifiers: Modifiers::NONE,
3043 });
3044 tree.dispatch_event(WidgetEvent::PointerUp {
3045 position: Point::new(40.0, click_y),
3046 button: PointerButton::Primary,
3047 modifiers: Modifiers::NONE,
3048 });
3049 assert_eq!(
3050 selection.selected_indices(),
3051 vec![1],
3052 "click selects flat row 1"
3053 );
3054
3055 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3057 assert_eq!(
3058 selection.selected_indices(),
3059 vec![2],
3060 "ArrowDown after a click resumes from the clicked row (1 → 2)"
3061 );
3062 }
3063
3064 #[test]
3065 fn role_is_treegrid() {
3066 let proxy = SortFilterTreeModel::new(sample_tree());
3067 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3068 let id = tree.add(
3069 TreeTableView::from_projection(proxy)
3070 .add_column(name_col())
3071 .add_column(size_col())
3072 .row_height(20.0),
3073 );
3074 tree.layout(SizeProposal {
3075 width: Some(400.0),
3076 height: Some(200.0),
3077 });
3078 let info = tree.accessibility_node(id);
3079 assert_eq!(info.role(), Role::TreeGrid);
3080 }
3081
3082 #[test]
3083 fn initial_state_shows_only_roots() {
3084 let proxy = SortFilterTreeModel::new(sample_tree());
3085 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3086 let _id = tree.add(
3087 TreeTableView::from_projection(proxy.clone())
3088 .add_column(name_col())
3089 .row_height(20.0),
3090 );
3091 tree.layout(SizeProposal {
3092 width: Some(400.0),
3093 height: Some(200.0),
3094 });
3095 assert_eq!(proxy.visible_count(), 2); }
3097
3098 #[test]
3099 fn expand_via_widget_reveals_children() {
3100 let proxy = SortFilterTreeModel::new(sample_tree());
3101 let docs = proxy.tree().root(0);
3102 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3103 let id = tree.add(
3104 TreeTableView::from_projection(proxy.clone())
3105 .add_column(name_col())
3106 .row_height(20.0),
3107 );
3108 tree.layout(SizeProposal {
3109 width: Some(400.0),
3110 height: Some(200.0),
3111 });
3112 {
3113 let any = tree.widget_as_any(id).unwrap();
3114 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3115 tt.expand(docs);
3116 }
3117 assert_eq!(proxy.visible_count(), 4); }
3119
3120 #[test]
3121 fn arrow_right_expands_and_left_collapses_on_tree_column() {
3122 let proxy = SortFilterTreeModel::new(sample_tree());
3123 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3124 let id = tree.add(
3125 TreeTableView::from_projection(proxy.clone())
3126 .add_column(name_col())
3127 .row_height(20.0),
3128 );
3129 tree.layout(SizeProposal {
3130 width: Some(400.0),
3131 height: Some(200.0),
3132 });
3133 tree.focus(id);
3134 {
3135 let any = tree.widget_as_any(id).unwrap();
3136 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3137 tt.set_focused_cell(0, 0);
3138 }
3139 tree.press_key(
3142 teksilo_core::event::Key::ArrowRight,
3143 teksilo_core::event::Modifiers::NONE,
3144 );
3145 assert_eq!(proxy.visible_count(), 4);
3146 tree.press_key(
3148 teksilo_core::event::Key::ArrowLeft,
3149 teksilo_core::event::Modifiers::NONE,
3150 );
3151 assert_eq!(proxy.visible_count(), 2);
3152 }
3153
3154 fn slice_rows() -> Vec<teksilo_data::TreeRow<u64, &'static str>> {
3157 use teksilo_data::TreeRow;
3158 vec![
3159 TreeRow::new(1, "docs", 0),
3160 TreeRow::new(2, "readme", 1),
3161 TreeRow::new(3, "guide", 1),
3162 TreeRow::new(4, "src", 0),
3163 ]
3164 }
3165
3166 fn external_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
3167 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3168 slice.set_source(slice_rows);
3169 slice.reload();
3170 slice
3171 }
3172
3173 #[test]
3174 fn from_source_renders_an_external_tree_without_a_tree_model() {
3175 let slice = external_slice();
3179 slice.expand(&1);
3180 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3181 let id = tree.add(
3182 TreeTableView::from_source(slice.clone())
3183 .add_column(name_col())
3184 .row_height(20.0),
3185 );
3186 tree.layout(SizeProposal {
3187 width: Some(400.0),
3188 height: Some(200.0),
3189 });
3190 assert_eq!(slice.visible_count(), 4, "docs + 2 children + src");
3191
3192 let any = tree.widget_as_any(id).unwrap();
3193 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3194 assert!(
3195 tt.projection().is_none(),
3196 "a source-backed view has no TreeModel projection to expose"
3197 );
3198 assert!(tt.body_pane_id.is_some(), "rows rendered from the source");
3199 }
3200
3201 #[test]
3202 fn from_source_keyed_selection_survives_a_full_resource() {
3203 let slice = external_slice();
3207 slice.expand(&1);
3208 let keyed = KeyedSelectionModel::<u64>::new(teksilo_data::SelectionMode::Multi);
3209 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3210 let _id = tree.add(
3211 TreeTableView::from_source_keyed(slice.clone(), keyed.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
3220 keyed.select(3); assert!(keyed.is_selected(&3));
3222
3223 slice.reload();
3225 assert!(
3226 keyed.is_selected(&3),
3227 "a domain-keyed selection must survive a re-source"
3228 );
3229 }
3230
3231 #[test]
3232 fn from_source_supports_drag_reorder_like_the_tree_view() {
3233 use std::cell::RefCell;
3237 use std::rc::Rc;
3238 use teksilo_canvas::Point;
3239
3240 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3242 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3243 {
3244 let order = order.clone();
3245 slice.set_source(move || {
3246 let names: std::collections::HashMap<u64, &'static str> =
3247 [(1, "docs"), (4, "src")].into_iter().collect();
3248 order
3249 .borrow()
3250 .iter()
3251 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3252 .collect()
3253 });
3254 }
3255 {
3256 let order = order.clone();
3257 slice.set_reorder(move |dragged, target, _pos| {
3259 let mut o = order.borrow_mut();
3260 let Some(from) = o.iter().position(|k| *k == dragged) else {
3261 return false;
3262 };
3263 let item = o.remove(from);
3264 let to = o
3265 .iter()
3266 .position(|k| *k == target)
3267 .map_or(o.len(), |i| i + 1);
3268 o.insert(to, item);
3269 true
3270 });
3271 }
3272 slice.set_drag_policy(|_| teksilo_data::DragEligibility::CanDrag);
3275 slice.reload();
3276 assert_eq!(*order.borrow(), vec![1, 4], "docs, src");
3277
3278 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3279 tree.add(
3280 TreeTableView::from_source(slice.clone())
3281 .add_column(name_col())
3282 .reorderable(true)
3283 .row_height(20.0),
3284 );
3285 tree.layout(SizeProposal {
3286 width: Some(400.0),
3287 height: Some(300.0),
3288 });
3289
3290 let h = cp::HEADER_HEIGHT;
3292 drag(
3293 &mut tree,
3294 Point::new(40.0, h + 10.0),
3295 Point::new(40.0, h + 38.0),
3296 );
3297 assert_eq!(
3298 *order.borrow(),
3299 vec![4, 1],
3300 "the source applied the reorder: src now precedes docs"
3301 );
3302 }
3303
3304 #[test]
3305 fn a_source_that_forbids_dragging_a_row_is_honored() {
3306 use std::cell::RefCell;
3309 use std::rc::Rc;
3310 use teksilo_canvas::Point;
3311
3312 let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3313 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3314 {
3315 let order = order.clone();
3316 slice.set_source(move || {
3317 let names: std::collections::HashMap<u64, &'static str> =
3318 [(1, "docs"), (4, "src")].into_iter().collect();
3319 order
3320 .borrow()
3321 .iter()
3322 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3323 .collect()
3324 });
3325 }
3326 {
3327 let order = order.clone();
3328 slice.set_reorder(move |dragged, target, _pos| {
3329 let mut o = order.borrow_mut();
3330 let Some(from) = o.iter().position(|k| *k == dragged) else {
3331 return false;
3332 };
3333 let item = o.remove(from);
3334 let to = o
3335 .iter()
3336 .position(|k| *k == target)
3337 .map_or(o.len(), |i| i + 1);
3338 o.insert(to, item);
3339 true
3340 });
3341 }
3342 slice.set_drag_policy(|k| {
3344 if *k == 1 {
3345 teksilo_data::DragEligibility::NoDrag
3346 } else {
3347 teksilo_data::DragEligibility::CanDrag
3348 }
3349 });
3350 slice.reload();
3351
3352 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3353 tree.add(
3354 TreeTableView::from_source(slice.clone())
3355 .add_column(name_col())
3356 .reorderable(true)
3357 .row_height(20.0),
3358 );
3359 tree.layout(SizeProposal {
3360 width: Some(400.0),
3361 height: Some(300.0),
3362 });
3363
3364 let h = cp::HEADER_HEIGHT;
3365 drag(
3366 &mut tree,
3367 Point::new(40.0, h + 10.0),
3368 Point::new(40.0, h + 38.0),
3369 );
3370 assert_eq!(
3371 *order.borrow(),
3372 vec![1, 4],
3373 "a NoDrag row must not move, even onto a valid target"
3374 );
3375 }
3376
3377 #[test]
3378 fn drop_on_the_middle_third_reparents_into_the_target() {
3379 use teksilo_canvas::Point;
3382 let proxy = SortFilterTreeModel::new(sample_tree());
3383 proxy.collapse_all(); let docs = proxy.tree().root(0);
3385 let src = proxy.tree().root(1);
3386 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3387 tree.add(
3388 TreeTableView::from_projection(proxy.clone())
3389 .add_column(name_col())
3390 .reorderable(true)
3391 .row_height(20.0),
3392 );
3393 tree.layout(SizeProposal {
3394 width: Some(400.0),
3395 height: Some(300.0),
3396 });
3397 let h = cp::HEADER_HEIGHT;
3398 drag(
3401 &mut tree,
3402 Point::new(40.0, h + 10.0),
3403 Point::new(40.0, h + 30.0),
3404 );
3405 assert_eq!(proxy.tree().root_count(), 1, "docs is no longer a root");
3406 assert_eq!(
3407 proxy.tree().parent(docs),
3408 Some(src),
3409 "docs became a child of src"
3410 );
3411 }
3412
3413 #[test]
3414 fn the_into_box_is_inset_and_the_insertion_line_is_indented() {
3415 use teksilo_canvas::{DrawCommand, Point, ShapeKind};
3420 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3421
3422 let proxy = SortFilterTreeModel::new(sample_tree());
3423 proxy.expand_all(); let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3425 tree.add(
3426 TreeTableView::from_projection(proxy.clone())
3427 .add_column(name_col())
3428 .reorderable(true)
3429 .row_height(20.0),
3430 );
3431 tree.layout(SizeProposal {
3432 width: Some(400.0),
3433 height: Some(300.0),
3434 });
3435 let h = cp::HEADER_HEIGHT;
3436
3437 let start = Point::new(40.0, h + 90.0);
3440 tree.dispatch_event(WidgetEvent::PointerDown {
3441 position: start,
3442 button: PointerButton::Primary,
3443 modifiers: Modifiers::NONE,
3444 });
3445 tree.dispatch_event(WidgetEvent::PointerMove {
3446 position: Point::new(52.0, start.y),
3447 });
3448
3449 tree.dispatch_event(WidgetEvent::PointerMove {
3451 position: Point::new(52.0, h + 38.0),
3452 });
3453 let frame = tree.render();
3454 let line_recipe = teksilo_core::styles::ListInsertionRecipe::default();
3455 let lines: Vec<_> = frame
3459 .decorations
3460 .iter()
3461 .filter(|d| (d.rect[3] - line_recipe.thickness).abs() < 0.01 && d.rect[2] > 100.0)
3462 .collect();
3463 assert_eq!(lines.len(), 1, "exactly one insertion line, got {lines:?}");
3464 assert!(
3465 lines[0].rect[0] >= line_recipe.indent_step,
3466 "the After line must start one indent step in for a depth-1 target, \
3467 got x = {} (step {})",
3468 lines[0].rect[0],
3469 line_recipe.indent_step
3470 );
3471
3472 tree.dispatch_event(WidgetEvent::PointerMove {
3474 position: Point::new(52.0, h + 10.0),
3475 });
3476 let frame = tree.render();
3477 let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
3478 let boxes: Vec<_> = frame
3479 .draw_order
3480 .iter()
3481 .filter_map(|c| match c {
3482 DrawCommand::Shape(i) => frame.shapes.get(*i),
3483 _ => None,
3484 })
3485 .filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
3486 .filter(|s| (s.screen[3] - (20.0 - recipe.inset * 2.0)).abs() < 0.01)
3487 .collect();
3488 assert!(
3489 !boxes.is_empty(),
3490 "no inset rounded box for the Into hover; shapes = {:?}",
3491 frame.shapes.iter().map(|s| s.screen).collect::<Vec<_>>()
3492 );
3493 assert!(
3496 boxes
3497 .iter()
3498 .all(|s| (s.screen[1] - (h + recipe.inset)).abs() < 0.01),
3499 "the Into box must be inset from the row's top edge ({}), got {:?}",
3500 h,
3501 boxes.iter().map(|s| s.screen).collect::<Vec<_>>()
3502 );
3503 assert!(
3504 boxes.iter().any(|s| s.stroke_width > 0.0)
3505 && boxes.iter().any(|s| s.stroke_width == 0.0),
3506 "the Into box needs both a wash and an outline"
3507 );
3508 }
3509
3510 #[test]
3511 fn an_active_sort_suppresses_drag_reorder() {
3512 use teksilo_canvas::Point;
3516 let proxy = SortFilterTreeModel::new(sample_tree())
3517 .with_comparator("name", |a: &&'static str, b: &&'static str| a.cmp(b));
3518 proxy.collapse_all();
3519 let docs = proxy.tree().root(0);
3520 let src = proxy.tree().root(1);
3521 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3522 let id = tree.add(
3523 TreeTableView::from_projection(proxy.clone())
3524 .add_column(name_col())
3525 .reorderable(true)
3526 .row_height(20.0),
3527 );
3528 tree.layout(SizeProposal {
3529 width: Some(400.0),
3530 height: Some(300.0),
3531 });
3532 {
3533 let any = tree.widget_as_any(id).unwrap();
3534 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3535 tt.set_sort(Some("name"), SortDirection::Ascending);
3536 }
3537 tree.layout(SizeProposal {
3538 width: Some(400.0),
3539 height: Some(300.0),
3540 });
3541
3542 let h = cp::HEADER_HEIGHT;
3543 drag(
3544 &mut tree,
3545 Point::new(40.0, h + 10.0),
3546 Point::new(40.0, h + 38.0),
3547 );
3548 assert_eq!(
3549 proxy.tree().root(0),
3550 docs,
3551 "structure unchanged while sorted"
3552 );
3553 assert_eq!(
3554 proxy.tree().root(1),
3555 src,
3556 "structure unchanged while sorted"
3557 );
3558 }
3559
3560 #[test]
3561 fn an_open_cell_editor_follows_its_row_and_closes_if_the_row_vanishes() {
3562 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3566 let all: Vec<u64> = vec![1, 2, 3];
3567 slice.set_source(move || {
3568 let names: std::collections::HashMap<u64, &'static str> =
3569 [(1, "one"), (2, "two"), (3, "three")].into_iter().collect();
3570 all.iter()
3571 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3572 .collect()
3573 });
3574 slice.reload();
3575
3576 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3577 let id = tree.add(
3578 TreeTableView::from_source(slice.clone())
3579 .add_column(name_col())
3580 .row_height(20.0),
3581 );
3582 let proposal = SizeProposal {
3583 width: Some(400.0),
3584 height: Some(200.0),
3585 };
3586 tree.layout(proposal);
3587
3588 {
3590 let any = tree.widget_as_any(id).unwrap();
3591 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3592 tt.begin_edit(2, "name");
3593 assert_eq!(tt.editing_cell_signal().get(), Some((2, 0)));
3594 }
3595 tree.layout(proposal); let fewer: Vec<u64> = vec![2, 3];
3599 slice.set_source(move || {
3600 let names: std::collections::HashMap<u64, &'static str> =
3601 [(2, "two"), (3, "three")].into_iter().collect();
3602 fewer
3603 .iter()
3604 .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3605 .collect()
3606 });
3607 slice.reload();
3608 tree.layout(proposal);
3609 {
3610 let any = tree.widget_as_any(id).unwrap();
3611 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3612 assert_eq!(
3613 tt.editing_cell_signal().get(),
3614 Some((1, 0)),
3615 "the editor must follow its row to index 1, not stay on index 2"
3616 );
3617 }
3618
3619 let last: Vec<u64> = vec![2];
3621 slice.set_source(move || {
3622 last.iter()
3623 .map(|k| teksilo_data::TreeRow::new(*k, "two", 0))
3624 .collect()
3625 });
3626 slice.reload();
3627 tree.layout(proposal);
3628 let any = tree.widget_as_any(id).unwrap();
3629 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3630 assert_eq!(
3631 tt.editing_cell_signal().get(),
3632 None,
3633 "the editor must close when its row is gone"
3634 );
3635 }
3636
3637 #[test]
3638 fn default_selection_mode_is_multi_row() {
3639 use teksilo_core::event::{Key, Modifiers};
3643 let proxy = SortFilterTreeModel::new(wide_tree(10));
3644 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3645 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3646 let id = tree.add(
3647 TreeTableView::from_projection(proxy)
3648 .add_column(name_col())
3649 .selection(selection.clone())
3650 .row_height(20.0),
3651 );
3652 tree.layout(SizeProposal {
3653 width: Some(400.0),
3654 height: Some(200.0),
3655 });
3656 tree.focus(id);
3657 {
3658 let any = tree.widget_as_any(id).unwrap();
3659 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3660 tt.set_focused_cell(0, 0);
3661 }
3662 selection.select(0);
3663 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3664 tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3665 assert_eq!(
3666 selection.selection_signal().get().len(),
3667 3,
3668 "default mode must extend a multi-row selection"
3669 );
3670 }
3671
3672 #[test]
3673 fn ctrl_arrow_moves_cursor_without_touching_selection() {
3674 use teksilo_core::event::{Key, Modifiers};
3679 let proxy = SortFilterTreeModel::new(wide_tree(5));
3680 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3681 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3682 let id = tree.add(
3683 TreeTableView::from_projection(proxy)
3684 .add_column(name_col())
3685 .selection(selection.clone())
3686 .row_height(20.0),
3687 );
3688 tree.layout(SizeProposal {
3689 width: Some(400.0),
3690 height: Some(200.0),
3691 });
3692 tree.focus(id);
3693 {
3694 let any = tree.widget_as_any(id).unwrap();
3695 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3696 tt.set_focused_cell(0, 0);
3697 }
3698 selection.select(0);
3699
3700 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3701 {
3702 let any = tree.widget_as_any(id).unwrap();
3703 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3704 assert_eq!(
3705 tt.focused_cell_signal().get(),
3706 Some((1, 0)),
3707 "cursor advances"
3708 );
3709 }
3710 assert_eq!(
3711 selection.selected_indices(),
3712 vec![0],
3713 "Ctrl+Arrow must not touch selection"
3714 );
3715
3716 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3718 {
3719 let any = tree.widget_as_any(id).unwrap();
3720 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3721 assert_eq!(tt.focused_cell_signal().get(), Some((2, 0)));
3722 }
3723 assert_eq!(
3724 selection.selected_indices(),
3725 vec![2],
3726 "plain Arrow selects the row it lands on"
3727 );
3728 }
3729
3730 #[test]
3731 fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
3732 use teksilo_core::event::{Key, Modifiers};
3733 let proxy = SortFilterTreeModel::new(wide_tree(5));
3734 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3735 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3736 let id = tree.add(
3737 TreeTableView::from_projection(proxy)
3738 .add_column(name_col())
3739 .selection(selection.clone())
3740 .row_height(20.0),
3741 );
3742 tree.layout(SizeProposal {
3743 width: Some(400.0),
3744 height: Some(200.0),
3745 });
3746 tree.focus(id);
3747 {
3748 let any = tree.widget_as_any(id).unwrap();
3749 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3750 tt.set_focused_cell(0, 0);
3751 }
3752 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3753 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3754 assert!(selection.selected_indices().is_empty());
3755
3756 tree.press_key(Key::Space, Modifiers::CTRL);
3757 assert_eq!(
3758 selection.selected_indices(),
3759 vec![2],
3760 "Ctrl+Space toggles the focused row on"
3761 );
3762
3763 tree.press_key(Key::Space, Modifiers::CTRL);
3764 assert!(
3765 selection.selected_indices().is_empty(),
3766 "Ctrl+Space toggles it back off"
3767 );
3768 }
3769
3770 #[test]
3771 fn empty_view_renders_when_the_tree_has_no_rows() {
3772 let proxy = SortFilterTreeModel::new(TreeModel::<&'static str>::new());
3773 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3774 let id = tree.add(
3775 TreeTableView::from_projection(proxy)
3776 .add_column(name_col())
3777 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("Nothing here"))))
3778 .row_height(20.0),
3779 );
3780 tree.layout(SizeProposal {
3781 width: Some(400.0),
3782 height: Some(200.0),
3783 });
3784 let any = tree.widget_as_any(id).unwrap();
3785 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3786 assert!(tt.empty_id.is_some(), "placeholder should be built");
3787 assert!(tt.body_pane_id.is_none(), "no body pane for zero rows");
3788 }
3789
3790 #[test]
3791 fn empty_view_appears_when_live_rows_drop_to_zero() {
3792 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3797 let needle = t.to_string();
3798 Box::new(move |r: &&'static str| r.contains(&needle))
3799 });
3800 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3801 let id = tree.add(
3802 TreeTableView::from_projection(proxy.clone())
3803 .add_column(name_col())
3804 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3805 .row_height(20.0),
3806 );
3807 let proposal = SizeProposal {
3808 width: Some(400.0),
3809 height: Some(200.0),
3810 };
3811 tree.layout(proposal);
3812 {
3813 let any = tree.widget_as_any(id).unwrap();
3814 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3815 assert!(tt.body_pane_id.is_some(), "starts with a body pane");
3816 assert!(tt.empty_id.is_none(), "no placeholder while rows exist");
3817 }
3818
3819 proxy.set_filter("name", "zzz-no-such-row");
3820 tree.layout(proposal);
3821 assert_eq!(proxy.visible_count(), 0);
3822 let any = tree.widget_as_any(id).unwrap();
3823 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3824 assert!(
3825 tt.empty_id.is_some(),
3826 "placeholder must appear once rows drop to zero"
3827 );
3828 assert!(tt.body_pane_id.is_none(), "stale body pane must be gone");
3829 }
3830
3831 #[test]
3832 fn empty_view_renders_when_a_filter_matches_nothing() {
3833 let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3836 let needle = t.to_string();
3837 Box::new(move |r: &&'static str| r.contains(&needle))
3838 });
3839 proxy.set_filter("name", "zzz-no-such-row");
3840 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3841 let id = tree.add(
3842 TreeTableView::from_projection(proxy.clone())
3843 .add_column(name_col())
3844 .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3845 .row_height(20.0),
3846 );
3847 tree.layout(SizeProposal {
3848 width: Some(400.0),
3849 height: Some(200.0),
3850 });
3851 assert_eq!(proxy.visible_count(), 0);
3852 let any = tree.widget_as_any(id).unwrap();
3853 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3854 assert!(tt.empty_id.is_some());
3855 }
3856
3857 #[test]
3858 fn scroll_to_row_and_ensure_row_visible_move_the_offset() {
3859 let proxy = SortFilterTreeModel::new(wide_tree(100));
3860 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3861 let id = tree.add(
3862 TreeTableView::from_projection(proxy)
3863 .add_column(name_col())
3864 .row_height(20.0),
3865 );
3866 tree.layout(SizeProposal {
3867 width: Some(400.0),
3868 height: Some(200.0),
3869 });
3870 let any = tree.widget_as_any(id).unwrap();
3871 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3872
3873 tt.scroll_to_row(50);
3875 assert!((tt.scroll_y_signal().get() - 1000.0).abs() < 1.0);
3876
3877 let before = tt.scroll_y_signal().get();
3879 tt.ensure_row_visible(51);
3880 assert!((tt.scroll_y_signal().get() - before).abs() < f32::EPSILON);
3881
3882 tt.ensure_row_visible(10);
3884 assert!((tt.scroll_y_signal().get() - 200.0).abs() < 1.0);
3885 }
3886
3887 #[test]
3888 fn begin_edit_resolves_a_column_id_and_end_edit_clears() {
3889 let proxy = SortFilterTreeModel::new(sample_tree());
3890 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3891 let id = tree.add(
3892 TreeTableView::from_projection(proxy)
3893 .add_column(name_col())
3894 .add_column(size_col())
3895 .row_height(20.0),
3896 );
3897 tree.layout(SizeProposal {
3898 width: Some(400.0),
3899 height: Some(200.0),
3900 });
3901 let any = tree.widget_as_any(id).unwrap();
3902 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3903
3904 tt.begin_edit(1, "size");
3905 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
3906 tt.end_edit();
3907 assert_eq!(tt.editing_cell_signal().get(), None);
3908
3909 tt.begin_edit(0, "no-such-column");
3911 assert_eq!(tt.editing_cell_signal().get(), None);
3912
3913 tt.begin_edit(9999, "name");
3917 assert_eq!(tt.editing_cell_signal().get(), None);
3918
3919 tt.begin_edit(1, "size");
3921 tt.begin_edit(9999, "size");
3922 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
3923 }
3924
3925 #[test]
3926 fn begin_edit_resolves_before_the_view_is_mounted() {
3927 let proxy = SortFilterTreeModel::new(sample_tree());
3939 let view = TreeTableView::from_projection(proxy)
3940 .add_column(name_col())
3941 .add_column(size_col().pinned(PinnedSide::Leading))
3942 .row_height(20.0);
3943
3944 view.begin_edit(1, "name");
3945 assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
3946
3947 view.end_edit();
3949 view.begin_edit(0, "no-such-column");
3950 assert_eq!(view.editing_cell_signal().get(), None);
3951 view.begin_edit(9999, "name");
3952 assert_eq!(view.editing_cell_signal().get(), None);
3953
3954 view.begin_edit(1, "name");
3957 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3958 let id = tree.add(view);
3959 tree.layout(SizeProposal {
3960 width: Some(400.0),
3961 height: Some(200.0),
3962 });
3963 let tt = tree
3964 .widget_as_any(id)
3965 .unwrap()
3966 .downcast_ref::<TreeTableView<&'static str>>()
3967 .unwrap();
3968 assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
3969 }
3970
3971 #[test]
3972 fn column_imperatives_write_their_signals() {
3973 let proxy = SortFilterTreeModel::new(sample_tree());
3974 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3975 let id = tree.add(
3976 TreeTableView::from_projection(proxy)
3977 .add_column(name_col())
3978 .add_column(size_col())
3979 .row_height(20.0),
3980 );
3981 tree.layout(SizeProposal {
3982 width: Some(400.0),
3983 height: Some(200.0),
3984 });
3985 let any = tree.widget_as_any(id).unwrap();
3986 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3987
3988 tt.set_column_width("name", 123.0);
3989 assert_eq!(tt.column_widths_signal().get().get("name"), Some(&123.0));
3990 tt.set_column_width("name", 0.0);
3992 assert!(!tt.column_widths_signal().get().contains_key("name"));
3993
3994 assert_eq!(
3997 tt.display_order(),
3998 vec![0, 1],
3999 "declaration order initially"
4000 );
4001
4002 tt.set_column_order(vec!["size".into(), "name".into()]);
4003 assert_eq!(tt.column_order_signal().get(), vec!["size", "name"]);
4004 assert_eq!(
4005 tt.display_order(),
4006 vec![1, 0],
4007 "set_column_order must reorder the display, not only the signal"
4008 );
4009
4010 tt.set_column_pinning("name", PinnedSide::Leading);
4013 assert_eq!(
4014 tt.column_pinning_signal().get().get("name"),
4015 Some(&PinnedSide::Leading)
4016 );
4017 assert_eq!(
4018 tt.display_order(),
4019 vec![0, 1],
4020 "set_column_pinning must pull the pinned column back to the front"
4021 );
4022 tt.set_column_pinning("name", PinnedSide::None);
4023 assert!(!tt.column_pinning_signal().get().contains_key("name"));
4024 assert_eq!(
4025 tt.display_order(),
4026 vec![1, 0],
4027 "clearing the pin restores the order list's arrangement"
4028 );
4029
4030 tt.set_sort(Some("name"), SortDirection::Ascending);
4031 assert!(tt.sort_signal().get().is_some());
4032 tt.clear_sort();
4033 assert_eq!(tt.sort_signal().get(), None);
4034 }
4035
4036 #[test]
4047 fn column_pinning_remaps_focused_cell_to_follow_its_column() {
4048 let proxy = SortFilterTreeModel::new(sample_tree());
4049 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4050 let id = tree.add(
4051 TreeTableView::from_projection(proxy)
4052 .add_column(name_col())
4053 .add_column(size_col())
4054 .row_height(20.0),
4055 );
4056 tree.layout(SizeProposal {
4057 width: Some(400.0),
4058 height: Some(200.0),
4059 });
4060 tree.focus(id);
4061 {
4062 let any = tree.widget_as_any(id).unwrap();
4063 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4064 tt.set_focused_cell(0, 1); tt.set_column_pinning("size", PinnedSide::Leading);
4069 }
4070 tree.layout(SizeProposal {
4071 width: Some(400.0),
4072 height: Some(200.0),
4073 });
4074 let any = tree.widget_as_any(id).unwrap();
4075 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4076 assert_eq!(
4077 tt.focused_cell_signal().get(),
4078 Some((0, 0)),
4079 "focus must follow `size` to its new display position"
4080 );
4081 }
4082
4083 #[test]
4084 fn column_pinning_remaps_editing_cell_to_follow_its_column() {
4085 let proxy = SortFilterTreeModel::new(sample_tree());
4086 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4087 let id = tree.add(
4088 TreeTableView::from_projection(proxy)
4089 .add_column(name_col())
4090 .add_column(size_col())
4091 .row_height(20.0),
4092 );
4093 tree.layout(SizeProposal {
4094 width: Some(400.0),
4095 height: Some(200.0),
4096 });
4097 {
4098 let any = tree.widget_as_any(id).unwrap();
4099 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4100 tt.begin_edit(0, "size"); assert_eq!(tt.editing_cell_signal().get(), Some((0, 1)));
4102 tt.set_column_pinning("size", PinnedSide::Leading);
4103 }
4104 tree.layout(SizeProposal {
4105 width: Some(400.0),
4106 height: Some(200.0),
4107 });
4108 let any = tree.widget_as_any(id).unwrap();
4109 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4110 assert_eq!(
4111 tt.editing_cell_signal().get(),
4112 Some((0, 0)),
4113 "the open editor must follow `size` to its new display \
4114 position, not relabel onto whatever column now sits at \
4115 position 1"
4116 );
4117 }
4118
4119 #[test]
4120 fn column_pinning_remaps_cell_selection_to_follow_its_column() {
4121 let proxy = SortFilterTreeModel::new(sample_tree());
4122 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4123 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4124 let id = tree.add(
4125 TreeTableView::from_projection(proxy)
4126 .add_column(name_col())
4127 .add_column(size_col())
4128 .row_height(20.0)
4129 .selection_mode(TableSelectionMode::MultiCell)
4130 .cell_selection(cs.clone()),
4131 );
4132 tree.layout(SizeProposal {
4133 width: Some(400.0),
4134 height: Some(200.0),
4135 });
4136 cs.select(0, 1); {
4138 let any = tree.widget_as_any(id).unwrap();
4139 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4140 tt.set_column_pinning("size", PinnedSide::Leading);
4141 }
4142 tree.layout(SizeProposal {
4143 width: Some(400.0),
4144 height: Some(200.0),
4145 });
4146 assert!(
4147 cs.is_selected(0, 0),
4148 "selection must follow `size` to its new display position"
4149 );
4150 assert!(!cs.is_selected(0, 1));
4151 }
4152
4153 #[test]
4154 fn collapsing_a_node_above_a_selected_cell_clears_stale_cell_selection() {
4155 let proxy = SortFilterTreeModel::new(sample_tree());
4161 let docs = proxy.tree().root(0);
4162 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4163 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4164 let id = tree.add(
4165 TreeTableView::from_projection(proxy.clone())
4166 .add_column(name_col())
4167 .row_height(20.0)
4168 .selection_mode(TableSelectionMode::MultiCell)
4169 .cell_selection(cs.clone()),
4170 );
4171 tree.layout(SizeProposal {
4172 width: Some(400.0),
4173 height: Some(200.0),
4174 });
4175 {
4176 let any = tree.widget_as_any(id).unwrap();
4177 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4178 tt.expand(docs);
4179 }
4180 tree.layout(SizeProposal {
4181 width: Some(400.0),
4182 height: Some(200.0),
4183 });
4184 assert_eq!(proxy.visible_count(), 4); cs.select(3, 0); {
4187 let any = tree.widget_as_any(id).unwrap();
4188 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4189 tt.collapse(docs);
4190 }
4191 tree.layout(SizeProposal {
4192 width: Some(400.0),
4193 height: Some(200.0),
4194 });
4195 assert_eq!(proxy.visible_count(), 2); assert_eq!(
4197 cs.count(),
4198 0,
4199 "a stale (row, col) surviving the collapse must be dropped, not \
4200 silently point at whatever node now sits at flat row 3"
4201 );
4202 }
4203
4204 #[test]
4205 fn content_only_update_leaves_cell_selection_untouched() {
4206 let model = sample_tree();
4210 let proxy = SortFilterTreeModel::new(model);
4211 let docs = proxy.tree().root(0);
4212 let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4213 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4214 tree.add(
4215 TreeTableView::from_projection(proxy.clone())
4216 .add_column(name_col())
4217 .row_height(20.0)
4218 .selection_mode(TableSelectionMode::MultiCell)
4219 .cell_selection(cs.clone()),
4220 );
4221 tree.layout(SizeProposal {
4222 width: Some(400.0),
4223 height: Some(200.0),
4224 });
4225 cs.select(0, 0); proxy.tree().update(docs, "docs-renamed");
4228 tree.layout(SizeProposal {
4229 width: Some(400.0),
4230 height: Some(200.0),
4231 });
4232 assert!(
4233 cs.is_selected(0, 0),
4234 "a content-only update must leave an unrelated selection alone"
4235 );
4236 }
4237
4238 #[test]
4241 fn focused_cell_sets_active_descendant_to_the_cell_node() {
4242 let proxy = SortFilterTreeModel::new(sample_tree());
4243 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4244 let id = tree.add(
4245 TreeTableView::from_projection(proxy)
4246 .add_column(name_col())
4247 .add_column(size_col())
4248 .row_height(20.0),
4249 );
4250 tree.layout(SizeProposal {
4251 width: Some(400.0),
4252 height: Some(200.0),
4253 });
4254 tree.focus(id);
4255 {
4256 let any = tree.widget_as_any(id).unwrap();
4257 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4258 tt.set_focused_cell(0, 1);
4259 }
4260 let update = tree.sync_accessibility();
4261 let root_node_id = widget_id_to_node_id(id);
4262 let root_node = update
4263 .nodes
4264 .iter()
4265 .find(|(nid, _)| *nid == root_node_id)
4266 .map(|(_, n)| n)
4267 .expect("root node present in the AT tree");
4268 let active = root_node
4269 .active_descendant()
4270 .expect("a focused cell must set active_descendant");
4271 let cell_node = update
4272 .nodes
4273 .iter()
4274 .find(|(nid, _)| *nid == active)
4275 .map(|(_, n)| n)
4276 .expect("active_descendant must reference a node present in the TreeUpdate");
4277 assert_eq!(cell_node.role(), Role::Cell);
4278 }
4279
4280 #[test]
4281 fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
4282 let proxy = SortFilterTreeModel::new(wide_tree(1000));
4283 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4284 let id = tree.add(
4285 TreeTableView::from_projection(proxy)
4286 .add_column(name_col())
4287 .row_height(20.0),
4288 );
4289 tree.layout(SizeProposal {
4290 width: Some(400.0),
4291 height: Some(200.0),
4292 });
4293 tree.focus(id);
4294 {
4295 let any = tree.widget_as_any(id).unwrap();
4296 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4297 tt.set_focused_cell(1, 0);
4298 }
4299 let root_node_id = widget_id_to_node_id(id);
4300 let update = tree.sync_accessibility();
4301 let active_before = update
4302 .nodes
4303 .iter()
4304 .find(|(nid, _)| *nid == root_node_id)
4305 .and_then(|(_, n)| n.active_descendant());
4306 assert!(active_before.is_some(), "row 1 is realized initially");
4307
4308 let signal = {
4313 let any = tree.widget_as_any(id).unwrap();
4314 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4315 tt.scroll_y_signal().clone()
4316 };
4317 signal.set(2000.0);
4318 tree.request_frame();
4319 tree.layout(SizeProposal {
4320 width: Some(400.0),
4321 height: Some(200.0),
4322 });
4323
4324 let update = tree.sync_accessibility();
4325 let active_after = update
4326 .nodes
4327 .iter()
4328 .find(|(nid, _)| *nid == root_node_id)
4329 .and_then(|(_, n)| n.active_descendant());
4330 assert_eq!(
4331 active_after, None,
4332 "a focused cell that scrolled out of realization must not leave \
4333 a stale active_descendant pointing at a destroyed node"
4334 );
4335 }
4336
4337 #[test]
4338 fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
4339 use std::cell::RefCell;
4346 use std::ops::Range;
4347 use teksilo_data::{FlatEntry, RowState};
4348
4349 struct Windowed {
4350 total: usize,
4351 requested: Rc<RefCell<Vec<Range<usize>>>>,
4352 version: Signal<u64>,
4353 }
4354 impl TreeDataSource for Windowed {
4355 type Item = &'static str;
4356 type Key = usize;
4357 fn visible_count(&self) -> usize {
4358 self.total
4359 }
4360 fn with_entry<R>(
4361 &self,
4362 _i: usize,
4363 _f: impl FnOnce(&&'static str, &FlatEntry<usize>) -> R,
4364 ) -> Option<R> {
4365 None }
4367 fn key_at(&self, i: usize) -> Option<usize> {
4368 (i < self.total).then_some(i)
4369 }
4370 fn flat_index_of(&self, key: &usize) -> Option<usize> {
4371 (*key < self.total).then_some(*key)
4372 }
4373 fn parent(&self, _key: &usize) -> Option<usize> {
4374 None
4375 }
4376 fn child_keys(&self, _key: &usize) -> Vec<usize> {
4377 vec![]
4378 }
4379 fn version_signal(&self) -> Signal<u64> {
4380 self.version.clone()
4381 }
4382 fn is_expanded(&self, _key: &usize) -> bool {
4383 false
4384 }
4385 fn set_expanded(&self, _key: &usize, _expanded: bool) {}
4386 fn row_state(&self, _flat_index: usize) -> RowState {
4387 RowState::Loading
4388 }
4389 fn request_window(&self, range: Range<usize>) {
4390 self.requested.borrow_mut().push(range);
4391 }
4392 }
4393
4394 let requested = Rc::new(RefCell::new(Vec::new()));
4395 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4396 let id = tree.add(
4397 TreeTableView::from_source(Windowed {
4398 total: 1000,
4399 requested: requested.clone(),
4400 version: Signal::new(0),
4401 })
4402 .add_column(name_col())
4403 .show_header(false)
4404 .row_height(30.0),
4405 );
4406 tree.layout(SizeProposal {
4407 width: Some(400.0),
4408 height: Some(300.0),
4409 });
4410
4411 let body_pane = tree.children(id)[0];
4415 let placeholder_rows = tree.children(body_pane).len();
4416 assert!(
4417 placeholder_rows >= 10,
4418 "loading rows must render as placeholders, got {placeholder_rows}"
4419 );
4420 assert!(
4422 !requested.borrow().is_empty(),
4423 "request_window must be called for the visible range"
4424 );
4425 }
4426
4427 #[test]
4428 fn arrow_expand_collapse_follows_a_non_leading_tree_column() {
4429 use teksilo_core::event::{Key, Modifiers};
4434 let proxy = SortFilterTreeModel::new(sample_tree());
4435 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4436 let id = tree.add(
4437 TreeTableView::from_projection(proxy.clone())
4438 .add_column(name_col())
4439 .add_column(size_col())
4440 .tree_column("size")
4441 .row_height(20.0),
4442 );
4443 tree.layout(SizeProposal {
4444 width: Some(400.0),
4445 height: Some(200.0),
4446 });
4447 tree.focus(id);
4448
4449 {
4452 let any = tree.widget_as_any(id).unwrap();
4453 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4454 tt.set_focused_cell(0, 0);
4455 }
4456 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4457 assert_eq!(
4458 proxy.visible_count(),
4459 2,
4460 "ArrowRight off the tree column must not expand"
4461 );
4462
4463 {
4465 let any = tree.widget_as_any(id).unwrap();
4466 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4467 tt.set_focused_cell(0, 1);
4468 }
4469 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4470 assert_eq!(
4471 proxy.visible_count(),
4472 4,
4473 "docs expands to reveal 2 children"
4474 );
4475 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
4476 assert_eq!(proxy.visible_count(), 2, "docs collapses again");
4477 }
4478
4479 #[test]
4480 fn arrow_nav_scroll_follows_focused_row() {
4481 use teksilo_core::event::{Key, Modifiers};
4487 let proxy = SortFilterTreeModel::new(wide_tree(100));
4488 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4489 let id = tree.add(
4490 TreeTableView::from_projection(proxy)
4491 .add_column(name_col())
4492 .row_height(20.0),
4493 );
4494 let proposal = SizeProposal {
4495 width: Some(400.0),
4496 height: Some(200.0),
4497 };
4498 tree.layout(proposal);
4499 tree.focus(id);
4500 let read_scroll = |tree: &WidgetTree| {
4501 let any = tree.widget_as_any(id).unwrap();
4502 any.downcast_ref::<TreeTableView<&'static str>>()
4503 .unwrap()
4504 .scroll_y_signal()
4505 .get()
4506 };
4507 let read_focus = |tree: &WidgetTree| {
4508 let any = tree.widget_as_any(id).unwrap();
4509 any.downcast_ref::<TreeTableView<&'static str>>()
4510 .unwrap()
4511 .focused_cell_signal()
4512 .get()
4513 };
4514 {
4515 let any = tree.widget_as_any(id).unwrap();
4516 any.downcast_ref::<TreeTableView<&'static str>>()
4517 .unwrap()
4518 .set_focused_cell(0, 0);
4519 }
4520 assert_eq!(read_scroll(&tree), 0.0, "starts at top");
4521
4522 for _ in 0..20 {
4523 tree.press_key(Key::ArrowDown, Modifiers::NONE);
4524 tree.layout(proposal);
4525 }
4526 assert_eq!(read_focus(&tree), Some((20, 0)));
4527 assert!(
4528 read_scroll(&tree) > 200.0,
4529 "arrow-down nav must scroll to reveal row 20, got {}",
4530 read_scroll(&tree)
4531 );
4532
4533 tree.press_key(Key::Home, Modifiers::COMMAND);
4535 tree.layout(proposal);
4536 assert_eq!(read_focus(&tree), Some((0, 0)));
4537 assert_eq!(read_scroll(&tree), 0.0, "Ctrl+Home scrolls to top");
4538 }
4539
4540 #[test]
4541 fn type_ahead_jumps_to_matching_row() {
4542 use teksilo_core::event::{Key, Modifiers};
4543 let model = TreeModel::new();
4544 model.insert_root(0, "Apple");
4545 model.insert_root(1, "Banana");
4546 model.insert_root(2, "Cherry");
4547 model.insert_root(3, "Cranberry");
4548 let proxy = SortFilterTreeModel::new(model);
4549 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4550 let id = tree.add(
4551 TreeTableView::from_projection(proxy)
4552 .add_column(name_col())
4553 .row_height(20.0)
4554 .type_ahead_label(|s: &&'static str| s.to_string()),
4555 );
4556 tree.layout(SizeProposal {
4557 width: Some(400.0),
4558 height: Some(200.0),
4559 });
4560 tree.focus(id);
4561 let read_focus = |tree: &WidgetTree| {
4562 let any = tree.widget_as_any(id).unwrap();
4563 any.downcast_ref::<TreeTableView<&'static str>>()
4564 .unwrap()
4565 .focused_cell_signal()
4566 .get()
4567 };
4568 {
4569 let any = tree.widget_as_any(id).unwrap();
4570 any.downcast_ref::<TreeTableView<&'static str>>()
4571 .unwrap()
4572 .set_focused_cell(0, 0);
4573 }
4574 tree.press_key(Key::C, Modifiers::NONE);
4575 assert_eq!(read_focus(&tree), Some((2, 0)), "'c' → Cherry");
4576 tree.press_key(Key::R, Modifiers::NONE);
4577 assert_eq!(read_focus(&tree), Some((3, 0)), "'cr' → Cranberry");
4578 }
4579
4580 #[test]
4581 fn ctrl_tab_escapes_the_cell_grid() {
4582 use crate::primitives::{TextWidget, VStack};
4583 use teksilo_core::event::{Key, Modifiers};
4584 use teksilo_core::widget_builder::WidgetBuilder;
4585
4586 let proxy = SortFilterTreeModel::new(wide_tree(5));
4587 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4588 let id = tree.add(
4589 TreeTableView::from_projection(proxy)
4590 .add_column(name_col())
4591 .row_height(20.0),
4592 );
4593 let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
4594 let _root = tree.add(VStack::new().add_child(id).add_child(sink));
4595 tree.layout(SizeProposal {
4596 width: Some(400.0),
4597 height: Some(200.0),
4598 });
4599 let read_focus = |tree: &WidgetTree| {
4600 let any = tree.widget_as_any(id).unwrap();
4601 any.downcast_ref::<TreeTableView<&'static str>>()
4602 .unwrap()
4603 .focused_cell_signal()
4604 .get()
4605 };
4606 tree.focus(id);
4607 {
4608 let any = tree.widget_as_any(id).unwrap();
4609 any.downcast_ref::<TreeTableView<&'static str>>()
4610 .unwrap()
4611 .set_focused_cell(0, 0);
4612 }
4613 let before = read_focus(&tree);
4614 tree.press_key(Key::Tab, Modifiers::CTRL);
4615 assert_eq!(
4616 read_focus(&tree),
4617 before,
4618 "Ctrl+Tab must not navigate cells"
4619 );
4620 assert_eq!(
4621 tree.focused(),
4622 Some(sink),
4623 "Ctrl+Tab moves focus out of the tree-table"
4624 );
4625 }
4626
4627 #[test]
4628 fn rows_carry_role_row_with_level_indicator() {
4629 let proxy = SortFilterTreeModel::new(sample_tree());
4630 let docs = proxy.tree().root(0);
4631 proxy.expand(docs);
4632 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4633 let id = tree.add(
4634 TreeTableView::from_projection(proxy)
4635 .add_column(name_col())
4636 .row_height(20.0),
4637 );
4638 tree.layout(SizeProposal {
4639 width: Some(400.0),
4640 height: Some(200.0),
4641 });
4642 let mut q = vec![id];
4644 let mut row_count = 0;
4645 while let Some(n) = q.pop() {
4646 if tree.accessibility_node(n).role() == Role::Row {
4647 row_count += 1;
4648 }
4649 for c in tree.children(n) {
4650 q.push(c);
4651 }
4652 }
4653 assert!(
4655 row_count >= 5,
4656 "expected at least 5 Role::Row nodes, got {row_count}"
4657 );
4658 }
4659
4660 #[test]
4661 fn filter_mode_keep_ancestors_works_via_proxy() {
4662 let proxy = SortFilterTreeModel::new(sample_tree())
4663 .filter_mode(TreeFilterMode::KeepAncestors)
4664 .with_predicate("name", |t| {
4665 let needle = t.to_string();
4666 Box::new(move |row: &&str| row.contains(&needle))
4667 });
4668 proxy.expand_all();
4669 proxy.set_filter("name", "main");
4670 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4671 let _id = tree.add(
4672 TreeTableView::from_projection(proxy.clone())
4673 .add_column(name_col())
4674 .row_height(20.0),
4675 );
4676 tree.layout(SizeProposal {
4677 width: Some(400.0),
4678 height: Some(200.0),
4679 });
4680 assert_eq!(proxy.visible_count(), 2);
4682 }
4683
4684 #[test]
4685 fn collapse_all_then_expand_all_round_trips() {
4686 let proxy = SortFilterTreeModel::new(sample_tree());
4687 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4688 let id = tree.add(
4689 TreeTableView::from_projection(proxy.clone())
4690 .add_column(name_col())
4691 .row_height(20.0),
4692 );
4693 tree.layout(SizeProposal {
4694 width: Some(400.0),
4695 height: Some(200.0),
4696 });
4697 {
4698 let any = tree.widget_as_any(id).unwrap();
4699 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4700 tt.expand_all();
4701 }
4702 assert_eq!(proxy.visible_count(), 5);
4703 {
4704 let any = tree.widget_as_any(id).unwrap();
4705 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4706 tt.collapse_all();
4707 }
4708 assert_eq!(proxy.visible_count(), 2);
4709 }
4710
4711 #[test]
4712 fn rows_report_sibling_position_and_size_among_siblings() {
4713 let proxy = SortFilterTreeModel::new(sample_tree());
4721 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4722 let id = tree.add(
4723 TreeTableView::from_projection(proxy.clone())
4724 .add_column(name_col())
4725 .row_height(20.0),
4726 );
4727 tree.layout(SizeProposal {
4728 width: Some(400.0),
4729 height: Some(400.0),
4730 });
4731 {
4732 let any = tree.widget_as_any(id).unwrap();
4733 any.downcast_ref::<TreeTableView<&'static str>>()
4734 .unwrap()
4735 .expand_all();
4736 }
4737 tree.layout(SizeProposal {
4738 width: Some(400.0),
4739 height: Some(400.0),
4740 });
4741 assert_eq!(proxy.visible_count(), 5);
4742
4743 let mut rows: Vec<WidgetId> = Vec::new();
4748 let mut q = vec![id];
4749 while let Some(n) = q.pop() {
4750 if tree.accessibility_node(n).role() == Role::Row {
4751 rows.push(n);
4752 }
4753 for c in tree.children(n) {
4754 q.push(c);
4755 }
4756 }
4757 rows.sort_by(|a, b| tree.bounds(*a).y.partial_cmp(&tree.bounds(*b).y).unwrap());
4758 assert_eq!(rows.len(), 6, "header + five body rows");
4759 let body_rows = &rows[1..];
4760
4761 let update = tree.sync_accessibility();
4765 let find = |wid: WidgetId| -> &teksilo_core::accesskit::Node {
4766 let nid = widget_id_to_node_id(wid);
4767 update
4768 .nodes
4769 .iter()
4770 .find(|(n, _)| *n == nid)
4771 .map(|(_, n)| n)
4772 .expect("row must be in the a11y tree")
4773 };
4774 let sets: Vec<(usize, usize)> = body_rows
4775 .iter()
4776 .map(|&r| {
4777 let node = find(r);
4778 (
4779 node.position_in_set().expect("position_in_set"),
4780 node.size_of_set().expect("size_of_set"),
4781 )
4782 })
4783 .collect();
4784 assert_eq!(
4785 sets,
4786 vec![(1, 2), (1, 2), (2, 2), (2, 2), (1, 1)],
4787 "docs(1/2) readme(1/2) guide(2/2) src(2/2) main.rs(1/1)"
4788 );
4789 }
4790
4791 #[test]
4792 fn row_count_in_a11y_includes_header() {
4793 let proxy = SortFilterTreeModel::new(sample_tree());
4794 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4795 let id = tree.add(
4796 TreeTableView::from_projection(proxy)
4797 .add_column(name_col())
4798 .add_column(size_col())
4799 .row_height(20.0),
4800 );
4801 tree.layout(SizeProposal {
4802 width: Some(400.0),
4803 height: Some(200.0),
4804 });
4805 let info = tree.accessibility_node(id);
4806 assert_eq!(info.role(), Role::TreeGrid);
4807 let mut q = vec![id];
4811 let mut rows = 0;
4812 while let Some(n) = q.pop() {
4813 if tree.accessibility_node(n).role() == Role::Row {
4814 rows += 1;
4815 }
4816 for c in tree.children(n) {
4817 q.push(c);
4818 }
4819 }
4820 assert_eq!(rows, 3); }
4822
4823 fn wide_tree(n: u32) -> TreeModel<&'static str> {
4827 let t = TreeModel::new();
4828 for i in 0..n {
4829 t.insert_root(i as usize, "node");
4830 }
4831 t
4832 }
4833
4834 fn row_bounds(tree: &WidgetTree, root: WidgetId) -> Vec<teksilo_canvas::Rect> {
4836 let mut q = vec![root];
4837 let mut out = Vec::new();
4838 while let Some(n) = q.pop() {
4839 if tree.accessibility_node(n).role() == Role::Row {
4840 out.push(tree.bounds(n));
4841 }
4842 for c in tree.children(n) {
4843 q.push(c);
4844 }
4845 }
4846 out
4847 }
4848
4849 #[test]
4850 fn rtl_swaps_tree_expand_collapse_keys() {
4851 use teksilo_core::environment::LayoutDirection;
4852 use teksilo_core::event::{Key, Modifiers};
4853
4854 let proxy = SortFilterTreeModel::new(sample_tree());
4855 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4856 let table = tree.add(
4857 TreeTableView::from_projection(proxy.clone())
4858 .add_column(name_col())
4859 .row_height(20.0),
4860 );
4861 tree.layout(SizeProposal {
4862 width: Some(400.0),
4863 height: Some(200.0),
4864 });
4865 assert_eq!(proxy.visible_count(), 2);
4867
4868 tree.set_layout_direction(LayoutDirection::RightToLeft);
4869 tree.focus(table);
4870 {
4871 let any = tree.widget_as_any(table).unwrap();
4872 any.downcast_ref::<TreeTableView<&'static str>>()
4873 .unwrap()
4874 .set_focused_cell(0, 0);
4875 }
4876
4877 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
4880 assert_eq!(
4881 proxy.visible_count(),
4882 4,
4883 "RTL ArrowLeft on the tree column should expand docs"
4884 );
4885 tree.press_key(Key::ArrowRight, Modifiers::NONE);
4886 assert_eq!(
4887 proxy.visible_count(),
4888 2,
4889 "RTL ArrowRight on the tree column should collapse docs"
4890 );
4891 }
4892
4893 #[test]
4894 fn rtl_tree_band_shifts_for_left_scrollbar() {
4895 use teksilo_core::environment::LayoutDirection;
4896 let proxy = SortFilterTreeModel::new(wide_tree(50));
4900 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4901 let table = tree.add(
4902 TreeTableView::from_projection(proxy)
4903 .add_column(name_col())
4904 .row_height(20.0),
4905 );
4906 tree.layout(SizeProposal {
4907 width: Some(400.0),
4908 height: Some(200.0),
4909 });
4910 tree.set_layout_direction(LayoutDirection::RightToLeft);
4911 tree.layout(SizeProposal {
4912 width: Some(400.0),
4913 height: Some(200.0),
4914 });
4915
4916 let table_bounds = tree.bounds(table);
4917 let body_row = row_bounds(&tree, table)
4919 .into_iter()
4920 .filter(|r| r.y > table_bounds.y + 5.0)
4921 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
4922 .expect("a body row");
4923 assert!(
4924 (body_row.x - SCROLLBAR_THICKNESS).abs() < 0.5,
4925 "RTL body row should start at SCROLLBAR_THICKNESS, got x={}",
4926 body_row.x
4927 );
4928 let mut tree2 = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4930 let proxy2 = SortFilterTreeModel::new(wide_tree(50));
4931 let table2 = tree2.add(
4932 TreeTableView::from_projection(proxy2)
4933 .add_column(name_col())
4934 .row_height(20.0),
4935 );
4936 tree2.layout(SizeProposal {
4937 width: Some(400.0),
4938 height: Some(200.0),
4939 });
4940 let tb2 = tree2.bounds(table2);
4941 let body_row2 = row_bounds(&tree2, table2)
4942 .into_iter()
4943 .filter(|r| r.y > tb2.y + 5.0)
4944 .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
4945 .expect("a body row");
4946 assert!(body_row2.x.abs() < 0.5, "LTR body row x={}", body_row2.x);
4947 }
4948
4949 fn nested_tree_table_fixture(
4955 inner: OverscrollBehavior,
4956 ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
4957 use crate::ScrollArea;
4958 use crate::primitives::{FixedSize, TextWidget, VStack};
4959 let model = TreeModel::new();
4960 for i in 0..40 {
4961 model.insert_root(i, "row");
4962 }
4963 let proxy = SortFilterTreeModel::new(model);
4964 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4965 let tt = TreeTableView::from_projection(proxy)
4966 .add_column(name_col())
4967 .show_header(false)
4968 .row_height(20.0)
4969 .overscroll_behavior(inner);
4970 let inner_y = tt.scroll_y_signal().clone();
4971 let tt_id = tree.add(tt);
4972 let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tt_id));
4973 let filler = tree.add(
4974 FixedSize::new()
4975 .width(220.0)
4976 .height(300.0)
4977 .child(TextWidget::new(lit!(""))),
4978 );
4979 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
4980 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
4981 let outer_y = outer.scroll_y_signal().clone();
4982 let _outer = tree.add(outer);
4983 tree.layout(SizeProposal {
4984 width: Some(220.0),
4985 height: Some(150.0),
4986 });
4987 (tree, inner_y, outer_y)
4988 }
4989
4990 #[test]
4991 fn nested_tree_table_chains_to_outer_at_boundary() {
4992 use teksilo_canvas::Point;
4993 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
4994 let (mut tree, inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Chain);
4995 tree.pointer_move(Point::new(50.0, 40.0));
4996 tree.dispatch_event(WidgetEvent::Scroll {
4997 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
4998 modifiers: Modifiers::NONE,
4999 });
5000 tree.layout(SizeProposal {
5001 width: Some(220.0),
5002 height: Some(150.0),
5003 });
5004 let inner_bottom = inner_y.get();
5005 assert!(
5006 inner_bottom > 0.0,
5007 "inner tree-table should scroll down; got {inner_bottom}"
5008 );
5009 tree.pointer_move(Point::new(50.0, 40.0));
5011 tree.dispatch_event(WidgetEvent::Scroll {
5012 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5013 modifiers: Modifiers::NONE,
5014 });
5015 tree.layout(SizeProposal {
5016 width: Some(220.0),
5017 height: Some(150.0),
5018 });
5019 assert!(
5020 (inner_y.get() - inner_bottom).abs() < 0.01,
5021 "inner stays clamped at bottom"
5022 );
5023 assert!(
5024 outer_y.get() > 0.01,
5025 "outer must scroll because the inner chained the boundary"
5026 );
5027 }
5028
5029 #[test]
5030 fn nested_tree_table_contain_blocks_chaining() {
5031 use teksilo_canvas::Point;
5032 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5033 let (mut tree, _inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Contain);
5034 tree.pointer_move(Point::new(50.0, 40.0));
5035 tree.dispatch_event(WidgetEvent::Scroll {
5036 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5037 modifiers: Modifiers::NONE,
5038 });
5039 tree.layout(SizeProposal {
5040 width: Some(220.0),
5041 height: Some(150.0),
5042 });
5043 tree.pointer_move(Point::new(50.0, 40.0));
5044 tree.dispatch_event(WidgetEvent::Scroll {
5045 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5046 modifiers: Modifiers::NONE,
5047 });
5048 tree.layout(SizeProposal {
5049 width: Some(220.0),
5050 height: Some(150.0),
5051 });
5052 assert!(
5053 outer_y.get() < 0.01,
5054 "Contain must prevent chaining: outer stays put"
5055 );
5056 }
5057
5058 fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
5061 let mut walker = vec![root];
5062 let mut n = 0;
5063 while let Some(id) = walker.pop() {
5064 if tree.accessibility_node(id).role() == role {
5065 n += 1;
5066 }
5067 for c in tree.children(id) {
5068 walker.push(c);
5069 }
5070 }
5071 n
5072 }
5073
5074 fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
5077 let mut walker = vec![root];
5078 let mut spans = Vec::new();
5079 while let Some(id) = walker.pop() {
5080 if tree.accessibility_node(id).role() == Role::Row {
5081 let b = tree.bounds(id);
5082 spans.push((b.y, b.height));
5083 }
5084 for c in tree.children(id) {
5085 walker.push(c);
5086 }
5087 }
5088 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5089 spans
5090 }
5091
5092 #[test]
5093 fn rows_rebuild_during_scrollbar_thumb_drag() {
5094 let model = TreeModel::new();
5098 for i in 0..500 {
5099 model.insert_root(i, "root");
5100 }
5101 let proxy = SortFilterTreeModel::new(model);
5102 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5103 let table = tree.add(
5104 TreeTableView::from_projection(proxy.clone())
5105 .add_column(name_col())
5106 .row_height(20.0),
5107 );
5108 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
5109 &mut tree,
5110 table,
5111 400.0,
5112 200.0,
5113 cp::HEADER_HEIGHT,
5114 "TreeTableView",
5115 |t| {
5116 let mut n = 0;
5117 let mut walker = vec![table];
5118 while let Some(id) = walker.pop() {
5119 if t.accessibility_node(id).role() == Role::Row {
5120 let b = t.bounds(id);
5121 if b.y >= 0.0 && b.y < 200.0 {
5122 n += 1;
5123 }
5124 }
5125 for c in t.children(id) {
5126 walker.push(c);
5127 }
5128 }
5129 n
5130 },
5131 );
5132 }
5133
5134 #[test]
5135 fn exact_row_height_fn_positions_tree_rows() {
5136 let heights = [60.0_f32, 20.0, 40.0];
5137 let proxy = SortFilterTreeModel::new(sample_tree());
5138 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5139 let table = tree.add(
5140 TreeTableView::from_projection(proxy)
5141 .add_column(name_col())
5142 .show_header(false)
5143 .row_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
5144 );
5145 tree.layout(SizeProposal {
5146 width: Some(400.0),
5147 height: Some(300.0),
5148 });
5149
5150 let spans = row_spans(&tree, table);
5152 assert_eq!(spans.len(), 2);
5153 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
5154 assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
5155 }
5156
5157 #[test]
5158 fn auto_row_height_measures_tree_cells() {
5159 #[derive(Debug)]
5160 struct FixedLeaf(f32, f32);
5161 impl Widget for FixedLeaf {
5162 fn layout_response(
5163 &self,
5164 _proposal: SizeProposal,
5165 _ctx: &LayoutContext,
5166 ) -> teksilo_core::widget::LayoutResponse {
5167 Size::new(self.0, self.1).into()
5168 }
5169 }
5170 let col = Column::<&str>::new("name", lit!("Name"), |_row, _: &CellContext| {
5171 Box::new(FixedLeaf(50.0, 30.0))
5172 })
5173 .width(ColumnWidth::Flex(1.0));
5174 let proxy = SortFilterTreeModel::new(sample_tree());
5175 let docs = proxy.tree().root(0);
5176 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5177 let table = tree.add(
5178 TreeTableView::from_projection(proxy.clone())
5179 .add_column(col)
5180 .show_header(false)
5181 .auto_row_height(50.0),
5182 );
5183 tree.layout(SizeProposal {
5184 width: Some(400.0),
5185 height: Some(300.0),
5186 });
5187 tree.layout(SizeProposal {
5188 width: Some(400.0),
5189 height: Some(300.0),
5190 });
5191
5192 let spans = row_spans(&tree, table);
5194 assert!(
5195 (spans[1].0 - 30.0).abs() < 0.01,
5196 "row 1 should sit at measured 30, got {}",
5197 spans[1].0
5198 );
5199
5200 proxy.expand(docs);
5204 tree.layout(SizeProposal {
5205 width: Some(400.0),
5206 height: Some(300.0),
5207 });
5208 tree.layout(SizeProposal {
5209 width: Some(400.0),
5210 height: Some(300.0),
5211 });
5212 let spans = row_spans(&tree, table);
5213 assert_eq!(spans.len(), 4); assert!(
5215 (spans[1].0 - 30.0).abs() < 0.01,
5216 "measured row 0 must survive the expand, got {}",
5217 spans[1].0
5218 );
5219 }
5220
5221 fn drag(tree: &mut WidgetTree, from: teksilo_canvas::Point, to: teksilo_canvas::Point) {
5226 use teksilo_canvas::Point;
5227 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
5228 tree.dispatch_event(WidgetEvent::PointerDown {
5229 position: from,
5230 button: PointerButton::Primary,
5231 modifiers: Modifiers::NONE,
5232 });
5233 tree.dispatch_event(WidgetEvent::PointerMove {
5234 position: Point::new(from.x + 10.0, from.y),
5235 });
5236 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
5237 tree.dispatch_event(WidgetEvent::PointerUp {
5238 position: to,
5239 button: PointerButton::Primary,
5240 modifiers: Modifiers::NONE,
5241 });
5242 }
5243
5244 #[test]
5245 fn drag_reorders_roots_after() {
5246 use teksilo_canvas::Point;
5247 let proxy = SortFilterTreeModel::new(sample_tree());
5248 proxy.collapse_all(); let docs = proxy.tree().root(0);
5250 let src = proxy.tree().root(1);
5251 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5252 tree.add(
5253 TreeTableView::from_projection(proxy.clone())
5254 .add_column(name_col())
5255 .reorderable(true)
5256 .row_height(20.0),
5257 );
5258 tree.layout(SizeProposal {
5259 width: Some(400.0),
5260 height: Some(300.0),
5261 });
5262 let h = cp::HEADER_HEIGHT;
5263 drag(
5266 &mut tree,
5267 Point::new(40.0, h + 10.0),
5268 Point::new(40.0, h + 38.0),
5269 );
5270 assert_eq!(proxy.tree().root_count(), 2);
5271 assert_eq!(proxy.tree().root(0), src, "src becomes the first root");
5272 assert_eq!(proxy.tree().root(1), docs, "docs moves after src");
5273 }
5274
5275 #[test]
5276 fn drag_into_own_descendant_is_refused() {
5277 use teksilo_canvas::Point;
5278 let proxy = SortFilterTreeModel::new(sample_tree());
5279 proxy.expand_all(); let docs = proxy.tree().root(0);
5281 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5282 tree.add(
5283 TreeTableView::from_projection(proxy.clone())
5284 .add_column(name_col())
5285 .reorderable(true)
5286 .row_height(20.0),
5287 );
5288 tree.layout(SizeProposal {
5289 width: Some(400.0),
5290 height: Some(300.0),
5291 });
5292 let h = cp::HEADER_HEIGHT;
5293 drag(
5296 &mut tree,
5297 Point::new(40.0, h + 10.0),
5298 Point::new(40.0, h + 30.0),
5299 );
5300 assert_eq!(proxy.tree().parent(docs), None, "docs stays a root");
5301 assert_eq!(proxy.tree().root_count(), 2);
5302 }
5303
5304 #[test]
5305 fn reorder_is_suppressed_while_sorted() {
5306 use teksilo_canvas::Point;
5307 let proxy = SortFilterTreeModel::new(sample_tree());
5308 proxy.collapse_all();
5309 let docs = proxy.tree().root(0);
5310 let src = proxy.tree().root(1);
5311 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5312 let id = tree.add(
5313 TreeTableView::from_projection(proxy.clone())
5314 .add_column(name_col())
5315 .reorderable(true)
5316 .row_height(20.0),
5317 );
5318 tree.layout(SizeProposal {
5319 width: Some(400.0),
5320 height: Some(300.0),
5321 });
5322 tree.widget_as_any(id)
5325 .and_then(|a| a.downcast_ref::<TreeTableView<&str>>())
5326 .expect("TreeTableView")
5327 .set_sort(Some("name"), teksilo_data::SortDirection::Ascending);
5328 let h = cp::HEADER_HEIGHT;
5329 drag(
5330 &mut tree,
5331 Point::new(40.0, h + 10.0),
5332 Point::new(40.0, h + 38.0),
5333 );
5334 assert_eq!(proxy.tree().root(0), docs, "docs unchanged while sorted");
5335 assert_eq!(proxy.tree().root(1), src, "src unchanged while sorted");
5336 }
5337
5338 #[test]
5339 fn keyed_selection_survives_collapse() {
5340 use teksilo_data::{KeyedSelectionModel, SelectionMode};
5345 let proxy = SortFilterTreeModel::new(sample_tree());
5346 proxy.expand_all();
5347 let docs = proxy.tree().root(0);
5348 let readme = proxy.tree().children(docs)[0];
5349 let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
5350 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5351 tree.add(
5352 TreeTableView::from_projection(proxy.clone())
5353 .add_column(name_col())
5354 .selection_mode(TableSelectionMode::MultiRow)
5355 .keyed_selection(keyed.clone())
5356 .row_height(20.0),
5357 );
5358 tree.layout(SizeProposal {
5359 width: Some(400.0),
5360 height: Some(300.0),
5361 });
5362
5363 keyed.select(readme);
5364 assert!(keyed.is_selected(&readme));
5365
5366 proxy.collapse(docs);
5369 assert!(
5370 keyed.is_selected(&readme),
5371 "a collapsed-but-present node stays selected by identity"
5372 );
5373
5374 proxy.expand(docs);
5376 assert!(keyed.is_selected(&readme));
5377 }
5378
5379 fn tt_flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
5395 let mut out = Vec::new();
5396 for id in ids {
5397 if matches!(
5398 tree.accessibility_node(id).role(),
5399 Role::GenericContainer | Role::Unknown
5400 ) {
5401 out.extend(tt_flatten_through_bands(tree, tree.children(id)));
5402 } else {
5403 out.push(id);
5404 }
5405 }
5406 out
5407 }
5408
5409 fn tt_first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5413 let mut walker = vec![root];
5414 while let Some(id) = walker.pop() {
5415 if tree.accessibility_node(id).role() == Role::Row {
5416 let flat = tt_flatten_through_bands(tree, tree.children(id));
5417 if flat
5418 .iter()
5419 .any(|&c| tree.accessibility_node(c).role() == Role::Cell)
5420 {
5421 return id;
5422 }
5423 }
5424 for c in tree.children(id) {
5425 walker.push(c);
5426 }
5427 }
5428 panic!("no body Role::Row found");
5429 }
5430
5431 fn tt_header_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5432 let mut walker = vec![root];
5433 while let Some(id) = walker.pop() {
5434 if tree.accessibility_node(id).role() == Role::Row {
5435 let flat = tt_flatten_through_bands(tree, tree.children(id));
5436 if !flat.is_empty()
5437 && flat
5438 .iter()
5439 .all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
5440 {
5441 return id;
5442 }
5443 }
5444 for c in tree.children(id) {
5445 walker.push(c);
5446 }
5447 }
5448 panic!("no header Role::Row found");
5449 }
5450
5451 fn tt_body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5452 tt_flatten_through_bands(tree, tree.children(tt_first_body_row_id(tree, root)))
5453 }
5454
5455 fn tt_header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5456 tt_flatten_through_bands(tree, tree.children(tt_header_row_id(tree, root)))
5457 }
5458
5459 fn build_tt_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
5463 let proxy = SortFilterTreeModel::new(sample_tree());
5464 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5465 let id = tree.add(
5466 TreeTableView::from_projection(proxy)
5467 .add_column(
5468 Column::<&'static str>::new("lead", lit!("Lead"), |row, _: &CellContext| {
5469 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5470 })
5471 .width(ColumnWidth::Fixed(60.0))
5472 .pinned(PinnedSide::Leading),
5473 )
5474 .add_column(
5475 Column::<&'static str>::new("mid", lit!("Mid"), |row, _: &CellContext| {
5476 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5477 })
5478 .width(ColumnWidth::Fixed(middle_w)),
5479 )
5480 .add_column(
5481 Column::<&'static str>::new("trail", lit!("Trail"), |_row, _: &CellContext| {
5482 Box::new(crate::primitives::TextWidget::new(lit!("x")))
5483 })
5484 .width(ColumnWidth::Fixed(60.0))
5485 .pinned(PinnedSide::Trailing),
5486 )
5487 .row_height(20.0),
5488 );
5489 tree.layout(SizeProposal {
5490 width: Some(table_w),
5491 height: Some(200.0),
5492 });
5493 (tree, id)
5494 }
5495
5496 fn build_tt_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
5498 let proxy = SortFilterTreeModel::new(sample_tree());
5499 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5500 let mut tv = TreeTableView::from_projection(proxy);
5501 for i in 0..n {
5502 let col_id = format!("c{i}");
5503 tv = tv.add_column(
5504 Column::<&'static str>::new(
5505 col_id.clone(),
5506 lit!(col_id.clone()),
5507 |row, _: &CellContext| Box::new(crate::primitives::TextWidget::new(lit!(*row))),
5508 )
5509 .width(ColumnWidth::Fixed(col_w)),
5510 );
5511 }
5512 let id = tree.add(tv.row_height(20.0));
5513 tree.layout(SizeProposal {
5514 width: Some(table_w),
5515 height: Some(200.0),
5516 });
5517 (tree, id)
5518 }
5519
5520 fn tt_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5521 tree.widget_as_any(id)
5522 .unwrap()
5523 .downcast_ref::<TreeTableView<&'static str>>()
5524 .unwrap()
5525 .scroll_x_signal()
5526 .get()
5527 }
5528
5529 fn tt_max_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5530 tree.widget_as_any(id)
5531 .unwrap()
5532 .downcast_ref::<TreeTableView<&'static str>>()
5533 .unwrap()
5534 .max_scroll_x_signal()
5535 .get()
5536 }
5537
5538 fn tt_set_scroll_x(tree: &WidgetTree, id: WidgetId, x: f32) {
5539 tree.widget_as_any(id)
5540 .unwrap()
5541 .downcast_ref::<TreeTableView<&'static str>>()
5542 .unwrap()
5543 .scroll_x_signal()
5544 .set(x);
5545 }
5546
5547 #[test]
5548 fn tt_scroll_x_clamps_after_the_pane_widens() {
5549 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 3, 300.0);
5550 let max = tt_max_scroll_x(&tree, id);
5551 assert!(max > 0.0, "columns must overflow the narrow table");
5552 tt_set_scroll_x(&tree, id, max);
5553 assert_eq!(tt_scroll_x(&tree, id), max);
5554
5555 tree.layout(SizeProposal {
5556 width: Some(700.0),
5557 height: Some(200.0),
5558 });
5559 assert_eq!(tt_max_scroll_x(&tree, id), 0.0, "content now fits");
5560 assert_eq!(
5561 tt_scroll_x(&tree, id),
5562 0.0,
5563 "scroll_x must clamp down with the new (smaller) max_scroll_x"
5564 );
5565 }
5566
5567 #[test]
5568 fn tt_pinned_columns_keep_their_bands_under_scroll() {
5569 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
5570
5571 let cells0 = tt_body_row_cells(&tree, id);
5572 assert_eq!(cells0.len(), 3, "lead, mid, trail");
5573 let lead_x0 = tree.bounds(cells0[0]).x;
5574 let mid_x0 = tree.bounds(cells0[1]).x;
5575 let trail_x0 = tree.bounds(cells0[2]).x;
5576
5577 let tree_row_a11y = tt_first_body_row_id(&tree, id);
5582 let body_row = tree.children(tree_row_a11y)[0];
5583 let raw_bands = tree.children(body_row);
5584 assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
5585 assert!(!tree.widget_clips_children(raw_bands[0]));
5586 assert!(
5587 tree.widget_clips_children(raw_bands[1]),
5588 "the Middle band must clip"
5589 );
5590 assert!(!tree.widget_clips_children(raw_bands[2]));
5591
5592 let max = tt_max_scroll_x(&tree, id);
5593 assert!(max > 0.0);
5594 tt_set_scroll_x(&tree, id, 50.0_f32.min(max));
5595 tree.layout(SizeProposal {
5596 width: Some(200.0),
5597 height: Some(200.0),
5598 });
5599
5600 let cells1 = tt_body_row_cells(&tree, id);
5601 assert_eq!(tree.bounds(cells1[0]).x, lead_x0, "Leading never moves");
5602 assert_eq!(tree.bounds(cells1[2]).x, trail_x0, "Trailing never moves");
5603 let mid_x1 = tree.bounds(cells1[1]).x;
5604 assert!(
5605 (mid_x1 - (mid_x0 - 50.0)).abs() < 0.5,
5606 "the Middle column shifts left by exactly scroll_x: got {mid_x1}, want ~{}",
5607 mid_x0 - 50.0
5608 );
5609 }
5610
5611 #[test]
5612 fn tt_header_and_body_x_offsets_agree_under_scroll() {
5613 let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
5614 tt_set_scroll_x(&tree, id, 37.0);
5615 tree.layout(SizeProposal {
5616 width: Some(200.0),
5617 height: Some(200.0),
5618 });
5619
5620 let header_cells = tt_header_row_cells(&tree, id);
5621 let body_cells = tt_body_row_cells(&tree, id);
5622 assert_eq!(header_cells.len(), body_cells.len());
5623 for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
5624 let hx = tree.bounds(h).x;
5625 let bx = tree.bounds(b).x;
5626 assert!(
5627 (hx - bx).abs() < 0.01,
5628 "column {i}: header x {hx} must equal body x {bx}"
5629 );
5630 }
5631 }
5632
5633 #[test]
5634 fn tt_shift_wheel_scrolls_horizontally() {
5635 use teksilo_canvas::Point;
5636 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5637 let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 4, 300.0);
5638 tree.pointer_move(Point::new(50.0, 60.0));
5639 tree.dispatch_event(WidgetEvent::Scroll {
5640 delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
5641 modifiers: Modifiers::SHIFT,
5642 });
5643 tree.layout(SizeProposal {
5644 width: Some(300.0),
5645 height: Some(200.0),
5646 });
5647 assert!(
5648 tt_scroll_x(&tree, id) > 0.0,
5649 "Shift+wheel must remap a vertical-only wheel to horizontal scroll"
5650 );
5651 let any = tree.widget_as_any(id).unwrap();
5652 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5653 assert_eq!(
5654 tt.scroll_y_signal().get(),
5655 0.0,
5656 "Shift+wheel must not also scroll vertically"
5657 );
5658 }
5659
5660 #[test]
5661 fn tt_ensure_col_visible_follows_focus_in_both_directions() {
5662 use teksilo_core::event::{Key, Modifiers};
5663 let (mut tree, id) = build_tt_wide_unpinned_table(150.0, 5, 300.0);
5664 tree.focus(id);
5665 {
5666 let any = tree.widget_as_any(id).unwrap();
5667 any.downcast_ref::<TreeTableView<&'static str>>()
5668 .unwrap()
5669 .set_focused_cell(0, 0);
5670 }
5671 assert_eq!(tt_scroll_x(&tree, id), 0.0);
5672
5673 tree.press_key(Key::End, Modifiers::NONE);
5674 {
5675 let any = tree.widget_as_any(id).unwrap();
5676 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5677 assert_eq!(tt.focused_cell_signal().get(), Some((0, 4)));
5678 }
5679 assert!(
5680 tt_scroll_x(&tree, id) > 0.0,
5681 "ensure-column-visible must scroll right to reveal column 4"
5682 );
5683
5684 tree.press_key(Key::Home, Modifiers::NONE);
5685 {
5686 let any = tree.widget_as_any(id).unwrap();
5687 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5688 assert_eq!(tt.focused_cell_signal().get(), Some((0, 0)));
5689 }
5690 assert_eq!(
5691 tt_scroll_x(&tree, id),
5692 0.0,
5693 "ensure-column-visible must scroll left back to 0 for column 0"
5694 );
5695 }
5696
5697 fn reorder_col(id: &'static str, width: f32) -> Column<&'static str> {
5719 Column::<&'static str>::new(id, lit!(id), |row, _: &CellContext| {
5720 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5721 })
5722 .width(ColumnWidth::Fixed(width))
5723 }
5724
5725 fn build_tt_reorder_table() -> (WidgetTree, WidgetId, SortFilterTreeModel<&'static str>) {
5729 let proxy = SortFilterTreeModel::new(sample_tree());
5730 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5731 let id = tree.add(
5732 TreeTableView::from_projection(proxy.clone())
5733 .add_column(reorder_col("a", 60.0))
5734 .add_column(reorder_col("b", 70.0))
5735 .add_column(reorder_col("c", 80.0))
5736 .add_column(reorder_col("d", 90.0))
5737 .row_height(20.0),
5738 );
5739 tree.layout(SizeProposal {
5740 width: Some(400.0),
5741 height: Some(200.0),
5742 });
5743 (tree, id, proxy)
5744 }
5745
5746 fn tt_subtree_has_twist_arrow(tree: &WidgetTree, id: WidgetId) -> bool {
5753 if tree.widget_type_name(id) == Some("teksilo_widgets::primitives::twist_arrow::TwistArrow")
5754 {
5755 return true;
5756 }
5757 tree.children(id)
5758 .into_iter()
5759 .any(|c| tt_subtree_has_twist_arrow(tree, c))
5760 }
5761
5762 #[test]
5763 fn header_drag_reorders_column_before_an_earlier_sibling() {
5764 let (mut tree, id, _proxy) = build_tt_reorder_table();
5767 let header = tt_header_row_cells(&tree, id);
5768 assert_eq!(header.len(), 4);
5769 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
5772
5773 {
5774 let any = tree.widget_as_any(id).unwrap();
5775 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5776 assert_eq!(
5777 tt.column_order_signal().get(),
5778 vec![
5779 "a".to_string(),
5780 "d".to_string(),
5781 "b".to_string(),
5782 "c".to_string()
5783 ],
5784 "dropping \"d\" before \"b\" must write [a, d, b, c]"
5785 );
5786 assert_eq!(
5787 tt.column_pinning_signal().get().get("d"),
5788 None,
5789 "a mid-band drop must not pin the moved column"
5790 );
5791 }
5792
5793 tree.layout(SizeProposal {
5797 width: Some(400.0),
5798 height: Some(200.0),
5799 });
5800 let after = tt_header_row_cells(&tree, id);
5801 let widths: Vec<f32> = after.iter().map(|&c| tree.bounds(c).width).collect();
5802 assert!(
5803 widths
5804 .iter()
5805 .zip([60.0, 90.0, 70.0, 80.0])
5806 .all(|(&w, want)| (w - want).abs() < 0.5),
5807 "header cells must reflow to widths [60, 90, 70, 80], got {widths:?}"
5808 );
5809 }
5810
5811 #[test]
5812 fn header_drag_to_the_leading_edge_pins_the_dropped_column() {
5813 let (mut tree, id, _proxy) = build_tt_reorder_table();
5818 let header = tt_header_row_cells(&tree, id);
5819 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(5.0, from.y); drag(&mut tree, from, to);
5822
5823 let any = tree.widget_as_any(id).unwrap();
5824 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5825 assert_eq!(
5826 tt.column_order_signal().get(),
5827 vec![
5828 "d".to_string(),
5829 "a".to_string(),
5830 "b".to_string(),
5831 "c".to_string()
5832 ],
5833 );
5834 assert_eq!(
5835 tt.column_pinning_signal().get().get("d").copied(),
5836 Some(PinnedSide::Leading),
5837 "dropping at the leading edge must pin the column, same as TableView"
5838 );
5839 }
5840
5841 #[test]
5842 fn header_drag_reorder_remaps_focused_and_editing_cell_to_follow_their_columns() {
5843 let (mut tree, id, _proxy) = build_tt_reorder_table();
5849 {
5850 let any = tree.widget_as_any(id).unwrap();
5851 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5852 tt.set_focused_cell(0, 1); tt.begin_edit(0, "d"); }
5855
5856 let header = tt_header_row_cells(&tree, id);
5857 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(65.0, from.y); drag(&mut tree, from, to);
5860 tree.layout(SizeProposal {
5861 width: Some(400.0),
5862 height: Some(200.0),
5863 });
5864
5865 let any = tree.widget_as_any(id).unwrap();
5866 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5867 assert_eq!(
5868 tt.column_order_signal().get(),
5869 vec![
5870 "a".to_string(),
5871 "d".to_string(),
5872 "b".to_string(),
5873 "c".to_string()
5874 ],
5875 );
5876 assert_eq!(
5877 tt.focused_cell_signal().get(),
5878 Some((0, 2)),
5879 "focus must follow \"b\" to its new display position"
5880 );
5881 assert_eq!(
5882 tt.editing_cell_signal().get(),
5883 Some((0, 1)),
5884 "the open editor must follow \"d\" to its new display position"
5885 );
5886 }
5887
5888 #[test]
5889 fn header_drag_moves_the_tree_column_and_twist_follows() {
5890 let (mut tree, id, proxy) = build_tt_reorder_table();
5899 let header = tt_header_row_cells(&tree, id);
5900 let from = tree.bounds(header[0]).center(); let to = teksilo_canvas::Point::new(220.0, from.y); drag(&mut tree, from, to);
5903
5904 {
5905 let any = tree.widget_as_any(id).unwrap();
5906 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5907 assert_eq!(
5908 tt.column_order_signal().get(),
5909 vec![
5910 "b".to_string(),
5911 "c".to_string(),
5912 "a".to_string(),
5913 "d".to_string()
5914 ],
5915 );
5916 assert_eq!(
5917 tt.column_pinning_signal().get().get("a"),
5918 None,
5919 "a mid-band drop must not pin the tree column either"
5920 );
5921 }
5922 tree.layout(SizeProposal {
5923 width: Some(400.0),
5924 height: Some(200.0),
5925 });
5926
5927 let body = tt_body_row_cells(&tree, id);
5928 assert_eq!(body.len(), 4);
5929 assert!(
5930 !tt_subtree_has_twist_arrow(&tree, body[0]),
5931 "\"b\" is no longer the tree column"
5932 );
5933 assert!(
5934 !tt_subtree_has_twist_arrow(&tree, body[1]),
5935 "\"c\" is no longer the tree column"
5936 );
5937 assert!(
5938 tt_subtree_has_twist_arrow(&tree, body[2]),
5939 "the twist must follow \"a\" to its new display position"
5940 );
5941 assert!(
5942 !tt_subtree_has_twist_arrow(&tree, body[3]),
5943 "\"d\" is not the tree column"
5944 );
5945
5946 use teksilo_core::event::{Key, Modifiers};
5948 tree.focus(id);
5949 {
5950 let any = tree.widget_as_any(id).unwrap();
5951 any.downcast_ref::<TreeTableView<&'static str>>()
5952 .unwrap()
5953 .set_focused_cell(0, 2); }
5955 tree.press_key(Key::ArrowRight, Modifiers::NONE);
5956 assert_eq!(
5957 proxy.visible_count(),
5958 4,
5959 "ArrowRight on the relocated tree column must expand \"docs\""
5960 );
5961 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5962 assert_eq!(proxy.visible_count(), 2, "and ArrowLeft collapses it again");
5963 }
5964
5965 #[test]
5966 fn header_drag_from_a_different_table_is_rejected() {
5967 use crate::primitives::{FixedSize, HStack};
5973 let proxy1 = SortFilterTreeModel::new(sample_tree());
5974 let proxy2 = SortFilterTreeModel::new(sample_tree());
5975 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5976
5977 let tt1 = TreeTableView::from_projection(proxy1)
5978 .add_column(reorder_col("x", 100.0))
5979 .add_column(reorder_col("y", 100.0))
5980 .row_height(20.0);
5981 let order1 = tt1.column_order_signal().clone();
5982 let id1 = tree.add(tt1);
5983 let tt2 = TreeTableView::from_projection(proxy2)
5984 .add_column(reorder_col("x", 100.0))
5985 .add_column(reorder_col("y", 100.0))
5986 .row_height(20.0);
5987 let order2 = tt2.column_order_signal().clone();
5988 let id2 = tree.add(tt2);
5989
5990 let fixed1 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id1));
5991 let fixed2 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id2));
5992 tree.add(HStack::new().add_child(fixed1).add_child(fixed2));
5993 tree.layout(SizeProposal {
5994 width: Some(400.0),
5995 height: Some(150.0),
5996 });
5997
5998 let from = tree.bounds(tt_header_row_cells(&tree, id1)[0]).center();
6001 let to = teksilo_canvas::Point::new(250.0, from.y); drag(&mut tree, from, to);
6003
6004 assert!(order1.get().is_empty(), "tt1's own order must be untouched");
6005 assert!(
6006 order2.get().is_empty(),
6007 "tt2 must reject a drop whose payload names a different table_id"
6008 );
6009 }
6010
6011 #[test]
6012 fn header_drag_released_over_the_body_does_not_trigger_foreign_row_drop() {
6013 use std::cell::Cell;
6023 let foreign_fired = Rc::new(Cell::new(false));
6024 let flag = foreign_fired.clone();
6025 let proxy = SortFilterTreeModel::new(sample_tree());
6026 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6027 let id = tree.add(
6028 TreeTableView::from_projection(proxy)
6029 .add_column(name_col())
6030 .add_column(size_col())
6031 .on_foreign_drop(move |_payload, _node, _pos, _ctx| {
6032 flag.set(true);
6033 true
6034 })
6035 .row_height(20.0),
6036 );
6037 tree.layout(SizeProposal {
6038 width: Some(400.0),
6039 height: Some(200.0),
6040 });
6041
6042 let header = tt_header_row_cells(&tree, id);
6043 let from = tree.bounds(header[0]).center();
6044 let to = teksilo_canvas::Point::new(from.x, cp::HEADER_HEIGHT + 10.0); drag(&mut tree, from, to);
6046
6047 assert!(
6048 !foreign_fired.get(),
6049 "a column-reorder drag must never reach on_foreign_drop"
6050 );
6051 let any = tree.widget_as_any(id).unwrap();
6052 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6053 assert!(
6054 tt.column_order_signal().get().is_empty(),
6055 "no header drop occurred either — the release point was outside the header strip"
6056 );
6057 }
6058
6059 #[test]
6060 fn header_drag_insertion_is_scroll_aware() {
6061 let (mut tree, id) = build_tt_wide_unpinned_table(100.0, 4, 200.0);
6066 let max = tt_max_scroll_x(&tree, id);
6067 assert!(max > 0.0, "4×100px columns must overflow a 200px viewport");
6068 tt_set_scroll_x(&tree, id, max); tree.layout(SizeProposal {
6070 width: Some(200.0),
6071 height: Some(200.0),
6072 });
6073
6074 let header = tt_header_row_cells(&tree, id);
6080 let from = tree.bounds(header[3]).center(); let to = teksilo_canvas::Point::new(10.0, from.y);
6082 drag(&mut tree, from, to);
6083
6084 let any = tree.widget_as_any(id).unwrap();
6085 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6086 assert_eq!(
6087 tt.column_order_signal().get(),
6088 vec![
6089 "c0".to_string(),
6090 "c1".to_string(),
6091 "c3".to_string(),
6092 "c2".to_string()
6093 ],
6094 "\"c3\" must land before \"c2\" (scroll-aware), not before \"c0\""
6095 );
6096 }
6097
6098 fn tt_resize_table() -> (WidgetTree, WidgetId) {
6106 let proxy = SortFilterTreeModel::new(sample_tree());
6109 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6110 let id = tree.add(
6111 TreeTableView::from_projection(proxy)
6112 .add_column(name_col())
6113 .add_column(size_col())
6114 .row_height(20.0)
6115 .show_internal_scrollbars(false),
6116 );
6117 tree.layout(SizeProposal {
6118 width: Some(400.0),
6119 height: Some(200.0),
6120 });
6121 (tree, id)
6122 }
6123
6124 fn tt_overrides(tree: &WidgetTree, id: WidgetId) -> std::collections::HashMap<String, f32> {
6125 let any = tree.widget_as_any(id).unwrap();
6126 any.downcast_ref::<TreeTableView<&'static str>>()
6127 .unwrap()
6128 .column_widths_signal()
6129 .get()
6130 }
6131
6132 #[test]
6133 fn tt_grip_reaches_into_the_next_column() {
6134 use teksilo_canvas::Point;
6135 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6136 let (mut tree, id) = tt_resize_table();
6137 let y = cp::HEADER_HEIGHT * 0.5;
6138 tree.dispatch_event(WidgetEvent::PointerDown {
6140 position: Point::new(341.0, y),
6141 button: PointerButton::Primary,
6142 modifiers: Modifiers::NONE,
6143 });
6144 tree.dispatch_event(WidgetEvent::PointerMove {
6145 position: Point::new(311.0, y),
6146 });
6147 tree.dispatch_event(WidgetEvent::PointerUp {
6148 position: Point::new(311.0, y),
6149 button: PointerButton::Primary,
6150 modifiers: Modifiers::NONE,
6151 });
6152 let w = tt_overrides(&tree, id);
6153 assert!(
6154 (w.get("name").copied().unwrap_or(0.0) - 310.0).abs() < 0.5,
6155 "dragging the divider left from `size` must shrink `name` from 340 \
6156 to 310; got {w:?}"
6157 );
6158 }
6159
6160 #[test]
6161 fn tt_header_strip_paints_column_separators() {
6162 let (mut tree, _id) = tt_resize_table();
6163 let frame = tree.render();
6164 let found = frame.decorations.iter().any(|d| {
6165 let [x, y, w, h] = d.rect;
6166 (x - 339.0).abs() < 0.6
6167 && w <= 1.5
6168 && y.abs() < 0.6
6169 && (h - cp::HEADER_HEIGHT).abs() < 0.6
6170 });
6171 assert!(
6172 found,
6173 "expected a header separator at the name/size divider (x≈339); \
6174 decorations={:?}",
6175 frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
6176 );
6177 }
6178
6179 #[test]
6180 fn tree_column_chrome_is_clipped_to_its_column() {
6181 let (tree, id) = tt_resize_table();
6188 let mut walker = vec![id];
6191 let mut checked = false;
6192 while let Some(node) = walker.pop() {
6193 if tree.accessibility_node(node).role() == Role::Cell {
6194 let kids = tree.children(node);
6195 if let Some(&wrapper) = kids.first()
6196 && tree.widget_clips_children(wrapper)
6197 {
6198 checked = true;
6199 break;
6200 }
6201 }
6202 for c in tree.children(node) {
6203 walker.push(c);
6204 }
6205 }
6206 assert!(
6207 checked,
6208 "the tree column's indent + twist wrapper must clip its children"
6209 );
6210 }
6211
6212 fn editable_name_col() -> Column<&'static str> {
6215 Column::<&str>::new("name", lit!("Name"), |row, cx: &CellContext| {
6216 if cx.is_editing {
6217 Box::new(crate::text_input::TextInput::new(Signal::new(
6218 (*row).to_string(),
6219 )))
6220 } else {
6221 Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6222 }
6223 })
6224 .width(ColumnWidth::Flex(1.0))
6225 .editable(true)
6226 }
6227
6228 fn three_row_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
6229 let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
6230 slice.set_source(move || {
6231 [(1_u64, "one"), (2, "two"), (3, "three")]
6232 .into_iter()
6233 .map(|(k, n)| teksilo_data::TreeRow::new(k, n, 0))
6234 .collect()
6235 });
6236 slice.reload();
6237 slice
6238 }
6239
6240 fn double_click_at(tree: &mut WidgetTree, at: Point) {
6243 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6244 for _ in 0..2 {
6245 tree.dispatch_event(WidgetEvent::PointerDown {
6246 position: at,
6247 button: PointerButton::Primary,
6248 modifiers: Modifiers::NONE,
6249 });
6250 tree.dispatch_event(WidgetEvent::PointerUp {
6251 position: at,
6252 button: PointerButton::Primary,
6253 modifiers: Modifiers::NONE,
6254 });
6255 }
6256 }
6257
6258 #[test]
6267 fn opening_a_cell_editor_moves_the_keyboard_into_it() {
6268 let slice = three_row_slice();
6269 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6270 let id = tree.add(
6271 TreeTableView::from_source(slice)
6272 .add_column(editable_name_col())
6273 .row_height(20.0),
6274 );
6275 let proposal = SizeProposal {
6276 width: Some(400.0),
6277 height: Some(200.0),
6278 };
6279 tree.layout(proposal);
6280 tree.focus(id);
6281
6282 {
6283 let any = tree.widget_as_any(id).unwrap();
6284 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6285 tt.begin_edit(1, "name");
6286 }
6287 tree.layout(proposal);
6288
6289 let focused = tree.focused().expect("something must hold focus");
6290 assert_ne!(
6291 focused, id,
6292 "focus is still on the table, not in the editor"
6293 );
6294 let cell = {
6295 let any = tree.widget_as_any(id).unwrap();
6296 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6297 tt.realized_cell(1, 0).expect("the edited cell is realized")
6298 };
6299 assert!(
6300 tree.is_descendant_of(focused, cell),
6301 "focus must land inside the edited cell, not on {:?}",
6302 tree.widget_type_name(focused)
6303 );
6304 }
6305
6306 #[test]
6315 fn an_open_editor_still_holds_the_keyboard_after_the_pane_rebuilds() {
6316 let slice = three_row_slice();
6317 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6318 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6319 let id = tree.add(
6320 TreeTableView::from_source(slice)
6321 .selection(selection.clone())
6322 .add_column(editable_name_col())
6323 .row_height(20.0),
6324 );
6325 let proposal = SizeProposal {
6326 width: Some(400.0),
6327 height: Some(200.0),
6328 };
6329 tree.layout(proposal);
6330 {
6331 let any = tree.widget_as_any(id).unwrap();
6332 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6333 tt.begin_edit(1, "name");
6334 }
6335 tree.layout(proposal);
6336 tree.focused().expect("the editor took focus");
6337
6338 selection.select(2);
6339 tree.layout(proposal);
6340
6341 let focused = tree.focused().expect("focus survived the rebuild");
6342 assert_ne!(focused, id, "the rebuild dropped focus back onto the table");
6343 let cell = {
6344 let any = tree.widget_as_any(id).unwrap();
6345 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6346 tt.realized_cell(1, 0)
6347 .expect("the edited cell is still realized")
6348 };
6349 assert!(
6350 tree.is_descendant_of(focused, cell),
6351 "focus must still be inside the edited cell, not on {:?}",
6352 tree.widget_type_name(focused)
6353 );
6354 }
6355
6356 #[test]
6362 fn a_double_click_on_an_editable_cell_opens_its_editor() {
6363 let (mut tree, id, seen, _) = click_probe(EditTriggers::DOUBLE_CLICK);
6364 let cell = realized(&tree, id, 1, 0);
6365 let at = tree.bounds(cell).center();
6366 double_click_at(&mut tree, at);
6367
6368 assert_eq!(
6369 seen.borrow().as_slice(),
6370 &[(1, "name".to_string())],
6371 "a double-click on an editable cell must request its editor"
6372 );
6373 let any = tree.widget_as_any(id).unwrap();
6374 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6375 assert_eq!(tt.editing_cell_signal().get(), Some((1, 0)));
6376 }
6377
6378 #[test]
6381 fn a_single_click_opens_the_editor_when_the_column_asks_for_it() {
6382 let (mut tree, id, seen, _) = click_probe(EditTriggers::SINGLE_CLICK);
6383 let cell = realized(&tree, id, 1, 0);
6384 tree.click(cell);
6385
6386 assert_eq!(
6387 seen.borrow().as_slice(),
6388 &[(1, "name".to_string())],
6389 "one click on a SINGLE_CLICK column must request its editor"
6390 );
6391 }
6392
6393 #[test]
6397 fn a_click_opens_nothing_when_the_column_asks_for_no_click_trigger() {
6398 let (mut tree, id, seen, _) = click_probe(EditTriggers::F2);
6399 let cell = realized(&tree, id, 1, 0);
6400 tree.click(cell);
6401 let at = tree.bounds(cell).center();
6402 double_click_at(&mut tree, at);
6403
6404 assert!(
6405 seen.borrow().is_empty(),
6406 "an F2-only column opened an editor from a click: {:?}",
6407 seen.borrow()
6408 );
6409 }
6410
6411 #[test]
6426 fn editing_a_cell_by_double_click_does_not_also_activate_the_row() {
6427 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6428 let cell = realized(&tree, id, 1, 0);
6429 let at = tree.bounds(cell).center();
6430 double_click_at(&mut tree, at);
6431 assert_eq!(
6432 activated.get(),
6433 0,
6434 "double-clicking an editable cell opened the item as well as the editor"
6435 );
6436 }
6437
6438 #[test]
6442 fn a_double_click_off_an_editable_cell_still_activates_the_row() {
6443 let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6444 let cell = realized(&tree, id, 1, 1);
6445 let at = tree.bounds(cell).center();
6446 double_click_at(&mut tree, at);
6447 assert_eq!(
6448 activated.get(),
6449 1,
6450 "a double-click away from an editable cell must still activate the row"
6451 );
6452 }
6453
6454 #[test]
6463 fn a_double_click_editable_cell_still_lets_its_row_select_on_one_click() {
6464 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6465 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6466 let id = tree.add(
6467 TreeTableView::from_source(three_row_slice())
6468 .selection(selection.clone())
6469 .add_column(editable_name_col().edit_triggers(EditTriggers::DOUBLE_CLICK))
6470 .row_height(20.0)
6471 .on_cell_edit_request(|_row, _col, _ctx| {}),
6472 );
6473 tree.layout(SizeProposal {
6474 width: Some(400.0),
6475 height: Some(200.0),
6476 });
6477
6478 let cell = realized(&tree, id, 1, 0);
6479 tree.click(cell);
6480 assert!(
6481 selection.is_selected(1),
6482 "one click on a double-click-editable cell must still select its row"
6483 );
6484 }
6485
6486 #[test]
6490 fn a_single_click_editable_cell_claims_the_press_from_row_selection() {
6491 let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6492 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6493 let id = tree.add(
6494 TreeTableView::from_source(three_row_slice())
6495 .selection(selection.clone())
6496 .add_column(editable_name_col().edit_triggers(EditTriggers::SINGLE_CLICK))
6497 .add_column(size_col())
6498 .row_height(20.0)
6499 .on_cell_edit_request(|_row, _col, _ctx| {}),
6500 );
6501 tree.layout(SizeProposal {
6502 width: Some(400.0),
6503 height: Some(200.0),
6504 });
6505
6506 let editable = realized(&tree, id, 1, 0);
6507 tree.click(editable);
6508 assert!(
6509 !selection.is_selected(1),
6510 "a SINGLE_CLICK cell's click must go to the editor, not to selection"
6511 );
6512
6513 let plain = realized(&tree, id, 2, 1);
6516 tree.click(plain);
6517 assert!(
6518 selection.is_selected(2),
6519 "a click on a non-editing column must still select its row"
6520 );
6521 }
6522
6523 fn realized(tree: &WidgetTree, id: WidgetId, row: usize, col: usize) -> WidgetId {
6525 let any = tree.widget_as_any(id).unwrap();
6526 let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6527 tt.realized_cell(row, col)
6528 .unwrap_or_else(|| panic!("cell ({row}, {col}) is not realized"))
6529 }
6530
6531 #[allow(clippy::type_complexity)]
6535 fn click_probe(
6536 triggers: EditTriggers,
6537 ) -> (
6538 WidgetTree,
6539 WidgetId,
6540 Rc<RefCell<Vec<(usize, String)>>>,
6541 Rc<Cell<usize>>,
6542 ) {
6543 let seen: Rc<RefCell<Vec<(usize, String)>>> = Rc::new(RefCell::new(Vec::new()));
6544 let sink = seen.clone();
6545 let activated: Rc<Cell<usize>> = Rc::new(Cell::new(0));
6546 let counter = activated.clone();
6547 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6548 let id = tree.add(
6549 TreeTableView::from_source(three_row_slice())
6550 .add_column(editable_name_col().edit_triggers(triggers))
6551 .add_column(size_col())
6552 .row_height(20.0)
6553 .on_cell_edit_request(move |row, col, _ctx| {
6554 sink.borrow_mut().push((row, col.to_string()));
6555 })
6556 .on_row_activate(move |_row, _ctx| counter.set(counter.get() + 1)),
6557 );
6558 tree.layout(SizeProposal {
6559 width: Some(400.0),
6560 height: Some(200.0),
6561 });
6562 (tree, id, seen, activated)
6563 }
6564}