1pub(crate) mod a11y;
29pub(crate) mod body_pane;
30pub(crate) mod drag;
31pub(crate) mod keyboard;
32pub mod layout;
33pub mod sections;
34pub(crate) mod selection;
35#[cfg(test)]
36mod tests;
37
38use std::cell::Cell;
39use std::collections::BTreeSet;
40use std::rc::Rc;
41
42use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
44use teksilo_core::binding::BindingLevel;
45use teksilo_core::build_context::BuildContext;
46use teksilo_core::drag_payload::DragPayload;
47use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
48use teksilo_core::signal::{Prop, Signal};
49use teksilo_core::styles::GridViewStyle;
50use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
51use teksilo_core::widget_builder::HandlerSet;
52use teksilo_core::widget_id::WidgetId;
53use teksilo_data::{
54 DataChange, DropPosition, DropResponse, ListModel, SelectionMode, SelectionModel,
55};
56use teksilo_tokens::{Easing, SurfaceRole};
57
58use std::time::Duration;
59
60use crate::common::scroll::OverscrollBehavior;
61use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
62use crate::list_source::ListSource;
63use crate::primitives::TextWidget;
64use crate::scroll_area::ScrollBarMode;
65use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
66
67use body_pane::{GridBodyPane, TileDelegate};
68use keyboard::{GridKeyConfig, build_grid_key_handler};
69use layout::masonry::VirtualizedMasonry;
70use layout::sectioned::SectionedGrid;
71use layout::strategy::{GridLayoutStrategy, TileRect};
72use layout::uniform::UniformGrid;
73use layout::variable_row::VariableRowGrid;
74use sections::{SectionData, SectionProvider};
75use selection::{MarqueeConfig, MarqueeState, build_marquee_handler};
76
77pub use sections::{GroupingSections, SectionProvider as GridSectionProvider, grouping_sections};
78
79#[derive(Debug, Clone, Copy)]
81enum StrategyKind {
82 Uniform,
84 VariableRow { estimated: f32 },
86 Waterfall { estimated: f32 },
88}
89
90pub use keyboard::GridTabTraversal;
91pub use layout::{GridSizing, ScrollAnchor};
92
93type CanAcceptFn = Rc<dyn Fn(&DragPayload, usize, DropPosition, ViewId) -> DropResponse>;
95
96fn drop_allowed<T: 'static>(
101 can_accept: &CanAcceptFn,
102 payload: &DragPayload,
103 idx: usize,
104 len: usize,
105 view_id: ViewId,
106 has_drop_cb: bool,
107 export: &crate::data_views::RowExport<T>,
108) -> bool {
109 match flat_insertion_target(idx, len) {
110 Some((target, position)) => match (can_accept)(payload, target, position, view_id) {
111 DropResponse::Accept | DropResponse::Redirect(_) => true,
112 DropResponse::Reject => {
113 let foreign = is_foreign::<T>(payload, view_id);
114 foreign && (has_drop_cb || export.accepts_foreign_export(payload, view_id))
115 }
116 },
117 None => false,
118 }
119}
120
121fn is_foreign<T: 'static>(payload: &DragPayload, view_id: ViewId) -> bool {
125 payload
126 .get_typed::<RowDragData<T>>()
127 .is_none_or(|rd| rd.source != view_id)
128}
129
130const SCROLLBAR_THICKNESS: f32 = 12.0;
132
133pub struct TileContext<'a, T: 'static> {
141 pub index: usize,
143 pub row: usize,
145 pub col: usize,
147 pub item: &'a T,
149 pub is_selected: bool,
151 pub is_focused: bool,
156}
157
158pub struct GridView<T: 'static> {
160 source: ListSource<T>,
161 delegate: TileDelegate<T>,
162
163 sizing: GridSizing,
168 sizing_signal: Option<Signal<GridSizing>>,
172 col_gap: f32,
173 row_gap: f32,
174 inset: EdgeInsets,
175 strategy_kind: StrategyKind,
176 #[allow(clippy::type_complexity)]
178 exact_item_height: Option<Rc<dyn Fn(usize) -> f32>>,
179 strategy: Option<Rc<dyn GridLayoutStrategy>>,
182
183 selection: Option<SelectionModel>,
185 #[allow(clippy::type_complexity)]
186 on_selection_changed: Option<Rc<dyn Fn(&BTreeSet<usize>)>>,
187 focused_index: Signal<Option<usize>>,
188 marquee_selection: bool,
190 marquee: Signal<Option<MarqueeState>>,
191
192 wrap_navigation: bool,
194 tab_traversal: GridTabTraversal,
195
196 show_scrollbar: bool,
198 overscroll_behavior: OverscrollBehavior,
199 smooth_scrolling: bool,
202 smooth_scroll_duration: Duration,
204 scroll_bar_style: ScrollBarMode,
207 scroll_y: Signal<f32>,
208 max_scroll_y: Signal<f32>,
209 viewport_ratio_y: Signal<f32>,
210 column_count: Signal<usize>,
214
215 reorderable: bool,
217 #[allow(clippy::type_complexity)]
218 on_item_drop: Option<
219 Rc<
220 dyn Fn(
221 teksilo_core::drag_payload::DragPayload,
222 usize,
223 &mut teksilo_core::widget::EventContext,
224 ) -> bool,
225 >,
226 >,
227 insertion: Signal<Option<usize>>,
229 model_id: ViewId,
232
233 export: crate::data_views::RowExport<T>,
238
239 #[allow(clippy::type_complexity)]
241 on_tile_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
242 activate_on: crate::data_views::ActivateOn,
245 #[allow(clippy::type_complexity)]
246 tile_context_menu: Option<
247 Rc<
248 dyn Fn(
249 usize,
250 Point,
251 &mut teksilo_core::widget::EventContext,
252 ) -> Option<Box<dyn Widget>>,
253 >,
254 >,
255 type_ahead_timeout: std::time::Duration,
256 type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
262 #[allow(clippy::type_complexity)]
263 type_ahead_label: Option<Rc<dyn Fn(usize) -> String>>,
264 #[allow(clippy::type_complexity)]
268 tile_a11y_label: Option<Rc<dyn Fn(usize) -> String>>,
269
270 #[allow(clippy::type_complexity)]
272 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
273 #[allow(clippy::type_complexity)]
274 loading_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
275 is_loading: Option<Prop<bool>>,
276 loading_id: Option<WidgetId>,
277
278 section_data: Option<SectionData>,
280 #[allow(clippy::type_complexity)]
281 header_delegate: Option<Rc<dyn Fn(usize, &str) -> Box<dyn Widget>>>,
282 header_height: f32,
283 pinned_section_headers: bool,
284 current_section: Signal<usize>,
285 pinned_header_id: Option<WidgetId>,
286
287 a11y_label: Option<String>,
289 tile_map: Rc<std::cell::RefCell<Vec<(usize, WidgetId)>>>,
292
293 style: Option<Rc<dyn GridViewStyle>>,
296
297 viewport_width: Rc<Cell<f32>>,
299 viewport_height: Rc<Cell<f32>>,
300 viewport_origin: Rc<Cell<Option<Point>>>,
305 last_needs_scrollbar: Cell<bool>,
309
310 body_pane_id: Option<WidgetId>,
312 empty_id: Option<WidgetId>,
313 scrollbar_id: Option<WidgetId>,
314 overlay_id: Option<WidgetId>,
315
316 enabled: Prop<bool>,
321}
322
323impl<T: 'static> GridView<T> {
324 pub fn new(
327 model: ListModel<T>,
328 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
329 ) -> Self {
330 Self::create(ListSource::from_model(model), delegate)
331 }
332
333 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
335 source: S,
336 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
337 ) -> Self {
338 Self::create(ListSource::from_data_source(source), delegate)
339 }
340
341 fn create(
342 source: ListSource<T>,
343 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
344 ) -> Self {
345 Self {
346 source,
347 delegate: Rc::new(delegate),
348 sizing: GridSizing::Adaptive {
349 min_width: 120.0,
350 max_width: None,
351 height: 120.0,
352 },
353 sizing_signal: None,
354 col_gap: 8.0,
355 row_gap: 8.0,
356 inset: EdgeInsets::ZERO,
357 strategy_kind: StrategyKind::Uniform,
358 exact_item_height: None,
359 strategy: None,
360 selection: None,
361 on_selection_changed: None,
362 focused_index: Signal::new(None),
363 marquee_selection: true,
364 marquee: Signal::new(None),
365 wrap_navigation: false,
366 tab_traversal: GridTabTraversal::OutOfGrid,
367 show_scrollbar: true,
368 overscroll_behavior: OverscrollBehavior::default(),
369 smooth_scrolling: true,
370 smooth_scroll_duration: Duration::from_millis(150),
371 scroll_bar_style: ScrollBarMode::Permanent,
372 scroll_y: Signal::new_animated(0.0),
373 max_scroll_y: Signal::new(0.0),
374 viewport_ratio_y: Signal::new(1.0),
375 column_count: Signal::new(1),
376 reorderable: false,
377 on_item_drop: None,
378 insertion: Signal::new(None),
379 model_id: ViewId::next(ViewKind::Grid),
380 export: crate::data_views::RowExport::default(),
381 on_tile_activate: None,
382 activate_on: crate::data_views::ActivateOn::default(),
383 tile_context_menu: None,
384 type_ahead_timeout: std::time::Duration::from_millis(500),
385 type_ahead: crate::common::type_ahead::TypeAheadState::new(),
386 type_ahead_label: None,
387 tile_a11y_label: None,
388 empty_view: None,
389 loading_view: None,
390 is_loading: None,
391 loading_id: None,
392 section_data: None,
393 header_delegate: None,
394 header_height: 28.0,
395 pinned_section_headers: false,
396 current_section: Signal::new(0),
397 pinned_header_id: None,
398 a11y_label: None,
399 tile_map: Rc::new(std::cell::RefCell::new(Vec::new())),
400 style: None,
401 viewport_width: Rc::new(Cell::new(400.0)),
402 viewport_height: Rc::new(Cell::new(400.0)),
403 viewport_origin: Rc::new(Cell::new(None)),
404 last_needs_scrollbar: Cell::new(false),
405 body_pane_id: None,
406 empty_id: None,
407 scrollbar_id: None,
408 overlay_id: None,
409 enabled: Prop::Static(true),
410 }
411 }
412
413 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
416 self.enabled = enabled.into();
417 self
418 }
419
420 pub fn sizing(mut self, sizing: impl Into<Prop<GridSizing>>) -> Self {
432 let sig = sizing.into().as_signal();
433 self.sizing = sig.get();
434 self.sizing_signal = Some(sig);
435 self
436 }
437
438 pub fn tile_size(mut self, width: f32, height: f32) -> Self {
440 self.sizing = GridSizing::Fixed { width, height };
441 self.sizing_signal = None;
442 self
443 }
444
445 pub fn column_count(mut self, count: usize, tile_height: f32) -> Self {
447 self.sizing = GridSizing::FixedColumnCount {
448 count,
449 height: tile_height,
450 };
451 self.sizing_signal = None;
452 self
453 }
454
455 pub fn variable_row_heights(mut self, estimated: f32) -> Self {
461 self.strategy_kind = StrategyKind::VariableRow {
462 estimated: estimated.max(1.0),
463 };
464 self
465 }
466
467 pub fn item_height(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
472 self.exact_item_height = Some(Rc::new(f));
473 if matches!(self.strategy_kind, StrategyKind::Uniform) {
474 self.strategy_kind = StrategyKind::VariableRow {
475 estimated: self.sizing.tile_height().max(1.0),
476 };
477 }
478 self
479 }
480
481 pub fn waterfall(mut self, estimated: f32) -> Self {
487 self.strategy_kind = StrategyKind::Waterfall {
488 estimated: estimated.max(1.0),
489 };
490 self
491 }
492
493 pub fn column_spacing(mut self, spacing: f32) -> Self {
497 self.col_gap = spacing.max(0.0);
498 self
499 }
500
501 pub fn row_spacing(mut self, spacing: f32) -> Self {
503 self.row_gap = spacing.max(0.0);
504 self
505 }
506
507 pub fn spacing(mut self, spacing: f32) -> Self {
509 self.col_gap = spacing.max(0.0);
510 self.row_gap = spacing.max(0.0);
511 self
512 }
513
514 pub fn content_inset(mut self, inset: EdgeInsets) -> Self {
516 self.inset = inset;
517 self
518 }
519
520 pub fn selection(mut self, sel: SelectionModel) -> Self {
524 self.selection = Some(sel);
525 self
526 }
527
528 pub fn on_selection_changed(mut self, f: impl Fn(&BTreeSet<usize>) + 'static) -> Self {
531 self.on_selection_changed = Some(Rc::new(f));
532 self
533 }
534
535 pub fn marquee_selection(mut self, enabled: bool) -> Self {
538 self.marquee_selection = enabled;
539 self
540 }
541
542 pub fn wrap_navigation(mut self, enabled: bool) -> Self {
546 self.wrap_navigation = enabled;
547 self
548 }
549
550 pub fn tab_traversal(mut self, traversal: GridTabTraversal) -> Self {
552 self.tab_traversal = traversal;
553 self
554 }
555
556 pub fn show_scrollbar(mut self, show: bool) -> Self {
561 self.show_scrollbar = show;
562 self
563 }
564
565 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
567 self.overscroll_behavior = behavior;
568 self
569 }
570
571 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
573 self.smooth_scrolling = enabled;
574 self
575 }
576
577 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
579 self.smooth_scroll_duration = duration;
580 self
581 }
582
583 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
587 self.scroll_bar_style = style;
588 self
589 }
590
591 pub fn scroll_y_signal(&self) -> &Signal<f32> {
593 &self.scroll_y
594 }
595
596 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
598 &self.max_scroll_y
599 }
600
601 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
603 &self.viewport_ratio_y
604 }
605
606 pub fn ensure_index_visible(&self, index: usize, anchor: ScrollAnchor) {
608 let Some(ref strategy) = self.strategy else {
609 return;
610 };
611 if let Some(target) = scroll_for_ensure_visible(
612 strategy.as_ref(),
613 index,
614 self.scroll_y.get(),
615 self.viewport_height.get(),
616 self.viewport_width.get(),
617 self.max_scroll_y.get(),
618 anchor,
619 ) {
620 self.scroll_y.set(target);
621 }
622 }
623
624 pub fn scroll_to_index(&self, index: usize, anchor: ScrollAnchor) {
627 self.ensure_index_visible(index, anchor);
628 }
629
630 fn reveal_focused_tile_on_focus(
672 &self,
673 ctx: &mut BuildContext,
674 strategy: Rc<dyn GridLayoutStrategy>,
675 ) {
676 let view_focused = ctx.begin_view_focus();
683 ctx.end_view_focus();
684
685 let scroll_y = self.scroll_y.clone();
686 let max_scroll_y = self.max_scroll_y.clone();
687 let viewport_height = self.viewport_height.clone();
688 let viewport_width = self.viewport_width.clone();
689 let focused_index = self.focused_index.clone();
690 let selection = self.selection.clone();
691
692 ctx.effect(&view_focused, move |focused| {
693 if !*focused {
694 return;
695 }
696 let Some(index) = focused_index.get().or_else(|| {
697 selection
698 .as_ref()
699 .and_then(|s| s.selected_indices().first().copied())
700 }) else {
701 return;
702 };
703 if let Some(target) = scroll_for_ensure_visible(
704 strategy.as_ref(),
705 index,
706 scroll_y.get(),
707 viewport_height.get(),
708 viewport_width.get(),
709 max_scroll_y.get(),
710 ScrollAnchor::Auto,
711 ) {
712 scroll_y.set(target);
713 }
714 });
715 }
716
717 pub fn sections<P: SectionProvider>(mut self, provider: P) -> Self {
724 let provider = Rc::new(provider);
725 let counts_provider = provider.clone();
726 let title_provider = provider.clone();
727 self.section_data = Some(SectionData {
728 counts_fn: Rc::new(move || counts_provider.section_counts()),
729 title_fn: Rc::new(move |s| title_provider.section_title(s)),
730 });
731 self
732 }
733
734 pub fn section_header_delegate(
737 mut self,
738 f: impl Fn(usize, &str) -> Box<dyn Widget> + 'static,
739 ) -> Self {
740 self.header_delegate = Some(Rc::new(f));
741 self
742 }
743
744 pub fn section_header_height(mut self, height: f32) -> Self {
746 self.header_height = height.max(0.0);
747 self
748 }
749
750 pub fn pinned_section_headers(mut self, enabled: bool) -> Self {
753 self.pinned_section_headers = enabled;
754 self
755 }
756
757 pub fn a11y_label(mut self, label: impl Into<String>) -> Self {
759 self.a11y_label = Some(label.into());
760 self
761 }
762
763 pub fn style(mut self, style: impl GridViewStyle) -> Self {
767 self.style = Some(Rc::new(style));
768 self
769 }
770
771 #[allow(clippy::type_complexity)]
774 fn header_factory(&self) -> Option<Rc<dyn Fn(usize) -> Box<dyn Widget>>> {
775 let data = self.section_data.as_ref()?;
776 let title_fn = data.title_fn.clone();
777 let delegate = self.header_delegate.clone();
778 Some(Rc::new(move |section| {
779 let title = title_fn(section);
780 match &delegate {
781 Some(d) => d(section, &title),
782 None => Box::new(TextWidget::new(teksilo_i18n::lit!(title))) as Box<dyn Widget>,
783 }
784 }))
785 }
786
787 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
789 self.empty_view = Some(Rc::new(f));
790 self
791 }
792
793 pub fn loading_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
795 self.loading_view = Some(Rc::new(f));
796 self
797 }
798
799 pub fn is_loading(mut self, flag: impl Into<Prop<bool>>) -> Self {
802 self.is_loading = Some(flag.into());
803 self
804 }
805
806 pub fn reorderable(mut self, enabled: bool) -> Self {
812 self.reorderable = enabled;
813 self
814 }
815
816 pub fn exportable(mut self, mode: DragTransferMode) -> Self
832 where
833 T: Clone,
834 {
835 self.export.set_exportable(mode);
836 self
837 }
838
839 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
847 where
848 T: Clone,
849 {
850 self.export.set_export_external(f);
851 self
852 }
853
854 pub fn on_rows_transferred_out(
860 mut self,
861 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
862 ) -> Self {
863 self.export.set_on_rows_transferred_out(f);
864 self
865 }
866
867 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
874 self.export.accept_foreign_rows = accept;
875 self
876 }
877
878 pub fn on_rows_received(
882 mut self,
883 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
884 ) -> Self {
885 self.export.set_on_rows_received(f);
886 self
887 }
888
889 pub fn on_item_drop(
892 mut self,
893 f: impl Fn(
894 teksilo_core::drag_payload::DragPayload,
895 usize,
896 &mut teksilo_core::widget::EventContext,
897 ) -> bool
898 + 'static,
899 ) -> Self {
900 self.on_item_drop = Some(Rc::new(f));
901 self
902 }
903
904 pub fn on_tile_activate(
910 mut self,
911 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
912 ) -> Self {
913 self.on_tile_activate = Some(Rc::new(f));
914 self
915 }
916
917 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
921 self.activate_on = mode;
922 self
923 }
924
925 pub fn tile_context_menu(
928 mut self,
929 f: impl Fn(usize, Point, &mut teksilo_core::widget::EventContext) -> Option<Box<dyn Widget>>
930 + 'static,
931 ) -> Self {
932 self.tile_context_menu = Some(Rc::new(f));
933 self
934 }
935
936 pub fn type_ahead_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
939 self.type_ahead_label = Some(Rc::new(f));
940 self
941 }
942
943 pub fn tile_a11y_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
948 self.tile_a11y_label = Some(Rc::new(f));
949 self
950 }
951
952 pub fn type_ahead_timeout(mut self, timeout: std::time::Duration) -> Self {
954 self.type_ahead_timeout = timeout;
955 self
956 }
957
958 fn ensure_strategy(&mut self) -> Rc<dyn GridLayoutStrategy> {
963 if self.strategy.is_none() {
964 if let Some(ref data) = self.section_data {
966 let s: Rc<dyn GridLayoutStrategy> = Rc::new(SectionedGrid::new(
967 self.sizing,
968 self.col_gap,
969 self.row_gap,
970 self.inset,
971 self.header_height,
972 data.counts_fn.clone(),
973 ));
974 self.strategy = Some(s);
975 return self.strategy.as_ref().unwrap().clone();
976 }
977 let s: Rc<dyn GridLayoutStrategy> = match self.strategy_kind {
978 StrategyKind::Uniform => Rc::new(UniformGrid::new(
979 self.sizing,
980 self.col_gap,
981 self.row_gap,
982 self.inset,
983 )),
984 StrategyKind::VariableRow { estimated } => Rc::new(VariableRowGrid::new(
985 self.sizing,
986 self.col_gap,
987 self.row_gap,
988 self.inset,
989 estimated,
990 self.exact_item_height.clone(),
991 )),
992 StrategyKind::Waterfall { estimated } => Rc::new(VirtualizedMasonry::new(
993 self.sizing,
994 self.col_gap,
995 self.row_gap,
996 self.inset,
997 estimated,
998 self.exact_item_height.clone(),
999 )),
1000 };
1001 self.strategy = Some(s);
1002 }
1003 self.strategy.as_ref().unwrap().clone()
1004 }
1005}
1006
1007impl<T: 'static> std::fmt::Debug for GridView<T> {
1008 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1009 f.debug_struct("GridView")
1010 .field("items", &self.source.len())
1011 .field("scroll_bar_style", &self.scroll_bar_style)
1012 .field("scroll_y", &self.scroll_y.get())
1013 .finish()
1014 }
1015}
1016
1017fn scroll_for_ensure_visible(
1028 strategy: &dyn GridLayoutStrategy,
1029 index: usize,
1030 scroll_y: f32,
1031 viewport_height: f32,
1032 viewport_width: f32,
1033 max_scroll_y: f32,
1034 anchor: ScrollAnchor,
1035) -> Option<f32> {
1036 let delta =
1037 strategy.scroll_delta_to_reveal(index, scroll_y, viewport_height, viewport_width, anchor);
1038 if delta.abs() <= 0.01 {
1039 return None;
1040 }
1041 Some((scroll_y + delta).clamp(0.0, max_scroll_y))
1042}
1043
1044impl<T: 'static> Widget for GridView<T> {
1045 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1046 let self_id = ctx.self_id();
1047 ctx.enabled_when(self_id, self.enabled.clone());
1048
1049 if let Some(ref sig) = self.sizing_signal {
1054 sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1055 let next = sig.get();
1056 if self.sizing != next {
1057 self.sizing = next;
1058 self.strategy = None;
1059 }
1060 }
1061
1062 let strategy = self.ensure_strategy();
1063
1064 let version = ctx.signal(0_u64);
1066 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1067
1068 self.scroll_y.bind_to(
1070 ctx.self_id(),
1071 ctx.binding_registry(),
1072 BindingLevel::Relayout,
1073 );
1074 ctx.register_animated_signal(&self.scroll_y);
1075
1076 if let Some(ref sel) = self.selection {
1078 sel.selection_signal().bind_to(
1079 ctx.self_id(),
1080 ctx.binding_registry(),
1081 BindingLevel::AccessibilityOnly,
1082 );
1083 }
1084 self.focused_index.bind_to(
1085 ctx.self_id(),
1086 ctx.binding_registry(),
1087 BindingLevel::AccessibilityOnly,
1088 );
1089
1090 self.reveal_focused_tile_on_focus(ctx, strategy.clone());
1093
1094 {
1096 let v = version.clone();
1097 let counter = Rc::new(Cell::new(0_u64));
1098 let strategy_obs = strategy.clone();
1099 let selection_obs = self.selection.clone();
1100 let len_fn = self.source.len_fn.clone();
1101 let scroll_reset = self.scroll_y.clone();
1102 let focused_obs = self.focused_index.clone();
1103 let handle = (self.source.observe_fn)(Box::new(move |change| {
1104 match change {
1105 DataChange::ItemsInserted { range } => {
1106 strategy_obs.invalidate_rows(range.start..usize::MAX);
1107 strategy_obs.resize((len_fn)());
1108 if let Some(ref s) = selection_obs {
1109 s.adjust_for_insert(range.start, range.end - range.start);
1110 }
1111 }
1112 DataChange::ItemsRemoved { range } => {
1113 strategy_obs.invalidate_rows(range.start..usize::MAX);
1114 strategy_obs.resize((len_fn)());
1115 if let Some(ref s) = selection_obs {
1116 s.adjust_for_remove(range.start, range.end - range.start);
1117 }
1118 }
1119 DataChange::ItemsMoved { from, to, count } => {
1120 strategy_obs.invalidate_rows(0..usize::MAX);
1121 if let Some(ref s) = selection_obs {
1122 s.adjust_for_move(*from, *to, *count);
1123 }
1124 }
1125 DataChange::ItemUpdated { index } => {
1126 strategy_obs.invalidate_rows(*index..index + 1);
1127 }
1128 DataChange::WindowLoaded { range } => {
1129 strategy_obs.invalidate_rows(range.start..range.end);
1130 }
1131 DataChange::Reset => {
1132 strategy_obs.invalidate_rows(0..usize::MAX);
1133 strategy_obs.resize(0);
1134 if let Some(ref s) = selection_obs {
1135 s.clear();
1136 }
1137 scroll_reset.set(0.0);
1138 }
1139 }
1140 if let Some(current) = focused_obs.get() {
1147 focused_obs.set(teksilo_data::data_change::adjust_single_index_for_change(
1148 current, change,
1149 ));
1150 }
1151 let next = counter.get() + 1;
1152 counter.set(next);
1153 v.set(next);
1154 }));
1155 ctx.own_handle(handle);
1156 }
1157
1158 if let (Some(sel), Some(cb)) = (&self.selection, &self.on_selection_changed) {
1162 let cb = cb.clone();
1163 ctx.effect(&sel.selection_signal(), move |set| cb(set));
1164 }
1165
1166 if let Some(flag) = &self.is_loading {
1168 let v = version.clone();
1169 let c = Rc::new(Cell::new(0_u64));
1170 ctx.effect(&flag.as_signal(), move |_| {
1171 c.set(c.get() + 1);
1172 v.set(c.get());
1173 });
1174 }
1175
1176 let mut handlers = HandlerSet::new().clips_children(true).focusable(true);
1178 {
1179 let scroll_y = self.scroll_y.clone();
1180 let max_scroll = self.max_scroll_y.clone();
1181 let line_height = strategy.estimated_row_height().max(1.0);
1182 let overscroll = self.overscroll_behavior;
1183 let smooth_scrolling = self.smooth_scrolling;
1184 let smooth_scroll_duration = self.smooth_scroll_duration;
1185 handlers = handlers.on_scroll(move |event, _ctx| match event {
1186 WidgetEvent::Scroll { delta, .. } => {
1187 let dy = match delta {
1188 ScrollDelta::Lines { y, .. } => y * line_height,
1189 ScrollDelta::Pixels { y, .. } => *y,
1190 };
1191 let base = scroll_y.animation_target().unwrap_or(scroll_y.get());
1194 let (new_y, moved) =
1195 crate::common::scroll::scroll_clamp_axis(base, dy, max_scroll.get());
1196 if moved {
1197 if smooth_scrolling {
1198 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
1199 } else {
1200 scroll_y.set(new_y);
1201 }
1202 }
1203 crate::common::scroll::scroll_response(
1204 moved,
1205 overscroll == OverscrollBehavior::Contain,
1206 )
1207 }
1208 _ => EventResponse::Ignored,
1209 });
1210 }
1211 handlers = handlers.on_key(build_grid_key_handler(GridKeyConfig {
1212 len_fn: self.source.len_fn.clone(),
1213 col_count: self.column_count.clone(),
1214 focused_index: self.focused_index.clone(),
1215 selection: self.selection.clone(),
1216 scroll_y: self.scroll_y.clone(),
1217 max_scroll_y: self.max_scroll_y.clone(),
1218 viewport_height: self.viewport_height.clone(),
1219 viewport_width: self.viewport_width.clone(),
1220 viewport_origin: self.viewport_origin.clone(),
1221 strategy: strategy.clone(),
1222 wrap_navigation: self.wrap_navigation,
1223 tab_traversal: self.tab_traversal,
1224 on_tile_activate: self.on_tile_activate.clone(),
1225 reorderable: self.reorderable,
1226 accept_drop_fn: self.source.dnd.accept_drop_fn.clone(),
1227 view_id: self.model_id,
1228 make_reorder_payload: {
1229 let model_id = self.model_id;
1230 let stash = self.source.dnd.stash_drag_keys_fn.clone();
1231 Rc::new(move |idx| {
1232 (stash)(&[idx]);
1236 DragPayload::typed(RowDragData::<T> {
1237 source: model_id,
1238 rows: vec![idx],
1239 items: None,
1240 })
1241 })
1242 },
1243 type_ahead_timeout: self.type_ahead_timeout,
1244 type_ahead: self.type_ahead.clone(),
1245 tile_map: self.tile_map.clone(),
1246 type_ahead_label: self.type_ahead_label.as_ref().map(|label| {
1253 let label = label.clone();
1254 let with_item_str = self.source.with_item_str_fn.clone();
1255 Rc::new(move |i: usize| (with_item_str)(i, &|_item: &T| label(i)))
1256 as Rc<dyn Fn(usize) -> Option<String>>
1257 }),
1258 }));
1259
1260 let marquee_on = self.marquee_selection
1264 && self
1265 .selection
1266 .as_ref()
1267 .map(|s| s.mode() == SelectionMode::Multi)
1268 .unwrap_or(false);
1269 if marquee_on {
1270 let additive_mods = Rc::new(Cell::new(false));
1271 {
1272 let mods = additive_mods.clone();
1273 handlers = handlers.on_pointer_event(move |event, _ctx| {
1274 if let WidgetEvent::PointerDown { modifiers, .. } = event {
1275 mods.set(modifiers.command() || modifiers.shift());
1276 }
1277 EventResponse::Ignored
1278 });
1279 }
1280 handlers = handlers.on_drag(build_marquee_handler(MarqueeConfig {
1281 marquee: self.marquee.clone(),
1282 selection: self.selection.clone().unwrap(),
1283 strategy: strategy.clone(),
1284 scroll_y: self.scroll_y.clone(),
1285 viewport_width: self.viewport_width.clone(),
1286 len_fn: self.source.len_fn.clone(),
1287 additive_mods,
1288 }));
1289
1290 let frame_request = ctx.frame_request_handle();
1302 let marquee_for_tick = self.marquee.clone();
1303 let scroll_for_tick = self.scroll_y.clone();
1304 let max_scroll_for_tick = self.max_scroll_y.clone();
1305 let viewport_h_for_tick = self.viewport_height.clone();
1306 ctx.effect(&ctx.frame_tick(), move |_delta| {
1307 let Some(st) = marquee_for_tick.get() else {
1308 return;
1309 };
1310 let step =
1311 selection::marquee_auto_scroll_step(st.current.y, viewport_h_for_tick.get());
1312 if step != 0.0 {
1313 let max = max_scroll_for_tick.get();
1314 let new_y = (scroll_for_tick.get() + step).clamp(0.0, max);
1315 scroll_for_tick.set(new_y);
1316 frame_request.set(true);
1320 }
1321 });
1322 }
1323
1324 if self.export.is_drop_target(self.reorderable) || self.on_item_drop.is_some() {
1332 let has_drop_cb = self.on_item_drop.is_some();
1333 let my_id = self.model_id;
1334
1335 let strategy_h = strategy.clone();
1336 let scroll_h = self.scroll_y.clone();
1337 let vp_w_h = self.viewport_width.clone();
1338 let len_h = self.source.len_fn.clone();
1339 let can_accept_h = self.source.dnd.can_accept_fn.clone();
1340 let insertion_h = self.insertion.clone();
1341 let export_for_hover = self.export.clone();
1342 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1343 let len = (len_h)();
1344 let idx = drag::insertion_index(
1345 strategy_h.as_ref(),
1346 position,
1347 scroll_h.get(),
1348 vp_w_h.get(),
1349 len,
1350 );
1351 let allowed = drop_allowed::<T>(
1352 &can_accept_h,
1353 payload,
1354 idx,
1355 len,
1356 my_id,
1357 has_drop_cb,
1358 &export_for_hover,
1359 );
1360 if allowed {
1361 insertion_h.set(Some(idx));
1362 teksilo_core::DropFeedback::Accept
1365 } else {
1366 insertion_h.set(None);
1367 teksilo_core::DropFeedback::NoFeedback
1368 }
1369 });
1370
1371 let insertion_leave = self.insertion.clone();
1372 handlers = handlers.on_drag_leave(move |_ctx| {
1373 insertion_leave.set(None);
1374 });
1375
1376 let strategy_d = strategy.clone();
1377 let scroll_d = self.scroll_y.clone();
1378 let vp_w_d = self.viewport_width.clone();
1379 let len_d = self.source.len_fn.clone();
1380 let accept_drop_d = self.source.dnd.accept_drop_fn.clone();
1381 let drop_cb = self.on_item_drop.clone();
1382 let insertion_d = self.insertion.clone();
1383 let export_for_drop = self.export.clone();
1384 let reorderable_for_drop = self.reorderable;
1385 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1386 insertion_d.set(None);
1387 let len = (len_d)();
1388 let to = drag::insertion_index(
1389 strategy_d.as_ref(),
1390 position,
1391 scroll_d.get(),
1392 vp_w_d.get(),
1393 len,
1394 );
1395 let is_same_view = payload
1396 .get_typed::<RowDragData<T>>()
1397 .is_some_and(|rd| rd.source == my_id);
1398 if (reorderable_for_drop || !is_same_view)
1403 && let Some((target, position_kind)) = flat_insertion_target(to, len)
1404 && (accept_drop_d)(&payload, target, position_kind, my_id)
1405 {
1406 if is_same_view {
1409 export_for_drop.note_self_reorder();
1410 }
1411 return true;
1412 }
1413 if export_for_drop.foreign_receive(&mut payload, my_id, to, ctx) {
1419 return true;
1420 }
1421 if let Some(ref cb) = drop_cb {
1424 return cb(payload, to, ctx);
1425 }
1426 false
1427 });
1428 }
1429 ctx.apply_self_handlers(handlers);
1430
1431 self.body_pane_id = None;
1436 self.empty_id = None;
1437 self.scrollbar_id = None;
1438 self.overlay_id = None;
1439 self.pinned_header_id = None;
1440
1441 let len = self.source.len();
1442 if len == 0 {
1443 self.tile_map.borrow_mut().clear();
1444 if let Some(ref ef) = self.empty_view {
1445 self.empty_id = Some(ctx.add_boxed(ef()));
1446 }
1447 } else {
1448 let pane_total_refresh = ctx.signal(0_u64);
1453 pane_total_refresh.bind_to(
1454 ctx.self_id(),
1455 ctx.binding_registry(),
1456 teksilo_core::binding::BindingLevel::Relayout,
1457 );
1458 let pane = GridBodyPane {
1459 len_fn: self.source.len_fn.clone(),
1460 with_item_fn: self.source.with_item_fn.clone(),
1461 delegate: self.delegate.clone(),
1462 strategy: strategy.clone(),
1463 viewport_width: self.viewport_width.clone(),
1464 viewport_height: self.viewport_height.clone(),
1465 viewport_origin: self.viewport_origin.clone(),
1466 column_count: self.column_count.clone(),
1467 scroll_y: self.scroll_y.clone(),
1468 selection: self.selection.clone(),
1469 focused_index: self.focused_index.clone(),
1470 on_tile_activate: self.on_tile_activate.clone(),
1471 activate_on: self.activate_on,
1472 tile_context_menu: self.tile_context_menu.clone(),
1473 tile_a11y_label: self.tile_a11y_label.clone(),
1474 reorderable: self.reorderable,
1475 model_id: self.model_id,
1476 scope_owner: ctx.self_id(),
1477 drag_fn: self.source.dnd.drag_fn.clone(),
1478 row_state_fn: self.source.dnd.row_state_fn.clone(),
1479 request_window_fn: self.source.dnd.request_window_fn.clone(),
1480 can_fetch_more_fn: self.source.dnd.can_fetch_more_fn.clone(),
1481 fetch_more_fn: self.source.dnd.fetch_more_fn.clone(),
1482 export: self.export.clone(),
1483 read_item_fn: self.source.read_item_fn.clone(),
1484 snapshot_out_fn: self.source.dnd.snapshot_out_fn.clone(),
1485 tile_map: self.tile_map.clone(),
1486 header_factory: self.header_factory(),
1487 header_title: self.section_data.as_ref().map(|d| d.title_fn.clone()),
1488 version: Signal::new(0_u64),
1491 prev_built_start: Rc::new(Cell::new(0)),
1492 prev_built_end: Rc::new(Cell::new(0)),
1493 total_refresh: pane_total_refresh,
1494 tile_entries: Vec::new(),
1495 header_entries: Vec::new(),
1496 in_place_children: Cell::new(false),
1497 };
1498 self.body_pane_id = Some(ctx.add(pane));
1499
1500 let overlay = GridOverlay {
1501 focused_index: self.focused_index.clone(),
1502 view_focused: ctx.view_focus_active(),
1506 focus_visible: ctx.focus_visible(),
1507 selection: self.selection.clone(),
1508 scroll_y: self.scroll_y.clone(),
1509 strategy: strategy.clone(),
1510 viewport_width: self.viewport_width.clone(),
1511 marquee: self.marquee.clone(),
1512 insertion: self.insertion.clone(),
1513 style: self.style.clone(),
1514 len_fn: self.source.len_fn.clone(),
1515 };
1516 self.overlay_id = Some(ctx.add(overlay));
1517
1518 self.pinned_header_id = None;
1525 let section_count = self
1526 .section_data
1527 .as_ref()
1528 .map(|d| (d.counts_fn)().len())
1529 .unwrap_or(0);
1530 if self.pinned_section_headers && section_count > 0 {
1531 if let Some(factory) = self.header_factory() {
1532 let ph = PinnedHeader {
1533 current_section: self.current_section.clone(),
1534 factory,
1535 child: None,
1536 style: self.style.clone(),
1537 };
1538 self.pinned_header_id = Some(ctx.add(ph));
1539 }
1540 }
1541 }
1542
1543 if self.show_scrollbar {
1544 let sb = ScrollBar::new(
1545 ScrollBarOrientation::Vertical,
1546 self.scroll_y.clone(),
1547 self.max_scroll_y.clone(),
1548 self.viewport_ratio_y.clone(),
1549 )
1550 .visual(match self.scroll_bar_style {
1551 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1552 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1553 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1554 });
1555 self.scrollbar_id = Some(ctx.add(sb));
1556 }
1557
1558 self.loading_id = None;
1560 if let Some(flag) = &self.is_loading {
1561 if flag.get() {
1562 if let Some(ref lv) = self.loading_view {
1563 self.loading_id = Some(ctx.add_boxed(lv()));
1564 }
1565 }
1566 }
1567
1568 let mut children = Vec::new();
1570 if let Some(id) = self.body_pane_id {
1571 children.push(id);
1572 }
1573 if let Some(id) = self.empty_id {
1574 children.push(id);
1575 }
1576 if let Some(id) = self.scrollbar_id {
1577 children.push(id);
1578 }
1579 if let Some(id) = self.overlay_id {
1580 children.push(id);
1581 }
1582 if let Some(id) = self.pinned_header_id {
1583 children.push(id);
1584 }
1585 if let Some(id) = self.loading_id {
1586 children.push(id);
1587 }
1588 children
1589 }
1590
1591 fn layout_response(
1592 &self,
1593 proposal: SizeProposal,
1594 _ctx: &LayoutContext,
1595 ) -> teksilo_core::widget::LayoutResponse {
1596 let size = crate::common::viewport::viewport_size(
1600 proposal,
1601 &self.viewport_height,
1602 Size::new(400.0, 400.0),
1603 );
1604 if proposal.width.is_some() {
1605 self.viewport_width.set(size.width);
1606 }
1607 size.into()
1608 }
1609
1610 fn place_children(
1611 &self,
1612 bounds: Rect,
1613 _proposal: SizeProposal,
1614 children: &mut [WidgetPlacement],
1615 _ctx: &LayoutContext,
1616 ) {
1617 let Some(ref strategy) = self.strategy else {
1618 return;
1619 };
1620 let len = self.source.len();
1621 let vp_h = bounds.height;
1622
1623 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1631 let body_w = if self.last_needs_scrollbar.get() && reserves_bar {
1632 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1633 } else {
1634 bounds.width
1635 };
1636 self.viewport_width.set(body_w);
1637
1638 let cols = strategy.column_count(body_w).max(1);
1639 if self.column_count.get() != cols {
1640 self.column_count.set(cols);
1641 }
1642
1643 let total = strategy.total_content_height(len, body_w);
1644 let needs_sb = self.show_scrollbar && total > vp_h + 0.5;
1645 if self.last_needs_scrollbar.get() != needs_sb {
1646 self.last_needs_scrollbar.set(needs_sb);
1647 }
1648 let max_y = (total - vp_h).max(0.0);
1649 self.max_scroll_y.set(max_y);
1650 let ratio = if total > 0.0 {
1651 (vp_h / total).clamp(0.0, 1.0)
1652 } else {
1653 1.0
1654 };
1655 self.viewport_ratio_y.set(ratio);
1656 let cur = self.scroll_y.get();
1658 let clamped = cur.clamp(0.0, max_y);
1659 if (clamped - cur).abs() > 0.001 {
1660 self.scroll_y.set(clamped);
1661 }
1662
1663 let pinned_rect = if self.pinned_header_id.is_some() {
1666 let cur = strategy.current_section(self.scroll_y.get(), body_w);
1667 if let Some(cur) = cur {
1668 if self.current_section.get() != cur {
1669 self.current_section.set(cur);
1670 }
1671 strategy.header_rect(cur, body_w).map(|r| {
1673 let screen_y = bounds.y + r.y - self.scroll_y.get();
1674 let visible = screen_y < bounds.y - 0.5;
1675 (visible, r.height)
1676 })
1677 } else {
1678 None
1679 }
1680 } else {
1681 None
1682 };
1683
1684 let body_rect_origin = bounds.origin();
1685 let body_size = Size::new(body_w, vp_h);
1686 for child in children.iter_mut() {
1687 if Some(child.id) == self.scrollbar_id {
1688 if needs_sb {
1689 child.origin =
1692 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1693 child.size = Size::new(SCROLLBAR_THICKNESS, vp_h);
1694 } else {
1695 child.origin = bounds.origin();
1696 child.size = Size::ZERO;
1697 }
1698 } else if Some(child.id) == self.pinned_header_id {
1699 match pinned_rect {
1700 Some((true, h)) => {
1701 child.origin = bounds.origin();
1702 child.size = Size::new(body_w, h);
1703 }
1704 _ => {
1705 child.origin = bounds.origin();
1706 child.size = Size::ZERO;
1707 }
1708 }
1709 } else {
1710 child.origin = body_rect_origin;
1712 child.size = body_size;
1713 }
1714 }
1715 }
1716
1717 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1718 builder.set_role(teksilo_core::accesskit::Role::Grid);
1719 if let Some(ref label) = self.a11y_label {
1720 builder.set_name(label.clone());
1721 }
1722
1723 let total = self.source.len();
1724 let cols = self.column_count.get().max(1);
1725 let rows = total.div_ceil(cols);
1726 builder.set_row_count(rows);
1727 builder.set_column_count(cols);
1728 if total > 0 {
1733 builder.set_size_of_set(total);
1734 }
1735
1736 if let Some(ref sel) = self.selection {
1737 if sel.mode() == SelectionMode::Multi {
1738 builder.set_multiselectable(true);
1739 }
1740 let count = sel.count();
1741 if count > 0 {
1742 builder.set_value(format!(
1743 "{} item{} selected",
1744 count,
1745 if count == 1 { "" } else { "s" }
1746 ));
1747 }
1748 builder.set_live(teksilo_core::accesskit::Live::Polite);
1749 }
1750
1751 if let Some(idx) = self.focused_index.get() {
1753 let map = self.tile_map.borrow();
1754 if let Some((_, tile_id)) = map.iter().find(|(i, _)| *i == idx) {
1755 builder.set_active_descendant(widget_id_to_node_id(*tile_id));
1756 }
1757 }
1758 }
1759
1760 fn context_menu_key_target(&self) -> Option<WidgetId> {
1773 let index = self.focused_index.get().or_else(|| {
1774 self.selection
1775 .as_ref()
1776 .and_then(|s| s.selected_indices().first().copied())
1777 })?;
1778 let map = self.tile_map.borrow();
1779 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
1780 }
1781
1782 fn as_any(&self) -> Option<&dyn std::any::Any> {
1783 Some(self)
1784 }
1785
1786 fn children(&self) -> Vec<WidgetId> {
1787 let mut ids = Vec::new();
1788 if let Some(id) = self.body_pane_id {
1789 ids.push(id);
1790 }
1791 if let Some(id) = self.empty_id {
1792 ids.push(id);
1793 }
1794 if let Some(id) = self.scrollbar_id {
1795 ids.push(id);
1796 }
1797 if let Some(id) = self.overlay_id {
1798 ids.push(id);
1799 }
1800 if let Some(id) = self.pinned_header_id {
1801 ids.push(id);
1802 }
1803 if let Some(id) = self.loading_id {
1804 ids.push(id);
1805 }
1806 ids
1807 }
1808
1809 fn clips_children(&self) -> bool {
1810 true
1811 }
1812}
1813
1814struct GridOverlay {
1819 focused_index: Signal<Option<usize>>,
1820 view_focused: Signal<bool>,
1824 focus_visible: Signal<bool>,
1827 selection: Option<SelectionModel>,
1831 scroll_y: Signal<f32>,
1832 strategy: Rc<dyn GridLayoutStrategy>,
1833 viewport_width: Rc<Cell<f32>>,
1834 marquee: Signal<Option<MarqueeState>>,
1835 insertion: Signal<Option<usize>>,
1836 style: Option<Rc<dyn GridViewStyle>>,
1837 len_fn: Rc<dyn Fn() -> usize>,
1843}
1844
1845impl std::fmt::Debug for GridOverlay {
1846 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1847 f.debug_struct("GridOverlay").finish()
1848 }
1849}
1850
1851impl GridOverlay {
1852 fn focus_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridFocusRingRecipe {
1853 resolve_grid_style(&self.style, ctx, |s| s.focus_ring())
1854 }
1855 fn marquee_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridMarqueeRecipe {
1856 resolve_grid_style(&self.style, ctx, |s| s.marquee())
1857 }
1858 fn insertion_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridInsertionRecipe {
1859 resolve_grid_style(&self.style, ctx, |s| s.insertion())
1860 }
1861}
1862
1863fn insertion_bar_geometry(
1871 strategy: &dyn GridLayoutStrategy,
1872 ins: usize,
1873 len: usize,
1874 viewport_width: f32,
1875) -> Option<(f32, TileRect)> {
1876 if len == 0 {
1877 return None;
1878 }
1879 if ins < len {
1880 let r = strategy.tile_rect(ins, viewport_width);
1881 Some((r.x, r))
1882 } else {
1883 let r = strategy.tile_rect(len - 1, viewport_width);
1884 Some((r.x + r.width, r))
1885 }
1886}
1887
1888fn resolve_grid_style<R: Default>(
1891 override_style: &Option<Rc<dyn GridViewStyle>>,
1892 ctx: &PaintContext,
1893 f: impl Fn(&dyn GridViewStyle) -> R,
1894) -> R {
1895 if let Some(s) = override_style {
1896 f(s.as_ref())
1897 } else if let Some(s) = ctx.theme.style_slots.grid_view.as_ref() {
1898 f(s.as_ref())
1899 } else {
1900 R::default()
1901 }
1902}
1903
1904impl Widget for GridOverlay {
1905 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1906 self.scroll_y.bind_to(
1908 ctx.self_id(),
1909 ctx.binding_registry(),
1910 BindingLevel::RepaintOnly,
1911 );
1912 self.focused_index.bind_to(
1913 ctx.self_id(),
1914 ctx.binding_registry(),
1915 BindingLevel::RepaintOnly,
1916 );
1917 self.view_focused.bind_to(
1918 ctx.self_id(),
1919 ctx.binding_registry(),
1920 BindingLevel::RepaintOnly,
1921 );
1922 self.focus_visible.bind_to(
1923 ctx.self_id(),
1924 ctx.binding_registry(),
1925 BindingLevel::RepaintOnly,
1926 );
1927 if let Some(ref sel) = self.selection {
1928 sel.selection_signal().bind_to(
1929 ctx.self_id(),
1930 ctx.binding_registry(),
1931 BindingLevel::RepaintOnly,
1932 );
1933 }
1934 self.marquee.bind_to(
1935 ctx.self_id(),
1936 ctx.binding_registry(),
1937 BindingLevel::RepaintOnly,
1938 );
1939 self.insertion.bind_to(
1940 ctx.self_id(),
1941 ctx.binding_registry(),
1942 BindingLevel::RepaintOnly,
1943 );
1944 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1946 Vec::new()
1947 }
1948
1949 fn layout_response(
1950 &self,
1951 proposal: SizeProposal,
1952 _ctx: &LayoutContext,
1953 ) -> teksilo_core::widget::LayoutResponse {
1954 proposal.resolve(0.0, 0.0).into()
1955 }
1956
1957 fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
1958 if let Some(m) = self.marquee.get() {
1960 let lr = m.local_rect(self.scroll_y.get());
1961 let rect = Rect::new(bounds.x + lr.x, bounds.y + lr.y, lr.width, lr.height);
1962 let recipe = self.marquee_recipe(ctx);
1963 let c = recipe.role.resolve(&ctx.theme.colors);
1964 let fill = teksilo_tokens::Color::new(c.r(), c.g(), c.b(), recipe.fill_alpha);
1965 canvas.fill_rect(rect, fill);
1966 canvas.stroke_rect(rect, c, recipe.stroke_width);
1967 }
1968
1969 if let Some(ins) = self.insertion.get()
1973 && let Some((bar_x, r)) =
1974 insertion_bar_geometry(self.strategy.as_ref(), ins, (self.len_fn)(), bounds.width)
1975 {
1976 let scroll_y = self.scroll_y.get();
1977 let y = bounds.y + r.y - scroll_y;
1978 let h = r.height;
1979 if y + h >= bounds.y && y <= bounds.bottom() {
1980 let recipe = self.insertion_recipe(ctx);
1981 let color = recipe.role.resolve(&ctx.theme.colors);
1982 let t = recipe.thickness;
1983 canvas.fill_rect(Rect::new(bounds.x + bar_x - t * 0.5, y, t, h), color);
1984 }
1985 }
1986
1987 if !self.view_focused.get() || !self.focus_visible.get() {
1990 return;
1991 }
1992 let idx = self.focused_index.get().filter(|&i| i < (self.len_fn)());
1996 let Some(idx) = idx else {
1997 let empty = self.selection.as_ref().is_none_or(|s| s.count() == 0);
2001 if empty {
2002 let inset = 1.0_f32;
2003 let rect = Rect::new(
2004 bounds.x + inset,
2005 bounds.y + inset,
2006 (bounds.width - inset * 2.0).max(0.0),
2007 (bounds.height - inset * 2.0).max(0.0),
2008 );
2009 let color = teksilo_tokens::BorderRole::Focused.resolve(&ctx.theme.colors);
2010 canvas.stroke_rect(rect, color, 1.5);
2011 }
2012 return;
2013 };
2014 let vp_w = bounds.width;
2015 let r = self.strategy.tile_rect(idx, vp_w);
2016 let scroll_y = self.scroll_y.get();
2017 let recipe = self.focus_recipe(ctx);
2018 let inset = recipe.inset;
2019 let stroke = recipe.thickness;
2020 let rx = bounds.x + r.x + inset;
2021 let ry = bounds.y + r.y - scroll_y + inset;
2022 let rw = (r.width - inset * 2.0).max(0.0);
2023 let rh = (r.height - inset * 2.0).max(0.0);
2024 if ry + rh < bounds.y || ry > bounds.bottom() {
2026 return;
2027 }
2028 let color = recipe.role.resolve(&ctx.theme.colors);
2029 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), color); canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), color); canvas.fill_rect(Rect::new(rx, ry, stroke, rh), color); canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), color); }
2034
2035 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2036 builder.set_hidden();
2037 }
2038}
2039
2040struct PinnedHeader {
2044 current_section: Signal<usize>,
2045 #[allow(clippy::type_complexity)]
2046 factory: Rc<dyn Fn(usize) -> Box<dyn Widget>>,
2047 child: Option<WidgetId>,
2048 style: Option<Rc<dyn GridViewStyle>>,
2049}
2050
2051impl std::fmt::Debug for PinnedHeader {
2052 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2053 f.debug_struct("PinnedHeader")
2054 .field("section", &self.current_section.get())
2055 .finish()
2056 }
2057}
2058
2059impl Widget for PinnedHeader {
2060 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2061 self.current_section
2062 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
2063 let section = self.current_section.get();
2064 let id = ctx.add_boxed((self.factory)(section));
2065 self.child = Some(id);
2066 vec![id]
2067 }
2068
2069 fn layout_response(
2070 &self,
2071 proposal: SizeProposal,
2072 _ctx: &LayoutContext,
2073 ) -> teksilo_core::widget::LayoutResponse {
2074 proposal.resolve(0.0, 0.0).into()
2075 }
2076
2077 fn place_children(
2078 &self,
2079 bounds: Rect,
2080 _proposal: SizeProposal,
2081 children: &mut [WidgetPlacement],
2082 _ctx: &LayoutContext,
2083 ) {
2084 for child in children.iter_mut() {
2085 child.origin = bounds.origin();
2086 child.size = bounds.size();
2087 }
2088 }
2089
2090 fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
2091 if bounds.height > 0.5 {
2092 let surface = self
2093 .style
2094 .as_ref()
2095 .or(ctx.theme.style_slots.grid_view.as_ref())
2096 .map(|s| s.pinned_header_surface())
2097 .unwrap_or(SurfaceRole::Raised);
2098 canvas.fill_rect(bounds, surface.resolve(&ctx.theme.colors));
2099 }
2100 }
2101
2102 fn children(&self) -> Vec<WidgetId> {
2103 self.child.into_iter().collect()
2104 }
2105
2106 fn clips_children(&self) -> bool {
2107 true
2108 }
2109}
2110
2111#[cfg(test)]
2112mod focus_reveal_tests {
2113 use super::*;
2120 use teksilo_core::widget::LayoutContext;
2121 use teksilo_core::widget_tree::WidgetTree;
2122
2123 #[derive(Debug)]
2124 struct FixedLeaf(f32, f32);
2125 impl Widget for FixedLeaf {
2126 fn layout_response(
2127 &self,
2128 _proposal: SizeProposal,
2129 _ctx: &LayoutContext,
2130 ) -> teksilo_core::widget::LayoutResponse {
2131 Size::new(self.0, self.1).into()
2132 }
2133 }
2134
2135 fn grid_with_selection(selection: &SelectionModel) -> (WidgetTree, WidgetId) {
2139 let model = ListModel::from_vec((0..300).collect::<Vec<usize>>());
2140 let mut tree = WidgetTree::new();
2141 let id = tree.add(
2142 GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
2143 .tile_size(100.0, 50.0)
2144 .selection(selection.clone()),
2145 );
2146 tree.layout(SizeProposal::exact(400.0, 300.0));
2147 (tree, id)
2148 }
2149
2150 fn selected_positions(tree: &WidgetTree) -> Vec<usize> {
2161 tree.accessibility_tree_snapshot()
2162 .nodes
2163 .iter()
2164 .filter(|(_, node)| node.is_selected() == Some(true))
2165 .filter_map(|(_, node)| node.position_in_set())
2166 .collect()
2167 }
2168
2169 #[test]
2172 fn taking_focus_reveals_the_current_tile() {
2173 let selection = SelectionModel::new(SelectionMode::Single);
2174 selection.select(150);
2175 let (mut tree, id) = grid_with_selection(&selection);
2176
2177 assert!(
2178 selected_positions(&tree).is_empty(),
2179 "tile 150 sits fifty rows below the realized window, which is the \
2180 case this is about"
2181 );
2182
2183 tree.focus(id);
2184 tree.layout(SizeProposal::exact(400.0, 300.0));
2185
2186 assert_eq!(
2187 selected_positions(&tree),
2188 vec![150],
2189 "taking focus has to bring the current tile into the realized \
2190 window, or nothing in the tree can be told about it"
2191 );
2192 }
2193
2194 fn tile_bounds(tree: &WidgetTree, grid: WidgetId, index: usize) -> Option<Rect> {
2199 let tile = tree
2200 .widget_as_any(grid)
2201 .and_then(|any| any.downcast_ref::<GridView<usize>>())
2202 .and_then(|g| {
2203 g.tile_map
2204 .borrow()
2205 .iter()
2206 .find(|(i, _)| *i == index)
2207 .map(|(_, id)| *id)
2208 })?;
2209 Some(tree.bounds(tile))
2210 }
2211
2212 #[test]
2222 fn taking_focus_does_not_move_a_tile_already_in_view() {
2223 let selection = SelectionModel::new(SelectionMode::Single);
2224 selection.select(12);
2225 let (mut tree, id) = grid_with_selection(&selection);
2226
2227 let scroll = tree
2228 .widget_as_any(id)
2229 .and_then(|any| any.downcast_ref::<GridView<usize>>())
2230 .map(|g| g.scroll_y_signal().clone())
2231 .expect("the grid is the widget at `id`");
2232 let before = scroll.get();
2233
2234 let rect = tile_bounds(&tree, id, 12).expect("tile 12 is realized");
2235 assert!(
2236 rect.y >= 0.0 && rect.y + rect.height <= 300.0,
2237 "this case only means anything while tile 12 is fully on screen, \
2238 and it spans y {}..{} of a 300px viewport",
2239 rect.y,
2240 rect.y + rect.height
2241 );
2242
2243 tree.focus(id);
2244 tree.layout(SizeProposal::exact(400.0, 300.0));
2245
2246 assert_eq!(
2247 scroll.get(),
2248 before,
2249 "tile 12 is already fully visible, so taking focus must not scroll \
2250 the grid under somebody who can see it"
2251 );
2252 }
2253}