1use std::cell::{Cell, RefCell};
50use std::rc::Rc;
51use std::time::Duration;
52
53use teksilo_canvas::{Point, Rect, Size, SizeProposal};
54use teksilo_tokens::{BorderRole, Easing};
55
56use teksilo_core::DropFeedback;
57use teksilo_core::accessibility::AccessNodeBuilder;
58use teksilo_core::binding::BindingLevel;
59use teksilo_core::drag_payload::DragPayload;
60use teksilo_core::signal::{Prop, Signal};
61use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
62use teksilo_core::widget_builder::HandlerSet;
63use teksilo_core::widget_id::WidgetId;
64
65use teksilo_data::selection_model::SelectionModel;
66use teksilo_data::{ItemKey, KeyedSelectionModel};
67
68use crate::data_views::RowSelection;
69use teksilo_data::{DataChange, DropPosition, DropResponse, ListModel};
70
71use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
72use crate::common::scroll::OverscrollBehavior;
73use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
74use crate::list_source::ListSource;
75use crate::scroll_area::ScrollBarMode;
76use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
77
78mod body_pane;
79
80const BUFFER_ITEMS: usize = 5;
82const DEFAULT_ITEM_HEIGHT: f32 = 32.0;
84const SCROLLBAR_THICKNESS: f32 = 12.0;
86
87pub struct ListView<T: 'static> {
91 source: ListSource<T>,
92 delegate: Rc<dyn Fn(usize, &T, bool) -> Box<dyn Widget>>,
93 row_tooltips: crate::data_views::RowTooltips<T>,
96 item_height: f32,
97 spacing: f32,
98 height_source: HeightSource,
100 metrics: SharedRowMetrics,
105 row_selection: Option<RowSelection>,
108
109 focused_index: Rc<Cell<Option<usize>>>,
111
112 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
119
120 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
126 type_ahead_timeout: Duration,
128 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
132
133 reorderable: bool,
135
136 show_scrollbar: bool,
142
143 scroll_y: Signal<f32>,
145 max_scroll_y: Signal<f32>,
146 overscroll_behavior: OverscrollBehavior,
148 viewport_ratio_y: Signal<f32>,
149
150 smooth_scrolling: bool,
153 smooth_scroll_duration: Duration,
155
156 scroll_bar_style: ScrollBarMode,
159
160 drop_feedback: Signal<Option<(f32, f32)>>, placed_content_width: Rc<Cell<f32>>,
166
167 on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
171 activate_on: crate::data_views::ActivateOn,
173
174 view_focused: Signal<bool>,
179 focus_visible: Signal<bool>,
182
183 layout_refresh: Signal<u64>,
190 paint_refresh: Signal<u64>,
195
196 pane_version: Signal<u64>,
201 pane_built_start: Rc<Cell<usize>>,
203 pane_built_end: Rc<Cell<usize>>,
204
205 body_pane_id: Option<WidgetId>,
207 scrollbar_id: Option<WidgetId>,
208 viewport_height: Rc<Cell<f32>>,
213 viewport_bounds: Rc<Cell<Rect>>,
221
222 model_id: ViewId,
225
226 export: crate::data_views::RowExport<T>,
231
232 enabled: Prop<bool>,
238}
239
240impl<T: 'static> ListView<T> {
241 pub fn new(
246 model: ListModel<T>,
247 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
248 ) -> Self {
249 Self::create(ListSource::from_model(model), delegate)
250 }
251
252 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
257 source: S,
258 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
259 ) -> Self {
260 Self::create(ListSource::from_data_source(source), delegate)
261 }
262
263 pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
271 source: S,
272 keyed: KeyedSelectionModel<S::Key>,
273 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
274 ) -> Self
275 where
276 S::Key: ItemKey,
277 {
278 let s = Rc::new(source);
279 let key_at = {
280 let s = s.clone();
281 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
282 };
283 let len = {
284 let s = s.clone();
285 Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
286 };
287 let contains = {
290 let s = s.clone();
291 Rc::new(move |k: &S::Key| (0..s.len()).any(|i| s.key_at(i).as_ref() == Some(k)))
292 as Rc<dyn Fn(&S::Key) -> bool>
293 };
294 let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
295 let mut view = Self::create(ListSource::from_data_source_rc(s), delegate);
296 view.row_selection = Some(row_selection);
297 view
298 }
299
300 pub(crate) fn from_list_source(
305 source: ListSource<T>,
306 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
307 ) -> Self {
308 Self::create(source, delegate)
309 }
310
311 fn create(
312 source: ListSource<T>,
313 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
314 ) -> Self {
315 let model_id = ViewId::next(ViewKind::List);
316 Self {
317 model_id,
318 export: crate::data_views::RowExport::default(),
319 source,
320 delegate: Rc::new(delegate),
321 row_tooltips: Default::default(),
322 item_height: DEFAULT_ITEM_HEIGHT,
323 spacing: 0.0,
324 height_source: HeightSource::Uniform,
325 metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
326 row_selection: None,
327 focused_index: Rc::new(Cell::new(None)),
328 row_map: Rc::new(RefCell::new(Vec::new())),
329 type_ahead_label: None,
330 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
331 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
332 reorderable: false,
333 show_scrollbar: true,
334 drop_feedback: Signal::new(None),
335 view_focused: Signal::new(false),
337 focus_visible: Signal::new(false),
338 placed_content_width: Rc::new(Cell::new(0.0)),
339 on_activate: None,
340 activate_on: crate::data_views::ActivateOn::default(),
341 overscroll_behavior: OverscrollBehavior::default(),
342 smooth_scrolling: true,
343 smooth_scroll_duration: Duration::from_millis(150),
344 scroll_bar_style: ScrollBarMode::Permanent,
345 scroll_y: Signal::new_animated(0.0),
346 max_scroll_y: Signal::new(0.0),
347 viewport_ratio_y: Signal::new(1.0),
348 layout_refresh: Signal::new(0_u64),
349 paint_refresh: Signal::new(0_u64),
350 pane_version: Signal::new(0_u64),
351 pane_built_start: Rc::new(Cell::new(0)),
352 pane_built_end: Rc::new(Cell::new(0)),
353 body_pane_id: None,
354 scrollbar_id: None,
355 viewport_height: Rc::new(Cell::new(600.0)),
356 viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
357 enabled: Prop::Static(true),
358 }
359 }
360
361 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
364 self.enabled = enabled.into();
365 self
366 }
367
368 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
372 self.overscroll_behavior = behavior;
373 self
374 }
375
376 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
378 self.smooth_scrolling = enabled;
379 self
380 }
381
382 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
384 self.smooth_scroll_duration = duration;
385 self
386 }
387
388 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
392 self.scroll_bar_style = style;
393 self
394 }
395
396 fn remake_metrics(&self) {
399 *self.metrics.borrow_mut() = self
400 .height_source
401 .make_metrics(self.item_height, self.spacing);
402 }
403
404 pub fn item_height(mut self, height: f32) -> Self {
409 self.item_height = height;
410 self.height_source = HeightSource::Uniform;
411 self.remake_metrics();
412 self
413 }
414
415 pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
420 self.height_source = HeightSource::Exact(Rc::new(f));
421 self.remake_metrics();
422 self
423 }
424
425 pub fn auto_item_height(mut self, estimated: f32) -> Self {
432 self.height_source = HeightSource::Auto { estimated };
433 self.remake_metrics();
434 self
435 }
436
437 pub fn spacing(mut self, spacing: f32) -> Self {
439 self.spacing = spacing;
440 self.remake_metrics();
441 self
442 }
443
444 pub fn selection(mut self, sel: SelectionModel) -> Self {
448 self.row_selection = Some(RowSelection::from_index(sel));
449 self
450 }
451
452 pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>> {
470 self.row_map.clone()
471 }
472
473 pub fn reorderable(mut self, enabled: bool) -> Self {
482 self.reorderable = enabled;
483 self
484 }
485
486 pub fn exportable(mut self, mode: DragTransferMode) -> Self
514 where
515 T: Clone,
516 {
517 self.export.set_exportable(mode);
518 self
519 }
520
521 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
529 where
530 T: Clone,
531 {
532 self.export.set_export_external(f);
533 self
534 }
535
536 pub fn on_rows_transferred_out(
542 mut self,
543 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
544 ) -> Self {
545 self.export.set_on_rows_transferred_out(f);
546 self
547 }
548
549 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
556 self.export.accept_foreign_rows = accept;
557 self
558 }
559
560 pub fn on_rows_received(
564 mut self,
565 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
566 ) -> Self {
567 self.export.set_on_rows_received(f);
568 self
569 }
570
571 pub fn on_activate(
580 mut self,
581 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
582 ) -> Self {
583 self.on_activate = Some(Rc::new(f));
584 self
585 }
586
587 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
591 self.activate_on = mode;
592 self
593 }
594
595 pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
610 self.row_tooltips.set_composite_sticky(on);
611 self
612 }
613
614 pub fn row_tooltip(
625 mut self,
626 f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
627 ) -> Self {
628 self.row_tooltips.set_plain(f);
629 self
630 }
631
632 pub fn row_rich_tooltip(
636 mut self,
637 f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
638 ) -> Self {
639 self.row_tooltips.set_rich(f);
640 self
641 }
642
643 pub fn row_composite_tooltip(
650 mut self,
651 f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
652 ) -> Self {
653 self.row_tooltips.set_composite(f);
654 self
655 }
656
657 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
658 self.type_ahead_label = Some(Rc::new(label));
659 self
660 }
661
662 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
665 self.type_ahead_timeout = timeout;
666 self
667 }
668
669 pub fn show_scrollbar(mut self, show: bool) -> Self {
678 self.show_scrollbar = show;
679 self
680 }
681
682 fn total_content_height(&self) -> f32 {
684 self.metrics.borrow_mut().total_height(self.source.len())
685 }
686
687 fn visible_range(&self) -> (usize, usize) {
689 self.metrics.borrow_mut().visible_range(
690 self.scroll_y.get(),
691 self.viewport_height.get(),
692 self.source.len(),
693 BUFFER_ITEMS,
694 )
695 }
696
697 fn child_ids(&self) -> Vec<WidgetId> {
702 [self.body_pane_id, self.scrollbar_id]
703 .into_iter()
704 .flatten()
705 .collect()
706 }
707
708 fn clamp_scroll(&self) {
710 let max = self.max_scroll_y.get();
711 let current = self.scroll_y.get();
712 let clamped = current.clamp(0.0, max);
713 if (clamped - current).abs() > 0.001 {
714 self.scroll_y.set(clamped);
715 }
716 }
717
718 #[cfg(test)]
721 pub(crate) fn drop_feedback_signal(&self) -> &Signal<Option<(f32, f32)>> {
722 &self.drop_feedback
723 }
724
725 pub fn scroll_y_signal(&self) -> &Signal<f32> {
732 &self.scroll_y
733 }
734
735 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
739 &self.max_scroll_y
740 }
741
742 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
745 &self.viewport_ratio_y
746 }
747
748 pub fn scroll_to_index(&self, index: usize) {
753 let target = self.metrics.borrow_mut().row_top(index);
754 let max = self.max_scroll_y.get();
755 self.scroll_y.set(target.clamp(0.0, max));
756 }
757
758 pub fn ensure_index_visible(&self, index: usize) {
761 let scroll = self.scroll_y.get();
762 let new_scroll = self.metrics.borrow_mut().scroll_for_ensure_visible(
763 index,
764 scroll,
765 self.viewport_height.get(),
766 self.max_scroll_y.get(),
767 );
768 if (new_scroll - scroll).abs() > f32::EPSILON {
769 self.scroll_y.set(new_scroll);
770 }
771 }
772}
773
774impl<T: 'static> std::fmt::Debug for ListView<T> {
775 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776 f.debug_struct("ListView")
777 .field("item_count", &self.source.len())
778 .field("item_height", &self.item_height)
779 .field("scroll_bar_style", &self.scroll_bar_style)
780 .field("scroll_y", &self.scroll_y.get())
781 .finish()
782 }
783}
784
785impl<T: 'static> Widget for ListView<T> {
786 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
787 let self_id = ctx.self_id();
794 ctx.enabled_when(self_id, self.enabled.clone());
795
796 self.layout_refresh.bind_to(
800 ctx.self_id(),
801 ctx.binding_registry(),
802 BindingLevel::Relayout,
803 );
804 self.paint_refresh.bind_to(
808 ctx.self_id(),
809 ctx.binding_registry(),
810 BindingLevel::RepaintOnly,
811 );
812
813 self.scroll_y.bind_to(
817 ctx.self_id(),
818 ctx.binding_registry(),
819 BindingLevel::Relayout,
820 );
821
822 ctx.register_animated_signal(&self.scroll_y);
828
829 self.drop_feedback.bind_to(
833 ctx.self_id(),
834 ctx.binding_registry(),
835 BindingLevel::RepaintOnly,
836 );
837
838 self.view_focused = ctx.begin_view_focus();
848 ctx.end_view_focus();
849 self.focus_visible = ctx.focus_visible();
850 self.view_focused.bind_to(
851 ctx.self_id(),
852 ctx.binding_registry(),
853 BindingLevel::RepaintOnly,
854 );
855 self.focus_visible.bind_to(
856 ctx.self_id(),
857 ctx.binding_registry(),
858 BindingLevel::RepaintOnly,
859 );
860
861 let pane_version_for_data = self.pane_version.clone();
867 let layout_refresh_for_data = self.layout_refresh.clone();
868 let data_ver = Rc::new(Cell::new(0_u64));
869 let data_handle = (self.source.observe_fn)(Box::new({
870 let dv = data_ver.clone();
871 let metrics = self.metrics.clone();
872 let len_fn = self.source.len_fn.clone();
873 let first_changed = self.source.first_changed_fn.clone();
874 let row_sel = self.row_selection.clone();
875 let focused = self.focused_index.clone();
876 move |change| {
877 let divergence = match change {
881 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
882 Some(range.start)
883 }
884 DataChange::ItemUpdated { index } => Some(*index),
885 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
886 DataChange::WindowLoaded { range } => Some(range.start),
888 DataChange::Reset => (first_changed)(),
891 };
892 metrics
893 .borrow_mut()
894 .apply_divergence(divergence, (len_fn)());
895 if let Some(ref rs) = row_sel {
898 rs.on_data_change(change);
899 }
900 if let Some(current) = focused.get() {
905 focused.set(teksilo_data::data_change::adjust_single_index_for_change(
906 current, change,
907 ));
908 }
909 let next = dv.get() + 1;
910 dv.set(next);
911 pane_version_for_data.set(next);
912 layout_refresh_for_data.set(next);
913 }
914 }));
915 ctx.own_handle(data_handle);
916
917 if let Some(ref rs) = self.row_selection {
922 let paint_refresh_for_sel = self.paint_refresh.clone();
923 let sel_ver = Rc::new(Cell::new(0_u64));
924 let handle = rs.observe_for_rebuild(move || {
925 let next = sel_ver.get() + 1;
926 sel_ver.set(next);
927 paint_refresh_for_sel.set(next);
928 });
929 ctx.own_handle(handle);
930 }
931
932 let scroll_y = self.scroll_y.clone();
939 let max_scroll = self.max_scroll_y.clone();
940 let line_height = self.item_height;
941 let overscroll_behavior = self.overscroll_behavior;
942 let smooth_scrolling = self.smooth_scrolling;
943 let smooth_scroll_duration = self.smooth_scroll_duration;
944 let mut handlers = HandlerSet::new()
945 .on_scroll(move |event, _ctx| match event {
946 teksilo_core::event::WidgetEvent::Scroll { delta, .. } => {
947 let dy = match delta {
948 teksilo_core::event::ScrollDelta::Lines { y, .. } => y * line_height,
949 teksilo_core::event::ScrollDelta::Pixels { y, .. } => *y,
950 };
951 let current = scroll_y.get();
952 let max = max_scroll.get();
953 let base = scroll_y.animation_target().unwrap_or(current);
956 let (new_y, moved) = crate::common::scroll::scroll_clamp_axis(base, dy, max);
957 if moved {
958 if smooth_scrolling {
959 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
960 } else {
961 scroll_y.set(new_y);
962 }
963 }
964 crate::common::scroll::scroll_response(
967 moved,
968 overscroll_behavior == OverscrollBehavior::Contain,
969 )
970 }
971 _ => teksilo_core::event::EventResponse::Ignored,
972 })
973 .clips_children(true)
974 .focusable(true);
975
976 {
978 let len_for_key = self.source.len_fn.clone();
979 let accept_drop_for_key = self.source.dnd.accept_drop_fn.clone();
980 let stash_for_key = self.source.dnd.stash_drag_keys_fn.clone();
981 let view_id_for_key = self.model_id;
982 let sel_for_key = self.row_selection.clone();
983 let activate_key = self.on_activate.clone();
984 let fi = self.focused_index.clone();
985 let reorderable = self.reorderable;
986 let scroll_for_nav = self.scroll_y.clone();
987 let metrics_for_nav = self.metrics.clone();
988 let max_for_nav = self.max_scroll_y.clone();
989 let vh_for_nav = self.viewport_height.clone();
990 let vb_for_nav = self.viewport_bounds.clone();
991 let ta_state = self.type_ahead.clone();
994 let ta_label = self.type_ahead_label.clone();
995 let ta_timeout = self.type_ahead_timeout;
996 let with_item_str = self.source.with_item_str_fn.clone();
997
998 handlers = handlers.on_key(move |event, ctx| {
999 if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
1000 use teksilo_core::event::Key;
1001 let count = (len_for_key)();
1002 if count == 0 {
1003 return teksilo_core::event::EventResponse::Ignored;
1004 }
1005
1006 if modifiers.command() && matches!(key, Key::A) {
1009 if let Some(ref sel) = sel_for_key
1010 && sel.mode() == teksilo_data::SelectionMode::Multi
1011 {
1012 sel.select_all(count);
1013 return teksilo_core::event::EventResponse::Handled;
1014 }
1015 return teksilo_core::event::EventResponse::Ignored;
1016 }
1017
1018 if ta_label.is_some()
1022 && !modifiers.ctrl()
1023 && !modifiers.alt()
1024 && !modifiers.super_key()
1025 && let Some(c) = key.to_char()
1026 {
1027 let current = fi.get().unwrap_or(0).min(count - 1);
1028 let label = ta_label.as_ref().unwrap();
1029 if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
1030 (with_item_str)(i, &|item| label(item))
1031 }) {
1032 fi.set(Some(idx));
1033 if let Some(ref sel) = sel_for_key {
1034 sel.select(idx);
1035 }
1036 let scroll = scroll_for_nav.get();
1037 let new_scroll =
1038 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1039 idx,
1040 scroll,
1041 vh_for_nav.get(),
1042 max_for_nav.get(),
1043 );
1044 if (new_scroll - scroll).abs() > f32::EPSILON {
1045 scroll_for_nav.set(new_scroll);
1046 }
1047 crate::common::row_metrics::chase_row_into_outer_view(
1048 ctx,
1049 &metrics_for_nav,
1050 vb_for_nav.get(),
1051 idx,
1052 new_scroll,
1053 );
1054 return teksilo_core::event::EventResponse::Handled;
1055 }
1056 return teksilo_core::event::EventResponse::Ignored;
1057 }
1058
1059 if modifiers.alt() && reorderable {
1064 let selected_idx = sel_for_key
1065 .as_ref()
1066 .and_then(|s| s.selected_indices().first().copied());
1067 if let Some(idx) = selected_idx {
1068 let mv = match key {
1069 teksilo_core::event::Key::ArrowUp if idx > 0 => {
1070 Some((idx - 1, DropPosition::Before, idx - 1))
1071 }
1072 teksilo_core::event::Key::ArrowDown if idx + 1 < count => {
1073 Some((idx + 1, DropPosition::After, idx + 1))
1074 }
1075 _ => None,
1076 };
1077 if let Some((target, position, dest)) = mv {
1078 (stash_for_key)(&[idx]);
1083 let payload = DragPayload::typed(RowDragData::<T> {
1084 source: view_id_for_key,
1085 rows: vec![idx],
1086 items: None,
1087 });
1088 if (accept_drop_for_key)(
1089 &payload,
1090 target,
1091 position,
1092 view_id_for_key,
1093 ) {
1094 if let Some(ref sel) = sel_for_key {
1095 sel.select(dest);
1096 }
1097 fi.set(Some(dest));
1098 let scroll = scroll_for_nav.get();
1101 let new_scroll =
1102 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1103 dest,
1104 scroll,
1105 vh_for_nav.get(),
1106 max_for_nav.get(),
1107 );
1108 if (new_scroll - scroll).abs() > f32::EPSILON {
1109 scroll_for_nav.set(new_scroll);
1110 }
1111 crate::common::row_metrics::chase_row_into_outer_view(
1112 ctx,
1113 &metrics_for_nav,
1114 vb_for_nav.get(),
1115 dest,
1116 new_scroll,
1117 );
1118 }
1119 return teksilo_core::event::EventResponse::Handled;
1120 }
1121 }
1122 }
1123
1124 let cursor = fi
1140 .get()
1141 .or_else(|| {
1142 sel_for_key
1143 .as_ref()
1144 .and_then(|s| s.selected_indices().first().copied())
1145 })
1146 .map(|i| i.min(count - 1));
1147 let current = cursor.unwrap_or(0);
1150 let new_idx = match key {
1151 Key::ArrowDown => Some(match cursor {
1152 None => 0,
1153 Some(c) => (c + 1).min(count - 1),
1154 }),
1155 Key::ArrowUp => Some(match cursor {
1156 None => count - 1,
1157 Some(c) => c.saturating_sub(1),
1158 }),
1159 Key::Home => Some(0),
1160 Key::End => Some(count - 1),
1161 Key::PageDown => {
1165 let vh = vh_for_nav.get();
1166 let r = {
1167 let mut m = metrics_for_nav.borrow_mut();
1168 m.resize(count);
1169 let target = m.row_top(current) + vh;
1170 m.row_at(target)
1171 };
1172 Some(if r == current {
1173 (current + 1).min(count - 1)
1174 } else {
1175 r.min(count - 1)
1176 })
1177 }
1178 Key::PageUp => {
1179 let vh = vh_for_nav.get();
1180 let r = {
1181 let mut m = metrics_for_nav.borrow_mut();
1182 m.resize(count);
1183 let target = (m.row_top(current) - vh).max(0.0);
1184 m.row_at(target)
1185 };
1186 Some(if r == current {
1187 current.saturating_sub(1)
1188 } else {
1189 r
1190 })
1191 }
1192 Key::Enter => {
1193 if let Some(ref sel) = sel_for_key {
1195 sel.select(current);
1196 }
1197 if let Some(ref cb) = activate_key {
1198 cb(current, ctx);
1199 }
1200 return teksilo_core::event::EventResponse::Handled;
1201 }
1202 Key::Space if modifiers.ctrl() => {
1203 if let Some(ref sel) = sel_for_key {
1220 sel.toggle(current);
1221 }
1222 fi.set(Some(current));
1223 return teksilo_core::event::EventResponse::Handled;
1224 }
1225 Key::Space => {
1226 if let Some(ref sel) = sel_for_key {
1231 if sel.mode() == teksilo_data::SelectionMode::Multi {
1232 sel.toggle(current);
1233 } else {
1234 sel.select(current);
1235 }
1236 }
1237 fi.set(Some(current));
1238 return teksilo_core::event::EventResponse::Handled;
1239 }
1240 _ => None,
1241 };
1242
1243 if let Some(idx) = new_idx {
1244 fi.set(Some(idx));
1245 let cursor_only = modifiers.ctrl()
1255 && !modifiers.shift()
1256 && matches!(key, Key::ArrowUp | Key::ArrowDown);
1257 if !cursor_only && let Some(ref sel) = sel_for_key {
1258 if modifiers.shift() {
1259 sel.extend_to(idx);
1260 } else {
1261 sel.select(idx);
1262 }
1263 }
1264 let scroll = scroll_for_nav.get();
1267 let new_scroll = metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1268 idx,
1269 scroll,
1270 vh_for_nav.get(),
1271 max_for_nav.get(),
1272 );
1273 if (new_scroll - scroll).abs() > f32::EPSILON {
1274 scroll_for_nav.set(new_scroll);
1275 }
1276 crate::common::row_metrics::chase_row_into_outer_view(
1277 ctx,
1278 &metrics_for_nav,
1279 vb_for_nav.get(),
1280 idx,
1281 new_scroll,
1282 );
1283 return teksilo_core::event::EventResponse::Handled;
1284 }
1285 }
1286 teksilo_core::event::EventResponse::Ignored
1287 });
1288 }
1289
1290 if self.export.is_drop_target(self.reorderable) {
1294 let metrics_for_hover = self.metrics.clone();
1295 let scroll_for_hover = self.scroll_y.clone();
1296 let len_for_hover = self.source.len_fn.clone();
1297 let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
1298 let my_view_id = self.model_id;
1299
1300 let feedback_for_hover = self.drop_feedback.clone();
1301 let width_for_hover = self.placed_content_width.clone();
1302 let export_for_hover = self.export.clone();
1303 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1304 let scroll = scroll_for_hover.get().max(0.0);
1305 let content_y = position.y + scroll;
1306 let len = (len_for_hover)();
1307 let (insertion_y, ins) = {
1308 let mut m = metrics_for_hover.borrow_mut();
1309 m.resize(len);
1310 let ins = m.insertion_index(content_y);
1311 (m.row_top(ins) - scroll, ins)
1312 };
1313 let line_width = width_for_hover.get();
1314 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1319 !matches!(
1320 (can_accept_for_hover)(payload, target, pos, my_view_id),
1321 DropResponse::Reject
1322 ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
1323 });
1324 if allowed {
1325 feedback_for_hover.set(Some((insertion_y, line_width)));
1326 DropFeedback::InsertionLine {
1327 y: insertion_y,
1328 width: line_width,
1329 }
1330 } else {
1331 feedback_for_hover.set(None);
1332 DropFeedback::NoFeedback
1333 }
1334 });
1335
1336 let len_for_drop = self.source.len_fn.clone();
1337 let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
1338 let drop_view_id = self.model_id;
1339 let scroll_for_drop = self.scroll_y.clone();
1340 let metrics_for_drop = self.metrics.clone();
1341 let export_for_drop = self.export.clone();
1342 let reorderable_for_drop = self.reorderable;
1343
1344 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1345 let scroll = scroll_for_drop.get().max(0.0);
1346 let content_y = position.y + scroll;
1347 let len = (len_for_drop)();
1348 let ins = {
1349 let mut m = metrics_for_drop.borrow_mut();
1350 m.resize(len);
1351 m.insertion_index(content_y)
1352 };
1353 let is_same_view = payload
1354 .get_typed::<RowDragData<T>>()
1355 .is_some_and(|rd| rd.source == drop_view_id);
1356 if (reorderable_for_drop || !is_same_view)
1360 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
1361 && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
1362 {
1363 if is_same_view {
1364 export_for_drop.note_self_reorder();
1365 }
1366 return true;
1367 }
1368 export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
1370 });
1371
1372 let feedback_for_leave = self.drop_feedback.clone();
1376 handlers = handlers.on_drag_leave(move |_ctx| {
1377 feedback_for_leave.set(None);
1378 });
1379
1380 let scroll_for_tick = self.scroll_y.clone();
1386 let max_scroll_for_tick = self.max_scroll_y.clone();
1387 let viewport_for_tick = self.viewport_height.clone();
1388 handlers = handlers.on_drag_tick(move |pos, _ctx| {
1389 const EDGE: f32 = 32.0;
1390 const MAX_VELOCITY: f32 = 12.0;
1391 let h = viewport_for_tick.get();
1392 let above = (EDGE - pos.y).max(0.0);
1393 let below = (pos.y - (h - EDGE)).max(0.0);
1394 let delta = if above > 0.0 {
1395 -(above / EDGE) * MAX_VELOCITY
1396 } else if below > 0.0 {
1397 (below / EDGE) * MAX_VELOCITY
1398 } else {
1399 0.0
1400 };
1401 if delta.abs() > 0.01 {
1402 let max = max_scroll_for_tick.get();
1403 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
1404 scroll_for_tick.set(new_y);
1405 }
1406 });
1407 }
1408
1409 handlers = self.export.install_completion(handlers);
1412
1413 ctx.apply_self_handlers(handlers);
1414
1415 let pane = body_pane::ListBodyPane::<T> {
1423 source: self.source.clone(),
1424 delegate: self.delegate.clone(),
1425 row_tooltips: self.row_tooltips.clone(),
1426 metrics: self.metrics.clone(),
1427 row_selection: self.row_selection.clone(),
1428 focused_index: self.focused_index.clone(),
1429 row_map: self.row_map.clone(),
1430 reorderable: self.reorderable,
1431 export: self.export.clone(),
1432 on_activate: self.on_activate.clone(),
1433 activate_on: self.activate_on,
1434 model_id: self.model_id,
1435 root_id: self_id,
1436 scroll_y: self.scroll_y.clone(),
1437 viewport_height: self.viewport_height.clone(),
1438 placed_content_width: self.placed_content_width.clone(),
1439 version: self.pane_version.clone(),
1440 total_refresh: self.layout_refresh.clone(),
1441 prev_built_start: self.pane_built_start.clone(),
1442 prev_built_end: self.pane_built_end.clone(),
1443 item_entries: Vec::new(),
1444 };
1445 self.body_pane_id = Some(ctx.add(pane));
1446
1447 if self.show_scrollbar {
1452 let scrollbar = ScrollBar::new(
1453 ScrollBarOrientation::Vertical,
1454 self.scroll_y.clone(),
1455 self.max_scroll_y.clone(),
1456 self.viewport_ratio_y.clone(),
1457 )
1458 .visual(match self.scroll_bar_style {
1459 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1460 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1461 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1462 });
1463 let sb_id = ctx.add(scrollbar);
1464 self.scrollbar_id = Some(sb_id);
1465 } else {
1466 self.scrollbar_id = None;
1467 }
1468
1469 self.child_ids()
1470 }
1471
1472 fn layout_response(
1473 &self,
1474 proposal: SizeProposal,
1475 _ctx: &LayoutContext,
1476 ) -> teksilo_core::widget::LayoutResponse {
1477 crate::common::viewport::viewport_size(
1481 proposal,
1482 &self.viewport_height,
1483 Size::new(300.0, 200.0),
1484 )
1485 .into()
1486 }
1487
1488 fn place_children(
1489 &self,
1490 bounds: Rect,
1491 _proposal: SizeProposal,
1492 children: &mut [WidgetPlacement],
1493 _ctx: &LayoutContext,
1494 ) {
1495 self.viewport_bounds.set(bounds);
1499 crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
1503
1504 if children.is_empty() {
1505 return;
1506 }
1507
1508 let viewport_height = bounds.height;
1509
1510 let provisional_total = self.total_content_height();
1514 let needs_internal_scrollbar =
1515 self.show_scrollbar && provisional_total > viewport_height + 0.5;
1516 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1517 let content_width = if needs_internal_scrollbar && reserves_bar {
1518 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1519 } else {
1520 bounds.width
1521 };
1522 self.placed_content_width.set(content_width);
1523
1524 let total_height = self.total_content_height();
1529 let max_y = (total_height - viewport_height).max(0.0);
1530 self.max_scroll_y.set(max_y);
1531 let ratio = if total_height > 0.0 {
1532 (viewport_height / total_height).clamp(0.0, 1.0)
1533 } else {
1534 1.0
1535 };
1536 self.viewport_ratio_y.set(ratio);
1537 self.clamp_scroll();
1538
1539 let mut next = 0;
1543 if self.body_pane_id.is_some() {
1544 if let Some(child) = children.get_mut(next) {
1545 child.origin = bounds.origin();
1546 child.size = Size::new(content_width, bounds.height);
1547 }
1548 next += 1;
1549 }
1550 if self.scrollbar_id.is_some()
1551 && let Some(sb_child) = children.get_mut(next)
1552 {
1553 if needs_internal_scrollbar {
1554 sb_child.origin =
1555 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1556 sb_child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1557 } else {
1558 sb_child.origin = bounds.origin();
1559 sb_child.size = Size::ZERO;
1560 }
1561 }
1562 }
1563
1564 fn paint(
1565 &self,
1566 bounds: Rect,
1567 canvas: &mut teksilo_canvas::Canvas,
1568 ctx: &teksilo_core::widget::PaintContext,
1569 ) {
1570 if let Some((y, width)) = self.drop_feedback.get() {
1574 let recipe = ctx
1575 .theme
1576 .style_slots
1577 .list_container
1578 .as_ref()
1579 .map(|s| s.insertion())
1580 .unwrap_or_default();
1581 let color = recipe.role.resolve(&ctx.theme.colors);
1582 let line_y = bounds.y + y;
1583 let line_x = bounds.x;
1584 let half = recipe.thickness * 0.5;
1585 canvas.set_clip(bounds);
1589 canvas.fill_rect(
1590 Rect::new(line_x, line_y - half, width, recipe.thickness),
1591 color,
1592 );
1593 canvas.clear_clip();
1594 }
1595
1596 let has_selection = self
1599 .row_selection
1600 .as_ref()
1601 .is_some_and(|s| s.has_selection());
1602 if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1603 let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1604 let inset = 1.0_f32;
1605 let rect = Rect::new(
1606 bounds.x + inset,
1607 bounds.y + inset,
1608 (bounds.width - inset * 2.0).max(0.0),
1609 (bounds.height - inset * 2.0).max(0.0),
1610 );
1611 canvas.stroke_rect(rect, color, 1.5);
1612 }
1613 }
1614
1615 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1616 builder.set_role(teksilo_core::accesskit::Role::ListBox);
1617 }
1618
1619 fn as_any(&self) -> Option<&dyn std::any::Any> {
1620 Some(self)
1621 }
1622
1623 fn children(&self) -> Vec<WidgetId> {
1624 self.child_ids()
1625 }
1626
1627 fn clips_children(&self) -> bool {
1628 true
1629 }
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634 use super::*;
1635 use teksilo_core::widget_tree::WidgetTree;
1636
1637 fn row_ids(tree: &WidgetTree, lv: WidgetId) -> Vec<WidgetId> {
1642 let kids = tree.children(lv);
1643 match kids.first() {
1644 Some(&pane) => tree.children(pane),
1645 None => Vec::new(),
1646 }
1647 }
1648
1649 fn scrollbar_of(tree: &WidgetTree, lv: WidgetId) -> WidgetId {
1651 *tree.children(lv).last().expect("ListView has children")
1652 }
1653
1654 #[test]
1655 fn smooth_scroll_survives_a_body_pane_rebuild() {
1656 let (mut tree, lv_id, model) = make_list_view(500, 20.0);
1657 let scroll = {
1658 tree.layout(SizeProposal::exact(400.0, 200.0));
1659 let any = tree.widget_as_any(lv_id).unwrap();
1660 any.downcast_ref::<ListView<usize>>()
1661 .unwrap()
1662 .scroll_y_signal()
1663 .clone()
1664 };
1665 crate::common::thumb_drag_test::assert_fling_survives_pane_rebuild(
1666 &mut tree,
1667 400.0,
1668 200.0,
1669 &scroll,
1670 "ListView",
1671 || model.push(9999),
1672 );
1673 }
1674
1675 #[test]
1676 fn rows_materialize_during_scrollbar_thumb_drag() {
1677 let (mut tree, lv_id, _model) = make_list_view(500, 20.0);
1680 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
1681 &mut tree,
1682 lv_id,
1683 400.0,
1684 200.0,
1685 0.0,
1686 "ListView",
1687 |t| {
1688 row_ids(t, lv_id)
1689 .into_iter()
1690 .filter(|id| {
1691 let b = t.bounds(*id);
1692 b.height > 1.0 && b.y > -b.height && b.y < 200.0
1693 })
1694 .count()
1695 },
1696 );
1697 }
1698
1699 #[derive(Debug)]
1700 struct FixedLeaf(f32, f32);
1701 impl Widget for FixedLeaf {
1702 fn layout_response(
1703 &self,
1704 _proposal: SizeProposal,
1705 _ctx: &LayoutContext,
1706 ) -> teksilo_core::widget::LayoutResponse {
1707 Size::new(self.0, self.1).into()
1708 }
1709 }
1710
1711 fn make_list_view(count: usize, item_height: f32) -> (WidgetTree, WidgetId, ListModel<usize>) {
1712 let model = ListModel::from_vec((0..count).collect());
1713 let mut tree = WidgetTree::new();
1714 let lv_id = tree.add(
1715 ListView::new(model.clone(), move |_i, _item, _selected| {
1716 Box::new(FixedLeaf(100.0, item_height))
1717 })
1718 .item_height(item_height),
1719 );
1720 (tree, lv_id, model)
1721 }
1722
1723 #[test]
1724 fn arrow_nav_resumes_from_the_clicked_row() {
1725 use teksilo_canvas::Point;
1729 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1730 use teksilo_data::{SelectionMode, SelectionModel};
1731
1732 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1733 let selection = SelectionModel::new(SelectionMode::Single);
1734 let sel = selection.clone();
1735 let mut tree = WidgetTree::new();
1736 let lv_id = tree.add(
1737 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
1738 .item_height(20.0)
1739 .selection(sel),
1740 );
1741 tree.layout(SizeProposal::exact(400.0, 300.0)); tree.focus(lv_id);
1743
1744 tree.dispatch_event(WidgetEvent::PointerDown {
1746 position: Point::new(50.0, 70.0),
1747 button: PointerButton::Primary,
1748 modifiers: Modifiers::NONE,
1749 });
1750 tree.dispatch_event(WidgetEvent::PointerUp {
1751 position: Point::new(50.0, 70.0),
1752 button: PointerButton::Primary,
1753 modifiers: Modifiers::NONE,
1754 });
1755 assert_eq!(
1756 selection.selected_indices(),
1757 vec![3],
1758 "precondition: body click selects row 3"
1759 );
1760
1761 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1763 assert_eq!(
1764 selection.selected_indices(),
1765 vec![4],
1766 "ArrowDown after a click resumes from the clicked row (3 → 4)"
1767 );
1768 }
1769
1770 #[test]
1771 fn focused_index_follows_insert_before_it() {
1772 use teksilo_canvas::Point;
1778 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1779 use teksilo_data::{SelectionMode, SelectionModel};
1780
1781 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1782 let selection = SelectionModel::new(SelectionMode::Single);
1783 let sel = selection.clone();
1784 let mut tree = WidgetTree::new();
1785 let lv_id = tree.add(
1786 ListView::new(model.clone(), |_i, _item, _sel| {
1787 Box::new(FixedLeaf(100.0, 20.0))
1788 })
1789 .item_height(20.0)
1790 .selection(sel),
1791 );
1792 tree.layout(SizeProposal::exact(400.0, 300.0));
1793 tree.focus(lv_id);
1794
1795 tree.dispatch_event(WidgetEvent::PointerDown {
1797 position: Point::new(50.0, 70.0),
1798 button: PointerButton::Primary,
1799 modifiers: Modifiers::NONE,
1800 });
1801 tree.dispatch_event(WidgetEvent::PointerUp {
1802 position: Point::new(50.0, 70.0),
1803 button: PointerButton::Primary,
1804 modifiers: Modifiers::NONE,
1805 });
1806 assert_eq!(selection.selected_indices(), vec![3], "precondition");
1807
1808 model.insert(0, 100);
1810 model.insert(0, 200);
1811 tree.layout(SizeProposal::exact(400.0, 300.0));
1812 assert_eq!(
1815 selection.selected_indices(),
1816 vec![5],
1817 "precondition: selection shifts with the inserted rows"
1818 );
1819
1820 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1824 assert_eq!(
1825 selection.selected_indices(),
1826 vec![6],
1827 "ArrowDown after a leading insert resumes from the shifted row (5 → 6), \
1828 not the stale pre-insert one (3 → 4)"
1829 );
1830 }
1831
1832 #[test]
1833 fn focused_index_dropped_when_its_row_is_removed() {
1834 use teksilo_canvas::Point;
1837 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
1838 use teksilo_data::{SelectionMode, SelectionModel};
1839
1840 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1841 let selection = SelectionModel::new(SelectionMode::Single);
1842 let sel = selection.clone();
1843 let mut tree = WidgetTree::new();
1844 let lv_id = tree.add(
1845 ListView::new(model.clone(), |_i, _item, _sel| {
1846 Box::new(FixedLeaf(100.0, 20.0))
1847 })
1848 .item_height(20.0)
1849 .selection(sel),
1850 );
1851 tree.layout(SizeProposal::exact(400.0, 300.0));
1852 tree.focus(lv_id);
1853
1854 tree.dispatch_event(WidgetEvent::PointerDown {
1856 position: Point::new(50.0, 70.0),
1857 button: PointerButton::Primary,
1858 modifiers: Modifiers::NONE,
1859 });
1860 tree.dispatch_event(WidgetEvent::PointerUp {
1861 position: Point::new(50.0, 70.0),
1862 button: PointerButton::Primary,
1863 modifiers: Modifiers::NONE,
1864 });
1865 assert_eq!(selection.selected_indices(), vec![3], "precondition");
1866
1867 model.remove(3);
1869 tree.layout(SizeProposal::exact(400.0, 300.0));
1870 assert!(
1871 selection.selected_indices().is_empty(),
1872 "precondition: selection drops the removed row"
1873 );
1874
1875 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1881 assert_eq!(
1882 selection.selected_indices(),
1883 vec![0],
1884 "focused_index was cleared, so nav restarts at the top (row 0), \
1885 not from the stale removed row's index (3 → 4)"
1886 );
1887 }
1888
1889 #[test]
1890 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
1891 use teksilo_core::event::{Key, Modifiers};
1896 use teksilo_data::{SelectionMode, SelectionModel};
1897
1898 for (key, want, what) in [
1899 (
1900 Key::ArrowDown,
1901 0usize,
1902 "first ArrowDown selects the first row",
1903 ),
1904 (Key::ArrowUp, 9usize, "first ArrowUp selects the last row"),
1905 ] {
1906 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1907 let selection = SelectionModel::new(SelectionMode::Single);
1908 let mut tree = WidgetTree::new();
1909 let lv_id = tree.add(
1910 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
1911 .item_height(20.0)
1912 .selection(selection.clone()),
1913 );
1914 tree.layout(SizeProposal::exact(400.0, 300.0));
1915 tree.focus(lv_id);
1916 assert!(
1917 selection.selected_indices().is_empty(),
1918 "precondition: nothing selected, no cursor"
1919 );
1920
1921 tree.press_key(key, Modifiers::NONE);
1922 assert_eq!(selection.selected_indices(), vec![want], "{what}");
1923 }
1924 }
1925
1926 #[test]
1927 fn keyboard_cursor_starts_from_a_preset_selection() {
1928 use teksilo_core::event::{Key, Modifiers};
1933 use teksilo_data::{SelectionMode, SelectionModel};
1934
1935 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
1936 let selection = SelectionModel::new(SelectionMode::Single);
1937 selection.select(2);
1938 let mut tree = WidgetTree::new();
1939 let lv_id = tree.add(
1940 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
1941 .item_height(20.0)
1942 .selection(selection.clone()),
1943 );
1944 tree.layout(SizeProposal::exact(400.0, 300.0));
1945 tree.focus(lv_id);
1946
1947 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1948 assert_eq!(
1949 selection.selected_indices(),
1950 vec![3],
1951 "Down from a preselected row 2 continues to 3"
1952 );
1953 tree.press_key(Key::ArrowUp, Modifiers::NONE);
1954 tree.press_key(Key::ArrowUp, Modifiers::NONE);
1955 assert_eq!(selection.selected_indices(), vec![1], "and Up walks back");
1956 }
1957
1958 #[test]
1959 fn checkbox_press_does_not_select_row() {
1960 use crate::styles::recipe_standard_item_style as si;
1964 use teksilo_canvas::Point;
1965 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
1966 use teksilo_data::{SelectionMode, SelectionModel};
1967 use teksilo_i18n::lit;
1968
1969 let model = ListModel::from_vec(vec!["alpha", "beta", "gamma"]);
1970 let checks: Vec<Signal<bool>> = (0..3).map(|_| Signal::new(false)).collect();
1971 let checks_for_rows = checks.clone();
1972 let selection = SelectionModel::new(SelectionMode::Single);
1973 let sel = selection.clone();
1974 let mut tree = WidgetTree::new();
1975 let lv_id = tree.add(
1976 ListView::new(model, move |i, _item, _selected| {
1977 Box::new(
1978 crate::StandardListItem::new(lit!(format!("row {i}")))
1979 .checkbox(checks_for_rows[i].clone()),
1980 ) as Box<dyn Widget>
1981 })
1982 .item_height(40.0)
1983 .selection(sel),
1984 );
1985 tree.layout(SizeProposal::exact(400.0, 300.0));
1986
1987 let rows = row_ids(&tree, lv_id);
1988 let row0 = tree.bounds(rows[0]);
1989 let press = |t: &mut WidgetTree, x: f32, y: f32| {
1990 t.dispatch_event(WidgetEvent::PointerDown {
1991 position: Point::new(x, y),
1992 button: PointerButton::Primary,
1993 modifiers: Modifiers::NONE,
1994 });
1995 t.dispatch_event(WidgetEvent::PointerUp {
1996 position: Point::new(x, y),
1997 button: PointerButton::Primary,
1998 modifiers: Modifiers::NONE,
1999 });
2000 };
2001
2002 let cb_x = row0.x
2004 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
2005 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2006 + 4.0;
2007 let cb_y = row0.y + row0.height * 0.5;
2008 press(&mut tree, cb_x, cb_y);
2009 assert!(checks[0].get(), "checkbox press should toggle the checkbox");
2010 assert!(
2011 selection.selected_indices().is_empty(),
2012 "checkbox press must not select the row (got {:?})",
2013 selection.selected_indices()
2014 );
2015
2016 let body_x = row0.x + row0.width * 0.7;
2018 press(&mut tree, body_x, cb_y);
2019 assert_eq!(
2020 selection.selected_indices(),
2021 vec![0],
2022 "body press should select row 0"
2023 );
2024 assert!(
2025 checks[0].get(),
2026 "body press must not toggle the checkbox back"
2027 );
2028 }
2029
2030 #[test]
2031 fn virtualization_creates_only_visible_items() {
2032 let (mut tree, lv_id, _model) = make_list_view(10_000, 30.0);
2033 tree.layout(SizeProposal::exact(400.0, 300.0));
2035
2036 let children = row_ids(&tree, lv_id);
2037 let item_count = children.len() - 1;
2039 assert!(
2040 item_count < 30,
2041 "Expected fewer than 30 items, got {}",
2042 item_count
2043 );
2044 assert!(
2045 item_count >= 10,
2046 "Expected at least 10 items, got {}",
2047 item_count
2048 );
2049 }
2050
2051 #[test]
2052 fn empty_model_shows_scrollbar_only() {
2053 let (mut tree, lv_id, _model) = make_list_view(0, 30.0);
2054 tree.layout(SizeProposal::exact(400.0, 300.0));
2055
2056 assert_eq!(tree.children(lv_id).len(), 2, "body pane + scrollbar");
2059 assert!(
2060 row_ids(&tree, lv_id).is_empty(),
2061 "no rows for an empty model"
2062 );
2063 }
2064
2065 #[test]
2066 fn data_change_triggers_rebuild() {
2067 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2068 tree.layout(SizeProposal::exact(400.0, 300.0));
2069
2070 let initial_items = row_ids(&tree, lv_id).len(); assert_eq!(initial_items, 5);
2072
2073 model.push(99);
2074 tree.layout(SizeProposal::exact(400.0, 300.0));
2075
2076 let new_items = row_ids(&tree, lv_id).len();
2077 assert_eq!(new_items, 6);
2078 }
2079
2080 #[test]
2081 fn remove_triggers_rebuild() {
2082 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2083 tree.layout(SizeProposal::exact(400.0, 300.0));
2084 assert_eq!(row_ids(&tree, lv_id).len(), 5);
2085
2086 model.remove(0);
2087 tree.layout(SizeProposal::exact(400.0, 300.0));
2088 assert_eq!(row_ids(&tree, lv_id).len(), 4);
2089 }
2090
2091 #[test]
2092 fn items_positioned_correctly() {
2093 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2094 tree.layout(SizeProposal::exact(400.0, 300.0));
2095
2096 let children = row_ids(&tree, lv_id);
2097 let y0 = tree.bounds(children[0]).y;
2099 let y1 = tree.bounds(children[1]).y;
2100 let y2 = tree.bounds(children[2]).y;
2101 assert!((y0 - 0.0).abs() < 0.01);
2102 assert!((y1 - 40.0).abs() < 0.01);
2103 assert!((y2 - 80.0).abs() < 0.01);
2104 }
2105
2106 #[test]
2107 fn items_have_correct_height() {
2108 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2109 tree.layout(SizeProposal::exact(400.0, 300.0));
2110
2111 let children = row_ids(&tree, lv_id);
2112 for i in 0..3 {
2113 let h = tree.bounds(children[i]).height;
2114 assert!((h - 40.0).abs() < 0.01, "Item {} height {} != 40.0", i, h);
2115 }
2116 }
2117
2118 #[test]
2119 fn scrollbar_positioned_on_right_edge() {
2120 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2121 tree.layout(SizeProposal::exact(400.0, 300.0));
2122
2123 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2124 assert!(
2126 (sb_bounds.x - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2127 "Scrollbar x {} != {}",
2128 sb_bounds.x,
2129 400.0 - SCROLLBAR_THICKNESS
2130 );
2131 assert!((sb_bounds.height - 300.0).abs() < 0.01);
2132 }
2133
2134 #[test]
2135 fn small_list_collapses_scrollbar() {
2136 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2137 tree.layout(SizeProposal::exact(400.0, 300.0));
2139
2140 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2141 assert!(
2142 sb_bounds.width < 0.01 && sb_bounds.height < 0.01,
2143 "Scrollbar should be collapsed for small lists"
2144 );
2145 }
2146
2147 #[test]
2148 fn item_width_leaves_room_for_scrollbar() {
2149 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2150 tree.layout(SizeProposal::exact(400.0, 300.0));
2151
2152 let children = row_ids(&tree, lv_id);
2153 let item_width = tree.bounds(children[0]).width;
2154 assert!(
2155 (item_width - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2156 "Item width {} should be {}",
2157 item_width,
2158 400.0 - SCROLLBAR_THICKNESS
2159 );
2160 }
2161
2162 #[test]
2163 fn small_list_items_use_full_width() {
2164 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2165 tree.layout(SizeProposal::exact(400.0, 300.0));
2167
2168 let children = row_ids(&tree, lv_id);
2169 let item_width = tree.bounds(children[0]).width;
2170 assert!(
2171 (item_width - 400.0).abs() < 0.01,
2172 "Small list item width {} should be full 400.0 (no scrollbar)",
2173 item_width,
2174 );
2175 }
2176
2177 fn make_selectable_list(
2180 count: usize,
2181 ) -> (
2182 WidgetTree,
2183 WidgetId,
2184 ListModel<usize>,
2185 teksilo_data::SelectionModel,
2186 ) {
2187 use teksilo_data::{SelectionMode, SelectionModel};
2188 let model = ListModel::from_vec((0..count).collect());
2189 let selection = SelectionModel::new(SelectionMode::Multi);
2190 let sel_clone = selection.clone();
2191 let mut tree = WidgetTree::new();
2192 let lv_id = tree.add(
2193 ListView::new(model.clone(), move |_i, _item, _selected| {
2194 Box::new(FixedLeaf(100.0, 30.0))
2195 })
2196 .item_height(30.0)
2197 .selection(sel_clone),
2198 );
2199 tree.layout(SizeProposal::exact(400.0, 300.0));
2200 (tree, lv_id, model, selection)
2201 }
2202
2203 #[test]
2204 fn click_selects_item() {
2205 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2206 let children = row_ids(&tree, lv_id);
2208 tree.click(children[1]);
2209 assert!(selection.is_selected(1), "item 1 should be selected");
2210 assert!(!selection.is_selected(0), "item 0 should not be selected");
2211 }
2212
2213 #[test]
2214 fn click_replaces_selection() {
2215 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2216 let children = row_ids(&tree, lv_id);
2217 tree.click(children[0]);
2218 assert!(selection.is_selected(0));
2219
2220 tree.click(children[2]);
2221 assert!(selection.is_selected(2));
2222 assert!(
2223 !selection.is_selected(0),
2224 "previous selection should be cleared"
2225 );
2226 }
2227
2228 #[test]
2229 fn keyed_selection_tracks_identity_not_index() {
2230 use std::rc::Rc;
2233 use teksilo_core::ObserverHandle;
2234 use teksilo_data::{KeyedSelectionModel, ListDataSource, SelectionMode};
2235
2236 struct KeyedSource {
2237 items: Vec<(u64, usize)>, }
2239 impl ListDataSource for KeyedSource {
2240 type Item = usize;
2241 type Key = u64;
2242 fn len(&self) -> usize {
2243 self.items.len()
2244 }
2245 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
2246 self.items.get(i).map(|(_, v)| f(v))
2247 }
2248 fn key_at(&self, i: usize) -> Option<u64> {
2249 self.items.get(i).map(|(k, _)| *k)
2250 }
2251 fn index_of(&self, key: &u64) -> Option<usize> {
2252 self.items.iter().position(|(k, _)| k == key)
2253 }
2254 fn observe_changes(
2255 &self,
2256 _f: impl Fn(&teksilo_data::DataChange) + 'static,
2257 ) -> ObserverHandle {
2258 ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
2259 }
2260 }
2261
2262 let keyed = KeyedSelectionModel::<u64>::new(SelectionMode::Single);
2263 let source = KeyedSource {
2264 items: vec![(10, 100), (20, 200), (30, 300)],
2265 };
2266 let mut tree = WidgetTree::new();
2267 let lv_id = tree.add(
2268 ListView::from_source_keyed(source, keyed.clone(), |_i, _v, _sel| {
2269 Box::new(FixedLeaf(100.0, 30.0))
2270 })
2271 .item_height(30.0),
2272 );
2273 tree.layout(SizeProposal::exact(400.0, 300.0));
2274
2275 let children = row_ids(&tree, lv_id);
2277 tree.click(children[1]);
2278 assert!(keyed.is_selected(&20), "selection is stored by key");
2279 assert_eq!(keyed.selected_keys(), vec![20]);
2280 assert!(!keyed.is_selected(&10));
2281 }
2282
2283 #[test]
2284 fn ctrl_click_toggles() {
2285 use teksilo_core::event::Modifiers;
2286 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2287 let children = row_ids(&tree, lv_id);
2288
2289 tree.click(children[0]);
2291 assert!(selection.is_selected(0));
2292
2293 let center = tree.bounds(children[2]).center();
2295 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2296 position: center,
2297 button: teksilo_core::event::PointerButton::Primary,
2298 modifiers: Modifiers::COMMAND,
2299 });
2300 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerUp {
2301 position: center,
2302 button: teksilo_core::event::PointerButton::Primary,
2303 modifiers: Modifiers::COMMAND,
2304 });
2305
2306 assert!(selection.is_selected(0), "item 0 should still be selected");
2307 assert!(selection.is_selected(2), "item 2 should be toggled on");
2308 }
2309
2310 #[test]
2311 fn shift_click_extends_range() {
2312 use teksilo_core::event::Modifiers;
2313 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2314 let children = row_ids(&tree, lv_id);
2315
2316 tree.click(children[1]);
2318 assert!(
2319 selection.is_selected(1),
2320 "item 1 should be selected after plain click"
2321 );
2322
2323 let center = tree.bounds(children[3]).center();
2325 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2326 position: center,
2327 button: teksilo_core::event::PointerButton::Primary,
2328 modifiers: Modifiers::SHIFT,
2329 });
2330
2331 let selected = selection.selected_indices();
2332 assert_eq!(
2333 selected,
2334 vec![1, 2, 3],
2335 "Shift+click should select range 1..=3, got {:?}",
2336 selected
2337 );
2338 }
2339
2340 #[test]
2343 fn scroll_changes_visible_items() {
2344 let model = ListModel::from_vec((0..100).collect());
2346 let mut tree = WidgetTree::new();
2347 let lv_id = tree.add(
2348 ListView::new(model.clone(), move |i, _item, _selected| {
2349 Box::new(FixedLeaf(i as f32, 30.0))
2351 })
2352 .item_height(30.0),
2353 );
2354 tree.layout(SizeProposal::exact(400.0, 300.0));
2355
2356 let children = row_ids(&tree, lv_id);
2358 let first_y = tree.bounds(children[0]).y;
2359 assert!(
2360 first_y.abs() < 30.0,
2361 "First visible item should be near the top, got y={}",
2362 first_y
2363 );
2364
2365 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
2367 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 1500.0 },
2368 modifiers: Default::default(),
2369 });
2370 tree.layout(SizeProposal::exact(400.0, 300.0));
2371
2372 let children_after = row_ids(&tree, lv_id);
2375 let item_count_after = children_after.len() - 1;
2376 assert!(
2377 item_count_after > 0,
2378 "Should have visible items after scroll"
2379 );
2380
2381 let first_y_after = tree.bounds(children_after[0]).y;
2385 assert!(
2386 first_y_after < 300.0,
2387 "First item should be in viewport after scroll, got y={}",
2388 first_y_after
2389 );
2390
2391 assert!(
2403 item_count_after < 30,
2404 "Should still be virtualized after scroll, got {} items",
2405 item_count_after
2406 );
2407 }
2408
2409 #[test]
2412 fn list_item_has_a11y_role() {
2413 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2414 tree.layout(SizeProposal::exact(400.0, 300.0));
2415
2416 let children = row_ids(&tree, lv_id);
2418 let info = tree.accessibility_node(children[0]);
2419 assert_eq!(
2420 info.role(),
2421 teksilo_core::accesskit::Role::ListBoxOption,
2422 "Item wrapper should have ListBoxOption role"
2423 );
2424 }
2425
2426 #[test]
2429 fn alt_arrow_moves_one_step_per_press_across_rebuilds() {
2430 use teksilo_core::event::{Key, Modifiers};
2436 use teksilo_data::{SelectionMode, SelectionModel};
2437
2438 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
2439 let selection = SelectionModel::new(SelectionMode::Single);
2440 let sel_clone = selection.clone();
2441 let model_clone = model.clone();
2442
2443 let mut tree = WidgetTree::new();
2444 let lv_id = tree.add(
2445 ListView::new(model_clone, move |_i, _item, _sel| {
2446 Box::new(FixedLeaf(100.0, 30.0))
2447 })
2448 .item_height(30.0)
2449 .selection(sel_clone)
2450 .reorderable(true),
2451 );
2452 tree.layout(SizeProposal::exact(400.0, 300.0));
2453
2454 for i in 0..3 {
2458 selection.select(i);
2459 tree.layout(SizeProposal::exact(400.0, 300.0));
2460 }
2461
2462 selection.select(0);
2463 tree.layout(SizeProposal::exact(400.0, 300.0));
2464
2465 tree.focus(lv_id);
2466 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
2467 key: Key::ArrowDown,
2468 modifiers: Modifiers::ALT,
2469 text: None,
2470 });
2471
2472 assert_eq!(model.with_item(0, |v| *v), Some(20));
2474 assert_eq!(model.with_item(1, |v| *v), Some(10));
2475 assert_eq!(model.with_item(2, |v| *v), Some(30));
2476 }
2477
2478 #[test]
2479 fn page_down_up_moves_selection_by_viewport() {
2480 use teksilo_core::event::{Key, Modifiers};
2481 use teksilo_data::{SelectionMode, SelectionModel};
2482
2483 let model = ListModel::from_vec((0..100usize).collect());
2484 let selection = SelectionModel::new(SelectionMode::Single);
2485 let sel = selection.clone();
2486 let mut tree = WidgetTree::new();
2487 let lv = tree.add(
2488 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2489 .item_height(20.0)
2490 .selection(sel),
2491 );
2492 let p = SizeProposal::exact(400.0, 200.0); tree.layout(p);
2494 tree.focus(lv);
2495 selection.select(0);
2496
2497 tree.press_key(Key::PageDown, Modifiers::NONE);
2498 tree.layout(p);
2499 let after_pgdn = selection.selected_indices()[0];
2500 assert!(
2501 after_pgdn >= 8,
2502 "PageDown should advance ~one viewport of rows, got {after_pgdn}"
2503 );
2504 let scroll = with_list_view::<usize, _>(&tree, lv, |v| v.scroll_y_signal().get());
2505 assert!(scroll > 0.0, "PageDown scrolls to follow, got {scroll}");
2506
2507 tree.press_key(Key::PageUp, Modifiers::NONE);
2508 tree.layout(p);
2509 assert!(
2510 selection.selected_indices()[0] < after_pgdn,
2511 "PageUp should move selection back up"
2512 );
2513 }
2514
2515 #[test]
2516 fn space_toggles_selection_enter_activates() {
2517 use std::cell::Cell;
2518 use teksilo_core::event::{Key, Modifiers};
2519 use teksilo_data::{SelectionMode, SelectionModel};
2520
2521 let model = ListModel::from_vec((0..5usize).collect());
2522 let selection = SelectionModel::new(SelectionMode::Multi);
2523 let sel = selection.clone();
2524 let activated = Rc::new(Cell::new(None));
2525 let act = activated.clone();
2526 let mut tree = WidgetTree::new();
2527 let lv = tree.add(
2528 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2529 .item_height(20.0)
2530 .selection(sel)
2531 .on_activate(move |i, _ctx| act.set(Some(i))),
2532 );
2533 tree.layout(SizeProposal::exact(400.0, 200.0));
2534 tree.focus(lv);
2535
2536 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2539 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2540 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2541 assert_eq!(selection.selected_indices(), vec![2]);
2542 assert_eq!(activated.get(), None, "arrows never activate");
2543
2544 tree.press_key(Key::Space, Modifiers::NONE);
2546 assert!(
2547 selection.selected_indices().is_empty(),
2548 "Space toggles row 2 off in Multi mode"
2549 );
2550 assert_eq!(activated.get(), None, "Space must NOT activate");
2551
2552 tree.press_key(Key::Enter, Modifiers::NONE);
2554 assert_eq!(activated.get(), Some(2), "Enter activates the focused row");
2555 }
2556
2557 #[test]
2558 fn ctrl_a_selects_all_in_multi_mode() {
2559 use teksilo_core::event::{Key, Modifiers};
2560 use teksilo_data::{SelectionMode, SelectionModel};
2561
2562 let model = ListModel::from_vec((0..6usize).collect());
2563 let selection = SelectionModel::new(SelectionMode::Multi);
2564 let sel = selection.clone();
2565 let mut tree = WidgetTree::new();
2566 let lv = tree.add(
2567 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2568 .item_height(20.0)
2569 .selection(sel),
2570 );
2571 tree.layout(SizeProposal::exact(400.0, 200.0));
2572 tree.focus(lv);
2573 tree.press_key(Key::A, Modifiers::COMMAND);
2574 assert_eq!(selection.selected_indices().len(), 6, "Ctrl+A selects all");
2575 }
2576
2577 #[test]
2578 fn ctrl_arrow_moves_cursor_without_selecting_in_multi_mode() {
2579 use teksilo_core::event::{Key, Modifiers};
2580 use teksilo_data::{SelectionMode, SelectionModel};
2581
2582 let model = ListModel::from_vec((0..6usize).collect());
2583 let selection = SelectionModel::new(SelectionMode::Multi);
2584 let sel = selection.clone();
2585 let mut tree = WidgetTree::new();
2586 let lv = tree.add(
2587 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2588 .item_height(20.0)
2589 .selection(sel),
2590 );
2591 tree.layout(SizeProposal::exact(400.0, 200.0));
2592 tree.focus(lv);
2593
2594 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2596 assert_eq!(selection.selected_indices(), vec![0]);
2597
2598 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
2600 assert_eq!(
2601 selection.selected_indices(),
2602 vec![0],
2603 "Ctrl+ArrowDown must leave the selection unchanged"
2604 );
2605 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
2606 assert_eq!(focused, Some(1), "Ctrl+ArrowDown moves the cursor to row 1");
2607
2608 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
2609 assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
2610 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
2611 assert_eq!(focused, Some(2));
2612
2613 tree.press_key(Key::Space, Modifiers::CTRL);
2616 assert_eq!(selection.selected_indices(), vec![0, 2]);
2617
2618 tree.press_key(Key::Space, Modifiers::CTRL);
2620 assert_eq!(selection.selected_indices(), vec![0]);
2621
2622 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2625 assert_eq!(selection.selected_indices(), vec![3]);
2626 }
2627
2628 #[test]
2629 fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
2630 use teksilo_core::event::{Key, Modifiers};
2631 use teksilo_data::{SelectionMode, SelectionModel};
2632
2633 let model = ListModel::from_vec((0..6usize).collect());
2634 let selection = SelectionModel::new(SelectionMode::Single);
2635 let sel = selection.clone();
2636 let mut tree = WidgetTree::new();
2637 let lv = tree.add(
2638 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2639 .item_height(20.0)
2640 .selection(sel),
2641 );
2642 tree.layout(SizeProposal::exact(400.0, 200.0));
2643 tree.focus(lv);
2644
2645 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2646 assert_eq!(selection.selected_indices(), vec![0]);
2647
2648 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
2649 assert_eq!(
2650 selection.selected_indices(),
2651 vec![0],
2652 "Ctrl+ArrowDown must not select in Single mode either"
2653 );
2654 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
2655 assert_eq!(focused, Some(1));
2656
2657 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2658 assert_eq!(selection.selected_indices(), vec![2]);
2659 }
2660
2661 #[test]
2662 fn type_ahead_jumps_to_matching_row() {
2663 use teksilo_core::event::{Key, Modifiers};
2664 use teksilo_data::{SelectionMode, SelectionModel};
2665
2666 let model = ListModel::from_vec(vec![
2667 "Apple".to_string(),
2668 "Banana".to_string(),
2669 "Cherry".to_string(),
2670 "Cranberry".to_string(),
2671 "Date".to_string(),
2672 ]);
2673 let selection = SelectionModel::new(SelectionMode::Single);
2674 let sel = selection.clone();
2675 let mut tree = WidgetTree::new();
2676 let lv = tree.add(
2677 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2678 .item_height(20.0)
2679 .selection(sel)
2680 .type_ahead_label(|s: &String| s.clone()),
2681 );
2682 tree.layout(SizeProposal::exact(400.0, 200.0));
2683 tree.focus(lv);
2684 selection.select(0);
2685
2686 tree.press_key(Key::C, Modifiers::NONE);
2688 assert_eq!(selection.selected_indices(), vec![2], "'c' → Cherry");
2689
2690 tree.press_key(Key::R, Modifiers::NONE);
2692 assert_eq!(selection.selected_indices(), vec![3], "'cr' → Cranberry");
2693 }
2694
2695 #[test]
2696 fn type_ahead_buffer_survives_rebuild() {
2697 use teksilo_core::event::{Key, Modifiers};
2702 use teksilo_data::{SelectionMode, SelectionModel};
2703
2704 let model = ListModel::from_vec(vec![
2705 "Apple".to_string(),
2706 "Cherry".to_string(),
2707 "Cranberry".to_string(),
2708 ]);
2709 let selection = SelectionModel::new(SelectionMode::Single);
2710 let sel = selection.clone();
2711 let mut tree = WidgetTree::new();
2712 let p = SizeProposal::exact(400.0, 200.0);
2713 let lv = tree.add(
2714 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2715 .item_height(20.0)
2716 .selection(sel)
2717 .type_ahead_label(|s: &String| s.clone()),
2718 );
2719 tree.layout(p);
2720 tree.focus(lv);
2721 selection.select(0);
2722
2723 tree.press_key(Key::C, Modifiers::NONE); assert_eq!(selection.selected_indices(), vec![1]);
2725 tree.layout(p); tree.press_key(Key::R, Modifiers::NONE); assert_eq!(
2728 selection.selected_indices(),
2729 vec![2],
2730 "buffer 'c' must survive the rebuild so 'cr' matches Cranberry"
2731 );
2732 }
2733
2734 #[test]
2735 fn page_down_on_short_list_jumps_to_last_without_panic() {
2736 use teksilo_core::event::{Key, Modifiers};
2737 use teksilo_data::{SelectionMode, SelectionModel};
2738
2739 let model = ListModel::from_vec((0..3usize).collect());
2741 let selection = SelectionModel::new(SelectionMode::Single);
2742 let sel = selection.clone();
2743 let mut tree = WidgetTree::new();
2744 let lv = tree.add(
2745 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
2746 .item_height(20.0)
2747 .selection(sel),
2748 );
2749 tree.layout(SizeProposal::exact(400.0, 200.0));
2750 tree.focus(lv);
2751 selection.select(0);
2752 tree.press_key(Key::PageDown, Modifiers::NONE);
2753 assert_eq!(selection.selected_indices(), vec![2], "PageDown → last row");
2754 tree.press_key(Key::PageDown, Modifiers::NONE);
2755 assert_eq!(selection.selected_indices(), vec![2], "stays at last");
2756 tree.press_key(Key::PageUp, Modifiers::NONE);
2757 assert_eq!(selection.selected_indices(), vec![0], "PageUp → first row");
2758 }
2759
2760 #[test]
2761 fn alt_arrow_reorders_item() {
2762 use teksilo_core::event::{Key, Modifiers};
2763 use teksilo_data::{SelectionMode, SelectionModel};
2764
2765 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
2766 let selection = SelectionModel::new(SelectionMode::Single);
2767 let sel_clone = selection.clone();
2768 let model_clone = model.clone();
2769
2770 let mut tree = WidgetTree::new();
2771 let lv_id = tree.add(
2772 ListView::new(model_clone.clone(), move |_i, _item, _sel| {
2773 Box::new(FixedLeaf(100.0, 30.0))
2774 })
2775 .item_height(30.0)
2776 .selection(sel_clone)
2777 .reorderable(true),
2778 );
2779 tree.layout(SizeProposal::exact(400.0, 300.0));
2780
2781 selection.select(2);
2783
2784 tree.focus(lv_id);
2786 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
2787 key: Key::ArrowDown,
2788 modifiers: Modifiers::ALT,
2789 text: None,
2790 });
2791
2792 assert_eq!(model.with_item(3, |v| *v), Some(30));
2794 assert_eq!(model.with_item(2, |v| *v), Some(40));
2795 }
2796
2797 fn make_reorderable_list(
2802 values: Vec<usize>,
2803 item_height: f32,
2804 ) -> (WidgetTree, WidgetId, ListModel<usize>) {
2805 let model = ListModel::from_vec(values);
2806 let model_clone = model.clone();
2807 let mut tree = WidgetTree::new();
2808 let lv_id = tree.add(
2809 ListView::new(model_clone, move |_i, _item, _sel| {
2810 Box::new(FixedLeaf(100.0, item_height))
2811 })
2812 .item_height(item_height)
2813 .reorderable(true),
2814 );
2815 (tree, lv_id, model)
2816 }
2817
2818 fn drag_item(tree: &mut WidgetTree, from: Point, to: Point) {
2821 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2822 tree.dispatch_event(WidgetEvent::PointerDown {
2823 position: from,
2824 button: PointerButton::Primary,
2825 modifiers: Modifiers::NONE,
2826 });
2827 tree.dispatch_event(WidgetEvent::PointerMove {
2829 position: Point::new(from.x + 10.0, from.y),
2830 });
2831 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
2832 tree.dispatch_event(WidgetEvent::PointerUp {
2833 position: to,
2834 button: PointerButton::Primary,
2835 modifiers: Modifiers::NONE,
2836 });
2837 }
2838
2839 #[test]
2840 fn drag_reorders_item_downward() {
2841 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
2842 tree.layout(SizeProposal::exact(400.0, 300.0));
2843
2844 let children = row_ids(&tree, lv_id);
2847 let from = tree.bounds(children[0]).center();
2848 let to = Point::new(from.x, 120.0);
2849 drag_item(&mut tree, from, to);
2850
2851 assert_eq!(model.with_item(0, |v| *v), Some(20));
2853 assert_eq!(model.with_item(3, |v| *v), Some(10));
2854 assert_eq!(model.with_item(4, |v| *v), Some(50));
2855 }
2856
2857 #[test]
2858 fn drag_reorders_item_upward() {
2859 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
2860 tree.layout(SizeProposal::exact(400.0, 300.0));
2861
2862 let children = row_ids(&tree, lv_id);
2865 let from = tree.bounds(children[3]).center();
2866 let to = Point::new(from.x, 15.0);
2867 drag_item(&mut tree, from, to);
2868
2869 assert_eq!(model.with_item(1, |v| *v), Some(40));
2871 assert_eq!(model.with_item(2, |v| *v), Some(20));
2872 assert_eq!(model.with_item(3, |v| *v), Some(30));
2873 }
2874
2875 #[test]
2876 fn reorderable_drag_routes_to_source_accept_drop() {
2877 use std::cell::RefCell;
2883 use std::rc::Rc;
2884 use teksilo_core::ObserverHandle;
2885 use teksilo_data::{
2886 DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
2887 ListDataSource,
2888 };
2889
2890 struct CapturingSource {
2891 items: Vec<usize>,
2892 captured: Rc<RefCell<Vec<(usize, usize)>>>,
2893 }
2894 impl ListDataSource for CapturingSource {
2895 type Item = usize;
2896 type Key = usize;
2897 fn len(&self) -> usize {
2898 self.items.len()
2899 }
2900 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
2901 self.items.get(i).map(f)
2902 }
2903 fn key_at(&self, i: usize) -> Option<usize> {
2904 (i < self.items.len()).then_some(i)
2905 }
2906 fn observe_changes(
2907 &self,
2908 _f: impl Fn(&teksilo_data::DataChange) + 'static,
2909 ) -> ObserverHandle {
2910 let inner: Rc<dyn std::any::Any> = Rc::new(());
2911 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
2912 }
2913 fn drag(&self, _k: &usize) -> DragEligibility {
2914 DragEligibility::CanDrag
2915 }
2916 fn can_accept(&self, q: &DropQuery<'_, usize>) -> DropResponse {
2917 match &q.source {
2918 DragSource::SameView { .. } if q.position != DropPosition::Into => {
2919 DropResponse::Accept
2920 }
2921 _ => DropResponse::Reject,
2922 }
2923 }
2924 fn accept_drop(&self, c: DropCommit<'_, usize>) -> bool {
2925 let DragSource::SameView { key: from } = c.source else {
2926 return false;
2927 };
2928 let target = c.target;
2929 let shift = if from < target { 1 } else { 0 };
2930 let to = match c.position {
2931 DropPosition::Before => target.saturating_sub(shift),
2932 DropPosition::After => (target + 1).saturating_sub(shift),
2933 DropPosition::Into => return false,
2934 };
2935 self.captured.borrow_mut().push((from, to));
2937 true
2938 }
2939 }
2940
2941 let captured: Rc<RefCell<Vec<(usize, usize)>>> = Rc::new(RefCell::new(Vec::new()));
2942 let source = CapturingSource {
2943 items: vec![10, 20, 30, 40, 50],
2944 captured: captured.clone(),
2945 };
2946 let mut tree = WidgetTree::new();
2947 let lv_id = tree.add(
2948 ListView::from_source(source, move |_i, _item, _sel| {
2949 Box::new(FixedLeaf(100.0, 30.0))
2950 })
2951 .item_height(30.0)
2952 .reorderable(true),
2953 );
2954 tree.layout(SizeProposal::exact(400.0, 300.0));
2955
2956 let children = row_ids(&tree, lv_id);
2959 let from = tree.bounds(children[0]).center();
2960 let to = Point::new(from.x, 120.0);
2961 drag_item(&mut tree, from, to);
2962
2963 assert_eq!(
2964 *captured.borrow(),
2965 vec![(0, 3)],
2966 "the drop is routed to the source's accept_drop with the resolved move"
2967 );
2968 }
2969
2970 #[test]
2971 fn drag_emits_items_moved_change() {
2972 use std::cell::Cell;
2973 use std::rc::Rc;
2974 use teksilo_data::DataChange;
2975
2976 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
2977 tree.layout(SizeProposal::exact(400.0, 300.0));
2978
2979 let moved = Rc::new(Cell::new(None::<(usize, usize)>));
2980 let moved_clone = moved.clone();
2981 let handle = model.observe_changes(move |change| {
2982 if let DataChange::ItemsMoved { from, to, .. } = change {
2983 moved_clone.set(Some((*from, *to)));
2984 }
2985 });
2986
2987 let children = row_ids(&tree, lv_id);
2989 let from = tree.bounds(children[0]).center();
2990 let to = Point::new(from.x, 120.0);
2991 drag_item(&mut tree, from, to);
2992
2993 assert_eq!(moved.get(), Some((0, 3)));
2994 drop(handle);
2995 }
2996
2997 #[test]
2998 fn drag_drop_accounts_for_scroll_offset() {
2999 let (mut tree, _lv_id, model) = make_reorderable_list((0..20).collect(), 30.0);
3002 tree.layout(SizeProposal::exact(400.0, 300.0));
3003
3004 tree.pointer_move(Point::new(50.0, 50.0));
3007 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3008 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 60.0 },
3009 modifiers: Default::default(),
3010 });
3011 tree.tick_animations(std::time::Duration::from_millis(200));
3014 tree.layout(SizeProposal::exact(400.0, 300.0));
3015
3016 let from = Point::new(50.0, 15.0);
3021 let to = Point::new(50.0, 120.0);
3022 drag_item(&mut tree, from, to);
3023
3024 assert_eq!(
3025 model.with_item(5, |v| *v),
3026 Some(2),
3027 "Item 2 should land at index 5 after drag with scroll offset"
3028 );
3029 assert_eq!(
3030 model.with_item(2, |v| *v),
3031 Some(3),
3032 "Item 3 should shift up to index 2"
3033 );
3034 }
3035
3036 #[test]
3037 fn click_selects_item_on_reorderable_list_with_selection() {
3038 use std::cell::Cell;
3047 use std::rc::Rc;
3048 use teksilo_data::{SelectionMode, SelectionModel};
3049
3050 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3051 let selection = SelectionModel::new(SelectionMode::Single);
3052 let sel_clone = selection.clone();
3053 let model_clone = model.clone();
3054
3055 let selected_rebuilds: Rc<std::cell::RefCell<Vec<Vec<usize>>>> =
3058 Rc::new(std::cell::RefCell::new(Vec::new()));
3059 let current_pass: Rc<Cell<Vec<usize>>> = Rc::new(Cell::new(Vec::new()));
3060 let _sr = selected_rebuilds.clone();
3061 let cp = current_pass.clone();
3062
3063 let mut tree = WidgetTree::new();
3064 let lv_id = tree.add(
3065 ListView::new(model_clone, move |index, _item, selected| {
3066 if selected {
3067 let mut acc = cp.take();
3068 acc.push(index);
3069 cp.set(acc);
3070 }
3071 Box::new(FixedLeaf(100.0, 30.0))
3072 })
3073 .item_height(30.0)
3074 .selection(sel_clone)
3075 .reorderable(true),
3076 );
3077 tree.layout(SizeProposal::exact(400.0, 300.0));
3078 selected_rebuilds.borrow_mut().push(current_pass.take());
3079
3080 let children = row_ids(&tree, lv_id);
3082 tree.click(children[2]);
3083
3084 assert_eq!(selection.selected_indices(), vec![2]);
3086
3087 tree.layout(SizeProposal::exact(400.0, 300.0));
3090 selected_rebuilds.borrow_mut().push(current_pass.take());
3091
3092 let passes = selected_rebuilds.borrow().clone();
3093 assert_eq!(
3094 passes[0],
3095 Vec::<usize>::new(),
3096 "initial build: nothing selected"
3097 );
3098 assert_eq!(
3099 passes[1],
3100 vec![2],
3101 "post-click rebuild should deliver selected=true for item 2"
3102 );
3103 }
3104
3105 #[test]
3106 fn drag_survives_rebuild_triggered_by_selection() {
3107 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3117 use teksilo_data::{SelectionMode, SelectionModel};
3118
3119 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3120 let selection = SelectionModel::new(SelectionMode::Single);
3121 let sel_clone = selection.clone();
3122 let model_clone = model.clone();
3123
3124 let mut tree = WidgetTree::new();
3125 let _lv_id = tree.add(
3126 ListView::new(model_clone, move |_i, _item, _sel| {
3127 Box::new(FixedLeaf(100.0, 30.0))
3128 })
3129 .item_height(30.0)
3130 .selection(sel_clone)
3131 .reorderable(true),
3132 );
3133 tree.layout(SizeProposal::exact(400.0, 300.0));
3134
3135 tree.dispatch_event(WidgetEvent::PointerDown {
3142 position: Point::new(50.0, 15.0),
3143 button: PointerButton::Primary,
3144 modifiers: Modifiers::NONE,
3145 });
3146 tree.layout(SizeProposal::exact(400.0, 300.0));
3151
3152 tree.dispatch_event(WidgetEvent::PointerMove {
3154 position: Point::new(60.0, 15.0),
3155 });
3156 tree.dispatch_event(WidgetEvent::PointerMove {
3158 position: Point::new(60.0, 120.0),
3159 });
3160 tree.dispatch_event(WidgetEvent::PointerUp {
3161 position: Point::new(60.0, 120.0),
3162 button: PointerButton::Primary,
3163 modifiers: Modifiers::NONE,
3164 });
3165
3166 assert_eq!(
3168 model.with_item(3, |v| *v),
3169 Some(10),
3170 "Drag must complete even after the selection-triggered rebuild \
3171 destroyed the originally-captured wrapper"
3172 );
3173 }
3174
3175 #[test]
3176 fn lazy_loading_rows_render_placeholders_and_request_the_window() {
3177 use std::cell::RefCell;
3181 use std::ops::Range;
3182 use std::rc::Rc;
3183 use teksilo_core::ObserverHandle;
3184 use teksilo_data::{ListDataSource, RowState};
3185
3186 struct Windowed {
3187 total: usize,
3188 requested: Rc<RefCell<Vec<Range<usize>>>>,
3189 }
3190 impl ListDataSource for Windowed {
3191 type Item = usize;
3192 type Key = usize;
3193 fn len(&self) -> usize {
3194 self.total
3195 }
3196 fn with_item<R>(&self, _i: usize, _f: impl FnOnce(&usize) -> R) -> Option<R> {
3197 None }
3199 fn key_at(&self, i: usize) -> Option<usize> {
3200 (i < self.total).then_some(i)
3201 }
3202 fn row_state(&self, _i: usize) -> RowState {
3203 RowState::Loading
3204 }
3205 fn request_window(&self, range: Range<usize>) {
3206 self.requested.borrow_mut().push(range);
3207 }
3208 fn observe_changes(
3209 &self,
3210 _f: impl Fn(&teksilo_data::DataChange) + 'static,
3211 ) -> ObserverHandle {
3212 let inner: Rc<dyn std::any::Any> = Rc::new(());
3213 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3214 }
3215 }
3216
3217 let requested = Rc::new(RefCell::new(Vec::new()));
3218 let source = Windowed {
3219 total: 1000,
3220 requested: requested.clone(),
3221 };
3222 let mut tree = WidgetTree::new();
3223 let lv_id = tree.add(
3224 ListView::from_source(source, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3225 .item_height(30.0),
3226 );
3227 tree.layout(SizeProposal::exact(400.0, 300.0));
3228
3229 let placeholder_rows = row_ids(&tree, lv_id).len();
3232 assert!(
3233 placeholder_rows >= 10,
3234 "loading rows must render as placeholders, got {placeholder_rows}"
3235 );
3236 assert!(
3238 !requested.borrow().is_empty(),
3239 "request_window must be called for the visible range"
3240 );
3241 }
3242
3243 fn with_list_view<T: 'static, R>(
3246 tree: &WidgetTree,
3247 id: WidgetId,
3248 f: impl FnOnce(&ListView<T>) -> R,
3249 ) -> R {
3250 let any = tree.widget_as_any(id).expect("widget exposes as_any");
3251 let lv = any
3252 .downcast_ref::<ListView<T>>()
3253 .expect("widget is a ListView<T>");
3254 f(lv)
3255 }
3256
3257 #[test]
3258 fn drop_indicator_clears_after_drop() {
3259 let (mut tree, lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
3263 tree.layout(SizeProposal::exact(400.0, 300.0));
3264
3265 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
3266
3267 let feedback =
3268 with_list_view::<usize, _>(&tree, lv_id, |lv| lv.drop_feedback_signal().get());
3269 assert!(
3270 feedback.is_none(),
3271 "drop_feedback must be cleared by on_drag_leave after drop, got {:?}",
3272 feedback
3273 );
3274 }
3275
3276 #[test]
3277 fn drag_spawns_preview_overlay_and_cleans_up() {
3278 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3279
3280 let (mut tree, _lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
3281 tree.layout(SizeProposal::exact(400.0, 300.0));
3282
3283 let baseline = tree.overlay_manager().len();
3284
3285 tree.dispatch_event(WidgetEvent::PointerDown {
3287 position: Point::new(50.0, 15.0),
3288 button: PointerButton::Primary,
3289 modifiers: Modifiers::NONE,
3290 });
3291 tree.dispatch_event(WidgetEvent::PointerMove {
3292 position: Point::new(60.0, 15.0),
3293 });
3294
3295 assert_eq!(
3296 tree.overlay_manager().len(),
3297 baseline + 1,
3298 "Preview overlay should be live during drag"
3299 );
3300
3301 tree.dispatch_event(WidgetEvent::PointerUp {
3303 position: Point::new(60.0, 15.0),
3304 button: PointerButton::Primary,
3305 modifiers: Modifiers::NONE,
3306 });
3307 assert_eq!(
3308 tree.overlay_manager().len(),
3309 baseline,
3310 "Preview overlay should be dismissed after drop"
3311 );
3312 }
3313
3314 #[test]
3315 fn edge_auto_scroll_advances_scroll_y_during_drag() {
3316 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3317
3318 let (mut tree, _lv_id, _model) =
3320 make_reorderable_list((0..50).collect::<Vec<usize>>(), 30.0);
3321 tree.layout(SizeProposal::exact(400.0, 300.0));
3322
3323 tree.dispatch_event(WidgetEvent::PointerDown {
3326 position: Point::new(50.0, 15.0),
3327 button: PointerButton::Primary,
3328 modifiers: Modifiers::NONE,
3329 });
3330 tree.dispatch_event(WidgetEvent::PointerMove {
3331 position: Point::new(60.0, 15.0),
3332 });
3333 tree.dispatch_event(WidgetEvent::PointerMove {
3334 position: Point::new(60.0, 290.0), });
3336
3337 for _ in 0..8 {
3339 tree.layout(SizeProposal::exact(400.0, 300.0));
3340 }
3341 let scroll_y = with_list_view::<usize, _>(&tree, _lv_id, |lv| lv.scroll_y_signal().get());
3342 assert!(
3343 scroll_y > 5.0,
3344 "Edge auto-scroll should have advanced scroll_y; got {scroll_y}"
3345 );
3346
3347 tree.dispatch_event(WidgetEvent::PointerUp {
3349 position: Point::new(60.0, 290.0),
3350 button: PointerButton::Primary,
3351 modifiers: Modifiers::NONE,
3352 });
3353 }
3354
3355 fn nested_list_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>, Signal<f32>) {
3361 use crate::ScrollArea;
3362 use crate::primitives::{FixedSize, VStack};
3363 let mut tree = WidgetTree::new();
3364 let model = ListModel::from_vec((0..40_usize).collect());
3365 let lv = ListView::new(model, move |_i, _item, _sel| {
3366 Box::new(FixedLeaf(180.0, 30.0))
3367 })
3368 .item_height(30.0)
3369 .overscroll_behavior(inner);
3370 let inner_y = lv.scroll_y_signal().clone();
3371 let lv_id = tree.add(lv);
3372 let viewport = tree.add(FixedSize::new().width(200.0).height(100.0).child_id(lv_id));
3373 let filler = tree.add(FixedLeaf(200.0, 200.0));
3374 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
3375 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
3376 let outer_y = outer.scroll_y_signal().clone();
3377 let _outer = tree.add(outer);
3378 tree.layout(SizeProposal::exact(200.0, 150.0));
3379 (tree, inner_y, outer_y)
3380 }
3381
3382 #[test]
3383 fn nested_list_chains_to_outer_at_boundary() {
3384 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
3385 let (mut tree, inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Chain);
3386 tree.pointer_move(Point::new(50.0, 40.0));
3387 tree.dispatch_event(WidgetEvent::Scroll {
3388 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
3389 modifiers: Modifiers::NONE,
3390 });
3391 tree.layout(SizeProposal::exact(200.0, 150.0));
3392 let inner_bottom = inner_y.get();
3393 assert!(
3394 inner_bottom > 0.0,
3395 "inner list should scroll down; got {inner_bottom}"
3396 );
3397 assert!(
3398 outer_y.get() < 0.01,
3399 "outer must not move while the inner absorbs"
3400 );
3401
3402 tree.pointer_move(Point::new(50.0, 40.0));
3403 tree.dispatch_event(WidgetEvent::Scroll {
3404 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3405 modifiers: Modifiers::NONE,
3406 });
3407 tree.layout(SizeProposal::exact(200.0, 150.0));
3408 assert!(
3409 (inner_y.get() - inner_bottom).abs() < 0.01,
3410 "inner stays clamped at bottom"
3411 );
3412 assert!(
3413 outer_y.get() > 0.01,
3414 "outer scrolled because the inner chained the boundary"
3415 );
3416 }
3417
3418 #[test]
3419 fn nested_list_contain_blocks_chaining() {
3420 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
3421 let (mut tree, _inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Contain);
3422 tree.pointer_move(Point::new(50.0, 40.0));
3423 tree.dispatch_event(WidgetEvent::Scroll {
3424 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
3425 modifiers: Modifiers::NONE,
3426 });
3427 tree.layout(SizeProposal::exact(200.0, 150.0));
3428 tree.pointer_move(Point::new(50.0, 40.0));
3429 tree.dispatch_event(WidgetEvent::Scroll {
3430 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3431 modifiers: Modifiers::NONE,
3432 });
3433 tree.layout(SizeProposal::exact(200.0, 150.0));
3434 assert!(
3435 outer_y.get() < 0.01,
3436 "Contain must prevent chaining: outer stays put"
3437 );
3438 }
3439
3440 #[test]
3441 fn keyboard_selection_chases_outer_scroll_area() {
3442 use crate::ScrollArea;
3448 use crate::primitives::{FixedSize, VStack};
3449 use teksilo_core::event::{Key, Modifiers};
3450 use teksilo_data::{SelectionMode, SelectionModel};
3451
3452 let mut tree = WidgetTree::new();
3453 let model = ListModel::from_vec((0..20_usize).collect());
3454 let selection = SelectionModel::new(SelectionMode::Single);
3455 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(180.0, 20.0)))
3456 .item_height(20.0)
3457 .selection(selection);
3458 let lv_id = tree.add(lv);
3459 let lv_box = tree.add(FixedSize::new().width(200.0).height(200.0).child_id(lv_id));
3460 let filler = tree.add(FixedLeaf(200.0, 200.0));
3461 let outer_content = tree.add(VStack::new().add_child(lv_box).add_child(filler));
3462 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
3463 let outer_y = outer.scroll_y_signal().clone();
3464 let _outer = tree.add(outer);
3465 tree.layout(SizeProposal::exact(200.0, 100.0));
3466
3467 tree.focus(lv_id);
3470 tree.layout(SizeProposal::exact(200.0, 100.0));
3471 outer_y.set(0.0);
3472 tree.layout(SizeProposal::exact(200.0, 100.0));
3473 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
3474
3475 for _ in 0..20 {
3477 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3478 }
3479 tree.layout(SizeProposal::exact(200.0, 100.0));
3480
3481 assert!(
3482 outer_y.get() > 0.01,
3483 "selecting a row below the outer fold must scroll the enclosing \
3484 ScrollArea (got {})",
3485 outer_y.get()
3486 );
3487 }
3488
3489 fn item_spans(tree: &WidgetTree, lv_id: WidgetId) -> Vec<(f32, f32)> {
3494 let children = row_ids(tree, lv_id);
3495 let mut spans: Vec<(f32, f32)> = children[..]
3496 .iter()
3497 .map(|c| {
3498 let b = tree.bounds(*c);
3499 (b.y, b.height)
3500 })
3501 .collect();
3502 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
3503 spans
3504 }
3505
3506 #[test]
3507 fn exact_item_height_fn_positions_rows_at_callback_heights() {
3508 let heights = [100.0_f32, 20.0, 50.0];
3509 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
3510 let mut tree = WidgetTree::new();
3511 let lv_id = tree.add(
3512 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3513 .item_height_fn(move |i| heights[i]),
3514 );
3515 tree.layout(SizeProposal::exact(400.0, 300.0));
3516
3517 let spans = item_spans(&tree, lv_id);
3518 assert_eq!(spans.len(), 3);
3519 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 100.0).abs() < 0.01);
3520 assert!((spans[1].0 - 100.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
3521 assert!((spans[2].0 - 120.0).abs() < 0.01 && (spans[2].1 - 50.0).abs() < 0.01);
3522 }
3523
3524 #[test]
3525 fn exact_heights_with_spacing() {
3526 let heights = [100.0_f32, 20.0, 50.0];
3527 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
3528 let mut tree = WidgetTree::new();
3529 let lv_id = tree.add(
3530 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3531 .item_height_fn(move |i| heights[i])
3532 .spacing(8.0),
3533 );
3534 tree.layout(SizeProposal::exact(400.0, 300.0));
3535
3536 let spans = item_spans(&tree, lv_id);
3537 assert!((spans[1].0 - 108.0).abs() < 0.01);
3538 assert!((spans[2].0 - 136.0).abs() < 0.01);
3539 }
3540
3541 #[test]
3542 fn variable_heights_virtualize() {
3543 let model = ListModel::from_vec((0..10_000).collect::<Vec<usize>>());
3544 let mut tree = WidgetTree::new();
3545 let lv_id = tree.add(
3546 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3547 .item_height_fn(|i| 20.0 + (i % 5) as f32 * 10.0),
3548 );
3549 tree.layout(SizeProposal::exact(400.0, 300.0));
3550
3551 let item_count = row_ids(&tree, lv_id).len();
3552 assert!(
3553 item_count < 40,
3554 "Expected fewer than 40 realized rows, got {item_count}"
3555 );
3556 assert!(
3557 item_count >= 8,
3558 "Expected at least 8 rows, got {item_count}"
3559 );
3560 }
3561
3562 #[test]
3563 fn auto_measure_corrects_rows_from_estimate() {
3564 let model = ListModel::from_vec(vec![0_usize, 1, 2, 3]);
3567 let mut tree = WidgetTree::new();
3568 let lv_id = tree.add(
3569 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3570 .auto_item_height(50.0),
3571 );
3572 tree.layout(SizeProposal::exact(400.0, 300.0));
3573 tree.layout(SizeProposal::exact(400.0, 300.0));
3574
3575 let spans = item_spans(&tree, lv_id);
3576 assert!(
3577 (spans[1].0 - 30.0).abs() < 0.01,
3578 "row 1 should sit at measured 30, got {}",
3579 spans[1].0
3580 );
3581 assert!((spans[1].1 - 30.0).abs() < 0.01);
3582 }
3583
3584 #[test]
3585 fn auto_measure_under_realization_converges() {
3586 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
3590 let mut tree = WidgetTree::new();
3591 let lv_id = tree.add(
3592 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
3593 .auto_item_height(100.0),
3594 );
3595 for _ in 0..6 {
3597 tree.layout(SizeProposal::exact(400.0, 300.0));
3598 }
3599
3600 let spans = item_spans(&tree, lv_id);
3601 let mut expected_y = spans[0].0;
3603 for (y, h) in &spans {
3604 assert!(
3605 (y - expected_y).abs() < 0.01,
3606 "rows must tile contiguously: expected y {expected_y}, got {y}"
3607 );
3608 expected_y = y + h;
3609 }
3610 let last_bottom = spans.last().map(|(y, h)| y + h).unwrap();
3612 assert!(
3613 last_bottom >= 300.0,
3614 "realized rows must cover the viewport bottom, got {last_bottom}"
3615 );
3616 }
3617
3618 #[test]
3619 fn auto_measure_append_preserves_measured_prefix() {
3620 let model = ListModel::from_vec((0..4).collect::<Vec<usize>>());
3621 let mut tree = WidgetTree::new();
3622 let lv_id = tree.add(
3623 ListView::new(model.clone(), |_i, _item, _sel| {
3624 Box::new(FixedLeaf(100.0, 30.0))
3625 })
3626 .auto_item_height(50.0),
3627 );
3628 tree.layout(SizeProposal::exact(400.0, 300.0));
3629 tree.layout(SizeProposal::exact(400.0, 300.0));
3630
3631 model.push(99);
3635 tree.layout(SizeProposal::exact(400.0, 300.0));
3636 let spans = item_spans(&tree, lv_id);
3637 assert_eq!(spans.len(), 5);
3638 assert!(
3639 (spans[1].0 - 30.0).abs() < 0.01,
3640 "measured prefix must survive an append, got y {}",
3641 spans[1].0
3642 );
3643 }
3644
3645 #[test]
3646 fn scrollbar_reservation_self_corrects_after_auto_measure_flips_the_decision() {
3647 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
3670 let mut tree = WidgetTree::new();
3671 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 40.0)))
3672 .auto_item_height(20.0);
3673 let scroll_y = lv.scroll_y_signal().clone();
3674 let lv_id = tree.add(lv);
3675
3676 tree.layout(SizeProposal::exact(400.0, 300.0));
3677 let children = row_ids(&tree, lv_id);
3678 let item0_frame1 = tree.bounds(children[0]).width;
3679 let sb_frame1 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
3680 assert!(
3681 (item0_frame1 - 400.0).abs() < 0.01,
3682 "frame 1 uses the pre-measure (no-scrollbar) decision, got width {item0_frame1}"
3683 );
3684 assert!(
3685 sb_frame1 < 0.01,
3686 "frame 1's scrollbar is still collapsed from the same stale decision, got {sb_frame1}"
3687 );
3688
3689 scroll_y.set(0.0);
3693 tree.layout(SizeProposal::exact(400.0, 300.0));
3694 let children = row_ids(&tree, lv_id);
3695 let item0_frame2 = tree.bounds(children[0]).width;
3696 let sb_frame2 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
3697 assert!(
3698 (item0_frame2 - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
3699 "frame 2 must self-correct to the measured (needs-scrollbar) width, got {item0_frame2}"
3700 );
3701 assert!(
3702 (sb_frame2 - SCROLLBAR_THICKNESS).abs() < 0.01,
3703 "frame 2's scrollbar must appear once the measured total is known, got {sb_frame2}"
3704 );
3705 }
3706
3707 #[test]
3708 fn ensure_index_visible_with_variable_heights() {
3709 let model = ListModel::from_vec((0..100).collect::<Vec<usize>>());
3710 let heights = |i: usize| 20.0 + (i % 3) as f32 * 20.0; let mut tree = WidgetTree::new();
3712 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3713 .item_height_fn(heights);
3714 let scroll = lv.scroll_y_signal().clone();
3715 let lv_id = tree.add(lv);
3716 tree.layout(SizeProposal::exact(400.0, 300.0));
3717
3718 let top_20: f32 = (0..20).map(heights).sum();
3721 let bottom_20 = top_20 + heights(20);
3722
3723 tree.widget_as_any(lv_id)
3724 .and_then(|any| any.downcast_ref::<ListView<usize>>())
3725 .expect("ListView exposes itself via as_any")
3726 .ensure_index_visible(20);
3727 assert!(
3730 (scroll.get() - (bottom_20 - 300.0)).abs() < 0.5,
3731 "scroll {} != bottom {} - viewport",
3732 scroll.get(),
3733 bottom_20
3734 );
3735 }
3736
3737 #[test]
3738 fn drag_insertion_with_variable_heights() {
3739 let model = ListModel::from_vec(vec![10_usize, 20, 30, 40, 50]);
3743 let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
3744 let mut tree = WidgetTree::new();
3745 let lv_id = tree.add(
3746 ListView::new(model.clone(), |_i, _item, _sel| {
3747 Box::new(FixedLeaf(100.0, 30.0))
3748 })
3749 .item_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
3750 .reorderable(true),
3751 );
3752 tree.layout(SizeProposal::exact(400.0, 300.0));
3753
3754 let children = row_ids(&tree, lv_id);
3756 let from = tree.bounds(children[4]).center();
3757 drag_item(&mut tree, from, Point::new(from.x, 35.0));
3758
3759 assert_eq!(model.with_item(1, |v| *v), Some(50));
3761 assert_eq!(model.with_item(2, |v| *v), Some(20));
3762 }
3763
3764 #[allow(clippy::type_complexity)]
3767 type Captured = Rc<RefCell<Option<(Vec<usize>, Option<Vec<usize>>)>>>;
3768
3769 fn export_scene(
3773 values: Vec<usize>,
3774 mode: DragTransferMode,
3775 ) -> (WidgetTree, ListModel<usize>, SelectionModel, Captured) {
3776 use crate::primitives::{FixedSize, VStack};
3777 use teksilo_core::widget_builder::WidgetBuilder as _;
3778 let model = ListModel::from_vec(values);
3779 let sel = SelectionModel::new(teksilo_data::SelectionMode::Multi);
3780 let cap: Captured = Rc::new(RefCell::new(None));
3781 let cap2 = cap.clone();
3782 let lv = ListView::new(model.clone(), |_i, _item, _s| {
3783 Box::new(FixedLeaf(180.0, 30.0))
3784 })
3785 .item_height(30.0)
3786 .selection(sel.clone())
3787 .exportable(mode);
3788 let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
3789 if let Some(rd) = payload.take_typed::<RowDragData<usize>>() {
3790 *cap2.borrow_mut() = Some((rd.rows, rd.items));
3791 true
3792 } else {
3793 false
3794 }
3795 });
3796 let mut tree = WidgetTree::new();
3797 tree.add(
3798 VStack::new()
3799 .spacing(0.0)
3800 .child(FixedSize::new().height(120.0).child(lv))
3801 .child(sink),
3802 );
3803 tree.layout(SizeProposal::exact(200.0, 300.0));
3804 (tree, model, sel, cap)
3805 }
3806
3807 #[test]
3808 fn exportable_row_drops_on_foreign_sink_with_items() {
3809 let (mut tree, _model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
3810 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3811 let (rows, items) = cap.borrow().clone().expect("sink received a RowDragData");
3812 assert_eq!(rows, vec![0]);
3813 assert_eq!(items, Some(vec![10]));
3814 }
3815
3816 #[test]
3817 fn exportable_move_removes_source_row_after_foreign_accept() {
3818 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Move);
3819 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3820 assert!(cap.borrow().is_some(), "sink accepted the drop");
3821 assert_eq!(model.len(), 2);
3823 assert_eq!(model.with_item(0, |v| *v), Some(20));
3824 }
3825
3826 #[test]
3827 fn exportable_copy_leaves_source_intact() {
3828 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
3829 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3830 assert!(cap.borrow().is_some());
3831 assert_eq!(model.len(), 3);
3832 assert_eq!(model.with_item(0, |v| *v), Some(10));
3833 }
3834
3835 #[test]
3836 fn exportable_multi_selection_drags_the_whole_set() {
3837 let (mut tree, _model, sel, cap) =
3838 export_scene(vec![10, 20, 30, 40], DragTransferMode::Copy);
3839 sel.select_indices([0_usize, 2_usize], false);
3841 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3842 let (rows, items) = cap.borrow().clone().expect("received");
3843 assert_eq!(rows, vec![0, 2]);
3844 assert_eq!(items, Some(vec![10, 30]));
3845 }
3846
3847 #[test]
3848 fn reorder_only_view_is_not_exportable() {
3849 use crate::primitives::{FixedSize, VStack};
3852 use teksilo_core::widget_builder::WidgetBuilder as _;
3853 let model: ListModel<usize> = ListModel::from_vec(vec![1, 2, 3]);
3854 let is_export: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
3855 let probe = is_export.clone();
3856 let lv = ListView::new(model.clone(), |_i, _it, _s| {
3857 Box::new(FixedLeaf(180.0, 30.0))
3858 })
3859 .item_height(30.0)
3860 .selection(SelectionModel::new(teksilo_data::SelectionMode::Single))
3861 .reorderable(true);
3862 let sink = FixedLeaf(180.0, 80.0).on_drop(move |payload, _pos, _ctx| {
3863 probe.set(
3864 payload
3865 .get_typed::<RowDragData<usize>>()
3866 .map(|rd| rd.is_export()),
3867 );
3868 true
3869 });
3870 let mut tree = WidgetTree::new();
3871 tree.add(
3872 VStack::new()
3873 .spacing(0.0)
3874 .child(FixedSize::new().height(120.0).child(lv))
3875 .child(sink),
3876 );
3877 tree.layout(SizeProposal::exact(200.0, 300.0));
3878 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
3879 assert_eq!(
3882 is_export.get(),
3883 Some(false),
3884 "reorder-only payload is not an export"
3885 );
3886 }
3887
3888 #[test]
3895 fn row_composite_tooltip_opens_for_the_hovered_row() {
3896 use crate::primitives::TextWidget;
3897 use std::time::Duration;
3898 use teksilo_i18n::lit;
3899
3900 let model = ListModel::from_vec(vec![
3901 "Alpha".to_string(),
3902 "Beta".to_string(),
3903 "Gamma".to_string(),
3904 ]);
3905 let mut tree = WidgetTree::new().with_text_backend(std::rc::Rc::new(
3906 std::cell::RefCell::new(teksilo_canvas::MockTextBackend::new()),
3907 ));
3908 let lv =
3909 tree.add(
3910 ListView::new(model, |_i, _it, _s| Box::new(FixedLeaf(180.0, 20.0)))
3911 .item_height(20.0)
3912 .row_composite_tooltip(|_i, item: &String| {
3913 Some(Box::new(TextWidget::new(lit!(format!("about {item}"))))
3914 as Box<dyn Widget>)
3915 }),
3916 );
3917 tree.layout(SizeProposal::exact(400.0, 200.0));
3918 assert!(tree.active_overlays().is_empty());
3919
3920 let bounds = tree.bounds(lv);
3922 tree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 30.0));
3923 tree.advance_time(Duration::from_millis(750));
3924
3925 assert_eq!(tree.active_overlays().len(), 1);
3926 assert!(
3927 tree.find_by_label("about Beta").is_some(),
3928 "the tip must carry the hovered row's own content"
3929 );
3930 }
3931
3932 #[test]
3933 fn accept_foreign_rows_receives_from_another_view() {
3934 use crate::primitives::{FixedSize, VStack};
3935 let a = ListModel::from_vec(vec![10, 20, 30]);
3937 let b = ListModel::from_vec(vec![100, 200]);
3938 let b_recv = b.clone();
3939 let lv_a = ListView::new(a.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
3940 .item_height(30.0)
3941 .exportable(DragTransferMode::Move);
3942 let lv_b = ListView::new(b.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
3943 .item_height(30.0)
3944 .accept_foreign_rows(true)
3945 .on_rows_received(move |items, at, _ctx| {
3946 for (k, v) in items.into_iter().enumerate() {
3947 b_recv.insert(at + k, v);
3948 }
3949 });
3950 let mut tree = WidgetTree::new();
3951 tree.add(
3952 VStack::new()
3953 .spacing(0.0)
3954 .child(FixedSize::new().height(90.0).child(lv_a))
3955 .child(FixedSize::new().height(150.0).child(lv_b))
3956 .child(FixedLeaf(180.0, 10.0)),
3957 );
3958 tree.layout(SizeProposal::exact(200.0, 300.0));
3959 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
3961 assert_eq!(b.len(), 3, "receiver B gained the dragged row");
3963 assert!(
3964 (0..b.len()).any(|i| b.with_item(i, |v| *v) == Some(10)),
3965 "B contains the moved value 10"
3966 );
3967 assert_eq!(a.len(), 2, "source A removed the moved row");
3968 assert!(
3969 (0..a.len()).all(|i| a.with_item(i, |v| *v) != Some(10)),
3970 "A no longer contains 10"
3971 );
3972 }
3973
3974 #[test]
3975 fn two_views_over_same_model_do_not_spuriously_reorder() {
3976 use crate::primitives::{FixedSize, VStack};
3977 let model = ListModel::from_vec(vec![10, 20, 30]);
3981 let lv_a = ListView::new(model.clone(), |_i, _it, _s| {
3982 Box::new(FixedLeaf(180.0, 30.0))
3983 })
3984 .item_height(30.0)
3985 .reorderable(true);
3986 let lv_b = ListView::new(model.clone(), |_i, _it, _s| {
3987 Box::new(FixedLeaf(180.0, 30.0))
3988 })
3989 .item_height(30.0)
3990 .reorderable(true);
3991 let mut tree = WidgetTree::new();
3992 tree.add(
3993 VStack::new()
3994 .spacing(0.0)
3995 .child(FixedSize::new().height(90.0).child(lv_a))
3996 .child(FixedSize::new().height(150.0).child(lv_b))
3997 .child(FixedLeaf(180.0, 10.0)),
3998 );
3999 tree.layout(SizeProposal::exact(200.0, 300.0));
4000 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4001 assert_eq!(model.with_item(0, |v| *v), Some(10));
4003 assert_eq!(model.with_item(1, |v| *v), Some(20));
4004 assert_eq!(model.with_item(2, |v| *v), Some(30));
4005 }
4006
4007 #[test]
4008 fn exportable_not_reorderable_does_not_reorder_on_same_view_drop() {
4009 use crate::primitives::{FixedSize, VStack};
4010 let model: ListModel<usize> = ListModel::from_vec(vec![10, 20, 30, 40]);
4014 let lv = ListView::new(model.clone(), |_i, _it, _s| {
4015 Box::new(FixedLeaf(180.0, 30.0))
4016 })
4017 .item_height(30.0)
4018 .exportable(DragTransferMode::Move)
4019 .accept_foreign_rows(true)
4020 .on_rows_received(|_items, _at, _ctx| {});
4021 let mut tree = WidgetTree::new();
4022 tree.add(
4023 VStack::new()
4024 .spacing(0.0)
4025 .child(FixedSize::new().height(200.0).child(lv))
4026 .child(FixedLeaf(180.0, 10.0)),
4027 );
4028 tree.layout(SizeProposal::exact(200.0, 300.0));
4029 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 75.0));
4031 assert_eq!(model.with_item(0, |v| *v), Some(10));
4033 assert_eq!(model.with_item(3, |v| *v), Some(40));
4034 }
4035}