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
88/// The way in a list that was not told, by the theme's platform: the reference's switch
89/// (`reorderable_list.dart:383`), a grip on the desktops and a hold on Android, Fuchsia
90/// and iOS.
91fn platform_grab(platform: frus_core::TargetPlatform) -> ReorderGrab {
92    use frus_core::TargetPlatform as P;
93    match platform {
94        P::Linux | P::Windows | P::MacOs => ReorderGrab::Handle,
95        P::Android | P::Fuchsia | P::Ios => ReorderGrab::LongPress,
96    }
97}
98
99/// Where a row **ends up**, given where it came from and the raw slot it was dropped on.
100///
101/// The raw slot counts the rows as they are on screen, with the carried row still among
102/// them. Once it is taken out, everything after it has shifted up by one — so a drop aimed
103/// past the row's own position lands one earlier than the number said.
104///
105/// It is a subtraction every application writes once and gets wrong once, which is why it
106/// is here and tested rather than in a doc comment telling callers to do it.
107pub fn settled_index(from: usize, raw: usize) -> usize {
108    if raw > from {
109        raw - 1
110    } else {
111        raw
112    }
113}
114
115type ReorderFn<Msg> = Rc<dyn Fn(usize, usize) -> Msg>;
116type HandleFn<Msg> = Rc<dyn Fn(&Theme) -> Box<dyn Widget<Msg>>>;
117
118/// Everything a row needs to know that belongs to the **list**, shared with the rows so
119/// that a setter called after a row was added still reaches it.
120///
121/// The alternative — settling each row when it is added — makes the order of the builder
122/// calls part of the API, and a list whose `.grab()` silently does nothing because it came
123/// after `.row()` is a bug no compiler catches.
124struct Spec<Msg> {
125    /// What the application said; `None` for the platform's way (milestone 615).
126    grab: Option<ReorderGrab>,
127    /// The theme's platform, as the last layout found it: what an untold list follows.
128    /// The gesture hooks run outside layout and read it from here.
129    platform: frus_core::TargetPlatform,
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    /// The way in: what the application said, else the platform's.
146    fn grab(&self) -> ReorderGrab {
147        self.grab.unwrap_or_else(|| platform_grab(self.platform))
148    }
149
150    /// Is the grip the way in, this frame?
151    fn gripped(&self) -> bool {
152        self.enabled && self.grab() == ReorderGrab::Handle
153    }
154
155    /// Is the whole row the way in, this frame?
156    fn held(&self) -> bool {
157        self.enabled && self.grab() == ReorderGrab::LongPress
158    }
159}
160
161type Shared<Msg> = Rc<RefCell<Spec<Msg>>>;
162
163/// The message for a move, or `None` when the row would not actually move.
164///
165/// A drop on the lower half of a row's **own** box asks for the slot after it, which is
166/// where it already is. The shell guards the same case by identity, but it cannot when the
167/// grip and the row are two widgets: what was grabbed is the grip, and what was dropped on
168/// is the row, so they do not compare equal. The arithmetic knows, and says nothing.
169fn move_message<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<Msg> {
170    let to = settled_index(from, raw);
171    if to == from {
172        return None;
173    }
174    spec.on_reorder.as_ref().map(|f| f(from, to))
175}
176
177/// What the screen reader says once the row has landed: its new position, one-based, of
178/// however many rows there are.
179fn move_announcement<Msg>(spec: &Spec<Msg>, from: usize, raw: usize) -> Option<String> {
180    let to = settled_index(from, raw);
181    (to != from).then(|| format!("Moved to position {} of {}", to + 1, spec.count.max(to + 1)))
182}
183
184/// One row: the caller's widget, and the grip beside it.
185///
186/// It is the drop **target** in both modes — the whole row is what a carried row is aimed
187/// at — and the drag **source** only when the mode is a hold.
188struct ReorderRow<Msg> {
189    index: usize,
190    key: Option<u64>,
191    spec: Shared<Msg>,
192    children: Vec<Box<dyn Widget<Msg>>>,
193}
194
195impl<Msg: Clone + 'static> Widget<Msg> for ReorderRow<Msg> {
196    fn style(&self) -> Style {
197        // The row and its grip sit side by side *across* the list: beside each other in a
198        // list that runs down, one above the other in a list that runs across.
199        let direction = match self.spec.borrow().axis {
200            ReorderAxis::Vertical => FlexDirection::Row,
201            ReorderAxis::Horizontal => FlexDirection::Column,
202        };
203        Style {
204            width: Dimension::Auto,
205            height: Dimension::Auto,
206            flex_direction: direction,
207            align: Align::Center,
208            ..Default::default()
209        }
210    }
211
212    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
213        &self.children
214    }
215
216    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
217
218    fn on_click(&self) -> Option<Msg> {
219        None
220    }
221
222    fn key(&self) -> Option<u64> {
223        self.key
224    }
225
226    fn reorder_index(&self) -> Option<usize> {
227        self.spec.borrow().enabled.then_some(self.index)
228    }
229
230    fn on_reorder(&self, to: usize) -> Option<Msg> {
231        move_message(&self.spec.borrow(), self.index, to)
232    }
233
234    fn reorder_announcement(&self, to: usize) -> Option<String> {
235        move_announcement(&self.spec.borrow(), self.index, to)
236    }
237
238    fn reorder_axis(&self) -> ReorderAxis {
239        self.spec.borrow().axis
240    }
241
242    fn reorder_inserts(&self) -> bool {
243        true
244    }
245
246    fn reorder_draggable(&self) -> bool {
247        self.spec.borrow().held()
248    }
249
250    fn drag_needs_long_press(&self) -> bool {
251        self.spec.borrow().held()
252    }
253}
254
255/// The grip: a source that is not a target.
256///
257/// It is always in the row, and takes no room at all in the modes that do not use it —
258/// which is what lets `.grab()` be read at build time instead of at the moment the row was
259/// added.
260struct ReorderHandle<Msg> {
261    index: usize,
262    spec: Shared<Msg>,
263    children: Vec<Box<dyn Widget<Msg>>>,
264}
265
266impl<Msg: Clone + 'static> Widget<Msg> for ReorderHandle<Msg> {
267    /// **Where the theme's platform is recorded** for the list: every row has a grip, laid
268    /// out under the theme the row is, and the gesture hooks — which run outside layout —
269    /// read what the last layout recorded here.
270    fn style_themed(&self, theme: &Theme) -> Style {
271        self.spec.borrow_mut().platform = theme.platform;
272        self.style()
273    }
274
275    fn style(&self) -> Style {
276        let spec = self.spec.borrow();
277        if !spec.gripped() {
278            return Style {
279                width: Dimension::Length(0.0),
280                height: Dimension::Length(0.0),
281                ..Default::default()
282            };
283        }
284        // Its depth is taken across the list: a gutter beside a row that runs down, a strip
285        // under one that runs across.
286        let (width, height) = match spec.axis {
287            ReorderAxis::Vertical => (Dimension::Length(spec.handle_width), Dimension::Auto),
288            ReorderAxis::Horizontal => (Dimension::Auto, Dimension::Length(spec.handle_width)),
289        };
290        Style {
291            width,
292            height,
293            // A grip that shrinks is a grip that misses: the row beside it is what gives
294            // way when the line is tight.
295            flex_shrink: 0.0,
296            align: Align::Center,
297            justify: Justify::Center,
298            ..Default::default()
299        }
300    }
301
302    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
303        &self.children
304    }
305
306    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
307
308    fn on_click(&self) -> Option<Msg> {
309        None
310    }
311
312    fn reorder_index(&self) -> Option<usize> {
313        self.spec.borrow().gripped().then_some(self.index)
314    }
315
316    fn on_reorder(&self, to: usize) -> Option<Msg> {
317        move_message(&self.spec.borrow(), self.index, to)
318    }
319
320    fn reorder_announcement(&self, to: usize) -> Option<String> {
321        move_announcement(&self.spec.borrow(), self.index, to)
322    }
323
324    fn reorder_axis(&self) -> ReorderAxis {
325        self.spec.borrow().axis
326    }
327
328    fn reorder_inserts(&self) -> bool {
329        true
330    }
331
332    fn reorder_droppable(&self) -> bool {
333        false
334    }
335}
336
337/// The caller's row, in the room its grip leaves.
338///
339/// Down a list it is exactly a [`crate::Expanded`]: a basis of zero, and all the width the
340/// grip did not take. Across one, that basis is wrong. The row and its grip are stacked in a
341/// column nobody gave a height to, so a row that starts at nothing grows into nothing, and a
342/// strip of chips lays out as a line of grips. There the row starts at its own height and
343/// grows only into a height the list was given. The axis is read at layout time, like the
344/// rest of the spec, so an `.axis()` after the rows still reaches them.
345struct ReorderContent<Msg> {
346    inner: Box<dyn Widget<Msg>>,
347    spec: Shared<Msg>,
348}
349
350impl<Msg> ReorderContent<Msg> {
351    /// The one thing this wrapper changes: the flex item the row is, along the axis.
352    fn restyle(&self, base: Style) -> Style {
353        let expanded = flex_item(base, 1.0, FlexFit::Tight);
354        match self.spec.borrow().axis {
355            ReorderAxis::Vertical => expanded,
356            ReorderAxis::Horizontal => Style {
357                flex_basis: Dimension::Auto,
358                ..expanded
359            },
360        }
361    }
362}
363
364crate::transparent::forward_transparent!(ReorderContent {
365    /// Forwarded: the row keeps the identity the application gave it.
366    fn key(&self) -> Option<u64> {
367        self.inner.key()
368    }
369
370    /// Forwarded: a box is not a place.
371    fn positioned(&self) -> Option<crate::positioned::Positioning> {
372        self.inner.positioned()
373    }
374
375    /// Forwarded too: a box is not a palette.
376    fn theme_override(
377        &self,
378        inherited: &crate::theme::Theme,
379    ) -> Option<Box<crate::theme::Theme>> {
380        self.inner.theme_override(inherited)
381    }
382
383    /// Forwarded: a wrapper is its child, and a scoped surface is the child's to impose.
384    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
385        self.inner.media_override(inherited)
386    }
387    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
388        self.inner.scaffold_override()
389    }
390    fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
391        self.inner.scroll_behavior_override()
392    }
393    /// Forwarded: a row that is a form is still that form.
394    fn autofill_group(&self) -> bool {
395        self.inner.autofill_group()
396    }
397    /// Forwarded: the area's bar is the area's, whatever wraps it.
398    fn area_toolbar(
399        &self,
400        context: crate::ToolbarContext,
401    ) -> Option<Option<&dyn crate::widget::Widget<Msg>>> {
402        self.inner.area_toolbar(context)
403    }
404});
405
406/// A list whose rows can be **dragged into a new order**.
407///
408/// The order stays the application's, as everywhere else here: the list emits *this row
409/// moved from `from` to `to`* and the application permutes its own data. Nothing moves on
410/// screen until it does.
411///
412/// ```ignore
413/// ReorderableList::new(|from, to| Msg::MoveTrack(from, to))
414///     .gap(8.0)
415///     .keyed_row(track.id, track_row(track))
416/// ```
417///
418/// **Each row should carry a key** — `keyed_row`, or a [`crate::keyed`] wrapper of your
419/// own. Rows are told apart by position otherwise, and a list whose rows are about to swap
420/// positions is exactly where that goes wrong: the hover and the animations of the row
421/// that moved would stay behind with its index.
422pub struct ReorderableList<Msg = crate::callback::Callback> {
423    spec: Shared<Msg>,
424    rows: Vec<Box<dyn Widget<Msg>>>,
425    gap: f32,
426    width: Dimension,
427    height: Dimension,
428    flex_grow: f32,
429}
430
431impl<Msg: Clone + 'static> ReorderableList<Msg> {
432    /// A list whose rows move; `on_reorder(from, to)` carries **the index the row ends up
433    /// at**, both counted in this list's own rows.
434    pub fn new<R: crate::callback::IntoMsg<Msg>>(
435        on_reorder: impl Fn(usize, usize) -> R + 'static,
436    ) -> Self {
437        Self {
438            spec: Rc::new(RefCell::new(Spec {
439                grab: None,
440                platform: frus_core::default_target_platform(),
441                axis: ReorderAxis::Vertical,
442                enabled: true,
443                on_reorder: Some(Rc::new(crate::callback::handler2(on_reorder))),
444                handle: None,
445                handle_icon: None,
446                handle_color: None,
447                handle_icon_size: None,
448                handle_width: HANDLE_WIDTH,
449                count: 0,
450            })),
451            rows: Vec::new(),
452            gap: 0.0,
453            width: Dimension::Auto,
454            height: Dimension::Auto,
455            flex_grow: 0.0,
456        }
457    }
458
459    /// Adds a row, identified by `key`.
460    #[must_use]
461    pub fn keyed_row(self, key: u64, row: impl Widget<Msg> + 'static) -> Self {
462        self.push(Some(key), Box::new(row))
463    }
464
465    /// Adds a row with no key of its own — for a list that never changes length.
466    #[must_use]
467    pub fn row(self, row: impl Widget<Msg> + 'static) -> Self {
468        self.push(None, Box::new(row))
469    }
470
471    fn push(mut self, key: Option<u64>, row: Box<dyn Widget<Msg>>) -> Self {
472        let index = self.rows.len();
473        self.spec.borrow_mut().count = index + 1;
474        let spec = Rc::clone(&self.spec);
475        let glyph = Rc::clone(&self.spec);
476        // The grip's own appearance is deferred until the theme is known, so that every
477        // one of its parts — the glyph, its colour, its size, or a widget of the
478        // application's entirely — can be set after this row was added and still be the
479        // one that is drawn.
480        let handle = ReorderHandle {
481            index,
482            spec: Rc::clone(&self.spec),
483            children: vec![Box::new(ThemeBuilder::new(move |theme: &Theme| {
484                grip(&glyph.borrow(), theme)
485            }))],
486        };
487        self.rows.push(Box::new(ReorderRow {
488            index,
489            key,
490            spec,
491            children: vec![
492                Box::new(ReorderContent {
493                    inner: row,
494                    spec: Rc::clone(&self.spec),
495                }),
496                Box::new(handle),
497            ],
498        }));
499        self
500    }
501
502    /// How a row is picked up. Untold, the theme's platform decides, as the reference's
503    /// does: a grip on Linux, macOS and Windows, a hold on Android, Fuchsia and iOS.
504    #[must_use]
505    pub fn grab(self, grab: ReorderGrab) -> Self {
506        self.spec.borrow_mut().grab = Some(grab);
507        self
508    }
509
510    /// The way the rows run, [`ReorderAxis::Vertical`] by default. A horizontal list lays its
511    /// rows out along x, puts each grip under its row, and is reordered across: see the
512    /// module's notes for what that means under a right-to-left layout.
513    #[must_use]
514    pub fn axis(self, axis: ReorderAxis) -> Self {
515        self.spec.borrow_mut().axis = axis;
516        self
517    }
518
519    /// Turns reordering off without changing the list: no grip, no hold, no drop target.
520    /// A list that is briefly read-only stays the same widget, so its rows keep their
521    /// state.
522    #[must_use]
523    pub fn enabled(self, enabled: bool) -> Self {
524        self.spec.borrow_mut().enabled = enabled;
525        self
526    }
527
528    /// The whole grip, replaced by a widget of the application's. It is built with the
529    /// ambient theme, and what it is made of is nobody's business but the caller's — this
530    /// widget only says where it goes and what grabbing it means.
531    #[must_use]
532    pub fn handle<W: Widget<Msg> + 'static>(self, handle: impl Fn(&Theme) -> W + 'static) -> Self {
533        self.spec.borrow_mut().handle = Some(Rc::new(move |theme| Box::new(handle(theme))));
534        self
535    }
536
537    /// The grip's glyph, over the theme's and the framework's.
538    #[must_use]
539    pub fn handle_icon(self, icon: IconData) -> Self {
540        self.spec.borrow_mut().handle_icon = Some(icon);
541        self
542    }
543
544    /// The grip's colour, over the theme's.
545    #[must_use]
546    pub fn handle_color(self, color: Color) -> Self {
547        self.spec.borrow_mut().handle_color = Some(color);
548        self
549    }
550
551    /// The grip's glyph size.
552    #[must_use]
553    pub fn handle_icon_size(self, size: f32) -> Self {
554        self.spec.borrow_mut().handle_icon_size = Some(size);
555        self
556    }
557
558    /// The depth of the grip's box — its tap target, which is not the size of the glyph
559    /// inside it. A width beside the rows of a vertical list, a height under the rows of a
560    /// horizontal one.
561    #[must_use]
562    pub fn handle_width(self, width: f32) -> Self {
563        self.spec.borrow_mut().handle_width = width;
564        self
565    }
566
567    /// Space between rows.
568    #[must_use]
569    pub fn gap(mut self, gap: f32) -> Self {
570        self.gap = gap;
571        self
572    }
573
574    /// An explicit width.
575    #[must_use]
576    pub fn width(mut self, width: f32) -> Self {
577        self.width = Dimension::Length(width);
578        self
579    }
580
581    /// A width given as a fraction of what the parent offers.
582    #[must_use]
583    pub fn width_fraction(mut self, fraction: f32) -> Self {
584        self.width = Dimension::Percent(fraction);
585        self
586    }
587
588    /// An explicit height.
589    #[must_use]
590    pub fn height(mut self, height: f32) -> Self {
591        self.height = Dimension::Length(height);
592        self
593    }
594
595    /// The share of a parent flex's spare room this list takes.
596    #[must_use]
597    pub fn flex(mut self, grow: f32) -> Self {
598        self.flex_grow = grow;
599        self
600    }
601}
602
603/// The grip's contents: the application's widget, or the default glyph.
604fn grip<Msg: Clone + 'static>(spec: &Spec<Msg>, theme: &Theme) -> Box<dyn Widget<Msg>> {
605    if !(spec.enabled
606        && spec.grab.unwrap_or_else(|| platform_grab(theme.platform)) == ReorderGrab::Handle)
607    {
608        // Not this mode: an empty box, so that nothing is measured, drawn or hit.
609        return Box::new(Container::new().width(0.0).height(0.0));
610    }
611    if let Some(handle) = &spec.handle {
612        return handle(theme);
613    }
614    let icon = spec
615        .handle_icon
616        .or(theme.widgets.reorderable.handle_icon)
617        .unwrap_or(Icons::DRAG_HANDLE);
618    let size = spec
619        .handle_icon_size
620        .or(theme.widgets.reorderable.handle_icon_size)
621        .unwrap_or(HANDLE_ICON);
622    let color = spec
623        .handle_color
624        .or(theme.widgets.reorderable.handle_color)
625        .unwrap_or(theme.muted);
626    Box::new(Icon::new(icon).size(size).color(color))
627}
628
629impl<Msg: Clone + 'static> Widget<Msg> for ReorderableList<Msg> {
630    fn style(&self) -> Style {
631        Style {
632            width: self.width,
633            height: self.height,
634            flex_grow: self.flex_grow,
635            flex_direction: match self.spec.borrow().axis {
636                ReorderAxis::Vertical => FlexDirection::Column,
637                ReorderAxis::Horizontal => FlexDirection::Row,
638            },
639            gap: self.gap,
640            ..Default::default()
641        }
642    }
643
644    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
645        &self.rows
646    }
647
648    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
649
650    fn on_click(&self) -> Option<Msg> {
651        None
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::inspector::InspectorNode;
659    use crate::widget::Widget;
660    use crate::{text, Container, Runtime, Size};
661
662    #[derive(Clone, Debug, PartialEq)]
663    enum Msg {
664        Moved(usize, usize),
665    }
666
667    fn list(grab: ReorderGrab) -> ReorderableList<Msg> {
668        ReorderableList::new(Msg::Moved)
669            .grab(grab)
670            .width(300.0)
671            .keyed_row(1, Container::new().height(40.0).child(text("one")))
672            .keyed_row(2, Container::new().height(40.0).child(text("two")))
673            .keyed_row(3, Container::new().height(40.0).child(text("three")))
674    }
675
676    /// Walks the built tree and hands back every widget, so the hooks the shell reads can
677    /// be read the same way.
678    ///
679    /// The grip's contents are deferred until the theme is known, so the tree is built
680    /// first — a walk that reaches a deferred node before any layout pass has is exactly
681    /// what milestone 415 made say so out loud.
682    fn nodes_of(root: &dyn Widget<Msg>) -> Vec<&dyn Widget<Msg>> {
683        crate::build_deferred(root, &Theme::dark(), &crate::Runtime::default());
684        let mut out: Vec<&dyn Widget<Msg>> = vec![root];
685        let mut i = 0;
686        while i < out.len() {
687            let node = out[i];
688            for child in node.children() {
689                out.push(child.as_ref());
690            }
691            i += 1;
692        }
693        out
694    }
695
696    /// The inspector's boxes for a list laid out in a 300 x 400 window.
697    fn inspected(root: &dyn Widget<Msg>) -> Vec<InspectorNode> {
698        crate::build_ui_inspected(
699            root,
700            Size::new(300.0, 400.0),
701            &Runtime::default(),
702            &Theme::dark(),
703        )
704        .1
705    }
706
707    /// Every reorderable in the tree, as `(index, draggable, droppable)`.
708    fn slots(root: &dyn Widget<Msg>) -> Vec<(usize, bool, bool)> {
709        nodes_of(root)
710            .into_iter()
711            .filter_map(|w| {
712                w.reorder_index()
713                    .map(|i| (i, w.reorder_draggable(), w.reorder_droppable()))
714            })
715            .collect()
716    }
717
718    /// **An untold list picks up the way the theme's platform does** (milestone 615), the
719    /// reference's switch: a grip on the three desktops, a hold on Android, Fuchsia and
720    /// iOS — whatever this test runs on. A list that was told keeps what it was told.
721    #[test]
722    fn an_untold_list_follows_the_theme_s_platform() {
723        use frus_core::TargetPlatform as P;
724        let untold = || {
725            ReorderableList::new(Msg::Moved)
726                .width(300.0)
727                .keyed_row(1, Container::new().height(40.0).child(text("one")))
728                .keyed_row(2, Container::new().height(40.0).child(text("two")))
729        };
730        // Laid out under a theme of that platform, then asked what the shell asks.
731        let held = |list: &ReorderableList<Msg>, platform: P| {
732            let theme = Theme::dark().with_platform(platform);
733            crate::build_ui(list, Size::new(300.0, 400.0), &Runtime::default(), &theme);
734            let mut nodes: Vec<&dyn Widget<Msg>> = vec![list];
735            let mut i = 0;
736            while i < nodes.len() {
737                for child in nodes[i].children() {
738                    nodes.push(child.as_ref());
739                }
740                i += 1;
741            }
742            let rows: Vec<_> = nodes
743                .into_iter()
744                .filter(|w| w.reorder_index().is_some() && w.reorder_droppable())
745                .collect();
746            assert_eq!(rows.len(), 2, "two rows under {platform}");
747            rows.iter().all(|w| w.drag_needs_long_press())
748        };
749        for platform in P::ALL {
750            let finger = matches!(platform, P::Android | P::Fuchsia | P::Ios);
751            assert_eq!(held(&untold(), platform), finger, "{platform}");
752            assert!(
753                !held(&untold().grab(ReorderGrab::Handle), platform),
754                "told, {platform}"
755            );
756            assert!(
757                held(&untold().grab(ReorderGrab::LongPress), platform),
758                "told, {platform}"
759            );
760        }
761    }
762
763    #[test]
764    fn a_settled_index_is_the_raw_one_minus_the_row_that_left() {
765        // Downwards: the row is taken out first, so everything after it has shifted up.
766        assert_eq!(settled_index(0, 3), 2);
767        // Upwards: nothing between the row and its destination has moved.
768        assert_eq!(settled_index(3, 0), 0);
769        // Onto itself, from either half: it is already there.
770        assert_eq!(settled_index(2, 2), 2);
771        assert_eq!(settled_index(2, 3), 2);
772    }
773
774    #[test]
775    fn a_gripped_row_is_a_target_and_its_grip_is_the_source() {
776        let list = list(ReorderGrab::Handle);
777        let slots = slots(&list);
778        // Three rows and three grips, each pair sharing an index.
779        assert_eq!(slots.len(), 6, "three rows and three grips: {slots:?}");
780        for i in 0..3 {
781            assert!(
782                slots.contains(&(i, false, true)),
783                "row {i} is a target and not a source: {slots:?}"
784            );
785            assert!(
786                slots.contains(&(i, true, false)),
787                "grip {i} is a source and not a target: {slots:?}"
788            );
789        }
790    }
791
792    #[test]
793    fn a_held_row_is_both_and_waits_for_the_hold() {
794        let list = list(ReorderGrab::LongPress);
795        // The grips withdraw entirely: no index, so nothing is registered for them.
796        assert_eq!(
797            slots(&list),
798            vec![(0, true, true), (1, true, true), (2, true, true)]
799        );
800        // And what makes the list still scrollable: the row does not take the press.
801        let held: Vec<bool> = nodes_of(&list)
802            .into_iter()
803            .filter(|w| w.reorder_index().is_some())
804            .map(|w| w.drag_needs_long_press())
805            .collect();
806        assert_eq!(held, vec![true, true, true]);
807    }
808
809    #[test]
810    fn the_message_carries_where_the_row_ends_up() {
811        let list = list(ReorderGrab::Handle);
812        let row = nodes_of(&list)
813            .into_iter()
814            .find(|w| w.reorder_index() == Some(0) && !w.reorder_draggable())
815            .expect("the first row");
816        // Dropped past the last row: raw 3, and the list is three long, so it lands at 2.
817        assert_eq!(row.on_reorder(3), Some(Msg::Moved(0, 2)));
818        // Dropped on its own lower half: raw 1, which is where it already is.
819        assert_eq!(row.on_reorder(1), None, "a move of nothing sends nothing");
820        assert_eq!(
821            row.reorder_announcement(3).as_deref(),
822            Some("Moved to position 3 of 3")
823        );
824        assert_eq!(row.reorder_announcement(1), None);
825    }
826
827    #[test]
828    fn a_setter_after_a_row_still_reaches_it() {
829        // The order of the builder calls is not part of the API: `.grab()` last is the
830        // same list as `.grab()` first.
831        let late = ReorderableList::new(Msg::Moved)
832            .width(300.0)
833            .keyed_row(1, Container::new().height(40.0).child(text("one")))
834            .grab(ReorderGrab::LongPress);
835        assert_eq!(slots(&late), vec![(0, true, true)]);
836    }
837
838    #[test]
839    fn a_disabled_list_offers_nothing_to_grab() {
840        let list = list(ReorderGrab::Handle).enabled(false);
841        assert!(slots(&list).is_empty(), "no source and no target");
842    }
843
844    /// The grip is beside the row, not on top of it: the row keeps the width it is left,
845    /// and the grip keeps its own however narrow the list gets.
846    #[test]
847    fn the_grip_takes_its_own_room_at_the_trailing_edge() {
848        let list = list(ReorderGrab::Handle);
849        let nodes = inspected(&list);
850        let grips: Vec<&InspectorNode> =
851            nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
852        assert_eq!(grips.len(), 3, "one grip per row");
853        for grip in grips {
854            assert!(
855                (grip.rect.width - HANDLE_WIDTH).abs() < 0.5,
856                "the grip keeps its 40 px: {:?}",
857                grip.rect
858            );
859            assert!(
860                (grip.rect.x + grip.rect.width - 300.0).abs() < 0.5,
861                "at the trailing edge: {:?}",
862                grip.rect
863            );
864        }
865    }
866
867    /// **The grip's middle is inside its row**, and the two share an index.
868    ///
869    /// That pair is not decoration: it is how the shell finds what a grab actually moves.
870    /// What is grabbed is the grip, and everything the drag is made of is the row's — the
871    /// ghost, the height of the gap that closes behind it, the band its neighbours are
872    /// matched against — so the shell looks for the droppable reorderable of the same
873    /// index under the grip's own middle. A grip that sat outside its row, or carried a
874    /// different index, would lift a 40-pixel icon and open a slot to match.
875    #[test]
876    fn the_grip_sits_inside_the_row_it_moves() {
877        let list = list(ReorderGrab::Handle);
878        let nodes = inspected(&list);
879        let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
880        let grips: Vec<&InspectorNode> =
881            nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
882        assert_eq!((rows.len(), grips.len()), (3, 3));
883        for (row, grip) in rows.iter().zip(&grips) {
884            let middle = frus_core::Point::new(
885                grip.rect.x + grip.rect.width * 0.5,
886                grip.rect.y + grip.rect.height * 0.5,
887            );
888            assert!(
889                row.rect.contains(middle),
890                "the grip's middle {middle:?} is inside its row {:?}",
891                row.rect
892            );
893        }
894        // And the index they share, which is what tells the shell which row is which.
895        let indices: Vec<(usize, bool)> = nodes_of(&list)
896            .into_iter()
897            .filter_map(|w| w.reorder_index().map(|i| (i, w.reorder_droppable())))
898            .collect();
899        for i in 0..3 {
900            assert!(indices.contains(&(i, true)) && indices.contains(&(i, false)));
901        }
902    }
903
904    /// A list that runs across: three rows 80 px wide, gripped, in a strip 300 px wide.
905    fn strip(grab: ReorderGrab) -> ReorderableList<Msg> {
906        ReorderableList::new(Msg::Moved)
907            .grab(grab)
908            .axis(ReorderAxis::Horizontal)
909            .width(300.0)
910            .keyed_row(1, Container::new().width(80.0).child(text("one")))
911            .keyed_row(2, Container::new().width(80.0).child(text("two")))
912            .keyed_row(3, Container::new().width(80.0).child(text("three")))
913    }
914
915    /// **Rows along x, and each grip under its row.** The grip keeps its 40 px of depth —
916    /// a height now — and its middle is still inside the row it moves, which is how the
917    /// shell finds the row from the grip on either axis.
918    #[test]
919    fn a_horizontal_list_lays_its_rows_along_x_with_the_grip_underneath() {
920        let list = strip(ReorderGrab::Handle);
921        let nodes = inspected(&list);
922        let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
923        let grips: Vec<&InspectorNode> =
924            nodes.iter().filter(|n| n.name == "ReorderHandle").collect();
925        assert_eq!((rows.len(), grips.len()), (3, 3));
926        for pair in rows.windows(2) {
927            assert!(
928                (pair[0].rect.y - pair[1].rect.y).abs() < 0.5
929                    && pair[1].rect.x >= pair[0].rect.x + pair[0].rect.width - 0.5,
930                "side by side, in order: {:?} then {:?}",
931                pair[0].rect,
932                pair[1].rect
933            );
934        }
935        for (row, grip) in rows.iter().zip(&grips) {
936            assert!(
937                (grip.rect.height - HANDLE_WIDTH).abs() < 0.5,
938                "the grip keeps its 40 px, as a height: {:?}",
939                grip.rect
940            );
941            assert!(
942                (grip.rect.y + grip.rect.height - (row.rect.y + row.rect.height)).abs() < 0.5,
943                "at the row's bottom edge: {:?} in {:?}",
944                grip.rect,
945                row.rect
946            );
947            assert!(
948                row.rect.height > HANDLE_WIDTH + 8.0,
949                "and the row's content keeps its room above it: {:?}",
950                row.rect
951            );
952            let middle = frus_core::Point::new(
953                grip.rect.x + grip.rect.width * 0.5,
954                grip.rect.y + grip.rect.height * 0.5,
955            );
956            assert!(
957                row.rect.contains(middle),
958                "{middle:?} inside {:?}",
959                row.rect
960            );
961        }
962    }
963
964    /// **A row across a strip keeps its own height.** Down a list the row takes the width
965    /// its grip leaves, from nothing; stacked over the grip in a strip with no height of its
966    /// own, a row that started at nothing stayed there and the strip was a line of grips. So
967    /// a held strip is as tall as its content, and a strip given a height hands what the
968    /// grip leaves to its rows.
969    #[test]
970    fn a_row_across_a_strip_keeps_its_height() {
971        let content = |list: &ReorderableList<Msg>| -> Vec<Rect> {
972            inspected(list)
973                .iter()
974                // The rows' own boxes; a held grip leaves an empty one of nothing.
975                .filter(|n| n.name == "Container" && n.rect.width > 0.0)
976                .map(|n| n.rect)
977                .take(3)
978                .collect()
979        };
980        let held = content(&strip(ReorderGrab::LongPress));
981        assert_eq!(held.len(), 3);
982        assert!(
983            held.iter().all(|r| r.height > 8.0),
984            "a held strip's rows are not flat: {held:?}"
985        );
986        let tall = content(&strip(ReorderGrab::Handle).height(120.0));
987        assert!(
988            tall.iter()
989                .all(|r| (r.height - (120.0 - HANDLE_WIDTH)).abs() < 0.5),
990            "a strip 120 tall leaves its rows 80 above the grip: {tall:?}"
991        );
992    }
993
994    /// Every row and every grip of a horizontal list says it runs across and is inserted
995    /// between its neighbours — the two answers the shell turns the gesture by. A list that
996    /// runs down says the same of itself.
997    #[test]
998    fn a_horizontal_list_answers_across_and_inserts() {
999        for grab in [ReorderGrab::Handle, ReorderGrab::LongPress] {
1000            let across = strip(grab);
1001            let answers: Vec<(ReorderAxis, bool)> = nodes_of(&across)
1002                .into_iter()
1003                .filter(|w| w.reorder_index().is_some())
1004                .map(|w| (w.reorder_axis(), w.reorder_inserts()))
1005                .collect();
1006            assert!(!answers.is_empty());
1007            assert!(
1008                answers
1009                    .iter()
1010                    .all(|a| *a == (ReorderAxis::Horizontal, true)),
1011                "{answers:?}"
1012            );
1013        }
1014        let down = list(ReorderGrab::Handle);
1015        assert!(nodes_of(&down)
1016            .into_iter()
1017            .filter(|w| w.reorder_index().is_some())
1018            .all(|w| w.reorder_axis() == ReorderAxis::Vertical));
1019        // And `.axis()` after the rows still reaches them.
1020        let late = ReorderableList::new(Msg::Moved)
1021            .keyed_row(1, Container::new().width(80.0).child(text("one")))
1022            .axis(ReorderAxis::Horizontal);
1023        assert!(nodes_of(&late)
1024            .into_iter()
1025            .filter(|w| w.reorder_index().is_some())
1026            .all(|w| w.reorder_axis() == ReorderAxis::Horizontal));
1027    }
1028
1029    /// **The whole route of a horizontal drop, on a real built frame**, asked in the order
1030    /// the shell asks it — grab the grip, find the row it moves, find nothing under a
1031    /// pointer past the end, fall back to the nearest slot along x, take the half, route the
1032    /// message — left to right and right to left.
1033    ///
1034    /// Right to left the strip is mirrored: its first row is on the right, past its end is
1035    /// past its **left** edge, and after a row is its left half. The same finger movement
1036    /// in reading terms has to give the same message.
1037    #[test]
1038    fn a_horizontal_drop_routes_the_same_in_either_reading_direction() {
1039        for rtl in [false, true] {
1040            let theme = if rtl {
1041                Theme::dark().rtl()
1042            } else {
1043                Theme::dark()
1044            };
1045            let list = strip(ReorderGrab::Handle);
1046            let ui = crate::build_ui(&list, Size::new(300.0, 120.0), &Runtime::default(), &theme);
1047            let find = |id| crate::find_widget(&list, id);
1048            let grip_of = |index| {
1049                ui.reorderables()
1050                    .iter()
1051                    .find(|(id, _)| {
1052                        find(*id).is_some_and(|w| {
1053                            w.reorder_index() == Some(index) && !w.reorder_droppable()
1054                        })
1055                    })
1056                    .copied()
1057                    .expect("a grip")
1058            };
1059            let (grip0, grip0_box) = grip_of(0);
1060            let middle = frus_core::Point::new(
1061                grip0_box.x + grip0_box.width * 0.5,
1062                grip0_box.y + grip0_box.height * 0.5,
1063            );
1064            let row0 = ui
1065                .reorderables_at(middle)
1066                .find(|id| {
1067                    find(*id).is_some_and(|w| w.reorder_droppable() && w.reorder_index() == Some(0))
1068                })
1069                .expect("the grip sits inside its row");
1070
1071            let slots = crate::reorder_siblings(&ui, &list, row0);
1072            assert_eq!(
1073                slots.len(),
1074                3,
1075                "rtl={rtl}: the row's list is the three rows"
1076            );
1077            let boxes: Vec<Rect> = slots.iter().map(|(_, r)| *r).collect();
1078            assert_eq!(
1079                boxes[0].x > boxes[2].x,
1080                rtl,
1081                "rtl={rtl}: the first row is on the reading side: {boxes:?}"
1082            );
1083
1084            // Past the end, in reading terms: beyond the last row's far edge.
1085            let last = boxes[2];
1086            let past = frus_core::Point::new(
1087                if rtl {
1088                    last.x - 24.0
1089                } else {
1090                    last.x + last.width + 24.0
1091                },
1092                last.y + last.height * 0.5,
1093            );
1094            assert!(
1095                (0.0..300.0).contains(&past.x),
1096                "rtl={rtl}: still in the window: {past:?}"
1097            );
1098            assert!(
1099                !ui.reorderables_at(past)
1100                    .any(|id| find(id).is_some_and(|w| w.reorder_droppable())),
1101                "rtl={rtl}: nothing to drop on past the end"
1102            );
1103            let nearest = crate::nearest_reorder_slot(past, &boxes, ReorderAxis::Horizontal)
1104                .expect("the strip's own nearest slot");
1105            assert_eq!(nearest, 2, "rtl={rtl}: the last row");
1106            let after = crate::reorder_drop_after(past, boxes[2], ReorderAxis::Horizontal, rtl);
1107            assert!(after, "rtl={rtl}: past the end is after the last row");
1108            let grip0 = find(grip0).expect("the grip");
1109            assert_eq!(
1110                grip0.on_reorder(nearest + usize::from(after)),
1111                Some(Msg::Moved(0, 2)),
1112                "rtl={rtl}: the first row ends up last"
1113            );
1114
1115            // And the last row carried onto the first row's leading half lands first.
1116            let first = boxes[0];
1117            let leading = frus_core::Point::new(
1118                if rtl {
1119                    first.x + first.width * 0.8
1120                } else {
1121                    first.x + first.width * 0.2
1122                },
1123                first.y + first.height * 0.3,
1124            );
1125            let (grip2, _) = grip_of(2);
1126            let after = crate::reorder_drop_after(leading, first, ReorderAxis::Horizontal, rtl);
1127            assert!(!after, "rtl={rtl}: the leading half is before the row");
1128            assert_eq!(
1129                find(grip2).and_then(|w| w.on_reorder(usize::from(after))),
1130                Some(Msg::Moved(2, 0)),
1131                "rtl={rtl}: the last row ends up first"
1132            );
1133        }
1134    }
1135
1136    /// **What makes room on a real built strip**, asked the way the shell asks it: the first
1137    /// row lifted with everything it paints, the rest of the strip movable. With no line,
1138    /// every row after it — its label, its grip, whatever it draws — moves back along x by
1139    /// the lifted row's width and not at all along y; with the line on the third row's
1140    /// leading edge, the second still closes the gap and the third, at the line, stays.
1141    #[test]
1142    fn a_built_strip_makes_room_along_x() {
1143        let list = strip(ReorderGrab::Handle);
1144        let ui = crate::build_ui(
1145            &list,
1146            Size::new(300.0, 120.0),
1147            &Runtime::default(),
1148            &Theme::dark(),
1149        );
1150        let mut rows: Vec<(crate::WidgetId, Rect)> = ui
1151            .reorderables()
1152            .iter()
1153            .filter(|(id, _)| crate::find_widget(&list, *id).is_some_and(|w| w.reorder_droppable()))
1154            .copied()
1155            .collect();
1156        rows.sort_by(|a, b| a.1.x.total_cmp(&b.1.x));
1157        assert_eq!(rows.len(), 3, "three rows: {rows:?}");
1158        let (row0, src) = rows[0];
1159        let lifted: std::collections::HashSet<u64> = crate::find_widget(&list, row0)
1160            .map(|w| {
1161                crate::subtree_ids(w, row0)
1162                    .iter()
1163                    .map(|i| i.as_u64())
1164                    .collect()
1165            })
1166            .expect("the first row");
1167        let movable = crate::reorderable_owners(&ui, &list);
1168        let prims = ui.scene().primitives();
1169        let kept: Vec<&frus_core::Primitive> = prims
1170            .iter()
1171            .filter(|p| !lifted.contains(&p.owner()))
1172            .collect();
1173        let centred_in = |b: Rect, r: Rect| {
1174            r.contains(frus_core::Point::new(
1175                b.x + b.width * 0.5,
1176                b.y + b.height * 0.5,
1177            ))
1178        };
1179
1180        let line = Rect::new(rows[2].1.x - 1.5, rows[2].1.y, 3.0, rows[2].1.height);
1181        for (line, shifts) in [
1182            (None, [-src.width, -src.width]),
1183            (Some(line), [-src.width, 0.0]),
1184        ] {
1185            let out = crate::reflow_reorder_cards(
1186                prims,
1187                src,
1188                line,
1189                &lifted,
1190                &movable,
1191                ReorderAxis::Horizontal,
1192            );
1193            assert_eq!(out.len(), kept.len(), "only the lifted row is taken out");
1194            let mut moved = [0, 0];
1195            for (before, after) in kept.iter().zip(&out) {
1196                let (b, a) = (before.bounds(), after.bounds());
1197                assert_eq!(a.y, b.y, "nothing moves across the strip: {b:?}");
1198                let row = (1..3).find(|&i| centred_in(b, rows[i].1));
1199                match row {
1200                    Some(i) if movable.contains(&before.owner()) => {
1201                        assert!(
1202                            (a.x - (b.x + shifts[i - 1])).abs() < 0.01,
1203                            "line={line:?}: row {i}'s {b:?} shifts by {} and is at {a:?}",
1204                            shifts[i - 1]
1205                        );
1206                        moved[i - 1] += 1;
1207                    }
1208                    _ => assert_eq!(a.x, b.x, "not part of a row: stays"),
1209                }
1210            }
1211            assert!(
1212                moved.iter().all(|&n| n > 0),
1213                "both rows paint something: {moved:?}"
1214            );
1215        }
1216    }
1217
1218    /// And in the mode that does not use it, it costs nothing at all.
1219    #[test]
1220    fn a_held_list_has_no_grip_taking_room() {
1221        let list = list(ReorderGrab::LongPress);
1222        let nodes = inspected(&list);
1223        for grip in nodes.iter().filter(|n| n.name == "ReorderHandle") {
1224            assert_eq!(grip.rect.width, 0.0, "no box: {:?}", grip.rect);
1225        }
1226        // The rows still fill the list's width, grip or no grip.
1227        let rows: Vec<&InspectorNode> = nodes.iter().filter(|n| n.name == "ReorderRow").collect();
1228        assert_eq!(rows.len(), 3);
1229        for row in rows {
1230            assert!((row.rect.width - 300.0).abs() < 0.5, "{:?}", row.rect);
1231        }
1232    }
1233}