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
88impl Default for ReorderGrab {
89 fn default() -> Self {
92 #[cfg(any(target_os = "android", target_os = "ios"))]
93 {
94 ReorderGrab::LongPress
95 }
96 #[cfg(not(any(target_os = "android", target_os = "ios")))]
97 {
98 ReorderGrab::Handle
99 }
100 }
101}
102
103pub fn settled_index(from: usize, raw: usize) -> usize {
112 if raw > from {
113 raw - 1
114 } else {
115 raw
116 }
117}
118
119type ReorderFn<Msg> = Rc<dyn Fn(usize, usize) -> Msg>;
120type HandleFn<Msg> = Rc<dyn Fn(&Theme) -> Box<dyn Widget<Msg>>>;
121
122struct Spec<Msg> {
129 grab: ReorderGrab,
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 gripped(&self) -> bool {
147 self.enabled && self.grab == ReorderGrab::Handle
148 }
149
150 fn held(&self) -> bool {
152 self.enabled && self.grab == ReorderGrab::LongPress
153 }
154}
155
156type Shared<Msg> = Rc<RefCell<Spec<Msg>>>;
157
158fn move_message<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<Msg> {
165 let to = settled_index(from, raw);
166 if to == from {
167 return None;
168 }
169 spec.on_reorder.as_ref().map(|f| f(from, to))
170}
171
172fn move_announcement<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<String> {
175 let to = settled_index(from, raw);
176 (to != from).then(|| format!("Moved to position {} of {}", to + 1, spec.count.max(to + 1)))
177}
178
179struct ReorderRow<Msg> {
184 index: usize,
185 key: Option<u64>,
186 spec: Shared<Msg>,
187 children: Vec<Box<dyn Widget<Msg>>>,
188}
189
190impl<Msg: Clone + 'static> Widget<Msg> for ReorderRow<Msg> {
191 fn style(&self) -> Style {
192 let direction = match self.spec.borrow().axis {
195 ReorderAxis::Vertical => FlexDirection::Row,
196 ReorderAxis::Horizontal => FlexDirection::Column,
197 };
198 Style {
199 width: Dimension::Auto,
200 height: Dimension::Auto,
201 flex_direction: direction,
202 align: Align::Center,
203 ..Default::default()
204 }
205 }
206
207 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
208 &self.children
209 }
210
211 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
212
213 fn on_click(&self) -> Option<Msg> {
214 None
215 }
216
217 fn key(&self) -> Option<u64> {
218 self.key
219 }
220
221 fn reorder_index(&self) -> Option<usize> {
222 self.spec.borrow().enabled.then_some(self.index)
223 }
224
225 fn on_reorder(&self, to: usize) -> Option<Msg> {
226 move_message(&self.spec.borrow(), self.index, to)
227 }
228
229 fn reorder_announcement(&self, to: usize) -> Option<String> {
230 move_announcement(&self.spec.borrow(), self.index, to)
231 }
232
233 fn reorder_axis(&self) -> ReorderAxis {
234 self.spec.borrow().axis
235 }
236
237 fn reorder_inserts(&self) -> bool {
238 true
239 }
240
241 fn reorder_draggable(&self) -> bool {
242 self.spec.borrow().held()
243 }
244
245 fn drag_needs_long_press(&self) -> bool {
246 self.spec.borrow().held()
247 }
248}
249
250struct ReorderHandle<Msg> {
256 index: usize,
257 spec: Shared<Msg>,
258 children: Vec<Box<dyn Widget<Msg>>>,
259}
260
261impl<Msg: Clone + 'static> Widget<Msg> for ReorderHandle<Msg> {
262 fn style(&self) -> Style {
263 let spec = self.spec.borrow();
264 if !spec.gripped() {
265 return Style {
266 width: Dimension::Length(0.0),
267 height: Dimension::Length(0.0),
268 ..Default::default()
269 };
270 }
271 let (width, height) = match spec.axis {
274 ReorderAxis::Vertical => (Dimension::Length(spec.handle_width), Dimension::Auto),
275 ReorderAxis::Horizontal => (Dimension::Auto, Dimension::Length(spec.handle_width)),
276 };
277 Style {
278 width,
279 height,
280 flex_shrink: 0.0,
283 align: Align::Center,
284 justify: Justify::Center,
285 ..Default::default()
286 }
287 }
288
289 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
290 &self.children
291 }
292
293 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
294
295 fn on_click(&self) -> Option<Msg> {
296 None
297 }
298
299 fn reorder_index(&self) -> Option<usize> {
300 self.spec.borrow().gripped().then_some(self.index)
301 }
302
303 fn on_reorder(&self, to: usize) -> Option<Msg> {
304 move_message(&self.spec.borrow(), self.index, to)
305 }
306
307 fn reorder_announcement(&self, to: usize) -> Option<String> {
308 move_announcement(&self.spec.borrow(), self.index, to)
309 }
310
311 fn reorder_axis(&self) -> ReorderAxis {
312 self.spec.borrow().axis
313 }
314
315 fn reorder_inserts(&self) -> bool {
316 true
317 }
318
319 fn reorder_droppable(&self) -> bool {
320 false
321 }
322}
323
324struct ReorderContent<Msg> {
333 inner: Box<dyn Widget<Msg>>,
334 spec: Shared<Msg>,
335}
336
337impl<Msg> ReorderContent<Msg> {
338 fn restyle(&self, base: Style) -> Style {
340 let expanded = flex_item(base, 1.0, FlexFit::Tight);
341 match self.spec.borrow().axis {
342 ReorderAxis::Vertical => expanded,
343 ReorderAxis::Horizontal => Style {
344 flex_basis: Dimension::Auto,
345 ..expanded
346 },
347 }
348 }
349}
350
351crate::transparent::forward_transparent!(ReorderContent {
352 fn key(&self) -> Option<u64> {
354 self.inner.key()
355 }
356
357 fn positioned(&self) -> Option<crate::positioned::Positioning> {
359 self.inner.positioned()
360 }
361
362 fn theme_override(
364 &self,
365 inherited: &crate::theme::Theme,
366 ) -> Option<Box<crate::theme::Theme>> {
367 self.inner.theme_override(inherited)
368 }
369
370 fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
372 self.inner.media_override(inherited)
373 }
374 fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
375 self.inner.scaffold_override()
376 }
377 fn autofill_group(&self) -> bool {
379 self.inner.autofill_group()
380 }
381});
382
383pub struct ReorderableList<Msg = crate::callback::Callback> {
400 spec: Shared<Msg>,
401 rows: Vec<Box<dyn Widget<Msg>>>,
402 gap: f32,
403 width: Dimension,
404 height: Dimension,
405 flex_grow: f32,
406}
407
408impl<Msg: Clone + 'static> ReorderableList<Msg> {
409 pub fn new<R: crate::callback::IntoMsg<Msg>>(
412 on_reorder: impl Fn(usize, usize) -> R + 'static,
413 ) -> Self {
414 Self {
415 spec: Rc::new(RefCell::new(Spec {
416 grab: ReorderGrab::default(),
417 axis: ReorderAxis::Vertical,
418 enabled: true,
419 on_reorder: Some(Rc::new(crate::callback::handler2(on_reorder))),
420 handle: None,
421 handle_icon: None,
422 handle_color: None,
423 handle_icon_size: None,
424 handle_width: HANDLE_WIDTH,
425 count: 0,
426 })),
427 rows: Vec::new(),
428 gap: 0.0,
429 width: Dimension::Auto,
430 height: Dimension::Auto,
431 flex_grow: 0.0,
432 }
433 }
434
435 #[must_use]
437 pub fn keyed_row(self, key: u64, row: impl Widget<Msg> + 'static) -> Self {
438 self.push(Some(key), Box::new(row))
439 }
440
441 #[must_use]
443 pub fn row(self, row: impl Widget<Msg> + 'static) -> Self {
444 self.push(None, Box::new(row))
445 }
446
447 fn push(mut self, key: Option<u64>, row: Box<dyn Widget<Msg>>) -> Self {
448 let index = self.rows.len();
449 self.spec.borrow_mut().count = index + 1;
450 let spec = Rc::clone(&self.spec);
451 let glyph = Rc::clone(&self.spec);
452 let handle = ReorderHandle {
457 index,
458 spec: Rc::clone(&self.spec),
459 children: vec![Box::new(ThemeBuilder::new(move |theme: &Theme| {
460 grip(&glyph.borrow(), theme)
461 }))],
462 };
463 self.rows.push(Box::new(ReorderRow {
464 index,
465 key,
466 spec,
467 children: vec![
468 Box::new(ReorderContent {
469 inner: row,
470 spec: Rc::clone(&self.spec),
471 }),
472 Box::new(handle),
473 ],
474 }));
475 self
476 }
477
478 #[must_use]
480 pub fn grab(self, grab: ReorderGrab) -> Self {
481 self.spec.borrow_mut().grab = grab;
482 self
483 }
484
485 #[must_use]
489 pub fn axis(self, axis: ReorderAxis) -> Self {
490 self.spec.borrow_mut().axis = axis;
491 self
492 }
493
494 #[must_use]
498 pub fn enabled(self, enabled: bool) -> Self {
499 self.spec.borrow_mut().enabled = enabled;
500 self
501 }
502
503 #[must_use]
507 pub fn handle<W: Widget<Msg> + 'static>(self, handle: impl Fn(&Theme) -> W + 'static) -> Self {
508 self.spec.borrow_mut().handle = Some(Rc::new(move |theme| Box::new(handle(theme))));
509 self
510 }
511
512 #[must_use]
514 pub fn handle_icon(self, icon: IconData) -> Self {
515 self.spec.borrow_mut().handle_icon = Some(icon);
516 self
517 }
518
519 #[must_use]
521 pub fn handle_color(self, color: Color) -> Self {
522 self.spec.borrow_mut().handle_color = Some(color);
523 self
524 }
525
526 #[must_use]
528 pub fn handle_icon_size(self, size: f32) -> Self {
529 self.spec.borrow_mut().handle_icon_size = Some(size);
530 self
531 }
532
533 #[must_use]
537 pub fn handle_width(self, width: f32) -> Self {
538 self.spec.borrow_mut().handle_width = width;
539 self
540 }
541
542 #[must_use]
544 pub fn gap(mut self, gap: f32) -> Self {
545 self.gap = gap;
546 self
547 }
548
549 #[must_use]
551 pub fn width(mut self, width: f32) -> Self {
552 self.width = Dimension::Length(width);
553 self
554 }
555
556 #[must_use]
558 pub fn width_fraction(mut self, fraction: f32) -> Self {
559 self.width = Dimension::Percent(fraction);
560 self
561 }
562
563 #[must_use]
565 pub fn height(mut self, height: f32) -> Self {
566 self.height = Dimension::Length(height);
567 self
568 }
569
570 #[must_use]
572 pub fn flex(mut self, grow: f32) -> Self {
573 self.flex_grow = grow;
574 self
575 }
576}
577
578fn grip<Msg: Clone + 'static>(spec: &Spec<Msg>, theme: &Theme) -> Box<dyn Widget<Msg>> {
580 if !spec.gripped() {
581 return Box::new(Container::new().width(0.0).height(0.0));
583 }
584 if let Some(handle) = &spec.handle {
585 return handle(theme);
586 }
587 let icon = spec
588 .handle_icon
589 .or(theme.widgets.reorderable.handle_icon)
590 .unwrap_or(Icons::DRAG_HANDLE);
591 let size = spec
592 .handle_icon_size
593 .or(theme.widgets.reorderable.handle_icon_size)
594 .unwrap_or(HANDLE_ICON);
595 let color = spec
596 .handle_color
597 .or(theme.widgets.reorderable.handle_color)
598 .unwrap_or(theme.muted);
599 Box::new(Icon::new(icon).size(size).color(color))
600}
601
602impl<Msg: Clone + 'static> Widget<Msg> for ReorderableList<Msg> {
603 fn style(&self) -> Style {
604 Style {
605 width: self.width,
606 height: self.height,
607 flex_grow: self.flex_grow,
608 flex_direction: match self.spec.borrow().axis {
609 ReorderAxis::Vertical => FlexDirection::Column,
610 ReorderAxis::Horizontal => FlexDirection::Row,
611 },
612 gap: self.gap,
613 ..Default::default()
614 }
615 }
616
617 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
618 &self.rows
619 }
620
621 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
622
623 fn on_click(&self) -> Option<Msg> {
624 None
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::inspector::InspectorNode;
632 use crate::widget::Widget;
633 use crate::{text, Container, Runtime, Size};
634
635 #[derive(Clone, Debug, PartialEq)]
636 enum Msg {
637 Moved(usize, usize),
638 }
639
640 fn list(grab: ReorderGrab) -> ReorderableList<Msg> {
641 ReorderableList::new(Msg::Moved)
642 .grab(grab)
643 .width(300.0)
644 .keyed_row(1, Container::new().height(40.0).child(text("one")))
645 .keyed_row(2, Container::new().height(40.0).child(text("two")))
646 .keyed_row(3, Container::new().height(40.0).child(text("three")))
647 }
648
649 fn nodes_of(root: &dyn Widget<Msg>) -> Vec<&dyn Widget<Msg>> {
656 crate::build_deferred(root, &Theme::dark(), &crate::Runtime::default());
657 let mut out: Vec<&dyn Widget<Msg>> = vec![root];
658 let mut i = 0;
659 while i < out.len() {
660 let node = out[i];
661 for child in node.children() {
662 out.push(child.as_ref());
663 }
664 i += 1;
665 }
666 out
667 }
668
669 fn inspected(root: &dyn Widget<Msg>) -> Vec<InspectorNode> {
671 crate::build_ui_inspected(
672 root,
673 Size::new(300.0, 400.0),
674 &Runtime::default(),
675 &Theme::dark(),
676 )
677 .1
678 }
679
680 fn slots(root: &dyn Widget<Msg>) -> Vec<(usize, bool, bool)> {
682 nodes_of(root)
683 .into_iter()
684 .filter_map(|w| {
685 w.reorder_index()
686 .map(|i| (i, w.reorder_draggable(), w.reorder_droppable()))
687 })
688 .collect()
689 }
690
691 #[test]
692 fn a_settled_index_is_the_raw_one_minus_the_row_that_left() {
693 assert_eq!(settled_index(0, 3), 2);
695 assert_eq!(settled_index(3, 0), 0);
697 assert_eq!(settled_index(2, 2), 2);
699 assert_eq!(settled_index(2, 3), 2);
700 }
701
702 #[test]
703 fn a_gripped_row_is_a_target_and_its_grip_is_the_source() {
704 let list = list(ReorderGrab::Handle);
705 let slots = slots(&list);
706 assert_eq!(slots.len(), 6, "three rows and three grips: {slots:?}");
708 for i in 0..3 {
709 assert!(
710 slots.contains(&(i, false, true)),
711 "row {i} is a target and not a source: {slots:?}"
712 );
713 assert!(
714 slots.contains(&(i, true, false)),
715 "grip {i} is a source and not a target: {slots:?}"
716 );
717 }
718 }
719
720 #[test]
721 fn a_held_row_is_both_and_waits_for_the_hold() {
722 let list = list(ReorderGrab::LongPress);
723 assert_eq!(
725 slots(&list),
726 vec![(0, true, true), (1, true, true), (2, true, true)]
727 );
728 let held: Vec<bool> = nodes_of(&list)
730 .into_iter()
731 .filter(|w| w.reorder_index().is_some())
732 .map(|w| w.drag_needs_long_press())
733 .collect();
734 assert_eq!(held, vec![true, true, true]);
735 }
736
737 #[test]
738 fn the_message_carries_where_the_row_ends_up() {
739 let list = list(ReorderGrab::Handle);
740 let row = nodes_of(&list)
741 .into_iter()
742 .find(|w| w.reorder_index() == Some(0) && !w.reorder_draggable())
743 .expect("the first row");
744 assert_eq!(row.on_reorder(3), Some(Msg::Moved(0, 2)));
746 assert_eq!(row.on_reorder(1), None, "a move of nothing sends nothing");
748 assert_eq!(
749 row.reorder_announcement(3).as_deref(),
750 Some("Moved to position 3 of 3")
751 );
752 assert_eq!(row.reorder_announcement(1), None);
753 }
754
755 #[test]
756 fn a_setter_after_a_row_still_reaches_it() {
757 let late = ReorderableList::new(Msg::Moved)
760 .width(300.0)
761 .keyed_row(1, Container::new().height(40.0).child(text("one")))
762 .grab(ReorderGrab::LongPress);
763 assert_eq!(slots(&late), vec![(0, true, true)]);
764 }
765
766 #[test]
767 fn a_disabled_list_offers_nothing_to_grab() {
768 let list = list(ReorderGrab::Handle).enabled(false);
769 assert!(slots(&list).is_empty(), "no source and no target");
770 }
771
772 #[test]
775 fn the_grip_takes_its_own_room_at_the_trailing_edge() {
776 let list = list(ReorderGrab::Handle);
777 let nodes = inspected(&list);
778 let grips: Vec<&InspectorNode> =
779 nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
780 assert_eq!(grips.len(), 3, "one grip per row");
781 for grip in grips {
782 assert!(
783 (grip.rect.width - HANDLE_WIDTH).abs() < 0.5,
784 "the grip keeps its 40 px: {:?}",
785 grip.rect
786 );
787 assert!(
788 (grip.rect.x + grip.rect.width - 300.0).abs() < 0.5,
789 "at the trailing edge: {:?}",
790 grip.rect
791 );
792 }
793 }
794
795 #[test]
804 fn the_grip_sits_inside_the_row_it_moves() {
805 let list = list(ReorderGrab::Handle);
806 let nodes = inspected(&list);
807 let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
808 let grips: Vec<&InspectorNode> =
809 nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
810 assert_eq!((rows.len(), grips.len()), (3, 3));
811 for (row, grip) in rows.iter().zip(&grips) {
812 let middle = frus_core::Point::new(
813 grip.rect.x + grip.rect.width * 0.5,
814 grip.rect.y + grip.rect.height * 0.5,
815 );
816 assert!(
817 row.rect.contains(middle),
818 "the grip's middle {middle:?} is inside its row {:?}",
819 row.rect
820 );
821 }
822 let indices: Vec<(usize, bool)> = nodes_of(&list)
824 .into_iter()
825 .filter_map(|w| w.reorder_index().map(|i| (i, w.reorder_droppable())))
826 .collect();
827 for i in 0..3 {
828 assert!(indices.contains(&(i, true)) && indices.contains(&(i, false)));
829 }
830 }
831
832 fn strip(grab: ReorderGrab) -> ReorderableList<Msg> {
834 ReorderableList::new(Msg::Moved)
835 .grab(grab)
836 .axis(ReorderAxis::Horizontal)
837 .width(300.0)
838 .keyed_row(1, Container::new().width(80.0).child(text("one")))
839 .keyed_row(2, Container::new().width(80.0).child(text("two")))
840 .keyed_row(3, Container::new().width(80.0).child(text("three")))
841 }
842
843 #[test]
847 fn a_horizontal_list_lays_its_rows_along_x_with_the_grip_underneath() {
848 let list = strip(ReorderGrab::Handle);
849 let nodes = inspected(&list);
850 let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
851 let grips: Vec<&InspectorNode> =
852 nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
853 assert_eq!((rows.len(), grips.len()), (3, 3));
854 for pair in rows.windows(2) {
855 assert!(
856 (pair[0].rect.y - pair[1].rect.y).abs() < 0.5
857 && pair[1].rect.x >= pair[0].rect.x + pair[0].rect.width - 0.5,
858 "side by side, in order: {:?} then {:?}",
859 pair[0].rect,
860 pair[1].rect
861 );
862 }
863 for (row, grip) in rows.iter().zip(&grips) {
864 assert!(
865 (grip.rect.height - HANDLE_WIDTH).abs() < 0.5,
866 "the grip keeps its 40 px, as a height: {:?}",
867 grip.rect
868 );
869 assert!(
870 (grip.rect.y + grip.rect.height - (row.rect.y + row.rect.height)).abs() < 0.5,
871 "at the row's bottom edge: {:?} in {:?}",
872 grip.rect,
873 row.rect
874 );
875 assert!(
876 row.rect.height > HANDLE_WIDTH + 8.0,
877 "and the row's content keeps its room above it: {:?}",
878 row.rect
879 );
880 let middle = frus_core::Point::new(
881 grip.rect.x + grip.rect.width * 0.5,
882 grip.rect.y + grip.rect.height * 0.5,
883 );
884 assert!(
885 row.rect.contains(middle),
886 "{middle:?} inside {:?}",
887 row.rect
888 );
889 }
890 }
891
892 #[test]
898 fn a_row_across_a_strip_keeps_its_height() {
899 let content = |list: &ReorderableList<Msg>| -> Vec<Rect> {
900 inspected(list)
901 .iter()
902 .filter(|n| n.name == "Container" && n.rect.width > 0.0)
904 .map(|n| n.rect)
905 .take(3)
906 .collect()
907 };
908 let held = content(&strip(ReorderGrab::LongPress));
909 assert_eq!(held.len(), 3);
910 assert!(
911 held.iter().all(|r| r.height > 8.0),
912 "a held strip's rows are not flat: {held:?}"
913 );
914 let tall = content(&strip(ReorderGrab::Handle).height(120.0));
915 assert!(
916 tall.iter()
917 .all(|r| (r.height - (120.0 - HANDLE_WIDTH)).abs() < 0.5),
918 "a strip 120 tall leaves its rows 80 above the grip: {tall:?}"
919 );
920 }
921
922 #[test]
926 fn a_horizontal_list_answers_across_and_inserts() {
927 for grab in [ReorderGrab::Handle, ReorderGrab::LongPress] {
928 let across = strip(grab);
929 let answers: Vec<(ReorderAxis, bool)> = nodes_of(&across)
930 .into_iter()
931 .filter(|w| w.reorder_index().is_some())
932 .map(|w| (w.reorder_axis(), w.reorder_inserts()))
933 .collect();
934 assert!(!answers.is_empty());
935 assert!(
936 answers
937 .iter()
938 .all(|a| *a == (ReorderAxis::Horizontal, true)),
939 "{answers:?}"
940 );
941 }
942 let down = list(ReorderGrab::Handle);
943 assert!(nodes_of(&down)
944 .into_iter()
945 .filter(|w| w.reorder_index().is_some())
946 .all(|w| w.reorder_axis() == ReorderAxis::Vertical));
947 let late = ReorderableList::new(Msg::Moved)
949 .keyed_row(1, Container::new().width(80.0).child(text("one")))
950 .axis(ReorderAxis::Horizontal);
951 assert!(nodes_of(&late)
952 .into_iter()
953 .filter(|w| w.reorder_index().is_some())
954 .all(|w| w.reorder_axis() == ReorderAxis::Horizontal));
955 }
956
957 #[test]
966 fn a_horizontal_drop_routes_the_same_in_either_reading_direction() {
967 for rtl in [false, true] {
968 let theme = if rtl {
969 Theme::dark().rtl()
970 } else {
971 Theme::dark()
972 };
973 let list = strip(ReorderGrab::Handle);
974 let ui = crate::build_ui(&list, Size::new(300.0, 120.0), &Runtime::default(), &theme);
975 let find = |id| crate::find_widget(&list, id);
976 let grip_of = |index| {
977 ui.reorderables()
978 .iter()
979 .find(|(id, _)| {
980 find(*id).is_some_and(|w| {
981 w.reorder_index() == Some(index) && !w.reorder_droppable()
982 })
983 })
984 .copied()
985 .expect("a grip")
986 };
987 let (grip0, grip0_box) = grip_of(0);
988 let middle = frus_core::Point::new(
989 grip0_box.x + grip0_box.width * 0.5,
990 grip0_box.y + grip0_box.height * 0.5,
991 );
992 let row0 = ui
993 .reorderables_at(middle)
994 .find(|id| {
995 find(*id).is_some_and(|w| w.reorder_droppable() && w.reorder_index() == Some(0))
996 })
997 .expect("the grip sits inside its row");
998
999 let slots = crate::reorder_siblings(&ui, &list, row0);
1000 assert_eq!(
1001 slots.len(),
1002 3,
1003 "rtl={rtl}: the row's list is the three rows"
1004 );
1005 let boxes: Vec<Rect> = slots.iter().map(|(_, r)| *r).collect();
1006 assert_eq!(
1007 boxes[0].x > boxes[2].x,
1008 rtl,
1009 "rtl={rtl}: the first row is on the reading side: {boxes:?}"
1010 );
1011
1012 let last = boxes[2];
1014 let past = frus_core::Point::new(
1015 if rtl {
1016 last.x - 24.0
1017 } else {
1018 last.x + last.width + 24.0
1019 },
1020 last.y + last.height * 0.5,
1021 );
1022 assert!(
1023 (0.0..300.0).contains(&past.x),
1024 "rtl={rtl}: still in the window: {past:?}"
1025 );
1026 assert!(
1027 !ui.reorderables_at(past)
1028 .any(|id| find(id).is_some_and(|w| w.reorder_droppable())),
1029 "rtl={rtl}: nothing to drop on past the end"
1030 );
1031 let nearest = crate::nearest_reorder_slot(past, &boxes, ReorderAxis::Horizontal)
1032 .expect("the strip's own nearest slot");
1033 assert_eq!(nearest, 2, "rtl={rtl}: the last row");
1034 let after = crate::reorder_drop_after(past, boxes[2], ReorderAxis::Horizontal, rtl);
1035 assert!(after, "rtl={rtl}: past the end is after the last row");
1036 let grip0 = find(grip0).expect("the grip");
1037 assert_eq!(
1038 grip0.on_reorder(nearest + usize::from(after)),
1039 Some(Msg::Moved(0, 2)),
1040 "rtl={rtl}: the first row ends up last"
1041 );
1042
1043 let first = boxes[0];
1045 let leading = frus_core::Point::new(
1046 if rtl {
1047 first.x + first.width * 0.8
1048 } else {
1049 first.x + first.width * 0.2
1050 },
1051 first.y + first.height * 0.3,
1052 );
1053 let (grip2, _) = grip_of(2);
1054 let after = crate::reorder_drop_after(leading, first, ReorderAxis::Horizontal, rtl);
1055 assert!(!after, "rtl={rtl}: the leading half is before the row");
1056 assert_eq!(
1057 find(grip2).and_then(|w| w.on_reorder(usize::from(after))),
1058 Some(Msg::Moved(2, 0)),
1059 "rtl={rtl}: the last row ends up first"
1060 );
1061 }
1062 }
1063
1064 #[test]
1070 fn a_built_strip_makes_room_along_x() {
1071 let list = strip(ReorderGrab::Handle);
1072 let ui = crate::build_ui(
1073 &list,
1074 Size::new(300.0, 120.0),
1075 &Runtime::default(),
1076 &Theme::dark(),
1077 );
1078 let mut rows: Vec<(crate::WidgetId, Rect)> = ui
1079 .reorderables()
1080 .iter()
1081 .filter(|(id, _)| crate::find_widget(&list, *id).is_some_and(|w| w.reorder_droppable()))
1082 .copied()
1083 .collect();
1084 rows.sort_by(|a, b| a.1.x.total_cmp(&b.1.x));
1085 assert_eq!(rows.len(), 3, "three rows: {rows:?}");
1086 let (row0, src) = rows[0];
1087 let lifted: std::collections::HashSet<u64> = crate::find_widget(&list, row0)
1088 .map(|w| {
1089 crate::subtree_ids(w, row0)
1090 .iter()
1091 .map(|i| i.as_u64())
1092 .collect()
1093 })
1094 .expect("the first row");
1095 let movable = crate::reorderable_owners(&ui, &list);
1096 let prims = ui.scene().primitives();
1097 let kept: Vec<&frus_core::Primitive> = prims
1098 .iter()
1099 .filter(|p| !lifted.contains(&p.owner()))
1100 .collect();
1101 let centred_in = |b: Rect, r: Rect| {
1102 r.contains(frus_core::Point::new(
1103 b.x + b.width * 0.5,
1104 b.y + b.height * 0.5,
1105 ))
1106 };
1107
1108 let line = Rect::new(rows[2].1.x - 1.5, rows[2].1.y, 3.0, rows[2].1.height);
1109 for (line, shifts) in [
1110 (None, [-src.width, -src.width]),
1111 (Some(line), [-src.width, 0.0]),
1112 ] {
1113 let out = crate::reflow_reorder_cards(
1114 prims,
1115 src,
1116 line,
1117 &lifted,
1118 &movable,
1119 ReorderAxis::Horizontal,
1120 );
1121 assert_eq!(out.len(), kept.len(), "only the lifted row is taken out");
1122 let mut moved = [0, 0];
1123 for (before, after) in kept.iter().zip(&out) {
1124 let (b, a) = (before.bounds(), after.bounds());
1125 assert_eq!(a.y, b.y, "nothing moves across the strip: {b:?}");
1126 let row = (1..3).find(|&i| centred_in(b, rows[i].1));
1127 match row {
1128 Some(i) if movable.contains(&before.owner()) => {
1129 assert!(
1130 (a.x - (b.x + shifts[i - 1])).abs() < 0.01,
1131 "line={line:?}: row {i}'s {b:?} shifts by {} and is at {a:?}",
1132 shifts[i - 1]
1133 );
1134 moved[i - 1] += 1;
1135 }
1136 _ => assert_eq!(a.x, b.x, "not part of a row: stays"),
1137 }
1138 }
1139 assert!(
1140 moved.iter().all(|&n| n > 0),
1141 "both rows paint something: {moved:?}"
1142 );
1143 }
1144 }
1145
1146 #[test]
1148 fn a_held_list_has_no_grip_taking_room() {
1149 let list = list(ReorderGrab::LongPress);
1150 let nodes = inspected(&list);
1151 for grip in nodes.iter().filter(|n| n.name == "ReorderHandle") {
1152 assert_eq!(grip.rect.width, 0.0, "no box: {:?}", grip.rect);
1153 }
1154 let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
1156 assert_eq!(rows.len(), 3);
1157 for row in rows {
1158 assert!((row.rect.width - 300.0).abs() < 0.5, "{:?}", row.rect);
1159 }
1160 }
1161}