1use std::cell::{Cell, RefCell};
63use std::rc::Rc;
64use std::time::Duration;
65
66use teksilo_canvas::{Point, Rect, Size, SizeProposal};
67use teksilo_tokens::{BorderRole, Easing};
68
69use teksilo_core::DropFeedback;
70use teksilo_core::accessibility::AccessNodeBuilder;
71use teksilo_core::binding::BindingLevel;
72use teksilo_core::drag_payload::DragPayload;
73use teksilo_core::signal::{Prop, Signal};
74use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
75use teksilo_core::widget_builder::HandlerSet;
76use teksilo_core::widget_id::WidgetId;
77
78use teksilo_data::selection_model::SelectionModel;
79use teksilo_data::{ItemKey, KeyedSelectionModel};
80
81use crate::data_views::RowSelection;
82use teksilo_data::{DataChange, DropPosition, DropResponse, ListModel};
83
84use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
85use crate::common::scroll::OverscrollBehavior;
86use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
87use crate::list_source::ListSource;
88use crate::scroll_area::ScrollBarMode;
89use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
90
91mod body_pane;
92
93const BUFFER_ITEMS: usize = 5;
95const DEFAULT_ITEM_HEIGHT: f32 = 32.0;
97const SCROLLBAR_THICKNESS: f32 = 12.0;
99
100pub struct ListView<T: 'static> {
104 source: ListSource<T>,
105 delegate: Rc<dyn Fn(usize, &T, bool) -> Box<dyn Widget>>,
106 row_tooltips: crate::data_views::RowTooltips<T>,
109 item_height: f32,
110 spacing: f32,
111 height_source: HeightSource,
113 metrics: SharedRowMetrics,
118 row_selection: Option<RowSelection>,
121
122 focused_index: Rc<Cell<Option<usize>>>,
124
125 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
132
133 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
139 type_ahead_timeout: Duration,
141 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
145
146 reorderable: bool,
148
149 show_scrollbar: bool,
155
156 scroll_y: Signal<f32>,
158 max_scroll_y: Signal<f32>,
159 overscroll_behavior: OverscrollBehavior,
161 viewport_ratio_y: Signal<f32>,
162
163 smooth_scrolling: bool,
166 smooth_scroll_duration: Duration,
168
169 scroll_bar_style: ScrollBarMode,
172
173 drop_feedback: Signal<Option<(f32, f32)>>, placed_content_width: Rc<Cell<f32>>,
179
180 on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
184 activate_on: crate::data_views::ActivateOn,
186
187 view_focused: Signal<bool>,
192 focus_visible: Signal<bool>,
195
196 layout_refresh: Signal<u64>,
203 paint_refresh: Signal<u64>,
208
209 pane_version: Signal<u64>,
214 pane_built_start: Rc<Cell<usize>>,
216 pane_built_end: Rc<Cell<usize>>,
217
218 body_pane_id: Option<WidgetId>,
220 scrollbar_id: Option<WidgetId>,
221 viewport_height: Rc<Cell<f32>>,
226 viewport_bounds: Rc<Cell<Rect>>,
234
235 model_id: ViewId,
238
239 export: crate::data_views::RowExport<T>,
244
245 enabled: Prop<bool>,
251}
252
253impl<T: 'static> ListView<T> {
254 pub fn new(
259 model: ListModel<T>,
260 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
261 ) -> Self {
262 Self::create(ListSource::from_model(model), delegate)
263 }
264
265 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
270 source: S,
271 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
272 ) -> Self {
273 Self::create(ListSource::from_data_source(source), delegate)
274 }
275
276 pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
284 source: S,
285 keyed: KeyedSelectionModel<S::Key>,
286 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
287 ) -> Self
288 where
289 S::Key: ItemKey,
290 {
291 let s = Rc::new(source);
292 let key_at = {
293 let s = s.clone();
294 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
295 };
296 let len = {
297 let s = s.clone();
298 Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
299 };
300 let contains = {
303 let s = s.clone();
304 Rc::new(move |k: &S::Key| (0..s.len()).any(|i| s.key_at(i).as_ref() == Some(k)))
305 as Rc<dyn Fn(&S::Key) -> bool>
306 };
307 let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
308 let mut view = Self::create(ListSource::from_data_source_rc(s), delegate);
309 view.row_selection = Some(row_selection);
310 view
311 }
312
313 pub(crate) fn from_list_source(
318 source: ListSource<T>,
319 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
320 ) -> Self {
321 Self::create(source, delegate)
322 }
323
324 fn create(
325 source: ListSource<T>,
326 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
327 ) -> Self {
328 let model_id = ViewId::next(ViewKind::List);
329 Self {
330 model_id,
331 export: crate::data_views::RowExport::default(),
332 source,
333 delegate: Rc::new(delegate),
334 row_tooltips: Default::default(),
335 item_height: DEFAULT_ITEM_HEIGHT,
336 spacing: 0.0,
337 height_source: HeightSource::Uniform,
338 metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
339 row_selection: None,
340 focused_index: Rc::new(Cell::new(None)),
341 row_map: Rc::new(RefCell::new(Vec::new())),
342 type_ahead_label: None,
343 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
344 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
345 reorderable: false,
346 show_scrollbar: true,
347 drop_feedback: Signal::new(None),
348 view_focused: Signal::new(false),
350 focus_visible: Signal::new(false),
351 placed_content_width: Rc::new(Cell::new(0.0)),
352 on_activate: None,
353 activate_on: crate::data_views::ActivateOn::default(),
354 overscroll_behavior: OverscrollBehavior::default(),
355 smooth_scrolling: true,
356 smooth_scroll_duration: Duration::from_millis(150),
357 scroll_bar_style: ScrollBarMode::Permanent,
358 scroll_y: Signal::new_animated(0.0),
359 max_scroll_y: Signal::new(0.0),
360 viewport_ratio_y: Signal::new(1.0),
361 layout_refresh: Signal::new(0_u64),
362 paint_refresh: Signal::new(0_u64),
363 pane_version: Signal::new(0_u64),
364 pane_built_start: Rc::new(Cell::new(0)),
365 pane_built_end: Rc::new(Cell::new(0)),
366 body_pane_id: None,
367 scrollbar_id: None,
368 viewport_height: Rc::new(Cell::new(600.0)),
369 viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
370 enabled: Prop::Static(true),
371 }
372 }
373
374 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
377 self.enabled = enabled.into();
378 self
379 }
380
381 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
385 self.overscroll_behavior = behavior;
386 self
387 }
388
389 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
391 self.smooth_scrolling = enabled;
392 self
393 }
394
395 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
397 self.smooth_scroll_duration = duration;
398 self
399 }
400
401 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
405 self.scroll_bar_style = style;
406 self
407 }
408
409 fn remake_metrics(&self) {
412 *self.metrics.borrow_mut() = self
413 .height_source
414 .make_metrics(self.item_height, self.spacing);
415 }
416
417 pub fn item_height(mut self, height: f32) -> Self {
422 self.item_height = height;
423 self.height_source = HeightSource::Uniform;
424 self.remake_metrics();
425 self
426 }
427
428 pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
433 self.height_source = HeightSource::Exact(Rc::new(f));
434 self.remake_metrics();
435 self
436 }
437
438 pub fn auto_item_height(mut self, estimated: f32) -> Self {
445 self.height_source = HeightSource::Auto { estimated };
446 self.remake_metrics();
447 self
448 }
449
450 pub fn spacing(mut self, spacing: f32) -> Self {
452 self.spacing = spacing;
453 self.remake_metrics();
454 self
455 }
456
457 pub fn selection(mut self, sel: SelectionModel) -> Self {
461 self.row_selection = Some(RowSelection::from_index(sel));
462 self
463 }
464
465 pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>> {
483 self.row_map.clone()
484 }
485
486 pub fn reorderable(mut self, enabled: bool) -> Self {
495 self.reorderable = enabled;
496 self
497 }
498
499 pub fn exportable(mut self, mode: DragTransferMode) -> Self
527 where
528 T: Clone,
529 {
530 self.export.set_exportable(mode);
531 self
532 }
533
534 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
542 where
543 T: Clone,
544 {
545 self.export.set_export_external(f);
546 self
547 }
548
549 pub fn on_rows_transferred_out(
555 mut self,
556 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
557 ) -> Self {
558 self.export.set_on_rows_transferred_out(f);
559 self
560 }
561
562 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
569 self.export.accept_foreign_rows = accept;
570 self
571 }
572
573 pub fn on_rows_received(
577 mut self,
578 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
579 ) -> Self {
580 self.export.set_on_rows_received(f);
581 self
582 }
583
584 pub fn on_activate(
593 mut self,
594 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
595 ) -> Self {
596 self.on_activate = Some(Rc::new(f));
597 self
598 }
599
600 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
604 self.activate_on = mode;
605 self
606 }
607
608 pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
623 self.row_tooltips.set_composite_sticky(on);
624 self
625 }
626
627 pub fn row_tooltip(
638 mut self,
639 f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
640 ) -> Self {
641 self.row_tooltips.set_plain(f);
642 self
643 }
644
645 pub fn row_rich_tooltip(
649 mut self,
650 f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
651 ) -> Self {
652 self.row_tooltips.set_rich(f);
653 self
654 }
655
656 pub fn row_composite_tooltip(
663 mut self,
664 f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
665 ) -> Self {
666 self.row_tooltips.set_composite(f);
667 self
668 }
669
670 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
671 self.type_ahead_label = Some(Rc::new(label));
672 self
673 }
674
675 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
678 self.type_ahead_timeout = timeout;
679 self
680 }
681
682 pub fn show_scrollbar(mut self, show: bool) -> Self {
691 self.show_scrollbar = show;
692 self
693 }
694
695 fn total_content_height(&self) -> f32 {
697 self.metrics.borrow_mut().total_height(self.source.len())
698 }
699
700 fn visible_range(&self) -> (usize, usize) {
702 self.metrics.borrow_mut().visible_range(
703 self.scroll_y.get(),
704 self.viewport_height.get(),
705 self.source.len(),
706 BUFFER_ITEMS,
707 )
708 }
709
710 fn child_ids(&self) -> Vec<WidgetId> {
715 [self.body_pane_id, self.scrollbar_id]
716 .into_iter()
717 .flatten()
718 .collect()
719 }
720
721 fn clamp_scroll(&self) {
723 let max = self.max_scroll_y.get();
724 let current = self.scroll_y.get();
725 let clamped = current.clamp(0.0, max);
726 if (clamped - current).abs() > 0.001 {
727 self.scroll_y.set(clamped);
728 }
729 }
730
731 #[cfg(test)]
734 pub(crate) fn drop_feedback_signal(&self) -> &Signal<Option<(f32, f32)>> {
735 &self.drop_feedback
736 }
737
738 pub fn scroll_y_signal(&self) -> &Signal<f32> {
745 &self.scroll_y
746 }
747
748 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
752 &self.max_scroll_y
753 }
754
755 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
758 &self.viewport_ratio_y
759 }
760
761 pub fn scroll_to_index(&self, index: usize) {
766 let target = self.metrics.borrow_mut().row_top(index);
767 let max = self.max_scroll_y.get();
768 self.scroll_y.set(target.clamp(0.0, max));
769 }
770
771 pub fn ensure_index_visible(&self, index: usize) {
774 let scroll = self.scroll_y.get();
775 let new_scroll = self.metrics.borrow_mut().scroll_for_ensure_visible(
776 index,
777 scroll,
778 self.viewport_height.get(),
779 self.max_scroll_y.get(),
780 );
781 if (new_scroll - scroll).abs() > f32::EPSILON {
782 self.scroll_y.set(new_scroll);
783 }
784 }
785}
786
787impl<T: 'static> std::fmt::Debug for ListView<T> {
788 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 f.debug_struct("ListView")
790 .field("item_count", &self.source.len())
791 .field("item_height", &self.item_height)
792 .field("scroll_bar_style", &self.scroll_bar_style)
793 .field("scroll_y", &self.scroll_y.get())
794 .finish()
795 }
796}
797
798impl<T: 'static> Widget for ListView<T> {
799 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
800 let self_id = ctx.self_id();
807 ctx.enabled_when(self_id, self.enabled.clone());
808
809 self.layout_refresh.bind_to(
813 ctx.self_id(),
814 ctx.binding_registry(),
815 BindingLevel::Relayout,
816 );
817 self.paint_refresh.bind_to(
821 ctx.self_id(),
822 ctx.binding_registry(),
823 BindingLevel::RepaintOnly,
824 );
825
826 self.scroll_y.bind_to(
830 ctx.self_id(),
831 ctx.binding_registry(),
832 BindingLevel::Relayout,
833 );
834
835 ctx.register_animated_signal(&self.scroll_y);
841
842 self.drop_feedback.bind_to(
846 ctx.self_id(),
847 ctx.binding_registry(),
848 BindingLevel::RepaintOnly,
849 );
850
851 self.view_focused = ctx.begin_view_focus();
861 ctx.end_view_focus();
862 self.focus_visible = ctx.focus_visible();
863 self.view_focused.bind_to(
864 ctx.self_id(),
865 ctx.binding_registry(),
866 BindingLevel::RepaintOnly,
867 );
868 self.focus_visible.bind_to(
869 ctx.self_id(),
870 ctx.binding_registry(),
871 BindingLevel::RepaintOnly,
872 );
873
874 let pane_version_for_data = self.pane_version.clone();
880 let layout_refresh_for_data = self.layout_refresh.clone();
881 let data_ver = Rc::new(Cell::new(0_u64));
882 let data_handle = (self.source.observe_fn)(Box::new({
883 let dv = data_ver.clone();
884 let metrics = self.metrics.clone();
885 let len_fn = self.source.len_fn.clone();
886 let first_changed = self.source.first_changed_fn.clone();
887 let row_sel = self.row_selection.clone();
888 let focused = self.focused_index.clone();
889 move |change| {
890 let divergence = match change {
894 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
895 Some(range.start)
896 }
897 DataChange::ItemUpdated { index } => Some(*index),
898 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
899 DataChange::WindowLoaded { range } => Some(range.start),
901 DataChange::Reset => (first_changed)(),
904 };
905 metrics
906 .borrow_mut()
907 .apply_divergence(divergence, (len_fn)());
908 if let Some(ref rs) = row_sel {
911 rs.on_data_change(change);
912 }
913 if let Some(current) = focused.get() {
918 focused.set(teksilo_data::data_change::adjust_single_index_for_change(
919 current, change,
920 ));
921 }
922 let next = dv.get() + 1;
923 dv.set(next);
924 pane_version_for_data.set(next);
925 layout_refresh_for_data.set(next);
926 }
927 }));
928 ctx.own_handle(data_handle);
929
930 if let Some(ref rs) = self.row_selection {
935 let paint_refresh_for_sel = self.paint_refresh.clone();
936 let sel_ver = Rc::new(Cell::new(0_u64));
937 let handle = rs.observe_for_rebuild(move || {
938 let next = sel_ver.get() + 1;
939 sel_ver.set(next);
940 paint_refresh_for_sel.set(next);
941 });
942 ctx.own_handle(handle);
943 }
944
945 let scroll_y = self.scroll_y.clone();
952 let max_scroll = self.max_scroll_y.clone();
953 let line_height = self.item_height;
954 let overscroll_behavior = self.overscroll_behavior;
955 let smooth_scrolling = self.smooth_scrolling;
956 let smooth_scroll_duration = self.smooth_scroll_duration;
957 let mut handlers = HandlerSet::new()
958 .on_scroll(move |event, _ctx| match event {
959 teksilo_core::event::WidgetEvent::Scroll { delta, .. } => {
960 let dy = match delta {
961 teksilo_core::event::ScrollDelta::Lines { y, .. } => y * line_height,
962 teksilo_core::event::ScrollDelta::Pixels { y, .. } => *y,
963 };
964 let current = scroll_y.get();
965 let max = max_scroll.get();
966 let base = scroll_y.animation_target().unwrap_or(current);
969 let (new_y, moved) = crate::common::scroll::scroll_clamp_axis(base, dy, max);
970 if moved {
971 if smooth_scrolling {
972 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
973 } else {
974 scroll_y.set(new_y);
975 }
976 }
977 crate::common::scroll::scroll_response(
980 moved,
981 overscroll_behavior == OverscrollBehavior::Contain,
982 )
983 }
984 _ => teksilo_core::event::EventResponse::Ignored,
985 })
986 .clips_children(true)
987 .focusable(true);
988
989 {
991 let len_for_key = self.source.len_fn.clone();
992 let accept_drop_for_key = self.source.dnd.accept_drop_fn.clone();
993 let stash_for_key = self.source.dnd.stash_drag_keys_fn.clone();
994 let view_id_for_key = self.model_id;
995 let sel_for_key = self.row_selection.clone();
996 let activate_key = self.on_activate.clone();
997 let fi = self.focused_index.clone();
998 let reorderable = self.reorderable;
999 let scroll_for_nav = self.scroll_y.clone();
1000 let metrics_for_nav = self.metrics.clone();
1001 let max_for_nav = self.max_scroll_y.clone();
1002 let vh_for_nav = self.viewport_height.clone();
1003 let vb_for_nav = self.viewport_bounds.clone();
1004 let ta_state = self.type_ahead.clone();
1007 let ta_label = self.type_ahead_label.clone();
1008 let ta_timeout = self.type_ahead_timeout;
1009 let with_item_str = self.source.with_item_str_fn.clone();
1010
1011 handlers = handlers.on_key(move |event, ctx| {
1012 if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
1013 use teksilo_core::event::Key;
1014 let count = (len_for_key)();
1015 if count == 0 {
1016 return teksilo_core::event::EventResponse::Ignored;
1017 }
1018
1019 if modifiers.command() && matches!(key, Key::A) {
1022 if let Some(ref sel) = sel_for_key
1023 && sel.mode() == teksilo_data::SelectionMode::Multi
1024 {
1025 sel.select_all(count);
1026 return teksilo_core::event::EventResponse::Handled;
1027 }
1028 return teksilo_core::event::EventResponse::Ignored;
1029 }
1030
1031 if ta_label.is_some()
1035 && !modifiers.ctrl()
1036 && !modifiers.alt()
1037 && !modifiers.super_key()
1038 && let Some(c) = key.to_char()
1039 {
1040 let current = fi.get().unwrap_or(0).min(count - 1);
1041 let label = ta_label.as_ref().unwrap();
1042 if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
1043 (with_item_str)(i, &|item| label(item))
1044 }) {
1045 fi.set(Some(idx));
1046 if let Some(ref sel) = sel_for_key {
1047 sel.select(idx);
1048 }
1049 let scroll = scroll_for_nav.get();
1050 let new_scroll =
1051 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1052 idx,
1053 scroll,
1054 vh_for_nav.get(),
1055 max_for_nav.get(),
1056 );
1057 if (new_scroll - scroll).abs() > f32::EPSILON {
1058 scroll_for_nav.set(new_scroll);
1059 }
1060 crate::common::row_metrics::chase_row_into_outer_view(
1061 ctx,
1062 &metrics_for_nav,
1063 vb_for_nav.get(),
1064 idx,
1065 new_scroll,
1066 );
1067 return teksilo_core::event::EventResponse::Handled;
1068 }
1069 return teksilo_core::event::EventResponse::Ignored;
1070 }
1071
1072 if modifiers.alt() && reorderable {
1077 let selected_idx = sel_for_key
1078 .as_ref()
1079 .and_then(|s| s.selected_indices().first().copied());
1080 if let Some(idx) = selected_idx {
1081 let mv = match key {
1082 teksilo_core::event::Key::ArrowUp if idx > 0 => {
1083 Some((idx - 1, DropPosition::Before, idx - 1))
1084 }
1085 teksilo_core::event::Key::ArrowDown if idx + 1 < count => {
1086 Some((idx + 1, DropPosition::After, idx + 1))
1087 }
1088 _ => None,
1089 };
1090 if let Some((target, position, dest)) = mv {
1091 (stash_for_key)(&[idx]);
1096 let payload = DragPayload::typed(RowDragData::<T> {
1097 source: view_id_for_key,
1098 rows: vec![idx],
1099 items: None,
1100 });
1101 if (accept_drop_for_key)(
1102 &payload,
1103 target,
1104 position,
1105 view_id_for_key,
1106 ) {
1107 if let Some(ref sel) = sel_for_key {
1108 sel.select(dest);
1109 }
1110 fi.set(Some(dest));
1111 let scroll = scroll_for_nav.get();
1114 let new_scroll =
1115 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1116 dest,
1117 scroll,
1118 vh_for_nav.get(),
1119 max_for_nav.get(),
1120 );
1121 if (new_scroll - scroll).abs() > f32::EPSILON {
1122 scroll_for_nav.set(new_scroll);
1123 }
1124 crate::common::row_metrics::chase_row_into_outer_view(
1125 ctx,
1126 &metrics_for_nav,
1127 vb_for_nav.get(),
1128 dest,
1129 new_scroll,
1130 );
1131 }
1132 return teksilo_core::event::EventResponse::Handled;
1133 }
1134 }
1135 }
1136
1137 let cursor = fi
1153 .get()
1154 .or_else(|| {
1155 sel_for_key
1156 .as_ref()
1157 .and_then(|s| s.selected_indices().first().copied())
1158 })
1159 .map(|i| i.min(count - 1));
1160 let current = cursor.unwrap_or(0);
1163 let new_idx = match key {
1164 Key::ArrowDown => Some(match cursor {
1165 None => 0,
1166 Some(c) => (c + 1).min(count - 1),
1167 }),
1168 Key::ArrowUp => Some(match cursor {
1169 None => count - 1,
1170 Some(c) => c.saturating_sub(1),
1171 }),
1172 Key::Home => Some(0),
1173 Key::End => Some(count - 1),
1174 Key::PageDown => {
1178 let vh = vh_for_nav.get();
1179 let r = {
1180 let mut m = metrics_for_nav.borrow_mut();
1181 m.resize(count);
1182 let target = m.row_top(current) + vh;
1183 m.row_at(target)
1184 };
1185 Some(if r == current {
1186 (current + 1).min(count - 1)
1187 } else {
1188 r.min(count - 1)
1189 })
1190 }
1191 Key::PageUp => {
1192 let vh = vh_for_nav.get();
1193 let r = {
1194 let mut m = metrics_for_nav.borrow_mut();
1195 m.resize(count);
1196 let target = (m.row_top(current) - vh).max(0.0);
1197 m.row_at(target)
1198 };
1199 Some(if r == current {
1200 current.saturating_sub(1)
1201 } else {
1202 r
1203 })
1204 }
1205 Key::Enter => {
1206 if let Some(ref sel) = sel_for_key {
1208 sel.select(current);
1209 }
1210 if let Some(ref cb) = activate_key {
1211 cb(current, ctx);
1212 }
1213 return teksilo_core::event::EventResponse::Handled;
1214 }
1215 Key::Space if modifiers.ctrl() => {
1216 if let Some(ref sel) = sel_for_key {
1233 sel.toggle(current);
1234 }
1235 fi.set(Some(current));
1236 return teksilo_core::event::EventResponse::Handled;
1237 }
1238 Key::Space => {
1239 if let Some(ref sel) = sel_for_key {
1244 if sel.mode() == teksilo_data::SelectionMode::Multi {
1245 sel.toggle(current);
1246 } else {
1247 sel.select(current);
1248 }
1249 }
1250 fi.set(Some(current));
1251 return teksilo_core::event::EventResponse::Handled;
1252 }
1253 _ => None,
1254 };
1255
1256 if let Some(idx) = new_idx {
1257 fi.set(Some(idx));
1258 let cursor_only = modifiers.ctrl()
1268 && !modifiers.shift()
1269 && matches!(key, Key::ArrowUp | Key::ArrowDown);
1270 if !cursor_only && let Some(ref sel) = sel_for_key {
1271 if modifiers.shift() {
1272 sel.extend_to(idx);
1273 } else {
1274 sel.select(idx);
1275 }
1276 }
1277 let scroll = scroll_for_nav.get();
1280 let new_scroll = metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1281 idx,
1282 scroll,
1283 vh_for_nav.get(),
1284 max_for_nav.get(),
1285 );
1286 if (new_scroll - scroll).abs() > f32::EPSILON {
1287 scroll_for_nav.set(new_scroll);
1288 }
1289 crate::common::row_metrics::chase_row_into_outer_view(
1290 ctx,
1291 &metrics_for_nav,
1292 vb_for_nav.get(),
1293 idx,
1294 new_scroll,
1295 );
1296 return teksilo_core::event::EventResponse::Handled;
1297 }
1298 }
1299 teksilo_core::event::EventResponse::Ignored
1300 });
1301 }
1302
1303 if self.export.is_drop_target(self.reorderable) {
1307 let metrics_for_hover = self.metrics.clone();
1308 let scroll_for_hover = self.scroll_y.clone();
1309 let len_for_hover = self.source.len_fn.clone();
1310 let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
1311 let my_view_id = self.model_id;
1312
1313 let feedback_for_hover = self.drop_feedback.clone();
1314 let width_for_hover = self.placed_content_width.clone();
1315 let export_for_hover = self.export.clone();
1316 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1317 let scroll = scroll_for_hover.get().max(0.0);
1318 let content_y = position.y + scroll;
1319 let len = (len_for_hover)();
1320 let (insertion_y, ins) = {
1321 let mut m = metrics_for_hover.borrow_mut();
1322 m.resize(len);
1323 let ins = m.insertion_index(content_y);
1324 (m.row_top(ins) - scroll, ins)
1325 };
1326 let line_width = width_for_hover.get();
1327 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1332 !matches!(
1333 (can_accept_for_hover)(payload, target, pos, my_view_id),
1334 DropResponse::Reject
1335 ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
1336 });
1337 if allowed {
1338 feedback_for_hover.set(Some((insertion_y, line_width)));
1339 DropFeedback::InsertionLine {
1340 y: insertion_y,
1341 width: line_width,
1342 }
1343 } else {
1344 feedback_for_hover.set(None);
1345 DropFeedback::NoFeedback
1346 }
1347 });
1348
1349 let len_for_drop = self.source.len_fn.clone();
1350 let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
1351 let drop_view_id = self.model_id;
1352 let scroll_for_drop = self.scroll_y.clone();
1353 let metrics_for_drop = self.metrics.clone();
1354 let export_for_drop = self.export.clone();
1355 let reorderable_for_drop = self.reorderable;
1356
1357 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1358 let scroll = scroll_for_drop.get().max(0.0);
1359 let content_y = position.y + scroll;
1360 let len = (len_for_drop)();
1361 let ins = {
1362 let mut m = metrics_for_drop.borrow_mut();
1363 m.resize(len);
1364 m.insertion_index(content_y)
1365 };
1366 let is_same_view = payload
1367 .get_typed::<RowDragData<T>>()
1368 .is_some_and(|rd| rd.source == drop_view_id);
1369 if (reorderable_for_drop || !is_same_view)
1373 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
1374 && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
1375 {
1376 if is_same_view {
1377 export_for_drop.note_self_reorder();
1378 }
1379 return true;
1380 }
1381 export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
1383 });
1384
1385 let feedback_for_leave = self.drop_feedback.clone();
1389 handlers = handlers.on_drag_leave(move |_ctx| {
1390 feedback_for_leave.set(None);
1391 });
1392
1393 let scroll_for_tick = self.scroll_y.clone();
1399 let max_scroll_for_tick = self.max_scroll_y.clone();
1400 let viewport_for_tick = self.viewport_height.clone();
1401 handlers = handlers.on_drag_tick(move |pos, _ctx| {
1402 const EDGE: f32 = 32.0;
1403 const MAX_VELOCITY: f32 = 12.0;
1404 let h = viewport_for_tick.get();
1405 let above = (EDGE - pos.y).max(0.0);
1406 let below = (pos.y - (h - EDGE)).max(0.0);
1407 let delta = if above > 0.0 {
1408 -(above / EDGE) * MAX_VELOCITY
1409 } else if below > 0.0 {
1410 (below / EDGE) * MAX_VELOCITY
1411 } else {
1412 0.0
1413 };
1414 if delta.abs() > 0.01 {
1415 let max = max_scroll_for_tick.get();
1416 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
1417 scroll_for_tick.set(new_y);
1418 }
1419 });
1420 }
1421
1422 handlers = self.export.install_completion(handlers);
1425
1426 ctx.apply_self_handlers(handlers);
1427
1428 let pane = body_pane::ListBodyPane::<T> {
1436 source: self.source.clone(),
1437 delegate: self.delegate.clone(),
1438 row_tooltips: self.row_tooltips.clone(),
1439 metrics: self.metrics.clone(),
1440 row_selection: self.row_selection.clone(),
1441 focused_index: self.focused_index.clone(),
1442 row_map: self.row_map.clone(),
1443 reorderable: self.reorderable,
1444 export: self.export.clone(),
1445 on_activate: self.on_activate.clone(),
1446 activate_on: self.activate_on,
1447 model_id: self.model_id,
1448 root_id: self_id,
1449 scroll_y: self.scroll_y.clone(),
1450 viewport_height: self.viewport_height.clone(),
1451 placed_content_width: self.placed_content_width.clone(),
1452 version: self.pane_version.clone(),
1453 total_refresh: self.layout_refresh.clone(),
1454 prev_built_start: self.pane_built_start.clone(),
1455 prev_built_end: self.pane_built_end.clone(),
1456 item_entries: Vec::new(),
1457 };
1458 self.body_pane_id = Some(ctx.add(pane));
1459
1460 if self.show_scrollbar {
1465 let scrollbar = ScrollBar::new(
1466 ScrollBarOrientation::Vertical,
1467 self.scroll_y.clone(),
1468 self.max_scroll_y.clone(),
1469 self.viewport_ratio_y.clone(),
1470 )
1471 .visual(match self.scroll_bar_style {
1472 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1473 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1474 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1475 });
1476 let sb_id = ctx.add(scrollbar);
1477 self.scrollbar_id = Some(sb_id);
1478 } else {
1479 self.scrollbar_id = None;
1480 }
1481
1482 self.child_ids()
1483 }
1484
1485 fn layout_response(
1486 &self,
1487 proposal: SizeProposal,
1488 _ctx: &LayoutContext,
1489 ) -> teksilo_core::widget::LayoutResponse {
1490 crate::common::viewport::viewport_size(
1494 proposal,
1495 &self.viewport_height,
1496 Size::new(300.0, 200.0),
1497 )
1498 .into()
1499 }
1500
1501 fn place_children(
1502 &self,
1503 bounds: Rect,
1504 _proposal: SizeProposal,
1505 children: &mut [WidgetPlacement],
1506 _ctx: &LayoutContext,
1507 ) {
1508 self.viewport_bounds.set(bounds);
1512 crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
1516
1517 if children.is_empty() {
1518 return;
1519 }
1520
1521 let viewport_height = bounds.height;
1522
1523 let provisional_total = self.total_content_height();
1527 let needs_internal_scrollbar =
1528 self.show_scrollbar && provisional_total > viewport_height + 0.5;
1529 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1530 let content_width = if needs_internal_scrollbar && reserves_bar {
1531 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1532 } else {
1533 bounds.width
1534 };
1535 self.placed_content_width.set(content_width);
1536
1537 let total_height = self.total_content_height();
1542 let max_y = (total_height - viewport_height).max(0.0);
1543 self.max_scroll_y.set(max_y);
1544 let ratio = if total_height > 0.0 {
1545 (viewport_height / total_height).clamp(0.0, 1.0)
1546 } else {
1547 1.0
1548 };
1549 self.viewport_ratio_y.set(ratio);
1550 self.clamp_scroll();
1551
1552 let mut next = 0;
1556 if self.body_pane_id.is_some() {
1557 if let Some(child) = children.get_mut(next) {
1558 child.origin = bounds.origin();
1559 child.size = Size::new(content_width, bounds.height);
1560 }
1561 next += 1;
1562 }
1563 if self.scrollbar_id.is_some()
1564 && let Some(sb_child) = children.get_mut(next)
1565 {
1566 if needs_internal_scrollbar {
1567 sb_child.origin =
1568 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1569 sb_child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1570 } else {
1571 sb_child.origin = bounds.origin();
1572 sb_child.size = Size::ZERO;
1573 }
1574 }
1575 }
1576
1577 fn paint(
1578 &self,
1579 bounds: Rect,
1580 canvas: &mut teksilo_canvas::Canvas,
1581 ctx: &teksilo_core::widget::PaintContext,
1582 ) {
1583 if let Some((y, width)) = self.drop_feedback.get() {
1587 let recipe = ctx
1588 .theme
1589 .style_slots
1590 .list_container
1591 .as_ref()
1592 .map(|s| s.insertion())
1593 .unwrap_or_default();
1594 let color = recipe.role.resolve(&ctx.theme.colors);
1595 let line_y = bounds.y + y;
1596 let line_x = bounds.x;
1597 let half = recipe.thickness * 0.5;
1598 canvas.set_clip(bounds);
1602 canvas.fill_rect(
1603 Rect::new(line_x, line_y - half, width, recipe.thickness),
1604 color,
1605 );
1606 canvas.clear_clip();
1607 }
1608
1609 let has_selection = self
1612 .row_selection
1613 .as_ref()
1614 .is_some_and(|s| s.has_selection());
1615 if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1616 let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1617 let inset = 1.0_f32;
1618 let rect = Rect::new(
1619 bounds.x + inset,
1620 bounds.y + inset,
1621 (bounds.width - inset * 2.0).max(0.0),
1622 (bounds.height - inset * 2.0).max(0.0),
1623 );
1624 canvas.stroke_rect(rect, color, 1.5);
1625 }
1626 }
1627
1628 fn context_menu_key_target(&self) -> Option<WidgetId> {
1643 let index = self.focused_index.get().or_else(|| {
1644 self.row_selection
1645 .as_ref()
1646 .and_then(|s| s.selected_indices().first().copied())
1647 })?;
1648 let map = self.row_map.borrow();
1649 map.iter()
1650 .find(|(i, _)| *i == index)
1651 .map(|(_, widget)| *widget)
1652 }
1653
1654 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1655 builder.set_role(teksilo_core::accesskit::Role::ListBox);
1656 builder.set_size_of_set(self.source.len());
1662 }
1663
1664 fn as_any(&self) -> Option<&dyn std::any::Any> {
1665 Some(self)
1666 }
1667
1668 fn children(&self) -> Vec<WidgetId> {
1669 self.child_ids()
1670 }
1671
1672 fn clips_children(&self) -> bool {
1673 true
1674 }
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679 use super::*;
1680 use teksilo_core::widget_tree::WidgetTree;
1681
1682 fn row_ids(tree: &WidgetTree, lv: WidgetId) -> Vec<WidgetId> {
1687 let kids = tree.children(lv);
1688 match kids.first() {
1689 Some(&pane) => tree.children(pane),
1690 None => Vec::new(),
1691 }
1692 }
1693
1694 fn scrollbar_of(tree: &WidgetTree, lv: WidgetId) -> WidgetId {
1696 *tree.children(lv).last().expect("ListView has children")
1697 }
1698
1699 #[test]
1700 fn smooth_scroll_survives_a_body_pane_rebuild() {
1701 let (mut tree, lv_id, model) = make_list_view(500, 20.0);
1702 let scroll = {
1703 tree.layout(SizeProposal::exact(400.0, 200.0));
1704 let any = tree.widget_as_any(lv_id).unwrap();
1705 any.downcast_ref::<ListView<usize>>()
1706 .unwrap()
1707 .scroll_y_signal()
1708 .clone()
1709 };
1710 crate::common::thumb_drag_test::assert_fling_survives_pane_rebuild(
1711 &mut tree,
1712 400.0,
1713 200.0,
1714 &scroll,
1715 "ListView",
1716 || model.push(9999),
1717 );
1718 }
1719
1720 #[test]
1721 fn rows_materialize_during_scrollbar_thumb_drag() {
1722 let (mut tree, lv_id, _model) = make_list_view(500, 20.0);
1725 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
1726 &mut tree,
1727 lv_id,
1728 400.0,
1729 200.0,
1730 0.0,
1731 "ListView",
1732 |t| {
1733 row_ids(t, lv_id)
1734 .into_iter()
1735 .filter(|id| {
1736 let b = t.bounds(*id);
1737 b.height > 1.0 && b.y > -b.height && b.y < 200.0
1738 })
1739 .count()
1740 },
1741 );
1742 }
1743
1744 #[derive(Debug)]
1745 struct FixedLeaf(f32, f32);
1746 impl Widget for FixedLeaf {
1747 fn layout_response(
1748 &self,
1749 _proposal: SizeProposal,
1750 _ctx: &LayoutContext,
1751 ) -> teksilo_core::widget::LayoutResponse {
1752 Size::new(self.0, self.1).into()
1753 }
1754 }
1755
1756 #[test]
1765 fn a_row_announces_its_position_out_of_the_whole_model() {
1766 let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
1767 tree.layout(SizeProposal::exact(400.0, 200.0));
1768 let rows = row_ids(&tree, lv_id);
1769 assert!(!rows.is_empty(), "some rows must be realized");
1770
1771 let update = tree.sync_accessibility();
1772 for (i, &row) in rows.iter().enumerate().take(3) {
1773 crate::a11y_set_semantics::assert_announces(
1774 &update,
1775 teksilo_core::accessibility::widget_id_to_node_id(row),
1776 i + 1,
1777 200,
1778 &format!("row {i}"),
1779 );
1780 }
1781 }
1782
1783 #[test]
1786 fn a_scrolled_row_announces_its_model_position_not_its_window_position() {
1787 let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
1788 tree.layout(SizeProposal::exact(400.0, 200.0));
1789 {
1790 let any = tree.widget_as_any(lv_id).unwrap();
1791 any.downcast_ref::<ListView<usize>>()
1792 .unwrap()
1793 .scroll_y_signal()
1794 .set(150.0 * 20.0);
1795 }
1796 tree.layout(SizeProposal::exact(400.0, 200.0));
1797
1798 let rows = row_ids(&tree, lv_id);
1799 assert!(
1800 !rows.is_empty(),
1801 "some rows must be realized after scrolling"
1802 );
1803 let update = tree.sync_accessibility();
1804 let (position, size) = crate::a11y_set_semantics::announced_set_position(
1805 &update,
1806 teksilo_core::accessibility::widget_id_to_node_id(rows[0]),
1807 );
1808 assert_eq!(
1809 size,
1810 Some(200),
1811 "the total is the model's, not the window's"
1812 );
1813 assert!(
1814 position.is_some_and(|p| p > 100),
1815 "the first realized row after scrolling to the 150th must announce a \
1816 model position, not a window position; got {position:?}"
1817 );
1818 }
1819
1820 fn make_list_view(count: usize, item_height: f32) -> (WidgetTree, WidgetId, ListModel<usize>) {
1821 let model = ListModel::from_vec((0..count).collect());
1822 let mut tree = WidgetTree::new();
1823 let lv_id = tree.add(
1824 ListView::new(model.clone(), move |_i, _item, _selected| {
1825 Box::new(FixedLeaf(100.0, item_height))
1826 })
1827 .item_height(item_height),
1828 );
1829 (tree, lv_id, model)
1830 }
1831
1832 #[test]
1833 fn arrow_nav_resumes_from_the_clicked_row() {
1834 use teksilo_canvas::Point;
1838 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1839 use teksilo_data::{SelectionMode, SelectionModel};
1840
1841 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1842 let selection = SelectionModel::new(SelectionMode::Single);
1843 let sel = selection.clone();
1844 let mut tree = WidgetTree::new();
1845 let lv_id = tree.add(
1846 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
1847 .item_height(20.0)
1848 .selection(sel),
1849 );
1850 tree.layout(SizeProposal::exact(400.0, 300.0)); tree.focus(lv_id);
1852
1853 tree.dispatch_event(WidgetEvent::PointerDown {
1855 position: Point::new(50.0, 70.0),
1856 button: PointerButton::Primary,
1857 modifiers: Modifiers::NONE,
1858 });
1859 tree.dispatch_event(WidgetEvent::PointerUp {
1860 position: Point::new(50.0, 70.0),
1861 button: PointerButton::Primary,
1862 modifiers: Modifiers::NONE,
1863 });
1864 assert_eq!(
1865 selection.selected_indices(),
1866 vec![3],
1867 "precondition: body click selects row 3"
1868 );
1869
1870 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1872 assert_eq!(
1873 selection.selected_indices(),
1874 vec![4],
1875 "ArrowDown after a click resumes from the clicked row (3 → 4)"
1876 );
1877 }
1878
1879 #[test]
1880 fn focused_index_follows_insert_before_it() {
1881 use teksilo_canvas::Point;
1887 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1888 use teksilo_data::{SelectionMode, SelectionModel};
1889
1890 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1891 let selection = SelectionModel::new(SelectionMode::Single);
1892 let sel = selection.clone();
1893 let mut tree = WidgetTree::new();
1894 let lv_id = tree.add(
1895 ListView::new(model.clone(), |_i, _item, _sel| {
1896 Box::new(FixedLeaf(100.0, 20.0))
1897 })
1898 .item_height(20.0)
1899 .selection(sel),
1900 );
1901 tree.layout(SizeProposal::exact(400.0, 300.0));
1902 tree.focus(lv_id);
1903
1904 tree.dispatch_event(WidgetEvent::PointerDown {
1906 position: Point::new(50.0, 70.0),
1907 button: PointerButton::Primary,
1908 modifiers: Modifiers::NONE,
1909 });
1910 tree.dispatch_event(WidgetEvent::PointerUp {
1911 position: Point::new(50.0, 70.0),
1912 button: PointerButton::Primary,
1913 modifiers: Modifiers::NONE,
1914 });
1915 assert_eq!(selection.selected_indices(), vec![3], "precondition");
1916
1917 model.insert(0, 100);
1919 model.insert(0, 200);
1920 tree.layout(SizeProposal::exact(400.0, 300.0));
1921 assert_eq!(
1924 selection.selected_indices(),
1925 vec![5],
1926 "precondition: selection shifts with the inserted rows"
1927 );
1928
1929 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1933 assert_eq!(
1934 selection.selected_indices(),
1935 vec![6],
1936 "ArrowDown after a leading insert resumes from the shifted row (5 → 6), \
1937 not the stale pre-insert one (3 → 4)"
1938 );
1939 }
1940
1941 #[test]
1942 fn focused_index_dropped_when_its_row_is_removed() {
1943 use teksilo_canvas::Point;
1946 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1947 use teksilo_data::{SelectionMode, SelectionModel};
1948
1949 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1950 let selection = SelectionModel::new(SelectionMode::Single);
1951 let sel = selection.clone();
1952 let mut tree = WidgetTree::new();
1953 let lv_id = tree.add(
1954 ListView::new(model.clone(), |_i, _item, _sel| {
1955 Box::new(FixedLeaf(100.0, 20.0))
1956 })
1957 .item_height(20.0)
1958 .selection(sel),
1959 );
1960 tree.layout(SizeProposal::exact(400.0, 300.0));
1961 tree.focus(lv_id);
1962
1963 tree.dispatch_event(WidgetEvent::PointerDown {
1965 position: Point::new(50.0, 70.0),
1966 button: PointerButton::Primary,
1967 modifiers: Modifiers::NONE,
1968 });
1969 tree.dispatch_event(WidgetEvent::PointerUp {
1970 position: Point::new(50.0, 70.0),
1971 button: PointerButton::Primary,
1972 modifiers: Modifiers::NONE,
1973 });
1974 assert_eq!(selection.selected_indices(), vec![3], "precondition");
1975
1976 model.remove(3);
1978 tree.layout(SizeProposal::exact(400.0, 300.0));
1979 assert!(
1980 selection.selected_indices().is_empty(),
1981 "precondition: selection drops the removed row"
1982 );
1983
1984 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1990 assert_eq!(
1991 selection.selected_indices(),
1992 vec![0],
1993 "focused_index was cleared, so nav restarts at the top (row 0), \
1994 not from the stale removed row's index (3 → 4)"
1995 );
1996 }
1997
1998 #[test]
1999 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
2000 use teksilo_core::event::{Key, Modifiers};
2005 use teksilo_data::{SelectionMode, SelectionModel};
2006
2007 for (key, want, what) in [
2008 (
2009 Key::ArrowDown,
2010 0usize,
2011 "first ArrowDown selects the first row",
2012 ),
2013 (Key::ArrowUp, 9usize, "first ArrowUp selects the last row"),
2014 ] {
2015 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2016 let selection = SelectionModel::new(SelectionMode::Single);
2017 let mut tree = WidgetTree::new();
2018 let lv_id = tree.add(
2019 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2020 .item_height(20.0)
2021 .selection(selection.clone()),
2022 );
2023 tree.layout(SizeProposal::exact(400.0, 300.0));
2024 tree.focus(lv_id);
2025 assert!(
2026 selection.selected_indices().is_empty(),
2027 "precondition: nothing selected, no cursor"
2028 );
2029
2030 tree.press_key(key, Modifiers::NONE);
2031 assert_eq!(selection.selected_indices(), vec![want], "{what}");
2032 }
2033 }
2034
2035 #[test]
2036 fn keyboard_cursor_starts_from_a_preset_selection() {
2037 use teksilo_core::event::{Key, Modifiers};
2042 use teksilo_data::{SelectionMode, SelectionModel};
2043
2044 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2045 let selection = SelectionModel::new(SelectionMode::Single);
2046 selection.select(2);
2047 let mut tree = WidgetTree::new();
2048 let lv_id = tree.add(
2049 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2050 .item_height(20.0)
2051 .selection(selection.clone()),
2052 );
2053 tree.layout(SizeProposal::exact(400.0, 300.0));
2054 tree.focus(lv_id);
2055
2056 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2057 assert_eq!(
2058 selection.selected_indices(),
2059 vec![3],
2060 "Down from a preselected row 2 continues to 3"
2061 );
2062 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2063 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2064 assert_eq!(selection.selected_indices(), vec![1], "and Up walks back");
2065 }
2066
2067 #[test]
2068 fn checkbox_press_does_not_select_row() {
2069 use crate::styles::recipe_standard_item_style as si;
2073 use teksilo_canvas::Point;
2074 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2075 use teksilo_data::{SelectionMode, SelectionModel};
2076 use teksilo_i18n::lit;
2077
2078 let model = ListModel::from_vec(vec!["alpha", "beta", "gamma"]);
2079 let checks: Vec<Signal<bool>> = (0..3).map(|_| Signal::new(false)).collect();
2080 let checks_for_rows = checks.clone();
2081 let selection = SelectionModel::new(SelectionMode::Single);
2082 let sel = selection.clone();
2083 let mut tree = WidgetTree::new();
2084 let lv_id = tree.add(
2085 ListView::new(model, move |i, _item, _selected| {
2086 Box::new(
2087 crate::StandardListItem::new(lit!(format!("row {i}")))
2088 .checkbox(checks_for_rows[i].clone()),
2089 ) as Box<dyn Widget>
2090 })
2091 .item_height(40.0)
2092 .selection(sel),
2093 );
2094 tree.layout(SizeProposal::exact(400.0, 300.0));
2095
2096 let rows = row_ids(&tree, lv_id);
2097 let row0 = tree.bounds(rows[0]);
2098 let press = |t: &mut WidgetTree, x: f32, y: f32| {
2099 t.dispatch_event(WidgetEvent::PointerDown {
2100 position: Point::new(x, y),
2101 button: PointerButton::Primary,
2102 modifiers: Modifiers::NONE,
2103 });
2104 t.dispatch_event(WidgetEvent::PointerUp {
2105 position: Point::new(x, y),
2106 button: PointerButton::Primary,
2107 modifiers: Modifiers::NONE,
2108 });
2109 };
2110
2111 let cb_x = row0.x
2113 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
2114 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2115 + 4.0;
2116 let cb_y = row0.y + row0.height * 0.5;
2117 press(&mut tree, cb_x, cb_y);
2118 assert!(checks[0].get(), "checkbox press should toggle the checkbox");
2119 assert!(
2120 selection.selected_indices().is_empty(),
2121 "checkbox press must not select the row (got {:?})",
2122 selection.selected_indices()
2123 );
2124
2125 let body_x = row0.x + row0.width * 0.7;
2127 press(&mut tree, body_x, cb_y);
2128 assert_eq!(
2129 selection.selected_indices(),
2130 vec![0],
2131 "body press should select row 0"
2132 );
2133 assert!(
2134 checks[0].get(),
2135 "body press must not toggle the checkbox back"
2136 );
2137 }
2138
2139 #[test]
2140 fn virtualization_creates_only_visible_items() {
2141 let (mut tree, lv_id, _model) = make_list_view(10_000, 30.0);
2142 tree.layout(SizeProposal::exact(400.0, 300.0));
2144
2145 let children = row_ids(&tree, lv_id);
2146 let item_count = children.len() - 1;
2148 assert!(
2149 item_count < 30,
2150 "Expected fewer than 30 items, got {}",
2151 item_count
2152 );
2153 assert!(
2154 item_count >= 10,
2155 "Expected at least 10 items, got {}",
2156 item_count
2157 );
2158 }
2159
2160 #[test]
2161 fn empty_model_shows_scrollbar_only() {
2162 let (mut tree, lv_id, _model) = make_list_view(0, 30.0);
2163 tree.layout(SizeProposal::exact(400.0, 300.0));
2164
2165 assert_eq!(tree.children(lv_id).len(), 2, "body pane + scrollbar");
2168 assert!(
2169 row_ids(&tree, lv_id).is_empty(),
2170 "no rows for an empty model"
2171 );
2172 }
2173
2174 #[test]
2175 fn data_change_triggers_rebuild() {
2176 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2177 tree.layout(SizeProposal::exact(400.0, 300.0));
2178
2179 let initial_items = row_ids(&tree, lv_id).len(); assert_eq!(initial_items, 5);
2181
2182 model.push(99);
2183 tree.layout(SizeProposal::exact(400.0, 300.0));
2184
2185 let new_items = row_ids(&tree, lv_id).len();
2186 assert_eq!(new_items, 6);
2187 }
2188
2189 #[test]
2190 fn remove_triggers_rebuild() {
2191 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2192 tree.layout(SizeProposal::exact(400.0, 300.0));
2193 assert_eq!(row_ids(&tree, lv_id).len(), 5);
2194
2195 model.remove(0);
2196 tree.layout(SizeProposal::exact(400.0, 300.0));
2197 assert_eq!(row_ids(&tree, lv_id).len(), 4);
2198 }
2199
2200 #[test]
2201 fn items_positioned_correctly() {
2202 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2203 tree.layout(SizeProposal::exact(400.0, 300.0));
2204
2205 let children = row_ids(&tree, lv_id);
2206 let y0 = tree.bounds(children[0]).y;
2208 let y1 = tree.bounds(children[1]).y;
2209 let y2 = tree.bounds(children[2]).y;
2210 assert!((y0 - 0.0).abs() < 0.01);
2211 assert!((y1 - 40.0).abs() < 0.01);
2212 assert!((y2 - 80.0).abs() < 0.01);
2213 }
2214
2215 #[test]
2216 fn items_have_correct_height() {
2217 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2218 tree.layout(SizeProposal::exact(400.0, 300.0));
2219
2220 let children = row_ids(&tree, lv_id);
2221 for i in 0..3 {
2222 let h = tree.bounds(children[i]).height;
2223 assert!((h - 40.0).abs() < 0.01, "Item {} height {} != 40.0", i, h);
2224 }
2225 }
2226
2227 #[test]
2228 fn scrollbar_positioned_on_right_edge() {
2229 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2230 tree.layout(SizeProposal::exact(400.0, 300.0));
2231
2232 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2233 assert!(
2235 (sb_bounds.x - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2236 "Scrollbar x {} != {}",
2237 sb_bounds.x,
2238 400.0 - SCROLLBAR_THICKNESS
2239 );
2240 assert!((sb_bounds.height - 300.0).abs() < 0.01);
2241 }
2242
2243 #[test]
2244 fn small_list_collapses_scrollbar() {
2245 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2246 tree.layout(SizeProposal::exact(400.0, 300.0));
2248
2249 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2250 assert!(
2251 sb_bounds.width < 0.01 && sb_bounds.height < 0.01,
2252 "Scrollbar should be collapsed for small lists"
2253 );
2254 }
2255
2256 #[test]
2257 fn item_width_leaves_room_for_scrollbar() {
2258 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2259 tree.layout(SizeProposal::exact(400.0, 300.0));
2260
2261 let children = row_ids(&tree, lv_id);
2262 let item_width = tree.bounds(children[0]).width;
2263 assert!(
2264 (item_width - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2265 "Item width {} should be {}",
2266 item_width,
2267 400.0 - SCROLLBAR_THICKNESS
2268 );
2269 }
2270
2271 #[test]
2272 fn small_list_items_use_full_width() {
2273 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2274 tree.layout(SizeProposal::exact(400.0, 300.0));
2276
2277 let children = row_ids(&tree, lv_id);
2278 let item_width = tree.bounds(children[0]).width;
2279 assert!(
2280 (item_width - 400.0).abs() < 0.01,
2281 "Small list item width {} should be full 400.0 (no scrollbar)",
2282 item_width,
2283 );
2284 }
2285
2286 fn make_selectable_list(
2289 count: usize,
2290 ) -> (
2291 WidgetTree,
2292 WidgetId,
2293 ListModel<usize>,
2294 teksilo_data::SelectionModel,
2295 ) {
2296 use teksilo_data::{SelectionMode, SelectionModel};
2297 let model = ListModel::from_vec((0..count).collect());
2298 let selection = SelectionModel::new(SelectionMode::Multi);
2299 let sel_clone = selection.clone();
2300 let mut tree = WidgetTree::new();
2301 let lv_id = tree.add(
2302 ListView::new(model.clone(), move |_i, _item, _selected| {
2303 Box::new(FixedLeaf(100.0, 30.0))
2304 })
2305 .item_height(30.0)
2306 .selection(sel_clone),
2307 );
2308 tree.layout(SizeProposal::exact(400.0, 300.0));
2309 (tree, lv_id, model, selection)
2310 }
2311
2312 #[test]
2313 fn click_selects_item() {
2314 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2315 let children = row_ids(&tree, lv_id);
2317 tree.click(children[1]);
2318 assert!(selection.is_selected(1), "item 1 should be selected");
2319 assert!(!selection.is_selected(0), "item 0 should not be selected");
2320 }
2321
2322 #[test]
2323 fn click_replaces_selection() {
2324 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2325 let children = row_ids(&tree, lv_id);
2326 tree.click(children[0]);
2327 assert!(selection.is_selected(0));
2328
2329 tree.click(children[2]);
2330 assert!(selection.is_selected(2));
2331 assert!(
2332 !selection.is_selected(0),
2333 "previous selection should be cleared"
2334 );
2335 }
2336
2337 #[test]
2338 fn keyed_selection_tracks_identity_not_index() {
2339 use std::rc::Rc;
2342 use teksilo_core::ObserverHandle;
2343 use teksilo_data::{KeyedSelectionModel, ListDataSource, SelectionMode};
2344
2345 struct KeyedSource {
2346 items: Vec<(u64, usize)>, }
2348 impl ListDataSource for KeyedSource {
2349 type Item = usize;
2350 type Key = u64;
2351 fn len(&self) -> usize {
2352 self.items.len()
2353 }
2354 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
2355 self.items.get(i).map(|(_, v)| f(v))
2356 }
2357 fn key_at(&self, i: usize) -> Option<u64> {
2358 self.items.get(i).map(|(k, _)| *k)
2359 }
2360 fn index_of(&self, key: &u64) -> Option<usize> {
2361 self.items.iter().position(|(k, _)| k == key)
2362 }
2363 fn observe_changes(
2364 &self,
2365 _f: impl Fn(&teksilo_data::DataChange) + 'static,
2366 ) -> ObserverHandle {
2367 ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
2368 }
2369 }
2370
2371 let keyed = KeyedSelectionModel::<u64>::new(SelectionMode::Single);
2372 let source = KeyedSource {
2373 items: vec![(10, 100), (20, 200), (30, 300)],
2374 };
2375 let mut tree = WidgetTree::new();
2376 let lv_id = tree.add(
2377 ListView::from_source_keyed(source, keyed.clone(), |_i, _v, _sel| {
2378 Box::new(FixedLeaf(100.0, 30.0))
2379 })
2380 .item_height(30.0),
2381 );
2382 tree.layout(SizeProposal::exact(400.0, 300.0));
2383
2384 let children = row_ids(&tree, lv_id);
2386 tree.click(children[1]);
2387 assert!(keyed.is_selected(&20), "selection is stored by key");
2388 assert_eq!(keyed.selected_keys(), vec![20]);
2389 assert!(!keyed.is_selected(&10));
2390 }
2391
2392 #[test]
2393 fn ctrl_click_toggles() {
2394 use teksilo_core::event::Modifiers;
2395 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2396 let children = row_ids(&tree, lv_id);
2397
2398 tree.click(children[0]);
2400 assert!(selection.is_selected(0));
2401
2402 let center = tree.bounds(children[2]).center();
2404 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2405 position: center,
2406 button: teksilo_core::event::PointerButton::Primary,
2407 modifiers: Modifiers::COMMAND,
2408 });
2409 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerUp {
2410 position: center,
2411 button: teksilo_core::event::PointerButton::Primary,
2412 modifiers: Modifiers::COMMAND,
2413 });
2414
2415 assert!(selection.is_selected(0), "item 0 should still be selected");
2416 assert!(selection.is_selected(2), "item 2 should be toggled on");
2417 }
2418
2419 #[test]
2420 fn shift_click_extends_range() {
2421 use teksilo_core::event::Modifiers;
2422 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2423 let children = row_ids(&tree, lv_id);
2424
2425 tree.click(children[1]);
2427 assert!(
2428 selection.is_selected(1),
2429 "item 1 should be selected after plain click"
2430 );
2431
2432 let center = tree.bounds(children[3]).center();
2434 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2435 position: center,
2436 button: teksilo_core::event::PointerButton::Primary,
2437 modifiers: Modifiers::SHIFT,
2438 });
2439
2440 let selected = selection.selected_indices();
2441 assert_eq!(
2442 selected,
2443 vec![1, 2, 3],
2444 "Shift+click should select range 1..=3, got {:?}",
2445 selected
2446 );
2447 }
2448
2449 #[test]
2452 fn scroll_changes_visible_items() {
2453 let model = ListModel::from_vec((0..100).collect());
2455 let mut tree = WidgetTree::new();
2456 let lv_id = tree.add(
2457 ListView::new(model.clone(), move |i, _item, _selected| {
2458 Box::new(FixedLeaf(i as f32, 30.0))
2460 })
2461 .item_height(30.0),
2462 );
2463 tree.layout(SizeProposal::exact(400.0, 300.0));
2464
2465 let children = row_ids(&tree, lv_id);
2467 let first_y = tree.bounds(children[0]).y;
2468 assert!(
2469 first_y.abs() < 30.0,
2470 "First visible item should be near the top, got y={}",
2471 first_y
2472 );
2473
2474 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
2476 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 1500.0 },
2477 modifiers: Default::default(),
2478 });
2479 tree.layout(SizeProposal::exact(400.0, 300.0));
2480
2481 let children_after = row_ids(&tree, lv_id);
2484 let item_count_after = children_after.len() - 1;
2485 assert!(
2486 item_count_after > 0,
2487 "Should have visible items after scroll"
2488 );
2489
2490 let first_y_after = tree.bounds(children_after[0]).y;
2494 assert!(
2495 first_y_after < 300.0,
2496 "First item should be in viewport after scroll, got y={}",
2497 first_y_after
2498 );
2499
2500 assert!(
2512 item_count_after < 30,
2513 "Should still be virtualized after scroll, got {} items",
2514 item_count_after
2515 );
2516 }
2517
2518 #[test]
2521 fn list_item_has_a11y_role() {
2522 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2523 tree.layout(SizeProposal::exact(400.0, 300.0));
2524
2525 let children = row_ids(&tree, lv_id);
2527 let info = tree.accessibility_node(children[0]);
2528 assert_eq!(
2529 info.role(),
2530 teksilo_core::accesskit::Role::ListBoxOption,
2531 "Item wrapper should have ListBoxOption role"
2532 );
2533 }
2534
2535 #[test]
2538 fn alt_arrow_moves_one_step_per_press_across_rebuilds() {
2539 use teksilo_core::event::{Key, Modifiers};
2545 use teksilo_data::{SelectionMode, SelectionModel};
2546
2547 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
2548 let selection = SelectionModel::new(SelectionMode::Single);
2549 let sel_clone = selection.clone();
2550 let model_clone = model.clone();
2551
2552 let mut tree = WidgetTree::new();
2553 let lv_id = tree.add(
2554 ListView::new(model_clone, move |_i, _item, _sel| {
2555 Box::new(FixedLeaf(100.0, 30.0))
2556 })
2557 .item_height(30.0)
2558 .selection(sel_clone)
2559 .reorderable(true),
2560 );
2561 tree.layout(SizeProposal::exact(400.0, 300.0));
2562
2563 for i in 0..3 {
2567 selection.select(i);
2568 tree.layout(SizeProposal::exact(400.0, 300.0));
2569 }
2570
2571 selection.select(0);
2572 tree.layout(SizeProposal::exact(400.0, 300.0));
2573
2574 tree.focus(lv_id);
2575 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
2576 key: Key::ArrowDown,
2577 modifiers: Modifiers::ALT,
2578 text: None,
2579 });
2580
2581 assert_eq!(model.with_item(0, |v| *v), Some(20));
2583 assert_eq!(model.with_item(1, |v| *v), Some(10));
2584 assert_eq!(model.with_item(2, |v| *v), Some(30));
2585 }
2586
2587 #[test]
2588 fn page_down_up_moves_selection_by_viewport() {
2589 use teksilo_core::event::{Key, Modifiers};
2590 use teksilo_data::{SelectionMode, SelectionModel};
2591
2592 let model = ListModel::from_vec((0..100usize).collect());
2593 let selection = SelectionModel::new(SelectionMode::Single);
2594 let sel = selection.clone();
2595 let mut tree = WidgetTree::new();
2596 let lv = tree.add(
2597 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2598 .item_height(20.0)
2599 .selection(sel),
2600 );
2601 let p = SizeProposal::exact(400.0, 200.0); tree.layout(p);
2603 tree.focus(lv);
2604 selection.select(0);
2605
2606 tree.press_key(Key::PageDown, Modifiers::NONE);
2607 tree.layout(p);
2608 let after_pgdn = selection.selected_indices()[0];
2609 assert!(
2610 after_pgdn >= 8,
2611 "PageDown should advance ~one viewport of rows, got {after_pgdn}"
2612 );
2613 let scroll = with_list_view::<usize, _>(&tree, lv, |v| v.scroll_y_signal().get());
2614 assert!(scroll > 0.0, "PageDown scrolls to follow, got {scroll}");
2615
2616 tree.press_key(Key::PageUp, Modifiers::NONE);
2617 tree.layout(p);
2618 assert!(
2619 selection.selected_indices()[0] < after_pgdn,
2620 "PageUp should move selection back up"
2621 );
2622 }
2623
2624 #[test]
2625 fn space_toggles_selection_enter_activates() {
2626 use std::cell::Cell;
2627 use teksilo_core::event::{Key, Modifiers};
2628 use teksilo_data::{SelectionMode, SelectionModel};
2629
2630 let model = ListModel::from_vec((0..5usize).collect());
2631 let selection = SelectionModel::new(SelectionMode::Multi);
2632 let sel = selection.clone();
2633 let activated = Rc::new(Cell::new(None));
2634 let act = activated.clone();
2635 let mut tree = WidgetTree::new();
2636 let lv = tree.add(
2637 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2638 .item_height(20.0)
2639 .selection(sel)
2640 .on_activate(move |i, _ctx| act.set(Some(i))),
2641 );
2642 tree.layout(SizeProposal::exact(400.0, 200.0));
2643 tree.focus(lv);
2644
2645 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2648 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2649 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2650 assert_eq!(selection.selected_indices(), vec![2]);
2651 assert_eq!(activated.get(), None, "arrows never activate");
2652
2653 tree.press_key(Key::Space, Modifiers::NONE);
2655 assert!(
2656 selection.selected_indices().is_empty(),
2657 "Space toggles row 2 off in Multi mode"
2658 );
2659 assert_eq!(activated.get(), None, "Space must NOT activate");
2660
2661 tree.press_key(Key::Enter, Modifiers::NONE);
2663 assert_eq!(activated.get(), Some(2), "Enter activates the focused row");
2664 }
2665
2666 #[test]
2667 fn ctrl_a_selects_all_in_multi_mode() {
2668 use teksilo_core::event::{Key, Modifiers};
2669 use teksilo_data::{SelectionMode, SelectionModel};
2670
2671 let model = ListModel::from_vec((0..6usize).collect());
2672 let selection = SelectionModel::new(SelectionMode::Multi);
2673 let sel = selection.clone();
2674 let mut tree = WidgetTree::new();
2675 let lv = tree.add(
2676 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2677 .item_height(20.0)
2678 .selection(sel),
2679 );
2680 tree.layout(SizeProposal::exact(400.0, 200.0));
2681 tree.focus(lv);
2682 tree.press_key(Key::A, Modifiers::COMMAND);
2683 assert_eq!(selection.selected_indices().len(), 6, "Ctrl+A selects all");
2684 }
2685
2686 #[test]
2687 fn ctrl_arrow_moves_cursor_without_selecting_in_multi_mode() {
2688 use teksilo_core::event::{Key, Modifiers};
2689 use teksilo_data::{SelectionMode, SelectionModel};
2690
2691 let model = ListModel::from_vec((0..6usize).collect());
2692 let selection = SelectionModel::new(SelectionMode::Multi);
2693 let sel = selection.clone();
2694 let mut tree = WidgetTree::new();
2695 let lv = tree.add(
2696 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2697 .item_height(20.0)
2698 .selection(sel),
2699 );
2700 tree.layout(SizeProposal::exact(400.0, 200.0));
2701 tree.focus(lv);
2702
2703 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2705 assert_eq!(selection.selected_indices(), vec![0]);
2706
2707 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
2709 assert_eq!(
2710 selection.selected_indices(),
2711 vec![0],
2712 "Ctrl+ArrowDown must leave the selection unchanged"
2713 );
2714 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
2715 assert_eq!(focused, Some(1), "Ctrl+ArrowDown moves the cursor to row 1");
2716
2717 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
2718 assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
2719 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
2720 assert_eq!(focused, Some(2));
2721
2722 tree.press_key(Key::Space, Modifiers::CTRL);
2725 assert_eq!(selection.selected_indices(), vec![0, 2]);
2726
2727 tree.press_key(Key::Space, Modifiers::CTRL);
2729 assert_eq!(selection.selected_indices(), vec![0]);
2730
2731 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2734 assert_eq!(selection.selected_indices(), vec![3]);
2735 }
2736
2737 #[test]
2738 fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
2739 use teksilo_core::event::{Key, Modifiers};
2740 use teksilo_data::{SelectionMode, SelectionModel};
2741
2742 let model = ListModel::from_vec((0..6usize).collect());
2743 let selection = SelectionModel::new(SelectionMode::Single);
2744 let sel = selection.clone();
2745 let mut tree = WidgetTree::new();
2746 let lv = tree.add(
2747 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2748 .item_height(20.0)
2749 .selection(sel),
2750 );
2751 tree.layout(SizeProposal::exact(400.0, 200.0));
2752 tree.focus(lv);
2753
2754 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2755 assert_eq!(selection.selected_indices(), vec![0]);
2756
2757 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
2758 assert_eq!(
2759 selection.selected_indices(),
2760 vec![0],
2761 "Ctrl+ArrowDown must not select in Single mode either"
2762 );
2763 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
2764 assert_eq!(focused, Some(1));
2765
2766 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2767 assert_eq!(selection.selected_indices(), vec![2]);
2768 }
2769
2770 #[test]
2771 fn type_ahead_jumps_to_matching_row() {
2772 use teksilo_core::event::{Key, Modifiers};
2773 use teksilo_data::{SelectionMode, SelectionModel};
2774
2775 let model = ListModel::from_vec(vec![
2776 "Apple".to_string(),
2777 "Banana".to_string(),
2778 "Cherry".to_string(),
2779 "Cranberry".to_string(),
2780 "Date".to_string(),
2781 ]);
2782 let selection = SelectionModel::new(SelectionMode::Single);
2783 let sel = selection.clone();
2784 let mut tree = WidgetTree::new();
2785 let lv = tree.add(
2786 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2787 .item_height(20.0)
2788 .selection(sel)
2789 .type_ahead_label(|s: &String| s.clone()),
2790 );
2791 tree.layout(SizeProposal::exact(400.0, 200.0));
2792 tree.focus(lv);
2793 selection.select(0);
2794
2795 tree.press_key(Key::C, Modifiers::NONE);
2797 assert_eq!(selection.selected_indices(), vec![2], "'c' → Cherry");
2798
2799 tree.press_key(Key::R, Modifiers::NONE);
2801 assert_eq!(selection.selected_indices(), vec![3], "'cr' → Cranberry");
2802 }
2803
2804 #[test]
2805 fn type_ahead_buffer_survives_rebuild() {
2806 use teksilo_core::event::{Key, Modifiers};
2811 use teksilo_data::{SelectionMode, SelectionModel};
2812
2813 let model = ListModel::from_vec(vec![
2814 "Apple".to_string(),
2815 "Cherry".to_string(),
2816 "Cranberry".to_string(),
2817 ]);
2818 let selection = SelectionModel::new(SelectionMode::Single);
2819 let sel = selection.clone();
2820 let mut tree = WidgetTree::new();
2821 let p = SizeProposal::exact(400.0, 200.0);
2822 let lv = tree.add(
2823 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2824 .item_height(20.0)
2825 .selection(sel)
2826 .type_ahead_label(|s: &String| s.clone()),
2827 );
2828 tree.layout(p);
2829 tree.focus(lv);
2830 selection.select(0);
2831
2832 tree.press_key(Key::C, Modifiers::NONE); assert_eq!(selection.selected_indices(), vec![1]);
2834 tree.layout(p); tree.press_key(Key::R, Modifiers::NONE); assert_eq!(
2837 selection.selected_indices(),
2838 vec![2],
2839 "buffer 'c' must survive the rebuild so 'cr' matches Cranberry"
2840 );
2841 }
2842
2843 #[test]
2844 fn page_down_on_short_list_jumps_to_last_without_panic() {
2845 use teksilo_core::event::{Key, Modifiers};
2846 use teksilo_data::{SelectionMode, SelectionModel};
2847
2848 let model = ListModel::from_vec((0..3usize).collect());
2850 let selection = SelectionModel::new(SelectionMode::Single);
2851 let sel = selection.clone();
2852 let mut tree = WidgetTree::new();
2853 let lv = tree.add(
2854 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2855 .item_height(20.0)
2856 .selection(sel),
2857 );
2858 tree.layout(SizeProposal::exact(400.0, 200.0));
2859 tree.focus(lv);
2860 selection.select(0);
2861 tree.press_key(Key::PageDown, Modifiers::NONE);
2862 assert_eq!(selection.selected_indices(), vec![2], "PageDown → last row");
2863 tree.press_key(Key::PageDown, Modifiers::NONE);
2864 assert_eq!(selection.selected_indices(), vec![2], "stays at last");
2865 tree.press_key(Key::PageUp, Modifiers::NONE);
2866 assert_eq!(selection.selected_indices(), vec![0], "PageUp → first row");
2867 }
2868
2869 #[test]
2870 fn alt_arrow_reorders_item() {
2871 use teksilo_core::event::{Key, Modifiers};
2872 use teksilo_data::{SelectionMode, SelectionModel};
2873
2874 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
2875 let selection = SelectionModel::new(SelectionMode::Single);
2876 let sel_clone = selection.clone();
2877 let model_clone = model.clone();
2878
2879 let mut tree = WidgetTree::new();
2880 let lv_id = tree.add(
2881 ListView::new(model_clone.clone(), move |_i, _item, _sel| {
2882 Box::new(FixedLeaf(100.0, 30.0))
2883 })
2884 .item_height(30.0)
2885 .selection(sel_clone)
2886 .reorderable(true),
2887 );
2888 tree.layout(SizeProposal::exact(400.0, 300.0));
2889
2890 selection.select(2);
2892
2893 tree.focus(lv_id);
2895 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
2896 key: Key::ArrowDown,
2897 modifiers: Modifiers::ALT,
2898 text: None,
2899 });
2900
2901 assert_eq!(model.with_item(3, |v| *v), Some(30));
2903 assert_eq!(model.with_item(2, |v| *v), Some(40));
2904 }
2905
2906 fn make_reorderable_list(
2911 values: Vec<usize>,
2912 item_height: f32,
2913 ) -> (WidgetTree, WidgetId, ListModel<usize>) {
2914 let model = ListModel::from_vec(values);
2915 let model_clone = model.clone();
2916 let mut tree = WidgetTree::new();
2917 let lv_id = tree.add(
2918 ListView::new(model_clone, move |_i, _item, _sel| {
2919 Box::new(FixedLeaf(100.0, item_height))
2920 })
2921 .item_height(item_height)
2922 .reorderable(true),
2923 );
2924 (tree, lv_id, model)
2925 }
2926
2927 fn drag_item(tree: &mut WidgetTree, from: Point, to: Point) {
2930 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2931 tree.dispatch_event(WidgetEvent::PointerDown {
2932 position: from,
2933 button: PointerButton::Primary,
2934 modifiers: Modifiers::NONE,
2935 });
2936 tree.dispatch_event(WidgetEvent::PointerMove {
2938 position: Point::new(from.x + 10.0, from.y),
2939 });
2940 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
2941 tree.dispatch_event(WidgetEvent::PointerUp {
2942 position: to,
2943 button: PointerButton::Primary,
2944 modifiers: Modifiers::NONE,
2945 });
2946 }
2947
2948 #[test]
2949 fn drag_reorders_item_downward() {
2950 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
2951 tree.layout(SizeProposal::exact(400.0, 300.0));
2952
2953 let children = row_ids(&tree, lv_id);
2956 let from = tree.bounds(children[0]).center();
2957 let to = Point::new(from.x, 120.0);
2958 drag_item(&mut tree, from, to);
2959
2960 assert_eq!(model.with_item(0, |v| *v), Some(20));
2962 assert_eq!(model.with_item(3, |v| *v), Some(10));
2963 assert_eq!(model.with_item(4, |v| *v), Some(50));
2964 }
2965
2966 #[test]
2967 fn drag_reorders_item_upward() {
2968 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
2969 tree.layout(SizeProposal::exact(400.0, 300.0));
2970
2971 let children = row_ids(&tree, lv_id);
2974 let from = tree.bounds(children[3]).center();
2975 let to = Point::new(from.x, 15.0);
2976 drag_item(&mut tree, from, to);
2977
2978 assert_eq!(model.with_item(1, |v| *v), Some(40));
2980 assert_eq!(model.with_item(2, |v| *v), Some(20));
2981 assert_eq!(model.with_item(3, |v| *v), Some(30));
2982 }
2983
2984 #[test]
2985 fn reorderable_drag_routes_to_source_accept_drop() {
2986 use std::cell::RefCell;
2992 use std::rc::Rc;
2993 use teksilo_core::ObserverHandle;
2994 use teksilo_data::{
2995 DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
2996 ListDataSource,
2997 };
2998
2999 struct CapturingSource {
3000 items: Vec<usize>,
3001 captured: Rc<RefCell<Vec<(usize, usize)>>>,
3002 }
3003 impl ListDataSource for CapturingSource {
3004 type Item = usize;
3005 type Key = usize;
3006 fn len(&self) -> usize {
3007 self.items.len()
3008 }
3009 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
3010 self.items.get(i).map(f)
3011 }
3012 fn key_at(&self, i: usize) -> Option<usize> {
3013 (i < self.items.len()).then_some(i)
3014 }
3015 fn observe_changes(
3016 &self,
3017 _f: impl Fn(&teksilo_data::DataChange) + 'static,
3018 ) -> ObserverHandle {
3019 let inner: Rc<dyn std::any::Any> = Rc::new(());
3020 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3021 }
3022 fn drag(&self, _k: &usize) -> DragEligibility {
3023 DragEligibility::CanDrag
3024 }
3025 fn can_accept(&self, q: &DropQuery<'_, usize>) -> DropResponse {
3026 match &q.source {
3027 DragSource::SameView { .. } if q.position != DropPosition::Into => {
3028 DropResponse::Accept
3029 }
3030 _ => DropResponse::Reject,
3031 }
3032 }
3033 fn accept_drop(&self, c: DropCommit<'_, usize>) -> bool {
3034 let DragSource::SameView { key: from } = c.source else {
3035 return false;
3036 };
3037 let target = c.target;
3038 let shift = if from < target { 1 } else { 0 };
3039 let to = match c.position {
3040 DropPosition::Before => target.saturating_sub(shift),
3041 DropPosition::After => (target + 1).saturating_sub(shift),
3042 DropPosition::Into => return false,
3043 };
3044 self.captured.borrow_mut().push((from, to));
3046 true
3047 }
3048 }
3049
3050 let captured: Rc<RefCell<Vec<(usize, usize)>>> = Rc::new(RefCell::new(Vec::new()));
3051 let source = CapturingSource {
3052 items: vec![10, 20, 30, 40, 50],
3053 captured: captured.clone(),
3054 };
3055 let mut tree = WidgetTree::new();
3056 let lv_id = tree.add(
3057 ListView::from_source(source, move |_i, _item, _sel| {
3058 Box::new(FixedLeaf(100.0, 30.0))
3059 })
3060 .item_height(30.0)
3061 .reorderable(true),
3062 );
3063 tree.layout(SizeProposal::exact(400.0, 300.0));
3064
3065 let children = row_ids(&tree, lv_id);
3068 let from = tree.bounds(children[0]).center();
3069 let to = Point::new(from.x, 120.0);
3070 drag_item(&mut tree, from, to);
3071
3072 assert_eq!(
3073 *captured.borrow(),
3074 vec![(0, 3)],
3075 "the drop is routed to the source's accept_drop with the resolved move"
3076 );
3077 }
3078
3079 #[test]
3080 fn drag_emits_items_moved_change() {
3081 use std::cell::Cell;
3082 use std::rc::Rc;
3083 use teksilo_data::DataChange;
3084
3085 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3086 tree.layout(SizeProposal::exact(400.0, 300.0));
3087
3088 let moved = Rc::new(Cell::new(None::<(usize, usize)>));
3089 let moved_clone = moved.clone();
3090 let handle = model.observe_changes(move |change| {
3091 if let DataChange::ItemsMoved { from, to, .. } = change {
3092 moved_clone.set(Some((*from, *to)));
3093 }
3094 });
3095
3096 let children = row_ids(&tree, lv_id);
3098 let from = tree.bounds(children[0]).center();
3099 let to = Point::new(from.x, 120.0);
3100 drag_item(&mut tree, from, to);
3101
3102 assert_eq!(moved.get(), Some((0, 3)));
3103 drop(handle);
3104 }
3105
3106 #[test]
3107 fn drag_drop_accounts_for_scroll_offset() {
3108 let (mut tree, _lv_id, model) = make_reorderable_list((0..20).collect(), 30.0);
3111 tree.layout(SizeProposal::exact(400.0, 300.0));
3112
3113 tree.pointer_move(Point::new(50.0, 50.0));
3116 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3117 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 60.0 },
3118 modifiers: Default::default(),
3119 });
3120 tree.tick_animations(std::time::Duration::from_millis(200));
3123 tree.layout(SizeProposal::exact(400.0, 300.0));
3124
3125 let from = Point::new(50.0, 15.0);
3130 let to = Point::new(50.0, 120.0);
3131 drag_item(&mut tree, from, to);
3132
3133 assert_eq!(
3134 model.with_item(5, |v| *v),
3135 Some(2),
3136 "Item 2 should land at index 5 after drag with scroll offset"
3137 );
3138 assert_eq!(
3139 model.with_item(2, |v| *v),
3140 Some(3),
3141 "Item 3 should shift up to index 2"
3142 );
3143 }
3144
3145 #[test]
3146 fn click_selects_item_on_reorderable_list_with_selection() {
3147 use std::cell::Cell;
3156 use std::rc::Rc;
3157 use teksilo_data::{SelectionMode, SelectionModel};
3158
3159 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3160 let selection = SelectionModel::new(SelectionMode::Single);
3161 let sel_clone = selection.clone();
3162 let model_clone = model.clone();
3163
3164 let selected_rebuilds: Rc<std::cell::RefCell<Vec<Vec<usize>>>> =
3167 Rc::new(std::cell::RefCell::new(Vec::new()));
3168 let current_pass: Rc<Cell<Vec<usize>>> = Rc::new(Cell::new(Vec::new()));
3169 let _sr = selected_rebuilds.clone();
3170 let cp = current_pass.clone();
3171
3172 let mut tree = WidgetTree::new();
3173 let lv_id = tree.add(
3174 ListView::new(model_clone, move |index, _item, selected| {
3175 if selected {
3176 let mut acc = cp.take();
3177 acc.push(index);
3178 cp.set(acc);
3179 }
3180 Box::new(FixedLeaf(100.0, 30.0))
3181 })
3182 .item_height(30.0)
3183 .selection(sel_clone)
3184 .reorderable(true),
3185 );
3186 tree.layout(SizeProposal::exact(400.0, 300.0));
3187 selected_rebuilds.borrow_mut().push(current_pass.take());
3188
3189 let children = row_ids(&tree, lv_id);
3191 tree.click(children[2]);
3192
3193 assert_eq!(selection.selected_indices(), vec![2]);
3195
3196 tree.layout(SizeProposal::exact(400.0, 300.0));
3199 selected_rebuilds.borrow_mut().push(current_pass.take());
3200
3201 let passes = selected_rebuilds.borrow().clone();
3202 assert_eq!(
3203 passes[0],
3204 Vec::<usize>::new(),
3205 "initial build: nothing selected"
3206 );
3207 assert_eq!(
3208 passes[1],
3209 vec![2],
3210 "post-click rebuild should deliver selected=true for item 2"
3211 );
3212 }
3213
3214 #[test]
3215 fn drag_survives_rebuild_triggered_by_selection() {
3216 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3226 use teksilo_data::{SelectionMode, SelectionModel};
3227
3228 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3229 let selection = SelectionModel::new(SelectionMode::Single);
3230 let sel_clone = selection.clone();
3231 let model_clone = model.clone();
3232
3233 let mut tree = WidgetTree::new();
3234 let _lv_id = tree.add(
3235 ListView::new(model_clone, move |_i, _item, _sel| {
3236 Box::new(FixedLeaf(100.0, 30.0))
3237 })
3238 .item_height(30.0)
3239 .selection(sel_clone)
3240 .reorderable(true),
3241 );
3242 tree.layout(SizeProposal::exact(400.0, 300.0));
3243
3244 tree.dispatch_event(WidgetEvent::PointerDown {
3251 position: Point::new(50.0, 15.0),
3252 button: PointerButton::Primary,
3253 modifiers: Modifiers::NONE,
3254 });
3255 tree.layout(SizeProposal::exact(400.0, 300.0));
3260
3261 tree.dispatch_event(WidgetEvent::PointerMove {
3263 position: Point::new(60.0, 15.0),
3264 });
3265 tree.dispatch_event(WidgetEvent::PointerMove {
3267 position: Point::new(60.0, 120.0),
3268 });
3269 tree.dispatch_event(WidgetEvent::PointerUp {
3270 position: Point::new(60.0, 120.0),
3271 button: PointerButton::Primary,
3272 modifiers: Modifiers::NONE,
3273 });
3274
3275 assert_eq!(
3277 model.with_item(3, |v| *v),
3278 Some(10),
3279 "Drag must complete even after the selection-triggered rebuild \
3280 destroyed the originally-captured wrapper"
3281 );
3282 }
3283
3284 #[test]
3285 fn lazy_loading_rows_render_placeholders_and_request_the_window() {
3286 use std::cell::RefCell;
3290 use std::ops::Range;
3291 use std::rc::Rc;
3292 use teksilo_core::ObserverHandle;
3293 use teksilo_data::{ListDataSource, RowState};
3294
3295 struct Windowed {
3296 total: usize,
3297 requested: Rc<RefCell<Vec<Range<usize>>>>,
3298 }
3299 impl ListDataSource for Windowed {
3300 type Item = usize;
3301 type Key = usize;
3302 fn len(&self) -> usize {
3303 self.total
3304 }
3305 fn with_item<R>(&self, _i: usize, _f: impl FnOnce(&usize) -> R) -> Option<R> {
3306 None }
3308 fn key_at(&self, i: usize) -> Option<usize> {
3309 (i < self.total).then_some(i)
3310 }
3311 fn row_state(&self, _i: usize) -> RowState {
3312 RowState::Loading
3313 }
3314 fn request_window(&self, range: Range<usize>) {
3315 self.requested.borrow_mut().push(range);
3316 }
3317 fn observe_changes(
3318 &self,
3319 _f: impl Fn(&teksilo_data::DataChange) + 'static,
3320 ) -> ObserverHandle {
3321 let inner: Rc<dyn std::any::Any> = Rc::new(());
3322 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3323 }
3324 }
3325
3326 let requested = Rc::new(RefCell::new(Vec::new()));
3327 let source = Windowed {
3328 total: 1000,
3329 requested: requested.clone(),
3330 };
3331 let mut tree = WidgetTree::new();
3332 let lv_id = tree.add(
3333 ListView::from_source(source, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3334 .item_height(30.0),
3335 );
3336 tree.layout(SizeProposal::exact(400.0, 300.0));
3337
3338 let placeholder_rows = row_ids(&tree, lv_id).len();
3341 assert!(
3342 placeholder_rows >= 10,
3343 "loading rows must render as placeholders, got {placeholder_rows}"
3344 );
3345 assert!(
3347 !requested.borrow().is_empty(),
3348 "request_window must be called for the visible range"
3349 );
3350 }
3351
3352 fn with_list_view<T: 'static, R>(
3355 tree: &WidgetTree,
3356 id: WidgetId,
3357 f: impl FnOnce(&ListView<T>) -> R,
3358 ) -> R {
3359 let any = tree.widget_as_any(id).expect("widget exposes as_any");
3360 let lv = any
3361 .downcast_ref::<ListView<T>>()
3362 .expect("widget is a ListView<T>");
3363 f(lv)
3364 }
3365
3366 #[test]
3367 fn drop_indicator_clears_after_drop() {
3368 let (mut tree, lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
3372 tree.layout(SizeProposal::exact(400.0, 300.0));
3373
3374 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
3375
3376 let feedback =
3377 with_list_view::<usize, _>(&tree, lv_id, |lv| lv.drop_feedback_signal().get());
3378 assert!(
3379 feedback.is_none(),
3380 "drop_feedback must be cleared by on_drag_leave after drop, got {:?}",
3381 feedback
3382 );
3383 }
3384
3385 #[test]
3386 fn drag_spawns_preview_overlay_and_cleans_up() {
3387 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3388
3389 let (mut tree, _lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
3390 tree.layout(SizeProposal::exact(400.0, 300.0));
3391
3392 let baseline = tree.overlay_manager().len();
3393
3394 tree.dispatch_event(WidgetEvent::PointerDown {
3396 position: Point::new(50.0, 15.0),
3397 button: PointerButton::Primary,
3398 modifiers: Modifiers::NONE,
3399 });
3400 tree.dispatch_event(WidgetEvent::PointerMove {
3401 position: Point::new(60.0, 15.0),
3402 });
3403
3404 assert_eq!(
3405 tree.overlay_manager().len(),
3406 baseline + 1,
3407 "Preview overlay should be live during drag"
3408 );
3409
3410 tree.dispatch_event(WidgetEvent::PointerUp {
3412 position: Point::new(60.0, 15.0),
3413 button: PointerButton::Primary,
3414 modifiers: Modifiers::NONE,
3415 });
3416 assert_eq!(
3417 tree.overlay_manager().len(),
3418 baseline,
3419 "Preview overlay should be dismissed after drop"
3420 );
3421 }
3422
3423 #[test]
3424 fn edge_auto_scroll_advances_scroll_y_during_drag() {
3425 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3426
3427 let (mut tree, _lv_id, _model) =
3429 make_reorderable_list((0..50).collect::<Vec<usize>>(), 30.0);
3430 tree.layout(SizeProposal::exact(400.0, 300.0));
3431
3432 tree.dispatch_event(WidgetEvent::PointerDown {
3435 position: Point::new(50.0, 15.0),
3436 button: PointerButton::Primary,
3437 modifiers: Modifiers::NONE,
3438 });
3439 tree.dispatch_event(WidgetEvent::PointerMove {
3440 position: Point::new(60.0, 15.0),
3441 });
3442 tree.dispatch_event(WidgetEvent::PointerMove {
3443 position: Point::new(60.0, 290.0), });
3445
3446 for _ in 0..8 {
3448 tree.layout(SizeProposal::exact(400.0, 300.0));
3449 }
3450 let scroll_y = with_list_view::<usize, _>(&tree, _lv_id, |lv| lv.scroll_y_signal().get());
3451 assert!(
3452 scroll_y > 5.0,
3453 "Edge auto-scroll should have advanced scroll_y; got {scroll_y}"
3454 );
3455
3456 tree.dispatch_event(WidgetEvent::PointerUp {
3458 position: Point::new(60.0, 290.0),
3459 button: PointerButton::Primary,
3460 modifiers: Modifiers::NONE,
3461 });
3462 }
3463
3464 fn nested_list_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>, Signal<f32>) {
3470 use crate::ScrollArea;
3471 use crate::primitives::{FixedSize, VStack};
3472 let mut tree = WidgetTree::new();
3473 let model = ListModel::from_vec((0..40_usize).collect());
3474 let lv = ListView::new(model, move |_i, _item, _sel| {
3475 Box::new(FixedLeaf(180.0, 30.0))
3476 })
3477 .item_height(30.0)
3478 .overscroll_behavior(inner);
3479 let inner_y = lv.scroll_y_signal().clone();
3480 let lv_id = tree.add(lv);
3481 let viewport = tree.add(FixedSize::new().width(200.0).height(100.0).child_id(lv_id));
3482 let filler = tree.add(FixedLeaf(200.0, 200.0));
3483 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
3484 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
3485 let outer_y = outer.scroll_y_signal().clone();
3486 let _outer = tree.add(outer);
3487 tree.layout(SizeProposal::exact(200.0, 150.0));
3488 (tree, inner_y, outer_y)
3489 }
3490
3491 #[test]
3492 fn nested_list_chains_to_outer_at_boundary() {
3493 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
3494 let (mut tree, inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Chain);
3495 tree.pointer_move(Point::new(50.0, 40.0));
3496 tree.dispatch_event(WidgetEvent::Scroll {
3497 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
3498 modifiers: Modifiers::NONE,
3499 });
3500 tree.layout(SizeProposal::exact(200.0, 150.0));
3501 let inner_bottom = inner_y.get();
3502 assert!(
3503 inner_bottom > 0.0,
3504 "inner list should scroll down; got {inner_bottom}"
3505 );
3506 assert!(
3507 outer_y.get() < 0.01,
3508 "outer must not move while the inner absorbs"
3509 );
3510
3511 tree.pointer_move(Point::new(50.0, 40.0));
3512 tree.dispatch_event(WidgetEvent::Scroll {
3513 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3514 modifiers: Modifiers::NONE,
3515 });
3516 tree.layout(SizeProposal::exact(200.0, 150.0));
3517 assert!(
3518 (inner_y.get() - inner_bottom).abs() < 0.01,
3519 "inner stays clamped at bottom"
3520 );
3521 assert!(
3522 outer_y.get() > 0.01,
3523 "outer scrolled because the inner chained the boundary"
3524 );
3525 }
3526
3527 #[test]
3528 fn nested_list_contain_blocks_chaining() {
3529 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
3530 let (mut tree, _inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Contain);
3531 tree.pointer_move(Point::new(50.0, 40.0));
3532 tree.dispatch_event(WidgetEvent::Scroll {
3533 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
3534 modifiers: Modifiers::NONE,
3535 });
3536 tree.layout(SizeProposal::exact(200.0, 150.0));
3537 tree.pointer_move(Point::new(50.0, 40.0));
3538 tree.dispatch_event(WidgetEvent::Scroll {
3539 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3540 modifiers: Modifiers::NONE,
3541 });
3542 tree.layout(SizeProposal::exact(200.0, 150.0));
3543 assert!(
3544 outer_y.get() < 0.01,
3545 "Contain must prevent chaining: outer stays put"
3546 );
3547 }
3548
3549 #[test]
3550 fn keyboard_selection_chases_outer_scroll_area() {
3551 use crate::ScrollArea;
3557 use crate::primitives::{FixedSize, VStack};
3558 use teksilo_core::event::{Key, Modifiers};
3559 use teksilo_data::{SelectionMode, SelectionModel};
3560
3561 let mut tree = WidgetTree::new();
3562 let model = ListModel::from_vec((0..20_usize).collect());
3563 let selection = SelectionModel::new(SelectionMode::Single);
3564 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(180.0, 20.0)))
3565 .item_height(20.0)
3566 .selection(selection);
3567 let lv_id = tree.add(lv);
3568 let lv_box = tree.add(FixedSize::new().width(200.0).height(200.0).child_id(lv_id));
3569 let filler = tree.add(FixedLeaf(200.0, 200.0));
3570 let outer_content = tree.add(VStack::new().add_child(lv_box).add_child(filler));
3571 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
3572 let outer_y = outer.scroll_y_signal().clone();
3573 let _outer = tree.add(outer);
3574 tree.layout(SizeProposal::exact(200.0, 100.0));
3575
3576 tree.focus(lv_id);
3579 tree.layout(SizeProposal::exact(200.0, 100.0));
3580 outer_y.set(0.0);
3581 tree.layout(SizeProposal::exact(200.0, 100.0));
3582 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
3583
3584 for _ in 0..20 {
3586 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3587 }
3588 tree.layout(SizeProposal::exact(200.0, 100.0));
3589
3590 assert!(
3591 outer_y.get() > 0.01,
3592 "selecting a row below the outer fold must scroll the enclosing \
3593 ScrollArea (got {})",
3594 outer_y.get()
3595 );
3596 }
3597
3598 fn item_spans(tree: &WidgetTree, lv_id: WidgetId) -> Vec<(f32, f32)> {
3603 let children = row_ids(tree, lv_id);
3604 let mut spans: Vec<(f32, f32)> = children[..]
3605 .iter()
3606 .map(|c| {
3607 let b = tree.bounds(*c);
3608 (b.y, b.height)
3609 })
3610 .collect();
3611 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
3612 spans
3613 }
3614
3615 #[test]
3616 fn exact_item_height_fn_positions_rows_at_callback_heights() {
3617 let heights = [100.0_f32, 20.0, 50.0];
3618 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
3619 let mut tree = WidgetTree::new();
3620 let lv_id = tree.add(
3621 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3622 .item_height_fn(move |i| heights[i]),
3623 );
3624 tree.layout(SizeProposal::exact(400.0, 300.0));
3625
3626 let spans = item_spans(&tree, lv_id);
3627 assert_eq!(spans.len(), 3);
3628 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 100.0).abs() < 0.01);
3629 assert!((spans[1].0 - 100.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
3630 assert!((spans[2].0 - 120.0).abs() < 0.01 && (spans[2].1 - 50.0).abs() < 0.01);
3631 }
3632
3633 #[test]
3634 fn exact_heights_with_spacing() {
3635 let heights = [100.0_f32, 20.0, 50.0];
3636 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
3637 let mut tree = WidgetTree::new();
3638 let lv_id = tree.add(
3639 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3640 .item_height_fn(move |i| heights[i])
3641 .spacing(8.0),
3642 );
3643 tree.layout(SizeProposal::exact(400.0, 300.0));
3644
3645 let spans = item_spans(&tree, lv_id);
3646 assert!((spans[1].0 - 108.0).abs() < 0.01);
3647 assert!((spans[2].0 - 136.0).abs() < 0.01);
3648 }
3649
3650 #[test]
3651 fn variable_heights_virtualize() {
3652 let model = ListModel::from_vec((0..10_000).collect::<Vec<usize>>());
3653 let mut tree = WidgetTree::new();
3654 let lv_id = tree.add(
3655 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3656 .item_height_fn(|i| 20.0 + (i % 5) as f32 * 10.0),
3657 );
3658 tree.layout(SizeProposal::exact(400.0, 300.0));
3659
3660 let item_count = row_ids(&tree, lv_id).len();
3661 assert!(
3662 item_count < 40,
3663 "Expected fewer than 40 realized rows, got {item_count}"
3664 );
3665 assert!(
3666 item_count >= 8,
3667 "Expected at least 8 rows, got {item_count}"
3668 );
3669 }
3670
3671 #[test]
3672 fn auto_measure_corrects_rows_from_estimate() {
3673 let model = ListModel::from_vec(vec![0_usize, 1, 2, 3]);
3676 let mut tree = WidgetTree::new();
3677 let lv_id = tree.add(
3678 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3679 .auto_item_height(50.0),
3680 );
3681 tree.layout(SizeProposal::exact(400.0, 300.0));
3682 tree.layout(SizeProposal::exact(400.0, 300.0));
3683
3684 let spans = item_spans(&tree, lv_id);
3685 assert!(
3686 (spans[1].0 - 30.0).abs() < 0.01,
3687 "row 1 should sit at measured 30, got {}",
3688 spans[1].0
3689 );
3690 assert!((spans[1].1 - 30.0).abs() < 0.01);
3691 }
3692
3693 #[test]
3694 fn auto_measure_under_realization_converges() {
3695 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
3699 let mut tree = WidgetTree::new();
3700 let lv_id = tree.add(
3701 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
3702 .auto_item_height(100.0),
3703 );
3704 for _ in 0..6 {
3706 tree.layout(SizeProposal::exact(400.0, 300.0));
3707 }
3708
3709 let spans = item_spans(&tree, lv_id);
3710 let mut expected_y = spans[0].0;
3712 for (y, h) in &spans {
3713 assert!(
3714 (y - expected_y).abs() < 0.01,
3715 "rows must tile contiguously: expected y {expected_y}, got {y}"
3716 );
3717 expected_y = y + h;
3718 }
3719 let last_bottom = spans.last().map(|(y, h)| y + h).unwrap();
3721 assert!(
3722 last_bottom >= 300.0,
3723 "realized rows must cover the viewport bottom, got {last_bottom}"
3724 );
3725 }
3726
3727 #[test]
3728 fn auto_measure_append_preserves_measured_prefix() {
3729 let model = ListModel::from_vec((0..4).collect::<Vec<usize>>());
3730 let mut tree = WidgetTree::new();
3731 let lv_id = tree.add(
3732 ListView::new(model.clone(), |_i, _item, _sel| {
3733 Box::new(FixedLeaf(100.0, 30.0))
3734 })
3735 .auto_item_height(50.0),
3736 );
3737 tree.layout(SizeProposal::exact(400.0, 300.0));
3738 tree.layout(SizeProposal::exact(400.0, 300.0));
3739
3740 model.push(99);
3744 tree.layout(SizeProposal::exact(400.0, 300.0));
3745 let spans = item_spans(&tree, lv_id);
3746 assert_eq!(spans.len(), 5);
3747 assert!(
3748 (spans[1].0 - 30.0).abs() < 0.01,
3749 "measured prefix must survive an append, got y {}",
3750 spans[1].0
3751 );
3752 }
3753
3754 #[test]
3755 fn scrollbar_reservation_self_corrects_after_auto_measure_flips_the_decision() {
3756 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
3779 let mut tree = WidgetTree::new();
3780 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 40.0)))
3781 .auto_item_height(20.0);
3782 let scroll_y = lv.scroll_y_signal().clone();
3783 let lv_id = tree.add(lv);
3784
3785 tree.layout(SizeProposal::exact(400.0, 300.0));
3786 let children = row_ids(&tree, lv_id);
3787 let item0_frame1 = tree.bounds(children[0]).width;
3788 let sb_frame1 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
3789 assert!(
3790 (item0_frame1 - 400.0).abs() < 0.01,
3791 "frame 1 uses the pre-measure (no-scrollbar) decision, got width {item0_frame1}"
3792 );
3793 assert!(
3794 sb_frame1 < 0.01,
3795 "frame 1's scrollbar is still collapsed from the same stale decision, got {sb_frame1}"
3796 );
3797
3798 scroll_y.set(0.0);
3802 tree.layout(SizeProposal::exact(400.0, 300.0));
3803 let children = row_ids(&tree, lv_id);
3804 let item0_frame2 = tree.bounds(children[0]).width;
3805 let sb_frame2 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
3806 assert!(
3807 (item0_frame2 - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
3808 "frame 2 must self-correct to the measured (needs-scrollbar) width, got {item0_frame2}"
3809 );
3810 assert!(
3811 (sb_frame2 - SCROLLBAR_THICKNESS).abs() < 0.01,
3812 "frame 2's scrollbar must appear once the measured total is known, got {sb_frame2}"
3813 );
3814 }
3815
3816 #[test]
3817 fn ensure_index_visible_with_variable_heights() {
3818 let model = ListModel::from_vec((0..100).collect::<Vec<usize>>());
3819 let heights = |i: usize| 20.0 + (i % 3) as f32 * 20.0; let mut tree = WidgetTree::new();
3821 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3822 .item_height_fn(heights);
3823 let scroll = lv.scroll_y_signal().clone();
3824 let lv_id = tree.add(lv);
3825 tree.layout(SizeProposal::exact(400.0, 300.0));
3826
3827 let top_20: f32 = (0..20).map(heights).sum();
3830 let bottom_20 = top_20 + heights(20);
3831
3832 tree.widget_as_any(lv_id)
3833 .and_then(|any| any.downcast_ref::<ListView<usize>>())
3834 .expect("ListView exposes itself via as_any")
3835 .ensure_index_visible(20);
3836 assert!(
3839 (scroll.get() - (bottom_20 - 300.0)).abs() < 0.5,
3840 "scroll {} != bottom {} - viewport",
3841 scroll.get(),
3842 bottom_20
3843 );
3844 }
3845
3846 #[test]
3847 fn drag_insertion_with_variable_heights() {
3848 let model = ListModel::from_vec(vec![10_usize, 20, 30, 40, 50]);
3852 let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
3853 let mut tree = WidgetTree::new();
3854 let lv_id = tree.add(
3855 ListView::new(model.clone(), |_i, _item, _sel| {
3856 Box::new(FixedLeaf(100.0, 30.0))
3857 })
3858 .item_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
3859 .reorderable(true),
3860 );
3861 tree.layout(SizeProposal::exact(400.0, 300.0));
3862
3863 let children = row_ids(&tree, lv_id);
3865 let from = tree.bounds(children[4]).center();
3866 drag_item(&mut tree, from, Point::new(from.x, 35.0));
3867
3868 assert_eq!(model.with_item(1, |v| *v), Some(50));
3870 assert_eq!(model.with_item(2, |v| *v), Some(20));
3871 }
3872
3873 #[allow(clippy::type_complexity)]
3876 type Captured = Rc<RefCell<Option<(Vec<usize>, Option<Vec<usize>>)>>>;
3877
3878 fn export_scene(
3882 values: Vec<usize>,
3883 mode: DragTransferMode,
3884 ) -> (WidgetTree, ListModel<usize>, SelectionModel, Captured) {
3885 use crate::primitives::{FixedSize, VStack};
3886 use teksilo_core::widget_builder::WidgetBuilder as _;
3887 let model = ListModel::from_vec(values);
3888 let sel = SelectionModel::new(teksilo_data::SelectionMode::Multi);
3889 let cap: Captured = Rc::new(RefCell::new(None));
3890 let cap2 = cap.clone();
3891 let lv = ListView::new(model.clone(), |_i, _item, _s| {
3892 Box::new(FixedLeaf(180.0, 30.0))
3893 })
3894 .item_height(30.0)
3895 .selection(sel.clone())
3896 .exportable(mode);
3897 let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
3898 if let Some(rd) = payload.take_typed::<RowDragData<usize>>() {
3899 *cap2.borrow_mut() = Some((rd.rows, rd.items));
3900 true
3901 } else {
3902 false
3903 }
3904 });
3905 let mut tree = WidgetTree::new();
3906 tree.add(
3907 VStack::new()
3908 .spacing(0.0)
3909 .child(FixedSize::new().height(120.0).child(lv))
3910 .child(sink),
3911 );
3912 tree.layout(SizeProposal::exact(200.0, 300.0));
3913 (tree, model, sel, cap)
3914 }
3915
3916 #[test]
3917 fn exportable_row_drops_on_foreign_sink_with_items() {
3918 let (mut tree, _model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
3919 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3920 let (rows, items) = cap.borrow().clone().expect("sink received a RowDragData");
3921 assert_eq!(rows, vec![0]);
3922 assert_eq!(items, Some(vec![10]));
3923 }
3924
3925 #[test]
3926 fn exportable_move_removes_source_row_after_foreign_accept() {
3927 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Move);
3928 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3929 assert!(cap.borrow().is_some(), "sink accepted the drop");
3930 assert_eq!(model.len(), 2);
3932 assert_eq!(model.with_item(0, |v| *v), Some(20));
3933 }
3934
3935 #[test]
3936 fn exportable_copy_leaves_source_intact() {
3937 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
3938 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3939 assert!(cap.borrow().is_some());
3940 assert_eq!(model.len(), 3);
3941 assert_eq!(model.with_item(0, |v| *v), Some(10));
3942 }
3943
3944 #[test]
3945 fn exportable_multi_selection_drags_the_whole_set() {
3946 let (mut tree, _model, sel, cap) =
3947 export_scene(vec![10, 20, 30, 40], DragTransferMode::Copy);
3948 sel.select_indices([0_usize, 2_usize], false);
3950 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3951 let (rows, items) = cap.borrow().clone().expect("received");
3952 assert_eq!(rows, vec![0, 2]);
3953 assert_eq!(items, Some(vec![10, 30]));
3954 }
3955
3956 #[test]
3957 fn reorder_only_view_is_not_exportable() {
3958 use crate::primitives::{FixedSize, VStack};
3961 use teksilo_core::widget_builder::WidgetBuilder as _;
3962 let model: ListModel<usize> = ListModel::from_vec(vec![1, 2, 3]);
3963 let is_export: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
3964 let probe = is_export.clone();
3965 let lv = ListView::new(model.clone(), |_i, _it, _s| {
3966 Box::new(FixedLeaf(180.0, 30.0))
3967 })
3968 .item_height(30.0)
3969 .selection(SelectionModel::new(teksilo_data::SelectionMode::Single))
3970 .reorderable(true);
3971 let sink = FixedLeaf(180.0, 80.0).on_drop(move |payload, _pos, _ctx| {
3972 probe.set(
3973 payload
3974 .get_typed::<RowDragData<usize>>()
3975 .map(|rd| rd.is_export()),
3976 );
3977 true
3978 });
3979 let mut tree = WidgetTree::new();
3980 tree.add(
3981 VStack::new()
3982 .spacing(0.0)
3983 .child(FixedSize::new().height(120.0).child(lv))
3984 .child(sink),
3985 );
3986 tree.layout(SizeProposal::exact(200.0, 300.0));
3987 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3988 assert_eq!(
3991 is_export.get(),
3992 Some(false),
3993 "reorder-only payload is not an export"
3994 );
3995 }
3996
3997 #[test]
4004 fn row_composite_tooltip_opens_for_the_hovered_row() {
4005 use crate::primitives::TextWidget;
4006 use std::time::Duration;
4007 use teksilo_i18n::lit;
4008
4009 let model = ListModel::from_vec(vec![
4010 "Alpha".to_string(),
4011 "Beta".to_string(),
4012 "Gamma".to_string(),
4013 ]);
4014 let mut tree = WidgetTree::new().with_text_backend(std::rc::Rc::new(
4015 std::cell::RefCell::new(teksilo_canvas::MockTextBackend::new()),
4016 ));
4017 let lv =
4018 tree.add(
4019 ListView::new(model, |_i, _it, _s| Box::new(FixedLeaf(180.0, 20.0)))
4020 .item_height(20.0)
4021 .row_composite_tooltip(|_i, item: &String| {
4022 Some(Box::new(TextWidget::new(lit!(format!("about {item}"))))
4023 as Box<dyn Widget>)
4024 }),
4025 );
4026 tree.layout(SizeProposal::exact(400.0, 200.0));
4027 assert!(tree.active_overlays().is_empty());
4028
4029 let bounds = tree.bounds(lv);
4031 tree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 30.0));
4032 tree.advance_time(Duration::from_millis(750));
4033
4034 assert_eq!(tree.active_overlays().len(), 1);
4035 assert!(
4036 tree.find_by_label("about Beta").is_some(),
4037 "the tip must carry the hovered row's own content"
4038 );
4039 }
4040
4041 #[test]
4042 fn accept_foreign_rows_receives_from_another_view() {
4043 use crate::primitives::{FixedSize, VStack};
4044 let a = ListModel::from_vec(vec![10, 20, 30]);
4046 let b = ListModel::from_vec(vec![100, 200]);
4047 let b_recv = b.clone();
4048 let lv_a = ListView::new(a.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4049 .item_height(30.0)
4050 .exportable(DragTransferMode::Move);
4051 let lv_b = ListView::new(b.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4052 .item_height(30.0)
4053 .accept_foreign_rows(true)
4054 .on_rows_received(move |items, at, _ctx| {
4055 for (k, v) in items.into_iter().enumerate() {
4056 b_recv.insert(at + k, v);
4057 }
4058 });
4059 let mut tree = WidgetTree::new();
4060 tree.add(
4061 VStack::new()
4062 .spacing(0.0)
4063 .child(FixedSize::new().height(90.0).child(lv_a))
4064 .child(FixedSize::new().height(150.0).child(lv_b))
4065 .child(FixedLeaf(180.0, 10.0)),
4066 );
4067 tree.layout(SizeProposal::exact(200.0, 300.0));
4068 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4070 assert_eq!(b.len(), 3, "receiver B gained the dragged row");
4072 assert!(
4073 (0..b.len()).any(|i| b.with_item(i, |v| *v) == Some(10)),
4074 "B contains the moved value 10"
4075 );
4076 assert_eq!(a.len(), 2, "source A removed the moved row");
4077 assert!(
4078 (0..a.len()).all(|i| a.with_item(i, |v| *v) != Some(10)),
4079 "A no longer contains 10"
4080 );
4081 }
4082
4083 #[test]
4084 fn two_views_over_same_model_do_not_spuriously_reorder() {
4085 use crate::primitives::{FixedSize, VStack};
4086 let model = ListModel::from_vec(vec![10, 20, 30]);
4090 let lv_a = ListView::new(model.clone(), |_i, _it, _s| {
4091 Box::new(FixedLeaf(180.0, 30.0))
4092 })
4093 .item_height(30.0)
4094 .reorderable(true);
4095 let lv_b = ListView::new(model.clone(), |_i, _it, _s| {
4096 Box::new(FixedLeaf(180.0, 30.0))
4097 })
4098 .item_height(30.0)
4099 .reorderable(true);
4100 let mut tree = WidgetTree::new();
4101 tree.add(
4102 VStack::new()
4103 .spacing(0.0)
4104 .child(FixedSize::new().height(90.0).child(lv_a))
4105 .child(FixedSize::new().height(150.0).child(lv_b))
4106 .child(FixedLeaf(180.0, 10.0)),
4107 );
4108 tree.layout(SizeProposal::exact(200.0, 300.0));
4109 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4110 assert_eq!(model.with_item(0, |v| *v), Some(10));
4112 assert_eq!(model.with_item(1, |v| *v), Some(20));
4113 assert_eq!(model.with_item(2, |v| *v), Some(30));
4114 }
4115
4116 #[test]
4117 fn exportable_not_reorderable_does_not_reorder_on_same_view_drop() {
4118 use crate::primitives::{FixedSize, VStack};
4119 let model: ListModel<usize> = ListModel::from_vec(vec![10, 20, 30, 40]);
4123 let lv = ListView::new(model.clone(), |_i, _it, _s| {
4124 Box::new(FixedLeaf(180.0, 30.0))
4125 })
4126 .item_height(30.0)
4127 .exportable(DragTransferMode::Move)
4128 .accept_foreign_rows(true)
4129 .on_rows_received(|_items, _at, _ctx| {});
4130 let mut tree = WidgetTree::new();
4131 tree.add(
4132 VStack::new()
4133 .spacing(0.0)
4134 .child(FixedSize::new().height(200.0).child(lv))
4135 .child(FixedLeaf(180.0, 10.0)),
4136 );
4137 tree.layout(SizeProposal::exact(200.0, 300.0));
4138 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 75.0));
4140 assert_eq!(model.with_item(0, |v| *v), Some(10));
4142 assert_eq!(model.with_item(3, |v| *v), Some(40));
4143 }
4144}