1use std::cell::{Cell, RefCell};
63use std::rc::Rc;
64use std::time::Duration;
65
66use teksilo_canvas::{Point, Rect, Size, SizeProposal};
67use teksilo_tokens::{BorderRole, Easing};
68
69use teksilo_core::DropFeedback;
70use teksilo_core::accessibility::AccessNodeBuilder;
71use teksilo_core::binding::BindingLevel;
72use teksilo_core::drag_payload::DragPayload;
73use teksilo_core::signal::{Prop, Signal};
74use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
75use teksilo_core::widget_builder::HandlerSet;
76use teksilo_core::widget_id::WidgetId;
77
78use teksilo_data::selection_model::SelectionModel;
79use teksilo_data::{ItemKey, KeyedSelectionModel};
80
81use crate::data_views::RowSelection;
82use teksilo_data::{DataChange, DropPosition, DropResponse, ListModel};
83
84use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
85use crate::common::scroll::OverscrollBehavior;
86use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
87use crate::list_source::ListSource;
88use crate::scroll_area::ScrollBarMode;
89use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
90
91mod body_pane;
92
93const BUFFER_ITEMS: usize = 5;
95const DEFAULT_ITEM_HEIGHT: f32 = 32.0;
97const SCROLLBAR_THICKNESS: f32 = 12.0;
99
100pub struct ListView<T: 'static> {
104 source: ListSource<T>,
105 delegate: Rc<dyn Fn(usize, &T, bool) -> Box<dyn Widget>>,
106 row_tooltips: crate::data_views::RowTooltips<T>,
109 item_height: f32,
110 spacing: f32,
111 height_source: HeightSource,
113 metrics: SharedRowMetrics,
118 row_selection: Option<RowSelection>,
121
122 focused_index: Rc<Cell<Option<usize>>>,
124
125 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
132
133 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
139 type_ahead_timeout: Duration,
141 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
145
146 reorderable: bool,
148
149 show_scrollbar: bool,
155
156 scroll_y: Signal<f32>,
158 max_scroll_y: Signal<f32>,
159 overscroll_behavior: OverscrollBehavior,
161 viewport_ratio_y: Signal<f32>,
162
163 smooth_scrolling: bool,
166 smooth_scroll_duration: Duration,
168
169 scroll_bar_style: ScrollBarMode,
172
173 drop_feedback: Signal<Option<(f32, f32)>>, placed_content_width: Rc<Cell<f32>>,
179
180 on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
184 activate_on: crate::data_views::ActivateOn,
186
187 view_focused: Signal<bool>,
192 focus_visible: Signal<bool>,
195
196 layout_refresh: Signal<u64>,
203 paint_refresh: Signal<u64>,
208
209 pane_version: Signal<u64>,
214 pane_built_start: Rc<Cell<usize>>,
216 pane_built_end: Rc<Cell<usize>>,
217
218 body_pane_id: Option<WidgetId>,
220 scrollbar_id: Option<WidgetId>,
221 viewport_height: Rc<Cell<f32>>,
226 viewport_bounds: Rc<Cell<Rect>>,
234
235 model_id: ViewId,
238
239 export: crate::data_views::RowExport<T>,
244
245 enabled: Prop<bool>,
251}
252
253impl<T: 'static> ListView<T> {
254 pub fn new(
259 model: ListModel<T>,
260 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
261 ) -> Self {
262 Self::create(ListSource::from_model(model), delegate)
263 }
264
265 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
270 source: S,
271 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
272 ) -> Self {
273 Self::create(ListSource::from_data_source(source), delegate)
274 }
275
276 pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
284 source: S,
285 keyed: KeyedSelectionModel<S::Key>,
286 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
287 ) -> Self
288 where
289 S::Key: ItemKey,
290 {
291 let s = Rc::new(source);
292 let key_at = {
293 let s = s.clone();
294 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
295 };
296 let len = {
297 let s = s.clone();
298 Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
299 };
300 let contains = {
303 let s = s.clone();
304 Rc::new(move |k: &S::Key| (0..s.len()).any(|i| s.key_at(i).as_ref() == Some(k)))
305 as Rc<dyn Fn(&S::Key) -> bool>
306 };
307 let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
308 let mut view = Self::create(ListSource::from_data_source_rc(s), delegate);
309 view.row_selection = Some(row_selection);
310 view
311 }
312
313 pub(crate) fn from_list_source(
318 source: ListSource<T>,
319 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
320 ) -> Self {
321 Self::create(source, delegate)
322 }
323
324 fn create(
325 source: ListSource<T>,
326 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
327 ) -> Self {
328 let model_id = ViewId::next(ViewKind::List);
329 Self {
330 model_id,
331 export: crate::data_views::RowExport::default(),
332 source,
333 delegate: Rc::new(delegate),
334 row_tooltips: Default::default(),
335 item_height: DEFAULT_ITEM_HEIGHT,
336 spacing: 0.0,
337 height_source: HeightSource::Uniform,
338 metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
339 row_selection: None,
340 focused_index: Rc::new(Cell::new(None)),
341 row_map: Rc::new(RefCell::new(Vec::new())),
342 type_ahead_label: None,
343 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
344 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
345 reorderable: false,
346 show_scrollbar: true,
347 drop_feedback: Signal::new(None),
348 view_focused: Signal::new(false),
350 focus_visible: Signal::new(false),
351 placed_content_width: Rc::new(Cell::new(0.0)),
352 on_activate: None,
353 activate_on: crate::data_views::ActivateOn::default(),
354 overscroll_behavior: OverscrollBehavior::default(),
355 smooth_scrolling: true,
356 smooth_scroll_duration: Duration::from_millis(150),
357 scroll_bar_style: ScrollBarMode::Permanent,
358 scroll_y: Signal::new_animated(0.0),
359 max_scroll_y: Signal::new(0.0),
360 viewport_ratio_y: Signal::new(1.0),
361 layout_refresh: Signal::new(0_u64),
362 paint_refresh: Signal::new(0_u64),
363 pane_version: Signal::new(0_u64),
364 pane_built_start: Rc::new(Cell::new(0)),
365 pane_built_end: Rc::new(Cell::new(0)),
366 body_pane_id: None,
367 scrollbar_id: None,
368 viewport_height: Rc::new(Cell::new(600.0)),
369 viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
370 enabled: Prop::Static(true),
371 }
372 }
373
374 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
377 self.enabled = enabled.into();
378 self
379 }
380
381 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
385 self.overscroll_behavior = behavior;
386 self
387 }
388
389 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
391 self.smooth_scrolling = enabled;
392 self
393 }
394
395 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
397 self.smooth_scroll_duration = duration;
398 self
399 }
400
401 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
405 self.scroll_bar_style = style;
406 self
407 }
408
409 fn remake_metrics(&self) {
412 *self.metrics.borrow_mut() = self
413 .height_source
414 .make_metrics(self.item_height, self.spacing);
415 }
416
417 pub fn item_height(mut self, height: f32) -> Self {
422 self.item_height = height;
423 self.height_source = HeightSource::Uniform;
424 self.remake_metrics();
425 self
426 }
427
428 pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
433 self.height_source = HeightSource::Exact(Rc::new(f));
434 self.remake_metrics();
435 self
436 }
437
438 pub fn auto_item_height(mut self, estimated: f32) -> Self {
445 self.height_source = HeightSource::Auto { estimated };
446 self.remake_metrics();
447 self
448 }
449
450 pub fn spacing(mut self, spacing: f32) -> Self {
452 self.spacing = spacing;
453 self.remake_metrics();
454 self
455 }
456
457 pub fn selection(mut self, sel: SelectionModel) -> Self {
461 self.row_selection = Some(RowSelection::from_index(sel));
462 self
463 }
464
465 fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
491 let metrics = self.metrics.clone();
492 let scroll_y = self.scroll_y.clone();
493 let viewport_height = self.viewport_height.clone();
494 let max_scroll_y = self.max_scroll_y.clone();
495 let focused_index = self.focused_index.clone();
496 let selection = self.row_selection.clone();
497
498 let reveal: Rc<dyn Fn()> = Rc::new(move || {
499 let Some(index) = focused_index.get().or_else(|| {
500 selection
501 .as_ref()
502 .and_then(|s| s.selected_indices().first().copied())
503 }) else {
504 return;
505 };
506 let current = scroll_y.get();
507 let target = metrics.borrow_mut().scroll_for_ensure_visible(
508 index,
509 current,
510 viewport_height.get(),
511 max_scroll_y.get(),
512 );
513 if (target - current).abs() > f32::EPSILON {
514 scroll_y.set(target);
515 }
516 });
517
518 let on_focus = reveal.clone();
519 ctx.effect(&self.view_focused, move |focused| {
520 if *focused {
521 on_focus();
522 }
523 });
524
525 if let Some(sel) = self.row_selection.as_ref() {
526 let on_select = reveal;
527 let focused = self.view_focused.clone();
528 let handle = sel.observe_for_rebuild(move || {
529 if focused.get() {
533 on_select();
534 }
535 });
536 ctx.own_handle(handle);
537 }
538 }
539
540 pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>> {
564 self.row_map.clone()
565 }
566
567 fn current_row_widget(&self) -> Option<WidgetId> {
574 let index = self.focused_index.get().or_else(|| {
575 self.row_selection
576 .as_ref()
577 .and_then(|s| s.selected_indices().first().copied())
578 })?;
579 let map = self.row_map.borrow();
580 map.iter()
581 .find(|(i, _)| *i == index)
582 .map(|(_, widget)| *widget)
583 }
584
585 pub fn reorderable(mut self, enabled: bool) -> Self {
594 self.reorderable = enabled;
595 self
596 }
597
598 pub fn exportable(mut self, mode: DragTransferMode) -> Self
626 where
627 T: Clone,
628 {
629 self.export.set_exportable(mode);
630 self
631 }
632
633 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
641 where
642 T: Clone,
643 {
644 self.export.set_export_external(f);
645 self
646 }
647
648 pub fn on_rows_transferred_out(
654 mut self,
655 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
656 ) -> Self {
657 self.export.set_on_rows_transferred_out(f);
658 self
659 }
660
661 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
668 self.export.accept_foreign_rows = accept;
669 self
670 }
671
672 pub fn on_rows_received(
676 mut self,
677 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
678 ) -> Self {
679 self.export.set_on_rows_received(f);
680 self
681 }
682
683 pub fn on_activate(
692 mut self,
693 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
694 ) -> Self {
695 self.on_activate = Some(Rc::new(f));
696 self
697 }
698
699 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
703 self.activate_on = mode;
704 self
705 }
706
707 pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
722 self.row_tooltips.set_composite_sticky(on);
723 self
724 }
725
726 pub fn row_tooltip(
737 mut self,
738 f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
739 ) -> Self {
740 self.row_tooltips.set_plain(f);
741 self
742 }
743
744 pub fn row_rich_tooltip(
748 mut self,
749 f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
750 ) -> Self {
751 self.row_tooltips.set_rich(f);
752 self
753 }
754
755 pub fn row_composite_tooltip(
762 mut self,
763 f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
764 ) -> Self {
765 self.row_tooltips.set_composite(f);
766 self
767 }
768
769 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
770 self.type_ahead_label = Some(Rc::new(label));
771 self
772 }
773
774 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
777 self.type_ahead_timeout = timeout;
778 self
779 }
780
781 pub fn show_scrollbar(mut self, show: bool) -> Self {
790 self.show_scrollbar = show;
791 self
792 }
793
794 fn total_content_height(&self) -> f32 {
796 self.metrics.borrow_mut().total_height(self.source.len())
797 }
798
799 fn visible_range(&self) -> (usize, usize) {
801 self.metrics.borrow_mut().visible_range(
802 self.scroll_y.get(),
803 self.viewport_height.get(),
804 self.source.len(),
805 BUFFER_ITEMS,
806 )
807 }
808
809 fn child_ids(&self) -> Vec<WidgetId> {
814 [self.body_pane_id, self.scrollbar_id]
815 .into_iter()
816 .flatten()
817 .collect()
818 }
819
820 fn clamp_scroll(&self) {
822 let max = self.max_scroll_y.get();
823 let current = self.scroll_y.get();
824 let clamped = current.clamp(0.0, max);
825 if (clamped - current).abs() > 0.001 {
826 self.scroll_y.set(clamped);
827 }
828 }
829
830 #[cfg(test)]
833 pub(crate) fn drop_feedback_signal(&self) -> &Signal<Option<(f32, f32)>> {
834 &self.drop_feedback
835 }
836
837 pub fn scroll_y_signal(&self) -> &Signal<f32> {
844 &self.scroll_y
845 }
846
847 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
851 &self.max_scroll_y
852 }
853
854 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
857 &self.viewport_ratio_y
858 }
859
860 pub fn scroll_to_index(&self, index: usize) {
865 let target = self.metrics.borrow_mut().row_top(index);
866 let max = self.max_scroll_y.get();
867 self.scroll_y.set(target.clamp(0.0, max));
868 }
869
870 pub fn ensure_index_visible(&self, index: usize) {
873 let scroll = self.scroll_y.get();
874 let new_scroll = self.metrics.borrow_mut().scroll_for_ensure_visible(
875 index,
876 scroll,
877 self.viewport_height.get(),
878 self.max_scroll_y.get(),
879 );
880 if (new_scroll - scroll).abs() > f32::EPSILON {
881 self.scroll_y.set(new_scroll);
882 }
883 }
884}
885
886impl<T: 'static> std::fmt::Debug for ListView<T> {
887 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
888 f.debug_struct("ListView")
889 .field("item_count", &self.source.len())
890 .field("item_height", &self.item_height)
891 .field("scroll_bar_style", &self.scroll_bar_style)
892 .field("scroll_y", &self.scroll_y.get())
893 .finish()
894 }
895}
896
897impl<T: 'static> Widget for ListView<T> {
898 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
899 let self_id = ctx.self_id();
906 ctx.enabled_when(self_id, self.enabled.clone());
907
908 self.layout_refresh.bind_to(
912 ctx.self_id(),
913 ctx.binding_registry(),
914 BindingLevel::Relayout,
915 );
916 self.paint_refresh.bind_to(
920 ctx.self_id(),
921 ctx.binding_registry(),
922 BindingLevel::RepaintOnly,
923 );
924
925 self.scroll_y.bind_to(
929 ctx.self_id(),
930 ctx.binding_registry(),
931 BindingLevel::Relayout,
932 );
933
934 ctx.register_animated_signal(&self.scroll_y);
940
941 self.drop_feedback.bind_to(
945 ctx.self_id(),
946 ctx.binding_registry(),
947 BindingLevel::RepaintOnly,
948 );
949
950 self.view_focused = ctx.begin_view_focus();
960 ctx.end_view_focus();
961 self.focus_visible = ctx.focus_visible();
962 self.reveal_current_row_on_focus(ctx);
963 self.view_focused.bind_to(
964 ctx.self_id(),
965 ctx.binding_registry(),
966 BindingLevel::RepaintOnly,
967 );
968 self.focus_visible.bind_to(
969 ctx.self_id(),
970 ctx.binding_registry(),
971 BindingLevel::RepaintOnly,
972 );
973
974 let pane_version_for_data = self.pane_version.clone();
980 let layout_refresh_for_data = self.layout_refresh.clone();
981 let data_ver = Rc::new(Cell::new(0_u64));
982 let data_handle = (self.source.observe_fn)(Box::new({
983 let dv = data_ver.clone();
984 let metrics = self.metrics.clone();
985 let len_fn = self.source.len_fn.clone();
986 let first_changed = self.source.first_changed_fn.clone();
987 let row_sel = self.row_selection.clone();
988 let focused = self.focused_index.clone();
989 move |change| {
990 let divergence = match change {
994 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
995 Some(range.start)
996 }
997 DataChange::ItemUpdated { index } => Some(*index),
998 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
999 DataChange::WindowLoaded { range } => Some(range.start),
1001 DataChange::Reset => (first_changed)(),
1004 };
1005 metrics
1006 .borrow_mut()
1007 .apply_divergence(divergence, (len_fn)());
1008 if let Some(ref rs) = row_sel {
1011 rs.on_data_change(change);
1012 }
1013 if let Some(current) = focused.get() {
1018 focused.set(teksilo_data::data_change::adjust_single_index_for_change(
1019 current, change,
1020 ));
1021 }
1022 let next = dv.get() + 1;
1023 dv.set(next);
1024 pane_version_for_data.set(next);
1025 layout_refresh_for_data.set(next);
1026 }
1027 }));
1028 ctx.own_handle(data_handle);
1029
1030 if let Some(ref rs) = self.row_selection {
1035 let paint_refresh_for_sel = self.paint_refresh.clone();
1036 let sel_ver = Rc::new(Cell::new(0_u64));
1037 let handle = rs.observe_for_rebuild(move || {
1038 let next = sel_ver.get() + 1;
1039 sel_ver.set(next);
1040 paint_refresh_for_sel.set(next);
1041 });
1042 ctx.own_handle(handle);
1043 }
1044
1045 let scroll_y = self.scroll_y.clone();
1052 let max_scroll = self.max_scroll_y.clone();
1053 let line_height = self.item_height;
1054 let overscroll_behavior = self.overscroll_behavior;
1055 let smooth_scrolling = self.smooth_scrolling;
1056 let smooth_scroll_duration = self.smooth_scroll_duration;
1057 let mut handlers = HandlerSet::new()
1058 .on_scroll(move |event, _ctx| match event {
1059 teksilo_core::event::WidgetEvent::Scroll { delta, .. } => {
1060 let dy = match delta {
1061 teksilo_core::event::ScrollDelta::Lines { y, .. } => y * line_height,
1062 teksilo_core::event::ScrollDelta::Pixels { y, .. } => *y,
1063 };
1064 let current = scroll_y.get();
1065 let max = max_scroll.get();
1066 let base = scroll_y.animation_target().unwrap_or(current);
1069 let (new_y, moved) = crate::common::scroll::scroll_clamp_axis(base, dy, max);
1070 if moved {
1071 if smooth_scrolling {
1072 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
1073 } else {
1074 scroll_y.set(new_y);
1075 }
1076 }
1077 crate::common::scroll::scroll_response(
1080 moved,
1081 overscroll_behavior == OverscrollBehavior::Contain,
1082 )
1083 }
1084 _ => teksilo_core::event::EventResponse::Ignored,
1085 })
1086 .clips_children(true)
1087 .focusable(true);
1088
1089 {
1091 let len_for_key = self.source.len_fn.clone();
1092 let accept_drop_for_key = self.source.dnd.accept_drop_fn.clone();
1093 let stash_for_key = self.source.dnd.stash_drag_keys_fn.clone();
1094 let view_id_for_key = self.model_id;
1095 let sel_for_key = self.row_selection.clone();
1096 let activate_key = self.on_activate.clone();
1097 let fi = self.focused_index.clone();
1098 let reorderable = self.reorderable;
1099 let scroll_for_nav = self.scroll_y.clone();
1100 let metrics_for_nav = self.metrics.clone();
1101 let max_for_nav = self.max_scroll_y.clone();
1102 let vh_for_nav = self.viewport_height.clone();
1103 let vb_for_nav = self.viewport_bounds.clone();
1104 let ta_state = self.type_ahead.clone();
1107 let ta_label = self.type_ahead_label.clone();
1108 let ta_timeout = self.type_ahead_timeout;
1109 let with_item_str = self.source.with_item_str_fn.clone();
1110
1111 handlers = handlers.on_key(move |event, ctx| {
1112 if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
1113 use teksilo_core::event::Key;
1114 let count = (len_for_key)();
1115 if count == 0 {
1116 return teksilo_core::event::EventResponse::Ignored;
1117 }
1118
1119 if modifiers.command() && matches!(key, Key::A) {
1122 if let Some(ref sel) = sel_for_key
1123 && sel.mode() == teksilo_data::SelectionMode::Multi
1124 {
1125 sel.select_all(count);
1126 return teksilo_core::event::EventResponse::Handled;
1127 }
1128 return teksilo_core::event::EventResponse::Ignored;
1129 }
1130
1131 if ta_label.is_some()
1135 && !modifiers.ctrl()
1136 && !modifiers.alt()
1137 && !modifiers.super_key()
1138 && let Some(c) = key.to_char()
1139 {
1140 let current = fi.get().unwrap_or(0).min(count - 1);
1141 let label = ta_label.as_ref().unwrap();
1142 if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
1143 (with_item_str)(i, &|item| label(item))
1144 }) {
1145 fi.set(Some(idx));
1146 if let Some(ref sel) = sel_for_key {
1147 sel.select(idx);
1148 }
1149 let scroll = scroll_for_nav.get();
1150 let new_scroll =
1151 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1152 idx,
1153 scroll,
1154 vh_for_nav.get(),
1155 max_for_nav.get(),
1156 );
1157 if (new_scroll - scroll).abs() > f32::EPSILON {
1158 scroll_for_nav.set(new_scroll);
1159 }
1160 crate::common::row_metrics::chase_row_into_outer_view(
1161 ctx,
1162 &metrics_for_nav,
1163 vb_for_nav.get(),
1164 idx,
1165 new_scroll,
1166 );
1167 return teksilo_core::event::EventResponse::Handled;
1168 }
1169 return teksilo_core::event::EventResponse::Ignored;
1170 }
1171
1172 if modifiers.alt() && reorderable {
1177 let selected_idx = sel_for_key
1178 .as_ref()
1179 .and_then(|s| s.selected_indices().first().copied());
1180 if let Some(idx) = selected_idx {
1181 let mv = match key {
1182 teksilo_core::event::Key::ArrowUp if idx > 0 => {
1183 Some((idx - 1, DropPosition::Before, idx - 1))
1184 }
1185 teksilo_core::event::Key::ArrowDown if idx + 1 < count => {
1186 Some((idx + 1, DropPosition::After, idx + 1))
1187 }
1188 _ => None,
1189 };
1190 if let Some((target, position, dest)) = mv {
1191 (stash_for_key)(&[idx]);
1196 let payload = DragPayload::typed(RowDragData::<T> {
1197 source: view_id_for_key,
1198 rows: vec![idx],
1199 items: None,
1200 });
1201 if (accept_drop_for_key)(
1202 &payload,
1203 target,
1204 position,
1205 view_id_for_key,
1206 ) {
1207 if let Some(ref sel) = sel_for_key {
1208 sel.select(dest);
1209 }
1210 fi.set(Some(dest));
1211 let scroll = scroll_for_nav.get();
1214 let new_scroll =
1215 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1216 dest,
1217 scroll,
1218 vh_for_nav.get(),
1219 max_for_nav.get(),
1220 );
1221 if (new_scroll - scroll).abs() > f32::EPSILON {
1222 scroll_for_nav.set(new_scroll);
1223 }
1224 crate::common::row_metrics::chase_row_into_outer_view(
1225 ctx,
1226 &metrics_for_nav,
1227 vb_for_nav.get(),
1228 dest,
1229 new_scroll,
1230 );
1231 }
1232 return teksilo_core::event::EventResponse::Handled;
1233 }
1234 }
1235 }
1236
1237 let cursor = fi
1253 .get()
1254 .or_else(|| {
1255 sel_for_key
1256 .as_ref()
1257 .and_then(|s| s.selected_indices().first().copied())
1258 })
1259 .map(|i| i.min(count - 1));
1260 let current = cursor.unwrap_or(0);
1263 let new_idx = match key {
1264 Key::ArrowDown => Some(match cursor {
1265 None => 0,
1266 Some(c) => (c + 1).min(count - 1),
1267 }),
1268 Key::ArrowUp => Some(match cursor {
1269 None => count - 1,
1270 Some(c) => c.saturating_sub(1),
1271 }),
1272 Key::Home => Some(0),
1273 Key::End => Some(count - 1),
1274 Key::PageDown => {
1278 let vh = vh_for_nav.get();
1279 let r = {
1280 let mut m = metrics_for_nav.borrow_mut();
1281 m.resize(count);
1282 let target = m.row_top(current) + vh;
1283 m.row_at(target)
1284 };
1285 Some(if r == current {
1286 (current + 1).min(count - 1)
1287 } else {
1288 r.min(count - 1)
1289 })
1290 }
1291 Key::PageUp => {
1292 let vh = vh_for_nav.get();
1293 let r = {
1294 let mut m = metrics_for_nav.borrow_mut();
1295 m.resize(count);
1296 let target = (m.row_top(current) - vh).max(0.0);
1297 m.row_at(target)
1298 };
1299 Some(if r == current {
1300 current.saturating_sub(1)
1301 } else {
1302 r
1303 })
1304 }
1305 Key::Enter => {
1306 if let Some(ref sel) = sel_for_key {
1308 sel.select(current);
1309 }
1310 if let Some(ref cb) = activate_key {
1311 cb(current, ctx);
1312 }
1313 return teksilo_core::event::EventResponse::Handled;
1314 }
1315 Key::Space if modifiers.ctrl() => {
1316 if let Some(ref sel) = sel_for_key {
1333 sel.toggle(current);
1334 }
1335 fi.set(Some(current));
1336 return teksilo_core::event::EventResponse::Handled;
1337 }
1338 Key::Space => {
1339 if let Some(ref sel) = sel_for_key {
1344 if sel.mode() == teksilo_data::SelectionMode::Multi {
1345 sel.toggle(current);
1346 } else {
1347 sel.select(current);
1348 }
1349 }
1350 fi.set(Some(current));
1351 return teksilo_core::event::EventResponse::Handled;
1352 }
1353 _ => None,
1354 };
1355
1356 if let Some(idx) = new_idx {
1357 fi.set(Some(idx));
1358 let cursor_only = modifiers.ctrl()
1368 && !modifiers.shift()
1369 && matches!(key, Key::ArrowUp | Key::ArrowDown);
1370 if !cursor_only && let Some(ref sel) = sel_for_key {
1371 if modifiers.shift() {
1372 sel.extend_to(idx);
1373 } else {
1374 sel.select(idx);
1375 }
1376 }
1377 let scroll = scroll_for_nav.get();
1380 let new_scroll = metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1381 idx,
1382 scroll,
1383 vh_for_nav.get(),
1384 max_for_nav.get(),
1385 );
1386 if (new_scroll - scroll).abs() > f32::EPSILON {
1387 scroll_for_nav.set(new_scroll);
1388 }
1389 crate::common::row_metrics::chase_row_into_outer_view(
1390 ctx,
1391 &metrics_for_nav,
1392 vb_for_nav.get(),
1393 idx,
1394 new_scroll,
1395 );
1396 return teksilo_core::event::EventResponse::Handled;
1397 }
1398 }
1399 teksilo_core::event::EventResponse::Ignored
1400 });
1401 }
1402
1403 if self.export.is_drop_target(self.reorderable) {
1407 let metrics_for_hover = self.metrics.clone();
1408 let scroll_for_hover = self.scroll_y.clone();
1409 let len_for_hover = self.source.len_fn.clone();
1410 let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
1411 let my_view_id = self.model_id;
1412
1413 let feedback_for_hover = self.drop_feedback.clone();
1414 let width_for_hover = self.placed_content_width.clone();
1415 let export_for_hover = self.export.clone();
1416 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1417 let scroll = scroll_for_hover.get().max(0.0);
1418 let content_y = position.y + scroll;
1419 let len = (len_for_hover)();
1420 let (insertion_y, ins) = {
1421 let mut m = metrics_for_hover.borrow_mut();
1422 m.resize(len);
1423 let ins = m.insertion_index(content_y);
1424 (m.row_top(ins) - scroll, ins)
1425 };
1426 let line_width = width_for_hover.get();
1427 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1432 !matches!(
1433 (can_accept_for_hover)(payload, target, pos, my_view_id),
1434 DropResponse::Reject
1435 ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
1436 });
1437 if allowed {
1438 feedback_for_hover.set(Some((insertion_y, line_width)));
1439 DropFeedback::InsertionLine {
1440 y: insertion_y,
1441 width: line_width,
1442 }
1443 } else {
1444 feedback_for_hover.set(None);
1445 DropFeedback::NoFeedback
1446 }
1447 });
1448
1449 let len_for_drop = self.source.len_fn.clone();
1450 let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
1451 let drop_view_id = self.model_id;
1452 let scroll_for_drop = self.scroll_y.clone();
1453 let metrics_for_drop = self.metrics.clone();
1454 let export_for_drop = self.export.clone();
1455 let reorderable_for_drop = self.reorderable;
1456
1457 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1458 let scroll = scroll_for_drop.get().max(0.0);
1459 let content_y = position.y + scroll;
1460 let len = (len_for_drop)();
1461 let ins = {
1462 let mut m = metrics_for_drop.borrow_mut();
1463 m.resize(len);
1464 m.insertion_index(content_y)
1465 };
1466 let is_same_view = payload
1467 .get_typed::<RowDragData<T>>()
1468 .is_some_and(|rd| rd.source == drop_view_id);
1469 if (reorderable_for_drop || !is_same_view)
1473 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
1474 && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
1475 {
1476 if is_same_view {
1477 export_for_drop.note_self_reorder();
1478 }
1479 return true;
1480 }
1481 export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
1483 });
1484
1485 let feedback_for_leave = self.drop_feedback.clone();
1489 handlers = handlers.on_drag_leave(move |_ctx| {
1490 feedback_for_leave.set(None);
1491 });
1492
1493 let scroll_for_tick = self.scroll_y.clone();
1499 let max_scroll_for_tick = self.max_scroll_y.clone();
1500 let viewport_for_tick = self.viewport_height.clone();
1501 handlers = handlers.on_drag_tick(move |pos, _ctx| {
1502 const EDGE: f32 = 32.0;
1503 const MAX_VELOCITY: f32 = 12.0;
1504 let h = viewport_for_tick.get();
1505 let above = (EDGE - pos.y).max(0.0);
1506 let below = (pos.y - (h - EDGE)).max(0.0);
1507 let delta = if above > 0.0 {
1508 -(above / EDGE) * MAX_VELOCITY
1509 } else if below > 0.0 {
1510 (below / EDGE) * MAX_VELOCITY
1511 } else {
1512 0.0
1513 };
1514 if delta.abs() > 0.01 {
1515 let max = max_scroll_for_tick.get();
1516 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
1517 scroll_for_tick.set(new_y);
1518 }
1519 });
1520 }
1521
1522 handlers = self.export.install_completion(handlers);
1525
1526 ctx.apply_self_handlers(handlers);
1527
1528 let pane = body_pane::ListBodyPane::<T> {
1536 source: self.source.clone(),
1537 delegate: self.delegate.clone(),
1538 row_tooltips: self.row_tooltips.clone(),
1539 metrics: self.metrics.clone(),
1540 row_selection: self.row_selection.clone(),
1541 focused_index: self.focused_index.clone(),
1542 row_map: self.row_map.clone(),
1543 reorderable: self.reorderable,
1544 export: self.export.clone(),
1545 on_activate: self.on_activate.clone(),
1546 activate_on: self.activate_on,
1547 model_id: self.model_id,
1548 root_id: self_id,
1549 scroll_y: self.scroll_y.clone(),
1550 viewport_height: self.viewport_height.clone(),
1551 placed_content_width: self.placed_content_width.clone(),
1552 version: self.pane_version.clone(),
1553 total_refresh: self.layout_refresh.clone(),
1554 prev_built_start: self.pane_built_start.clone(),
1555 prev_built_end: self.pane_built_end.clone(),
1556 item_entries: Vec::new(),
1557 };
1558 self.body_pane_id = Some(ctx.add(pane));
1559
1560 if self.show_scrollbar {
1565 let scrollbar = ScrollBar::new(
1566 ScrollBarOrientation::Vertical,
1567 self.scroll_y.clone(),
1568 self.max_scroll_y.clone(),
1569 self.viewport_ratio_y.clone(),
1570 )
1571 .visual(match self.scroll_bar_style {
1572 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1573 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1574 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1575 });
1576 let sb_id = ctx.add(scrollbar);
1577 self.scrollbar_id = Some(sb_id);
1578 } else {
1579 self.scrollbar_id = None;
1580 }
1581
1582 self.child_ids()
1583 }
1584
1585 fn layout_response(
1586 &self,
1587 proposal: SizeProposal,
1588 _ctx: &LayoutContext,
1589 ) -> teksilo_core::widget::LayoutResponse {
1590 crate::common::viewport::viewport_size(
1594 proposal,
1595 &self.viewport_height,
1596 Size::new(300.0, 200.0),
1597 )
1598 .into()
1599 }
1600
1601 fn place_children(
1602 &self,
1603 bounds: Rect,
1604 _proposal: SizeProposal,
1605 children: &mut [WidgetPlacement],
1606 _ctx: &LayoutContext,
1607 ) {
1608 self.viewport_bounds.set(bounds);
1612 crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
1616
1617 if children.is_empty() {
1618 return;
1619 }
1620
1621 let viewport_height = bounds.height;
1622
1623 let provisional_total = self.total_content_height();
1627 let needs_internal_scrollbar =
1628 self.show_scrollbar && provisional_total > viewport_height + 0.5;
1629 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1630 let content_width = if needs_internal_scrollbar && reserves_bar {
1631 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1632 } else {
1633 bounds.width
1634 };
1635 self.placed_content_width.set(content_width);
1636
1637 let total_height = self.total_content_height();
1642 let max_y = (total_height - viewport_height).max(0.0);
1643 self.max_scroll_y.set(max_y);
1644 let ratio = if total_height > 0.0 {
1645 (viewport_height / total_height).clamp(0.0, 1.0)
1646 } else {
1647 1.0
1648 };
1649 self.viewport_ratio_y.set(ratio);
1650 self.clamp_scroll();
1651
1652 let mut next = 0;
1656 if self.body_pane_id.is_some() {
1657 if let Some(child) = children.get_mut(next) {
1658 child.origin = bounds.origin();
1659 child.size = Size::new(content_width, bounds.height);
1660 }
1661 next += 1;
1662 }
1663 if self.scrollbar_id.is_some()
1664 && let Some(sb_child) = children.get_mut(next)
1665 {
1666 if needs_internal_scrollbar {
1667 sb_child.origin =
1668 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1669 sb_child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1670 } else {
1671 sb_child.origin = bounds.origin();
1672 sb_child.size = Size::ZERO;
1673 }
1674 }
1675 }
1676
1677 fn paint(
1678 &self,
1679 bounds: Rect,
1680 canvas: &mut teksilo_canvas::Canvas,
1681 ctx: &teksilo_core::widget::PaintContext,
1682 ) {
1683 if let Some((y, width)) = self.drop_feedback.get() {
1687 let recipe = ctx
1688 .theme
1689 .style_slots
1690 .list_container
1691 .as_ref()
1692 .map(|s| s.insertion())
1693 .unwrap_or_default();
1694 let color = recipe.role.resolve(&ctx.theme.colors);
1695 let line_y = bounds.y + y;
1696 let line_x = bounds.x;
1697 let half = recipe.thickness * 0.5;
1698 canvas.set_clip(bounds);
1702 canvas.fill_rect(
1703 Rect::new(line_x, line_y - half, width, recipe.thickness),
1704 color,
1705 );
1706 canvas.clear_clip();
1707 }
1708
1709 let has_selection = self
1712 .row_selection
1713 .as_ref()
1714 .is_some_and(|s| s.has_selection());
1715 if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1716 let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1717 let inset = 1.0_f32;
1718 let rect = Rect::new(
1719 bounds.x + inset,
1720 bounds.y + inset,
1721 (bounds.width - inset * 2.0).max(0.0),
1722 (bounds.height - inset * 2.0).max(0.0),
1723 );
1724 canvas.stroke_rect(rect, color, 1.5);
1725 }
1726 }
1727
1728 fn context_menu_key_target(&self) -> Option<WidgetId> {
1743 self.current_row_widget()
1744 }
1745
1746 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1747 builder.set_role(teksilo_core::accesskit::Role::ListBox);
1748 if self
1761 .row_selection
1762 .as_ref()
1763 .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1764 {
1765 builder.set_multiselectable(true);
1766 }
1767
1768 builder.set_size_of_set(self.source.len());
1774
1775 if self.view_focused.get()
1805 && let Some(row) = self.current_row_widget()
1806 {
1807 builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(row));
1808 }
1809 }
1810
1811 fn as_any(&self) -> Option<&dyn std::any::Any> {
1812 Some(self)
1813 }
1814
1815 fn children(&self) -> Vec<WidgetId> {
1816 self.child_ids()
1817 }
1818
1819 fn clips_children(&self) -> bool {
1820 true
1821 }
1822}
1823
1824#[cfg(test)]
1825mod tests {
1826 use super::*;
1827 use teksilo_core::widget_tree::WidgetTree;
1828
1829 fn row_ids(tree: &WidgetTree, lv: WidgetId) -> Vec<WidgetId> {
1834 let kids = tree.children(lv);
1835 match kids.first() {
1836 Some(&pane) => tree.children(pane),
1837 None => Vec::new(),
1838 }
1839 }
1840
1841 fn scrollbar_of(tree: &WidgetTree, lv: WidgetId) -> WidgetId {
1843 *tree.children(lv).last().expect("ListView has children")
1844 }
1845
1846 #[test]
1847 fn smooth_scroll_survives_a_body_pane_rebuild() {
1848 let (mut tree, lv_id, model) = make_list_view(500, 20.0);
1849 let scroll = {
1850 tree.layout(SizeProposal::exact(400.0, 200.0));
1851 let any = tree.widget_as_any(lv_id).unwrap();
1852 any.downcast_ref::<ListView<usize>>()
1853 .unwrap()
1854 .scroll_y_signal()
1855 .clone()
1856 };
1857 crate::common::thumb_drag_test::assert_fling_survives_pane_rebuild(
1858 &mut tree,
1859 400.0,
1860 200.0,
1861 &scroll,
1862 "ListView",
1863 || model.push(9999),
1864 );
1865 }
1866
1867 #[test]
1868 fn rows_materialize_during_scrollbar_thumb_drag() {
1869 let (mut tree, lv_id, _model) = make_list_view(500, 20.0);
1872 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
1873 &mut tree,
1874 lv_id,
1875 400.0,
1876 200.0,
1877 0.0,
1878 "ListView",
1879 |t| {
1880 row_ids(t, lv_id)
1881 .into_iter()
1882 .filter(|id| {
1883 let b = t.bounds(*id);
1884 b.height > 1.0 && b.y > -b.height && b.y < 200.0
1885 })
1886 .count()
1887 },
1888 );
1889 }
1890
1891 #[derive(Debug)]
1892 struct FixedLeaf(f32, f32);
1893 impl Widget for FixedLeaf {
1894 fn layout_response(
1895 &self,
1896 _proposal: SizeProposal,
1897 _ctx: &LayoutContext,
1898 ) -> teksilo_core::widget::LayoutResponse {
1899 Size::new(self.0, self.1).into()
1900 }
1901 }
1902
1903 #[test]
1912 fn a_row_announces_its_position_out_of_the_whole_model() {
1913 let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
1914 tree.layout(SizeProposal::exact(400.0, 200.0));
1915 let rows = row_ids(&tree, lv_id);
1916 assert!(!rows.is_empty(), "some rows must be realized");
1917
1918 let update = tree.sync_accessibility();
1919 for (i, &row) in rows.iter().enumerate().take(3) {
1920 crate::a11y_set_semantics::assert_announces(
1921 &update,
1922 teksilo_core::accessibility::widget_id_to_node_id(row),
1923 i + 1,
1924 200,
1925 &format!("row {i}"),
1926 );
1927 }
1928 }
1929
1930 #[test]
1933 fn a_scrolled_row_announces_its_model_position_not_its_window_position() {
1934 let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
1935 tree.layout(SizeProposal::exact(400.0, 200.0));
1936 {
1937 let any = tree.widget_as_any(lv_id).unwrap();
1938 any.downcast_ref::<ListView<usize>>()
1939 .unwrap()
1940 .scroll_y_signal()
1941 .set(150.0 * 20.0);
1942 }
1943 tree.layout(SizeProposal::exact(400.0, 200.0));
1944
1945 let rows = row_ids(&tree, lv_id);
1946 assert!(
1947 !rows.is_empty(),
1948 "some rows must be realized after scrolling"
1949 );
1950 let update = tree.sync_accessibility();
1951 let (position, size) = crate::a11y_set_semantics::announced_set_position(
1952 &update,
1953 teksilo_core::accessibility::widget_id_to_node_id(rows[0]),
1954 );
1955 assert_eq!(
1956 size,
1957 Some(200),
1958 "the total is the model's, not the window's"
1959 );
1960 assert!(
1961 position.is_some_and(|p| p > 100),
1962 "the first realized row after scrolling to the 150th must announce a \
1963 model position, not a window position; got {position:?}"
1964 );
1965 }
1966
1967 fn make_list_view(count: usize, item_height: f32) -> (WidgetTree, WidgetId, ListModel<usize>) {
1968 let model = ListModel::from_vec((0..count).collect());
1969 let mut tree = WidgetTree::new();
1970 let lv_id = tree.add(
1971 ListView::new(model.clone(), move |_i, _item, _selected| {
1972 Box::new(FixedLeaf(100.0, item_height))
1973 })
1974 .item_height(item_height),
1975 );
1976 (tree, lv_id, model)
1977 }
1978
1979 #[test]
1992 fn taking_focus_reveals_the_current_row() {
1993 use teksilo_data::{SelectionMode, SelectionModel};
1994
1995 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
1996 let selection = SelectionModel::new(SelectionMode::Single);
1997 selection.select(150);
1998
1999 let mut tree = WidgetTree::new();
2000 let lv_id = tree.add(
2001 ListView::new(model.clone(), move |_i, _item, _selected| {
2002 Box::new(FixedLeaf(100.0, 20.0))
2003 })
2004 .item_height(20.0)
2005 .selection(selection.clone()),
2006 );
2007 tree.layout(SizeProposal::exact(400.0, 200.0));
2008
2009 let realized_selected = |tree: &mut WidgetTree| -> Vec<usize> {
2010 tree.accessibility_tree_snapshot()
2011 .nodes
2012 .iter()
2013 .filter(|(_, node)| node.is_selected() == Some(true))
2014 .filter_map(|(_, node)| node.position_in_set())
2015 .collect()
2016 };
2017
2018 assert!(
2019 realized_selected(&mut tree).is_empty(),
2020 "row 150 starts far outside the realized window, which is the case \
2021 this is about"
2022 );
2023
2024 tree.focus(lv_id);
2025 tree.layout(SizeProposal::exact(400.0, 200.0));
2026
2027 assert_eq!(
2028 realized_selected(&mut tree),
2029 vec![150],
2030 "taking focus has to bring the current row into the realized window, \
2031 or nothing in the tree can be told about it"
2032 );
2033 }
2034
2035 #[test]
2040 fn taking_focus_does_not_move_a_row_already_in_view() {
2041 use teksilo_data::{SelectionMode, SelectionModel};
2042
2043 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
2044 let selection = SelectionModel::new(SelectionMode::Single);
2045 selection.select(2);
2046
2047 let mut tree = WidgetTree::new();
2048 let lv_id = tree.add(
2049 ListView::new(model.clone(), move |_i, _item, _selected| {
2050 Box::new(FixedLeaf(100.0, 20.0))
2051 })
2052 .item_height(20.0)
2053 .selection(selection.clone()),
2054 );
2055 tree.layout(SizeProposal::exact(400.0, 200.0));
2056
2057 let scroll = {
2058 let any = tree.widget_as_any(lv_id).unwrap();
2059 any.downcast_ref::<ListView<usize>>()
2060 .unwrap()
2061 .scroll_y_signal()
2062 .clone()
2063 };
2064 let before = scroll.get();
2065
2066 tree.focus(lv_id);
2067 tree.layout(SizeProposal::exact(400.0, 200.0));
2068
2069 assert_eq!(
2070 scroll.get(),
2071 before,
2072 "row 2 is already visible, so the list must not scroll at all"
2073 );
2074 }
2075
2076 #[test]
2090 fn the_current_row_is_what_the_platform_calls_focused() {
2091 use teksilo_data::{SelectionMode, SelectionModel};
2092
2093 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2094 let selection = SelectionModel::new(SelectionMode::Single);
2095 selection.select(3);
2096
2097 let mut tree = WidgetTree::new();
2098 let lv_id = tree.add(
2099 ListView::new(model.clone(), move |_i, _item, _selected| {
2100 Box::new(FixedLeaf(100.0, 20.0))
2101 })
2102 .item_height(20.0)
2103 .selection(selection.clone()),
2104 );
2105 tree.layout(SizeProposal::exact(400.0, 200.0));
2106 tree.focus(lv_id);
2107 tree.layout(SizeProposal::exact(400.0, 200.0));
2108
2109 let focused_row = |tree: &mut WidgetTree| -> Option<usize> {
2111 let snapshot = tree.accessibility_tree_snapshot();
2112 let consumer = accesskit_consumer::Tree::new(snapshot, true);
2113 let state = consumer.state();
2114 let focus = state.focus_id()?;
2115 let focused = state.node_by_id(focus)?;
2116 let resolved = focused.active_descendant().unwrap_or(focused);
2121 resolved.position_in_set()
2123 };
2124
2125 assert_eq!(
2126 focused_row(&mut tree),
2127 Some(3),
2128 "with the list focused, the platform's focused node must be the \
2129 current row and not the list itself"
2130 );
2131
2132 selection.select(6);
2133 tree.layout(SizeProposal::exact(400.0, 200.0));
2134
2135 assert_eq!(
2136 focused_row(&mut tree),
2137 Some(6),
2138 "and moving the selection must move it, which is the focus change \
2139 NVDA announces; without it an arrow press is silent"
2140 );
2141 }
2142
2143 #[test]
2149 fn an_unselected_list_nominates_no_row() {
2150 let (mut tree, lv_id, _model) = make_list_view(10, 20.0);
2151 tree.layout(SizeProposal::exact(400.0, 200.0));
2152 tree.focus(lv_id);
2153 tree.layout(SizeProposal::exact(400.0, 200.0));
2154
2155 let snapshot = tree.accessibility_tree_snapshot();
2156 let consumer = accesskit_consumer::Tree::new(snapshot, true);
2157 let state = consumer.state();
2158 let focus = state.focus_id().expect("something has focus");
2159 let focused = state.node_by_id(focus).expect("the focused node exists");
2160 let resolved = focused.active_descendant().unwrap_or(focused);
2161 assert_eq!(
2162 resolved.role(),
2163 teksilo_core::accesskit::Role::ListBox,
2164 "with no selection the list itself is the focused node"
2165 );
2166 }
2167
2168 #[test]
2174 fn the_selected_row_reports_itself_after_a_move() {
2175 use teksilo_data::{SelectionMode, SelectionModel};
2176
2177 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2178 let selection = SelectionModel::new(SelectionMode::Single);
2179 selection.select(3);
2180
2181 let mut tree = WidgetTree::new();
2182 let _ = tree.add(
2185 ListView::new(model.clone(), move |_i, _item, _selected| {
2186 Box::new(FixedLeaf(100.0, 20.0))
2187 })
2188 .item_height(20.0)
2189 .selection(selection.clone()),
2190 );
2191 tree.layout(SizeProposal::exact(400.0, 200.0));
2192
2193 let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
2194 let nodes = tree.accessibility_tree_snapshot().nodes;
2195 nodes
2196 .iter()
2197 .filter(|(_, node)| node.is_selected() == Some(true))
2198 .filter_map(|(_, node)| node.position_in_set())
2199 .collect()
2200 };
2201
2202 assert_eq!(selected_rows(&mut tree), vec![3]);
2205
2206 selection.select(4);
2207 tree.layout(SizeProposal::exact(400.0, 200.0));
2208
2209 assert_eq!(
2210 selected_rows(&mut tree),
2211 vec![4],
2212 "the flag has to follow the selection, whether or not the widget was \
2213 rebuilt to carry it"
2214 );
2215 }
2216
2217 #[test]
2218 fn arrow_nav_resumes_from_the_clicked_row() {
2219 use teksilo_canvas::Point;
2223 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2224 use teksilo_data::{SelectionMode, SelectionModel};
2225
2226 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2227 let selection = SelectionModel::new(SelectionMode::Single);
2228 let sel = selection.clone();
2229 let mut tree = WidgetTree::new();
2230 let lv_id = tree.add(
2231 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2232 .item_height(20.0)
2233 .selection(sel),
2234 );
2235 tree.layout(SizeProposal::exact(400.0, 300.0)); tree.focus(lv_id);
2237
2238 tree.dispatch_event(WidgetEvent::PointerDown {
2240 position: Point::new(50.0, 70.0),
2241 button: PointerButton::Primary,
2242 modifiers: Modifiers::NONE,
2243 });
2244 tree.dispatch_event(WidgetEvent::PointerUp {
2245 position: Point::new(50.0, 70.0),
2246 button: PointerButton::Primary,
2247 modifiers: Modifiers::NONE,
2248 });
2249 assert_eq!(
2250 selection.selected_indices(),
2251 vec![3],
2252 "precondition: body click selects row 3"
2253 );
2254
2255 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2257 assert_eq!(
2258 selection.selected_indices(),
2259 vec![4],
2260 "ArrowDown after a click resumes from the clicked row (3 → 4)"
2261 );
2262 }
2263
2264 #[test]
2265 fn focused_index_follows_insert_before_it() {
2266 use teksilo_canvas::Point;
2272 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2273 use teksilo_data::{SelectionMode, SelectionModel};
2274
2275 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2276 let selection = SelectionModel::new(SelectionMode::Single);
2277 let sel = selection.clone();
2278 let mut tree = WidgetTree::new();
2279 let lv_id = tree.add(
2280 ListView::new(model.clone(), |_i, _item, _sel| {
2281 Box::new(FixedLeaf(100.0, 20.0))
2282 })
2283 .item_height(20.0)
2284 .selection(sel),
2285 );
2286 tree.layout(SizeProposal::exact(400.0, 300.0));
2287 tree.focus(lv_id);
2288
2289 tree.dispatch_event(WidgetEvent::PointerDown {
2291 position: Point::new(50.0, 70.0),
2292 button: PointerButton::Primary,
2293 modifiers: Modifiers::NONE,
2294 });
2295 tree.dispatch_event(WidgetEvent::PointerUp {
2296 position: Point::new(50.0, 70.0),
2297 button: PointerButton::Primary,
2298 modifiers: Modifiers::NONE,
2299 });
2300 assert_eq!(selection.selected_indices(), vec![3], "precondition");
2301
2302 model.insert(0, 100);
2304 model.insert(0, 200);
2305 tree.layout(SizeProposal::exact(400.0, 300.0));
2306 assert_eq!(
2309 selection.selected_indices(),
2310 vec![5],
2311 "precondition: selection shifts with the inserted rows"
2312 );
2313
2314 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2318 assert_eq!(
2319 selection.selected_indices(),
2320 vec![6],
2321 "ArrowDown after a leading insert resumes from the shifted row (5 → 6), \
2322 not the stale pre-insert one (3 → 4)"
2323 );
2324 }
2325
2326 #[test]
2327 fn focused_index_dropped_when_its_row_is_removed() {
2328 use teksilo_canvas::Point;
2331 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2332 use teksilo_data::{SelectionMode, SelectionModel};
2333
2334 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2335 let selection = SelectionModel::new(SelectionMode::Single);
2336 let sel = selection.clone();
2337 let mut tree = WidgetTree::new();
2338 let lv_id = tree.add(
2339 ListView::new(model.clone(), |_i, _item, _sel| {
2340 Box::new(FixedLeaf(100.0, 20.0))
2341 })
2342 .item_height(20.0)
2343 .selection(sel),
2344 );
2345 tree.layout(SizeProposal::exact(400.0, 300.0));
2346 tree.focus(lv_id);
2347
2348 tree.dispatch_event(WidgetEvent::PointerDown {
2350 position: Point::new(50.0, 70.0),
2351 button: PointerButton::Primary,
2352 modifiers: Modifiers::NONE,
2353 });
2354 tree.dispatch_event(WidgetEvent::PointerUp {
2355 position: Point::new(50.0, 70.0),
2356 button: PointerButton::Primary,
2357 modifiers: Modifiers::NONE,
2358 });
2359 assert_eq!(selection.selected_indices(), vec![3], "precondition");
2360
2361 model.remove(3);
2363 tree.layout(SizeProposal::exact(400.0, 300.0));
2364 assert!(
2365 selection.selected_indices().is_empty(),
2366 "precondition: selection drops the removed row"
2367 );
2368
2369 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2375 assert_eq!(
2376 selection.selected_indices(),
2377 vec![0],
2378 "focused_index was cleared, so nav restarts at the top (row 0), \
2379 not from the stale removed row's index (3 → 4)"
2380 );
2381 }
2382
2383 #[test]
2384 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
2385 use teksilo_core::event::{Key, Modifiers};
2390 use teksilo_data::{SelectionMode, SelectionModel};
2391
2392 for (key, want, what) in [
2393 (
2394 Key::ArrowDown,
2395 0usize,
2396 "first ArrowDown selects the first row",
2397 ),
2398 (Key::ArrowUp, 9usize, "first ArrowUp selects the last row"),
2399 ] {
2400 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2401 let selection = SelectionModel::new(SelectionMode::Single);
2402 let mut tree = WidgetTree::new();
2403 let lv_id = tree.add(
2404 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2405 .item_height(20.0)
2406 .selection(selection.clone()),
2407 );
2408 tree.layout(SizeProposal::exact(400.0, 300.0));
2409 tree.focus(lv_id);
2410 assert!(
2411 selection.selected_indices().is_empty(),
2412 "precondition: nothing selected, no cursor"
2413 );
2414
2415 tree.press_key(key, Modifiers::NONE);
2416 assert_eq!(selection.selected_indices(), vec![want], "{what}");
2417 }
2418 }
2419
2420 #[test]
2421 fn keyboard_cursor_starts_from_a_preset_selection() {
2422 use teksilo_core::event::{Key, Modifiers};
2427 use teksilo_data::{SelectionMode, SelectionModel};
2428
2429 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2430 let selection = SelectionModel::new(SelectionMode::Single);
2431 selection.select(2);
2432 let mut tree = WidgetTree::new();
2433 let lv_id = tree.add(
2434 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2435 .item_height(20.0)
2436 .selection(selection.clone()),
2437 );
2438 tree.layout(SizeProposal::exact(400.0, 300.0));
2439 tree.focus(lv_id);
2440
2441 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2442 assert_eq!(
2443 selection.selected_indices(),
2444 vec![3],
2445 "Down from a preselected row 2 continues to 3"
2446 );
2447 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2448 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2449 assert_eq!(selection.selected_indices(), vec![1], "and Up walks back");
2450 }
2451
2452 #[test]
2453 fn checkbox_press_does_not_select_row() {
2454 use crate::styles::recipe_standard_item_style as si;
2458 use teksilo_canvas::Point;
2459 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2460 use teksilo_data::{SelectionMode, SelectionModel};
2461 use teksilo_i18n::lit;
2462
2463 let model = ListModel::from_vec(vec!["alpha", "beta", "gamma"]);
2464 let checks: Vec<Signal<bool>> = (0..3).map(|_| Signal::new(false)).collect();
2465 let checks_for_rows = checks.clone();
2466 let selection = SelectionModel::new(SelectionMode::Single);
2467 let sel = selection.clone();
2468 let mut tree = WidgetTree::new();
2469 let lv_id = tree.add(
2470 ListView::new(model, move |i, _item, _selected| {
2471 Box::new(
2472 crate::StandardListItem::new(lit!(format!("row {i}")))
2473 .checkbox(checks_for_rows[i].clone()),
2474 ) as Box<dyn Widget>
2475 })
2476 .item_height(40.0)
2477 .selection(sel),
2478 );
2479 tree.layout(SizeProposal::exact(400.0, 300.0));
2480
2481 let rows = row_ids(&tree, lv_id);
2482 let row0 = tree.bounds(rows[0]);
2483 let press = |t: &mut WidgetTree, x: f32, y: f32| {
2484 t.dispatch_event(WidgetEvent::PointerDown {
2485 position: Point::new(x, y),
2486 button: PointerButton::Primary,
2487 modifiers: Modifiers::NONE,
2488 });
2489 t.dispatch_event(WidgetEvent::PointerUp {
2490 position: Point::new(x, y),
2491 button: PointerButton::Primary,
2492 modifiers: Modifiers::NONE,
2493 });
2494 };
2495
2496 let cb_x = row0.x
2498 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
2499 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2500 + 4.0;
2501 let cb_y = row0.y + row0.height * 0.5;
2502 press(&mut tree, cb_x, cb_y);
2503 assert!(checks[0].get(), "checkbox press should toggle the checkbox");
2504 assert!(
2505 selection.selected_indices().is_empty(),
2506 "checkbox press must not select the row (got {:?})",
2507 selection.selected_indices()
2508 );
2509
2510 let body_x = row0.x + row0.width * 0.7;
2512 press(&mut tree, body_x, cb_y);
2513 assert_eq!(
2514 selection.selected_indices(),
2515 vec![0],
2516 "body press should select row 0"
2517 );
2518 assert!(
2519 checks[0].get(),
2520 "body press must not toggle the checkbox back"
2521 );
2522 }
2523
2524 #[test]
2525 fn virtualization_creates_only_visible_items() {
2526 let (mut tree, lv_id, _model) = make_list_view(10_000, 30.0);
2527 tree.layout(SizeProposal::exact(400.0, 300.0));
2529
2530 let children = row_ids(&tree, lv_id);
2531 let item_count = children.len() - 1;
2533 assert!(
2534 item_count < 30,
2535 "Expected fewer than 30 items, got {}",
2536 item_count
2537 );
2538 assert!(
2539 item_count >= 10,
2540 "Expected at least 10 items, got {}",
2541 item_count
2542 );
2543 }
2544
2545 #[test]
2546 fn empty_model_shows_scrollbar_only() {
2547 let (mut tree, lv_id, _model) = make_list_view(0, 30.0);
2548 tree.layout(SizeProposal::exact(400.0, 300.0));
2549
2550 assert_eq!(tree.children(lv_id).len(), 2, "body pane + scrollbar");
2553 assert!(
2554 row_ids(&tree, lv_id).is_empty(),
2555 "no rows for an empty model"
2556 );
2557 }
2558
2559 #[test]
2560 fn data_change_triggers_rebuild() {
2561 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2562 tree.layout(SizeProposal::exact(400.0, 300.0));
2563
2564 let initial_items = row_ids(&tree, lv_id).len(); assert_eq!(initial_items, 5);
2566
2567 model.push(99);
2568 tree.layout(SizeProposal::exact(400.0, 300.0));
2569
2570 let new_items = row_ids(&tree, lv_id).len();
2571 assert_eq!(new_items, 6);
2572 }
2573
2574 #[test]
2575 fn remove_triggers_rebuild() {
2576 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2577 tree.layout(SizeProposal::exact(400.0, 300.0));
2578 assert_eq!(row_ids(&tree, lv_id).len(), 5);
2579
2580 model.remove(0);
2581 tree.layout(SizeProposal::exact(400.0, 300.0));
2582 assert_eq!(row_ids(&tree, lv_id).len(), 4);
2583 }
2584
2585 #[test]
2586 fn items_positioned_correctly() {
2587 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2588 tree.layout(SizeProposal::exact(400.0, 300.0));
2589
2590 let children = row_ids(&tree, lv_id);
2591 let y0 = tree.bounds(children[0]).y;
2593 let y1 = tree.bounds(children[1]).y;
2594 let y2 = tree.bounds(children[2]).y;
2595 assert!((y0 - 0.0).abs() < 0.01);
2596 assert!((y1 - 40.0).abs() < 0.01);
2597 assert!((y2 - 80.0).abs() < 0.01);
2598 }
2599
2600 #[test]
2601 fn items_have_correct_height() {
2602 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2603 tree.layout(SizeProposal::exact(400.0, 300.0));
2604
2605 let children = row_ids(&tree, lv_id);
2606 for i in 0..3 {
2607 let h = tree.bounds(children[i]).height;
2608 assert!((h - 40.0).abs() < 0.01, "Item {} height {} != 40.0", i, h);
2609 }
2610 }
2611
2612 #[test]
2613 fn scrollbar_positioned_on_right_edge() {
2614 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2615 tree.layout(SizeProposal::exact(400.0, 300.0));
2616
2617 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2618 assert!(
2620 (sb_bounds.x - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2621 "Scrollbar x {} != {}",
2622 sb_bounds.x,
2623 400.0 - SCROLLBAR_THICKNESS
2624 );
2625 assert!((sb_bounds.height - 300.0).abs() < 0.01);
2626 }
2627
2628 #[test]
2629 fn small_list_collapses_scrollbar() {
2630 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2631 tree.layout(SizeProposal::exact(400.0, 300.0));
2633
2634 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2635 assert!(
2636 sb_bounds.width < 0.01 && sb_bounds.height < 0.01,
2637 "Scrollbar should be collapsed for small lists"
2638 );
2639 }
2640
2641 #[test]
2642 fn item_width_leaves_room_for_scrollbar() {
2643 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2644 tree.layout(SizeProposal::exact(400.0, 300.0));
2645
2646 let children = row_ids(&tree, lv_id);
2647 let item_width = tree.bounds(children[0]).width;
2648 assert!(
2649 (item_width - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2650 "Item width {} should be {}",
2651 item_width,
2652 400.0 - SCROLLBAR_THICKNESS
2653 );
2654 }
2655
2656 #[test]
2657 fn small_list_items_use_full_width() {
2658 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2659 tree.layout(SizeProposal::exact(400.0, 300.0));
2661
2662 let children = row_ids(&tree, lv_id);
2663 let item_width = tree.bounds(children[0]).width;
2664 assert!(
2665 (item_width - 400.0).abs() < 0.01,
2666 "Small list item width {} should be full 400.0 (no scrollbar)",
2667 item_width,
2668 );
2669 }
2670
2671 fn make_selectable_list(
2674 count: usize,
2675 ) -> (
2676 WidgetTree,
2677 WidgetId,
2678 ListModel<usize>,
2679 teksilo_data::SelectionModel,
2680 ) {
2681 use teksilo_data::{SelectionMode, SelectionModel};
2682 let model = ListModel::from_vec((0..count).collect());
2683 let selection = SelectionModel::new(SelectionMode::Multi);
2684 let sel_clone = selection.clone();
2685 let mut tree = WidgetTree::new();
2686 let lv_id = tree.add(
2687 ListView::new(model.clone(), move |_i, _item, _selected| {
2688 Box::new(FixedLeaf(100.0, 30.0))
2689 })
2690 .item_height(30.0)
2691 .selection(sel_clone),
2692 );
2693 tree.layout(SizeProposal::exact(400.0, 300.0));
2694 (tree, lv_id, model, selection)
2695 }
2696
2697 #[test]
2698 fn click_selects_item() {
2699 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2700 let children = row_ids(&tree, lv_id);
2702 tree.click(children[1]);
2703 assert!(selection.is_selected(1), "item 1 should be selected");
2704 assert!(!selection.is_selected(0), "item 0 should not be selected");
2705 }
2706
2707 #[test]
2708 fn a_multi_select_list_says_so_and_a_single_select_one_does_not() {
2709 use teksilo_data::{SelectionMode, SelectionModel};
2718
2719 let published = |mode: SelectionMode| {
2720 let model = ListModel::from_vec(vec![1, 2, 3]);
2721 let mut tree = WidgetTree::new();
2722 tree.add(
2725 ListView::new(model, move |_i, _item, _selected| {
2726 Box::new(FixedLeaf(100.0, 30.0))
2727 })
2728 .item_height(30.0)
2729 .selection(SelectionModel::new(mode)),
2730 );
2731 tree.layout(SizeProposal::exact(400.0, 300.0));
2732 let snapshot = tree.accessibility_tree_snapshot();
2736 let consumer = accesskit_consumer::Tree::new(snapshot, true);
2737 let root = consumer.state().root();
2738 fn listbox<'a>(
2739 node: accesskit_consumer::NodeRef<'a>,
2740 ) -> Option<accesskit_consumer::NodeRef<'a>> {
2741 if node.role() == teksilo_core::accesskit::Role::ListBox {
2742 return Some(node);
2743 }
2744 node.children().find_map(listbox)
2745 }
2746 listbox(root)
2747 .expect("the view publishes a ListBox")
2748 .is_multiselectable()
2749 };
2750
2751 assert!(
2752 published(SelectionMode::Multi),
2753 "a list that takes more than one row has to say so"
2754 );
2755 assert!(
2756 !published(SelectionMode::Single),
2757 "and one that does not must not, or Windows raises the wrong event"
2758 );
2759 }
2760
2761 #[test]
2762 fn moving_the_selection_keeps_every_row_node() {
2763 let (mut tree, lv_id, _model, selection) = make_selectable_list(5);
2772 selection.select(0);
2773 tree.layout(SizeProposal::exact(400.0, 300.0));
2774 let before = row_ids(&tree, lv_id);
2775 assert_eq!(
2776 before.len(),
2777 5,
2778 "all five rows realize in a 300 px viewport"
2779 );
2780
2781 selection.select(1);
2782 tree.layout(SizeProposal::exact(400.0, 300.0));
2783 let after = row_ids(&tree, lv_id);
2784
2785 assert_eq!(
2786 before, after,
2787 "a selection move must not replace the row nodes"
2788 );
2789 }
2790
2791 #[test]
2792 fn click_replaces_selection() {
2793 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2794 let children = row_ids(&tree, lv_id);
2795 tree.click(children[0]);
2796 assert!(selection.is_selected(0));
2797
2798 tree.click(children[2]);
2799 assert!(selection.is_selected(2));
2800 assert!(
2801 !selection.is_selected(0),
2802 "previous selection should be cleared"
2803 );
2804 }
2805
2806 #[test]
2807 fn keyed_selection_tracks_identity_not_index() {
2808 use std::rc::Rc;
2811 use teksilo_core::ObserverHandle;
2812 use teksilo_data::{KeyedSelectionModel, ListDataSource, SelectionMode};
2813
2814 struct KeyedSource {
2815 items: Vec<(u64, usize)>, }
2817 impl ListDataSource for KeyedSource {
2818 type Item = usize;
2819 type Key = u64;
2820 fn len(&self) -> usize {
2821 self.items.len()
2822 }
2823 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
2824 self.items.get(i).map(|(_, v)| f(v))
2825 }
2826 fn key_at(&self, i: usize) -> Option<u64> {
2827 self.items.get(i).map(|(k, _)| *k)
2828 }
2829 fn index_of(&self, key: &u64) -> Option<usize> {
2830 self.items.iter().position(|(k, _)| k == key)
2831 }
2832 fn observe_changes(
2833 &self,
2834 _f: impl Fn(&teksilo_data::DataChange) + 'static,
2835 ) -> ObserverHandle {
2836 ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
2837 }
2838 }
2839
2840 let keyed = KeyedSelectionModel::<u64>::new(SelectionMode::Single);
2841 let source = KeyedSource {
2842 items: vec![(10, 100), (20, 200), (30, 300)],
2843 };
2844 let mut tree = WidgetTree::new();
2845 let lv_id = tree.add(
2846 ListView::from_source_keyed(source, keyed.clone(), |_i, _v, _sel| {
2847 Box::new(FixedLeaf(100.0, 30.0))
2848 })
2849 .item_height(30.0),
2850 );
2851 tree.layout(SizeProposal::exact(400.0, 300.0));
2852
2853 let children = row_ids(&tree, lv_id);
2855 tree.click(children[1]);
2856 assert!(keyed.is_selected(&20), "selection is stored by key");
2857 assert_eq!(keyed.selected_keys(), vec![20]);
2858 assert!(!keyed.is_selected(&10));
2859 }
2860
2861 #[test]
2862 fn ctrl_click_toggles() {
2863 use teksilo_core::event::Modifiers;
2864 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2865 let children = row_ids(&tree, lv_id);
2866
2867 tree.click(children[0]);
2869 assert!(selection.is_selected(0));
2870
2871 let center = tree.bounds(children[2]).center();
2873 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2874 position: center,
2875 button: teksilo_core::event::PointerButton::Primary,
2876 modifiers: Modifiers::COMMAND,
2877 });
2878 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerUp {
2879 position: center,
2880 button: teksilo_core::event::PointerButton::Primary,
2881 modifiers: Modifiers::COMMAND,
2882 });
2883
2884 assert!(selection.is_selected(0), "item 0 should still be selected");
2885 assert!(selection.is_selected(2), "item 2 should be toggled on");
2886 }
2887
2888 #[test]
2889 fn shift_click_extends_range() {
2890 use teksilo_core::event::Modifiers;
2891 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2892 let children = row_ids(&tree, lv_id);
2893
2894 tree.click(children[1]);
2896 assert!(
2897 selection.is_selected(1),
2898 "item 1 should be selected after plain click"
2899 );
2900
2901 let center = tree.bounds(children[3]).center();
2903 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2904 position: center,
2905 button: teksilo_core::event::PointerButton::Primary,
2906 modifiers: Modifiers::SHIFT,
2907 });
2908
2909 let selected = selection.selected_indices();
2910 assert_eq!(
2911 selected,
2912 vec![1, 2, 3],
2913 "Shift+click should select range 1..=3, got {:?}",
2914 selected
2915 );
2916 }
2917
2918 #[test]
2921 fn scroll_changes_visible_items() {
2922 let model = ListModel::from_vec((0..100).collect());
2924 let mut tree = WidgetTree::new();
2925 let lv_id = tree.add(
2926 ListView::new(model.clone(), move |i, _item, _selected| {
2927 Box::new(FixedLeaf(i as f32, 30.0))
2929 })
2930 .item_height(30.0),
2931 );
2932 tree.layout(SizeProposal::exact(400.0, 300.0));
2933
2934 let children = row_ids(&tree, lv_id);
2936 let first_y = tree.bounds(children[0]).y;
2937 assert!(
2938 first_y.abs() < 30.0,
2939 "First visible item should be near the top, got y={}",
2940 first_y
2941 );
2942
2943 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
2945 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 1500.0 },
2946 modifiers: Default::default(),
2947 });
2948 tree.layout(SizeProposal::exact(400.0, 300.0));
2949
2950 let children_after = row_ids(&tree, lv_id);
2953 let item_count_after = children_after.len() - 1;
2954 assert!(
2955 item_count_after > 0,
2956 "Should have visible items after scroll"
2957 );
2958
2959 let first_y_after = tree.bounds(children_after[0]).y;
2963 assert!(
2964 first_y_after < 300.0,
2965 "First item should be in viewport after scroll, got y={}",
2966 first_y_after
2967 );
2968
2969 assert!(
2981 item_count_after < 30,
2982 "Should still be virtualized after scroll, got {} items",
2983 item_count_after
2984 );
2985 }
2986
2987 #[test]
2990 fn list_item_has_a11y_role() {
2991 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2992 tree.layout(SizeProposal::exact(400.0, 300.0));
2993
2994 let children = row_ids(&tree, lv_id);
2996 let info = tree.accessibility_node(children[0]);
2997 assert_eq!(
2998 info.role(),
2999 teksilo_core::accesskit::Role::ListBoxOption,
3000 "Item wrapper should have ListBoxOption role"
3001 );
3002 }
3003
3004 #[test]
3007 fn alt_arrow_moves_one_step_per_press_across_rebuilds() {
3008 use teksilo_core::event::{Key, Modifiers};
3014 use teksilo_data::{SelectionMode, SelectionModel};
3015
3016 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3017 let selection = SelectionModel::new(SelectionMode::Single);
3018 let sel_clone = selection.clone();
3019 let model_clone = model.clone();
3020
3021 let mut tree = WidgetTree::new();
3022 let lv_id = tree.add(
3023 ListView::new(model_clone, move |_i, _item, _sel| {
3024 Box::new(FixedLeaf(100.0, 30.0))
3025 })
3026 .item_height(30.0)
3027 .selection(sel_clone)
3028 .reorderable(true),
3029 );
3030 tree.layout(SizeProposal::exact(400.0, 300.0));
3031
3032 for i in 0..3 {
3036 selection.select(i);
3037 tree.layout(SizeProposal::exact(400.0, 300.0));
3038 }
3039
3040 selection.select(0);
3041 tree.layout(SizeProposal::exact(400.0, 300.0));
3042
3043 tree.focus(lv_id);
3044 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
3045 key: Key::ArrowDown,
3046 modifiers: Modifiers::ALT,
3047 text: None,
3048 });
3049
3050 assert_eq!(model.with_item(0, |v| *v), Some(20));
3052 assert_eq!(model.with_item(1, |v| *v), Some(10));
3053 assert_eq!(model.with_item(2, |v| *v), Some(30));
3054 }
3055
3056 #[test]
3057 fn page_down_up_moves_selection_by_viewport() {
3058 use teksilo_core::event::{Key, Modifiers};
3059 use teksilo_data::{SelectionMode, SelectionModel};
3060
3061 let model = ListModel::from_vec((0..100usize).collect());
3062 let selection = SelectionModel::new(SelectionMode::Single);
3063 let sel = selection.clone();
3064 let mut tree = WidgetTree::new();
3065 let lv = tree.add(
3066 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3067 .item_height(20.0)
3068 .selection(sel),
3069 );
3070 let p = SizeProposal::exact(400.0, 200.0); tree.layout(p);
3072 tree.focus(lv);
3073 selection.select(0);
3074
3075 tree.press_key(Key::PageDown, Modifiers::NONE);
3076 tree.layout(p);
3077 let after_pgdn = selection.selected_indices()[0];
3078 assert!(
3079 after_pgdn >= 8,
3080 "PageDown should advance ~one viewport of rows, got {after_pgdn}"
3081 );
3082 let scroll = with_list_view::<usize, _>(&tree, lv, |v| v.scroll_y_signal().get());
3083 assert!(scroll > 0.0, "PageDown scrolls to follow, got {scroll}");
3084
3085 tree.press_key(Key::PageUp, Modifiers::NONE);
3086 tree.layout(p);
3087 assert!(
3088 selection.selected_indices()[0] < after_pgdn,
3089 "PageUp should move selection back up"
3090 );
3091 }
3092
3093 #[test]
3094 fn space_toggles_selection_enter_activates() {
3095 use std::cell::Cell;
3096 use teksilo_core::event::{Key, Modifiers};
3097 use teksilo_data::{SelectionMode, SelectionModel};
3098
3099 let model = ListModel::from_vec((0..5usize).collect());
3100 let selection = SelectionModel::new(SelectionMode::Multi);
3101 let sel = selection.clone();
3102 let activated = Rc::new(Cell::new(None));
3103 let act = activated.clone();
3104 let mut tree = WidgetTree::new();
3105 let lv = tree.add(
3106 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3107 .item_height(20.0)
3108 .selection(sel)
3109 .on_activate(move |i, _ctx| act.set(Some(i))),
3110 );
3111 tree.layout(SizeProposal::exact(400.0, 200.0));
3112 tree.focus(lv);
3113
3114 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3117 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3118 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3119 assert_eq!(selection.selected_indices(), vec![2]);
3120 assert_eq!(activated.get(), None, "arrows never activate");
3121
3122 tree.press_key(Key::Space, Modifiers::NONE);
3124 assert!(
3125 selection.selected_indices().is_empty(),
3126 "Space toggles row 2 off in Multi mode"
3127 );
3128 assert_eq!(activated.get(), None, "Space must NOT activate");
3129
3130 tree.press_key(Key::Enter, Modifiers::NONE);
3132 assert_eq!(activated.get(), Some(2), "Enter activates the focused row");
3133 }
3134
3135 #[test]
3136 fn ctrl_a_selects_all_in_multi_mode() {
3137 use teksilo_core::event::{Key, Modifiers};
3138 use teksilo_data::{SelectionMode, SelectionModel};
3139
3140 let model = ListModel::from_vec((0..6usize).collect());
3141 let selection = SelectionModel::new(SelectionMode::Multi);
3142 let sel = selection.clone();
3143 let mut tree = WidgetTree::new();
3144 let lv = tree.add(
3145 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3146 .item_height(20.0)
3147 .selection(sel),
3148 );
3149 tree.layout(SizeProposal::exact(400.0, 200.0));
3150 tree.focus(lv);
3151 tree.press_key(Key::A, Modifiers::COMMAND);
3152 assert_eq!(selection.selected_indices().len(), 6, "Ctrl+A selects all");
3153 }
3154
3155 #[test]
3156 fn ctrl_arrow_moves_cursor_without_selecting_in_multi_mode() {
3157 use teksilo_core::event::{Key, Modifiers};
3158 use teksilo_data::{SelectionMode, SelectionModel};
3159
3160 let model = ListModel::from_vec((0..6usize).collect());
3161 let selection = SelectionModel::new(SelectionMode::Multi);
3162 let sel = selection.clone();
3163 let mut tree = WidgetTree::new();
3164 let lv = tree.add(
3165 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3166 .item_height(20.0)
3167 .selection(sel),
3168 );
3169 tree.layout(SizeProposal::exact(400.0, 200.0));
3170 tree.focus(lv);
3171
3172 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3174 assert_eq!(selection.selected_indices(), vec![0]);
3175
3176 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3178 assert_eq!(
3179 selection.selected_indices(),
3180 vec![0],
3181 "Ctrl+ArrowDown must leave the selection unchanged"
3182 );
3183 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3184 assert_eq!(focused, Some(1), "Ctrl+ArrowDown moves the cursor to row 1");
3185
3186 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3187 assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
3188 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3189 assert_eq!(focused, Some(2));
3190
3191 tree.press_key(Key::Space, Modifiers::CTRL);
3194 assert_eq!(selection.selected_indices(), vec![0, 2]);
3195
3196 tree.press_key(Key::Space, Modifiers::CTRL);
3198 assert_eq!(selection.selected_indices(), vec![0]);
3199
3200 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3203 assert_eq!(selection.selected_indices(), vec![3]);
3204 }
3205
3206 #[test]
3207 fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
3208 use teksilo_core::event::{Key, Modifiers};
3209 use teksilo_data::{SelectionMode, SelectionModel};
3210
3211 let model = ListModel::from_vec((0..6usize).collect());
3212 let selection = SelectionModel::new(SelectionMode::Single);
3213 let sel = selection.clone();
3214 let mut tree = WidgetTree::new();
3215 let lv = tree.add(
3216 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3217 .item_height(20.0)
3218 .selection(sel),
3219 );
3220 tree.layout(SizeProposal::exact(400.0, 200.0));
3221 tree.focus(lv);
3222
3223 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3224 assert_eq!(selection.selected_indices(), vec![0]);
3225
3226 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3227 assert_eq!(
3228 selection.selected_indices(),
3229 vec![0],
3230 "Ctrl+ArrowDown must not select in Single mode either"
3231 );
3232 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3233 assert_eq!(focused, Some(1));
3234
3235 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3236 assert_eq!(selection.selected_indices(), vec![2]);
3237 }
3238
3239 #[test]
3240 fn type_ahead_jumps_to_matching_row() {
3241 use teksilo_core::event::{Key, Modifiers};
3242 use teksilo_data::{SelectionMode, SelectionModel};
3243
3244 let model = ListModel::from_vec(vec![
3245 "Apple".to_string(),
3246 "Banana".to_string(),
3247 "Cherry".to_string(),
3248 "Cranberry".to_string(),
3249 "Date".to_string(),
3250 ]);
3251 let selection = SelectionModel::new(SelectionMode::Single);
3252 let sel = selection.clone();
3253 let mut tree = WidgetTree::new();
3254 let lv = tree.add(
3255 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3256 .item_height(20.0)
3257 .selection(sel)
3258 .type_ahead_label(|s: &String| s.clone()),
3259 );
3260 tree.layout(SizeProposal::exact(400.0, 200.0));
3261 tree.focus(lv);
3262 selection.select(0);
3263
3264 tree.press_key(Key::C, Modifiers::NONE);
3266 assert_eq!(selection.selected_indices(), vec![2], "'c' → Cherry");
3267
3268 tree.press_key(Key::R, Modifiers::NONE);
3270 assert_eq!(selection.selected_indices(), vec![3], "'cr' → Cranberry");
3271 }
3272
3273 #[test]
3274 fn type_ahead_buffer_survives_rebuild() {
3275 use teksilo_core::event::{Key, Modifiers};
3280 use teksilo_data::{SelectionMode, SelectionModel};
3281
3282 let model = ListModel::from_vec(vec![
3283 "Apple".to_string(),
3284 "Cherry".to_string(),
3285 "Cranberry".to_string(),
3286 ]);
3287 let selection = SelectionModel::new(SelectionMode::Single);
3288 let sel = selection.clone();
3289 let mut tree = WidgetTree::new();
3290 let p = SizeProposal::exact(400.0, 200.0);
3291 let lv = tree.add(
3292 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3293 .item_height(20.0)
3294 .selection(sel)
3295 .type_ahead_label(|s: &String| s.clone()),
3296 );
3297 tree.layout(p);
3298 tree.focus(lv);
3299 selection.select(0);
3300
3301 tree.press_key(Key::C, Modifiers::NONE); assert_eq!(selection.selected_indices(), vec![1]);
3303 tree.layout(p); tree.press_key(Key::R, Modifiers::NONE); assert_eq!(
3306 selection.selected_indices(),
3307 vec![2],
3308 "buffer 'c' must survive the rebuild so 'cr' matches Cranberry"
3309 );
3310 }
3311
3312 #[test]
3313 fn page_down_on_short_list_jumps_to_last_without_panic() {
3314 use teksilo_core::event::{Key, Modifiers};
3315 use teksilo_data::{SelectionMode, SelectionModel};
3316
3317 let model = ListModel::from_vec((0..3usize).collect());
3319 let selection = SelectionModel::new(SelectionMode::Single);
3320 let sel = selection.clone();
3321 let mut tree = WidgetTree::new();
3322 let lv = tree.add(
3323 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3324 .item_height(20.0)
3325 .selection(sel),
3326 );
3327 tree.layout(SizeProposal::exact(400.0, 200.0));
3328 tree.focus(lv);
3329 selection.select(0);
3330 tree.press_key(Key::PageDown, Modifiers::NONE);
3331 assert_eq!(selection.selected_indices(), vec![2], "PageDown → last row");
3332 tree.press_key(Key::PageDown, Modifiers::NONE);
3333 assert_eq!(selection.selected_indices(), vec![2], "stays at last");
3334 tree.press_key(Key::PageUp, Modifiers::NONE);
3335 assert_eq!(selection.selected_indices(), vec![0], "PageUp → first row");
3336 }
3337
3338 #[test]
3339 fn alt_arrow_reorders_item() {
3340 use teksilo_core::event::{Key, Modifiers};
3341 use teksilo_data::{SelectionMode, SelectionModel};
3342
3343 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3344 let selection = SelectionModel::new(SelectionMode::Single);
3345 let sel_clone = selection.clone();
3346 let model_clone = model.clone();
3347
3348 let mut tree = WidgetTree::new();
3349 let lv_id = tree.add(
3350 ListView::new(model_clone.clone(), move |_i, _item, _sel| {
3351 Box::new(FixedLeaf(100.0, 30.0))
3352 })
3353 .item_height(30.0)
3354 .selection(sel_clone)
3355 .reorderable(true),
3356 );
3357 tree.layout(SizeProposal::exact(400.0, 300.0));
3358
3359 selection.select(2);
3361
3362 tree.focus(lv_id);
3364 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
3365 key: Key::ArrowDown,
3366 modifiers: Modifiers::ALT,
3367 text: None,
3368 });
3369
3370 assert_eq!(model.with_item(3, |v| *v), Some(30));
3372 assert_eq!(model.with_item(2, |v| *v), Some(40));
3373 }
3374
3375 fn make_reorderable_list(
3380 values: Vec<usize>,
3381 item_height: f32,
3382 ) -> (WidgetTree, WidgetId, ListModel<usize>) {
3383 let model = ListModel::from_vec(values);
3384 let model_clone = model.clone();
3385 let mut tree = WidgetTree::new();
3386 let lv_id = tree.add(
3387 ListView::new(model_clone, move |_i, _item, _sel| {
3388 Box::new(FixedLeaf(100.0, item_height))
3389 })
3390 .item_height(item_height)
3391 .reorderable(true),
3392 );
3393 (tree, lv_id, model)
3394 }
3395
3396 fn drag_item(tree: &mut WidgetTree, from: Point, to: Point) {
3399 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3400 tree.dispatch_event(WidgetEvent::PointerDown {
3401 position: from,
3402 button: PointerButton::Primary,
3403 modifiers: Modifiers::NONE,
3404 });
3405 tree.dispatch_event(WidgetEvent::PointerMove {
3407 position: Point::new(from.x + 10.0, from.y),
3408 });
3409 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
3410 tree.dispatch_event(WidgetEvent::PointerUp {
3411 position: to,
3412 button: PointerButton::Primary,
3413 modifiers: Modifiers::NONE,
3414 });
3415 }
3416
3417 #[test]
3418 fn drag_reorders_item_downward() {
3419 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3420 tree.layout(SizeProposal::exact(400.0, 300.0));
3421
3422 let children = row_ids(&tree, lv_id);
3425 let from = tree.bounds(children[0]).center();
3426 let to = Point::new(from.x, 120.0);
3427 drag_item(&mut tree, from, to);
3428
3429 assert_eq!(model.with_item(0, |v| *v), Some(20));
3431 assert_eq!(model.with_item(3, |v| *v), Some(10));
3432 assert_eq!(model.with_item(4, |v| *v), Some(50));
3433 }
3434
3435 #[test]
3436 fn drag_reorders_item_upward() {
3437 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3438 tree.layout(SizeProposal::exact(400.0, 300.0));
3439
3440 let children = row_ids(&tree, lv_id);
3443 let from = tree.bounds(children[3]).center();
3444 let to = Point::new(from.x, 15.0);
3445 drag_item(&mut tree, from, to);
3446
3447 assert_eq!(model.with_item(1, |v| *v), Some(40));
3449 assert_eq!(model.with_item(2, |v| *v), Some(20));
3450 assert_eq!(model.with_item(3, |v| *v), Some(30));
3451 }
3452
3453 #[test]
3454 fn reorderable_drag_routes_to_source_accept_drop() {
3455 use std::cell::RefCell;
3461 use std::rc::Rc;
3462 use teksilo_core::ObserverHandle;
3463 use teksilo_data::{
3464 DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
3465 ListDataSource,
3466 };
3467
3468 struct CapturingSource {
3469 items: Vec<usize>,
3470 captured: Rc<RefCell<Vec<(usize, usize)>>>,
3471 }
3472 impl ListDataSource for CapturingSource {
3473 type Item = usize;
3474 type Key = usize;
3475 fn len(&self) -> usize {
3476 self.items.len()
3477 }
3478 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
3479 self.items.get(i).map(f)
3480 }
3481 fn key_at(&self, i: usize) -> Option<usize> {
3482 (i < self.items.len()).then_some(i)
3483 }
3484 fn observe_changes(
3485 &self,
3486 _f: impl Fn(&teksilo_data::DataChange) + 'static,
3487 ) -> ObserverHandle {
3488 let inner: Rc<dyn std::any::Any> = Rc::new(());
3489 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3490 }
3491 fn drag(&self, _k: &usize) -> DragEligibility {
3492 DragEligibility::CanDrag
3493 }
3494 fn can_accept(&self, q: &DropQuery<'_, usize>) -> DropResponse {
3495 match &q.source {
3496 DragSource::SameView { .. } if q.position != DropPosition::Into => {
3497 DropResponse::Accept
3498 }
3499 _ => DropResponse::Reject,
3500 }
3501 }
3502 fn accept_drop(&self, c: DropCommit<'_, usize>) -> bool {
3503 let DragSource::SameView { key: from } = c.source else {
3504 return false;
3505 };
3506 let target = c.target;
3507 let shift = if from < target { 1 } else { 0 };
3508 let to = match c.position {
3509 DropPosition::Before => target.saturating_sub(shift),
3510 DropPosition::After => (target + 1).saturating_sub(shift),
3511 DropPosition::Into => return false,
3512 };
3513 self.captured.borrow_mut().push((from, to));
3515 true
3516 }
3517 }
3518
3519 let captured: Rc<RefCell<Vec<(usize, usize)>>> = Rc::new(RefCell::new(Vec::new()));
3520 let source = CapturingSource {
3521 items: vec![10, 20, 30, 40, 50],
3522 captured: captured.clone(),
3523 };
3524 let mut tree = WidgetTree::new();
3525 let lv_id = tree.add(
3526 ListView::from_source(source, move |_i, _item, _sel| {
3527 Box::new(FixedLeaf(100.0, 30.0))
3528 })
3529 .item_height(30.0)
3530 .reorderable(true),
3531 );
3532 tree.layout(SizeProposal::exact(400.0, 300.0));
3533
3534 let children = row_ids(&tree, lv_id);
3537 let from = tree.bounds(children[0]).center();
3538 let to = Point::new(from.x, 120.0);
3539 drag_item(&mut tree, from, to);
3540
3541 assert_eq!(
3542 *captured.borrow(),
3543 vec![(0, 3)],
3544 "the drop is routed to the source's accept_drop with the resolved move"
3545 );
3546 }
3547
3548 #[test]
3549 fn drag_emits_items_moved_change() {
3550 use std::cell::Cell;
3551 use std::rc::Rc;
3552 use teksilo_data::DataChange;
3553
3554 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3555 tree.layout(SizeProposal::exact(400.0, 300.0));
3556
3557 let moved = Rc::new(Cell::new(None::<(usize, usize)>));
3558 let moved_clone = moved.clone();
3559 let handle = model.observe_changes(move |change| {
3560 if let DataChange::ItemsMoved { from, to, .. } = change {
3561 moved_clone.set(Some((*from, *to)));
3562 }
3563 });
3564
3565 let children = row_ids(&tree, lv_id);
3567 let from = tree.bounds(children[0]).center();
3568 let to = Point::new(from.x, 120.0);
3569 drag_item(&mut tree, from, to);
3570
3571 assert_eq!(moved.get(), Some((0, 3)));
3572 drop(handle);
3573 }
3574
3575 #[test]
3576 fn drag_drop_accounts_for_scroll_offset() {
3577 let (mut tree, _lv_id, model) = make_reorderable_list((0..20).collect(), 30.0);
3580 tree.layout(SizeProposal::exact(400.0, 300.0));
3581
3582 tree.pointer_move(Point::new(50.0, 50.0));
3585 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3586 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 60.0 },
3587 modifiers: Default::default(),
3588 });
3589 tree.tick_animations(std::time::Duration::from_millis(200));
3592 tree.layout(SizeProposal::exact(400.0, 300.0));
3593
3594 let from = Point::new(50.0, 15.0);
3599 let to = Point::new(50.0, 120.0);
3600 drag_item(&mut tree, from, to);
3601
3602 assert_eq!(
3603 model.with_item(5, |v| *v),
3604 Some(2),
3605 "Item 2 should land at index 5 after drag with scroll offset"
3606 );
3607 assert_eq!(
3608 model.with_item(2, |v| *v),
3609 Some(3),
3610 "Item 3 should shift up to index 2"
3611 );
3612 }
3613
3614 #[test]
3615 fn click_selects_item_on_reorderable_list_with_selection() {
3616 use std::cell::Cell;
3625 use std::rc::Rc;
3626 use teksilo_data::{SelectionMode, SelectionModel};
3627
3628 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3629 let selection = SelectionModel::new(SelectionMode::Single);
3630 let sel_clone = selection.clone();
3631 let model_clone = model.clone();
3632
3633 let selected_rebuilds: Rc<std::cell::RefCell<Vec<Vec<usize>>>> =
3636 Rc::new(std::cell::RefCell::new(Vec::new()));
3637 let current_pass: Rc<Cell<Vec<usize>>> = Rc::new(Cell::new(Vec::new()));
3638 let _sr = selected_rebuilds.clone();
3639 let cp = current_pass.clone();
3640
3641 let mut tree = WidgetTree::new();
3642 let lv_id = tree.add(
3643 ListView::new(model_clone, move |index, _item, selected| {
3644 if selected {
3645 let mut acc = cp.take();
3646 acc.push(index);
3647 cp.set(acc);
3648 }
3649 Box::new(FixedLeaf(100.0, 30.0))
3650 })
3651 .item_height(30.0)
3652 .selection(sel_clone)
3653 .reorderable(true),
3654 );
3655 tree.layout(SizeProposal::exact(400.0, 300.0));
3656 selected_rebuilds.borrow_mut().push(current_pass.take());
3657
3658 let children = row_ids(&tree, lv_id);
3660 tree.click(children[2]);
3661
3662 assert_eq!(selection.selected_indices(), vec![2]);
3664
3665 tree.layout(SizeProposal::exact(400.0, 300.0));
3668 selected_rebuilds.borrow_mut().push(current_pass.take());
3669
3670 let passes = selected_rebuilds.borrow().clone();
3671 assert_eq!(
3672 passes[0],
3673 Vec::<usize>::new(),
3674 "initial build: nothing selected"
3675 );
3676 assert_eq!(
3677 passes[1],
3678 vec![2],
3679 "post-click rebuild should deliver selected=true for item 2"
3680 );
3681 }
3682
3683 #[test]
3684 fn drag_survives_rebuild_triggered_by_selection() {
3685 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3695 use teksilo_data::{SelectionMode, SelectionModel};
3696
3697 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3698 let selection = SelectionModel::new(SelectionMode::Single);
3699 let sel_clone = selection.clone();
3700 let model_clone = model.clone();
3701
3702 let mut tree = WidgetTree::new();
3703 let _lv_id = tree.add(
3704 ListView::new(model_clone, move |_i, _item, _sel| {
3705 Box::new(FixedLeaf(100.0, 30.0))
3706 })
3707 .item_height(30.0)
3708 .selection(sel_clone)
3709 .reorderable(true),
3710 );
3711 tree.layout(SizeProposal::exact(400.0, 300.0));
3712
3713 tree.dispatch_event(WidgetEvent::PointerDown {
3720 position: Point::new(50.0, 15.0),
3721 button: PointerButton::Primary,
3722 modifiers: Modifiers::NONE,
3723 });
3724 tree.layout(SizeProposal::exact(400.0, 300.0));
3729
3730 tree.dispatch_event(WidgetEvent::PointerMove {
3732 position: Point::new(60.0, 15.0),
3733 });
3734 tree.dispatch_event(WidgetEvent::PointerMove {
3736 position: Point::new(60.0, 120.0),
3737 });
3738 tree.dispatch_event(WidgetEvent::PointerUp {
3739 position: Point::new(60.0, 120.0),
3740 button: PointerButton::Primary,
3741 modifiers: Modifiers::NONE,
3742 });
3743
3744 assert_eq!(
3746 model.with_item(3, |v| *v),
3747 Some(10),
3748 "Drag must complete even after the selection-triggered rebuild \
3749 destroyed the originally-captured wrapper"
3750 );
3751 }
3752
3753 #[test]
3754 fn lazy_loading_rows_render_placeholders_and_request_the_window() {
3755 use std::cell::RefCell;
3759 use std::ops::Range;
3760 use std::rc::Rc;
3761 use teksilo_core::ObserverHandle;
3762 use teksilo_data::{ListDataSource, RowState};
3763
3764 struct Windowed {
3765 total: usize,
3766 requested: Rc<RefCell<Vec<Range<usize>>>>,
3767 }
3768 impl ListDataSource for Windowed {
3769 type Item = usize;
3770 type Key = usize;
3771 fn len(&self) -> usize {
3772 self.total
3773 }
3774 fn with_item<R>(&self, _i: usize, _f: impl FnOnce(&usize) -> R) -> Option<R> {
3775 None }
3777 fn key_at(&self, i: usize) -> Option<usize> {
3778 (i < self.total).then_some(i)
3779 }
3780 fn row_state(&self, _i: usize) -> RowState {
3781 RowState::Loading
3782 }
3783 fn request_window(&self, range: Range<usize>) {
3784 self.requested.borrow_mut().push(range);
3785 }
3786 fn observe_changes(
3787 &self,
3788 _f: impl Fn(&teksilo_data::DataChange) + 'static,
3789 ) -> ObserverHandle {
3790 let inner: Rc<dyn std::any::Any> = Rc::new(());
3791 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3792 }
3793 }
3794
3795 let requested = Rc::new(RefCell::new(Vec::new()));
3796 let source = Windowed {
3797 total: 1000,
3798 requested: requested.clone(),
3799 };
3800 let mut tree = WidgetTree::new();
3801 let lv_id = tree.add(
3802 ListView::from_source(source, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
3803 .item_height(30.0),
3804 );
3805 tree.layout(SizeProposal::exact(400.0, 300.0));
3806
3807 let placeholder_rows = row_ids(&tree, lv_id).len();
3810 assert!(
3811 placeholder_rows >= 10,
3812 "loading rows must render as placeholders, got {placeholder_rows}"
3813 );
3814 assert!(
3816 !requested.borrow().is_empty(),
3817 "request_window must be called for the visible range"
3818 );
3819 }
3820
3821 fn with_list_view<T: 'static, R>(
3824 tree: &WidgetTree,
3825 id: WidgetId,
3826 f: impl FnOnce(&ListView<T>) -> R,
3827 ) -> R {
3828 let any = tree.widget_as_any(id).expect("widget exposes as_any");
3829 let lv = any
3830 .downcast_ref::<ListView<T>>()
3831 .expect("widget is a ListView<T>");
3832 f(lv)
3833 }
3834
3835 #[test]
3836 fn drop_indicator_clears_after_drop() {
3837 let (mut tree, lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
3841 tree.layout(SizeProposal::exact(400.0, 300.0));
3842
3843 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
3844
3845 let feedback =
3846 with_list_view::<usize, _>(&tree, lv_id, |lv| lv.drop_feedback_signal().get());
3847 assert!(
3848 feedback.is_none(),
3849 "drop_feedback must be cleared by on_drag_leave after drop, got {:?}",
3850 feedback
3851 );
3852 }
3853
3854 #[test]
3855 fn drag_spawns_preview_overlay_and_cleans_up() {
3856 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3857
3858 let (mut tree, _lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
3859 tree.layout(SizeProposal::exact(400.0, 300.0));
3860
3861 let baseline = tree.overlay_manager().len();
3862
3863 tree.dispatch_event(WidgetEvent::PointerDown {
3865 position: Point::new(50.0, 15.0),
3866 button: PointerButton::Primary,
3867 modifiers: Modifiers::NONE,
3868 });
3869 tree.dispatch_event(WidgetEvent::PointerMove {
3870 position: Point::new(60.0, 15.0),
3871 });
3872
3873 assert_eq!(
3874 tree.overlay_manager().len(),
3875 baseline + 1,
3876 "Preview overlay should be live during drag"
3877 );
3878
3879 tree.dispatch_event(WidgetEvent::PointerUp {
3881 position: Point::new(60.0, 15.0),
3882 button: PointerButton::Primary,
3883 modifiers: Modifiers::NONE,
3884 });
3885 assert_eq!(
3886 tree.overlay_manager().len(),
3887 baseline,
3888 "Preview overlay should be dismissed after drop"
3889 );
3890 }
3891
3892 #[test]
3893 fn edge_auto_scroll_advances_scroll_y_during_drag() {
3894 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3895
3896 let (mut tree, _lv_id, _model) =
3898 make_reorderable_list((0..50).collect::<Vec<usize>>(), 30.0);
3899 tree.layout(SizeProposal::exact(400.0, 300.0));
3900
3901 tree.dispatch_event(WidgetEvent::PointerDown {
3904 position: Point::new(50.0, 15.0),
3905 button: PointerButton::Primary,
3906 modifiers: Modifiers::NONE,
3907 });
3908 tree.dispatch_event(WidgetEvent::PointerMove {
3909 position: Point::new(60.0, 15.0),
3910 });
3911 tree.dispatch_event(WidgetEvent::PointerMove {
3912 position: Point::new(60.0, 290.0), });
3914
3915 for _ in 0..8 {
3917 tree.layout(SizeProposal::exact(400.0, 300.0));
3918 }
3919 let scroll_y = with_list_view::<usize, _>(&tree, _lv_id, |lv| lv.scroll_y_signal().get());
3920 assert!(
3921 scroll_y > 5.0,
3922 "Edge auto-scroll should have advanced scroll_y; got {scroll_y}"
3923 );
3924
3925 tree.dispatch_event(WidgetEvent::PointerUp {
3927 position: Point::new(60.0, 290.0),
3928 button: PointerButton::Primary,
3929 modifiers: Modifiers::NONE,
3930 });
3931 }
3932
3933 fn nested_list_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>, Signal<f32>) {
3939 use crate::ScrollArea;
3940 use crate::primitives::{FixedSize, VStack};
3941 let mut tree = WidgetTree::new();
3942 let model = ListModel::from_vec((0..40_usize).collect());
3943 let lv = ListView::new(model, move |_i, _item, _sel| {
3944 Box::new(FixedLeaf(180.0, 30.0))
3945 })
3946 .item_height(30.0)
3947 .overscroll_behavior(inner);
3948 let inner_y = lv.scroll_y_signal().clone();
3949 let lv_id = tree.add(lv);
3950 let viewport = tree.add(FixedSize::new().width(200.0).height(100.0).child_id(lv_id));
3951 let filler = tree.add(FixedLeaf(200.0, 200.0));
3952 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
3953 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
3954 let outer_y = outer.scroll_y_signal().clone();
3955 let _outer = tree.add(outer);
3956 tree.layout(SizeProposal::exact(200.0, 150.0));
3957 (tree, inner_y, outer_y)
3958 }
3959
3960 #[test]
3961 fn nested_list_chains_to_outer_at_boundary() {
3962 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
3963 let (mut tree, inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Chain);
3964 tree.pointer_move(Point::new(50.0, 40.0));
3965 tree.dispatch_event(WidgetEvent::Scroll {
3966 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
3967 modifiers: Modifiers::NONE,
3968 });
3969 tree.layout(SizeProposal::exact(200.0, 150.0));
3970 let inner_bottom = inner_y.get();
3971 assert!(
3972 inner_bottom > 0.0,
3973 "inner list should scroll down; got {inner_bottom}"
3974 );
3975 assert!(
3976 outer_y.get() < 0.01,
3977 "outer must not move while the inner absorbs"
3978 );
3979
3980 tree.pointer_move(Point::new(50.0, 40.0));
3981 tree.dispatch_event(WidgetEvent::Scroll {
3982 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
3983 modifiers: Modifiers::NONE,
3984 });
3985 tree.layout(SizeProposal::exact(200.0, 150.0));
3986 assert!(
3987 (inner_y.get() - inner_bottom).abs() < 0.01,
3988 "inner stays clamped at bottom"
3989 );
3990 assert!(
3991 outer_y.get() > 0.01,
3992 "outer scrolled because the inner chained the boundary"
3993 );
3994 }
3995
3996 #[test]
3997 fn nested_list_contain_blocks_chaining() {
3998 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
3999 let (mut tree, _inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Contain);
4000 tree.pointer_move(Point::new(50.0, 40.0));
4001 tree.dispatch_event(WidgetEvent::Scroll {
4002 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
4003 modifiers: Modifiers::NONE,
4004 });
4005 tree.layout(SizeProposal::exact(200.0, 150.0));
4006 tree.pointer_move(Point::new(50.0, 40.0));
4007 tree.dispatch_event(WidgetEvent::Scroll {
4008 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
4009 modifiers: Modifiers::NONE,
4010 });
4011 tree.layout(SizeProposal::exact(200.0, 150.0));
4012 assert!(
4013 outer_y.get() < 0.01,
4014 "Contain must prevent chaining: outer stays put"
4015 );
4016 }
4017
4018 #[test]
4019 fn keyboard_selection_chases_outer_scroll_area() {
4020 use crate::ScrollArea;
4026 use crate::primitives::{FixedSize, VStack};
4027 use teksilo_core::event::{Key, Modifiers};
4028 use teksilo_data::{SelectionMode, SelectionModel};
4029
4030 let mut tree = WidgetTree::new();
4031 let model = ListModel::from_vec((0..20_usize).collect());
4032 let selection = SelectionModel::new(SelectionMode::Single);
4033 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(180.0, 20.0)))
4034 .item_height(20.0)
4035 .selection(selection);
4036 let lv_id = tree.add(lv);
4037 let lv_box = tree.add(FixedSize::new().width(200.0).height(200.0).child_id(lv_id));
4038 let filler = tree.add(FixedLeaf(200.0, 200.0));
4039 let outer_content = tree.add(VStack::new().add_child(lv_box).add_child(filler));
4040 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
4041 let outer_y = outer.scroll_y_signal().clone();
4042 let _outer = tree.add(outer);
4043 tree.layout(SizeProposal::exact(200.0, 100.0));
4044
4045 tree.focus(lv_id);
4048 tree.layout(SizeProposal::exact(200.0, 100.0));
4049 outer_y.set(0.0);
4050 tree.layout(SizeProposal::exact(200.0, 100.0));
4051 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
4052
4053 for _ in 0..20 {
4055 tree.press_key(Key::ArrowDown, Modifiers::NONE);
4056 }
4057 tree.layout(SizeProposal::exact(200.0, 100.0));
4058
4059 assert!(
4060 outer_y.get() > 0.01,
4061 "selecting a row below the outer fold must scroll the enclosing \
4062 ScrollArea (got {})",
4063 outer_y.get()
4064 );
4065 }
4066
4067 fn item_spans(tree: &WidgetTree, lv_id: WidgetId) -> Vec<(f32, f32)> {
4072 let children = row_ids(tree, lv_id);
4073 let mut spans: Vec<(f32, f32)> = children[..]
4074 .iter()
4075 .map(|c| {
4076 let b = tree.bounds(*c);
4077 (b.y, b.height)
4078 })
4079 .collect();
4080 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
4081 spans
4082 }
4083
4084 #[test]
4085 fn exact_item_height_fn_positions_rows_at_callback_heights() {
4086 let heights = [100.0_f32, 20.0, 50.0];
4087 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
4088 let mut tree = WidgetTree::new();
4089 let lv_id = tree.add(
4090 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4091 .item_height_fn(move |i| heights[i]),
4092 );
4093 tree.layout(SizeProposal::exact(400.0, 300.0));
4094
4095 let spans = item_spans(&tree, lv_id);
4096 assert_eq!(spans.len(), 3);
4097 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 100.0).abs() < 0.01);
4098 assert!((spans[1].0 - 100.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
4099 assert!((spans[2].0 - 120.0).abs() < 0.01 && (spans[2].1 - 50.0).abs() < 0.01);
4100 }
4101
4102 #[test]
4103 fn exact_heights_with_spacing() {
4104 let heights = [100.0_f32, 20.0, 50.0];
4105 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
4106 let mut tree = WidgetTree::new();
4107 let lv_id = tree.add(
4108 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4109 .item_height_fn(move |i| heights[i])
4110 .spacing(8.0),
4111 );
4112 tree.layout(SizeProposal::exact(400.0, 300.0));
4113
4114 let spans = item_spans(&tree, lv_id);
4115 assert!((spans[1].0 - 108.0).abs() < 0.01);
4116 assert!((spans[2].0 - 136.0).abs() < 0.01);
4117 }
4118
4119 #[test]
4120 fn variable_heights_virtualize() {
4121 let model = ListModel::from_vec((0..10_000).collect::<Vec<usize>>());
4122 let mut tree = WidgetTree::new();
4123 let lv_id = tree.add(
4124 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4125 .item_height_fn(|i| 20.0 + (i % 5) as f32 * 10.0),
4126 );
4127 tree.layout(SizeProposal::exact(400.0, 300.0));
4128
4129 let item_count = row_ids(&tree, lv_id).len();
4130 assert!(
4131 item_count < 40,
4132 "Expected fewer than 40 realized rows, got {item_count}"
4133 );
4134 assert!(
4135 item_count >= 8,
4136 "Expected at least 8 rows, got {item_count}"
4137 );
4138 }
4139
4140 #[test]
4141 fn auto_measure_corrects_rows_from_estimate() {
4142 let model = ListModel::from_vec(vec![0_usize, 1, 2, 3]);
4145 let mut tree = WidgetTree::new();
4146 let lv_id = tree.add(
4147 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4148 .auto_item_height(50.0),
4149 );
4150 tree.layout(SizeProposal::exact(400.0, 300.0));
4151 tree.layout(SizeProposal::exact(400.0, 300.0));
4152
4153 let spans = item_spans(&tree, lv_id);
4154 assert!(
4155 (spans[1].0 - 30.0).abs() < 0.01,
4156 "row 1 should sit at measured 30, got {}",
4157 spans[1].0
4158 );
4159 assert!((spans[1].1 - 30.0).abs() < 0.01);
4160 }
4161
4162 #[test]
4163 fn auto_measure_under_realization_converges() {
4164 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
4168 let mut tree = WidgetTree::new();
4169 let lv_id = tree.add(
4170 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
4171 .auto_item_height(100.0),
4172 );
4173 for _ in 0..6 {
4175 tree.layout(SizeProposal::exact(400.0, 300.0));
4176 }
4177
4178 let spans = item_spans(&tree, lv_id);
4179 let mut expected_y = spans[0].0;
4181 for (y, h) in &spans {
4182 assert!(
4183 (y - expected_y).abs() < 0.01,
4184 "rows must tile contiguously: expected y {expected_y}, got {y}"
4185 );
4186 expected_y = y + h;
4187 }
4188 let last_bottom = spans.last().map(|(y, h)| y + h).unwrap();
4190 assert!(
4191 last_bottom >= 300.0,
4192 "realized rows must cover the viewport bottom, got {last_bottom}"
4193 );
4194 }
4195
4196 #[test]
4197 fn auto_measure_append_preserves_measured_prefix() {
4198 let model = ListModel::from_vec((0..4).collect::<Vec<usize>>());
4199 let mut tree = WidgetTree::new();
4200 let lv_id = tree.add(
4201 ListView::new(model.clone(), |_i, _item, _sel| {
4202 Box::new(FixedLeaf(100.0, 30.0))
4203 })
4204 .auto_item_height(50.0),
4205 );
4206 tree.layout(SizeProposal::exact(400.0, 300.0));
4207 tree.layout(SizeProposal::exact(400.0, 300.0));
4208
4209 model.push(99);
4213 tree.layout(SizeProposal::exact(400.0, 300.0));
4214 let spans = item_spans(&tree, lv_id);
4215 assert_eq!(spans.len(), 5);
4216 assert!(
4217 (spans[1].0 - 30.0).abs() < 0.01,
4218 "measured prefix must survive an append, got y {}",
4219 spans[1].0
4220 );
4221 }
4222
4223 #[test]
4224 fn scrollbar_reservation_self_corrects_after_auto_measure_flips_the_decision() {
4225 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
4248 let mut tree = WidgetTree::new();
4249 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 40.0)))
4250 .auto_item_height(20.0);
4251 let scroll_y = lv.scroll_y_signal().clone();
4252 let lv_id = tree.add(lv);
4253
4254 tree.layout(SizeProposal::exact(400.0, 300.0));
4255 let children = row_ids(&tree, lv_id);
4256 let item0_frame1 = tree.bounds(children[0]).width;
4257 let sb_frame1 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
4258 assert!(
4259 (item0_frame1 - 400.0).abs() < 0.01,
4260 "frame 1 uses the pre-measure (no-scrollbar) decision, got width {item0_frame1}"
4261 );
4262 assert!(
4263 sb_frame1 < 0.01,
4264 "frame 1's scrollbar is still collapsed from the same stale decision, got {sb_frame1}"
4265 );
4266
4267 scroll_y.set(0.0);
4271 tree.layout(SizeProposal::exact(400.0, 300.0));
4272 let children = row_ids(&tree, lv_id);
4273 let item0_frame2 = tree.bounds(children[0]).width;
4274 let sb_frame2 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
4275 assert!(
4276 (item0_frame2 - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
4277 "frame 2 must self-correct to the measured (needs-scrollbar) width, got {item0_frame2}"
4278 );
4279 assert!(
4280 (sb_frame2 - SCROLLBAR_THICKNESS).abs() < 0.01,
4281 "frame 2's scrollbar must appear once the measured total is known, got {sb_frame2}"
4282 );
4283 }
4284
4285 #[test]
4286 fn ensure_index_visible_with_variable_heights() {
4287 let model = ListModel::from_vec((0..100).collect::<Vec<usize>>());
4288 let heights = |i: usize| 20.0 + (i % 3) as f32 * 20.0; let mut tree = WidgetTree::new();
4290 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4291 .item_height_fn(heights);
4292 let scroll = lv.scroll_y_signal().clone();
4293 let lv_id = tree.add(lv);
4294 tree.layout(SizeProposal::exact(400.0, 300.0));
4295
4296 let top_20: f32 = (0..20).map(heights).sum();
4299 let bottom_20 = top_20 + heights(20);
4300
4301 tree.widget_as_any(lv_id)
4302 .and_then(|any| any.downcast_ref::<ListView<usize>>())
4303 .expect("ListView exposes itself via as_any")
4304 .ensure_index_visible(20);
4305 assert!(
4308 (scroll.get() - (bottom_20 - 300.0)).abs() < 0.5,
4309 "scroll {} != bottom {} - viewport",
4310 scroll.get(),
4311 bottom_20
4312 );
4313 }
4314
4315 #[test]
4316 fn drag_insertion_with_variable_heights() {
4317 let model = ListModel::from_vec(vec![10_usize, 20, 30, 40, 50]);
4321 let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
4322 let mut tree = WidgetTree::new();
4323 let lv_id = tree.add(
4324 ListView::new(model.clone(), |_i, _item, _sel| {
4325 Box::new(FixedLeaf(100.0, 30.0))
4326 })
4327 .item_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
4328 .reorderable(true),
4329 );
4330 tree.layout(SizeProposal::exact(400.0, 300.0));
4331
4332 let children = row_ids(&tree, lv_id);
4334 let from = tree.bounds(children[4]).center();
4335 drag_item(&mut tree, from, Point::new(from.x, 35.0));
4336
4337 assert_eq!(model.with_item(1, |v| *v), Some(50));
4339 assert_eq!(model.with_item(2, |v| *v), Some(20));
4340 }
4341
4342 #[allow(clippy::type_complexity)]
4345 type Captured = Rc<RefCell<Option<(Vec<usize>, Option<Vec<usize>>)>>>;
4346
4347 fn export_scene(
4351 values: Vec<usize>,
4352 mode: DragTransferMode,
4353 ) -> (WidgetTree, ListModel<usize>, SelectionModel, Captured) {
4354 use crate::primitives::{FixedSize, VStack};
4355 use teksilo_core::widget_builder::WidgetBuilder as _;
4356 let model = ListModel::from_vec(values);
4357 let sel = SelectionModel::new(teksilo_data::SelectionMode::Multi);
4358 let cap: Captured = Rc::new(RefCell::new(None));
4359 let cap2 = cap.clone();
4360 let lv = ListView::new(model.clone(), |_i, _item, _s| {
4361 Box::new(FixedLeaf(180.0, 30.0))
4362 })
4363 .item_height(30.0)
4364 .selection(sel.clone())
4365 .exportable(mode);
4366 let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
4367 if let Some(rd) = payload.take_typed::<RowDragData<usize>>() {
4368 *cap2.borrow_mut() = Some((rd.rows, rd.items));
4369 true
4370 } else {
4371 false
4372 }
4373 });
4374 let mut tree = WidgetTree::new();
4375 tree.add(
4376 VStack::new()
4377 .spacing(0.0)
4378 .child(FixedSize::new().height(120.0).child(lv))
4379 .child(sink),
4380 );
4381 tree.layout(SizeProposal::exact(200.0, 300.0));
4382 (tree, model, sel, cap)
4383 }
4384
4385 #[test]
4386 fn exportable_row_drops_on_foreign_sink_with_items() {
4387 let (mut tree, _model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
4388 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4389 let (rows, items) = cap.borrow().clone().expect("sink received a RowDragData");
4390 assert_eq!(rows, vec![0]);
4391 assert_eq!(items, Some(vec![10]));
4392 }
4393
4394 #[test]
4395 fn exportable_move_removes_source_row_after_foreign_accept() {
4396 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Move);
4397 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4398 assert!(cap.borrow().is_some(), "sink accepted the drop");
4399 assert_eq!(model.len(), 2);
4401 assert_eq!(model.with_item(0, |v| *v), Some(20));
4402 }
4403
4404 #[test]
4405 fn exportable_copy_leaves_source_intact() {
4406 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
4407 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4408 assert!(cap.borrow().is_some());
4409 assert_eq!(model.len(), 3);
4410 assert_eq!(model.with_item(0, |v| *v), Some(10));
4411 }
4412
4413 #[test]
4414 fn exportable_multi_selection_drags_the_whole_set() {
4415 let (mut tree, _model, sel, cap) =
4416 export_scene(vec![10, 20, 30, 40], DragTransferMode::Copy);
4417 sel.select_indices([0_usize, 2_usize], false);
4419 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4420 let (rows, items) = cap.borrow().clone().expect("received");
4421 assert_eq!(rows, vec![0, 2]);
4422 assert_eq!(items, Some(vec![10, 30]));
4423 }
4424
4425 #[test]
4426 fn reorder_only_view_is_not_exportable() {
4427 use crate::primitives::{FixedSize, VStack};
4430 use teksilo_core::widget_builder::WidgetBuilder as _;
4431 let model: ListModel<usize> = ListModel::from_vec(vec![1, 2, 3]);
4432 let is_export: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
4433 let probe = is_export.clone();
4434 let lv = ListView::new(model.clone(), |_i, _it, _s| {
4435 Box::new(FixedLeaf(180.0, 30.0))
4436 })
4437 .item_height(30.0)
4438 .selection(SelectionModel::new(teksilo_data::SelectionMode::Single))
4439 .reorderable(true);
4440 let sink = FixedLeaf(180.0, 80.0).on_drop(move |payload, _pos, _ctx| {
4441 probe.set(
4442 payload
4443 .get_typed::<RowDragData<usize>>()
4444 .map(|rd| rd.is_export()),
4445 );
4446 true
4447 });
4448 let mut tree = WidgetTree::new();
4449 tree.add(
4450 VStack::new()
4451 .spacing(0.0)
4452 .child(FixedSize::new().height(120.0).child(lv))
4453 .child(sink),
4454 );
4455 tree.layout(SizeProposal::exact(200.0, 300.0));
4456 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4457 assert_eq!(
4460 is_export.get(),
4461 Some(false),
4462 "reorder-only payload is not an export"
4463 );
4464 }
4465
4466 #[test]
4473 fn row_composite_tooltip_opens_for_the_hovered_row() {
4474 use crate::primitives::TextWidget;
4475 use std::time::Duration;
4476 use teksilo_i18n::lit;
4477
4478 let model = ListModel::from_vec(vec![
4479 "Alpha".to_string(),
4480 "Beta".to_string(),
4481 "Gamma".to_string(),
4482 ]);
4483 let mut tree = WidgetTree::new().with_text_backend(std::rc::Rc::new(
4484 std::cell::RefCell::new(teksilo_canvas::MockTextBackend::new()),
4485 ));
4486 let lv =
4487 tree.add(
4488 ListView::new(model, |_i, _it, _s| Box::new(FixedLeaf(180.0, 20.0)))
4489 .item_height(20.0)
4490 .row_composite_tooltip(|_i, item: &String| {
4491 Some(Box::new(TextWidget::new(lit!(format!("about {item}"))))
4492 as Box<dyn Widget>)
4493 }),
4494 );
4495 tree.layout(SizeProposal::exact(400.0, 200.0));
4496 assert!(tree.active_overlays().is_empty());
4497
4498 let bounds = tree.bounds(lv);
4500 tree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 30.0));
4501 tree.advance_time(Duration::from_millis(750));
4502
4503 assert_eq!(tree.active_overlays().len(), 1);
4504 assert!(
4505 tree.find_by_label("about Beta").is_some(),
4506 "the tip must carry the hovered row's own content"
4507 );
4508 }
4509
4510 #[test]
4511 fn accept_foreign_rows_receives_from_another_view() {
4512 use crate::primitives::{FixedSize, VStack};
4513 let a = ListModel::from_vec(vec![10, 20, 30]);
4515 let b = ListModel::from_vec(vec![100, 200]);
4516 let b_recv = b.clone();
4517 let lv_a = ListView::new(a.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4518 .item_height(30.0)
4519 .exportable(DragTransferMode::Move);
4520 let lv_b = ListView::new(b.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4521 .item_height(30.0)
4522 .accept_foreign_rows(true)
4523 .on_rows_received(move |items, at, _ctx| {
4524 for (k, v) in items.into_iter().enumerate() {
4525 b_recv.insert(at + k, v);
4526 }
4527 });
4528 let mut tree = WidgetTree::new();
4529 tree.add(
4530 VStack::new()
4531 .spacing(0.0)
4532 .child(FixedSize::new().height(90.0).child(lv_a))
4533 .child(FixedSize::new().height(150.0).child(lv_b))
4534 .child(FixedLeaf(180.0, 10.0)),
4535 );
4536 tree.layout(SizeProposal::exact(200.0, 300.0));
4537 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4539 assert_eq!(b.len(), 3, "receiver B gained the dragged row");
4541 assert!(
4542 (0..b.len()).any(|i| b.with_item(i, |v| *v) == Some(10)),
4543 "B contains the moved value 10"
4544 );
4545 assert_eq!(a.len(), 2, "source A removed the moved row");
4546 assert!(
4547 (0..a.len()).all(|i| a.with_item(i, |v| *v) != Some(10)),
4548 "A no longer contains 10"
4549 );
4550 }
4551
4552 #[test]
4553 fn two_views_over_same_model_do_not_spuriously_reorder() {
4554 use crate::primitives::{FixedSize, VStack};
4555 let model = ListModel::from_vec(vec![10, 20, 30]);
4559 let lv_a = ListView::new(model.clone(), |_i, _it, _s| {
4560 Box::new(FixedLeaf(180.0, 30.0))
4561 })
4562 .item_height(30.0)
4563 .reorderable(true);
4564 let lv_b = ListView::new(model.clone(), |_i, _it, _s| {
4565 Box::new(FixedLeaf(180.0, 30.0))
4566 })
4567 .item_height(30.0)
4568 .reorderable(true);
4569 let mut tree = WidgetTree::new();
4570 tree.add(
4571 VStack::new()
4572 .spacing(0.0)
4573 .child(FixedSize::new().height(90.0).child(lv_a))
4574 .child(FixedSize::new().height(150.0).child(lv_b))
4575 .child(FixedLeaf(180.0, 10.0)),
4576 );
4577 tree.layout(SizeProposal::exact(200.0, 300.0));
4578 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4579 assert_eq!(model.with_item(0, |v| *v), Some(10));
4581 assert_eq!(model.with_item(1, |v| *v), Some(20));
4582 assert_eq!(model.with_item(2, |v| *v), Some(30));
4583 }
4584
4585 #[test]
4586 fn exportable_not_reorderable_does_not_reorder_on_same_view_drop() {
4587 use crate::primitives::{FixedSize, VStack};
4588 let model: ListModel<usize> = ListModel::from_vec(vec![10, 20, 30, 40]);
4592 let lv = ListView::new(model.clone(), |_i, _it, _s| {
4593 Box::new(FixedLeaf(180.0, 30.0))
4594 })
4595 .item_height(30.0)
4596 .exportable(DragTransferMode::Move)
4597 .accept_foreign_rows(true)
4598 .on_rows_received(|_items, _at, _ctx| {});
4599 let mut tree = WidgetTree::new();
4600 tree.add(
4601 VStack::new()
4602 .spacing(0.0)
4603 .child(FixedSize::new().height(200.0).child(lv))
4604 .child(FixedLeaf(180.0, 10.0)),
4605 );
4606 tree.layout(SizeProposal::exact(200.0, 300.0));
4607 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 75.0));
4609 assert_eq!(model.with_item(0, |v| *v), Some(10));
4611 assert_eq!(model.with_item(3, |v| *v), Some(40));
4612 }
4613
4614 #[test]
4618 fn advertises_focus_so_assistive_tech_can_focus_the_list() {
4619 let (mut tree, lv_id, _model) = make_list_view(20, 20.0);
4620 tree.layout(SizeProposal::exact(400.0, 200.0));
4621
4622 let info = tree.accessibility_node(lv_id);
4623 assert_eq!(info.role(), teksilo_core::accesskit::Role::ListBox);
4624 assert!(
4625 info.actions()
4626 .contains(&teksilo_core::accesskit::Action::Focus),
4627 "ListView must advertise Action::Focus; without it no screen reader \
4628 can move focus into the list"
4629 );
4630
4631 let mut ops = teksilo_core::window::NoopWindowOps;
4633 let handled = tree.dispatch_access_action(
4634 teksilo_core::accessibility::widget_id_to_node_id(lv_id),
4635 teksilo_core::accesskit::Action::Focus,
4636 None,
4637 &mut ops,
4638 );
4639 assert!(handled, "the Focus action must be serviced");
4640 assert_eq!(tree.focused(), Some(lv_id));
4641 }
4642}