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 #[allow(clippy::type_complexity)]
257 type_ahead_label: Option<Rc<dyn Fn(usize) -> String>>,
258 #[allow(clippy::type_complexity)]
262 tile_a11y_label: Option<Rc<dyn Fn(usize) -> String>>,
263
264 #[allow(clippy::type_complexity)]
266 empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
267 #[allow(clippy::type_complexity)]
268 loading_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
269 is_loading: Option<Prop<bool>>,
270 loading_id: Option<WidgetId>,
271
272 section_data: Option<SectionData>,
274 #[allow(clippy::type_complexity)]
275 header_delegate: Option<Rc<dyn Fn(usize, &str) -> Box<dyn Widget>>>,
276 header_height: f32,
277 pinned_section_headers: bool,
278 current_section: Signal<usize>,
279 pinned_header_id: Option<WidgetId>,
280
281 a11y_label: Option<String>,
283 tile_map: Rc<std::cell::RefCell<Vec<(usize, WidgetId)>>>,
286
287 style: Option<Rc<dyn GridViewStyle>>,
290
291 viewport_width: Rc<Cell<f32>>,
293 viewport_height: Rc<Cell<f32>>,
294 viewport_origin: Rc<Cell<Option<Point>>>,
299 last_needs_scrollbar: Cell<bool>,
303
304 body_pane_id: Option<WidgetId>,
306 empty_id: Option<WidgetId>,
307 scrollbar_id: Option<WidgetId>,
308 overlay_id: Option<WidgetId>,
309
310 enabled: Prop<bool>,
315}
316
317impl<T: 'static> GridView<T> {
318 pub fn new(
321 model: ListModel<T>,
322 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
323 ) -> Self {
324 Self::create(ListSource::from_model(model), delegate)
325 }
326
327 pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
329 source: S,
330 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
331 ) -> Self {
332 Self::create(ListSource::from_data_source(source), delegate)
333 }
334
335 fn create(
336 source: ListSource<T>,
337 delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static,
338 ) -> Self {
339 Self {
340 source,
341 delegate: Rc::new(delegate),
342 sizing: GridSizing::Adaptive {
343 min_width: 120.0,
344 max_width: None,
345 height: 120.0,
346 },
347 sizing_signal: None,
348 col_gap: 8.0,
349 row_gap: 8.0,
350 inset: EdgeInsets::ZERO,
351 strategy_kind: StrategyKind::Uniform,
352 exact_item_height: None,
353 strategy: None,
354 selection: None,
355 on_selection_changed: None,
356 focused_index: Signal::new(None),
357 marquee_selection: true,
358 marquee: Signal::new(None),
359 wrap_navigation: false,
360 tab_traversal: GridTabTraversal::OutOfGrid,
361 show_scrollbar: true,
362 overscroll_behavior: OverscrollBehavior::default(),
363 smooth_scrolling: true,
364 smooth_scroll_duration: Duration::from_millis(150),
365 scroll_bar_style: ScrollBarMode::Permanent,
366 scroll_y: Signal::new_animated(0.0),
367 max_scroll_y: Signal::new(0.0),
368 viewport_ratio_y: Signal::new(1.0),
369 column_count: Signal::new(1),
370 reorderable: false,
371 on_item_drop: None,
372 insertion: Signal::new(None),
373 model_id: ViewId::next(ViewKind::Grid),
374 export: crate::data_views::RowExport::default(),
375 on_tile_activate: None,
376 activate_on: crate::data_views::ActivateOn::default(),
377 tile_context_menu: None,
378 type_ahead_timeout: std::time::Duration::from_millis(500),
379 type_ahead_label: None,
380 tile_a11y_label: None,
381 empty_view: None,
382 loading_view: None,
383 is_loading: None,
384 loading_id: None,
385 section_data: None,
386 header_delegate: None,
387 header_height: 28.0,
388 pinned_section_headers: false,
389 current_section: Signal::new(0),
390 pinned_header_id: None,
391 a11y_label: None,
392 tile_map: Rc::new(std::cell::RefCell::new(Vec::new())),
393 style: None,
394 viewport_width: Rc::new(Cell::new(400.0)),
395 viewport_height: Rc::new(Cell::new(400.0)),
396 viewport_origin: Rc::new(Cell::new(None)),
397 last_needs_scrollbar: Cell::new(false),
398 body_pane_id: None,
399 empty_id: None,
400 scrollbar_id: None,
401 overlay_id: None,
402 enabled: Prop::Static(true),
403 }
404 }
405
406 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
409 self.enabled = enabled.into();
410 self
411 }
412
413 pub fn sizing(mut self, sizing: impl Into<Prop<GridSizing>>) -> Self {
425 let sig = sizing.into().as_signal();
426 self.sizing = sig.get();
427 self.sizing_signal = Some(sig);
428 self
429 }
430
431 pub fn tile_size(mut self, width: f32, height: f32) -> Self {
433 self.sizing = GridSizing::Fixed { width, height };
434 self.sizing_signal = None;
435 self
436 }
437
438 pub fn column_count(mut self, count: usize, tile_height: f32) -> Self {
440 self.sizing = GridSizing::FixedColumnCount {
441 count,
442 height: tile_height,
443 };
444 self.sizing_signal = None;
445 self
446 }
447
448 pub fn variable_row_heights(mut self, estimated: f32) -> Self {
454 self.strategy_kind = StrategyKind::VariableRow {
455 estimated: estimated.max(1.0),
456 };
457 self
458 }
459
460 pub fn item_height(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
465 self.exact_item_height = Some(Rc::new(f));
466 if matches!(self.strategy_kind, StrategyKind::Uniform) {
467 self.strategy_kind = StrategyKind::VariableRow {
468 estimated: self.sizing.tile_height().max(1.0),
469 };
470 }
471 self
472 }
473
474 pub fn waterfall(mut self, estimated: f32) -> Self {
480 self.strategy_kind = StrategyKind::Waterfall {
481 estimated: estimated.max(1.0),
482 };
483 self
484 }
485
486 pub fn column_spacing(mut self, spacing: f32) -> Self {
490 self.col_gap = spacing.max(0.0);
491 self
492 }
493
494 pub fn row_spacing(mut self, spacing: f32) -> Self {
496 self.row_gap = spacing.max(0.0);
497 self
498 }
499
500 pub fn spacing(mut self, spacing: f32) -> Self {
502 self.col_gap = spacing.max(0.0);
503 self.row_gap = spacing.max(0.0);
504 self
505 }
506
507 pub fn content_inset(mut self, inset: EdgeInsets) -> Self {
509 self.inset = inset;
510 self
511 }
512
513 pub fn selection(mut self, sel: SelectionModel) -> Self {
517 self.selection = Some(sel);
518 self
519 }
520
521 pub fn on_selection_changed(mut self, f: impl Fn(&BTreeSet<usize>) + 'static) -> Self {
524 self.on_selection_changed = Some(Rc::new(f));
525 self
526 }
527
528 pub fn marquee_selection(mut self, enabled: bool) -> Self {
531 self.marquee_selection = enabled;
532 self
533 }
534
535 pub fn wrap_navigation(mut self, enabled: bool) -> Self {
539 self.wrap_navigation = enabled;
540 self
541 }
542
543 pub fn tab_traversal(mut self, traversal: GridTabTraversal) -> Self {
545 self.tab_traversal = traversal;
546 self
547 }
548
549 pub fn show_scrollbar(mut self, show: bool) -> Self {
554 self.show_scrollbar = show;
555 self
556 }
557
558 pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
560 self.overscroll_behavior = behavior;
561 self
562 }
563
564 pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
566 self.smooth_scrolling = enabled;
567 self
568 }
569
570 pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
572 self.smooth_scroll_duration = duration;
573 self
574 }
575
576 pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
580 self.scroll_bar_style = style;
581 self
582 }
583
584 pub fn scroll_y_signal(&self) -> &Signal<f32> {
586 &self.scroll_y
587 }
588
589 pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
591 &self.max_scroll_y
592 }
593
594 pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
596 &self.viewport_ratio_y
597 }
598
599 pub fn ensure_index_visible(&self, index: usize, anchor: ScrollAnchor) {
601 let Some(ref strategy) = self.strategy else {
602 return;
603 };
604 if let Some(target) = scroll_for_ensure_visible(
605 strategy.as_ref(),
606 index,
607 self.scroll_y.get(),
608 self.viewport_height.get(),
609 self.viewport_width.get(),
610 self.max_scroll_y.get(),
611 anchor,
612 ) {
613 self.scroll_y.set(target);
614 }
615 }
616
617 pub fn scroll_to_index(&self, index: usize, anchor: ScrollAnchor) {
620 self.ensure_index_visible(index, anchor);
621 }
622
623 fn reveal_focused_tile_on_focus(
665 &self,
666 ctx: &mut BuildContext,
667 strategy: Rc<dyn GridLayoutStrategy>,
668 ) {
669 let view_focused = ctx.begin_view_focus();
676 ctx.end_view_focus();
677
678 let scroll_y = self.scroll_y.clone();
679 let max_scroll_y = self.max_scroll_y.clone();
680 let viewport_height = self.viewport_height.clone();
681 let viewport_width = self.viewport_width.clone();
682 let focused_index = self.focused_index.clone();
683 let selection = self.selection.clone();
684
685 ctx.effect(&view_focused, move |focused| {
686 if !*focused {
687 return;
688 }
689 let Some(index) = focused_index.get().or_else(|| {
690 selection
691 .as_ref()
692 .and_then(|s| s.selected_indices().first().copied())
693 }) else {
694 return;
695 };
696 if let Some(target) = scroll_for_ensure_visible(
697 strategy.as_ref(),
698 index,
699 scroll_y.get(),
700 viewport_height.get(),
701 viewport_width.get(),
702 max_scroll_y.get(),
703 ScrollAnchor::Auto,
704 ) {
705 scroll_y.set(target);
706 }
707 });
708 }
709
710 pub fn sections<P: SectionProvider>(mut self, provider: P) -> Self {
717 let provider = Rc::new(provider);
718 let counts_provider = provider.clone();
719 let title_provider = provider.clone();
720 self.section_data = Some(SectionData {
721 counts_fn: Rc::new(move || counts_provider.section_counts()),
722 title_fn: Rc::new(move |s| title_provider.section_title(s)),
723 });
724 self
725 }
726
727 pub fn section_header_delegate(
730 mut self,
731 f: impl Fn(usize, &str) -> Box<dyn Widget> + 'static,
732 ) -> Self {
733 self.header_delegate = Some(Rc::new(f));
734 self
735 }
736
737 pub fn section_header_height(mut self, height: f32) -> Self {
739 self.header_height = height.max(0.0);
740 self
741 }
742
743 pub fn pinned_section_headers(mut self, enabled: bool) -> Self {
746 self.pinned_section_headers = enabled;
747 self
748 }
749
750 pub fn a11y_label(mut self, label: impl Into<String>) -> Self {
752 self.a11y_label = Some(label.into());
753 self
754 }
755
756 pub fn style(mut self, style: impl GridViewStyle) -> Self {
760 self.style = Some(Rc::new(style));
761 self
762 }
763
764 #[allow(clippy::type_complexity)]
767 fn header_factory(&self) -> Option<Rc<dyn Fn(usize) -> Box<dyn Widget>>> {
768 let data = self.section_data.as_ref()?;
769 let title_fn = data.title_fn.clone();
770 let delegate = self.header_delegate.clone();
771 Some(Rc::new(move |section| {
772 let title = title_fn(section);
773 match &delegate {
774 Some(d) => d(section, &title),
775 None => Box::new(TextWidget::new(teksilo_i18n::lit!(title))) as Box<dyn Widget>,
776 }
777 }))
778 }
779
780 pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
782 self.empty_view = Some(Rc::new(f));
783 self
784 }
785
786 pub fn loading_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
788 self.loading_view = Some(Rc::new(f));
789 self
790 }
791
792 pub fn is_loading(mut self, flag: impl Into<Prop<bool>>) -> Self {
795 self.is_loading = Some(flag.into());
796 self
797 }
798
799 pub fn reorderable(mut self, enabled: bool) -> Self {
805 self.reorderable = enabled;
806 self
807 }
808
809 pub fn exportable(mut self, mode: DragTransferMode) -> Self
825 where
826 T: Clone,
827 {
828 self.export.set_exportable(mode);
829 self
830 }
831
832 pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
840 where
841 T: Clone,
842 {
843 self.export.set_export_external(f);
844 self
845 }
846
847 pub fn on_rows_transferred_out(
853 mut self,
854 f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
855 ) -> Self {
856 self.export.set_on_rows_transferred_out(f);
857 self
858 }
859
860 pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
867 self.export.accept_foreign_rows = accept;
868 self
869 }
870
871 pub fn on_rows_received(
875 mut self,
876 f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
877 ) -> Self {
878 self.export.set_on_rows_received(f);
879 self
880 }
881
882 pub fn on_item_drop(
885 mut self,
886 f: impl Fn(
887 teksilo_core::drag_payload::DragPayload,
888 usize,
889 &mut teksilo_core::widget::EventContext,
890 ) -> bool
891 + 'static,
892 ) -> Self {
893 self.on_item_drop = Some(Rc::new(f));
894 self
895 }
896
897 pub fn on_tile_activate(
903 mut self,
904 f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
905 ) -> Self {
906 self.on_tile_activate = Some(Rc::new(f));
907 self
908 }
909
910 pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
914 self.activate_on = mode;
915 self
916 }
917
918 pub fn tile_context_menu(
921 mut self,
922 f: impl Fn(usize, Point, &mut teksilo_core::widget::EventContext) -> Option<Box<dyn Widget>>
923 + 'static,
924 ) -> Self {
925 self.tile_context_menu = Some(Rc::new(f));
926 self
927 }
928
929 pub fn type_ahead_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
932 self.type_ahead_label = Some(Rc::new(f));
933 self
934 }
935
936 pub fn tile_a11y_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self {
941 self.tile_a11y_label = Some(Rc::new(f));
942 self
943 }
944
945 pub fn type_ahead_timeout(mut self, timeout: std::time::Duration) -> Self {
947 self.type_ahead_timeout = timeout;
948 self
949 }
950
951 fn ensure_strategy(&mut self) -> Rc<dyn GridLayoutStrategy> {
956 if self.strategy.is_none() {
957 if let Some(ref data) = self.section_data {
959 let s: Rc<dyn GridLayoutStrategy> = Rc::new(SectionedGrid::new(
960 self.sizing,
961 self.col_gap,
962 self.row_gap,
963 self.inset,
964 self.header_height,
965 data.counts_fn.clone(),
966 ));
967 self.strategy = Some(s);
968 return self.strategy.as_ref().unwrap().clone();
969 }
970 let s: Rc<dyn GridLayoutStrategy> = match self.strategy_kind {
971 StrategyKind::Uniform => Rc::new(UniformGrid::new(
972 self.sizing,
973 self.col_gap,
974 self.row_gap,
975 self.inset,
976 )),
977 StrategyKind::VariableRow { estimated } => Rc::new(VariableRowGrid::new(
978 self.sizing,
979 self.col_gap,
980 self.row_gap,
981 self.inset,
982 estimated,
983 self.exact_item_height.clone(),
984 )),
985 StrategyKind::Waterfall { estimated } => Rc::new(VirtualizedMasonry::new(
986 self.sizing,
987 self.col_gap,
988 self.row_gap,
989 self.inset,
990 estimated,
991 self.exact_item_height.clone(),
992 )),
993 };
994 self.strategy = Some(s);
995 }
996 self.strategy.as_ref().unwrap().clone()
997 }
998}
999
1000impl<T: 'static> std::fmt::Debug for GridView<T> {
1001 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1002 f.debug_struct("GridView")
1003 .field("items", &self.source.len())
1004 .field("scroll_bar_style", &self.scroll_bar_style)
1005 .field("scroll_y", &self.scroll_y.get())
1006 .finish()
1007 }
1008}
1009
1010fn scroll_for_ensure_visible(
1021 strategy: &dyn GridLayoutStrategy,
1022 index: usize,
1023 scroll_y: f32,
1024 viewport_height: f32,
1025 viewport_width: f32,
1026 max_scroll_y: f32,
1027 anchor: ScrollAnchor,
1028) -> Option<f32> {
1029 let delta =
1030 strategy.scroll_delta_to_reveal(index, scroll_y, viewport_height, viewport_width, anchor);
1031 if delta.abs() <= 0.01 {
1032 return None;
1033 }
1034 Some((scroll_y + delta).clamp(0.0, max_scroll_y))
1035}
1036
1037impl<T: 'static> Widget for GridView<T> {
1038 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1039 let self_id = ctx.self_id();
1040 ctx.enabled_when(self_id, self.enabled.clone());
1041
1042 if let Some(ref sig) = self.sizing_signal {
1047 sig.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1048 let next = sig.get();
1049 if self.sizing != next {
1050 self.sizing = next;
1051 self.strategy = None;
1052 }
1053 }
1054
1055 let strategy = self.ensure_strategy();
1056
1057 let version = ctx.signal(0_u64);
1059 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1060
1061 self.scroll_y.bind_to(
1063 ctx.self_id(),
1064 ctx.binding_registry(),
1065 BindingLevel::Relayout,
1066 );
1067 ctx.register_animated_signal(&self.scroll_y);
1068
1069 if let Some(ref sel) = self.selection {
1071 sel.selection_signal().bind_to(
1072 ctx.self_id(),
1073 ctx.binding_registry(),
1074 BindingLevel::AccessibilityOnly,
1075 );
1076 }
1077 self.focused_index.bind_to(
1078 ctx.self_id(),
1079 ctx.binding_registry(),
1080 BindingLevel::AccessibilityOnly,
1081 );
1082
1083 self.reveal_focused_tile_on_focus(ctx, strategy.clone());
1086
1087 {
1089 let v = version.clone();
1090 let counter = Rc::new(Cell::new(0_u64));
1091 let strategy_obs = strategy.clone();
1092 let selection_obs = self.selection.clone();
1093 let len_fn = self.source.len_fn.clone();
1094 let scroll_reset = self.scroll_y.clone();
1095 let focused_obs = self.focused_index.clone();
1096 let handle = (self.source.observe_fn)(Box::new(move |change| {
1097 match change {
1098 DataChange::ItemsInserted { range } => {
1099 strategy_obs.invalidate_rows(range.start..usize::MAX);
1100 strategy_obs.resize((len_fn)());
1101 if let Some(ref s) = selection_obs {
1102 s.adjust_for_insert(range.start, range.end - range.start);
1103 }
1104 }
1105 DataChange::ItemsRemoved { 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_remove(range.start, range.end - range.start);
1110 }
1111 }
1112 DataChange::ItemsMoved { from, to, count } => {
1113 strategy_obs.invalidate_rows(0..usize::MAX);
1114 if let Some(ref s) = selection_obs {
1115 s.adjust_for_move(*from, *to, *count);
1116 }
1117 }
1118 DataChange::ItemUpdated { index } => {
1119 strategy_obs.invalidate_rows(*index..index + 1);
1120 }
1121 DataChange::WindowLoaded { range } => {
1122 strategy_obs.invalidate_rows(range.start..range.end);
1123 }
1124 DataChange::Reset => {
1125 strategy_obs.invalidate_rows(0..usize::MAX);
1126 strategy_obs.resize(0);
1127 if let Some(ref s) = selection_obs {
1128 s.clear();
1129 }
1130 scroll_reset.set(0.0);
1131 }
1132 }
1133 if let Some(current) = focused_obs.get() {
1140 focused_obs.set(teksilo_data::data_change::adjust_single_index_for_change(
1141 current, change,
1142 ));
1143 }
1144 let next = counter.get() + 1;
1145 counter.set(next);
1146 v.set(next);
1147 }));
1148 ctx.own_handle(handle);
1149 }
1150
1151 if let (Some(sel), Some(cb)) = (&self.selection, &self.on_selection_changed) {
1155 let cb = cb.clone();
1156 ctx.effect(&sel.selection_signal(), move |set| cb(set));
1157 }
1158
1159 if let Some(flag) = &self.is_loading {
1161 let v = version.clone();
1162 let c = Rc::new(Cell::new(0_u64));
1163 ctx.effect(&flag.as_signal(), move |_| {
1164 c.set(c.get() + 1);
1165 v.set(c.get());
1166 });
1167 }
1168
1169 let mut handlers = HandlerSet::new().clips_children(true).focusable(true);
1171 {
1172 let scroll_y = self.scroll_y.clone();
1173 let max_scroll = self.max_scroll_y.clone();
1174 let line_height = strategy.estimated_row_height().max(1.0);
1175 let overscroll = self.overscroll_behavior;
1176 let smooth_scrolling = self.smooth_scrolling;
1177 let smooth_scroll_duration = self.smooth_scroll_duration;
1178 handlers = handlers.on_scroll(move |event, _ctx| match event {
1179 WidgetEvent::Scroll { delta, .. } => {
1180 let dy = match delta {
1181 ScrollDelta::Lines { y, .. } => y * line_height,
1182 ScrollDelta::Pixels { y, .. } => *y,
1183 };
1184 let base = scroll_y.animation_target().unwrap_or(scroll_y.get());
1187 let (new_y, moved) =
1188 crate::common::scroll::scroll_clamp_axis(base, dy, max_scroll.get());
1189 if moved {
1190 if smooth_scrolling {
1191 scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
1192 } else {
1193 scroll_y.set(new_y);
1194 }
1195 }
1196 crate::common::scroll::scroll_response(
1197 moved,
1198 overscroll == OverscrollBehavior::Contain,
1199 )
1200 }
1201 _ => EventResponse::Ignored,
1202 });
1203 }
1204 handlers = handlers.on_key(build_grid_key_handler(GridKeyConfig {
1205 len_fn: self.source.len_fn.clone(),
1206 col_count: self.column_count.clone(),
1207 focused_index: self.focused_index.clone(),
1208 selection: self.selection.clone(),
1209 scroll_y: self.scroll_y.clone(),
1210 max_scroll_y: self.max_scroll_y.clone(),
1211 viewport_height: self.viewport_height.clone(),
1212 viewport_width: self.viewport_width.clone(),
1213 viewport_origin: self.viewport_origin.clone(),
1214 strategy: strategy.clone(),
1215 wrap_navigation: self.wrap_navigation,
1216 tab_traversal: self.tab_traversal,
1217 on_tile_activate: self.on_tile_activate.clone(),
1218 reorderable: self.reorderable,
1219 accept_drop_fn: self.source.dnd.accept_drop_fn.clone(),
1220 view_id: self.model_id,
1221 make_reorder_payload: {
1222 let model_id = self.model_id;
1223 let stash = self.source.dnd.stash_drag_keys_fn.clone();
1224 Rc::new(move |idx| {
1225 (stash)(&[idx]);
1229 DragPayload::typed(RowDragData::<T> {
1230 source: model_id,
1231 rows: vec![idx],
1232 items: None,
1233 })
1234 })
1235 },
1236 type_ahead_timeout: self.type_ahead_timeout,
1237 type_ahead_label: self.type_ahead_label.as_ref().map(|label| {
1244 let label = label.clone();
1245 let with_item_str = self.source.with_item_str_fn.clone();
1246 Rc::new(move |i: usize| (with_item_str)(i, &|_item: &T| label(i)))
1247 as Rc<dyn Fn(usize) -> Option<String>>
1248 }),
1249 }));
1250
1251 let marquee_on = self.marquee_selection
1255 && self
1256 .selection
1257 .as_ref()
1258 .map(|s| s.mode() == SelectionMode::Multi)
1259 .unwrap_or(false);
1260 if marquee_on {
1261 let additive_mods = Rc::new(Cell::new(false));
1262 {
1263 let mods = additive_mods.clone();
1264 handlers = handlers.on_pointer_event(move |event, _ctx| {
1265 if let WidgetEvent::PointerDown { modifiers, .. } = event {
1266 mods.set(modifiers.command() || modifiers.shift());
1267 }
1268 EventResponse::Ignored
1269 });
1270 }
1271 handlers = handlers.on_drag(build_marquee_handler(MarqueeConfig {
1272 marquee: self.marquee.clone(),
1273 selection: self.selection.clone().unwrap(),
1274 strategy: strategy.clone(),
1275 scroll_y: self.scroll_y.clone(),
1276 viewport_width: self.viewport_width.clone(),
1277 len_fn: self.source.len_fn.clone(),
1278 additive_mods,
1279 }));
1280
1281 let frame_request = ctx.frame_request_handle();
1293 let marquee_for_tick = self.marquee.clone();
1294 let scroll_for_tick = self.scroll_y.clone();
1295 let max_scroll_for_tick = self.max_scroll_y.clone();
1296 let viewport_h_for_tick = self.viewport_height.clone();
1297 ctx.effect(&ctx.frame_tick(), move |_delta| {
1298 let Some(st) = marquee_for_tick.get() else {
1299 return;
1300 };
1301 let step =
1302 selection::marquee_auto_scroll_step(st.current.y, viewport_h_for_tick.get());
1303 if step != 0.0 {
1304 let max = max_scroll_for_tick.get();
1305 let new_y = (scroll_for_tick.get() + step).clamp(0.0, max);
1306 scroll_for_tick.set(new_y);
1307 frame_request.set(true);
1311 }
1312 });
1313 }
1314
1315 if self.export.is_drop_target(self.reorderable) || self.on_item_drop.is_some() {
1323 let has_drop_cb = self.on_item_drop.is_some();
1324 let my_id = self.model_id;
1325
1326 let strategy_h = strategy.clone();
1327 let scroll_h = self.scroll_y.clone();
1328 let vp_w_h = self.viewport_width.clone();
1329 let len_h = self.source.len_fn.clone();
1330 let can_accept_h = self.source.dnd.can_accept_fn.clone();
1331 let insertion_h = self.insertion.clone();
1332 let export_for_hover = self.export.clone();
1333 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1334 let len = (len_h)();
1335 let idx = drag::insertion_index(
1336 strategy_h.as_ref(),
1337 position,
1338 scroll_h.get(),
1339 vp_w_h.get(),
1340 len,
1341 );
1342 let allowed = drop_allowed::<T>(
1343 &can_accept_h,
1344 payload,
1345 idx,
1346 len,
1347 my_id,
1348 has_drop_cb,
1349 &export_for_hover,
1350 );
1351 if allowed {
1352 insertion_h.set(Some(idx));
1353 teksilo_core::DropFeedback::Accept
1356 } else {
1357 insertion_h.set(None);
1358 teksilo_core::DropFeedback::NoFeedback
1359 }
1360 });
1361
1362 let insertion_leave = self.insertion.clone();
1363 handlers = handlers.on_drag_leave(move |_ctx| {
1364 insertion_leave.set(None);
1365 });
1366
1367 let strategy_d = strategy.clone();
1368 let scroll_d = self.scroll_y.clone();
1369 let vp_w_d = self.viewport_width.clone();
1370 let len_d = self.source.len_fn.clone();
1371 let accept_drop_d = self.source.dnd.accept_drop_fn.clone();
1372 let drop_cb = self.on_item_drop.clone();
1373 let insertion_d = self.insertion.clone();
1374 let export_for_drop = self.export.clone();
1375 let reorderable_for_drop = self.reorderable;
1376 handlers = handlers.on_drop(move |mut payload, position, ctx| {
1377 insertion_d.set(None);
1378 let len = (len_d)();
1379 let to = drag::insertion_index(
1380 strategy_d.as_ref(),
1381 position,
1382 scroll_d.get(),
1383 vp_w_d.get(),
1384 len,
1385 );
1386 let is_same_view = payload
1387 .get_typed::<RowDragData<T>>()
1388 .is_some_and(|rd| rd.source == my_id);
1389 if (reorderable_for_drop || !is_same_view)
1394 && let Some((target, position_kind)) = flat_insertion_target(to, len)
1395 && (accept_drop_d)(&payload, target, position_kind, my_id)
1396 {
1397 if is_same_view {
1400 export_for_drop.note_self_reorder();
1401 }
1402 return true;
1403 }
1404 if export_for_drop.foreign_receive(&mut payload, my_id, to, ctx) {
1410 return true;
1411 }
1412 if let Some(ref cb) = drop_cb {
1415 return cb(payload, to, ctx);
1416 }
1417 false
1418 });
1419 }
1420 ctx.apply_self_handlers(handlers);
1421
1422 self.body_pane_id = None;
1427 self.empty_id = None;
1428 self.scrollbar_id = None;
1429 self.overlay_id = None;
1430 self.pinned_header_id = None;
1431
1432 let len = self.source.len();
1433 if len == 0 {
1434 self.tile_map.borrow_mut().clear();
1435 if let Some(ref ef) = self.empty_view {
1436 self.empty_id = Some(ctx.add_boxed(ef()));
1437 }
1438 } else {
1439 let pane_total_refresh = ctx.signal(0_u64);
1444 pane_total_refresh.bind_to(
1445 ctx.self_id(),
1446 ctx.binding_registry(),
1447 teksilo_core::binding::BindingLevel::Relayout,
1448 );
1449 let pane = GridBodyPane {
1450 len_fn: self.source.len_fn.clone(),
1451 with_item_fn: self.source.with_item_fn.clone(),
1452 delegate: self.delegate.clone(),
1453 strategy: strategy.clone(),
1454 viewport_width: self.viewport_width.clone(),
1455 viewport_height: self.viewport_height.clone(),
1456 viewport_origin: self.viewport_origin.clone(),
1457 column_count: self.column_count.clone(),
1458 scroll_y: self.scroll_y.clone(),
1459 selection: self.selection.clone(),
1460 focused_index: self.focused_index.clone(),
1461 on_tile_activate: self.on_tile_activate.clone(),
1462 activate_on: self.activate_on,
1463 tile_context_menu: self.tile_context_menu.clone(),
1464 tile_a11y_label: self.tile_a11y_label.clone(),
1465 reorderable: self.reorderable,
1466 model_id: self.model_id,
1467 scope_owner: ctx.self_id(),
1468 drag_fn: self.source.dnd.drag_fn.clone(),
1469 row_state_fn: self.source.dnd.row_state_fn.clone(),
1470 request_window_fn: self.source.dnd.request_window_fn.clone(),
1471 can_fetch_more_fn: self.source.dnd.can_fetch_more_fn.clone(),
1472 fetch_more_fn: self.source.dnd.fetch_more_fn.clone(),
1473 export: self.export.clone(),
1474 read_item_fn: self.source.read_item_fn.clone(),
1475 snapshot_out_fn: self.source.dnd.snapshot_out_fn.clone(),
1476 tile_map: self.tile_map.clone(),
1477 header_factory: self.header_factory(),
1478 header_title: self.section_data.as_ref().map(|d| d.title_fn.clone()),
1479 version: Signal::new(0_u64),
1482 prev_built_start: Rc::new(Cell::new(0)),
1483 prev_built_end: Rc::new(Cell::new(0)),
1484 total_refresh: pane_total_refresh,
1485 tile_entries: Vec::new(),
1486 header_entries: Vec::new(),
1487 in_place_children: Cell::new(false),
1488 };
1489 self.body_pane_id = Some(ctx.add(pane));
1490
1491 let overlay = GridOverlay {
1492 focused_index: self.focused_index.clone(),
1493 view_focused: ctx.view_focus_active(),
1497 focus_visible: ctx.focus_visible(),
1498 selection: self.selection.clone(),
1499 scroll_y: self.scroll_y.clone(),
1500 strategy: strategy.clone(),
1501 viewport_width: self.viewport_width.clone(),
1502 marquee: self.marquee.clone(),
1503 insertion: self.insertion.clone(),
1504 style: self.style.clone(),
1505 len_fn: self.source.len_fn.clone(),
1506 };
1507 self.overlay_id = Some(ctx.add(overlay));
1508
1509 self.pinned_header_id = None;
1516 let section_count = self
1517 .section_data
1518 .as_ref()
1519 .map(|d| (d.counts_fn)().len())
1520 .unwrap_or(0);
1521 if self.pinned_section_headers && section_count > 0 {
1522 if let Some(factory) = self.header_factory() {
1523 let ph = PinnedHeader {
1524 current_section: self.current_section.clone(),
1525 factory,
1526 child: None,
1527 style: self.style.clone(),
1528 };
1529 self.pinned_header_id = Some(ctx.add(ph));
1530 }
1531 }
1532 }
1533
1534 if self.show_scrollbar {
1535 let sb = ScrollBar::new(
1536 ScrollBarOrientation::Vertical,
1537 self.scroll_y.clone(),
1538 self.max_scroll_y.clone(),
1539 self.viewport_ratio_y.clone(),
1540 )
1541 .visual(match self.scroll_bar_style {
1542 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1543 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1544 ScrollBarMode::Thin => ScrollBarVisual::Thin,
1545 });
1546 self.scrollbar_id = Some(ctx.add(sb));
1547 }
1548
1549 self.loading_id = None;
1551 if let Some(flag) = &self.is_loading {
1552 if flag.get() {
1553 if let Some(ref lv) = self.loading_view {
1554 self.loading_id = Some(ctx.add_boxed(lv()));
1555 }
1556 }
1557 }
1558
1559 let mut children = Vec::new();
1561 if let Some(id) = self.body_pane_id {
1562 children.push(id);
1563 }
1564 if let Some(id) = self.empty_id {
1565 children.push(id);
1566 }
1567 if let Some(id) = self.scrollbar_id {
1568 children.push(id);
1569 }
1570 if let Some(id) = self.overlay_id {
1571 children.push(id);
1572 }
1573 if let Some(id) = self.pinned_header_id {
1574 children.push(id);
1575 }
1576 if let Some(id) = self.loading_id {
1577 children.push(id);
1578 }
1579 children
1580 }
1581
1582 fn layout_response(
1583 &self,
1584 proposal: SizeProposal,
1585 _ctx: &LayoutContext,
1586 ) -> teksilo_core::widget::LayoutResponse {
1587 let size = crate::common::viewport::viewport_size(
1591 proposal,
1592 &self.viewport_height,
1593 Size::new(400.0, 400.0),
1594 );
1595 if proposal.width.is_some() {
1596 self.viewport_width.set(size.width);
1597 }
1598 size.into()
1599 }
1600
1601 fn place_children(
1602 &self,
1603 bounds: Rect,
1604 _proposal: SizeProposal,
1605 children: &mut [WidgetPlacement],
1606 _ctx: &LayoutContext,
1607 ) {
1608 let Some(ref strategy) = self.strategy else {
1609 return;
1610 };
1611 let len = self.source.len();
1612 let vp_h = bounds.height;
1613
1614 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1622 let body_w = if self.last_needs_scrollbar.get() && reserves_bar {
1623 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1624 } else {
1625 bounds.width
1626 };
1627 self.viewport_width.set(body_w);
1628
1629 let cols = strategy.column_count(body_w).max(1);
1630 if self.column_count.get() != cols {
1631 self.column_count.set(cols);
1632 }
1633
1634 let total = strategy.total_content_height(len, body_w);
1635 let needs_sb = self.show_scrollbar && total > vp_h + 0.5;
1636 if self.last_needs_scrollbar.get() != needs_sb {
1637 self.last_needs_scrollbar.set(needs_sb);
1638 }
1639 let max_y = (total - vp_h).max(0.0);
1640 self.max_scroll_y.set(max_y);
1641 let ratio = if total > 0.0 {
1642 (vp_h / total).clamp(0.0, 1.0)
1643 } else {
1644 1.0
1645 };
1646 self.viewport_ratio_y.set(ratio);
1647 let cur = self.scroll_y.get();
1649 let clamped = cur.clamp(0.0, max_y);
1650 if (clamped - cur).abs() > 0.001 {
1651 self.scroll_y.set(clamped);
1652 }
1653
1654 let pinned_rect = if self.pinned_header_id.is_some() {
1657 let cur = strategy.current_section(self.scroll_y.get(), body_w);
1658 if let Some(cur) = cur {
1659 if self.current_section.get() != cur {
1660 self.current_section.set(cur);
1661 }
1662 strategy.header_rect(cur, body_w).map(|r| {
1664 let screen_y = bounds.y + r.y - self.scroll_y.get();
1665 let visible = screen_y < bounds.y - 0.5;
1666 (visible, r.height)
1667 })
1668 } else {
1669 None
1670 }
1671 } else {
1672 None
1673 };
1674
1675 let body_rect_origin = bounds.origin();
1676 let body_size = Size::new(body_w, vp_h);
1677 for child in children.iter_mut() {
1678 if Some(child.id) == self.scrollbar_id {
1679 if needs_sb {
1680 child.origin =
1683 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1684 child.size = Size::new(SCROLLBAR_THICKNESS, vp_h);
1685 } else {
1686 child.origin = bounds.origin();
1687 child.size = Size::ZERO;
1688 }
1689 } else if Some(child.id) == self.pinned_header_id {
1690 match pinned_rect {
1691 Some((true, h)) => {
1692 child.origin = bounds.origin();
1693 child.size = Size::new(body_w, h);
1694 }
1695 _ => {
1696 child.origin = bounds.origin();
1697 child.size = Size::ZERO;
1698 }
1699 }
1700 } else {
1701 child.origin = body_rect_origin;
1703 child.size = body_size;
1704 }
1705 }
1706 }
1707
1708 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1709 builder.set_role(teksilo_core::accesskit::Role::Grid);
1710 if let Some(ref label) = self.a11y_label {
1711 builder.set_name(label.clone());
1712 }
1713
1714 let total = self.source.len();
1715 let cols = self.column_count.get().max(1);
1716 let rows = total.div_ceil(cols);
1717 builder.set_row_count(rows);
1718 builder.set_column_count(cols);
1719 if total > 0 {
1724 builder.set_size_of_set(total);
1725 }
1726
1727 if let Some(ref sel) = self.selection {
1728 if sel.mode() == SelectionMode::Multi {
1729 builder.set_multiselectable(true);
1730 }
1731 let count = sel.count();
1732 if count > 0 {
1733 builder.set_value(format!(
1734 "{} item{} selected",
1735 count,
1736 if count == 1 { "" } else { "s" }
1737 ));
1738 }
1739 builder.set_live(teksilo_core::accesskit::Live::Polite);
1740 }
1741
1742 if let Some(idx) = self.focused_index.get() {
1744 let map = self.tile_map.borrow();
1745 if let Some((_, tile_id)) = map.iter().find(|(i, _)| *i == idx) {
1746 builder.set_active_descendant(widget_id_to_node_id(*tile_id));
1747 }
1748 }
1749 }
1750
1751 fn context_menu_key_target(&self) -> Option<WidgetId> {
1764 let index = self.focused_index.get().or_else(|| {
1765 self.selection
1766 .as_ref()
1767 .and_then(|s| s.selected_indices().first().copied())
1768 })?;
1769 let map = self.tile_map.borrow();
1770 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
1771 }
1772
1773 fn as_any(&self) -> Option<&dyn std::any::Any> {
1774 Some(self)
1775 }
1776
1777 fn children(&self) -> Vec<WidgetId> {
1778 let mut ids = Vec::new();
1779 if let Some(id) = self.body_pane_id {
1780 ids.push(id);
1781 }
1782 if let Some(id) = self.empty_id {
1783 ids.push(id);
1784 }
1785 if let Some(id) = self.scrollbar_id {
1786 ids.push(id);
1787 }
1788 if let Some(id) = self.overlay_id {
1789 ids.push(id);
1790 }
1791 if let Some(id) = self.pinned_header_id {
1792 ids.push(id);
1793 }
1794 if let Some(id) = self.loading_id {
1795 ids.push(id);
1796 }
1797 ids
1798 }
1799
1800 fn clips_children(&self) -> bool {
1801 true
1802 }
1803}
1804
1805struct GridOverlay {
1810 focused_index: Signal<Option<usize>>,
1811 view_focused: Signal<bool>,
1815 focus_visible: Signal<bool>,
1818 selection: Option<SelectionModel>,
1822 scroll_y: Signal<f32>,
1823 strategy: Rc<dyn GridLayoutStrategy>,
1824 viewport_width: Rc<Cell<f32>>,
1825 marquee: Signal<Option<MarqueeState>>,
1826 insertion: Signal<Option<usize>>,
1827 style: Option<Rc<dyn GridViewStyle>>,
1828 len_fn: Rc<dyn Fn() -> usize>,
1834}
1835
1836impl std::fmt::Debug for GridOverlay {
1837 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1838 f.debug_struct("GridOverlay").finish()
1839 }
1840}
1841
1842impl GridOverlay {
1843 fn focus_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridFocusRingRecipe {
1844 resolve_grid_style(&self.style, ctx, |s| s.focus_ring())
1845 }
1846 fn marquee_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridMarqueeRecipe {
1847 resolve_grid_style(&self.style, ctx, |s| s.marquee())
1848 }
1849 fn insertion_recipe(&self, ctx: &PaintContext) -> teksilo_core::styles::GridInsertionRecipe {
1850 resolve_grid_style(&self.style, ctx, |s| s.insertion())
1851 }
1852}
1853
1854fn insertion_bar_geometry(
1862 strategy: &dyn GridLayoutStrategy,
1863 ins: usize,
1864 len: usize,
1865 viewport_width: f32,
1866) -> Option<(f32, TileRect)> {
1867 if len == 0 {
1868 return None;
1869 }
1870 if ins < len {
1871 let r = strategy.tile_rect(ins, viewport_width);
1872 Some((r.x, r))
1873 } else {
1874 let r = strategy.tile_rect(len - 1, viewport_width);
1875 Some((r.x + r.width, r))
1876 }
1877}
1878
1879fn resolve_grid_style<R: Default>(
1882 override_style: &Option<Rc<dyn GridViewStyle>>,
1883 ctx: &PaintContext,
1884 f: impl Fn(&dyn GridViewStyle) -> R,
1885) -> R {
1886 if let Some(s) = override_style {
1887 f(s.as_ref())
1888 } else if let Some(s) = ctx.theme.style_slots.grid_view.as_ref() {
1889 f(s.as_ref())
1890 } else {
1891 R::default()
1892 }
1893}
1894
1895impl Widget for GridOverlay {
1896 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1897 self.scroll_y.bind_to(
1899 ctx.self_id(),
1900 ctx.binding_registry(),
1901 BindingLevel::RepaintOnly,
1902 );
1903 self.focused_index.bind_to(
1904 ctx.self_id(),
1905 ctx.binding_registry(),
1906 BindingLevel::RepaintOnly,
1907 );
1908 self.view_focused.bind_to(
1909 ctx.self_id(),
1910 ctx.binding_registry(),
1911 BindingLevel::RepaintOnly,
1912 );
1913 self.focus_visible.bind_to(
1914 ctx.self_id(),
1915 ctx.binding_registry(),
1916 BindingLevel::RepaintOnly,
1917 );
1918 if let Some(ref sel) = self.selection {
1919 sel.selection_signal().bind_to(
1920 ctx.self_id(),
1921 ctx.binding_registry(),
1922 BindingLevel::RepaintOnly,
1923 );
1924 }
1925 self.marquee.bind_to(
1926 ctx.self_id(),
1927 ctx.binding_registry(),
1928 BindingLevel::RepaintOnly,
1929 );
1930 self.insertion.bind_to(
1931 ctx.self_id(),
1932 ctx.binding_registry(),
1933 BindingLevel::RepaintOnly,
1934 );
1935 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
1937 Vec::new()
1938 }
1939
1940 fn layout_response(
1941 &self,
1942 proposal: SizeProposal,
1943 _ctx: &LayoutContext,
1944 ) -> teksilo_core::widget::LayoutResponse {
1945 proposal.resolve(0.0, 0.0).into()
1946 }
1947
1948 fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
1949 if let Some(m) = self.marquee.get() {
1951 let lr = m.local_rect(self.scroll_y.get());
1952 let rect = Rect::new(bounds.x + lr.x, bounds.y + lr.y, lr.width, lr.height);
1953 let recipe = self.marquee_recipe(ctx);
1954 let c = recipe.role.resolve(&ctx.theme.colors);
1955 let fill = teksilo_tokens::Color::new(c.r(), c.g(), c.b(), recipe.fill_alpha);
1956 canvas.fill_rect(rect, fill);
1957 canvas.stroke_rect(rect, c, recipe.stroke_width);
1958 }
1959
1960 if let Some(ins) = self.insertion.get()
1964 && let Some((bar_x, r)) =
1965 insertion_bar_geometry(self.strategy.as_ref(), ins, (self.len_fn)(), bounds.width)
1966 {
1967 let scroll_y = self.scroll_y.get();
1968 let y = bounds.y + r.y - scroll_y;
1969 let h = r.height;
1970 if y + h >= bounds.y && y <= bounds.bottom() {
1971 let recipe = self.insertion_recipe(ctx);
1972 let color = recipe.role.resolve(&ctx.theme.colors);
1973 let t = recipe.thickness;
1974 canvas.fill_rect(Rect::new(bounds.x + bar_x - t * 0.5, y, t, h), color);
1975 }
1976 }
1977
1978 if !self.view_focused.get() || !self.focus_visible.get() {
1981 return;
1982 }
1983 let idx = self.focused_index.get().filter(|&i| i < (self.len_fn)());
1987 let Some(idx) = idx else {
1988 let empty = self.selection.as_ref().is_none_or(|s| s.count() == 0);
1992 if empty {
1993 let inset = 1.0_f32;
1994 let rect = Rect::new(
1995 bounds.x + inset,
1996 bounds.y + inset,
1997 (bounds.width - inset * 2.0).max(0.0),
1998 (bounds.height - inset * 2.0).max(0.0),
1999 );
2000 let color = teksilo_tokens::BorderRole::Focused.resolve(&ctx.theme.colors);
2001 canvas.stroke_rect(rect, color, 1.5);
2002 }
2003 return;
2004 };
2005 let vp_w = bounds.width;
2006 let r = self.strategy.tile_rect(idx, vp_w);
2007 let scroll_y = self.scroll_y.get();
2008 let recipe = self.focus_recipe(ctx);
2009 let inset = recipe.inset;
2010 let stroke = recipe.thickness;
2011 let rx = bounds.x + r.x + inset;
2012 let ry = bounds.y + r.y - scroll_y + inset;
2013 let rw = (r.width - inset * 2.0).max(0.0);
2014 let rh = (r.height - inset * 2.0).max(0.0);
2015 if ry + rh < bounds.y || ry > bounds.bottom() {
2017 return;
2018 }
2019 let color = recipe.role.resolve(&ctx.theme.colors);
2020 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); }
2025
2026 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2027 builder.set_hidden();
2028 }
2029}
2030
2031struct PinnedHeader {
2035 current_section: Signal<usize>,
2036 #[allow(clippy::type_complexity)]
2037 factory: Rc<dyn Fn(usize) -> Box<dyn Widget>>,
2038 child: Option<WidgetId>,
2039 style: Option<Rc<dyn GridViewStyle>>,
2040}
2041
2042impl std::fmt::Debug for PinnedHeader {
2043 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2044 f.debug_struct("PinnedHeader")
2045 .field("section", &self.current_section.get())
2046 .finish()
2047 }
2048}
2049
2050impl Widget for PinnedHeader {
2051 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2052 self.current_section
2053 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
2054 let section = self.current_section.get();
2055 let id = ctx.add_boxed((self.factory)(section));
2056 self.child = Some(id);
2057 vec![id]
2058 }
2059
2060 fn layout_response(
2061 &self,
2062 proposal: SizeProposal,
2063 _ctx: &LayoutContext,
2064 ) -> teksilo_core::widget::LayoutResponse {
2065 proposal.resolve(0.0, 0.0).into()
2066 }
2067
2068 fn place_children(
2069 &self,
2070 bounds: Rect,
2071 _proposal: SizeProposal,
2072 children: &mut [WidgetPlacement],
2073 _ctx: &LayoutContext,
2074 ) {
2075 for child in children.iter_mut() {
2076 child.origin = bounds.origin();
2077 child.size = bounds.size();
2078 }
2079 }
2080
2081 fn paint(&self, bounds: Rect, canvas: &mut teksilo_canvas::Canvas, ctx: &PaintContext) {
2082 if bounds.height > 0.5 {
2083 let surface = self
2084 .style
2085 .as_ref()
2086 .or(ctx.theme.style_slots.grid_view.as_ref())
2087 .map(|s| s.pinned_header_surface())
2088 .unwrap_or(SurfaceRole::Raised);
2089 canvas.fill_rect(bounds, surface.resolve(&ctx.theme.colors));
2090 }
2091 }
2092
2093 fn children(&self) -> Vec<WidgetId> {
2094 self.child.into_iter().collect()
2095 }
2096
2097 fn clips_children(&self) -> bool {
2098 true
2099 }
2100}
2101
2102#[cfg(test)]
2103mod focus_reveal_tests {
2104 use super::*;
2111 use teksilo_core::widget::LayoutContext;
2112 use teksilo_core::widget_tree::WidgetTree;
2113
2114 #[derive(Debug)]
2115 struct FixedLeaf(f32, f32);
2116 impl Widget for FixedLeaf {
2117 fn layout_response(
2118 &self,
2119 _proposal: SizeProposal,
2120 _ctx: &LayoutContext,
2121 ) -> teksilo_core::widget::LayoutResponse {
2122 Size::new(self.0, self.1).into()
2123 }
2124 }
2125
2126 fn grid_with_selection(selection: &SelectionModel) -> (WidgetTree, WidgetId) {
2130 let model = ListModel::from_vec((0..300).collect::<Vec<usize>>());
2131 let mut tree = WidgetTree::new();
2132 let id = tree.add(
2133 GridView::new(model, |_tc| Box::new(FixedLeaf(100.0, 50.0)))
2134 .tile_size(100.0, 50.0)
2135 .selection(selection.clone()),
2136 );
2137 tree.layout(SizeProposal::exact(400.0, 300.0));
2138 (tree, id)
2139 }
2140
2141 fn selected_positions(tree: &WidgetTree) -> Vec<usize> {
2152 tree.accessibility_tree_snapshot()
2153 .nodes
2154 .iter()
2155 .filter(|(_, node)| node.is_selected() == Some(true))
2156 .filter_map(|(_, node)| node.position_in_set())
2157 .collect()
2158 }
2159
2160 #[test]
2163 fn taking_focus_reveals_the_current_tile() {
2164 let selection = SelectionModel::new(SelectionMode::Single);
2165 selection.select(150);
2166 let (mut tree, id) = grid_with_selection(&selection);
2167
2168 assert!(
2169 selected_positions(&tree).is_empty(),
2170 "tile 150 sits fifty rows below the realized window, which is the \
2171 case this is about"
2172 );
2173
2174 tree.focus(id);
2175 tree.layout(SizeProposal::exact(400.0, 300.0));
2176
2177 assert_eq!(
2178 selected_positions(&tree),
2179 vec![150],
2180 "taking focus has to bring the current tile into the realized \
2181 window, or nothing in the tree can be told about it"
2182 );
2183 }
2184
2185 fn tile_bounds(tree: &WidgetTree, grid: WidgetId, index: usize) -> Option<Rect> {
2190 let tile = tree
2191 .widget_as_any(grid)
2192 .and_then(|any| any.downcast_ref::<GridView<usize>>())
2193 .and_then(|g| {
2194 g.tile_map
2195 .borrow()
2196 .iter()
2197 .find(|(i, _)| *i == index)
2198 .map(|(_, id)| *id)
2199 })?;
2200 Some(tree.bounds(tile))
2201 }
2202
2203 #[test]
2213 fn taking_focus_does_not_move_a_tile_already_in_view() {
2214 let selection = SelectionModel::new(SelectionMode::Single);
2215 selection.select(12);
2216 let (mut tree, id) = grid_with_selection(&selection);
2217
2218 let scroll = tree
2219 .widget_as_any(id)
2220 .and_then(|any| any.downcast_ref::<GridView<usize>>())
2221 .map(|g| g.scroll_y_signal().clone())
2222 .expect("the grid is the widget at `id`");
2223 let before = scroll.get();
2224
2225 let rect = tile_bounds(&tree, id, 12).expect("tile 12 is realized");
2226 assert!(
2227 rect.y >= 0.0 && rect.y + rect.height <= 300.0,
2228 "this case only means anything while tile 12 is fully on screen, \
2229 and it spans y {}..{} of a 300px viewport",
2230 rect.y,
2231 rect.y + rect.height
2232 );
2233
2234 tree.focus(id);
2235 tree.layout(SizeProposal::exact(400.0, 300.0));
2236
2237 assert_eq!(
2238 scroll.get(),
2239 before,
2240 "tile 12 is already fully visible, so taking focus must not scroll \
2241 the grid under somebody who can see it"
2242 );
2243 }
2244}