use std::cell::RefCell;
use std::rc::Rc;
use frus_core::{Color, Rect, Scene};
use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
use crate::expanded::{flex_item, FlexFit};
use crate::icons::{IconData, Icons};
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::{ReorderAxis, Widget};
use crate::{Container, Icon, ThemeBuilder};
const HANDLE_WIDTH: f32 = 40.0;
const HANDLE_ICON: f32 = 20.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReorderGrab {
Handle,
LongPress,
}
impl Default for ReorderGrab {
fn default() -> Self {
#[cfg(any(target_os = "android", target_os = "ios"))]
{
ReorderGrab::LongPress
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
ReorderGrab::Handle
}
}
}
pub fn settled_index(from: usize, raw: usize) -> usize {
if raw > from {
raw - 1
} else {
raw
}
}
type ReorderFn<Msg> = Rc<dyn Fn(usize, usize) -> Msg>;
type HandleFn<Msg> = Rc<dyn Fn(&Theme) -> Box<dyn Widget<Msg>>>;
struct Spec<Msg> {
grab: ReorderGrab,
axis: ReorderAxis,
enabled: bool,
on_reorder: Option<ReorderFn<Msg>>,
handle: Option<HandleFn<Msg>>,
handle_icon: Option<IconData>,
handle_color: Option<Color>,
handle_icon_size: Option<f32>,
handle_width: f32,
count: usize,
}
impl<Msg> Spec<Msg> {
fn gripped(&self) -> bool {
self.enabled && self.grab == ReorderGrab::Handle
}
fn held(&self) -> bool {
self.enabled && self.grab == ReorderGrab::LongPress
}
}
type Shared<Msg> = Rc<RefCell<Spec<Msg>>>;
fn move_message<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<Msg> {
let to = settled_index(from, raw);
if to == from {
return None;
}
spec.on_reorder.as_ref().map(|f| f(from, to))
}
fn move_announcement<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<String> {
let to = settled_index(from, raw);
(to != from).then(|| format!("Moved to position {} of {}", to + 1, spec.count.max(to + 1)))
}
struct ReorderRow<Msg> {
index: usize,
key: Option<u64>,
spec: Shared<Msg>,
children: Vec<Box<dyn Widget<Msg>>>,
}
impl<Msg: Clone + 'static> Widget<Msg> for ReorderRow<Msg> {
fn style(&self) -> Style {
let direction = match self.spec.borrow().axis {
ReorderAxis::Vertical => FlexDirection::Row,
ReorderAxis::Horizontal => FlexDirection::Column,
};
Style {
width: Dimension::Auto,
height: Dimension::Auto,
flex_direction: direction,
align: Align::Center,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn key(&self) -> Option<u64> {
self.key
}
fn reorder_index(&self) -> Option<usize> {
self.spec.borrow().enabled.then_some(self.index)
}
fn on_reorder(&self, to: usize) -> Option<Msg> {
move_message(&self.spec.borrow(), self.index, to)
}
fn reorder_announcement(&self, to: usize) -> Option<String> {
move_announcement(&self.spec.borrow(), self.index, to)
}
fn reorder_axis(&self) -> ReorderAxis {
self.spec.borrow().axis
}
fn reorder_inserts(&self) -> bool {
true
}
fn reorder_draggable(&self) -> bool {
self.spec.borrow().held()
}
fn drag_needs_long_press(&self) -> bool {
self.spec.borrow().held()
}
}
struct ReorderHandle<Msg> {
index: usize,
spec: Shared<Msg>,
children: Vec<Box<dyn Widget<Msg>>>,
}
impl<Msg: Clone + 'static> Widget<Msg> for ReorderHandle<Msg> {
fn style(&self) -> Style {
let spec = self.spec.borrow();
if !spec.gripped() {
return Style {
width: Dimension::Length(0.0),
height: Dimension::Length(0.0),
..Default::default()
};
}
let (width, height) = match spec.axis {
ReorderAxis::Vertical => (Dimension::Length(spec.handle_width), Dimension::Auto),
ReorderAxis::Horizontal => (Dimension::Auto, Dimension::Length(spec.handle_width)),
};
Style {
width,
height,
flex_shrink: 0.0,
align: Align::Center,
justify: Justify::Center,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn reorder_index(&self) -> Option<usize> {
self.spec.borrow().gripped().then_some(self.index)
}
fn on_reorder(&self, to: usize) -> Option<Msg> {
move_message(&self.spec.borrow(), self.index, to)
}
fn reorder_announcement(&self, to: usize) -> Option<String> {
move_announcement(&self.spec.borrow(), self.index, to)
}
fn reorder_axis(&self) -> ReorderAxis {
self.spec.borrow().axis
}
fn reorder_inserts(&self) -> bool {
true
}
fn reorder_droppable(&self) -> bool {
false
}
}
struct ReorderContent<Msg> {
inner: Box<dyn Widget<Msg>>,
spec: Shared<Msg>,
}
impl<Msg> ReorderContent<Msg> {
fn restyle(&self, base: Style) -> Style {
let expanded = flex_item(base, 1.0, FlexFit::Tight);
match self.spec.borrow().axis {
ReorderAxis::Vertical => expanded,
ReorderAxis::Horizontal => Style {
flex_basis: Dimension::Auto,
..expanded
},
}
}
}
crate::transparent::forward_transparent!(ReorderContent {
fn key(&self) -> Option<u64> {
self.inner.key()
}
fn positioned(&self) -> Option<crate::positioned::Positioning> {
self.inner.positioned()
}
fn theme_override(
&self,
inherited: &crate::theme::Theme,
) -> Option<Box<crate::theme::Theme>> {
self.inner.theme_override(inherited)
}
fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
self.inner.media_override(inherited)
}
fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
self.inner.scaffold_override()
}
fn autofill_group(&self) -> bool {
self.inner.autofill_group()
}
});
pub struct ReorderableList<Msg = crate::callback::Callback> {
spec: Shared<Msg>,
rows: Vec<Box<dyn Widget<Msg>>>,
gap: f32,
width: Dimension,
height: Dimension,
flex_grow: f32,
}
impl<Msg: Clone + 'static> ReorderableList<Msg> {
pub fn new<R: crate::callback::IntoMsg<Msg>>(
on_reorder: impl Fn(usize, usize) -> R + 'static,
) -> Self {
Self {
spec: Rc::new(RefCell::new(Spec {
grab: ReorderGrab::default(),
axis: ReorderAxis::Vertical,
enabled: true,
on_reorder: Some(Rc::new(crate::callback::handler2(on_reorder))),
handle: None,
handle_icon: None,
handle_color: None,
handle_icon_size: None,
handle_width: HANDLE_WIDTH,
count: 0,
})),
rows: Vec::new(),
gap: 0.0,
width: Dimension::Auto,
height: Dimension::Auto,
flex_grow: 0.0,
}
}
#[must_use]
pub fn keyed_row(self, key: u64, row: impl Widget<Msg> + 'static) -> Self {
self.push(Some(key), Box::new(row))
}
#[must_use]
pub fn row(self, row: impl Widget<Msg> + 'static) -> Self {
self.push(None, Box::new(row))
}
fn push(mut self, key: Option<u64>, row: Box<dyn Widget<Msg>>) -> Self {
let index = self.rows.len();
self.spec.borrow_mut().count = index + 1;
let spec = Rc::clone(&self.spec);
let glyph = Rc::clone(&self.spec);
let handle = ReorderHandle {
index,
spec: Rc::clone(&self.spec),
children: vec![Box::new(ThemeBuilder::new(move |theme: &Theme| {
grip(&glyph.borrow(), theme)
}))],
};
self.rows.push(Box::new(ReorderRow {
index,
key,
spec,
children: vec![
Box::new(ReorderContent {
inner: row,
spec: Rc::clone(&self.spec),
}),
Box::new(handle),
],
}));
self
}
#[must_use]
pub fn grab(self, grab: ReorderGrab) -> Self {
self.spec.borrow_mut().grab = grab;
self
}
#[must_use]
pub fn axis(self, axis: ReorderAxis) -> Self {
self.spec.borrow_mut().axis = axis;
self
}
#[must_use]
pub fn enabled(self, enabled: bool) -> Self {
self.spec.borrow_mut().enabled = enabled;
self
}
#[must_use]
pub fn handle<W: Widget<Msg> + 'static>(self, handle: impl Fn(&Theme) -> W + 'static) -> Self {
self.spec.borrow_mut().handle = Some(Rc::new(move |theme| Box::new(handle(theme))));
self
}
#[must_use]
pub fn handle_icon(self, icon: IconData) -> Self {
self.spec.borrow_mut().handle_icon = Some(icon);
self
}
#[must_use]
pub fn handle_color(self, color: Color) -> Self {
self.spec.borrow_mut().handle_color = Some(color);
self
}
#[must_use]
pub fn handle_icon_size(self, size: f32) -> Self {
self.spec.borrow_mut().handle_icon_size = Some(size);
self
}
#[must_use]
pub fn handle_width(self, width: f32) -> Self {
self.spec.borrow_mut().handle_width = width;
self
}
#[must_use]
pub fn gap(mut self, gap: f32) -> Self {
self.gap = gap;
self
}
#[must_use]
pub fn width(mut self, width: f32) -> Self {
self.width = Dimension::Length(width);
self
}
#[must_use]
pub fn width_fraction(mut self, fraction: f32) -> Self {
self.width = Dimension::Percent(fraction);
self
}
#[must_use]
pub fn height(mut self, height: f32) -> Self {
self.height = Dimension::Length(height);
self
}
#[must_use]
pub fn flex(mut self, grow: f32) -> Self {
self.flex_grow = grow;
self
}
}
fn grip<Msg: Clone + 'static>(spec: &Spec<Msg>, theme: &Theme) -> Box<dyn Widget<Msg>> {
if !spec.gripped() {
return Box::new(Container::new().width(0.0).height(0.0));
}
if let Some(handle) = &spec.handle {
return handle(theme);
}
let icon = spec
.handle_icon
.or(theme.widgets.reorderable.handle_icon)
.unwrap_or(Icons::DRAG_HANDLE);
let size = spec
.handle_icon_size
.or(theme.widgets.reorderable.handle_icon_size)
.unwrap_or(HANDLE_ICON);
let color = spec
.handle_color
.or(theme.widgets.reorderable.handle_color)
.unwrap_or(theme.muted);
Box::new(Icon::new(icon).size(size).color(color))
}
impl<Msg: Clone + 'static> Widget<Msg> for ReorderableList<Msg> {
fn style(&self) -> Style {
Style {
width: self.width,
height: self.height,
flex_grow: self.flex_grow,
flex_direction: match self.spec.borrow().axis {
ReorderAxis::Vertical => FlexDirection::Column,
ReorderAxis::Horizontal => FlexDirection::Row,
},
gap: self.gap,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.rows
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inspector::InspectorNode;
use crate::widget::Widget;
use crate::{text, Container, Runtime, Size};
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Moved(usize, usize),
}
fn list(grab: ReorderGrab) -> ReorderableList<Msg> {
ReorderableList::new(Msg::Moved)
.grab(grab)
.width(300.0)
.keyed_row(1, Container::new().height(40.0).child(text("one")))
.keyed_row(2, Container::new().height(40.0).child(text("two")))
.keyed_row(3, Container::new().height(40.0).child(text("three")))
}
fn nodes_of(root: &dyn Widget<Msg>) -> Vec<&dyn Widget<Msg>> {
crate::build_deferred(root, &Theme::dark(), &crate::Runtime::default());
let mut out: Vec<&dyn Widget<Msg>> = vec![root];
let mut i = 0;
while i < out.len() {
let node = out[i];
for child in node.children() {
out.push(child.as_ref());
}
i += 1;
}
out
}
fn inspected(root: &dyn Widget<Msg>) -> Vec<InspectorNode> {
crate::build_ui_inspected(
root,
Size::new(300.0, 400.0),
&Runtime::default(),
&Theme::dark(),
)
.1
}
fn slots(root: &dyn Widget<Msg>) -> Vec<(usize, bool, bool)> {
nodes_of(root)
.into_iter()
.filter_map(|w| {
w.reorder_index()
.map(|i| (i, w.reorder_draggable(), w.reorder_droppable()))
})
.collect()
}
#[test]
fn a_settled_index_is_the_raw_one_minus_the_row_that_left() {
assert_eq!(settled_index(0, 3), 2);
assert_eq!(settled_index(3, 0), 0);
assert_eq!(settled_index(2, 2), 2);
assert_eq!(settled_index(2, 3), 2);
}
#[test]
fn a_gripped_row_is_a_target_and_its_grip_is_the_source() {
let list = list(ReorderGrab::Handle);
let slots = slots(&list);
assert_eq!(slots.len(), 6, "three rows and three grips: {slots:?}");
for i in 0..3 {
assert!(
slots.contains(&(i, false, true)),
"row {i} is a target and not a source: {slots:?}"
);
assert!(
slots.contains(&(i, true, false)),
"grip {i} is a source and not a target: {slots:?}"
);
}
}
#[test]
fn a_held_row_is_both_and_waits_for_the_hold() {
let list = list(ReorderGrab::LongPress);
assert_eq!(
slots(&list),
vec![(0, true, true), (1, true, true), (2, true, true)]
);
let held: Vec<bool> = nodes_of(&list)
.into_iter()
.filter(|w| w.reorder_index().is_some())
.map(|w| w.drag_needs_long_press())
.collect();
assert_eq!(held, vec![true, true, true]);
}
#[test]
fn the_message_carries_where_the_row_ends_up() {
let list = list(ReorderGrab::Handle);
let row = nodes_of(&list)
.into_iter()
.find(|w| w.reorder_index() == Some(0) && !w.reorder_draggable())
.expect("the first row");
assert_eq!(row.on_reorder(3), Some(Msg::Moved(0, 2)));
assert_eq!(row.on_reorder(1), None, "a move of nothing sends nothing");
assert_eq!(
row.reorder_announcement(3).as_deref(),
Some("Moved to position 3 of 3")
);
assert_eq!(row.reorder_announcement(1), None);
}
#[test]
fn a_setter_after_a_row_still_reaches_it() {
let late = ReorderableList::new(Msg::Moved)
.width(300.0)
.keyed_row(1, Container::new().height(40.0).child(text("one")))
.grab(ReorderGrab::LongPress);
assert_eq!(slots(&late), vec![(0, true, true)]);
}
#[test]
fn a_disabled_list_offers_nothing_to_grab() {
let list = list(ReorderGrab::Handle).enabled(false);
assert!(slots(&list).is_empty(), "no source and no target");
}
#[test]
fn the_grip_takes_its_own_room_at_the_trailing_edge() {
let list = list(ReorderGrab::Handle);
let nodes = inspected(&list);
let grips: Vec<&InspectorNode> =
nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
assert_eq!(grips.len(), 3, "one grip per row");
for grip in grips {
assert!(
(grip.rect.width - HANDLE_WIDTH).abs() < 0.5,
"the grip keeps its 40 px: {:?}",
grip.rect
);
assert!(
(grip.rect.x + grip.rect.width - 300.0).abs() < 0.5,
"at the trailing edge: {:?}",
grip.rect
);
}
}
#[test]
fn the_grip_sits_inside_the_row_it_moves() {
let list = list(ReorderGrab::Handle);
let nodes = inspected(&list);
let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
let grips: Vec<&InspectorNode> =
nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
assert_eq!((rows.len(), grips.len()), (3, 3));
for (row, grip) in rows.iter().zip(&grips) {
let middle = frus_core::Point::new(
grip.rect.x + grip.rect.width * 0.5,
grip.rect.y + grip.rect.height * 0.5,
);
assert!(
row.rect.contains(middle),
"the grip's middle {middle:?} is inside its row {:?}",
row.rect
);
}
let indices: Vec<(usize, bool)> = nodes_of(&list)
.into_iter()
.filter_map(|w| w.reorder_index().map(|i| (i, w.reorder_droppable())))
.collect();
for i in 0..3 {
assert!(indices.contains(&(i, true)) && indices.contains(&(i, false)));
}
}
fn strip(grab: ReorderGrab) -> ReorderableList<Msg> {
ReorderableList::new(Msg::Moved)
.grab(grab)
.axis(ReorderAxis::Horizontal)
.width(300.0)
.keyed_row(1, Container::new().width(80.0).child(text("one")))
.keyed_row(2, Container::new().width(80.0).child(text("two")))
.keyed_row(3, Container::new().width(80.0).child(text("three")))
}
#[test]
fn a_horizontal_list_lays_its_rows_along_x_with_the_grip_underneath() {
let list = strip(ReorderGrab::Handle);
let nodes = inspected(&list);
let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
let grips: Vec<&InspectorNode> =
nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
assert_eq!((rows.len(), grips.len()), (3, 3));
for pair in rows.windows(2) {
assert!(
(pair[0].rect.y - pair[1].rect.y).abs() < 0.5
&& pair[1].rect.x >= pair[0].rect.x + pair[0].rect.width - 0.5,
"side by side, in order: {:?} then {:?}",
pair[0].rect,
pair[1].rect
);
}
for (row, grip) in rows.iter().zip(&grips) {
assert!(
(grip.rect.height - HANDLE_WIDTH).abs() < 0.5,
"the grip keeps its 40 px, as a height: {:?}",
grip.rect
);
assert!(
(grip.rect.y + grip.rect.height - (row.rect.y + row.rect.height)).abs() < 0.5,
"at the row's bottom edge: {:?} in {:?}",
grip.rect,
row.rect
);
assert!(
row.rect.height > HANDLE_WIDTH + 8.0,
"and the row's content keeps its room above it: {:?}",
row.rect
);
let middle = frus_core::Point::new(
grip.rect.x + grip.rect.width * 0.5,
grip.rect.y + grip.rect.height * 0.5,
);
assert!(
row.rect.contains(middle),
"{middle:?} inside {:?}",
row.rect
);
}
}
#[test]
fn a_row_across_a_strip_keeps_its_height() {
let content = |list: &ReorderableList<Msg>| -> Vec<Rect> {
inspected(list)
.iter()
.filter(|n| n.name == "Container" && n.rect.width > 0.0)
.map(|n| n.rect)
.take(3)
.collect()
};
let held = content(&strip(ReorderGrab::LongPress));
assert_eq!(held.len(), 3);
assert!(
held.iter().all(|r| r.height > 8.0),
"a held strip's rows are not flat: {held:?}"
);
let tall = content(&strip(ReorderGrab::Handle).height(120.0));
assert!(
tall.iter()
.all(|r| (r.height - (120.0 - HANDLE_WIDTH)).abs() < 0.5),
"a strip 120 tall leaves its rows 80 above the grip: {tall:?}"
);
}
#[test]
fn a_horizontal_list_answers_across_and_inserts() {
for grab in [ReorderGrab::Handle, ReorderGrab::LongPress] {
let across = strip(grab);
let answers: Vec<(ReorderAxis, bool)> = nodes_of(&across)
.into_iter()
.filter(|w| w.reorder_index().is_some())
.map(|w| (w.reorder_axis(), w.reorder_inserts()))
.collect();
assert!(!answers.is_empty());
assert!(
answers
.iter()
.all(|a| *a == (ReorderAxis::Horizontal, true)),
"{answers:?}"
);
}
let down = list(ReorderGrab::Handle);
assert!(nodes_of(&down)
.into_iter()
.filter(|w| w.reorder_index().is_some())
.all(|w| w.reorder_axis() == ReorderAxis::Vertical));
let late = ReorderableList::new(Msg::Moved)
.keyed_row(1, Container::new().width(80.0).child(text("one")))
.axis(ReorderAxis::Horizontal);
assert!(nodes_of(&late)
.into_iter()
.filter(|w| w.reorder_index().is_some())
.all(|w| w.reorder_axis() == ReorderAxis::Horizontal));
}
#[test]
fn a_horizontal_drop_routes_the_same_in_either_reading_direction() {
for rtl in [false, true] {
let theme = if rtl {
Theme::dark().rtl()
} else {
Theme::dark()
};
let list = strip(ReorderGrab::Handle);
let ui = crate::build_ui(&list, Size::new(300.0, 120.0), &Runtime::default(), &theme);
let find = |id| crate::find_widget(&list, id);
let grip_of = |index| {
ui.reorderables()
.iter()
.find(|(id, _)| {
find(*id).is_some_and(|w| {
w.reorder_index() == Some(index) && !w.reorder_droppable()
})
})
.copied()
.expect("a grip")
};
let (grip0, grip0_box) = grip_of(0);
let middle = frus_core::Point::new(
grip0_box.x + grip0_box.width * 0.5,
grip0_box.y + grip0_box.height * 0.5,
);
let row0 = ui
.reorderables_at(middle)
.find(|id| {
find(*id).is_some_and(|w| w.reorder_droppable() && w.reorder_index() == Some(0))
})
.expect("the grip sits inside its row");
let slots = crate::reorder_siblings(&ui, &list, row0);
assert_eq!(
slots.len(),
3,
"rtl={rtl}: the row's list is the three rows"
);
let boxes: Vec<Rect> = slots.iter().map(|(_, r)| *r).collect();
assert_eq!(
boxes[0].x > boxes[2].x,
rtl,
"rtl={rtl}: the first row is on the reading side: {boxes:?}"
);
let last = boxes[2];
let past = frus_core::Point::new(
if rtl {
last.x - 24.0
} else {
last.x + last.width + 24.0
},
last.y + last.height * 0.5,
);
assert!(
(0.0..300.0).contains(&past.x),
"rtl={rtl}: still in the window: {past:?}"
);
assert!(
!ui.reorderables_at(past)
.any(|id| find(id).is_some_and(|w| w.reorder_droppable())),
"rtl={rtl}: nothing to drop on past the end"
);
let nearest = crate::nearest_reorder_slot(past, &boxes, ReorderAxis::Horizontal)
.expect("the strip's own nearest slot");
assert_eq!(nearest, 2, "rtl={rtl}: the last row");
let after = crate::reorder_drop_after(past, boxes[2], ReorderAxis::Horizontal, rtl);
assert!(after, "rtl={rtl}: past the end is after the last row");
let grip0 = find(grip0).expect("the grip");
assert_eq!(
grip0.on_reorder(nearest + usize::from(after)),
Some(Msg::Moved(0, 2)),
"rtl={rtl}: the first row ends up last"
);
let first = boxes[0];
let leading = frus_core::Point::new(
if rtl {
first.x + first.width * 0.8
} else {
first.x + first.width * 0.2
},
first.y + first.height * 0.3,
);
let (grip2, _) = grip_of(2);
let after = crate::reorder_drop_after(leading, first, ReorderAxis::Horizontal, rtl);
assert!(!after, "rtl={rtl}: the leading half is before the row");
assert_eq!(
find(grip2).and_then(|w| w.on_reorder(usize::from(after))),
Some(Msg::Moved(2, 0)),
"rtl={rtl}: the last row ends up first"
);
}
}
#[test]
fn a_built_strip_makes_room_along_x() {
let list = strip(ReorderGrab::Handle);
let ui = crate::build_ui(
&list,
Size::new(300.0, 120.0),
&Runtime::default(),
&Theme::dark(),
);
let mut rows: Vec<(crate::WidgetId, Rect)> = ui
.reorderables()
.iter()
.filter(|(id, _)| crate::find_widget(&list, *id).is_some_and(|w| w.reorder_droppable()))
.copied()
.collect();
rows.sort_by(|a, b| a.1.x.total_cmp(&b.1.x));
assert_eq!(rows.len(), 3, "three rows: {rows:?}");
let (row0, src) = rows[0];
let lifted: std::collections::HashSet<u64> = crate::find_widget(&list, row0)
.map(|w| {
crate::subtree_ids(w, row0)
.iter()
.map(|i| i.as_u64())
.collect()
})
.expect("the first row");
let movable = crate::reorderable_owners(&ui, &list);
let prims = ui.scene().primitives();
let kept: Vec<&frus_core::Primitive> = prims
.iter()
.filter(|p| !lifted.contains(&p.owner()))
.collect();
let centred_in = |b: Rect, r: Rect| {
r.contains(frus_core::Point::new(
b.x + b.width * 0.5,
b.y + b.height * 0.5,
))
};
let line = Rect::new(rows[2].1.x - 1.5, rows[2].1.y, 3.0, rows[2].1.height);
for (line, shifts) in [
(None, [-src.width, -src.width]),
(Some(line), [-src.width, 0.0]),
] {
let out = crate::reflow_reorder_cards(
prims,
src,
line,
&lifted,
&movable,
ReorderAxis::Horizontal,
);
assert_eq!(out.len(), kept.len(), "only the lifted row is taken out");
let mut moved = [0, 0];
for (before, after) in kept.iter().zip(&out) {
let (b, a) = (before.bounds(), after.bounds());
assert_eq!(a.y, b.y, "nothing moves across the strip: {b:?}");
let row = (1..3).find(|&i| centred_in(b, rows[i].1));
match row {
Some(i) if movable.contains(&before.owner()) => {
assert!(
(a.x - (b.x + shifts[i - 1])).abs() < 0.01,
"line={line:?}: row {i}'s {b:?} shifts by {} and is at {a:?}",
shifts[i - 1]
);
moved[i - 1] += 1;
}
_ => assert_eq!(a.x, b.x, "not part of a row: stays"),
}
}
assert!(
moved.iter().all(|&n| n > 0),
"both rows paint something: {moved:?}"
);
}
}
#[test]
fn a_held_list_has_no_grip_taking_room() {
let list = list(ReorderGrab::LongPress);
let nodes = inspected(&list);
for grip in nodes.iter().filter(|n| n.name == "ReorderHandle") {
assert_eq!(grip.rect.width, 0.0, "no box: {:?}", grip.rect);
}
let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
assert_eq!(rows.len(), 3);
for row in rows {
assert!((row.rect.width - 300.0).abs() < 0.5, "{:?}", row.rect);
}
}
}