1use std::cell::RefCell;
82use std::rc::Rc;
83
84use teksilo_canvas::{Rect, SizeProposal};
85
86use teksilo_core::binding::BindingLevel;
87use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
88use teksilo_core::widget_id::WidgetId;
89
90use teksilo_data::{DataChange, ListModel};
91
92use crate::primitives::{HStack, VStack, Wrap};
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum RepeaterLayout {
97 #[default]
99 Vertical,
100 Horizontal,
103 Wrap,
107}
108
109enum RepeaterFactory<T> {
111 Keyless(Rc<dyn Fn(&T) -> Box<dyn Widget>>),
114 Indexed(Rc<dyn Fn(usize, &T) -> Box<dyn Widget>>),
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum ItemSlot {
123 Vacant,
125 Filled(WidgetId),
127}
128
129pub struct Repeater<T: 'static> {
134 model: ListModel<T>,
135 factory: RepeaterFactory<T>,
136 layout: RepeaterLayout,
137 spacing: f32,
138 line_spacing: f32,
139 slots: Option<Rc<RefCell<Vec<ItemSlot>>>>,
145 container_id: Option<WidgetId>,
147}
148
149impl<T: 'static> Repeater<T> {
150 pub fn new(model: ListModel<T>, factory: impl Fn(&T) -> Box<dyn Widget> + 'static) -> Self {
162 Self {
163 model,
164 factory: RepeaterFactory::Keyless(Rc::new(factory)),
165 layout: RepeaterLayout::Vertical,
166 spacing: 0.0,
167 line_spacing: 0.0,
168 slots: Some(Rc::new(RefCell::new(Vec::new()))),
169 container_id: None,
170 }
171 }
172
173 pub fn indexed(
182 model: ListModel<T>,
183 factory: impl Fn(usize, &T) -> Box<dyn Widget> + 'static,
184 ) -> Self {
185 Self {
186 model,
187 factory: RepeaterFactory::Indexed(Rc::new(factory)),
188 layout: RepeaterLayout::Vertical,
189 spacing: 0.0,
190 line_spacing: 0.0,
191 slots: None,
192 container_id: None,
193 }
194 }
195
196 pub fn layout(mut self, layout: RepeaterLayout) -> Self {
198 self.layout = layout;
199 self
200 }
201
202 pub fn horizontal(self) -> Self {
204 self.layout(RepeaterLayout::Horizontal)
205 }
206
207 pub fn wrap(self) -> Self {
209 self.layout(RepeaterLayout::Wrap)
210 }
211
212 pub fn spacing(mut self, spacing: f32) -> Self {
215 self.spacing = spacing;
216 self
217 }
218
219 pub fn line_spacing(mut self, line_spacing: f32) -> Self {
222 self.line_spacing = line_spacing;
223 self
224 }
225
226 fn build_item_ids(&self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
233 let count = self.model.len();
234 let mut ids = Vec::with_capacity(count);
235
236 match &self.factory {
237 RepeaterFactory::Keyless(factory) => {
238 let factory = factory.clone();
239 let slots_rc = self
240 .slots
241 .clone()
242 .expect("reconciling mode always has a reconciliation table");
243 let mut slots = slots_rc.borrow_mut();
244
245 if slots.len() < count {
251 slots.resize(count, ItemSlot::Vacant);
252 } else if slots.len() > count {
253 slots.truncate(count);
254 }
255
256 for i in 0..count {
257 let id = match slots[i] {
258 ItemSlot::Filled(id) => id,
259 ItemSlot::Vacant => {
260 let widget = self
261 .model
262 .with_item(i, |item| factory(item))
263 .expect("index < len() so with_item yields Some");
264 let id = ctx.add_boxed(widget);
265 slots[i] = ItemSlot::Filled(id);
266 id
267 }
268 };
269 ids.push(id);
270 }
271 }
272 RepeaterFactory::Indexed(factory) => {
273 let factory = factory.clone();
274 for i in 0..count {
275 if let Some(widget) = self.model.with_item(i, |item| factory(i, item)) {
278 ids.push(ctx.add_boxed(widget));
279 }
280 }
281 }
282 }
283
284 ids
285 }
286
287 fn build_container(
289 &self,
290 ctx: &mut teksilo_core::build_context::BuildContext,
291 item_ids: &[WidgetId],
292 ) -> WidgetId {
293 match self.layout {
294 RepeaterLayout::Vertical => {
295 let mut container = VStack::new().spacing(self.spacing);
296 for &id in item_ids {
297 container = container.add_child(id);
298 }
299 ctx.add(container)
300 }
301 RepeaterLayout::Horizontal => {
302 let mut container = HStack::new().spacing(self.spacing);
303 for &id in item_ids {
304 container = container.add_child(id);
305 }
306 ctx.add(container)
307 }
308 RepeaterLayout::Wrap => {
309 let mut container = Wrap::new()
310 .spacing(self.spacing)
311 .line_spacing(self.line_spacing);
312 for &id in item_ids {
313 container = container.add_child(id);
314 }
315 ctx.add(container)
316 }
317 }
318 }
319}
320
321fn apply_data_change(slots: &mut Vec<ItemSlot>, change: &DataChange) {
326 match change {
327 DataChange::ItemsInserted { range } => {
328 let start = range.start.min(slots.len());
329 let n = range.len();
330 slots.splice(start..start, std::iter::repeat_n(ItemSlot::Vacant, n));
331 }
332 DataChange::ItemsRemoved { range } => {
333 let start = range.start.min(slots.len());
334 let end = range.end.min(slots.len());
335 if start < end {
336 slots.drain(start..end);
337 }
338 }
339 DataChange::ItemsMoved { from, to, count } => {
340 let (from, to, count) = (*from, *to, *count);
341 if count == 0 || from >= slots.len() {
342 return;
343 }
344 let end = (from + count).min(slots.len());
347 let moved: Vec<ItemSlot> = slots.drain(from..end).collect();
348 let insert_at = to.min(slots.len());
349 slots.splice(insert_at..insert_at, moved);
350 }
351 DataChange::ItemUpdated { index } => {
352 if *index < slots.len() {
355 slots[*index] = ItemSlot::Vacant;
356 }
357 }
358 DataChange::WindowLoaded { range } => {
359 for i in range.clone() {
363 if i < slots.len() {
364 slots[i] = ItemSlot::Vacant;
365 }
366 }
367 }
368 DataChange::Reset => {
369 slots.clear();
371 }
372 }
373}
374
375impl<T: 'static> std::fmt::Debug for Repeater<T> {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("Repeater")
378 .field("item_count", &self.model.len())
379 .field("layout", &self.layout)
380 .field("spacing", &self.spacing)
381 .field(
382 "mode",
383 &if self.slots.is_some() {
384 "reconciling"
385 } else {
386 "indexed"
387 },
388 )
389 .finish()
390 }
391}
392
393impl<T: 'static> Widget for Repeater<T> {
394 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
395 let version = ctx.signal(0_u64);
398 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
399
400 let slots_for_observer = self.slots.clone();
404 let version_for_observer = version.clone();
405 let handle = self.model.observe_changes(move |change| {
406 if let Some(slots) = &slots_for_observer {
407 apply_data_change(&mut slots.borrow_mut(), change);
408 }
409 version_for_observer.set(version_for_observer.get().wrapping_add(1));
410 });
411 ctx.own_handle(handle);
412
413 let item_ids = self.build_item_ids(ctx);
414 let root = self.build_container(ctx, &item_ids);
415 self.container_id = Some(root);
416 vec![root]
417 }
418
419 fn preserves_children_on_rebuild(&self) -> bool {
425 self.slots.is_some()
426 }
427
428 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
429 self.container_id
430 .and_then(|id| ctx.child_size(id, proposal))
431 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
432 .into()
433 }
434
435 fn place_children(
436 &self,
437 bounds: Rect,
438 _proposal: SizeProposal,
439 children: &mut [WidgetPlacement],
440 _ctx: &LayoutContext,
441 ) {
442 for child in children.iter_mut() {
444 child.origin = bounds.origin();
445 child.size = bounds.size();
446 }
447 }
448
449 fn children(&self) -> Vec<WidgetId> {
450 self.container_id.into_iter().collect()
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use std::cell::Cell;
458 use teksilo_canvas::Size;
459 use teksilo_core::widget_tree::WidgetTree;
460
461 #[derive(Debug)]
462 struct FixedLeaf(f32, f32);
463 impl Widget for FixedLeaf {
464 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
465 Size::new(self.0, self.1).into()
466 }
467 }
468
469 #[derive(Debug)]
472 struct CountingLeaf {
473 _tag: u32,
474 }
475 impl CountingLeaf {
476 fn new(tag: u32, builds: &Rc<Cell<u32>>) -> Self {
477 builds.set(builds.get() + 1);
478 Self { _tag: tag }
479 }
480 }
481 impl Widget for CountingLeaf {
482 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
483 Size::new(50.0, 20.0).into()
484 }
485 }
486
487 fn item_ids(tree: &WidgetTree, repeater_id: WidgetId) -> Vec<WidgetId> {
489 let container = tree.children(repeater_id)[0];
490 tree.children(container)
491 }
492
493 fn counting_repeater(
495 model: &ListModel<&'static str>,
496 builds: &Rc<Cell<u32>>,
497 ) -> Repeater<&'static str> {
498 let builds_f = builds.clone();
499 Repeater::new(model.clone(), move |item: &&str| {
500 Box::new(CountingLeaf::new(item.len() as u32, &builds_f))
501 })
502 }
503
504 #[test]
507 fn creates_children_from_model() {
508 let model = ListModel::from_vec(vec!["a", "b", "c"]);
509 let mut tree = WidgetTree::new();
510
511 let repeater_id = tree.add(Repeater::new(model, |_item| {
512 Box::new(FixedLeaf(100.0, 30.0))
513 }));
514 tree.layout(SizeProposal::exact(200.0, 400.0));
515
516 let repeater_children = tree.children(repeater_id);
517 assert_eq!(repeater_children.len(), 1); assert_eq!(item_ids(&tree, repeater_id).len(), 3);
519 }
520
521 #[test]
522 fn empty_model_creates_no_children() {
523 let model: ListModel<&str> = ListModel::new();
524 let mut tree = WidgetTree::new();
525
526 let repeater_id = tree.add(Repeater::new(model, |_item| {
527 Box::new(FixedLeaf(100.0, 30.0))
528 }));
529 tree.layout(SizeProposal::exact(200.0, 400.0));
530 assert_eq!(item_ids(&tree, repeater_id).len(), 0);
531 }
532
533 #[test]
534 fn push_adds_a_child() {
535 let model = ListModel::from_vec(vec!["a", "b"]);
536 let mut tree = WidgetTree::new();
537
538 let repeater_id = tree.add(Repeater::new(model.clone(), |_item| {
539 Box::new(FixedLeaf(100.0, 30.0))
540 }));
541 tree.layout(SizeProposal::exact(200.0, 400.0));
542 assert_eq!(item_ids(&tree, repeater_id).len(), 2);
543
544 model.push("c");
545 tree.layout(SizeProposal::exact(200.0, 400.0));
546 assert_eq!(item_ids(&tree, repeater_id).len(), 3);
547 }
548
549 #[test]
550 fn spacing_is_applied() {
551 let model = ListModel::from_vec(vec!["a", "b", "c"]);
552 let mut tree = WidgetTree::new();
553
554 let repeater_id =
555 tree.add(Repeater::new(model, |_item| Box::new(FixedLeaf(100.0, 20.0))).spacing(10.0));
556 tree.layout(SizeProposal::exact(200.0, 400.0));
557
558 let children = item_ids(&tree, repeater_id);
559 assert_eq!(children.len(), 3);
560 let y0 = tree.bounds(children[0]).y;
561 let y1 = tree.bounds(children[1]).y;
562 let y2 = tree.bounds(children[2]).y;
563 assert!((y1 - y0 - 30.0).abs() < 0.01); assert!((y2 - y1 - 30.0).abs() < 0.01);
565 }
566
567 #[test]
568 fn horizontal_layout_places_children_across() {
569 let model = ListModel::from_vec(vec!["a", "b", "c"]);
570 let mut tree = WidgetTree::new();
571
572 let repeater_id = tree.add(
573 Repeater::new(model, |_item| Box::new(FixedLeaf(40.0, 20.0)))
574 .horizontal()
575 .spacing(10.0),
576 );
577 tree.layout(SizeProposal::exact(400.0, 100.0));
578
579 let children = item_ids(&tree, repeater_id);
580 assert_eq!(children.len(), 3);
581 let x0 = tree.bounds(children[0]).x;
582 let x1 = tree.bounds(children[1]).x;
583 let x2 = tree.bounds(children[2]).x;
584 assert!((tree.bounds(children[0]).y - tree.bounds(children[1]).y).abs() < 0.01);
586 assert!((x1 - x0 - 50.0).abs() < 0.01); assert!((x2 - x1 - 50.0).abs() < 0.01);
588 }
589
590 #[test]
591 fn wrap_layout_flows_to_next_line() {
592 let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
593 let mut tree = WidgetTree::new();
594
595 let repeater_id = tree.add(
597 Repeater::new(model, |_item| Box::new(FixedLeaf(40.0, 20.0)))
598 .wrap()
599 .spacing(10.0)
600 .line_spacing(6.0),
601 );
602 tree.layout(SizeProposal::exact(100.0, 200.0));
603
604 let children = item_ids(&tree, repeater_id);
605 assert_eq!(children.len(), 4);
606 let y_first = tree.bounds(children[0]).y;
607 assert!(
608 tree.bounds(children[2]).y > y_first + 0.01,
609 "third item should wrap to the next line"
610 );
611 }
612
613 #[test]
616 fn reuses_widget_ids_on_insert() {
617 let model = ListModel::from_vec(vec!["a", "b", "c"]);
618 let builds = Rc::new(Cell::new(0));
619 let mut tree = WidgetTree::new();
620
621 let repeater_id = tree.add(counting_repeater(&model, &builds));
622 tree.layout(SizeProposal::exact(200.0, 400.0));
623 let before = item_ids(&tree, repeater_id);
624 assert_eq!(before.len(), 3);
625 assert_eq!(builds.get(), 3, "each item built once");
626
627 model.insert(0, "z");
629 tree.layout(SizeProposal::exact(200.0, 400.0));
630
631 let after = item_ids(&tree, repeater_id);
632 assert_eq!(after.len(), 4);
633 assert_eq!(builds.get(), 4, "only the inserted item built anew");
634 assert_eq!(&after[1..], &before[..], "survivors keep their widgets");
635 assert!(!before.contains(&after[0]), "index 0 is a fresh widget");
636 }
637
638 #[test]
639 fn reorder_preserves_all_widgets() {
640 let model = ListModel::from_vec(vec!["a", "b", "c"]);
641 let builds = Rc::new(Cell::new(0));
642 let mut tree = WidgetTree::new();
643
644 let repeater_id = tree.add(counting_repeater(&model, &builds));
645 tree.layout(SizeProposal::exact(200.0, 400.0));
646 let before = item_ids(&tree, repeater_id);
647 assert_eq!(builds.get(), 3);
648
649 model.move_item(0, 2);
650 tree.layout(SizeProposal::exact(200.0, 400.0));
651
652 let after = item_ids(&tree, repeater_id);
653 assert_eq!(builds.get(), 3, "reorder builds nothing");
654 assert_eq!(after, vec![before[1], before[2], before[0]]);
655 }
656
657 #[test]
658 fn remove_reaps_only_removed() {
659 let model = ListModel::from_vec(vec!["a", "b", "c"]);
660 let builds = Rc::new(Cell::new(0));
661 let mut tree = WidgetTree::new();
662
663 let repeater_id = tree.add(counting_repeater(&model, &builds));
664 tree.layout(SizeProposal::exact(200.0, 400.0));
665 let before = item_ids(&tree, repeater_id);
666
667 model.remove(1);
668 tree.layout(SizeProposal::exact(200.0, 400.0));
669
670 let after = item_ids(&tree, repeater_id);
671 assert_eq!(builds.get(), 3, "remove builds nothing");
672 assert_eq!(after, vec![before[0], before[2]]);
673 assert!(!tree.is_active(before[1]), "removed widget reaped");
674 }
675
676 #[test]
677 fn update_rebuilds_only_that_item() {
678 let model = ListModel::from_vec(vec!["a", "b", "c"]);
679 let builds = Rc::new(Cell::new(0));
680 let mut tree = WidgetTree::new();
681
682 let repeater_id = tree.add(counting_repeater(&model, &builds));
683 tree.layout(SizeProposal::exact(200.0, 400.0));
684 let before = item_ids(&tree, repeater_id);
685
686 model.set(1, "beta");
687 tree.layout(SizeProposal::exact(200.0, 400.0));
688
689 let after = item_ids(&tree, repeater_id);
690 assert_eq!(builds.get(), 4, "exactly one extra build for the update");
691 assert_eq!(after[0], before[0], "unchanged neighbours reused");
692 assert_eq!(after[2], before[2], "unchanged neighbours reused");
693 assert_ne!(after[1], before[1], "updated item is a fresh widget");
694 assert!(!tree.is_active(before[1]), "stale widget reaped");
695 }
696
697 #[test]
698 fn reset_rebuilds_all() {
699 let model = ListModel::from_vec(vec!["a", "b"]);
700 let builds = Rc::new(Cell::new(0));
701 let mut tree = WidgetTree::new();
702
703 let repeater_id = tree.add(counting_repeater(&model, &builds));
704 tree.layout(SizeProposal::exact(200.0, 400.0));
705 let before = item_ids(&tree, repeater_id);
706 assert_eq!(builds.get(), 2);
707
708 model.replace_all(vec!["x", "y", "z"]);
709 tree.layout(SizeProposal::exact(200.0, 400.0));
710
711 let after = item_ids(&tree, repeater_id);
712 assert_eq!(after.len(), 3);
713 assert_eq!(builds.get(), 5, "all three rebuilt after a reset");
714 for id in &before {
715 assert!(!after.contains(id), "no widget survives a reset");
716 }
717 }
718
719 #[test]
720 fn preserves_child_signal_state_across_insert() {
721 use teksilo_core::signal::Signal;
725
726 #[derive(Debug)]
727 struct Stateful {
728 state: Signal<u32>,
729 }
730 impl Widget for Stateful {
731 fn build(
732 &mut self,
733 ctx: &mut teksilo_core::build_context::BuildContext,
734 ) -> Vec<WidgetId> {
735 self.state.bind_to(
740 ctx.self_id(),
741 ctx.binding_registry(),
742 BindingLevel::Relayout,
743 );
744 vec![]
745 }
746 fn layout_response(&self, _p: SizeProposal, _c: &LayoutContext) -> LayoutResponse {
747 Size::new(20.0, self.state.get() as f32).into()
749 }
750 }
751
752 let seeds: Rc<RefCell<Vec<Signal<u32>>>> = Rc::new(RefCell::new(Vec::new()));
753 let model = ListModel::from_vec(vec![1_u32, 2, 3]);
754 let mut tree = WidgetTree::new();
755
756 let seeds_f = seeds.clone();
757 let repeater_id = tree.add(Repeater::new(model.clone(), move |item: &u32| {
758 let state = Signal::new(*item);
759 seeds_f.borrow_mut().push(state.clone());
760 Box::new(Stateful { state })
761 }));
762
763 tree.layout(SizeProposal::exact(200.0, 400.0));
764 let before = item_ids(&tree, repeater_id);
765 seeds.borrow()[1].set(999);
767 tree.layout(SizeProposal::exact(200.0, 400.0));
768 assert!((tree.bounds(before[1]).height - 999.0).abs() < 0.01);
769
770 model.insert(0, 0);
772 tree.layout(SizeProposal::exact(200.0, 400.0));
773
774 let after = item_ids(&tree, repeater_id);
775 assert_eq!(after[2], before[1], "the stateful child was reused");
776 assert!(
777 (tree.bounds(after[2]).height - 999.0).abs() < 0.01,
778 "reused child kept its mutated state"
779 );
780 }
781
782 #[test]
783 fn preserves_focus_across_insert() {
784 use teksilo_core::widget_builder::WidgetBuilder;
787
788 let model = ListModel::from_vec(vec!["a", "b", "c"]);
789 let mut tree = WidgetTree::new();
790
791 let repeater_id = tree.add(Repeater::new(model.clone(), |_item: &&str| {
792 Box::new(FixedLeaf(50.0, 20.0).focusable(true))
793 }));
794
795 tree.layout(SizeProposal::exact(200.0, 400.0));
796 let before = item_ids(&tree, repeater_id);
797
798 tree.focus(before[1]);
800 assert_eq!(tree.focused(), Some(before[1]));
801
802 model.insert(0, "z");
803 tree.layout(SizeProposal::exact(200.0, 400.0));
804
805 let after = item_ids(&tree, repeater_id);
806 assert_eq!(after[2], before[1], "the focused child was reused");
807 assert_eq!(
808 tree.focused(),
809 Some(before[1]),
810 "focus stays on the same widget across the insert"
811 );
812 }
813
814 #[test]
817 fn indexed_factory_receives_index_and_item() {
818 let model = ListModel::from_vec(vec![10.0_f32, 20.0, 30.0]);
819 let mut tree = WidgetTree::new();
820
821 let repeater_id = tree.add(Repeater::indexed(model, |i, item| {
822 Box::new(FixedLeaf(i as f32, *item))
824 }));
825 tree.layout(SizeProposal::exact(200.0, 400.0));
826
827 let children = item_ids(&tree, repeater_id);
828 assert_eq!(children.len(), 3);
829 assert!((tree.bounds(children[0]).height - 10.0).abs() < 0.01);
830 assert!((tree.bounds(children[1]).height - 20.0).abs() < 0.01);
831 assert!((tree.bounds(children[2]).height - 30.0).abs() < 0.01);
832 }
833
834 #[test]
835 fn indexed_rebuilds_every_child_on_change() {
836 let model = ListModel::from_vec(vec!["a", "b", "c"]);
839 let builds = Rc::new(Cell::new(0));
840 let mut tree = WidgetTree::new();
841
842 let builds_f = builds.clone();
843 let repeater_id = tree.add(Repeater::indexed(model.clone(), move |_i, item: &&str| {
844 Box::new(CountingLeaf::new(item.len() as u32, &builds_f))
845 }));
846
847 tree.layout(SizeProposal::exact(200.0, 400.0));
848 let before = item_ids(&tree, repeater_id);
849 assert_eq!(builds.get(), 3);
850
851 model.insert(0, "z");
854 tree.layout(SizeProposal::exact(200.0, 400.0));
855
856 let after = item_ids(&tree, repeater_id);
857 assert_eq!(after.len(), 4);
858 assert_eq!(builds.get(), 7, "3 initial + 4 rebuilt");
859 for id in &before {
860 assert!(!after.contains(id), "no widget is reused in indexed mode");
861 }
862 }
863
864 #[test]
867 fn accepts_standard_access_overrides() {
868 use teksilo_core::accesskit::Role;
869 use teksilo_core::widget_builder::WidgetBuilder;
870 use teksilo_i18n::lit;
871
872 let model = ListModel::from_vec(vec!["a", "b"]);
873 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
874
875 let repeater_id = tree.add(
876 Repeater::new(model, |_item| Box::new(FixedLeaf(100.0, 20.0)))
877 .access_role(Role::List)
878 .access_label(lit!("Tags")),
879 );
880 tree.layout(SizeProposal::exact(200.0, 200.0));
881
882 let node = tree.accessibility_node(repeater_id);
883 assert_eq!(node.role(), Role::List);
884 assert_eq!(node.name(), Some("Tags"));
885 }
886}