1use std::cell::{Cell, RefCell};
72use std::rc::Rc;
73use std::time::Duration;
74
75use teksilo_canvas::{Point, Rect, Size, SizeProposal};
76use teksilo_tokens::{BorderRole, Easing};
77
78use teksilo_core::DropFeedback;
79use teksilo_core::accessibility::AccessNodeBuilder;
80use teksilo_core::binding::BindingLevel;
81use teksilo_core::drag_payload::DragPayload;
82use teksilo_core::signal::{Prop, Signal};
83use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
84use teksilo_core::widget_builder::HandlerSet;
85use teksilo_core::widget_id::WidgetId;
86
87use teksilo_data::selection_model::SelectionModel;
88use teksilo_data::{ItemKey, KeyedSelectionModel};
89
90use crate::data_views::RowSelection;
91use teksilo_data::{DataChange, DropPosition, DropResponse, ListModel};
92
93use crate::common::list_nav;
96use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
97use crate::common::scroll::OverscrollBehavior;
98use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
99use crate::list_source::ListSource;
100use crate::scroll_area::ScrollBarMode;
101use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
102
103mod body_pane;
104
105const BUFFER_ITEMS: usize = 5;
107const DEFAULT_ITEM_HEIGHT: f32 = 32.0;
109const SCROLLBAR_THICKNESS: f32 = 12.0;
111
112pub struct ListView<T: 'static> {
116 source: ListSource<T>,
117 delegate: Rc<dyn Fn(usize, &T, bool) -> Box<dyn Widget>>,
118 row_tooltips: crate::data_views::RowTooltips<T>,
121 item_height: f32,
122 spacing: f32,
123 height_source: HeightSource,
125 metrics: SharedRowMetrics,
130 row_selection: Option<RowSelection>,
133
134 focused_index: Rc<Cell<Option<usize>>>,
136
137 row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
144
145 type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
151 type_ahead_timeout: Duration,
153 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
157
158 reorderable: bool,
160
161 show_scrollbar: bool,
167
168 scroll_y: Signal<f32>,
170 max_scroll_y: Signal<f32>,
171 overscroll_behavior: OverscrollBehavior,
173 viewport_ratio_y: Signal<f32>,
174
175 smooth_scrolling: bool,
178 smooth_scroll_duration: Duration,
180
181 scroll_bar_style: ScrollBarMode,
184
185 drop_feedback: Signal<Option<(f32, f32)>>, placed_content_width: Rc<Cell<f32>>,
191
192 on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
196 activate_on: crate::data_views::ActivateOn,
198
199 view_focused: Signal<bool>,
204 focus_visible: Signal<bool>,
207
208 layout_refresh: Signal<u64>,
215 paint_refresh: Signal<u64>,
220
221 pane_version: Signal<u64>,
226 pane_built_start: Rc<Cell<usize>>,
228 pane_built_end: Rc<Cell<usize>>,
229
230 body_pane_id: Option<WidgetId>,
232 scrollbar_id: Option<WidgetId>,
233 viewport_height: Rc<Cell<f32>>,
238 viewport_bounds: Rc<Cell<Rect>>,
246
247 model_id: ViewId,
250
251 export: crate::data_views::RowExport<T>,
256
257 enabled: Prop<bool>,
263}
264
265impl<T: 'static> ListView<T> {
266 pub fn new(
271 model: ListModel<T>,
272 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
273 ) -> Self {
274 Self::create(ListSource::from_model(model), delegate)
275 }
276
277 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
282 source: S,
283 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
284 ) -> Self {
285 Self::create(ListSource::from_data_source(source), delegate)
286 }
287
288 pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
296 source: S,
297 keyed: KeyedSelectionModel<S::Key>,
298 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
299 ) -> Self
300 where
301 S::Key: ItemKey,
302 {
303 let s = Rc::new(source);
304 let key_at = {
305 let s = s.clone();
306 Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
307 };
308 let len = {
309 let s = s.clone();
310 Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
311 };
312 let contains = {
315 let s = s.clone();
316 Rc::new(move |k: &S::Key| (0..s.len()).any(|i| s.key_at(i).as_ref() == Some(k)))
317 as Rc<dyn Fn(&S::Key) -> bool>
318 };
319 let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
320 let mut view = Self::create(ListSource::from_data_source_rc(s), delegate);
321 view.row_selection = Some(row_selection);
322 view
323 }
324
325 pub(crate) fn from_list_source(
330 source: ListSource<T>,
331 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
332 ) -> Self {
333 Self::create(source, delegate)
334 }
335
336 fn create(
337 source: ListSource<T>,
338 delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
339 ) -> Self {
340 let model_id = ViewId::next(ViewKind::List);
341 Self {
342 model_id,
343 export: crate::data_views::RowExport::default(),
344 source,
345 delegate: Rc::new(delegate),
346 row_tooltips: Default::default(),
347 item_height: DEFAULT_ITEM_HEIGHT,
348 spacing: 0.0,
349 height_source: HeightSource::Uniform,
350 metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
351 row_selection: None,
352 focused_index: Rc::new(Cell::new(None)),
353 row_map: Rc::new(RefCell::new(Vec::new())),
354 type_ahead_label: None,
355 type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
356 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
357 reorderable: false,
358 show_scrollbar: true,
359 drop_feedback: Signal::new(None),
360 view_focused: Signal::new(false),
362 focus_visible: Signal::new(false),
363 placed_content_width: Rc::new(Cell::new(0.0)),
364 on_activate: None,
365 activate_on: crate::data_views::ActivateOn::default(),
366 overscroll_behavior: OverscrollBehavior::default(),
367 smooth_scrolling: true,
368 smooth_scroll_duration: Duration::from_millis(150),
369 scroll_bar_style: ScrollBarMode::Permanent,
370 scroll_y: Signal::new_animated(0.0),
371 max_scroll_y: Signal::new(0.0),
372 viewport_ratio_y: Signal::new(1.0),
373 layout_refresh: Signal::new(0_u64),
374 paint_refresh: Signal::new(0_u64),
375 pane_version: Signal::new(0_u64),
376 pane_built_start: Rc::new(Cell::new(0)),
377 pane_built_end: Rc::new(Cell::new(0)),
378 body_pane_id: None,
379 scrollbar_id: None,
380 viewport_height: Rc::new(Cell::new(600.0)),
381 viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
382 enabled: Prop::Static(true),
383 }
384 }
385
386 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
389 self.enabled = enabled.into();
390 self
391 }
392
393 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
397 self.overscroll_behavior = behavior;
398 self
399 }
400
401 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
403 self.smooth_scrolling = enabled;
404 self
405 }
406
407 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
409 self.smooth_scroll_duration = duration;
410 self
411 }
412
413 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
417 self.scroll_bar_style = style;
418 self
419 }
420
421 fn remake_metrics(&self) {
424 *self.metrics.borrow_mut() = self
425 .height_source
426 .make_metrics(self.item_height, self.spacing);
427 }
428
429 pub fn item_height(mut self, height: f32) -> Self {
434 self.item_height = height;
435 self.height_source = HeightSource::Uniform;
436 self.remake_metrics();
437 self
438 }
439
440 pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
445 self.height_source = HeightSource::Exact(Rc::new(f));
446 self.remake_metrics();
447 self
448 }
449
450 pub fn auto_item_height(mut self, estimated: f32) -> Self {
457 self.height_source = HeightSource::Auto { estimated };
458 self.remake_metrics();
459 self
460 }
461
462 pub fn spacing(mut self, spacing: f32) -> Self {
464 self.spacing = spacing;
465 self.remake_metrics();
466 self
467 }
468
469 pub fn selection(mut self, sel: SelectionModel) -> Self {
473 self.row_selection = Some(RowSelection::from_index(sel));
474 self
475 }
476
477 fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
503 let metrics = self.metrics.clone();
504 let scroll_y = self.scroll_y.clone();
505 let viewport_height = self.viewport_height.clone();
506 let max_scroll_y = self.max_scroll_y.clone();
507 let focused_index = self.focused_index.clone();
508 let selection = self.row_selection.clone();
509
510 let reveal: Rc<dyn Fn()> = Rc::new(move || {
511 let Some(index) = focused_index.get().or_else(|| {
512 selection
513 .as_ref()
514 .and_then(|s| s.selected_indices().first().copied())
515 }) else {
516 return;
517 };
518 let current = scroll_y.get();
519 let target = metrics.borrow_mut().scroll_for_ensure_visible(
520 index,
521 current,
522 viewport_height.get(),
523 max_scroll_y.get(),
524 );
525 if (target - current).abs() > f32::EPSILON {
526 scroll_y.set(target);
527 }
528 });
529
530 let on_focus = reveal.clone();
531 ctx.effect(&self.view_focused, move |focused| {
532 if *focused {
533 on_focus();
534 }
535 });
536
537 if let Some(sel) = self.row_selection.as_ref() {
538 let on_select = reveal;
539 let focused = self.view_focused.clone();
540 let handle = sel.observe_for_rebuild(move || {
541 if focused.get() {
545 on_select();
546 }
547 });
548 ctx.own_handle(handle);
549 }
550 }
551
552 pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>> {
576 self.row_map.clone()
577 }
578
579 fn current_row_widget(&self) -> Option<WidgetId> {
586 let index = self.focused_index.get().or_else(|| {
587 self.row_selection
588 .as_ref()
589 .and_then(|s| s.selected_indices().first().copied())
590 })?;
591 let map = self.row_map.borrow();
592 map.iter()
593 .find(|(i, _)| *i == index)
594 .map(|(_, widget)| *widget)
595 }
596
597 pub fn reorderable(mut self, enabled: bool) -> Self {
606 self.reorderable = enabled;
607 self
608 }
609
610 pub fn exportable(mut self, mode: DragTransferMode) -> Self
638 where
639 T: Clone,
640 {
641 self.export.set_exportable(mode);
642 self
643 }
644
645 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
653 where
654 T: Clone,
655 {
656 self.export.set_export_external(f);
657 self
658 }
659
660 pub fn on_rows_transferred_out(
666 mut self,
667 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
668 ) -> Self {
669 self.export.set_on_rows_transferred_out(f);
670 self
671 }
672
673 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
680 self.export.accept_foreign_rows = accept;
681 self
682 }
683
684 pub fn on_rows_received(
688 mut self,
689 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
690 ) -> Self {
691 self.export.set_on_rows_received(f);
692 self
693 }
694
695 pub fn on_activate(
704 mut self,
705 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
706 ) -> Self {
707 self.on_activate = Some(Rc::new(f));
708 self
709 }
710
711 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
715 self.activate_on = mode;
716 self
717 }
718
719 pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
734 self.row_tooltips.set_composite_sticky(on);
735 self
736 }
737
738 pub fn row_tooltip(
749 mut self,
750 f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
751 ) -> Self {
752 self.row_tooltips.set_plain(f);
753 self
754 }
755
756 pub fn row_rich_tooltip(
760 mut self,
761 f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
762 ) -> Self {
763 self.row_tooltips.set_rich(f);
764 self
765 }
766
767 pub fn row_composite_tooltip(
774 mut self,
775 f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
776 ) -> Self {
777 self.row_tooltips.set_composite(f);
778 self
779 }
780
781 pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
782 self.type_ahead_label = Some(Rc::new(label));
783 self
784 }
785
786 pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
789 self.type_ahead_timeout = timeout;
790 self
791 }
792
793 pub fn show_scrollbar(mut self, show: bool) -> Self {
802 self.show_scrollbar = show;
803 self
804 }
805
806 fn total_content_height(&self) -> f32 {
808 self.metrics.borrow_mut().total_height(self.source.len())
809 }
810
811 fn visible_range(&self) -> (usize, usize) {
813 self.metrics.borrow_mut().visible_range(
814 self.scroll_y.get(),
815 self.viewport_height.get(),
816 self.source.len(),
817 BUFFER_ITEMS,
818 )
819 }
820
821 fn child_ids(&self) -> Vec<WidgetId> {
826 [self.body_pane_id, self.scrollbar_id]
827 .into_iter()
828 .flatten()
829 .collect()
830 }
831
832 fn clamp_scroll(&self) {
834 let max = self.max_scroll_y.get();
835 let current = self.scroll_y.get();
836 let clamped = current.clamp(0.0, max);
837 if (clamped - current).abs() > 0.001 {
838 self.scroll_y.set(clamped);
839 }
840 }
841
842 #[cfg(test)]
845 pub(crate) fn drop_feedback_signal(&self) -> &Signal<Option<(f32, f32)>> {
846 &self.drop_feedback
847 }
848
849 pub fn scroll_y_signal(&self) -> &Signal<f32> {
856 &self.scroll_y
857 }
858
859 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
863 &self.max_scroll_y
864 }
865
866 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
869 &self.viewport_ratio_y
870 }
871
872 pub fn scroll_to_index(&self, index: usize) {
877 let target = self.metrics.borrow_mut().row_top(index);
878 let max = self.max_scroll_y.get();
879 self.scroll_y.set(target.clamp(0.0, max));
880 }
881
882 pub fn ensure_index_visible(&self, index: usize) {
885 let scroll = self.scroll_y.get();
886 let new_scroll = self.metrics.borrow_mut().scroll_for_ensure_visible(
887 index,
888 scroll,
889 self.viewport_height.get(),
890 self.max_scroll_y.get(),
891 );
892 if (new_scroll - scroll).abs() > f32::EPSILON {
893 self.scroll_y.set(new_scroll);
894 }
895 }
896}
897
898impl<T: 'static> std::fmt::Debug for ListView<T> {
899 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900 f.debug_struct("ListView")
901 .field("item_count", &self.source.len())
902 .field("item_height", &self.item_height)
903 .field("scroll_bar_style", &self.scroll_bar_style)
904 .field("scroll_y", &self.scroll_y.get())
905 .finish()
906 }
907}
908
909impl<T: 'static> Widget for ListView<T> {
910 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
911 let self_id = ctx.self_id();
918 ctx.enabled_when(self_id, self.enabled.clone());
919
920 self.layout_refresh.bind_to(
924 ctx.self_id(),
925 ctx.binding_registry(),
926 BindingLevel::Relayout,
927 );
928 self.paint_refresh.bind_to(
932 ctx.self_id(),
933 ctx.binding_registry(),
934 BindingLevel::RepaintOnly,
935 );
936
937 self.scroll_y.bind_to(
941 ctx.self_id(),
942 ctx.binding_registry(),
943 BindingLevel::Relayout,
944 );
945
946 ctx.register_animated_signal(&self.scroll_y);
952
953 self.drop_feedback.bind_to(
957 ctx.self_id(),
958 ctx.binding_registry(),
959 BindingLevel::RepaintOnly,
960 );
961
962 self.view_focused = ctx.begin_view_focus();
972 ctx.end_view_focus();
973 self.focus_visible = ctx.focus_visible();
974 self.reveal_current_row_on_focus(ctx);
975 self.view_focused.bind_to(
976 ctx.self_id(),
977 ctx.binding_registry(),
978 BindingLevel::RepaintOnly,
979 );
980 self.focus_visible.bind_to(
981 ctx.self_id(),
982 ctx.binding_registry(),
983 BindingLevel::RepaintOnly,
984 );
985
986 let pane_version_for_data = self.pane_version.clone();
992 let layout_refresh_for_data = self.layout_refresh.clone();
993 let data_ver = Rc::new(Cell::new(0_u64));
994 let data_handle = (self.source.observe_fn)(Box::new({
995 let dv = data_ver.clone();
996 let metrics = self.metrics.clone();
997 let len_fn = self.source.len_fn.clone();
998 let first_changed = self.source.first_changed_fn.clone();
999 let row_sel = self.row_selection.clone();
1000 let focused = self.focused_index.clone();
1001 move |change| {
1002 let divergence = match change {
1006 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
1007 Some(range.start)
1008 }
1009 DataChange::ItemUpdated { index } => Some(*index),
1010 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
1011 DataChange::WindowLoaded { range } => Some(range.start),
1013 DataChange::Reset => (first_changed)(),
1016 };
1017 metrics
1018 .borrow_mut()
1019 .apply_divergence(divergence, (len_fn)());
1020 if let Some(ref rs) = row_sel {
1023 rs.on_data_change(change);
1024 }
1025 if let Some(current) = focused.get() {
1030 focused.set(teksilo_data::data_change::adjust_single_index_for_change(
1031 current, change,
1032 ));
1033 }
1034 let next = dv.get() + 1;
1035 dv.set(next);
1036 pane_version_for_data.set(next);
1037 layout_refresh_for_data.set(next);
1038 }
1039 }));
1040 ctx.own_handle(data_handle);
1041
1042 if let Some(ref rs) = self.row_selection {
1047 let paint_refresh_for_sel = self.paint_refresh.clone();
1048 let sel_ver = Rc::new(Cell::new(0_u64));
1049 let handle = rs.observe_for_rebuild(move || {
1050 let next = sel_ver.get() + 1;
1051 sel_ver.set(next);
1052 paint_refresh_for_sel.set(next);
1053 });
1054 ctx.own_handle(handle);
1055 }
1056
1057 let scroll_y = self.scroll_y.clone();
1064 let max_scroll = self.max_scroll_y.clone();
1065 let line_height = self.item_height;
1066 let overscroll_behavior = self.overscroll_behavior;
1067 let smooth_scrolling = self.smooth_scrolling;
1068 let smooth_scroll_duration = self.smooth_scroll_duration;
1069 let mut handlers = HandlerSet::new()
1070 .on_scroll(move |event, _ctx| match event {
1071 teksilo_core::event::WidgetEvent::Scroll { delta, .. } => {
1072 let dy = match delta {
1073 teksilo_core::event::ScrollDelta::Lines { y, .. } => y * line_height,
1074 teksilo_core::event::ScrollDelta::Pixels { y, .. } => *y,
1075 };
1076 let current = scroll_y.get();
1077 let max = max_scroll.get();
1078 let base = scroll_y.animation_target().unwrap_or(current);
1081 let (new_y, moved) = crate::common::scroll::scroll_clamp_axis(base, dy, max);
1082 if moved {
1083 if smooth_scrolling {
1084 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
1085 } else {
1086 scroll_y.set(new_y);
1087 }
1088 }
1089 crate::common::scroll::scroll_response(
1092 moved,
1093 overscroll_behavior == OverscrollBehavior::Contain,
1094 )
1095 }
1096 _ => teksilo_core::event::EventResponse::Ignored,
1097 })
1098 .clips_children(true)
1099 .focusable(true);
1100
1101 {
1103 let len_for_key = self.source.len_fn.clone();
1104 let accept_drop_for_key = self.source.dnd.accept_drop_fn.clone();
1105 let stash_for_key = self.source.dnd.stash_drag_keys_fn.clone();
1106 let view_id_for_key = self.model_id;
1107 let sel_for_key = self.row_selection.clone();
1108 let activate_key = self.on_activate.clone();
1109 let fi = self.focused_index.clone();
1110 let reorderable = self.reorderable;
1111 let scroll_for_nav = self.scroll_y.clone();
1112 let metrics_for_nav = self.metrics.clone();
1113 let max_for_nav = self.max_scroll_y.clone();
1114 let vh_for_nav = self.viewport_height.clone();
1115 let vb_for_nav = self.viewport_bounds.clone();
1116 let ta_state = self.type_ahead.clone();
1119 let row_map_for_key = self.row_map.clone();
1123 let ta_label = self.type_ahead_label.clone();
1124 let ta_timeout = self.type_ahead_timeout;
1125 let with_item_str = self.source.with_item_str_fn.clone();
1126
1127 handlers = handlers.on_key(move |event, ctx| {
1128 if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
1129 use teksilo_core::event::Key;
1130 let count = (len_for_key)();
1131 if count == 0 {
1132 return teksilo_core::event::EventResponse::Ignored;
1133 }
1134
1135 if modifiers.command() && matches!(key, Key::A) {
1142 if let Some(ref sel) = sel_for_key
1143 && sel.mode() == teksilo_data::SelectionMode::Multi
1144 {
1145 if modifiers.shift() {
1146 sel.clear();
1147 } else {
1148 sel.select_all(count);
1149 }
1150 return teksilo_core::event::EventResponse::Handled;
1151 }
1152 return teksilo_core::event::EventResponse::Ignored;
1153 }
1154
1155 if let Some(alias) = list_nav::mac_alias(*key, *modifiers, ctx.is_rtl()) {
1163 if alias == list_nav::MacAlias::Activate {
1164 let row = fi
1165 .get()
1166 .or_else(|| {
1167 sel_for_key
1168 .as_ref()
1169 .and_then(|s| s.selected_indices().first().copied())
1170 })
1171 .unwrap_or(0)
1172 .min(count - 1);
1173 if let Some(ref sel) = sel_for_key {
1174 sel.select(row);
1175 }
1176 if let Some(ref cb) = activate_key {
1177 cb(row, ctx);
1178 }
1179 return teksilo_core::event::EventResponse::Handled;
1180 }
1181 return teksilo_core::event::EventResponse::Ignored;
1182 }
1183
1184 if ta_label.is_some()
1188 && !modifiers.ctrl()
1189 && !modifiers.alt()
1190 && !modifiers.super_key()
1191 && let Some(c) = key.to_char()
1192 {
1193 let current = fi.get().unwrap_or(0).min(count - 1);
1194 let label = ta_label.as_ref().unwrap();
1195 if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
1196 (with_item_str)(i, &|item| label(item))
1197 }) {
1198 fi.set(Some(idx));
1199 if let Some(ref sel) = sel_for_key {
1200 sel.select(idx);
1201 }
1202 let scroll = scroll_for_nav.get();
1203 let new_scroll =
1204 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1205 idx,
1206 scroll,
1207 vh_for_nav.get(),
1208 max_for_nav.get(),
1209 );
1210 if (new_scroll - scroll).abs() > f32::EPSILON {
1211 scroll_for_nav.set(new_scroll);
1212 }
1213 crate::common::row_metrics::chase_row_into_outer_view(
1214 ctx,
1215 &metrics_for_nav,
1216 vb_for_nav.get(),
1217 idx,
1218 new_scroll,
1219 );
1220 return teksilo_core::event::EventResponse::Handled;
1221 }
1222 return teksilo_core::event::EventResponse::Ignored;
1223 }
1224
1225 if modifiers.alt() && reorderable {
1230 let selected_idx = sel_for_key
1231 .as_ref()
1232 .and_then(|s| s.selected_indices().first().copied());
1233 if let Some(idx) = selected_idx {
1234 let mv = match key {
1235 teksilo_core::event::Key::ArrowUp if idx > 0 => {
1236 Some((idx - 1, DropPosition::Before, idx - 1))
1237 }
1238 teksilo_core::event::Key::ArrowDown if idx + 1 < count => {
1239 Some((idx + 1, DropPosition::After, idx + 1))
1240 }
1241 _ => None,
1242 };
1243 if let Some((target, position, dest)) = mv {
1244 (stash_for_key)(&[idx]);
1249 let payload = DragPayload::typed(RowDragData::<T> {
1250 source: view_id_for_key,
1251 rows: vec![idx],
1252 items: None,
1253 });
1254 if (accept_drop_for_key)(
1255 &payload,
1256 target,
1257 position,
1258 view_id_for_key,
1259 ) {
1260 if let Some(ref sel) = sel_for_key {
1261 sel.select(dest);
1262 }
1263 fi.set(Some(dest));
1264 let scroll = scroll_for_nav.get();
1267 let new_scroll =
1268 metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1269 dest,
1270 scroll,
1271 vh_for_nav.get(),
1272 max_for_nav.get(),
1273 );
1274 if (new_scroll - scroll).abs() > f32::EPSILON {
1275 scroll_for_nav.set(new_scroll);
1276 }
1277 crate::common::row_metrics::chase_row_into_outer_view(
1278 ctx,
1279 &metrics_for_nav,
1280 vb_for_nav.get(),
1281 dest,
1282 new_scroll,
1283 );
1284 }
1285 return teksilo_core::event::EventResponse::Handled;
1286 }
1287 }
1288 }
1289
1290 let cursor = fi
1306 .get()
1307 .or_else(|| {
1308 sel_for_key
1309 .as_ref()
1310 .and_then(|s| s.selected_indices().first().copied())
1311 })
1312 .map(|i| i.min(count - 1));
1313 let current = cursor.unwrap_or(0);
1316 let nav = list_nav::nav_chord(*key, *modifiers, list_nav::ViewKind::Linear);
1323 let new_idx = if let Some(chord) = nav {
1324 Some(match chord.movement {
1325 list_nav::NavMove::First | list_nav::NavMove::RowFirst => 0,
1326 list_nav::NavMove::Last | list_nav::NavMove::RowLast => count - 1,
1327 list_nav::NavMove::Page { down } => {
1332 let vh = vh_for_nav.get();
1333 let r = {
1334 let mut m = metrics_for_nav.borrow_mut();
1335 m.resize(count);
1336 let target = if down {
1337 m.row_top(current) + vh
1338 } else {
1339 (m.row_top(current) - vh).max(0.0)
1340 };
1341 m.row_at(target)
1342 };
1343 if r == current && down {
1346 (current + 1).min(count - 1)
1347 } else if r == current {
1348 current.saturating_sub(1)
1349 } else {
1350 r.min(count - 1)
1351 }
1352 }
1353 })
1354 } else {
1355 match key {
1356 Key::ArrowDown => Some(match cursor {
1357 None => 0,
1358 Some(c) => (c + 1).min(count - 1),
1359 }),
1360 Key::ArrowUp => Some(match cursor {
1361 None => count - 1,
1362 Some(c) => c.saturating_sub(1),
1363 }),
1364 Key::Enter => {
1365 if let Some(ref sel) = sel_for_key {
1367 sel.select(current);
1368 }
1369 if let Some(ref cb) = activate_key {
1370 cb(current, ctx);
1371 }
1372 return teksilo_core::event::EventResponse::Handled;
1373 }
1374 Key::Space if modifiers.ctrl() => {
1375 if let Some(ref sel) = sel_for_key {
1392 sel.toggle(current);
1393 }
1394 fi.set(Some(current));
1395 return teksilo_core::event::EventResponse::Handled;
1396 }
1397 Key::Space => {
1398 if let Some(row_id) = row_map_for_key
1410 .borrow()
1411 .iter()
1412 .find(|(i, _)| *i == current)
1413 .map(|(_, id)| *id)
1414 {
1415 let sel_fallback = sel_for_key.clone();
1416 ctx.row_space_activate(
1417 row_id,
1418 std::rc::Rc::new(move || {
1419 if let Some(ref sel) = sel_fallback {
1420 if sel.mode() == teksilo_data::SelectionMode::Multi
1421 {
1422 sel.toggle(current);
1423 } else {
1424 sel.select(current);
1425 }
1426 }
1427 }),
1428 );
1429 fi.set(Some(current));
1430 return teksilo_core::event::EventResponse::Handled;
1431 }
1432 if let Some(ref sel) = sel_for_key {
1437 if sel.mode() == teksilo_data::SelectionMode::Multi {
1438 sel.toggle(current);
1439 } else {
1440 sel.select(current);
1441 }
1442 }
1443 fi.set(Some(current));
1444 return teksilo_core::event::EventResponse::Handled;
1445 }
1446 _ => None,
1447 }
1448 };
1449
1450 if let Some(idx) = new_idx {
1451 fi.set(Some(idx));
1452 let op = match nav {
1464 Some(chord) => chord.selection,
1465 None if modifiers.ctrl()
1466 && !modifiers.shift()
1467 && matches!(key, Key::ArrowUp | Key::ArrowDown) =>
1468 {
1469 list_nav::SelectionOp::Suppress
1470 }
1471 None if modifiers.shift() => list_nav::SelectionOp::Extend,
1472 None => list_nav::SelectionOp::Replace,
1473 };
1474 if let Some(ref sel) = sel_for_key {
1475 match op {
1476 list_nav::SelectionOp::Replace => sel.select(idx),
1477 list_nav::SelectionOp::Suppress => {}
1478 list_nav::SelectionOp::Extend => sel.extend_to(idx),
1479 list_nav::SelectionOp::ExtendAdditive => {
1480 sel.extend_to_additive(idx)
1481 }
1482 }
1483 }
1484 let scroll = scroll_for_nav.get();
1487 let new_scroll = metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1488 idx,
1489 scroll,
1490 vh_for_nav.get(),
1491 max_for_nav.get(),
1492 );
1493 if (new_scroll - scroll).abs() > f32::EPSILON {
1494 scroll_for_nav.set(new_scroll);
1495 }
1496 crate::common::row_metrics::chase_row_into_outer_view(
1497 ctx,
1498 &metrics_for_nav,
1499 vb_for_nav.get(),
1500 idx,
1501 new_scroll,
1502 );
1503 return teksilo_core::event::EventResponse::Handled;
1504 }
1505 }
1506 teksilo_core::event::EventResponse::Ignored
1507 });
1508 }
1509
1510 if self.export.is_drop_target(self.reorderable) {
1514 let metrics_for_hover = self.metrics.clone();
1515 let scroll_for_hover = self.scroll_y.clone();
1516 let len_for_hover = self.source.len_fn.clone();
1517 let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
1518 let my_view_id = self.model_id;
1519
1520 let feedback_for_hover = self.drop_feedback.clone();
1521 let width_for_hover = self.placed_content_width.clone();
1522 let export_for_hover = self.export.clone();
1523 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1524 let scroll = scroll_for_hover.get().max(0.0);
1525 let content_y = position.y + scroll;
1526 let len = (len_for_hover)();
1527 let (insertion_y, ins) = {
1528 let mut m = metrics_for_hover.borrow_mut();
1529 m.resize(len);
1530 let ins = m.insertion_index(content_y);
1531 (m.row_top(ins) - scroll, ins)
1532 };
1533 let line_width = width_for_hover.get();
1534 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1539 !matches!(
1540 (can_accept_for_hover)(payload, target, pos, my_view_id),
1541 DropResponse::Reject
1542 ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
1543 });
1544 if allowed {
1545 feedback_for_hover.set(Some((insertion_y, line_width)));
1546 DropFeedback::InsertionLine {
1547 y: insertion_y,
1548 width: line_width,
1549 }
1550 } else {
1551 feedback_for_hover.set(None);
1552 DropFeedback::NoFeedback
1553 }
1554 });
1555
1556 let len_for_drop = self.source.len_fn.clone();
1557 let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
1558 let drop_view_id = self.model_id;
1559 let scroll_for_drop = self.scroll_y.clone();
1560 let metrics_for_drop = self.metrics.clone();
1561 let export_for_drop = self.export.clone();
1562 let reorderable_for_drop = self.reorderable;
1563
1564 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1565 let scroll = scroll_for_drop.get().max(0.0);
1566 let content_y = position.y + scroll;
1567 let len = (len_for_drop)();
1568 let ins = {
1569 let mut m = metrics_for_drop.borrow_mut();
1570 m.resize(len);
1571 m.insertion_index(content_y)
1572 };
1573 let is_same_view = payload
1574 .get_typed::<RowDragData<T>>()
1575 .is_some_and(|rd| rd.source == drop_view_id);
1576 if (reorderable_for_drop || !is_same_view)
1580 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
1581 && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
1582 {
1583 if is_same_view {
1584 export_for_drop.note_self_reorder();
1585 }
1586 return true;
1587 }
1588 export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
1590 });
1591
1592 let feedback_for_leave = self.drop_feedback.clone();
1596 handlers = handlers.on_drag_leave(move |_ctx| {
1597 feedback_for_leave.set(None);
1598 });
1599
1600 let scroll_for_tick = self.scroll_y.clone();
1606 let max_scroll_for_tick = self.max_scroll_y.clone();
1607 let viewport_for_tick = self.viewport_height.clone();
1608 handlers = handlers.on_drag_tick(move |pos, _ctx| {
1609 const EDGE: f32 = 32.0;
1610 const MAX_VELOCITY: f32 = 12.0;
1611 let h = viewport_for_tick.get();
1612 let above = (EDGE - pos.y).max(0.0);
1613 let below = (pos.y - (h - EDGE)).max(0.0);
1614 let delta = if above > 0.0 {
1615 -(above / EDGE) * MAX_VELOCITY
1616 } else if below > 0.0 {
1617 (below / EDGE) * MAX_VELOCITY
1618 } else {
1619 0.0
1620 };
1621 if delta.abs() > 0.01 {
1622 let max = max_scroll_for_tick.get();
1623 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
1624 scroll_for_tick.set(new_y);
1625 }
1626 });
1627 }
1628
1629 handlers = self.export.install_completion(handlers);
1632
1633 ctx.apply_self_handlers(handlers);
1634
1635 let pane = body_pane::ListBodyPane::<T> {
1643 source: self.source.clone(),
1644 delegate: self.delegate.clone(),
1645 row_tooltips: self.row_tooltips.clone(),
1646 metrics: self.metrics.clone(),
1647 row_selection: self.row_selection.clone(),
1648 focused_index: self.focused_index.clone(),
1649 row_map: self.row_map.clone(),
1650 reorderable: self.reorderable,
1651 export: self.export.clone(),
1652 on_activate: self.on_activate.clone(),
1653 activate_on: self.activate_on,
1654 model_id: self.model_id,
1655 root_id: self_id,
1656 scroll_y: self.scroll_y.clone(),
1657 viewport_height: self.viewport_height.clone(),
1658 placed_content_width: self.placed_content_width.clone(),
1659 version: self.pane_version.clone(),
1660 total_refresh: self.layout_refresh.clone(),
1661 prev_built_start: self.pane_built_start.clone(),
1662 prev_built_end: self.pane_built_end.clone(),
1663 item_entries: Vec::new(),
1664 };
1665 self.body_pane_id = Some(ctx.add(pane));
1666
1667 if self.show_scrollbar {
1672 let scrollbar = ScrollBar::new(
1673 ScrollBarOrientation::Vertical,
1674 self.scroll_y.clone(),
1675 self.max_scroll_y.clone(),
1676 self.viewport_ratio_y.clone(),
1677 )
1678 .visual(match self.scroll_bar_style {
1679 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1680 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1681 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1682 });
1683 let sb_id = ctx.add(scrollbar);
1684 self.scrollbar_id = Some(sb_id);
1685 } else {
1686 self.scrollbar_id = None;
1687 }
1688
1689 self.child_ids()
1690 }
1691
1692 fn layout_response(
1693 &self,
1694 proposal: SizeProposal,
1695 _ctx: &LayoutContext,
1696 ) -> teksilo_core::widget::LayoutResponse {
1697 crate::common::viewport::viewport_size(
1701 proposal,
1702 &self.viewport_height,
1703 Size::new(300.0, 200.0),
1704 )
1705 .into()
1706 }
1707
1708 fn place_children(
1709 &self,
1710 bounds: Rect,
1711 _proposal: SizeProposal,
1712 children: &mut [WidgetPlacement],
1713 _ctx: &LayoutContext,
1714 ) {
1715 self.viewport_bounds.set(bounds);
1719 crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
1723
1724 if children.is_empty() {
1725 return;
1726 }
1727
1728 let viewport_height = bounds.height;
1729
1730 let provisional_total = self.total_content_height();
1734 let needs_internal_scrollbar =
1735 self.show_scrollbar && provisional_total > viewport_height + 0.5;
1736 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1737 let content_width = if needs_internal_scrollbar && reserves_bar {
1738 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1739 } else {
1740 bounds.width
1741 };
1742 self.placed_content_width.set(content_width);
1743
1744 let total_height = self.total_content_height();
1749 let max_y = (total_height - viewport_height).max(0.0);
1750 self.max_scroll_y.set(max_y);
1751 let ratio = if total_height > 0.0 {
1752 (viewport_height / total_height).clamp(0.0, 1.0)
1753 } else {
1754 1.0
1755 };
1756 self.viewport_ratio_y.set(ratio);
1757 self.clamp_scroll();
1758
1759 let mut next = 0;
1763 if self.body_pane_id.is_some() {
1764 if let Some(child) = children.get_mut(next) {
1765 child.origin = bounds.origin();
1766 child.size = Size::new(content_width, bounds.height);
1767 }
1768 next += 1;
1769 }
1770 if self.scrollbar_id.is_some()
1771 && let Some(sb_child) = children.get_mut(next)
1772 {
1773 if needs_internal_scrollbar {
1774 sb_child.origin =
1775 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1776 sb_child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1777 } else {
1778 sb_child.origin = bounds.origin();
1779 sb_child.size = Size::ZERO;
1780 }
1781 }
1782 }
1783
1784 fn paint(
1785 &self,
1786 bounds: Rect,
1787 canvas: &mut teksilo_canvas::Canvas,
1788 ctx: &teksilo_core::widget::PaintContext,
1789 ) {
1790 if let Some((y, width)) = self.drop_feedback.get() {
1794 let recipe = ctx
1795 .theme
1796 .style_slots
1797 .list_container
1798 .as_ref()
1799 .map(|s| s.insertion())
1800 .unwrap_or_default();
1801 let color = recipe.role.resolve(&ctx.theme.colors);
1802 let line_y = bounds.y + y;
1803 let line_x = bounds.x;
1804 let half = recipe.thickness * 0.5;
1805 canvas.set_clip(bounds);
1809 canvas.fill_rect(
1810 Rect::new(line_x, line_y - half, width, recipe.thickness),
1811 color,
1812 );
1813 canvas.clear_clip();
1814 }
1815
1816 let has_selection = self
1819 .row_selection
1820 .as_ref()
1821 .is_some_and(|s| s.has_selection());
1822 if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1823 let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1824 let inset = 1.0_f32;
1825 let rect = Rect::new(
1826 bounds.x + inset,
1827 bounds.y + inset,
1828 (bounds.width - inset * 2.0).max(0.0),
1829 (bounds.height - inset * 2.0).max(0.0),
1830 );
1831 canvas.stroke_rect(rect, color, 1.5);
1832 }
1833 }
1834
1835 fn context_menu_key_target(&self) -> Option<WidgetId> {
1850 self.current_row_widget()
1851 }
1852
1853 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1854 builder.set_role(teksilo_core::accesskit::Role::ListBox);
1855 if self
1868 .row_selection
1869 .as_ref()
1870 .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1871 {
1872 builder.set_multiselectable(true);
1873 }
1874
1875 builder.set_size_of_set(self.source.len());
1881
1882 if self.view_focused.get()
1912 && let Some(row) = self.current_row_widget()
1913 {
1914 builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(row));
1915 }
1916 }
1917
1918 fn as_any(&self) -> Option<&dyn std::any::Any> {
1919 Some(self)
1920 }
1921
1922 fn children(&self) -> Vec<WidgetId> {
1923 self.child_ids()
1924 }
1925
1926 fn clips_children(&self) -> bool {
1927 true
1928 }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933 use super::*;
1934 use teksilo_core::widget_tree::WidgetTree;
1935
1936 fn row_ids(tree: &WidgetTree, lv: WidgetId) -> Vec<WidgetId> {
1941 let kids = tree.children(lv);
1942 match kids.first() {
1943 Some(&pane) => tree.children(pane),
1944 None => Vec::new(),
1945 }
1946 }
1947
1948 fn scrollbar_of(tree: &WidgetTree, lv: WidgetId) -> WidgetId {
1950 *tree.children(lv).last().expect("ListView has children")
1951 }
1952
1953 #[test]
1954 fn smooth_scroll_survives_a_body_pane_rebuild() {
1955 let (mut tree, lv_id, model) = make_list_view(500, 20.0);
1956 let scroll = {
1957 tree.layout(SizeProposal::exact(400.0, 200.0));
1958 let any = tree.widget_as_any(lv_id).unwrap();
1959 any.downcast_ref::<ListView<usize>>()
1960 .unwrap()
1961 .scroll_y_signal()
1962 .clone()
1963 };
1964 crate::common::thumb_drag_test::assert_fling_survives_pane_rebuild(
1965 &mut tree,
1966 400.0,
1967 200.0,
1968 &scroll,
1969 "ListView",
1970 || model.push(9999),
1971 );
1972 }
1973
1974 #[test]
1975 fn rows_materialize_during_scrollbar_thumb_drag() {
1976 let (mut tree, lv_id, _model) = make_list_view(500, 20.0);
1979 crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
1980 &mut tree,
1981 lv_id,
1982 400.0,
1983 200.0,
1984 0.0,
1985 "ListView",
1986 |t| {
1987 row_ids(t, lv_id)
1988 .into_iter()
1989 .filter(|id| {
1990 let b = t.bounds(*id);
1991 b.height > 1.0 && b.y > -b.height && b.y < 200.0
1992 })
1993 .count()
1994 },
1995 );
1996 }
1997
1998 #[derive(Debug)]
1999 pub(super) struct FixedLeaf(pub f32, pub f32);
2000 impl Widget for FixedLeaf {
2001 fn layout_response(
2002 &self,
2003 _proposal: SizeProposal,
2004 _ctx: &LayoutContext,
2005 ) -> teksilo_core::widget::LayoutResponse {
2006 Size::new(self.0, self.1).into()
2007 }
2008 }
2009
2010 #[test]
2019 fn a_row_announces_its_position_out_of_the_whole_model() {
2020 let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
2021 tree.layout(SizeProposal::exact(400.0, 200.0));
2022 let rows = row_ids(&tree, lv_id);
2023 assert!(!rows.is_empty(), "some rows must be realized");
2024
2025 let update = tree.sync_accessibility();
2026 for (i, &row) in rows.iter().enumerate().take(3) {
2027 crate::a11y_set_semantics::assert_announces(
2028 &update,
2029 teksilo_core::accessibility::widget_id_to_node_id(row),
2030 i + 1,
2031 200,
2032 &format!("row {i}"),
2033 );
2034 }
2035 }
2036
2037 #[test]
2040 fn a_scrolled_row_announces_its_model_position_not_its_window_position() {
2041 let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
2042 tree.layout(SizeProposal::exact(400.0, 200.0));
2043 {
2044 let any = tree.widget_as_any(lv_id).unwrap();
2045 any.downcast_ref::<ListView<usize>>()
2046 .unwrap()
2047 .scroll_y_signal()
2048 .set(150.0 * 20.0);
2049 }
2050 tree.layout(SizeProposal::exact(400.0, 200.0));
2051
2052 let rows = row_ids(&tree, lv_id);
2053 assert!(
2054 !rows.is_empty(),
2055 "some rows must be realized after scrolling"
2056 );
2057 let update = tree.sync_accessibility();
2058 let (position, size) = crate::a11y_set_semantics::announced_set_position(
2059 &update,
2060 teksilo_core::accessibility::widget_id_to_node_id(rows[0]),
2061 );
2062 assert_eq!(
2063 size,
2064 Some(200),
2065 "the total is the model's, not the window's"
2066 );
2067 assert!(
2068 position.is_some_and(|p| p > 100),
2069 "the first realized row after scrolling to the 150th must announce a \
2070 model position, not a window position; got {position:?}"
2071 );
2072 }
2073
2074 fn make_list_view(count: usize, item_height: f32) -> (WidgetTree, WidgetId, ListModel<usize>) {
2075 let model = ListModel::from_vec((0..count).collect());
2076 let mut tree = WidgetTree::new();
2077 let lv_id = tree.add(
2078 ListView::new(model.clone(), move |_i, _item, _selected| {
2079 Box::new(FixedLeaf(100.0, item_height))
2080 })
2081 .item_height(item_height),
2082 );
2083 (tree, lv_id, model)
2084 }
2085
2086 #[test]
2099 fn taking_focus_reveals_the_current_row() {
2100 use teksilo_data::{SelectionMode, SelectionModel};
2101
2102 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
2103 let selection = SelectionModel::new(SelectionMode::Single);
2104 selection.select(150);
2105
2106 let mut tree = WidgetTree::new();
2107 let lv_id = tree.add(
2108 ListView::new(model.clone(), move |_i, _item, _selected| {
2109 Box::new(FixedLeaf(100.0, 20.0))
2110 })
2111 .item_height(20.0)
2112 .selection(selection.clone()),
2113 );
2114 tree.layout(SizeProposal::exact(400.0, 200.0));
2115
2116 let realized_selected = |tree: &mut WidgetTree| -> Vec<usize> {
2117 tree.accessibility_tree_snapshot()
2118 .nodes
2119 .iter()
2120 .filter(|(_, node)| node.is_selected() == Some(true))
2121 .filter_map(|(_, node)| node.position_in_set())
2122 .collect()
2123 };
2124
2125 assert!(
2126 realized_selected(&mut tree).is_empty(),
2127 "row 150 starts far outside the realized window, which is the case \
2128 this is about"
2129 );
2130
2131 tree.focus(lv_id);
2132 tree.layout(SizeProposal::exact(400.0, 200.0));
2133
2134 assert_eq!(
2135 realized_selected(&mut tree),
2136 vec![150],
2137 "taking focus has to bring the current row into the realized window, \
2138 or nothing in the tree can be told about it"
2139 );
2140 }
2141
2142 #[test]
2147 fn taking_focus_does_not_move_a_row_already_in_view() {
2148 use teksilo_data::{SelectionMode, SelectionModel};
2149
2150 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
2151 let selection = SelectionModel::new(SelectionMode::Single);
2152 selection.select(2);
2153
2154 let mut tree = WidgetTree::new();
2155 let lv_id = tree.add(
2156 ListView::new(model.clone(), move |_i, _item, _selected| {
2157 Box::new(FixedLeaf(100.0, 20.0))
2158 })
2159 .item_height(20.0)
2160 .selection(selection.clone()),
2161 );
2162 tree.layout(SizeProposal::exact(400.0, 200.0));
2163
2164 let scroll = {
2165 let any = tree.widget_as_any(lv_id).unwrap();
2166 any.downcast_ref::<ListView<usize>>()
2167 .unwrap()
2168 .scroll_y_signal()
2169 .clone()
2170 };
2171 let before = scroll.get();
2172
2173 tree.focus(lv_id);
2174 tree.layout(SizeProposal::exact(400.0, 200.0));
2175
2176 assert_eq!(
2177 scroll.get(),
2178 before,
2179 "row 2 is already visible, so the list must not scroll at all"
2180 );
2181 }
2182
2183 #[test]
2197 fn the_current_row_is_what_the_platform_calls_focused() {
2198 use teksilo_data::{SelectionMode, SelectionModel};
2199
2200 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2201 let selection = SelectionModel::new(SelectionMode::Single);
2202 selection.select(3);
2203
2204 let mut tree = WidgetTree::new();
2205 let lv_id = tree.add(
2206 ListView::new(model.clone(), move |_i, _item, _selected| {
2207 Box::new(FixedLeaf(100.0, 20.0))
2208 })
2209 .item_height(20.0)
2210 .selection(selection.clone()),
2211 );
2212 tree.layout(SizeProposal::exact(400.0, 200.0));
2213 tree.focus(lv_id);
2214 tree.layout(SizeProposal::exact(400.0, 200.0));
2215
2216 let focused_row = |tree: &mut WidgetTree| -> Option<usize> {
2218 let snapshot = tree.accessibility_tree_snapshot();
2219 let consumer = accesskit_consumer::Tree::new(snapshot, true);
2220 let state = consumer.state();
2221 let focus = state.focus_id()?;
2222 let focused = state.node_by_id(focus)?;
2223 let resolved = focused.active_descendant().unwrap_or(focused);
2228 resolved.position_in_set()
2230 };
2231
2232 assert_eq!(
2233 focused_row(&mut tree),
2234 Some(3),
2235 "with the list focused, the platform's focused node must be the \
2236 current row and not the list itself"
2237 );
2238
2239 selection.select(6);
2240 tree.layout(SizeProposal::exact(400.0, 200.0));
2241
2242 assert_eq!(
2243 focused_row(&mut tree),
2244 Some(6),
2245 "and moving the selection must move it, which is the focus change \
2246 NVDA announces; without it an arrow press is silent"
2247 );
2248 }
2249
2250 #[test]
2256 fn an_unselected_list_nominates_no_row() {
2257 let (mut tree, lv_id, _model) = make_list_view(10, 20.0);
2258 tree.layout(SizeProposal::exact(400.0, 200.0));
2259 tree.focus(lv_id);
2260 tree.layout(SizeProposal::exact(400.0, 200.0));
2261
2262 let snapshot = tree.accessibility_tree_snapshot();
2263 let consumer = accesskit_consumer::Tree::new(snapshot, true);
2264 let state = consumer.state();
2265 let focus = state.focus_id().expect("something has focus");
2266 let focused = state.node_by_id(focus).expect("the focused node exists");
2267 let resolved = focused.active_descendant().unwrap_or(focused);
2268 assert_eq!(
2269 resolved.role(),
2270 teksilo_core::accesskit::Role::ListBox,
2271 "with no selection the list itself is the focused node"
2272 );
2273 }
2274
2275 #[test]
2281 fn the_selected_row_reports_itself_after_a_move() {
2282 use teksilo_data::{SelectionMode, SelectionModel};
2283
2284 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2285 let selection = SelectionModel::new(SelectionMode::Single);
2286 selection.select(3);
2287
2288 let mut tree = WidgetTree::new();
2289 let _ = tree.add(
2292 ListView::new(model.clone(), move |_i, _item, _selected| {
2293 Box::new(FixedLeaf(100.0, 20.0))
2294 })
2295 .item_height(20.0)
2296 .selection(selection.clone()),
2297 );
2298 tree.layout(SizeProposal::exact(400.0, 200.0));
2299
2300 let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
2301 let nodes = tree.accessibility_tree_snapshot().nodes;
2302 nodes
2303 .iter()
2304 .filter(|(_, node)| node.is_selected() == Some(true))
2305 .filter_map(|(_, node)| node.position_in_set())
2306 .collect()
2307 };
2308
2309 assert_eq!(selected_rows(&mut tree), vec![3]);
2312
2313 selection.select(4);
2314 tree.layout(SizeProposal::exact(400.0, 200.0));
2315
2316 assert_eq!(
2317 selected_rows(&mut tree),
2318 vec![4],
2319 "the flag has to follow the selection, whether or not the widget was \
2320 rebuilt to carry it"
2321 );
2322 }
2323
2324 #[test]
2325 fn arrow_nav_resumes_from_the_clicked_row() {
2326 use teksilo_canvas::Point;
2330 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2331 use teksilo_data::{SelectionMode, SelectionModel};
2332
2333 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2334 let selection = SelectionModel::new(SelectionMode::Single);
2335 let sel = selection.clone();
2336 let mut tree = WidgetTree::new();
2337 let lv_id = tree.add(
2338 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2339 .item_height(20.0)
2340 .selection(sel),
2341 );
2342 tree.layout(SizeProposal::exact(400.0, 300.0)); tree.focus(lv_id);
2344
2345 tree.dispatch_event(WidgetEvent::PointerDown {
2347 position: Point::new(50.0, 70.0),
2348 button: PointerButton::Primary,
2349 modifiers: Modifiers::NONE,
2350 });
2351 tree.dispatch_event(WidgetEvent::PointerUp {
2352 position: Point::new(50.0, 70.0),
2353 button: PointerButton::Primary,
2354 modifiers: Modifiers::NONE,
2355 });
2356 assert_eq!(
2357 selection.selected_indices(),
2358 vec![3],
2359 "precondition: body click selects row 3"
2360 );
2361
2362 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2364 assert_eq!(
2365 selection.selected_indices(),
2366 vec![4],
2367 "ArrowDown after a click resumes from the clicked row (3 → 4)"
2368 );
2369 }
2370
2371 #[test]
2372 fn focused_index_follows_insert_before_it() {
2373 use teksilo_canvas::Point;
2379 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2380 use teksilo_data::{SelectionMode, SelectionModel};
2381
2382 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2383 let selection = SelectionModel::new(SelectionMode::Single);
2384 let sel = selection.clone();
2385 let mut tree = WidgetTree::new();
2386 let lv_id = tree.add(
2387 ListView::new(model.clone(), |_i, _item, _sel| {
2388 Box::new(FixedLeaf(100.0, 20.0))
2389 })
2390 .item_height(20.0)
2391 .selection(sel),
2392 );
2393 tree.layout(SizeProposal::exact(400.0, 300.0));
2394 tree.focus(lv_id);
2395
2396 tree.dispatch_event(WidgetEvent::PointerDown {
2398 position: Point::new(50.0, 70.0),
2399 button: PointerButton::Primary,
2400 modifiers: Modifiers::NONE,
2401 });
2402 tree.dispatch_event(WidgetEvent::PointerUp {
2403 position: Point::new(50.0, 70.0),
2404 button: PointerButton::Primary,
2405 modifiers: Modifiers::NONE,
2406 });
2407 assert_eq!(selection.selected_indices(), vec![3], "precondition");
2408
2409 model.insert(0, 100);
2411 model.insert(0, 200);
2412 tree.layout(SizeProposal::exact(400.0, 300.0));
2413 assert_eq!(
2416 selection.selected_indices(),
2417 vec![5],
2418 "precondition: selection shifts with the inserted rows"
2419 );
2420
2421 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2425 assert_eq!(
2426 selection.selected_indices(),
2427 vec![6],
2428 "ArrowDown after a leading insert resumes from the shifted row (5 → 6), \
2429 not the stale pre-insert one (3 → 4)"
2430 );
2431 }
2432
2433 #[test]
2434 fn focused_index_dropped_when_its_row_is_removed() {
2435 use teksilo_canvas::Point;
2438 use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2439 use teksilo_data::{SelectionMode, SelectionModel};
2440
2441 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2442 let selection = SelectionModel::new(SelectionMode::Single);
2443 let sel = selection.clone();
2444 let mut tree = WidgetTree::new();
2445 let lv_id = tree.add(
2446 ListView::new(model.clone(), |_i, _item, _sel| {
2447 Box::new(FixedLeaf(100.0, 20.0))
2448 })
2449 .item_height(20.0)
2450 .selection(sel),
2451 );
2452 tree.layout(SizeProposal::exact(400.0, 300.0));
2453 tree.focus(lv_id);
2454
2455 tree.dispatch_event(WidgetEvent::PointerDown {
2457 position: Point::new(50.0, 70.0),
2458 button: PointerButton::Primary,
2459 modifiers: Modifiers::NONE,
2460 });
2461 tree.dispatch_event(WidgetEvent::PointerUp {
2462 position: Point::new(50.0, 70.0),
2463 button: PointerButton::Primary,
2464 modifiers: Modifiers::NONE,
2465 });
2466 assert_eq!(selection.selected_indices(), vec![3], "precondition");
2467
2468 model.remove(3);
2470 tree.layout(SizeProposal::exact(400.0, 300.0));
2471 assert!(
2472 selection.selected_indices().is_empty(),
2473 "precondition: selection drops the removed row"
2474 );
2475
2476 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2482 assert_eq!(
2483 selection.selected_indices(),
2484 vec![0],
2485 "focused_index was cleared, so nav restarts at the top (row 0), \
2486 not from the stale removed row's index (3 → 4)"
2487 );
2488 }
2489
2490 #[test]
2491 fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
2492 use teksilo_core::event::{Key, Modifiers};
2497 use teksilo_data::{SelectionMode, SelectionModel};
2498
2499 for (key, want, what) in [
2500 (
2501 Key::ArrowDown,
2502 0usize,
2503 "first ArrowDown selects the first row",
2504 ),
2505 (Key::ArrowUp, 9usize, "first ArrowUp selects the last row"),
2506 ] {
2507 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2508 let selection = SelectionModel::new(SelectionMode::Single);
2509 let mut tree = WidgetTree::new();
2510 let lv_id = tree.add(
2511 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2512 .item_height(20.0)
2513 .selection(selection.clone()),
2514 );
2515 tree.layout(SizeProposal::exact(400.0, 300.0));
2516 tree.focus(lv_id);
2517 assert!(
2518 selection.selected_indices().is_empty(),
2519 "precondition: nothing selected, no cursor"
2520 );
2521
2522 tree.press_key(key, Modifiers::NONE);
2523 assert_eq!(selection.selected_indices(), vec![want], "{what}");
2524 }
2525 }
2526
2527 #[test]
2528 fn keyboard_cursor_starts_from_a_preset_selection() {
2529 use teksilo_core::event::{Key, Modifiers};
2534 use teksilo_data::{SelectionMode, SelectionModel};
2535
2536 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2537 let selection = SelectionModel::new(SelectionMode::Single);
2538 selection.select(2);
2539 let mut tree = WidgetTree::new();
2540 let lv_id = tree.add(
2541 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2542 .item_height(20.0)
2543 .selection(selection.clone()),
2544 );
2545 tree.layout(SizeProposal::exact(400.0, 300.0));
2546 tree.focus(lv_id);
2547
2548 tree.press_key(Key::ArrowDown, Modifiers::NONE);
2549 assert_eq!(
2550 selection.selected_indices(),
2551 vec![3],
2552 "Down from a preselected row 2 continues to 3"
2553 );
2554 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2555 tree.press_key(Key::ArrowUp, Modifiers::NONE);
2556 assert_eq!(selection.selected_indices(), vec![1], "and Up walks back");
2557 }
2558
2559 #[test]
2560 fn checkbox_press_does_not_select_row() {
2561 use crate::styles::recipe_standard_item_style as si;
2565 use teksilo_canvas::Point;
2566 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2567 use teksilo_data::{SelectionMode, SelectionModel};
2568 use teksilo_i18n::lit;
2569
2570 let model = ListModel::from_vec(vec!["alpha", "beta", "gamma"]);
2571 let checks: Vec<Signal<bool>> = (0..3).map(|_| Signal::new(false)).collect();
2572 let checks_for_rows = checks.clone();
2573 let selection = SelectionModel::new(SelectionMode::Single);
2574 let sel = selection.clone();
2575 let mut tree = WidgetTree::new();
2576 let lv_id = tree.add(
2577 ListView::new(model, move |i, _item, _selected| {
2578 Box::new(
2579 crate::StandardListItem::new(lit!(format!("row {i}")))
2580 .checkbox(checks_for_rows[i].clone()),
2581 ) as Box<dyn Widget>
2582 })
2583 .item_height(40.0)
2584 .selection(sel),
2585 );
2586 tree.layout(SizeProposal::exact(400.0, 300.0));
2587
2588 let rows = row_ids(&tree, lv_id);
2589 let row0 = tree.bounds(rows[0]);
2590 let press = |t: &mut WidgetTree, x: f32, y: f32| {
2591 t.dispatch_event(WidgetEvent::PointerDown {
2592 position: Point::new(x, y),
2593 button: PointerButton::Primary,
2594 modifiers: Modifiers::NONE,
2595 });
2596 t.dispatch_event(WidgetEvent::PointerUp {
2597 position: Point::new(x, y),
2598 button: PointerButton::Primary,
2599 modifiers: Modifiers::NONE,
2600 });
2601 };
2602
2603 let cb_x = row0.x
2605 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
2606 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2607 + 4.0;
2608 let cb_y = row0.y + row0.height * 0.5;
2609 press(&mut tree, cb_x, cb_y);
2610 assert!(checks[0].get(), "checkbox press should toggle the checkbox");
2611 assert!(
2612 selection.selected_indices().is_empty(),
2613 "checkbox press must not select the row (got {:?})",
2614 selection.selected_indices()
2615 );
2616
2617 let body_x = row0.x + row0.width * 0.7;
2619 press(&mut tree, body_x, cb_y);
2620 assert_eq!(
2621 selection.selected_indices(),
2622 vec![0],
2623 "body press should select row 0"
2624 );
2625 assert!(
2626 checks[0].get(),
2627 "body press must not toggle the checkbox back"
2628 );
2629 }
2630
2631 #[test]
2632 fn virtualization_creates_only_visible_items() {
2633 let (mut tree, lv_id, _model) = make_list_view(10_000, 30.0);
2634 tree.layout(SizeProposal::exact(400.0, 300.0));
2636
2637 let children = row_ids(&tree, lv_id);
2638 let item_count = children.len() - 1;
2640 assert!(
2641 item_count < 30,
2642 "Expected fewer than 30 items, got {}",
2643 item_count
2644 );
2645 assert!(
2646 item_count >= 10,
2647 "Expected at least 10 items, got {}",
2648 item_count
2649 );
2650 }
2651
2652 #[test]
2653 fn empty_model_shows_scrollbar_only() {
2654 let (mut tree, lv_id, _model) = make_list_view(0, 30.0);
2655 tree.layout(SizeProposal::exact(400.0, 300.0));
2656
2657 assert_eq!(tree.children(lv_id).len(), 2, "body pane + scrollbar");
2660 assert!(
2661 row_ids(&tree, lv_id).is_empty(),
2662 "no rows for an empty model"
2663 );
2664 }
2665
2666 #[test]
2667 fn data_change_triggers_rebuild() {
2668 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2669 tree.layout(SizeProposal::exact(400.0, 300.0));
2670
2671 let initial_items = row_ids(&tree, lv_id).len(); assert_eq!(initial_items, 5);
2673
2674 model.push(99);
2675 tree.layout(SizeProposal::exact(400.0, 300.0));
2676
2677 let new_items = row_ids(&tree, lv_id).len();
2678 assert_eq!(new_items, 6);
2679 }
2680
2681 #[test]
2682 fn remove_triggers_rebuild() {
2683 let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2684 tree.layout(SizeProposal::exact(400.0, 300.0));
2685 assert_eq!(row_ids(&tree, lv_id).len(), 5);
2686
2687 model.remove(0);
2688 tree.layout(SizeProposal::exact(400.0, 300.0));
2689 assert_eq!(row_ids(&tree, lv_id).len(), 4);
2690 }
2691
2692 #[test]
2693 fn items_positioned_correctly() {
2694 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2695 tree.layout(SizeProposal::exact(400.0, 300.0));
2696
2697 let children = row_ids(&tree, lv_id);
2698 let y0 = tree.bounds(children[0]).y;
2700 let y1 = tree.bounds(children[1]).y;
2701 let y2 = tree.bounds(children[2]).y;
2702 assert!((y0 - 0.0).abs() < 0.01);
2703 assert!((y1 - 40.0).abs() < 0.01);
2704 assert!((y2 - 80.0).abs() < 0.01);
2705 }
2706
2707 #[test]
2708 fn items_have_correct_height() {
2709 let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2710 tree.layout(SizeProposal::exact(400.0, 300.0));
2711
2712 let children = row_ids(&tree, lv_id);
2713 for i in 0..3 {
2714 let h = tree.bounds(children[i]).height;
2715 assert!((h - 40.0).abs() < 0.01, "Item {} height {} != 40.0", i, h);
2716 }
2717 }
2718
2719 #[test]
2720 fn scrollbar_positioned_on_right_edge() {
2721 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2722 tree.layout(SizeProposal::exact(400.0, 300.0));
2723
2724 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2725 assert!(
2727 (sb_bounds.x - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2728 "Scrollbar x {} != {}",
2729 sb_bounds.x,
2730 400.0 - SCROLLBAR_THICKNESS
2731 );
2732 assert!((sb_bounds.height - 300.0).abs() < 0.01);
2733 }
2734
2735 #[test]
2736 fn small_list_collapses_scrollbar() {
2737 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2738 tree.layout(SizeProposal::exact(400.0, 300.0));
2740
2741 let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2742 assert!(
2743 sb_bounds.width < 0.01 && sb_bounds.height < 0.01,
2744 "Scrollbar should be collapsed for small lists"
2745 );
2746 }
2747
2748 #[test]
2749 fn item_width_leaves_room_for_scrollbar() {
2750 let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2751 tree.layout(SizeProposal::exact(400.0, 300.0));
2752
2753 let children = row_ids(&tree, lv_id);
2754 let item_width = tree.bounds(children[0]).width;
2755 assert!(
2756 (item_width - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2757 "Item width {} should be {}",
2758 item_width,
2759 400.0 - SCROLLBAR_THICKNESS
2760 );
2761 }
2762
2763 #[test]
2764 fn small_list_items_use_full_width() {
2765 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2766 tree.layout(SizeProposal::exact(400.0, 300.0));
2768
2769 let children = row_ids(&tree, lv_id);
2770 let item_width = tree.bounds(children[0]).width;
2771 assert!(
2772 (item_width - 400.0).abs() < 0.01,
2773 "Small list item width {} should be full 400.0 (no scrollbar)",
2774 item_width,
2775 );
2776 }
2777
2778 fn make_selectable_list(
2781 count: usize,
2782 ) -> (
2783 WidgetTree,
2784 WidgetId,
2785 ListModel<usize>,
2786 teksilo_data::SelectionModel,
2787 ) {
2788 use teksilo_data::{SelectionMode, SelectionModel};
2789 let model = ListModel::from_vec((0..count).collect());
2790 let selection = SelectionModel::new(SelectionMode::Multi);
2791 let sel_clone = selection.clone();
2792 let mut tree = WidgetTree::new();
2793 let lv_id = tree.add(
2794 ListView::new(model.clone(), move |_i, _item, _selected| {
2795 Box::new(FixedLeaf(100.0, 30.0))
2796 })
2797 .item_height(30.0)
2798 .selection(sel_clone),
2799 );
2800 tree.layout(SizeProposal::exact(400.0, 300.0));
2801 (tree, lv_id, model, selection)
2802 }
2803
2804 #[test]
2805 fn click_selects_item() {
2806 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2807 let children = row_ids(&tree, lv_id);
2809 tree.click(children[1]);
2810 assert!(selection.is_selected(1), "item 1 should be selected");
2811 assert!(!selection.is_selected(0), "item 0 should not be selected");
2812 }
2813
2814 #[test]
2815 fn a_multi_select_list_says_so_and_a_single_select_one_does_not() {
2816 use teksilo_data::{SelectionMode, SelectionModel};
2825
2826 let published = |mode: SelectionMode| {
2827 let model = ListModel::from_vec(vec![1, 2, 3]);
2828 let mut tree = WidgetTree::new();
2829 tree.add(
2832 ListView::new(model, move |_i, _item, _selected| {
2833 Box::new(FixedLeaf(100.0, 30.0))
2834 })
2835 .item_height(30.0)
2836 .selection(SelectionModel::new(mode)),
2837 );
2838 tree.layout(SizeProposal::exact(400.0, 300.0));
2839 let snapshot = tree.accessibility_tree_snapshot();
2843 let consumer = accesskit_consumer::Tree::new(snapshot, true);
2844 let root = consumer.state().root();
2845 fn listbox<'a>(
2846 node: accesskit_consumer::NodeRef<'a>,
2847 ) -> Option<accesskit_consumer::NodeRef<'a>> {
2848 if node.role() == teksilo_core::accesskit::Role::ListBox {
2849 return Some(node);
2850 }
2851 node.children().find_map(listbox)
2852 }
2853 listbox(root)
2854 .expect("the view publishes a ListBox")
2855 .is_multiselectable()
2856 };
2857
2858 assert!(
2859 published(SelectionMode::Multi),
2860 "a list that takes more than one row has to say so"
2861 );
2862 assert!(
2863 !published(SelectionMode::Single),
2864 "and one that does not must not, or Windows raises the wrong event"
2865 );
2866 }
2867
2868 #[test]
2869 fn moving_the_selection_keeps_every_row_node() {
2870 let (mut tree, lv_id, _model, selection) = make_selectable_list(5);
2879 selection.select(0);
2880 tree.layout(SizeProposal::exact(400.0, 300.0));
2881 let before = row_ids(&tree, lv_id);
2882 assert_eq!(
2883 before.len(),
2884 5,
2885 "all five rows realize in a 300 px viewport"
2886 );
2887
2888 selection.select(1);
2889 tree.layout(SizeProposal::exact(400.0, 300.0));
2890 let after = row_ids(&tree, lv_id);
2891
2892 assert_eq!(
2893 before, after,
2894 "a selection move must not replace the row nodes"
2895 );
2896 }
2897
2898 #[test]
2899 fn click_replaces_selection() {
2900 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2901 let children = row_ids(&tree, lv_id);
2902 tree.click(children[0]);
2903 assert!(selection.is_selected(0));
2904
2905 tree.click(children[2]);
2906 assert!(selection.is_selected(2));
2907 assert!(
2908 !selection.is_selected(0),
2909 "previous selection should be cleared"
2910 );
2911 }
2912
2913 #[test]
2914 fn keyed_selection_tracks_identity_not_index() {
2915 use std::rc::Rc;
2918 use teksilo_core::ObserverHandle;
2919 use teksilo_data::{KeyedSelectionModel, ListDataSource, SelectionMode};
2920
2921 struct KeyedSource {
2922 items: Vec<(u64, usize)>, }
2924 impl ListDataSource for KeyedSource {
2925 type Item = usize;
2926 type Key = u64;
2927 fn len(&self) -> usize {
2928 self.items.len()
2929 }
2930 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
2931 self.items.get(i).map(|(_, v)| f(v))
2932 }
2933 fn key_at(&self, i: usize) -> Option<u64> {
2934 self.items.get(i).map(|(k, _)| *k)
2935 }
2936 fn index_of(&self, key: &u64) -> Option<usize> {
2937 self.items.iter().position(|(k, _)| k == key)
2938 }
2939 fn observe_changes(
2940 &self,
2941 _f: impl Fn(&teksilo_data::DataChange) + 'static,
2942 ) -> ObserverHandle {
2943 ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
2944 }
2945 }
2946
2947 let keyed = KeyedSelectionModel::<u64>::new(SelectionMode::Single);
2948 let source = KeyedSource {
2949 items: vec![(10, 100), (20, 200), (30, 300)],
2950 };
2951 let mut tree = WidgetTree::new();
2952 let lv_id = tree.add(
2953 ListView::from_source_keyed(source, keyed.clone(), |_i, _v, _sel| {
2954 Box::new(FixedLeaf(100.0, 30.0))
2955 })
2956 .item_height(30.0),
2957 );
2958 tree.layout(SizeProposal::exact(400.0, 300.0));
2959
2960 let children = row_ids(&tree, lv_id);
2962 tree.click(children[1]);
2963 assert!(keyed.is_selected(&20), "selection is stored by key");
2964 assert_eq!(keyed.selected_keys(), vec![20]);
2965 assert!(!keyed.is_selected(&10));
2966 }
2967
2968 #[test]
2969 fn ctrl_click_toggles() {
2970 use teksilo_core::event::Modifiers;
2971 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2972 let children = row_ids(&tree, lv_id);
2973
2974 tree.click(children[0]);
2976 assert!(selection.is_selected(0));
2977
2978 let center = tree.bounds(children[2]).center();
2980 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2981 position: center,
2982 button: teksilo_core::event::PointerButton::Primary,
2983 modifiers: Modifiers::COMMAND,
2984 });
2985 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerUp {
2986 position: center,
2987 button: teksilo_core::event::PointerButton::Primary,
2988 modifiers: Modifiers::COMMAND,
2989 });
2990
2991 assert!(selection.is_selected(0), "item 0 should still be selected");
2992 assert!(selection.is_selected(2), "item 2 should be toggled on");
2993 }
2994
2995 #[test]
2996 fn shift_click_extends_range() {
2997 use teksilo_core::event::Modifiers;
2998 let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2999 let children = row_ids(&tree, lv_id);
3000
3001 tree.click(children[1]);
3003 assert!(
3004 selection.is_selected(1),
3005 "item 1 should be selected after plain click"
3006 );
3007
3008 let center = tree.bounds(children[3]).center();
3010 tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
3011 position: center,
3012 button: teksilo_core::event::PointerButton::Primary,
3013 modifiers: Modifiers::SHIFT,
3014 });
3015
3016 let selected = selection.selected_indices();
3017 assert_eq!(
3018 selected,
3019 vec![1, 2, 3],
3020 "Shift+click should select range 1..=3, got {:?}",
3021 selected
3022 );
3023 }
3024
3025 #[test]
3028 fn scroll_changes_visible_items() {
3029 let model = ListModel::from_vec((0..100).collect());
3031 let mut tree = WidgetTree::new();
3032 let lv_id = tree.add(
3033 ListView::new(model.clone(), move |i, _item, _selected| {
3034 Box::new(FixedLeaf(i as f32, 30.0))
3036 })
3037 .item_height(30.0),
3038 );
3039 tree.layout(SizeProposal::exact(400.0, 300.0));
3040
3041 let children = row_ids(&tree, lv_id);
3043 let first_y = tree.bounds(children[0]).y;
3044 assert!(
3045 first_y.abs() < 30.0,
3046 "First visible item should be near the top, got y={}",
3047 first_y
3048 );
3049
3050 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3052 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 1500.0 },
3053 modifiers: Default::default(),
3054 });
3055 tree.layout(SizeProposal::exact(400.0, 300.0));
3056
3057 let children_after = row_ids(&tree, lv_id);
3060 let item_count_after = children_after.len() - 1;
3061 assert!(
3062 item_count_after > 0,
3063 "Should have visible items after scroll"
3064 );
3065
3066 let first_y_after = tree.bounds(children_after[0]).y;
3070 assert!(
3071 first_y_after < 300.0,
3072 "First item should be in viewport after scroll, got y={}",
3073 first_y_after
3074 );
3075
3076 assert!(
3088 item_count_after < 30,
3089 "Should still be virtualized after scroll, got {} items",
3090 item_count_after
3091 );
3092 }
3093
3094 #[test]
3097 fn list_item_has_a11y_role() {
3098 let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
3099 tree.layout(SizeProposal::exact(400.0, 300.0));
3100
3101 let children = row_ids(&tree, lv_id);
3103 let info = tree.accessibility_node(children[0]);
3104 assert_eq!(
3105 info.role(),
3106 teksilo_core::accesskit::Role::ListBoxOption,
3107 "Item wrapper should have ListBoxOption role"
3108 );
3109 }
3110
3111 #[test]
3114 fn alt_arrow_moves_one_step_per_press_across_rebuilds() {
3115 use teksilo_core::event::{Key, Modifiers};
3121 use teksilo_data::{SelectionMode, SelectionModel};
3122
3123 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3124 let selection = SelectionModel::new(SelectionMode::Single);
3125 let sel_clone = selection.clone();
3126 let model_clone = model.clone();
3127
3128 let mut tree = WidgetTree::new();
3129 let lv_id = tree.add(
3130 ListView::new(model_clone, move |_i, _item, _sel| {
3131 Box::new(FixedLeaf(100.0, 30.0))
3132 })
3133 .item_height(30.0)
3134 .selection(sel_clone)
3135 .reorderable(true),
3136 );
3137 tree.layout(SizeProposal::exact(400.0, 300.0));
3138
3139 for i in 0..3 {
3143 selection.select(i);
3144 tree.layout(SizeProposal::exact(400.0, 300.0));
3145 }
3146
3147 selection.select(0);
3148 tree.layout(SizeProposal::exact(400.0, 300.0));
3149
3150 tree.focus(lv_id);
3151 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
3152 key: Key::ArrowDown,
3153 modifiers: Modifiers::ALT,
3154 text: None,
3155 });
3156
3157 assert_eq!(model.with_item(0, |v| *v), Some(20));
3159 assert_eq!(model.with_item(1, |v| *v), Some(10));
3160 assert_eq!(model.with_item(2, |v| *v), Some(30));
3161 }
3162
3163 fn keyboard_fixture(
3167 mode: teksilo_data::SelectionMode,
3168 ) -> (
3169 WidgetTree,
3170 teksilo_data::SelectionModel,
3171 Signal<f32>,
3172 SizeProposal,
3173 ) {
3174 use teksilo_data::SelectionModel;
3175 let model = ListModel::from_vec((0..100usize).collect());
3176 let selection = SelectionModel::new(mode);
3177 let sel = selection.clone();
3178 let mut tree = WidgetTree::new();
3179 let view = ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3180 .item_height(20.0)
3181 .selection(sel);
3182 let scroll = view.scroll_y_signal().clone();
3183 let lv = tree.add(view);
3184 let p = SizeProposal::exact(400.0, 200.0);
3185 tree.layout(p);
3186 tree.focus(lv);
3187 (tree, selection, scroll, p)
3188 }
3189
3190 #[test]
3191 fn home_and_end_reach_the_ends_of_the_collection() {
3192 use teksilo_core::event::{Key, Modifiers};
3193 let (mut tree, selection, _scroll, p) =
3194 keyboard_fixture(teksilo_data::SelectionMode::Single);
3195 selection.select(40);
3196
3197 tree.press_key(Key::End, Modifiers::NONE);
3198 tree.layout(p);
3199 assert_eq!(selection.selected_indices(), vec![99]);
3200
3201 tree.press_key(Key::Home, Modifiers::NONE);
3202 tree.layout(p);
3203 assert_eq!(selection.selected_indices(), vec![0]);
3204 }
3205
3206 #[test]
3207 fn home_and_end_scroll_the_row_they_land_on_into_view() {
3208 use teksilo_core::event::{Key, Modifiers};
3209 let (mut tree, selection, scroll, p) =
3210 keyboard_fixture(teksilo_data::SelectionMode::Single);
3211 selection.select(0);
3212 tree.layout(p);
3213
3214 tree.press_key(Key::End, Modifiers::NONE);
3215 tree.layout(p);
3216 let at_end = scroll.get();
3219 assert!(
3220 at_end > 1500.0,
3221 "End should scroll to the bottom of the content, got {at_end}"
3222 );
3223
3224 tree.press_key(Key::Home, Modifiers::NONE);
3225 tree.layout(p);
3226 assert!(scroll.get() < 1.0, "Home should scroll back to the top");
3227 }
3228
3229 #[test]
3230 fn the_accelerator_moves_the_cursor_without_disturbing_the_selection() {
3231 use teksilo_core::event::{Key, Modifiers};
3232 let (mut tree, selection, _scroll, p) =
3233 keyboard_fixture(teksilo_data::SelectionMode::Multi);
3234 selection.select(40);
3235
3236 tree.press_key(Key::End, Modifiers::COMMAND);
3240 tree.layout(p);
3241 assert_eq!(selection.selected_indices(), vec![40]);
3242
3243 tree.press_key(Key::Home, Modifiers::COMMAND);
3244 tree.layout(p);
3245 assert_eq!(selection.selected_indices(), vec![40]);
3246
3247 tree.press_key(Key::PageDown, Modifiers::COMMAND);
3249 tree.layout(p);
3250 assert_eq!(selection.selected_indices(), vec![40]);
3251 }
3252
3253 #[test]
3254 fn shift_end_then_shift_home_selects_one_range_not_the_whole_list() {
3255 use teksilo_core::event::{Key, Modifiers};
3256 let (mut tree, selection, _scroll, p) =
3257 keyboard_fixture(teksilo_data::SelectionMode::Multi);
3258 selection.select(10);
3259
3260 tree.press_key(Key::End, Modifiers::SHIFT);
3261 tree.layout(p);
3262 assert_eq!(selection.count(), 90, "10..=99");
3263
3264 tree.press_key(Key::Home, Modifiers::SHIFT);
3265 tree.layout(p);
3266 assert_eq!(
3267 selection.selected_indices(),
3268 (0..=10).collect::<Vec<_>>(),
3269 "reversing the gesture must shrink the range, not union with it"
3270 );
3271 }
3272
3273 #[test]
3274 fn ctrl_shift_end_extends_without_losing_an_earlier_pick() {
3275 use teksilo_core::event::{Key, Modifiers};
3276 let (mut tree, selection, _scroll, p) =
3277 keyboard_fixture(teksilo_data::SelectionMode::Multi);
3278 selection.select(0);
3279 selection.toggle(95); tree.press_key(Key::End, Modifiers::COMMAND | Modifiers::SHIFT);
3282 tree.layout(p);
3283 let got = selection.selected_indices();
3284 assert_eq!(got.first().copied(), Some(0), "the earlier pick survives");
3285 assert_eq!(got.last().copied(), Some(99));
3286 assert_eq!(got.len(), 6, "0 plus 95..=99");
3287 }
3288
3289 #[test]
3290 fn ctrl_shift_a_deselects_everything() {
3291 use teksilo_core::event::{Key, Modifiers};
3292 let (mut tree, selection, _scroll, p) =
3293 keyboard_fixture(teksilo_data::SelectionMode::Multi);
3294 tree.press_key(Key::A, Modifiers::COMMAND);
3295 tree.layout(p);
3296 assert_eq!(selection.count(), 100);
3297
3298 tree.press_key(Key::A, Modifiers::COMMAND | Modifiers::SHIFT);
3299 tree.layout(p);
3300 assert_eq!(selection.count(), 0);
3301 }
3302
3303 #[test]
3304 fn page_down_up_moves_selection_by_viewport() {
3305 use teksilo_core::event::{Key, Modifiers};
3306 use teksilo_data::{SelectionMode, SelectionModel};
3307
3308 let model = ListModel::from_vec((0..100usize).collect());
3309 let selection = SelectionModel::new(SelectionMode::Single);
3310 let sel = selection.clone();
3311 let mut tree = WidgetTree::new();
3312 let lv = tree.add(
3313 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3314 .item_height(20.0)
3315 .selection(sel),
3316 );
3317 let p = SizeProposal::exact(400.0, 200.0); tree.layout(p);
3319 tree.focus(lv);
3320 selection.select(0);
3321
3322 tree.press_key(Key::PageDown, Modifiers::NONE);
3323 tree.layout(p);
3324 let after_pgdn = selection.selected_indices()[0];
3325 assert!(
3326 after_pgdn >= 8,
3327 "PageDown should advance ~one viewport of rows, got {after_pgdn}"
3328 );
3329 let scroll = with_list_view::<usize, _>(&tree, lv, |v| v.scroll_y_signal().get());
3330 assert!(scroll > 0.0, "PageDown scrolls to follow, got {scroll}");
3331
3332 tree.press_key(Key::PageUp, Modifiers::NONE);
3333 tree.layout(p);
3334 assert!(
3335 selection.selected_indices()[0] < after_pgdn,
3336 "PageUp should move selection back up"
3337 );
3338 }
3339
3340 #[test]
3341 fn space_toggles_selection_enter_activates() {
3342 use std::cell::Cell;
3343 use teksilo_core::event::{Key, Modifiers};
3344 use teksilo_data::{SelectionMode, SelectionModel};
3345
3346 let model = ListModel::from_vec((0..5usize).collect());
3347 let selection = SelectionModel::new(SelectionMode::Multi);
3348 let sel = selection.clone();
3349 let activated = Rc::new(Cell::new(None));
3350 let act = activated.clone();
3351 let mut tree = WidgetTree::new();
3352 let lv = tree.add(
3353 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3354 .item_height(20.0)
3355 .selection(sel)
3356 .on_activate(move |i, _ctx| act.set(Some(i))),
3357 );
3358 tree.layout(SizeProposal::exact(400.0, 200.0));
3359 tree.focus(lv);
3360
3361 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3364 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3365 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3366 assert_eq!(selection.selected_indices(), vec![2]);
3367 assert_eq!(activated.get(), None, "arrows never activate");
3368
3369 tree.press_key(Key::Space, Modifiers::NONE);
3371 assert!(
3372 selection.selected_indices().is_empty(),
3373 "Space toggles row 2 off in Multi mode"
3374 );
3375 assert_eq!(activated.get(), None, "Space must NOT activate");
3376
3377 tree.press_key(Key::Enter, Modifiers::NONE);
3379 assert_eq!(activated.get(), Some(2), "Enter activates the focused row");
3380 }
3381
3382 #[test]
3383 fn ctrl_a_selects_all_in_multi_mode() {
3384 use teksilo_core::event::{Key, Modifiers};
3385 use teksilo_data::{SelectionMode, SelectionModel};
3386
3387 let model = ListModel::from_vec((0..6usize).collect());
3388 let selection = SelectionModel::new(SelectionMode::Multi);
3389 let sel = selection.clone();
3390 let mut tree = WidgetTree::new();
3391 let lv = tree.add(
3392 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3393 .item_height(20.0)
3394 .selection(sel),
3395 );
3396 tree.layout(SizeProposal::exact(400.0, 200.0));
3397 tree.focus(lv);
3398 tree.press_key(Key::A, Modifiers::COMMAND);
3399 assert_eq!(selection.selected_indices().len(), 6, "Ctrl+A selects all");
3400 }
3401
3402 #[test]
3403 fn ctrl_arrow_moves_cursor_without_selecting_in_multi_mode() {
3404 use teksilo_core::event::{Key, Modifiers};
3405 use teksilo_data::{SelectionMode, SelectionModel};
3406
3407 let model = ListModel::from_vec((0..6usize).collect());
3408 let selection = SelectionModel::new(SelectionMode::Multi);
3409 let sel = selection.clone();
3410 let mut tree = WidgetTree::new();
3411 let lv = tree.add(
3412 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3413 .item_height(20.0)
3414 .selection(sel),
3415 );
3416 tree.layout(SizeProposal::exact(400.0, 200.0));
3417 tree.focus(lv);
3418
3419 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3421 assert_eq!(selection.selected_indices(), vec![0]);
3422
3423 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3425 assert_eq!(
3426 selection.selected_indices(),
3427 vec![0],
3428 "Ctrl+ArrowDown must leave the selection unchanged"
3429 );
3430 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3431 assert_eq!(focused, Some(1), "Ctrl+ArrowDown moves the cursor to row 1");
3432
3433 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3434 assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
3435 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3436 assert_eq!(focused, Some(2));
3437
3438 tree.press_key(Key::Space, Modifiers::CTRL);
3441 assert_eq!(selection.selected_indices(), vec![0, 2]);
3442
3443 tree.press_key(Key::Space, Modifiers::CTRL);
3445 assert_eq!(selection.selected_indices(), vec![0]);
3446
3447 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3450 assert_eq!(selection.selected_indices(), vec![3]);
3451 }
3452
3453 #[test]
3454 fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
3455 use teksilo_core::event::{Key, Modifiers};
3456 use teksilo_data::{SelectionMode, SelectionModel};
3457
3458 let model = ListModel::from_vec((0..6usize).collect());
3459 let selection = SelectionModel::new(SelectionMode::Single);
3460 let sel = selection.clone();
3461 let mut tree = WidgetTree::new();
3462 let lv = tree.add(
3463 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3464 .item_height(20.0)
3465 .selection(sel),
3466 );
3467 tree.layout(SizeProposal::exact(400.0, 200.0));
3468 tree.focus(lv);
3469
3470 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3471 assert_eq!(selection.selected_indices(), vec![0]);
3472
3473 tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3474 assert_eq!(
3475 selection.selected_indices(),
3476 vec![0],
3477 "Ctrl+ArrowDown must not select in Single mode either"
3478 );
3479 let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3480 assert_eq!(focused, Some(1));
3481
3482 tree.press_key(Key::ArrowDown, Modifiers::NONE);
3483 assert_eq!(selection.selected_indices(), vec![2]);
3484 }
3485
3486 #[test]
3487 fn type_ahead_jumps_to_matching_row() {
3488 use teksilo_core::event::{Key, Modifiers};
3489 use teksilo_data::{SelectionMode, SelectionModel};
3490
3491 let model = ListModel::from_vec(vec![
3492 "Apple".to_string(),
3493 "Banana".to_string(),
3494 "Cherry".to_string(),
3495 "Cranberry".to_string(),
3496 "Date".to_string(),
3497 ]);
3498 let selection = SelectionModel::new(SelectionMode::Single);
3499 let sel = selection.clone();
3500 let mut tree = WidgetTree::new();
3501 let lv = tree.add(
3502 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3503 .item_height(20.0)
3504 .selection(sel)
3505 .type_ahead_label(|s: &String| s.clone()),
3506 );
3507 tree.layout(SizeProposal::exact(400.0, 200.0));
3508 tree.focus(lv);
3509 selection.select(0);
3510
3511 tree.press_key(Key::C, Modifiers::NONE);
3513 assert_eq!(selection.selected_indices(), vec![2], "'c' → Cherry");
3514
3515 tree.press_key(Key::R, Modifiers::NONE);
3517 assert_eq!(selection.selected_indices(), vec![3], "'cr' → Cranberry");
3518 }
3519
3520 #[test]
3521 fn type_ahead_buffer_survives_rebuild() {
3522 use teksilo_core::event::{Key, Modifiers};
3527 use teksilo_data::{SelectionMode, SelectionModel};
3528
3529 let model = ListModel::from_vec(vec![
3530 "Apple".to_string(),
3531 "Cherry".to_string(),
3532 "Cranberry".to_string(),
3533 ]);
3534 let selection = SelectionModel::new(SelectionMode::Single);
3535 let sel = selection.clone();
3536 let mut tree = WidgetTree::new();
3537 let p = SizeProposal::exact(400.0, 200.0);
3538 let lv = tree.add(
3539 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3540 .item_height(20.0)
3541 .selection(sel)
3542 .type_ahead_label(|s: &String| s.clone()),
3543 );
3544 tree.layout(p);
3545 tree.focus(lv);
3546 selection.select(0);
3547
3548 tree.press_key(Key::C, Modifiers::NONE); assert_eq!(selection.selected_indices(), vec![1]);
3550 tree.layout(p); tree.press_key(Key::R, Modifiers::NONE); assert_eq!(
3553 selection.selected_indices(),
3554 vec![2],
3555 "buffer 'c' must survive the rebuild so 'cr' matches Cranberry"
3556 );
3557 }
3558
3559 #[test]
3560 fn page_down_on_short_list_jumps_to_last_without_panic() {
3561 use teksilo_core::event::{Key, Modifiers};
3562 use teksilo_data::{SelectionMode, SelectionModel};
3563
3564 let model = ListModel::from_vec((0..3usize).collect());
3566 let selection = SelectionModel::new(SelectionMode::Single);
3567 let sel = selection.clone();
3568 let mut tree = WidgetTree::new();
3569 let lv = tree.add(
3570 ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3571 .item_height(20.0)
3572 .selection(sel),
3573 );
3574 tree.layout(SizeProposal::exact(400.0, 200.0));
3575 tree.focus(lv);
3576 selection.select(0);
3577 tree.press_key(Key::PageDown, Modifiers::NONE);
3578 assert_eq!(selection.selected_indices(), vec![2], "PageDown → last row");
3579 tree.press_key(Key::PageDown, Modifiers::NONE);
3580 assert_eq!(selection.selected_indices(), vec![2], "stays at last");
3581 tree.press_key(Key::PageUp, Modifiers::NONE);
3582 assert_eq!(selection.selected_indices(), vec![0], "PageUp → first row");
3583 }
3584
3585 #[test]
3586 fn alt_arrow_reorders_item() {
3587 use teksilo_core::event::{Key, Modifiers};
3588 use teksilo_data::{SelectionMode, SelectionModel};
3589
3590 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3591 let selection = SelectionModel::new(SelectionMode::Single);
3592 let sel_clone = selection.clone();
3593 let model_clone = model.clone();
3594
3595 let mut tree = WidgetTree::new();
3596 let lv_id = tree.add(
3597 ListView::new(model_clone.clone(), move |_i, _item, _sel| {
3598 Box::new(FixedLeaf(100.0, 30.0))
3599 })
3600 .item_height(30.0)
3601 .selection(sel_clone)
3602 .reorderable(true),
3603 );
3604 tree.layout(SizeProposal::exact(400.0, 300.0));
3605
3606 selection.select(2);
3608
3609 tree.focus(lv_id);
3611 tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
3612 key: Key::ArrowDown,
3613 modifiers: Modifiers::ALT,
3614 text: None,
3615 });
3616
3617 assert_eq!(model.with_item(3, |v| *v), Some(30));
3619 assert_eq!(model.with_item(2, |v| *v), Some(40));
3620 }
3621
3622 fn make_reorderable_list(
3627 values: Vec<usize>,
3628 item_height: f32,
3629 ) -> (WidgetTree, WidgetId, ListModel<usize>) {
3630 let model = ListModel::from_vec(values);
3631 let model_clone = model.clone();
3632 let mut tree = WidgetTree::new();
3633 let lv_id = tree.add(
3634 ListView::new(model_clone, move |_i, _item, _sel| {
3635 Box::new(FixedLeaf(100.0, item_height))
3636 })
3637 .item_height(item_height)
3638 .reorderable(true),
3639 );
3640 (tree, lv_id, model)
3641 }
3642
3643 fn drag_item(tree: &mut WidgetTree, from: Point, to: Point) {
3646 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3647 tree.dispatch_event(WidgetEvent::PointerDown {
3648 position: from,
3649 button: PointerButton::Primary,
3650 modifiers: Modifiers::NONE,
3651 });
3652 tree.dispatch_event(WidgetEvent::PointerMove {
3654 position: Point::new(from.x + 10.0, from.y),
3655 });
3656 tree.dispatch_event(WidgetEvent::PointerMove { position: to });
3657 tree.dispatch_event(WidgetEvent::PointerUp {
3658 position: to,
3659 button: PointerButton::Primary,
3660 modifiers: Modifiers::NONE,
3661 });
3662 }
3663
3664 #[test]
3665 fn drag_reorders_item_downward() {
3666 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3667 tree.layout(SizeProposal::exact(400.0, 300.0));
3668
3669 let children = row_ids(&tree, lv_id);
3672 let from = tree.bounds(children[0]).center();
3673 let to = Point::new(from.x, 120.0);
3674 drag_item(&mut tree, from, to);
3675
3676 assert_eq!(model.with_item(0, |v| *v), Some(20));
3678 assert_eq!(model.with_item(3, |v| *v), Some(10));
3679 assert_eq!(model.with_item(4, |v| *v), Some(50));
3680 }
3681
3682 #[test]
3683 fn drag_reorders_item_upward() {
3684 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3685 tree.layout(SizeProposal::exact(400.0, 300.0));
3686
3687 let children = row_ids(&tree, lv_id);
3690 let from = tree.bounds(children[3]).center();
3691 let to = Point::new(from.x, 15.0);
3692 drag_item(&mut tree, from, to);
3693
3694 assert_eq!(model.with_item(1, |v| *v), Some(40));
3696 assert_eq!(model.with_item(2, |v| *v), Some(20));
3697 assert_eq!(model.with_item(3, |v| *v), Some(30));
3698 }
3699
3700 #[test]
3701 fn reorderable_drag_routes_to_source_accept_drop() {
3702 use std::cell::RefCell;
3708 use std::rc::Rc;
3709 use teksilo_core::ObserverHandle;
3710 use teksilo_data::{
3711 DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
3712 ListDataSource,
3713 };
3714
3715 struct CapturingSource {
3716 items: Vec<usize>,
3717 captured: Rc<RefCell<Vec<(usize, usize)>>>,
3718 }
3719 impl ListDataSource for CapturingSource {
3720 type Item = usize;
3721 type Key = usize;
3722 fn len(&self) -> usize {
3723 self.items.len()
3724 }
3725 fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
3726 self.items.get(i).map(f)
3727 }
3728 fn key_at(&self, i: usize) -> Option<usize> {
3729 (i < self.items.len()).then_some(i)
3730 }
3731 fn observe_changes(
3732 &self,
3733 _f: impl Fn(&teksilo_data::DataChange) + 'static,
3734 ) -> ObserverHandle {
3735 let inner: Rc<dyn std::any::Any> = Rc::new(());
3736 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3737 }
3738 fn drag(&self, _k: &usize) -> DragEligibility {
3739 DragEligibility::CanDrag
3740 }
3741 fn can_accept(&self, q: &DropQuery<'_, usize>) -> DropResponse {
3742 match &q.source {
3743 DragSource::SameView { .. } if q.position != DropPosition::Into => {
3744 DropResponse::Accept
3745 }
3746 _ => DropResponse::Reject,
3747 }
3748 }
3749 fn accept_drop(&self, c: DropCommit<'_, usize>) -> bool {
3750 let DragSource::SameView { key: from } = c.source else {
3751 return false;
3752 };
3753 let target = c.target;
3754 let shift = if from < target { 1 } else { 0 };
3755 let to = match c.position {
3756 DropPosition::Before => target.saturating_sub(shift),
3757 DropPosition::After => (target + 1).saturating_sub(shift),
3758 DropPosition::Into => return false,
3759 };
3760 self.captured.borrow_mut().push((from, to));
3762 true
3763 }
3764 }
3765
3766 let captured: Rc<RefCell<Vec<(usize, usize)>>> = Rc::new(RefCell::new(Vec::new()));
3767 let source = CapturingSource {
3768 items: vec![10, 20, 30, 40, 50],
3769 captured: captured.clone(),
3770 };
3771 let mut tree = WidgetTree::new();
3772 let lv_id = tree.add(
3773 ListView::from_source(source, move |_i, _item, _sel| {
3774 Box::new(FixedLeaf(100.0, 30.0))
3775 })
3776 .item_height(30.0)
3777 .reorderable(true),
3778 );
3779 tree.layout(SizeProposal::exact(400.0, 300.0));
3780
3781 let children = row_ids(&tree, lv_id);
3784 let from = tree.bounds(children[0]).center();
3785 let to = Point::new(from.x, 120.0);
3786 drag_item(&mut tree, from, to);
3787
3788 assert_eq!(
3789 *captured.borrow(),
3790 vec![(0, 3)],
3791 "the drop is routed to the source's accept_drop with the resolved move"
3792 );
3793 }
3794
3795 #[test]
3796 fn drag_emits_items_moved_change() {
3797 use std::cell::Cell;
3798 use std::rc::Rc;
3799 use teksilo_data::DataChange;
3800
3801 let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3802 tree.layout(SizeProposal::exact(400.0, 300.0));
3803
3804 let moved = Rc::new(Cell::new(None::<(usize, usize)>));
3805 let moved_clone = moved.clone();
3806 let handle = model.observe_changes(move |change| {
3807 if let DataChange::ItemsMoved { from, to, .. } = change {
3808 moved_clone.set(Some((*from, *to)));
3809 }
3810 });
3811
3812 let children = row_ids(&tree, lv_id);
3814 let from = tree.bounds(children[0]).center();
3815 let to = Point::new(from.x, 120.0);
3816 drag_item(&mut tree, from, to);
3817
3818 assert_eq!(moved.get(), Some((0, 3)));
3819 drop(handle);
3820 }
3821
3822 #[test]
3823 fn drag_drop_accounts_for_scroll_offset() {
3824 let (mut tree, _lv_id, model) = make_reorderable_list((0..20).collect(), 30.0);
3827 tree.layout(SizeProposal::exact(400.0, 300.0));
3828
3829 tree.pointer_move(Point::new(50.0, 50.0));
3832 tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3833 delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 60.0 },
3834 modifiers: Default::default(),
3835 });
3836 tree.tick_animations(std::time::Duration::from_millis(200));
3839 tree.layout(SizeProposal::exact(400.0, 300.0));
3840
3841 let from = Point::new(50.0, 15.0);
3846 let to = Point::new(50.0, 120.0);
3847 drag_item(&mut tree, from, to);
3848
3849 assert_eq!(
3850 model.with_item(5, |v| *v),
3851 Some(2),
3852 "Item 2 should land at index 5 after drag with scroll offset"
3853 );
3854 assert_eq!(
3855 model.with_item(2, |v| *v),
3856 Some(3),
3857 "Item 3 should shift up to index 2"
3858 );
3859 }
3860
3861 #[test]
3862 fn click_selects_item_on_reorderable_list_with_selection() {
3863 use std::cell::Cell;
3872 use std::rc::Rc;
3873 use teksilo_data::{SelectionMode, SelectionModel};
3874
3875 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3876 let selection = SelectionModel::new(SelectionMode::Single);
3877 let sel_clone = selection.clone();
3878 let model_clone = model.clone();
3879
3880 let selected_rebuilds: Rc<std::cell::RefCell<Vec<Vec<usize>>>> =
3883 Rc::new(std::cell::RefCell::new(Vec::new()));
3884 let current_pass: Rc<Cell<Vec<usize>>> = Rc::new(Cell::new(Vec::new()));
3885 let _sr = selected_rebuilds.clone();
3886 let cp = current_pass.clone();
3887
3888 let mut tree = WidgetTree::new();
3889 let lv_id = tree.add(
3890 ListView::new(model_clone, move |index, _item, selected| {
3891 if selected {
3892 let mut acc = cp.take();
3893 acc.push(index);
3894 cp.set(acc);
3895 }
3896 Box::new(FixedLeaf(100.0, 30.0))
3897 })
3898 .item_height(30.0)
3899 .selection(sel_clone)
3900 .reorderable(true),
3901 );
3902 tree.layout(SizeProposal::exact(400.0, 300.0));
3903 selected_rebuilds.borrow_mut().push(current_pass.take());
3904
3905 let children = row_ids(&tree, lv_id);
3907 tree.click(children[2]);
3908
3909 assert_eq!(selection.selected_indices(), vec![2]);
3911
3912 tree.layout(SizeProposal::exact(400.0, 300.0));
3915 selected_rebuilds.borrow_mut().push(current_pass.take());
3916
3917 let passes = selected_rebuilds.borrow().clone();
3918 assert_eq!(
3919 passes[0],
3920 Vec::<usize>::new(),
3921 "initial build: nothing selected"
3922 );
3923 assert_eq!(
3924 passes[1],
3925 vec![2],
3926 "post-click rebuild should deliver selected=true for item 2"
3927 );
3928 }
3929
3930 #[test]
3931 fn drag_survives_rebuild_triggered_by_selection() {
3932 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3942 use teksilo_data::{SelectionMode, SelectionModel};
3943
3944 let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3945 let selection = SelectionModel::new(SelectionMode::Single);
3946 let sel_clone = selection.clone();
3947 let model_clone = model.clone();
3948
3949 let mut tree = WidgetTree::new();
3950 let _lv_id = tree.add(
3951 ListView::new(model_clone, move |_i, _item, _sel| {
3952 Box::new(FixedLeaf(100.0, 30.0))
3953 })
3954 .item_height(30.0)
3955 .selection(sel_clone)
3956 .reorderable(true),
3957 );
3958 tree.layout(SizeProposal::exact(400.0, 300.0));
3959
3960 tree.dispatch_event(WidgetEvent::PointerDown {
3967 position: Point::new(50.0, 15.0),
3968 button: PointerButton::Primary,
3969 modifiers: Modifiers::NONE,
3970 });
3971 tree.layout(SizeProposal::exact(400.0, 300.0));
3976
3977 tree.dispatch_event(WidgetEvent::PointerMove {
3979 position: Point::new(60.0, 15.0),
3980 });
3981 tree.dispatch_event(WidgetEvent::PointerMove {
3983 position: Point::new(60.0, 120.0),
3984 });
3985 tree.dispatch_event(WidgetEvent::PointerUp {
3986 position: Point::new(60.0, 120.0),
3987 button: PointerButton::Primary,
3988 modifiers: Modifiers::NONE,
3989 });
3990
3991 assert_eq!(
3993 model.with_item(3, |v| *v),
3994 Some(10),
3995 "Drag must complete even after the selection-triggered rebuild \
3996 destroyed the originally-captured wrapper"
3997 );
3998 }
3999
4000 #[test]
4001 fn lazy_loading_rows_render_placeholders_and_request_the_window() {
4002 use std::cell::RefCell;
4006 use std::ops::Range;
4007 use std::rc::Rc;
4008 use teksilo_core::ObserverHandle;
4009 use teksilo_data::{ListDataSource, RowState};
4010
4011 struct Windowed {
4012 total: usize,
4013 requested: Rc<RefCell<Vec<Range<usize>>>>,
4014 }
4015 impl ListDataSource for Windowed {
4016 type Item = usize;
4017 type Key = usize;
4018 fn len(&self) -> usize {
4019 self.total
4020 }
4021 fn with_item<R>(&self, _i: usize, _f: impl FnOnce(&usize) -> R) -> Option<R> {
4022 None }
4024 fn key_at(&self, i: usize) -> Option<usize> {
4025 (i < self.total).then_some(i)
4026 }
4027 fn row_state(&self, _i: usize) -> RowState {
4028 RowState::Loading
4029 }
4030 fn request_window(&self, range: Range<usize>) {
4031 self.requested.borrow_mut().push(range);
4032 }
4033 fn observe_changes(
4034 &self,
4035 _f: impl Fn(&teksilo_data::DataChange) + 'static,
4036 ) -> ObserverHandle {
4037 let inner: Rc<dyn std::any::Any> = Rc::new(());
4038 ObserverHandle::new(inner, 0, Rc::new(|_| {}))
4039 }
4040 }
4041
4042 let requested = Rc::new(RefCell::new(Vec::new()));
4043 let source = Windowed {
4044 total: 1000,
4045 requested: requested.clone(),
4046 };
4047 let mut tree = WidgetTree::new();
4048 let lv_id = tree.add(
4049 ListView::from_source(source, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4050 .item_height(30.0),
4051 );
4052 tree.layout(SizeProposal::exact(400.0, 300.0));
4053
4054 let placeholder_rows = row_ids(&tree, lv_id).len();
4057 assert!(
4058 placeholder_rows >= 10,
4059 "loading rows must render as placeholders, got {placeholder_rows}"
4060 );
4061 assert!(
4063 !requested.borrow().is_empty(),
4064 "request_window must be called for the visible range"
4065 );
4066 }
4067
4068 fn with_list_view<T: 'static, R>(
4071 tree: &WidgetTree,
4072 id: WidgetId,
4073 f: impl FnOnce(&ListView<T>) -> R,
4074 ) -> R {
4075 let any = tree.widget_as_any(id).expect("widget exposes as_any");
4076 let lv = any
4077 .downcast_ref::<ListView<T>>()
4078 .expect("widget is a ListView<T>");
4079 f(lv)
4080 }
4081
4082 #[test]
4083 fn drop_indicator_clears_after_drop() {
4084 let (mut tree, lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
4088 tree.layout(SizeProposal::exact(400.0, 300.0));
4089
4090 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4091
4092 let feedback =
4093 with_list_view::<usize, _>(&tree, lv_id, |lv| lv.drop_feedback_signal().get());
4094 assert!(
4095 feedback.is_none(),
4096 "drop_feedback must be cleared by on_drag_leave after drop, got {:?}",
4097 feedback
4098 );
4099 }
4100
4101 #[test]
4102 fn drag_spawns_preview_overlay_and_cleans_up() {
4103 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
4104
4105 let (mut tree, _lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
4106 tree.layout(SizeProposal::exact(400.0, 300.0));
4107
4108 let baseline = tree.overlay_manager().len();
4109
4110 tree.dispatch_event(WidgetEvent::PointerDown {
4112 position: Point::new(50.0, 15.0),
4113 button: PointerButton::Primary,
4114 modifiers: Modifiers::NONE,
4115 });
4116 tree.dispatch_event(WidgetEvent::PointerMove {
4117 position: Point::new(60.0, 15.0),
4118 });
4119
4120 assert_eq!(
4121 tree.overlay_manager().len(),
4122 baseline + 1,
4123 "Preview overlay should be live during drag"
4124 );
4125
4126 tree.dispatch_event(WidgetEvent::PointerUp {
4128 position: Point::new(60.0, 15.0),
4129 button: PointerButton::Primary,
4130 modifiers: Modifiers::NONE,
4131 });
4132 assert_eq!(
4133 tree.overlay_manager().len(),
4134 baseline,
4135 "Preview overlay should be dismissed after drop"
4136 );
4137 }
4138
4139 #[test]
4140 fn edge_auto_scroll_advances_scroll_y_during_drag() {
4141 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
4142
4143 let (mut tree, _lv_id, _model) =
4145 make_reorderable_list((0..50).collect::<Vec<usize>>(), 30.0);
4146 tree.layout(SizeProposal::exact(400.0, 300.0));
4147
4148 tree.dispatch_event(WidgetEvent::PointerDown {
4151 position: Point::new(50.0, 15.0),
4152 button: PointerButton::Primary,
4153 modifiers: Modifiers::NONE,
4154 });
4155 tree.dispatch_event(WidgetEvent::PointerMove {
4156 position: Point::new(60.0, 15.0),
4157 });
4158 tree.dispatch_event(WidgetEvent::PointerMove {
4159 position: Point::new(60.0, 290.0), });
4161
4162 for _ in 0..8 {
4164 tree.layout(SizeProposal::exact(400.0, 300.0));
4165 }
4166 let scroll_y = with_list_view::<usize, _>(&tree, _lv_id, |lv| lv.scroll_y_signal().get());
4167 assert!(
4168 scroll_y > 5.0,
4169 "Edge auto-scroll should have advanced scroll_y; got {scroll_y}"
4170 );
4171
4172 tree.dispatch_event(WidgetEvent::PointerUp {
4174 position: Point::new(60.0, 290.0),
4175 button: PointerButton::Primary,
4176 modifiers: Modifiers::NONE,
4177 });
4178 }
4179
4180 fn nested_list_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>, Signal<f32>) {
4186 use crate::ScrollArea;
4187 use crate::primitives::{FixedSize, VStack};
4188 let mut tree = WidgetTree::new();
4189 let model = ListModel::from_vec((0..40_usize).collect());
4190 let lv = ListView::new(model, move |_i, _item, _sel| {
4191 Box::new(FixedLeaf(180.0, 30.0))
4192 })
4193 .item_height(30.0)
4194 .overscroll_behavior(inner);
4195 let inner_y = lv.scroll_y_signal().clone();
4196 let lv_id = tree.add(lv);
4197 let viewport = tree.add(FixedSize::new().width(200.0).height(100.0).child_id(lv_id));
4198 let filler = tree.add(FixedLeaf(200.0, 200.0));
4199 let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
4200 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
4201 let outer_y = outer.scroll_y_signal().clone();
4202 let _outer = tree.add(outer);
4203 tree.layout(SizeProposal::exact(200.0, 150.0));
4204 (tree, inner_y, outer_y)
4205 }
4206
4207 #[test]
4208 fn nested_list_chains_to_outer_at_boundary() {
4209 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
4210 let (mut tree, inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Chain);
4211 tree.pointer_move(Point::new(50.0, 40.0));
4212 tree.dispatch_event(WidgetEvent::Scroll {
4213 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
4214 modifiers: Modifiers::NONE,
4215 });
4216 tree.layout(SizeProposal::exact(200.0, 150.0));
4217 let inner_bottom = inner_y.get();
4218 assert!(
4219 inner_bottom > 0.0,
4220 "inner list should scroll down; got {inner_bottom}"
4221 );
4222 assert!(
4223 outer_y.get() < 0.01,
4224 "outer must not move while the inner absorbs"
4225 );
4226
4227 tree.pointer_move(Point::new(50.0, 40.0));
4228 tree.dispatch_event(WidgetEvent::Scroll {
4229 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
4230 modifiers: Modifiers::NONE,
4231 });
4232 tree.layout(SizeProposal::exact(200.0, 150.0));
4233 assert!(
4234 (inner_y.get() - inner_bottom).abs() < 0.01,
4235 "inner stays clamped at bottom"
4236 );
4237 assert!(
4238 outer_y.get() > 0.01,
4239 "outer scrolled because the inner chained the boundary"
4240 );
4241 }
4242
4243 #[test]
4244 fn nested_list_contain_blocks_chaining() {
4245 use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
4246 let (mut tree, _inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Contain);
4247 tree.pointer_move(Point::new(50.0, 40.0));
4248 tree.dispatch_event(WidgetEvent::Scroll {
4249 delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
4250 modifiers: Modifiers::NONE,
4251 });
4252 tree.layout(SizeProposal::exact(200.0, 150.0));
4253 tree.pointer_move(Point::new(50.0, 40.0));
4254 tree.dispatch_event(WidgetEvent::Scroll {
4255 delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
4256 modifiers: Modifiers::NONE,
4257 });
4258 tree.layout(SizeProposal::exact(200.0, 150.0));
4259 assert!(
4260 outer_y.get() < 0.01,
4261 "Contain must prevent chaining: outer stays put"
4262 );
4263 }
4264
4265 #[test]
4266 fn keyboard_selection_chases_outer_scroll_area() {
4267 use crate::ScrollArea;
4273 use crate::primitives::{FixedSize, VStack};
4274 use teksilo_core::event::{Key, Modifiers};
4275 use teksilo_data::{SelectionMode, SelectionModel};
4276
4277 let mut tree = WidgetTree::new();
4278 let model = ListModel::from_vec((0..20_usize).collect());
4279 let selection = SelectionModel::new(SelectionMode::Single);
4280 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(180.0, 20.0)))
4281 .item_height(20.0)
4282 .selection(selection);
4283 let lv_id = tree.add(lv);
4284 let lv_box = tree.add(FixedSize::new().width(200.0).height(200.0).child_id(lv_id));
4285 let filler = tree.add(FixedLeaf(200.0, 200.0));
4286 let outer_content = tree.add(VStack::new().add_child(lv_box).add_child(filler));
4287 let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
4288 let outer_y = outer.scroll_y_signal().clone();
4289 let _outer = tree.add(outer);
4290 tree.layout(SizeProposal::exact(200.0, 100.0));
4291
4292 tree.focus(lv_id);
4295 tree.layout(SizeProposal::exact(200.0, 100.0));
4296 outer_y.set(0.0);
4297 tree.layout(SizeProposal::exact(200.0, 100.0));
4298 assert!(outer_y.get().abs() < 0.01, "reset outer to top");
4299
4300 for _ in 0..20 {
4302 tree.press_key(Key::ArrowDown, Modifiers::NONE);
4303 }
4304 tree.layout(SizeProposal::exact(200.0, 100.0));
4305
4306 assert!(
4307 outer_y.get() > 0.01,
4308 "selecting a row below the outer fold must scroll the enclosing \
4309 ScrollArea (got {})",
4310 outer_y.get()
4311 );
4312 }
4313
4314 fn item_spans(tree: &WidgetTree, lv_id: WidgetId) -> Vec<(f32, f32)> {
4319 let children = row_ids(tree, lv_id);
4320 let mut spans: Vec<(f32, f32)> = children[..]
4321 .iter()
4322 .map(|c| {
4323 let b = tree.bounds(*c);
4324 (b.y, b.height)
4325 })
4326 .collect();
4327 spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
4328 spans
4329 }
4330
4331 #[test]
4332 fn exact_item_height_fn_positions_rows_at_callback_heights() {
4333 let heights = [100.0_f32, 20.0, 50.0];
4334 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
4335 let mut tree = WidgetTree::new();
4336 let lv_id = tree.add(
4337 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4338 .item_height_fn(move |i| heights[i]),
4339 );
4340 tree.layout(SizeProposal::exact(400.0, 300.0));
4341
4342 let spans = item_spans(&tree, lv_id);
4343 assert_eq!(spans.len(), 3);
4344 assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 100.0).abs() < 0.01);
4345 assert!((spans[1].0 - 100.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
4346 assert!((spans[2].0 - 120.0).abs() < 0.01 && (spans[2].1 - 50.0).abs() < 0.01);
4347 }
4348
4349 #[test]
4350 fn exact_heights_with_spacing() {
4351 let heights = [100.0_f32, 20.0, 50.0];
4352 let model = ListModel::from_vec(vec![0_usize, 1, 2]);
4353 let mut tree = WidgetTree::new();
4354 let lv_id = tree.add(
4355 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4356 .item_height_fn(move |i| heights[i])
4357 .spacing(8.0),
4358 );
4359 tree.layout(SizeProposal::exact(400.0, 300.0));
4360
4361 let spans = item_spans(&tree, lv_id);
4362 assert!((spans[1].0 - 108.0).abs() < 0.01);
4363 assert!((spans[2].0 - 136.0).abs() < 0.01);
4364 }
4365
4366 #[test]
4367 fn variable_heights_virtualize() {
4368 let model = ListModel::from_vec((0..10_000).collect::<Vec<usize>>());
4369 let mut tree = WidgetTree::new();
4370 let lv_id = tree.add(
4371 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4372 .item_height_fn(|i| 20.0 + (i % 5) as f32 * 10.0),
4373 );
4374 tree.layout(SizeProposal::exact(400.0, 300.0));
4375
4376 let item_count = row_ids(&tree, lv_id).len();
4377 assert!(
4378 item_count < 40,
4379 "Expected fewer than 40 realized rows, got {item_count}"
4380 );
4381 assert!(
4382 item_count >= 8,
4383 "Expected at least 8 rows, got {item_count}"
4384 );
4385 }
4386
4387 #[test]
4388 fn auto_measure_corrects_rows_from_estimate() {
4389 let model = ListModel::from_vec(vec![0_usize, 1, 2, 3]);
4392 let mut tree = WidgetTree::new();
4393 let lv_id = tree.add(
4394 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4395 .auto_item_height(50.0),
4396 );
4397 tree.layout(SizeProposal::exact(400.0, 300.0));
4398 tree.layout(SizeProposal::exact(400.0, 300.0));
4399
4400 let spans = item_spans(&tree, lv_id);
4401 assert!(
4402 (spans[1].0 - 30.0).abs() < 0.01,
4403 "row 1 should sit at measured 30, got {}",
4404 spans[1].0
4405 );
4406 assert!((spans[1].1 - 30.0).abs() < 0.01);
4407 }
4408
4409 #[test]
4410 fn auto_measure_under_realization_converges() {
4411 let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
4415 let mut tree = WidgetTree::new();
4416 let lv_id = tree.add(
4417 ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
4418 .auto_item_height(100.0),
4419 );
4420 for _ in 0..6 {
4422 tree.layout(SizeProposal::exact(400.0, 300.0));
4423 }
4424
4425 let spans = item_spans(&tree, lv_id);
4426 let mut expected_y = spans[0].0;
4428 for (y, h) in &spans {
4429 assert!(
4430 (y - expected_y).abs() < 0.01,
4431 "rows must tile contiguously: expected y {expected_y}, got {y}"
4432 );
4433 expected_y = y + h;
4434 }
4435 let last_bottom = spans.last().map(|(y, h)| y + h).unwrap();
4437 assert!(
4438 last_bottom >= 300.0,
4439 "realized rows must cover the viewport bottom, got {last_bottom}"
4440 );
4441 }
4442
4443 #[test]
4444 fn auto_measure_append_preserves_measured_prefix() {
4445 let model = ListModel::from_vec((0..4).collect::<Vec<usize>>());
4446 let mut tree = WidgetTree::new();
4447 let lv_id = tree.add(
4448 ListView::new(model.clone(), |_i, _item, _sel| {
4449 Box::new(FixedLeaf(100.0, 30.0))
4450 })
4451 .auto_item_height(50.0),
4452 );
4453 tree.layout(SizeProposal::exact(400.0, 300.0));
4454 tree.layout(SizeProposal::exact(400.0, 300.0));
4455
4456 model.push(99);
4460 tree.layout(SizeProposal::exact(400.0, 300.0));
4461 let spans = item_spans(&tree, lv_id);
4462 assert_eq!(spans.len(), 5);
4463 assert!(
4464 (spans[1].0 - 30.0).abs() < 0.01,
4465 "measured prefix must survive an append, got y {}",
4466 spans[1].0
4467 );
4468 }
4469
4470 #[test]
4471 fn scrollbar_reservation_self_corrects_after_auto_measure_flips_the_decision() {
4472 let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
4495 let mut tree = WidgetTree::new();
4496 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 40.0)))
4497 .auto_item_height(20.0);
4498 let scroll_y = lv.scroll_y_signal().clone();
4499 let lv_id = tree.add(lv);
4500
4501 tree.layout(SizeProposal::exact(400.0, 300.0));
4502 let children = row_ids(&tree, lv_id);
4503 let item0_frame1 = tree.bounds(children[0]).width;
4504 let sb_frame1 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
4505 assert!(
4506 (item0_frame1 - 400.0).abs() < 0.01,
4507 "frame 1 uses the pre-measure (no-scrollbar) decision, got width {item0_frame1}"
4508 );
4509 assert!(
4510 sb_frame1 < 0.01,
4511 "frame 1's scrollbar is still collapsed from the same stale decision, got {sb_frame1}"
4512 );
4513
4514 scroll_y.set(0.0);
4518 tree.layout(SizeProposal::exact(400.0, 300.0));
4519 let children = row_ids(&tree, lv_id);
4520 let item0_frame2 = tree.bounds(children[0]).width;
4521 let sb_frame2 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
4522 assert!(
4523 (item0_frame2 - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
4524 "frame 2 must self-correct to the measured (needs-scrollbar) width, got {item0_frame2}"
4525 );
4526 assert!(
4527 (sb_frame2 - SCROLLBAR_THICKNESS).abs() < 0.01,
4528 "frame 2's scrollbar must appear once the measured total is known, got {sb_frame2}"
4529 );
4530 }
4531
4532 #[test]
4533 fn ensure_index_visible_with_variable_heights() {
4534 let model = ListModel::from_vec((0..100).collect::<Vec<usize>>());
4535 let heights = |i: usize| 20.0 + (i % 3) as f32 * 20.0; let mut tree = WidgetTree::new();
4537 let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4538 .item_height_fn(heights);
4539 let scroll = lv.scroll_y_signal().clone();
4540 let lv_id = tree.add(lv);
4541 tree.layout(SizeProposal::exact(400.0, 300.0));
4542
4543 let top_20: f32 = (0..20).map(heights).sum();
4546 let bottom_20 = top_20 + heights(20);
4547
4548 tree.widget_as_any(lv_id)
4549 .and_then(|any| any.downcast_ref::<ListView<usize>>())
4550 .expect("ListView exposes itself via as_any")
4551 .ensure_index_visible(20);
4552 assert!(
4555 (scroll.get() - (bottom_20 - 300.0)).abs() < 0.5,
4556 "scroll {} != bottom {} - viewport",
4557 scroll.get(),
4558 bottom_20
4559 );
4560 }
4561
4562 #[test]
4563 fn drag_insertion_with_variable_heights() {
4564 let model = ListModel::from_vec(vec![10_usize, 20, 30, 40, 50]);
4568 let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
4569 let mut tree = WidgetTree::new();
4570 let lv_id = tree.add(
4571 ListView::new(model.clone(), |_i, _item, _sel| {
4572 Box::new(FixedLeaf(100.0, 30.0))
4573 })
4574 .item_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
4575 .reorderable(true),
4576 );
4577 tree.layout(SizeProposal::exact(400.0, 300.0));
4578
4579 let children = row_ids(&tree, lv_id);
4581 let from = tree.bounds(children[4]).center();
4582 drag_item(&mut tree, from, Point::new(from.x, 35.0));
4583
4584 assert_eq!(model.with_item(1, |v| *v), Some(50));
4586 assert_eq!(model.with_item(2, |v| *v), Some(20));
4587 }
4588
4589 #[allow(clippy::type_complexity)]
4592 type Captured = Rc<RefCell<Option<(Vec<usize>, Option<Vec<usize>>)>>>;
4593
4594 fn export_scene(
4598 values: Vec<usize>,
4599 mode: DragTransferMode,
4600 ) -> (WidgetTree, ListModel<usize>, SelectionModel, Captured) {
4601 use crate::primitives::{FixedSize, VStack};
4602 use teksilo_core::widget_builder::WidgetBuilder as _;
4603 let model = ListModel::from_vec(values);
4604 let sel = SelectionModel::new(teksilo_data::SelectionMode::Multi);
4605 let cap: Captured = Rc::new(RefCell::new(None));
4606 let cap2 = cap.clone();
4607 let lv = ListView::new(model.clone(), |_i, _item, _s| {
4608 Box::new(FixedLeaf(180.0, 30.0))
4609 })
4610 .item_height(30.0)
4611 .selection(sel.clone())
4612 .exportable(mode);
4613 let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
4614 if let Some(rd) = payload.take_typed::<RowDragData<usize>>() {
4615 *cap2.borrow_mut() = Some((rd.rows, rd.items));
4616 true
4617 } else {
4618 false
4619 }
4620 });
4621 let mut tree = WidgetTree::new();
4622 tree.add(
4623 VStack::new()
4624 .spacing(0.0)
4625 .child(FixedSize::new().height(120.0).child(lv))
4626 .child(sink),
4627 );
4628 tree.layout(SizeProposal::exact(200.0, 300.0));
4629 (tree, model, sel, cap)
4630 }
4631
4632 #[test]
4633 fn exportable_row_drops_on_foreign_sink_with_items() {
4634 let (mut tree, _model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
4635 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4636 let (rows, items) = cap.borrow().clone().expect("sink received a RowDragData");
4637 assert_eq!(rows, vec![0]);
4638 assert_eq!(items, Some(vec![10]));
4639 }
4640
4641 #[test]
4642 fn exportable_move_removes_source_row_after_foreign_accept() {
4643 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Move);
4644 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4645 assert!(cap.borrow().is_some(), "sink accepted the drop");
4646 assert_eq!(model.len(), 2);
4648 assert_eq!(model.with_item(0, |v| *v), Some(20));
4649 }
4650
4651 #[test]
4652 fn exportable_copy_leaves_source_intact() {
4653 let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
4654 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4655 assert!(cap.borrow().is_some());
4656 assert_eq!(model.len(), 3);
4657 assert_eq!(model.with_item(0, |v| *v), Some(10));
4658 }
4659
4660 #[test]
4661 fn exportable_multi_selection_drags_the_whole_set() {
4662 let (mut tree, _model, sel, cap) =
4663 export_scene(vec![10, 20, 30, 40], DragTransferMode::Copy);
4664 sel.select_indices([0_usize, 2_usize], false);
4666 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4667 let (rows, items) = cap.borrow().clone().expect("received");
4668 assert_eq!(rows, vec![0, 2]);
4669 assert_eq!(items, Some(vec![10, 30]));
4670 }
4671
4672 #[test]
4673 fn reorder_only_view_is_not_exportable() {
4674 use crate::primitives::{FixedSize, VStack};
4677 use teksilo_core::widget_builder::WidgetBuilder as _;
4678 let model: ListModel<usize> = ListModel::from_vec(vec![1, 2, 3]);
4679 let is_export: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
4680 let probe = is_export.clone();
4681 let lv = ListView::new(model.clone(), |_i, _it, _s| {
4682 Box::new(FixedLeaf(180.0, 30.0))
4683 })
4684 .item_height(30.0)
4685 .selection(SelectionModel::new(teksilo_data::SelectionMode::Single))
4686 .reorderable(true);
4687 let sink = FixedLeaf(180.0, 80.0).on_drop(move |payload, _pos, _ctx| {
4688 probe.set(
4689 payload
4690 .get_typed::<RowDragData<usize>>()
4691 .map(|rd| rd.is_export()),
4692 );
4693 true
4694 });
4695 let mut tree = WidgetTree::new();
4696 tree.add(
4697 VStack::new()
4698 .spacing(0.0)
4699 .child(FixedSize::new().height(120.0).child(lv))
4700 .child(sink),
4701 );
4702 tree.layout(SizeProposal::exact(200.0, 300.0));
4703 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4704 assert_eq!(
4707 is_export.get(),
4708 Some(false),
4709 "reorder-only payload is not an export"
4710 );
4711 }
4712
4713 #[test]
4720 fn row_composite_tooltip_opens_for_the_hovered_row() {
4721 use crate::primitives::TextWidget;
4722 use std::time::Duration;
4723 use teksilo_i18n::lit;
4724
4725 let model = ListModel::from_vec(vec![
4726 "Alpha".to_string(),
4727 "Beta".to_string(),
4728 "Gamma".to_string(),
4729 ]);
4730 let mut tree = WidgetTree::new().with_text_backend(std::rc::Rc::new(
4731 std::cell::RefCell::new(teksilo_canvas::MockTextBackend::new()),
4732 ));
4733 let lv =
4734 tree.add(
4735 ListView::new(model, |_i, _it, _s| Box::new(FixedLeaf(180.0, 20.0)))
4736 .item_height(20.0)
4737 .row_composite_tooltip(|_i, item: &String| {
4738 Some(Box::new(TextWidget::new(lit!(format!("about {item}"))))
4739 as Box<dyn Widget>)
4740 }),
4741 );
4742 tree.layout(SizeProposal::exact(400.0, 200.0));
4743 assert!(tree.active_overlays().is_empty());
4744
4745 let bounds = tree.bounds(lv);
4747 tree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 30.0));
4748 tree.advance_time(Duration::from_millis(750));
4749
4750 assert_eq!(tree.active_overlays().len(), 1);
4751 assert!(
4752 tree.find_by_label("about Beta").is_some(),
4753 "the tip must carry the hovered row's own content"
4754 );
4755 }
4756
4757 #[test]
4758 fn accept_foreign_rows_receives_from_another_view() {
4759 use crate::primitives::{FixedSize, VStack};
4760 let a = ListModel::from_vec(vec![10, 20, 30]);
4762 let b = ListModel::from_vec(vec![100, 200]);
4763 let b_recv = b.clone();
4764 let lv_a = ListView::new(a.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4765 .item_height(30.0)
4766 .exportable(DragTransferMode::Move);
4767 let lv_b = ListView::new(b.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4768 .item_height(30.0)
4769 .accept_foreign_rows(true)
4770 .on_rows_received(move |items, at, _ctx| {
4771 for (k, v) in items.into_iter().enumerate() {
4772 b_recv.insert(at + k, v);
4773 }
4774 });
4775 let mut tree = WidgetTree::new();
4776 tree.add(
4777 VStack::new()
4778 .spacing(0.0)
4779 .child(FixedSize::new().height(90.0).child(lv_a))
4780 .child(FixedSize::new().height(150.0).child(lv_b))
4781 .child(FixedLeaf(180.0, 10.0)),
4782 );
4783 tree.layout(SizeProposal::exact(200.0, 300.0));
4784 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4786 assert_eq!(b.len(), 3, "receiver B gained the dragged row");
4788 assert!(
4789 (0..b.len()).any(|i| b.with_item(i, |v| *v) == Some(10)),
4790 "B contains the moved value 10"
4791 );
4792 assert_eq!(a.len(), 2, "source A removed the moved row");
4793 assert!(
4794 (0..a.len()).all(|i| a.with_item(i, |v| *v) != Some(10)),
4795 "A no longer contains 10"
4796 );
4797 }
4798
4799 #[test]
4800 fn two_views_over_same_model_do_not_spuriously_reorder() {
4801 use crate::primitives::{FixedSize, VStack};
4802 let model = ListModel::from_vec(vec![10, 20, 30]);
4806 let lv_a = ListView::new(model.clone(), |_i, _it, _s| {
4807 Box::new(FixedLeaf(180.0, 30.0))
4808 })
4809 .item_height(30.0)
4810 .reorderable(true);
4811 let lv_b = ListView::new(model.clone(), |_i, _it, _s| {
4812 Box::new(FixedLeaf(180.0, 30.0))
4813 })
4814 .item_height(30.0)
4815 .reorderable(true);
4816 let mut tree = WidgetTree::new();
4817 tree.add(
4818 VStack::new()
4819 .spacing(0.0)
4820 .child(FixedSize::new().height(90.0).child(lv_a))
4821 .child(FixedSize::new().height(150.0).child(lv_b))
4822 .child(FixedLeaf(180.0, 10.0)),
4823 );
4824 tree.layout(SizeProposal::exact(200.0, 300.0));
4825 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4826 assert_eq!(model.with_item(0, |v| *v), Some(10));
4828 assert_eq!(model.with_item(1, |v| *v), Some(20));
4829 assert_eq!(model.with_item(2, |v| *v), Some(30));
4830 }
4831
4832 #[test]
4833 fn exportable_not_reorderable_does_not_reorder_on_same_view_drop() {
4834 use crate::primitives::{FixedSize, VStack};
4835 let model: ListModel<usize> = ListModel::from_vec(vec![10, 20, 30, 40]);
4839 let lv = ListView::new(model.clone(), |_i, _it, _s| {
4840 Box::new(FixedLeaf(180.0, 30.0))
4841 })
4842 .item_height(30.0)
4843 .exportable(DragTransferMode::Move)
4844 .accept_foreign_rows(true)
4845 .on_rows_received(|_items, _at, _ctx| {});
4846 let mut tree = WidgetTree::new();
4847 tree.add(
4848 VStack::new()
4849 .spacing(0.0)
4850 .child(FixedSize::new().height(200.0).child(lv))
4851 .child(FixedLeaf(180.0, 10.0)),
4852 );
4853 tree.layout(SizeProposal::exact(200.0, 300.0));
4854 drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 75.0));
4856 assert_eq!(model.with_item(0, |v| *v), Some(10));
4858 assert_eq!(model.with_item(3, |v| *v), Some(40));
4859 }
4860
4861 #[test]
4865 fn advertises_focus_so_assistive_tech_can_focus_the_list() {
4866 let (mut tree, lv_id, _model) = make_list_view(20, 20.0);
4867 tree.layout(SizeProposal::exact(400.0, 200.0));
4868
4869 let info = tree.accessibility_node(lv_id);
4870 assert_eq!(info.role(), teksilo_core::accesskit::Role::ListBox);
4871 assert!(
4872 info.actions()
4873 .contains(&teksilo_core::accesskit::Action::Focus),
4874 "ListView must advertise Action::Focus; without it no screen reader \
4875 can move focus into the list"
4876 );
4877
4878 let mut ops = teksilo_core::window::NoopWindowOps;
4880 let handled = tree.dispatch_access_action(
4881 teksilo_core::accessibility::widget_id_to_node_id(lv_id),
4882 teksilo_core::accesskit::Action::Focus,
4883 None,
4884 &mut ops,
4885 );
4886 assert!(handled, "the Focus action must be serviced");
4887 assert_eq!(tree.focused(), Some(lv_id));
4888 }
4889}
4890
4891#[cfg(test)]
4892mod checkbox_keyboard_tests {
4893 use super::tests::FixedLeaf;
4894 use super::*;
4895 use teksilo_core::WidgetTree;
4896 use teksilo_core::event::{Key, Modifiers};
4897 use teksilo_data::{CheckedModel, SelectionMode, SelectionModel};
4898 use teksilo_i18n::lit;
4899
4900 fn checked_list() -> (WidgetTree, SelectionModel, CheckedModel, SizeProposal) {
4902 let model = ListModel::from_vec((0..200usize).collect());
4903 let checks = CheckedModel::new();
4904 let selection = SelectionModel::new(SelectionMode::Multi);
4905 let (sel, ck) = (selection.clone(), checks.clone());
4906 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4907 let lv = tree.add(
4908 ListView::new(model, move |i, item, selected| {
4909 Box::new(
4910 crate::StandardListItem::new(lit!(format!("Row {item}")))
4911 .selected(selected)
4912 .checkbox(ck.signal_for(i)),
4913 )
4914 })
4915 .item_height(24.0)
4916 .selection(sel),
4917 );
4918 let p = SizeProposal::exact(400.0, 240.0);
4919 tree.layout(p);
4920 tree.focus(lv);
4921 (tree, selection, checks, p)
4922 }
4923
4924 #[test]
4925 fn a_list_is_one_tab_stop_however_many_rows_are_realized() {
4926 let (mut tree, _sel, _ck, p) = checked_list();
4931 let mut seen = std::collections::BTreeSet::new();
4932 for _ in 0..40 {
4933 tree.press_key(Key::Tab, Modifiers::NONE);
4934 tree.layout(p);
4935 seen.insert(tree.focused());
4936 }
4937 assert_eq!(
4938 seen.len(),
4939 1,
4940 "Tab must not walk into the rows; got {} distinct stops",
4941 seen.len()
4942 );
4943 }
4944
4945 #[test]
4946 fn space_checks_the_focused_row_and_ctrl_space_still_selects() {
4947 let (mut tree, sel, checks, p) = checked_list();
4948 sel.select(3);
4949 tree.layout(p);
4950
4951 tree.press_key(Key::Space, Modifiers::NONE);
4954 tree.layout(p);
4955 assert!(checks.signal_for(3).get(), "Space checks the focused row");
4956 assert_eq!(sel.selected_indices(), vec![3], "and leaves the selection");
4957
4958 tree.press_key(Key::Space, Modifiers::NONE);
4959 tree.layout(p);
4960 assert!(!checks.signal_for(3).get(), "and unchecks it again");
4961
4962 tree.press_key(Key::Space, Modifiers::CTRL);
4964 tree.layout(p);
4965 assert_eq!(sel.selected_indices(), Vec::<usize>::new());
4966 assert!(!checks.signal_for(3).get(), "the check is untouched");
4967 }
4968
4969 #[test]
4970 fn a_row_without_a_checkbox_keeps_space_on_the_selection() {
4971 let model = ListModel::from_vec((0..20usize).collect());
4972 let selection = SelectionModel::new(SelectionMode::Multi);
4973 let sel = selection.clone();
4974 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4975 let lv = tree.add(
4976 ListView::new(model, move |_i, _item, _s| Box::new(FixedLeaf(100.0, 24.0)))
4977 .item_height(24.0)
4978 .selection(sel),
4979 );
4980 let p = SizeProposal::exact(400.0, 240.0);
4981 tree.layout(p);
4982 tree.focus(lv);
4983 selection.select(2);
4984
4985 tree.press_key(Key::Space, Modifiers::NONE);
4986 tree.layout(p);
4987 assert_eq!(
4988 selection.selected_indices(),
4989 Vec::<usize>::new(),
4990 "no checkbox to publish a toggle, so Space is still the selection"
4991 );
4992 }
4993}