1use std::cell::RefCell;
55use std::rc::Rc;
56
57use frus_core::{Color, Rect, Scene};
58use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
59
60use crate::expanded::{flex_item, FlexFit};
61use crate::icons::{IconData, Icons};
62use crate::interaction::Status;
63use crate::theme::Theme;
64use crate::widget::{ReorderAxis, Widget};
65use crate::{Container, Icon, ThemeBuilder};
66
67const HANDLE_WIDTH: f32 = 40.0;
69const HANDLE_ICON: f32 = 20.0;
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum ReorderGrab {
79 Handle,
82 LongPress,
86}
87
88fn platform_grab(platform: frus_core::TargetPlatform) -> ReorderGrab {
92 use frus_core::TargetPlatform as P;
93 match platform {
94 P::Linux | P::Windows | P::MacOs => ReorderGrab::Handle,
95 P::Android | P::Fuchsia | P::Ios => ReorderGrab::LongPress,
96 }
97}
98
99pub fn settled_index(from: usize, raw: usize) -> usize {
108 if raw > from {
109 raw - 1
110 } else {
111 raw
112 }
113}
114
115type ReorderFn<Msg> = Rc<dyn Fn(usize, usize) -> Msg>;
116type HandleFn<Msg> = Rc<dyn Fn(&Theme) -> Box<dyn Widget<Msg>>>;
117
118struct Spec<Msg> {
125 grab: Option<ReorderGrab>,
127 platform: frus_core::TargetPlatform,
130 axis: ReorderAxis,
132 enabled: bool,
133 on_reorder: Option<ReorderFn<Msg>>,
134 handle: Option<HandleFn<Msg>>,
135 handle_icon: Option<IconData>,
136 handle_color: Option<Color>,
137 handle_icon_size: Option<f32>,
138 handle_width: f32,
139 count: usize,
142}
143
144impl<Msg> Spec<Msg> {
145 fn grab(&self) -> ReorderGrab {
147 self.grab.unwrap_or_else(|| platform_grab(self.platform))
148 }
149
150 fn gripped(&self) -> bool {
152 self.enabled && self.grab() == ReorderGrab::Handle
153 }
154
155 fn held(&self) -> bool {
157 self.enabled && self.grab() == ReorderGrab::LongPress
158 }
159}
160
161type Shared<Msg> = Rc<RefCell<Spec<Msg>>>;
162
163fn move_message<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<Msg> {
170 let to = settled_index(from, raw);
171 if to == from {
172 return None;
173 }
174 spec.on_reorder.as_ref().map(|f| f(from, to))
175}
176
177fn move_announcement<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<String> {
180 let to = settled_index(from, raw);
181 (to != from).then(|| format!("Moved to position {} of {}", to + 1, spec.count.max(to + 1)))
182}
183
184struct ReorderRow<Msg> {
189 index: usize,
190 key: Option<u64>,
191 spec: Shared<Msg>,
192 children: Vec<Box<dyn Widget<Msg>>>,
193}
194
195impl<Msg: Clone + 'static> Widget<Msg> for ReorderRow<Msg> {
196 fn style(&self) -> Style {
197 let direction = match self.spec.borrow().axis {
200 ReorderAxis::Vertical => FlexDirection::Row,
201 ReorderAxis::Horizontal => FlexDirection::Column,
202 };
203 Style {
204 width: Dimension::Auto,
205 height: Dimension::Auto,
206 flex_direction: direction,
207 align: Align::Center,
208 ..Default::default()
209 }
210 }
211
212 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
213 &self.children
214 }
215
216 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
217
218 fn on_click(&self) -> Option<Msg> {
219 None
220 }
221
222 fn key(&self) -> Option<u64> {
223 self.key
224 }
225
226 fn reorder_index(&self) -> Option<usize> {
227 self.spec.borrow().enabled.then_some(self.index)
228 }
229
230 fn on_reorder(&self, to: usize) -> Option<Msg> {
231 move_message(&self.spec.borrow(), self.index, to)
232 }
233
234 fn reorder_announcement(&self, to: usize) -> Option<String> {
235 move_announcement(&self.spec.borrow(), self.index, to)
236 }
237
238 fn reorder_axis(&self) -> ReorderAxis {
239 self.spec.borrow().axis
240 }
241
242 fn reorder_inserts(&self) -> bool {
243 true
244 }
245
246 fn reorder_draggable(&self) -> bool {
247 self.spec.borrow().held()
248 }
249
250 fn drag_needs_long_press(&self) -> bool {
251 self.spec.borrow().held()
252 }
253}
254
255struct ReorderHandle<Msg> {
261 index: usize,
262 spec: Shared<Msg>,
263 children: Vec<Box<dyn Widget<Msg>>>,
264}
265
266impl<Msg: Clone + 'static> Widget<Msg> for ReorderHandle<Msg> {
267 fn style_themed(&self, theme: &Theme) -> Style {
271 self.spec.borrow_mut().platform = theme.platform;
272 self.style()
273 }
274
275 fn style(&self) -> Style {
276 let spec = self.spec.borrow();
277 if !spec.gripped() {
278 return Style {
279 width: Dimension::Length(0.0),
280 height: Dimension::Length(0.0),
281 ..Default::default()
282 };
283 }
284 let (width, height) = match spec.axis {
287 ReorderAxis::Vertical => (Dimension::Length(spec.handle_width), Dimension::Auto),
288 ReorderAxis::Horizontal => (Dimension::Auto, Dimension::Length(spec.handle_width)),
289 };
290 Style {
291 width,
292 height,
293 flex_shrink: 0.0,
296 align: Align::Center,
297 justify: Justify::Center,
298 ..Default::default()
299 }
300 }
301
302 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
303 &self.children
304 }
305
306 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
307
308 fn on_click(&self) -> Option<Msg> {
309 None
310 }
311
312 fn reorder_index(&self) -> Option<usize> {
313 self.spec.borrow().gripped().then_some(self.index)
314 }
315
316 fn on_reorder(&self, to: usize) -> Option<Msg> {
317 move_message(&self.spec.borrow(), self.index, to)
318 }
319
320 fn reorder_announcement(&self, to: usize) -> Option<String> {
321 move_announcement(&self.spec.borrow(), self.index, to)
322 }
323
324 fn reorder_axis(&self) -> ReorderAxis {
325 self.spec.borrow().axis
326 }
327
328 fn reorder_inserts(&self) -> bool {
329 true
330 }
331
332 fn reorder_droppable(&self) -> bool {
333 false
334 }
335}
336
337struct ReorderContent<Msg> {
346 inner: Box<dyn Widget<Msg>>,
347 spec: Shared<Msg>,
348}
349
350impl<Msg> ReorderContent<Msg> {
351 fn restyle(&self, base: Style) -> Style {
353 let expanded = flex_item(base, 1.0, FlexFit::Tight);
354 match self.spec.borrow().axis {
355 ReorderAxis::Vertical => expanded,
356 ReorderAxis::Horizontal => Style {
357 flex_basis: Dimension::Auto,
358 ..expanded
359 },
360 }
361 }
362}
363
364crate::transparent::forward_transparent!(ReorderContent {
365 fn key(&self) -> Option<u64> {
367 self.inner.key()
368 }
369
370 fn positioned(&self) -> Option<crate::positioned::Positioning> {
372 self.inner.positioned()
373 }
374
375 fn theme_override(
377 &self,
378 inherited: &crate::theme::Theme,
379 ) -> Option<Box<crate::theme::Theme>> {
380 self.inner.theme_override(inherited)
381 }
382
383 fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
385 self.inner.media_override(inherited)
386 }
387 fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
388 self.inner.scaffold_override()
389 }
390 fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
391 self.inner.scroll_behavior_override()
392 }
393 fn autofill_group(&self) -> bool {
395 self.inner.autofill_group()
396 }
397 fn area_toolbar(
399 &self,
400 context: crate::ToolbarContext,
401 ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
402 self.inner.area_toolbar(context)
403 }
404});
405
406pub struct ReorderableList<Msg = crate::callback::Callback> {
423 spec: Shared<Msg>,
424 rows: Vec<Box<dyn Widget<Msg>>>,
425 gap: f32,
426 width: Dimension,
427 height: Dimension,
428 flex_grow: f32,
429}
430
431impl<Msg: Clone + 'static> ReorderableList<Msg> {
432 pub fn new<R: crate::callback::IntoMsg<Msg>>(
435 on_reorder: impl Fn(usize, usize) -> R + 'static,
436 ) -> Self {
437 Self {
438 spec: Rc::new(RefCell::new(Spec {
439 grab: None,
440 platform: frus_core::default_target_platform(),
441 axis: ReorderAxis::Vertical,
442 enabled: true,
443 on_reorder: Some(Rc::new(crate::callback::handler2(on_reorder))),
444 handle: None,
445 handle_icon: None,
446 handle_color: None,
447 handle_icon_size: None,
448 handle_width: HANDLE_WIDTH,
449 count: 0,
450 })),
451 rows: Vec::new(),
452 gap: 0.0,
453 width: Dimension::Auto,
454 height: Dimension::Auto,
455 flex_grow: 0.0,
456 }
457 }
458
459 #[must_use]
461 pub fn keyed_row(self, key: u64, row: impl Widget<Msg> + 'static) -> Self {
462 self.push(Some(key), Box::new(row))
463 }
464
465 #[must_use]
467 pub fn row(self, row: impl Widget<Msg> + 'static) -> Self {
468 self.push(None, Box::new(row))
469 }
470
471 fn push(mut self, key: Option<u64>, row: Box<dyn Widget<Msg>>) -> Self {
472 let index = self.rows.len();
473 self.spec.borrow_mut().count = index + 1;
474 let spec = Rc::clone(&self.spec);
475 let glyph = Rc::clone(&self.spec);
476 let handle = ReorderHandle {
481 index,
482 spec: Rc::clone(&self.spec),
483 children: vec![Box::new(ThemeBuilder::new(move |theme: &Theme| {
484 grip(&glyph.borrow(), theme)
485 }))],
486 };
487 self.rows.push(Box::new(ReorderRow {
488 index,
489 key,
490 spec,
491 children: vec![
492 Box::new(ReorderContent {
493 inner: row,
494 spec: Rc::clone(&self.spec),
495 }),
496 Box::new(handle),
497 ],
498 }));
499 self
500 }
501
502 #[must_use]
505 pub fn grab(self, grab: ReorderGrab) -> Self {
506 self.spec.borrow_mut().grab = Some(grab);
507 self
508 }
509
510 #[must_use]
514 pub fn axis(self, axis: ReorderAxis) -> Self {
515 self.spec.borrow_mut().axis = axis;
516 self
517 }
518
519 #[must_use]
523 pub fn enabled(self, enabled: bool) -> Self {
524 self.spec.borrow_mut().enabled = enabled;
525 self
526 }
527
528 #[must_use]
532 pub fn handle<W: Widget<Msg> + 'static>(self, handle: impl Fn(&Theme) -> W + 'static) -> Self {
533 self.spec.borrow_mut().handle = Some(Rc::new(move |theme| Box::new(handle(theme))));
534 self
535 }
536
537 #[must_use]
539 pub fn handle_icon(self, icon: IconData) -> Self {
540 self.spec.borrow_mut().handle_icon = Some(icon);
541 self
542 }
543
544 #[must_use]
546 pub fn handle_color(self, color: Color) -> Self {
547 self.spec.borrow_mut().handle_color = Some(color);
548 self
549 }
550
551 #[must_use]
553 pub fn handle_icon_size(self, size: f32) -> Self {
554 self.spec.borrow_mut().handle_icon_size = Some(size);
555 self
556 }
557
558 #[must_use]
562 pub fn handle_width(self, width: f32) -> Self {
563 self.spec.borrow_mut().handle_width = width;
564 self
565 }
566
567 #[must_use]
569 pub fn gap(mut self, gap: f32) -> Self {
570 self.gap = gap;
571 self
572 }
573
574 #[must_use]
576 pub fn width(mut self, width: f32) -> Self {
577 self.width = Dimension::Length(width);
578 self
579 }
580
581 #[must_use]
583 pub fn width_fraction(mut self, fraction: f32) -> Self {
584 self.width = Dimension::Percent(fraction);
585 self
586 }
587
588 #[must_use]
590 pub fn height(mut self, height: f32) -> Self {
591 self.height = Dimension::Length(height);
592 self
593 }
594
595 #[must_use]
597 pub fn flex(mut self, grow: f32) -> Self {
598 self.flex_grow = grow;
599 self
600 }
601}
602
603fn grip<Msg: Clone + 'static>(spec: &Spec<Msg>, theme: &Theme) -> Box<dyn Widget<Msg>> {
605 if !(spec.enabled
606 && spec.grab.unwrap_or_else(|| platform_grab(theme.platform)) == ReorderGrab::Handle)
607 {
608 return Box::new(Container::new().width(0.0).height(0.0));
610 }
611 if let Some(handle) = &spec.handle {
612 return handle(theme);
613 }
614 let icon = spec
615 .handle_icon
616 .or(theme.widgets.reorderable.handle_icon)
617 .unwrap_or(Icons::DRAG_HANDLE);
618 let size = spec
619 .handle_icon_size
620 .or(theme.widgets.reorderable.handle_icon_size)
621 .unwrap_or(HANDLE_ICON);
622 let color = spec
623 .handle_color
624 .or(theme.widgets.reorderable.handle_color)
625 .unwrap_or(theme.muted);
626 Box::new(Icon::new(icon).size(size).color(color))
627}
628
629impl<Msg: Clone + 'static> Widget<Msg> for ReorderableList<Msg> {
630 fn style(&self) -> Style {
631 Style {
632 width: self.width,
633 height: self.height,
634 flex_grow: self.flex_grow,
635 flex_direction: match self.spec.borrow().axis {
636 ReorderAxis::Vertical => FlexDirection::Column,
637 ReorderAxis::Horizontal => FlexDirection::Row,
638 },
639 gap: self.gap,
640 ..Default::default()
641 }
642 }
643
644 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
645 &self.rows
646 }
647
648 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
649
650 fn on_click(&self) -> Option<Msg> {
651 None
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use crate::inspector::InspectorNode;
659 use crate::widget::Widget;
660 use crate::{text, Container, Runtime, Size};
661
662 #[derive(Clone, Debug, PartialEq)]
663 enum Msg {
664 Moved(usize, usize),
665 }
666
667 fn list(grab: ReorderGrab) -> ReorderableList<Msg> {
668 ReorderableList::new(Msg::Moved)
669 .grab(grab)
670 .width(300.0)
671 .keyed_row(1, Container::new().height(40.0).child(text("one")))
672 .keyed_row(2, Container::new().height(40.0).child(text("two")))
673 .keyed_row(3, Container::new().height(40.0).child(text("three")))
674 }
675
676 fn nodes_of(root: &dyn Widget<Msg>) -> Vec<&dyn Widget<Msg>> {
683 crate::build_deferred(root, &Theme::dark(), &crate::Runtime::default());
684 let mut out: Vec<&dyn Widget<Msg>> = vec![root];
685 let mut i = 0;
686 while i < out.len() {
687 let node = out[i];
688 for child in node.children() {
689 out.push(child.as_ref());
690 }
691 i += 1;
692 }
693 out
694 }
695
696 fn inspected(root: &dyn Widget<Msg>) -> Vec<InspectorNode> {
698 crate::build_ui_inspected(
699 root,
700 Size::new(300.0, 400.0),
701 &Runtime::default(),
702 &Theme::dark(),
703 )
704 .1
705 }
706
707 fn slots(root: &dyn Widget<Msg>) -> Vec<(usize, bool, bool)> {
709 nodes_of(root)
710 .into_iter()
711 .filter_map(|w| {
712 w.reorder_index()
713 .map(|i| (i, w.reorder_draggable(), w.reorder_droppable()))
714 })
715 .collect()
716 }
717
718 #[test]
722 fn an_untold_list_follows_the_theme_s_platform() {
723 use frus_core::TargetPlatform as P;
724 let untold = || {
725 ReorderableList::new(Msg::Moved)
726 .width(300.0)
727 .keyed_row(1, Container::new().height(40.0).child(text("one")))
728 .keyed_row(2, Container::new().height(40.0).child(text("two")))
729 };
730 let held = |list: &ReorderableList<Msg>, platform: P| {
732 let theme = Theme::dark().with_platform(platform);
733 crate::build_ui(list, Size::new(300.0, 400.0), &Runtime::default(), &theme);
734 let mut nodes: Vec<&dyn Widget<Msg>> = vec![list];
735 let mut i = 0;
736 while i < nodes.len() {
737 for child in nodes[i].children() {
738 nodes.push(child.as_ref());
739 }
740 i += 1;
741 }
742 let rows: Vec<_> = nodes
743 .into_iter()
744 .filter(|w| w.reorder_index().is_some() && w.reorder_droppable())
745 .collect();
746 assert_eq!(rows.len(), 2, "two rows under {platform}");
747 rows.iter().all(|w| w.drag_needs_long_press())
748 };
749 for platform in P::ALL {
750 let finger = matches!(platform, P::Android | P::Fuchsia | P::Ios);
751 assert_eq!(held(&untold(), platform), finger, "{platform}");
752 assert!(
753 !held(&untold().grab(ReorderGrab::Handle), platform),
754 "told, {platform}"
755 );
756 assert!(
757 held(&untold().grab(ReorderGrab::LongPress), platform),
758 "told, {platform}"
759 );
760 }
761 }
762
763 #[test]
764 fn a_settled_index_is_the_raw_one_minus_the_row_that_left() {
765 assert_eq!(settled_index(0, 3), 2);
767 assert_eq!(settled_index(3, 0), 0);
769 assert_eq!(settled_index(2, 2), 2);
771 assert_eq!(settled_index(2, 3), 2);
772 }
773
774 #[test]
775 fn a_gripped_row_is_a_target_and_its_grip_is_the_source() {
776 let list = list(ReorderGrab::Handle);
777 let slots = slots(&list);
778 assert_eq!(slots.len(), 6, "three rows and three grips: {slots:?}");
780 for i in 0..3 {
781 assert!(
782 slots.contains(&(i, false, true)),
783 "row {i} is a target and not a source: {slots:?}"
784 );
785 assert!(
786 slots.contains(&(i, true, false)),
787 "grip {i} is a source and not a target: {slots:?}"
788 );
789 }
790 }
791
792 #[test]
793 fn a_held_row_is_both_and_waits_for_the_hold() {
794 let list = list(ReorderGrab::LongPress);
795 assert_eq!(
797 slots(&list),
798 vec![(0, true, true), (1, true, true), (2, true, true)]
799 );
800 let held: Vec<bool> = nodes_of(&list)
802 .into_iter()
803 .filter(|w| w.reorder_index().is_some())
804 .map(|w| w.drag_needs_long_press())
805 .collect();
806 assert_eq!(held, vec![true, true, true]);
807 }
808
809 #[test]
810 fn the_message_carries_where_the_row_ends_up() {
811 let list = list(ReorderGrab::Handle);
812 let row = nodes_of(&list)
813 .into_iter()
814 .find(|w| w.reorder_index() == Some(0) && !w.reorder_draggable())
815 .expect("the first row");
816 assert_eq!(row.on_reorder(3), Some(Msg::Moved(0, 2)));
818 assert_eq!(row.on_reorder(1), None, "a move of nothing sends nothing");
820 assert_eq!(
821 row.reorder_announcement(3).as_deref(),
822 Some("Moved to position 3 of 3")
823 );
824 assert_eq!(row.reorder_announcement(1), None);
825 }
826
827 #[test]
828 fn a_setter_after_a_row_still_reaches_it() {
829 let late = ReorderableList::new(Msg::Moved)
832 .width(300.0)
833 .keyed_row(1, Container::new().height(40.0).child(text("one")))
834 .grab(ReorderGrab::LongPress);
835 assert_eq!(slots(&late), vec![(0, true, true)]);
836 }
837
838 #[test]
839 fn a_disabled_list_offers_nothing_to_grab() {
840 let list = list(ReorderGrab::Handle).enabled(false);
841 assert!(slots(&list).is_empty(), "no source and no target");
842 }
843
844 #[test]
847 fn the_grip_takes_its_own_room_at_the_trailing_edge() {
848 let list = list(ReorderGrab::Handle);
849 let nodes = inspected(&list);
850 let grips: Vec<&InspectorNode> =
851 nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
852 assert_eq!(grips.len(), 3, "one grip per row");
853 for grip in grips {
854 assert!(
855 (grip.rect.width - HANDLE_WIDTH).abs() < 0.5,
856 "the grip keeps its 40 px: {:?}",
857 grip.rect
858 );
859 assert!(
860 (grip.rect.x + grip.rect.width - 300.0).abs() < 0.5,
861 "at the trailing edge: {:?}",
862 grip.rect
863 );
864 }
865 }
866
867 #[test]
876 fn the_grip_sits_inside_the_row_it_moves() {
877 let list = list(ReorderGrab::Handle);
878 let nodes = inspected(&list);
879 let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
880 let grips: Vec<&InspectorNode> =
881 nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
882 assert_eq!((rows.len(), grips.len()), (3, 3));
883 for (row, grip) in rows.iter().zip(&grips) {
884 let middle = frus_core::Point::new(
885 grip.rect.x + grip.rect.width * 0.5,
886 grip.rect.y + grip.rect.height * 0.5,
887 );
888 assert!(
889 row.rect.contains(middle),
890 "the grip's middle {middle:?} is inside its row {:?}",
891 row.rect
892 );
893 }
894 let indices: Vec<(usize, bool)> = nodes_of(&list)
896 .into_iter()
897 .filter_map(|w| w.reorder_index().map(|i| (i, w.reorder_droppable())))
898 .collect();
899 for i in 0..3 {
900 assert!(indices.contains(&(i, true)) && indices.contains(&(i, false)));
901 }
902 }
903
904 fn strip(grab: ReorderGrab) -> ReorderableList<Msg> {
906 ReorderableList::new(Msg::Moved)
907 .grab(grab)
908 .axis(ReorderAxis::Horizontal)
909 .width(300.0)
910 .keyed_row(1, Container::new().width(80.0).child(text("one")))
911 .keyed_row(2, Container::new().width(80.0).child(text("two")))
912 .keyed_row(3, Container::new().width(80.0).child(text("three")))
913 }
914
915 #[test]
919 fn a_horizontal_list_lays_its_rows_along_x_with_the_grip_underneath() {
920 let list = strip(ReorderGrab::Handle);
921 let nodes = inspected(&list);
922 let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
923 let grips: Vec<&InspectorNode> =
924 nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
925 assert_eq!((rows.len(), grips.len()), (3, 3));
926 for pair in rows.windows(2) {
927 assert!(
928 (pair[0].rect.y - pair[1].rect.y).abs() < 0.5
929 && pair[1].rect.x >= pair[0].rect.x + pair[0].rect.width - 0.5,
930 "side by side, in order: {:?} then {:?}",
931 pair[0].rect,
932 pair[1].rect
933 );
934 }
935 for (row, grip) in rows.iter().zip(&grips) {
936 assert!(
937 (grip.rect.height - HANDLE_WIDTH).abs() < 0.5,
938 "the grip keeps its 40 px, as a height: {:?}",
939 grip.rect
940 );
941 assert!(
942 (grip.rect.y + grip.rect.height - (row.rect.y + row.rect.height)).abs() < 0.5,
943 "at the row's bottom edge: {:?} in {:?}",
944 grip.rect,
945 row.rect
946 );
947 assert!(
948 row.rect.height > HANDLE_WIDTH + 8.0,
949 "and the row's content keeps its room above it: {:?}",
950 row.rect
951 );
952 let middle = frus_core::Point::new(
953 grip.rect.x + grip.rect.width * 0.5,
954 grip.rect.y + grip.rect.height * 0.5,
955 );
956 assert!(
957 row.rect.contains(middle),
958 "{middle:?} inside {:?}",
959 row.rect
960 );
961 }
962 }
963
964 #[test]
970 fn a_row_across_a_strip_keeps_its_height() {
971 let content = |list: &ReorderableList<Msg>| -> Vec<Rect> {
972 inspected(list)
973 .iter()
974 .filter(|n| n.name == "Container" && n.rect.width > 0.0)
976 .map(|n| n.rect)
977 .take(3)
978 .collect()
979 };
980 let held = content(&strip(ReorderGrab::LongPress));
981 assert_eq!(held.len(), 3);
982 assert!(
983 held.iter().all(|r| r.height > 8.0),
984 "a held strip's rows are not flat: {held:?}"
985 );
986 let tall = content(&strip(ReorderGrab::Handle).height(120.0));
987 assert!(
988 tall.iter()
989 .all(|r| (r.height - (120.0 - HANDLE_WIDTH)).abs() < 0.5),
990 "a strip 120 tall leaves its rows 80 above the grip: {tall:?}"
991 );
992 }
993
994 #[test]
998 fn a_horizontal_list_answers_across_and_inserts() {
999 for grab in [ReorderGrab::Handle, ReorderGrab::LongPress] {
1000 let across = strip(grab);
1001 let answers: Vec<(ReorderAxis, bool)> = nodes_of(&across)
1002 .into_iter()
1003 .filter(|w| w.reorder_index().is_some())
1004 .map(|w| (w.reorder_axis(), w.reorder_inserts()))
1005 .collect();
1006 assert!(!answers.is_empty());
1007 assert!(
1008 answers
1009 .iter()
1010 .all(|a| *a == (ReorderAxis::Horizontal, true)),
1011 "{answers:?}"
1012 );
1013 }
1014 let down = list(ReorderGrab::Handle);
1015 assert!(nodes_of(&down)
1016 .into_iter()
1017 .filter(|w| w.reorder_index().is_some())
1018 .all(|w| w.reorder_axis() == ReorderAxis::Vertical));
1019 let late = ReorderableList::new(Msg::Moved)
1021 .keyed_row(1, Container::new().width(80.0).child(text("one")))
1022 .axis(ReorderAxis::Horizontal);
1023 assert!(nodes_of(&late)
1024 .into_iter()
1025 .filter(|w| w.reorder_index().is_some())
1026 .all(|w| w.reorder_axis() == ReorderAxis::Horizontal));
1027 }
1028
1029 #[test]
1038 fn a_horizontal_drop_routes_the_same_in_either_reading_direction() {
1039 for rtl in [false, true] {
1040 let theme = if rtl {
1041 Theme::dark().rtl()
1042 } else {
1043 Theme::dark()
1044 };
1045 let list = strip(ReorderGrab::Handle);
1046 let ui = crate::build_ui(&list, Size::new(300.0, 120.0), &Runtime::default(), &theme);
1047 let find = |id| crate::find_widget(&list, id);
1048 let grip_of = |index| {
1049 ui.reorderables()
1050 .iter()
1051 .find(|(id, _)| {
1052 find(*id).is_some_and(|w| {
1053 w.reorder_index() == Some(index) && !w.reorder_droppable()
1054 })
1055 })
1056 .copied()
1057 .expect("a grip")
1058 };
1059 let (grip0, grip0_box) = grip_of(0);
1060 let middle = frus_core::Point::new(
1061 grip0_box.x + grip0_box.width * 0.5,
1062 grip0_box.y + grip0_box.height * 0.5,
1063 );
1064 let row0 = ui
1065 .reorderables_at(middle)
1066 .find(|id| {
1067 find(*id).is_some_and(|w| w.reorder_droppable() && w.reorder_index() == Some(0))
1068 })
1069 .expect("the grip sits inside its row");
1070
1071 let slots = crate::reorder_siblings(&ui, &list, row0);
1072 assert_eq!(
1073 slots.len(),
1074 3,
1075 "rtl={rtl}: the row's list is the three rows"
1076 );
1077 let boxes: Vec<Rect> = slots.iter().map(|(_, r)| *r).collect();
1078 assert_eq!(
1079 boxes[0].x > boxes[2].x,
1080 rtl,
1081 "rtl={rtl}: the first row is on the reading side: {boxes:?}"
1082 );
1083
1084 let last = boxes[2];
1086 let past = frus_core::Point::new(
1087 if rtl {
1088 last.x - 24.0
1089 } else {
1090 last.x + last.width + 24.0
1091 },
1092 last.y + last.height * 0.5,
1093 );
1094 assert!(
1095 (0.0..300.0).contains(&past.x),
1096 "rtl={rtl}: still in the window: {past:?}"
1097 );
1098 assert!(
1099 !ui.reorderables_at(past)
1100 .any(|id| find(id).is_some_and(|w| w.reorder_droppable())),
1101 "rtl={rtl}: nothing to drop on past the end"
1102 );
1103 let nearest = crate::nearest_reorder_slot(past, &boxes, ReorderAxis::Horizontal)
1104 .expect("the strip's own nearest slot");
1105 assert_eq!(nearest, 2, "rtl={rtl}: the last row");
1106 let after = crate::reorder_drop_after(past, boxes[2], ReorderAxis::Horizontal, rtl);
1107 assert!(after, "rtl={rtl}: past the end is after the last row");
1108 let grip0 = find(grip0).expect("the grip");
1109 assert_eq!(
1110 grip0.on_reorder(nearest + usize::from(after)),
1111 Some(Msg::Moved(0, 2)),
1112 "rtl={rtl}: the first row ends up last"
1113 );
1114
1115 let first = boxes[0];
1117 let leading = frus_core::Point::new(
1118 if rtl {
1119 first.x + first.width * 0.8
1120 } else {
1121 first.x + first.width * 0.2
1122 },
1123 first.y + first.height * 0.3,
1124 );
1125 let (grip2, _) = grip_of(2);
1126 let after = crate::reorder_drop_after(leading, first, ReorderAxis::Horizontal, rtl);
1127 assert!(!after, "rtl={rtl}: the leading half is before the row");
1128 assert_eq!(
1129 find(grip2).and_then(|w| w.on_reorder(usize::from(after))),
1130 Some(Msg::Moved(2, 0)),
1131 "rtl={rtl}: the last row ends up first"
1132 );
1133 }
1134 }
1135
1136 #[test]
1142 fn a_built_strip_makes_room_along_x() {
1143 let list = strip(ReorderGrab::Handle);
1144 let ui = crate::build_ui(
1145 &list,
1146 Size::new(300.0, 120.0),
1147 &Runtime::default(),
1148 &Theme::dark(),
1149 );
1150 let mut rows: Vec<(crate::WidgetId, Rect)> = ui
1151 .reorderables()
1152 .iter()
1153 .filter(|(id, _)| crate::find_widget(&list, *id).is_some_and(|w| w.reorder_droppable()))
1154 .copied()
1155 .collect();
1156 rows.sort_by(|a, b| a.1.x.total_cmp(&b.1.x));
1157 assert_eq!(rows.len(), 3, "three rows: {rows:?}");
1158 let (row0, src) = rows[0];
1159 let lifted: std::collections::HashSet<u64> = crate::find_widget(&list, row0)
1160 .map(|w| {
1161 crate::subtree_ids(w, row0)
1162 .iter()
1163 .map(|i| i.as_u64())
1164 .collect()
1165 })
1166 .expect("the first row");
1167 let movable = crate::reorderable_owners(&ui, &list);
1168 let prims = ui.scene().primitives();
1169 let kept: Vec<&frus_core::Primitive> = prims
1170 .iter()
1171 .filter(|p| !lifted.contains(&p.owner()))
1172 .collect();
1173 let centred_in = |b: Rect, r: Rect| {
1174 r.contains(frus_core::Point::new(
1175 b.x + b.width * 0.5,
1176 b.y + b.height * 0.5,
1177 ))
1178 };
1179
1180 let line = Rect::new(rows[2].1.x - 1.5, rows[2].1.y, 3.0, rows[2].1.height);
1181 for (line, shifts) in [
1182 (None, [-src.width, -src.width]),
1183 (Some(line), [-src.width, 0.0]),
1184 ] {
1185 let out = crate::reflow_reorder_cards(
1186 prims,
1187 src,
1188 line,
1189 &lifted,
1190 &movable,
1191 ReorderAxis::Horizontal,
1192 );
1193 assert_eq!(out.len(), kept.len(), "only the lifted row is taken out");
1194 let mut moved = [0, 0];
1195 for (before, after) in kept.iter().zip(&out) {
1196 let (b, a) = (before.bounds(), after.bounds());
1197 assert_eq!(a.y, b.y, "nothing moves across the strip: {b:?}");
1198 let row = (1..3).find(|&i| centred_in(b, rows[i].1));
1199 match row {
1200 Some(i) if movable.contains(&before.owner()) => {
1201 assert!(
1202 (a.x - (b.x + shifts[i - 1])).abs() < 0.01,
1203 "line={line:?}: row {i}'s {b:?} shifts by {} and is at {a:?}",
1204 shifts[i - 1]
1205 );
1206 moved[i - 1] += 1;
1207 }
1208 _ => assert_eq!(a.x, b.x, "not part of a row: stays"),
1209 }
1210 }
1211 assert!(
1212 moved.iter().all(|&n| n > 0),
1213 "both rows paint something: {moved:?}"
1214 );
1215 }
1216 }
1217
1218 #[test]
1220 fn a_held_list_has_no_grip_taking_room() {
1221 let list = list(ReorderGrab::LongPress);
1222 let nodes = inspected(&list);
1223 for grip in nodes.iter().filter(|n| n.name == "ReorderHandle") {
1224 assert_eq!(grip.rect.width, 0.0, "no box: {:?}", grip.rect);
1225 }
1226 let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
1228 assert_eq!(rows.len(), 3);
1229 for row in rows {
1230 assert!((row.rect.width - 300.0).abs() < 0.5, "{:?}", row.rect);
1231 }
1232 }
1233}