Skip to main content

frus_widgets/
reorderable.rs

1//! [`ReorderableList`]: **a list whose rows can be dragged into a new order** — a
2//! playlist, a set of favourites, a to-do list.
3//!
4//! Reordering already existed here, but only as a board: [`crate::Kanban`] moves cards
5//! between columns and [`crate::Table`] moves columns, and an application that wanted the
6//! ordinary case had either to take a board's assumptions or to build the gesture itself
7//! out of [`crate::Draggable`]. What it had to build is the part that is hard: a gap that
8//! opens where the row would land, a list that keeps scrolling while a row is carried past
9//! its edge, and a grip that does not take the gesture the scroll needs.
10//!
11//! **What is new here is thinner than it looks**, and that is the point. The shell has
12//! carried a vertical reordering drag since milestone 252 — the ghost, the insertion line,
13//! the neighbours sliding out of the way, the drop routed back as `on_reorder` — and it
14//! carries it for any widget that answers [`Widget::reorder_index`] with
15//! [`ReorderAxis::Vertical`]. A list's rows answer it. So this module is a row wrapper, a
16//! grip, and the two decisions that gesture needs: **who may be grabbed**, and **what index
17//! comes back**.
18//!
19//! ## Who may be grabbed
20//!
21//! A row that could be lifted by a press anywhere on it would stop its own list scrolling,
22//! because the press that starts a drag is the press that would have started the scroll.
23//! The reference splits the answer into two widgets — a listener that grabs immediately,
24//! for a handle, and one that waits for a long press, for the whole row — and leaves the
25//! choice to the application, because it depends on what else the row is for.
26//!
27//! Here that is [`ReorderGrab`], defaulting the way the reference defaults it: a **grip**
28//! on a desktop, where there is a pointer and a small target is easy, and a **hold** on a
29//! phone, where the finger is coarse and the list scrolls under it.
30//!
31//! ## What index comes back
32//!
33//! Dropping a row on the lower half of the third row means *after the third row*, which is
34//! raw index 3 — but the row being carried is no longer in the list, so where it lands is
35//! index 2. The reference passed the raw number and told applications to subtract one
36//! themselves; it has since deprecated that callback in favour of one that does the
37//! subtraction for it. This framework starts where the reference ended up:
38//! `on_reorder(from, to)` gives **the index the row ends up at**, and
39//! [`settled_index`] is the one line that makes it so.
40//!
41//! ## Along either axis
42//!
43//! A list of rows reads down; a strip of cards, a row of chips, a tab bar reads across.
44//! [`ReorderableList::axis`] turns the whole list: the rows are laid out along x, the grip
45//! moves from the trailing edge to the bottom, and the gesture is the same one transposed —
46//! the gap, the insertion line, the drop past either end, the list scrolling at the left
47//! and right edges. The ghost stays in its lane, as the reference keeps it: a horizontal list
48//! has nowhere else to put a row.
49//!
50//! Under a right-to-left layout a horizontal list runs from the right, as every row of
51//! children does here: its first row is the rightmost, *after* a row is its left, and past
52//! the end is past the left edge. Nothing is asked of the application for that.
53
54use 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
67/// The width of the grip's box, and so the size of its tap target.
68const HANDLE_WIDTH: f32 = 40.0;
69/// The glyph inside it.
70const HANDLE_ICON: f32 = 20.0;
71
72/// How a row of a [`ReorderableList`] is picked up.
73///
74/// Not a decoration: it decides whether the list can still be scrolled with a finger, so
75/// it is the first thing to get right and the reason the reference has two widgets where
76/// it could have had one.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum ReorderGrab {
79    /// A **grip** at the row's trailing edge, which lifts on the press. Everywhere else on
80    /// the row keeps meaning what it meant — a tap, a swipe, a scroll.
81    Handle,
82    /// **The whole row**, after a hold. Nothing is added to the row, and the list still
83    /// scrolls: a finger that holds still is not scrolling, which is what makes the hold
84    /// a gesture the scroll cannot claim.
85    LongPress,
86}
87
88impl Default for ReorderGrab {
89    /// The reference's own rule, read from the platform rather than from a flag: a grip
90    /// where there is a pointer, a hold where there is a finger.
91    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
103/// Where a row **ends up**, given where it came from and the raw slot it was dropped on.
104///
105/// The raw slot counts the rows as they are on screen, with the carried row still among
106/// them. Once it is taken out, everything after it has shifted up by one — so a drop aimed
107/// past the row's own position lands one earlier than the number said.
108///
109/// It is a subtraction every application writes once and gets wrong once, which is why it
110/// is here and tested rather than in a doc comment telling callers to do it.
111pub 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
122/// Everything a row needs to know that belongs to the **list**, shared with the rows so
123/// that a setter called after a row was added still reaches it.
124///
125/// The alternative — settling each row when it is added — makes the order of the builder
126/// calls part of the API, and a list whose `.grab()` silently does nothing because it came
127/// after `.row()` is a bug no compiler catches.
128struct Spec<Msg> {
129    grab: ReorderGrab,
130    /// The way the rows run: down, or across.
131    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    /// How many rows there are, for what is said out loud on the drop. Live, because the
140    /// last row is added after the first one was.
141    count: usize,
142}
143
144impl<Msg> Spec<Msg> {
145    /// Is the grip the way in, this frame?
146    fn gripped(&self) -> bool {
147        self.enabled && self.grab == ReorderGrab::Handle
148    }
149
150    /// Is the whole row the way in, this frame?
151    fn held(&self) -> bool {
152        self.enabled && self.grab == ReorderGrab::LongPress
153    }
154}
155
156type Shared<Msg> = Rc<RefCell<Spec<Msg>>>;
157
158/// The message for a move, or `None` when the row would not actually move.
159///
160/// A drop on the lower half of a row's **own** box asks for the slot after it, which is
161/// where it already is. The shell guards the same case by identity, but it cannot when the
162/// grip and the row are two widgets: what was grabbed is the grip, and what was dropped on
163/// is the row, so they do not compare equal. The arithmetic knows, and says nothing.
164fn 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
172/// What the screen reader says once the row has landed: its new position, one-based, of
173/// however many rows there are.
174fn 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
179/// One row: the caller's widget, and the grip beside it.
180///
181/// It is the drop **target** in both modes — the whole row is what a carried row is aimed
182/// at — and the drag **source** only when the mode is a hold.
183struct 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        // The row and its grip sit side by side *across* the list: beside each other in a
193        // list that runs down, one above the other in a list that runs across.
194        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
250/// The grip: a source that is not a target.
251///
252/// It is always in the row, and takes no room at all in the modes that do not use it —
253/// which is what lets `.grab()` be read at build time instead of at the moment the row was
254/// added.
255struct 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        // Its depth is taken across the list: a gutter beside a row that runs down, a strip
272        // under one that runs across.
273        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            // A grip that shrinks is a grip that misses: the row beside it is what gives
281            // way when the line is tight.
282            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
324/// The caller's row, in the room its grip leaves.
325///
326/// Down a list it is exactly a [`crate::Expanded`]: a basis of zero, and all the width the
327/// grip did not take. Across one, that basis is wrong. The row and its grip are stacked in a
328/// column nobody gave a height to, so a row that starts at nothing grows into nothing, and a
329/// strip of chips lays out as a line of grips. There the row starts at its own height and
330/// grows only into a height the list was given. The axis is read at layout time, like the
331/// rest of the spec, so an `.axis()` after the rows still reaches them.
332struct ReorderContent<Msg> {
333    inner: Box<dyn Widget<Msg>>,
334    spec: Shared<Msg>,
335}
336
337impl<Msg> ReorderContent<Msg> {
338    /// The one thing this wrapper changes: the flex item the row is, along the axis.
339    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    /// Forwarded: the row keeps the identity the application gave it.
353    fn key(&self) -> Option<u64> {
354        self.inner.key()
355    }
356
357    /// Forwarded: a box is not a place.
358    fn positioned(&self) -> Option<crate::positioned::Positioning> {
359        self.inner.positioned()
360    }
361
362    /// Forwarded too: a box is not a palette.
363    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    /// Forwarded: a wrapper is its child, and a scoped surface is the child's to impose.
371    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    /// Forwarded: a row that is a form is still that form.
378    fn autofill_group(&self) -> bool {
379        self.inner.autofill_group()
380    }
381});
382
383/// A list whose rows can be **dragged into a new order**.
384///
385/// The order stays the application's, as everywhere else here: the list emits *this row
386/// moved from `from` to `to`* and the application permutes its own data. Nothing moves on
387/// screen until it does.
388///
389/// ```ignore
390/// ReorderableList::new(|from, to| Msg::MoveTrack(from, to))
391///     .gap(8.0)
392///     .keyed_row(track.id, track_row(track))
393/// ```
394///
395/// **Each row should carry a key** — `keyed_row`, or a [`crate::keyed`] wrapper of your
396/// own. Rows are told apart by position otherwise, and a list whose rows are about to swap
397/// positions is exactly where that goes wrong: the hover and the animations of the row
398/// that moved would stay behind with its index.
399pub 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    /// A list whose rows move; `on_reorder(from, to)` carries **the index the row ends up
410    /// at**, both counted in this list's own rows.
411    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    /// Adds a row, identified by `key`.
436    #[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    /// Adds a row with no key of its own — for a list that never changes length.
442    #[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        // The grip's own appearance is deferred until the theme is known, so that every
453        // one of its parts — the glyph, its colour, its size, or a widget of the
454        // application's entirely — can be set after this row was added and still be the
455        // one that is drawn.
456        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    /// How a row is picked up. Defaults to the platform's habit — see [`ReorderGrab`].
479    #[must_use]
480    pub fn grab(self, grab: ReorderGrab) -> Self {
481        self.spec.borrow_mut().grab = grab;
482        self
483    }
484
485    /// The way the rows run, [`ReorderAxis::Vertical`] by default. A horizontal list lays its
486    /// rows out along x, puts each grip under its row, and is reordered across: see the
487    /// module's notes for what that means under a right-to-left layout.
488    #[must_use]
489    pub fn axis(self, axis: ReorderAxis) -> Self {
490        self.spec.borrow_mut().axis = axis;
491        self
492    }
493
494    /// Turns reordering off without changing the list: no grip, no hold, no drop target.
495    /// A list that is briefly read-only stays the same widget, so its rows keep their
496    /// state.
497    #[must_use]
498    pub fn enabled(self, enabled: bool) -> Self {
499        self.spec.borrow_mut().enabled = enabled;
500        self
501    }
502
503    /// The whole grip, replaced by a widget of the application's. It is built with the
504    /// ambient theme, and what it is made of is nobody's business but the caller's — this
505    /// widget only says where it goes and what grabbing it means.
506    #[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    /// The grip's glyph, over the theme's and the framework's.
513    #[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    /// The grip's colour, over the theme's.
520    #[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    /// The grip's glyph size.
527    #[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    /// The depth of the grip's box — its tap target, which is not the size of the glyph
534    /// inside it. A width beside the rows of a vertical list, a height under the rows of a
535    /// horizontal one.
536    #[must_use]
537    pub fn handle_width(self, width: f32) -> Self {
538        self.spec.borrow_mut().handle_width = width;
539        self
540    }
541
542    /// Space between rows.
543    #[must_use]
544    pub fn gap(mut self, gap: f32) -> Self {
545        self.gap = gap;
546        self
547    }
548
549    /// An explicit width.
550    #[must_use]
551    pub fn width(mut self, width: f32) -> Self {
552        self.width = Dimension::Length(width);
553        self
554    }
555
556    /// A width given as a fraction of what the parent offers.
557    #[must_use]
558    pub fn width_fraction(mut self, fraction: f32) -> Self {
559        self.width = Dimension::Percent(fraction);
560        self
561    }
562
563    /// An explicit height.
564    #[must_use]
565    pub fn height(mut self, height: f32) -> Self {
566        self.height = Dimension::Length(height);
567        self
568    }
569
570    /// The share of a parent flex's spare room this list takes.
571    #[must_use]
572    pub fn flex(mut self, grow: f32) -> Self {
573        self.flex_grow = grow;
574        self
575    }
576}
577
578/// The grip's contents: the application's widget, or the default glyph.
579fn grip<Msg: Clone + 'static>(spec: &Spec<Msg>, theme: &Theme) -> Box<dyn Widget<Msg>> {
580    if !spec.gripped() {
581        // Not this mode: an empty box, so that nothing is measured, drawn or hit.
582        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    /// Walks the built tree and hands back every widget, so the hooks the shell reads can
650    /// be read the same way.
651    ///
652    /// The grip's contents are deferred until the theme is known, so the tree is built
653    /// first — a walk that reaches a deferred node before any layout pass has is exactly
654    /// what milestone 415 made say so out loud.
655    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    /// The inspector's boxes for a list laid out in a 300 x 400 window.
670    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    /// Every reorderable in the tree, as `(index, draggable, droppable)`.
681    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        // Downwards: the row is taken out first, so everything after it has shifted up.
694        assert_eq!(settled_index(0, 3), 2);
695        // Upwards: nothing between the row and its destination has moved.
696        assert_eq!(settled_index(3, 0), 0);
697        // Onto itself, from either half: it is already there.
698        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        // Three rows and three grips, each pair sharing an index.
707        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        // The grips withdraw entirely: no index, so nothing is registered for them.
724        assert_eq!(
725            slots(&list),
726            vec![(0, true, true), (1, true, true), (2, true, true)]
727        );
728        // And what makes the list still scrollable: the row does not take the press.
729        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        // Dropped past the last row: raw 3, and the list is three long, so it lands at 2.
745        assert_eq!(row.on_reorder(3), Some(Msg::Moved(0, 2)));
746        // Dropped on its own lower half: raw 1, which is where it already is.
747        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        // The order of the builder calls is not part of the API: `.grab()` last is the
758        // same list as `.grab()` first.
759        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    /// The grip is beside the row, not on top of it: the row keeps the width it is left,
773    /// and the grip keeps its own however narrow the list gets.
774    #[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    /// **The grip's middle is inside its row**, and the two share an index.
796    ///
797    /// That pair is not decoration: it is how the shell finds what a grab actually moves.
798    /// What is grabbed is the grip, and everything the drag is made of is the row's — the
799    /// ghost, the height of the gap that closes behind it, the band its neighbours are
800    /// matched against — so the shell looks for the droppable reorderable of the same
801    /// index under the grip's own middle. A grip that sat outside its row, or carried a
802    /// different index, would lift a 40-pixel icon and open a slot to match.
803    #[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        // And the index they share, which is what tells the shell which row is which.
823        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    /// A list that runs across: three rows 80 px wide, gripped, in a strip 300 px wide.
833    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    /// **Rows along x, and each grip under its row.** The grip keeps its 40 px of depth —
844    /// a height now — and its middle is still inside the row it moves, which is how the
845    /// shell finds the row from the grip on either axis.
846    #[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    /// **A row across a strip keeps its own height.** Down a list the row takes the width
893    /// its grip leaves, from nothing; stacked over the grip in a strip with no height of its
894    /// own, a row that started at nothing stayed there and the strip was a line of grips. So
895    /// a held strip is as tall as its content, and a strip given a height hands what the
896    /// grip leaves to its rows.
897    #[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                // The rows' own boxes; a held grip leaves an empty one of nothing.
903                .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    /// Every row and every grip of a horizontal list says it runs across and is inserted
923    /// between its neighbours — the two answers the shell turns the gesture by. A list that
924    /// runs down says the same of itself.
925    #[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        // And `.axis()` after the rows still reaches them.
948        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    /// **The whole route of a horizontal drop, on a real built frame**, asked in the order
958    /// the shell asks it — grab the grip, find the row it moves, find nothing under a
959    /// pointer past the end, fall back to the nearest slot along x, take the half, route the
960    /// message — left to right and right to left.
961    ///
962    /// Right to left the strip is mirrored: its first row is on the right, past its end is
963    /// past its **left** edge, and after a row is its left half. The same finger movement
964    /// in reading terms has to give the same message.
965    #[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            // Past the end, in reading terms: beyond the last row's far edge.
1013            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            // And the last row carried onto the first row's leading half lands first.
1044            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    /// **What makes room on a real built strip**, asked the way the shell asks it: the first
1065    /// row lifted with everything it paints, the rest of the strip movable. With no line,
1066    /// every row after it — its label, its grip, whatever it draws — moves back along x by
1067    /// the lifted row's width and not at all along y; with the line on the third row's
1068    /// leading edge, the second still closes the gap and the third, at the line, stays.
1069    #[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    /// And in the mode that does not use it, it costs nothing at all.
1147    #[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        // The rows still fill the list's width, grip or no grip.
1155        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}