Skip to main content

frust_widgets/drag/
reorderable.rs

1//! [`reorderable_list()`]: a drag-to-reorder list built entirely from the
2//! existing session primitives — a composition, not a new layout algorithm.
3//!
4//! # Shape
5//!
6//! `N` rows produce `N + 1` thin gap targets, one before the first row and
7//! one after every row, interleaved into a single [`crate::FlexView`] column
8//! (`gap, row, gap, row, …, row, gap`) built once per call. Each row is
9//! wrapped in [`super::draggable()`] carrying its own index (into the list as
10//! given to this call) as the payload; each gap is a
11//! [`super::drag_target::<usize, _, _>()`] over that same index type, so a
12//! row and every gap share one coordinator session with no type mismatch.
13//! Both halves are the unmodified widgets the rest of the module ships — the
14//! ghost is [`super::draggable()`]'s default child snapshot, the keyboard
15//! lift/cycle/drop chord is answered by the row's own focus handling, and
16//! cycling walks the gaps in registration order, which is this column's
17//! build order, which is visual order (see the [module docs](super#target-resolution)
18//! and [`mod@super::draggable`]'s *Lift, cycle, drop*). Every row keeps the
19//! [`crate::ChildKey`] it was given, so [`crate::keyed`] reconciliation
20//! carries each row's retained state — scroll position, focus, whatever a
21//! custom row builds — through a reorder instead of reattaching it to
22//! whatever index now sits there.
23//!
24//! # Index convention
25//!
26//! A gap's own position, `g`, is its index among the `N + 1` gaps in
27//! registration/visual order (`0` is before the first row). Dropping a
28//! session carrying source index `i` onto gap `g` reports
29//! [`ReorderableListView::on_reorder`]`(state, i, to)` where `to` is `g`
30//! adjusted for the source's own removal — the index `i` would land at in
31//! the list *after* it is taken out, which is what a caller applying the
32//! move to its own backing storage (a `Vec::remove` + `Vec::insert` pair)
33//! wants directly:
34//!
35//! * `g <= i` — nothing between the gap and the list start shifts when `i`
36//!   is removed, so `to = g`.
37//! * `g > i` — removing `i` shifts everything after it down by one, so
38//!   `to = g - 1`.
39//!
40//! The two gaps touching the source itself — immediately before it (`g ==
41//! i`) and immediately after it (`g == i + 1`) — both resolve to `to == i`,
42//! the source's own current position: dropping there is a no-op and
43//! [`ReorderableListView::on_reorder`] does not fire, matching the drop
44//! target's own cancel-on-reject precedent of never completing a session
45//! that changed nothing. Every other gap reports a real move.
46//!
47//! # Limits
48//!
49//! * Vertical only: this helper always builds a vertical column. A
50//!   horizontal reorderable row is out of scope here — compose
51//!   [`super::draggable()`]/[`super::drag_target()`] directly for that, the
52//!   way this module does internally.
53//! * The gap's own highlight is [`super::target::DragTargetView`]'s default
54//!   (a themed inset stroke) rather than a bespoke insertion-line treatment;
55//!   override per gap is not exposed — a design system wanting its own gap
56//!   affordance composes the two primitives directly instead of this helper.
57//! * [`ReorderableListView::auto_scroll`] builds its own [`crate::ScrollView`]
58//!   bound to the controller it is given — the list itself becomes the
59//!   scroll surface, not something a caller wraps around it. Bound the
60//!   result's height from outside (a fixed-size ancestor, the way every
61//!   other scroll surface in this crate needs one) to get a real viewport to
62//!   scroll within; do not also put the list inside a separate
63//!   `scroll_view(...)` bound to the same controller, which would give one
64//!   handle two competing surfaces.
65//! * Row identity during a session: each row's drag payload is the plain
66//!   index [`reorderable_list()`] captured when it built the column, not a
67//!   stable id. A caller that mutates `items`' order or length while a
68//!   session on this list's coordinator is in flight can have
69//!   [`ReorderableListView::on_reorder`] fire against a pair computed for a
70//!   list that no longer matches what is on screen — this helper does not
71//!   guard against it. Keep the list's order and length fixed for the
72//!   duration of any session on its coordinator.
73
74use std::cell::RefCell;
75use std::rc::Rc;
76
77use frust_core::{AnyView, BuildCtx, ChangeFlags, View, Widget, any};
78
79use super::auto_scroll::auto_scroll_zone;
80use super::coordinator::DragCoordinator;
81use super::draggable::draggable;
82use super::target::drag_target;
83use crate::flex::{Axis, CrossAxisAlignment, FlexChild, FlexView};
84use crate::{ChildKey, ScrollController, SizedBox, keyed, scroll_view};
85
86/// Thickness, in logical px, of the drop-target band between two rows (and
87/// before the first/after the last).
88///
89/// **Community-approximate**: thin enough not to read as a row of its own,
90/// wide enough to land a long-press finger on; no platform publishes a
91/// reorder gap metric.
92const GAP_PX: f64 = 8.0;
93
94/// A reorder completion callback: the source's original index and the index
95/// it lands at after the move (see the [module docs](self#index-convention)).
96type ReorderCallback<State> = Rc<dyn Fn(&mut State, usize, usize)>;
97
98/// The cell every gap's `on_drop` reads from and
99/// [`ReorderableListView::on_reorder`] writes to — shared so the callback
100/// can be attached after the column (which needs no payload-adjustment logic
101/// of its own) is already built.
102type ReorderCell<State> = Rc<RefCell<Option<ReorderCallback<State>>>>;
103
104/// A declarative drag-to-reorder list. See the [module docs](self).
105pub struct ReorderableListView<State: 'static> {
106    inner: AnyView<State>,
107    coordinator: DragCoordinator,
108    on_reorder: ReorderCell<State>,
109}
110
111/// Build a reorderable list over `items`, each an `(identity, content)` pair
112/// — the same [`crate::ChildKey`] a [`crate::keyed`] [`crate::Column`] row
113/// would carry. See the [module docs](self) for the composition and the
114/// index convention [`ReorderableListView::on_reorder`] reports through.
115///
116/// `items` is any iterable of one `(key, view)` pair type; each view is
117/// erased here, so a homogeneous list of rows needs no `any()`.
118///
119/// ```
120/// use frust_widgets::{ChildKey, DragCoordinator, ReorderableListView, reorderable_list, text};
121/// # fn demo(coordinator: DragCoordinator) -> ReorderableListView<()> {
122/// let names = ["alpha", "beta", "gamma"];
123/// reorderable_list(coordinator, names.map(|name| (ChildKey::new(name), text(name))))
124/// # }
125/// # let _ = demo;
126/// ```
127pub fn reorderable_list<State, K, V>(
128    coordinator: DragCoordinator,
129    items: impl IntoIterator<Item = (K, V)>,
130) -> ReorderableListView<State>
131where
132    State: 'static,
133    K: Into<ChildKey>,
134    V: View<State>,
135{
136    let on_reorder: ReorderCell<State> = Rc::new(RefCell::new(None));
137    let items = items.into_iter();
138    let count = items.size_hint().0;
139    let mut children: Vec<FlexChild<State>> = Vec::with_capacity(count * 2 + 1);
140    children.push(gap_child(0, &coordinator, &on_reorder));
141    for (index, (key, view)) in items.enumerate() {
142        let key: ChildKey = key.into();
143        let row = draggable(view, coordinator.clone(), move |_: &State| index);
144        children.push(keyed(key, row));
145        children.push(gap_child(index + 1, &coordinator, &on_reorder));
146    }
147    let column = FlexView::new(Axis::Vertical, children).cross_axis(CrossAxisAlignment::Stretch);
148    ReorderableListView {
149        inner: any(column),
150        coordinator,
151        on_reorder,
152    }
153}
154
155/// Build the gap target at position `gap_index` (see the [module
156/// docs](self#index-convention)), keyed by its own position so the
157/// reconciler matches it across rebuilds (the gap count never changes
158/// independently of the row count, so this stays stable across reorders).
159fn gap_child<State: 'static>(
160    gap_index: usize,
161    coordinator: &DragCoordinator,
162    on_reorder: &ReorderCell<State>,
163) -> FlexChild<State> {
164    let on_reorder = Rc::clone(on_reorder);
165    let target =
166        drag_target::<usize, State, _>(SizedBox::<State>(None, Some(GAP_PX)), coordinator.clone())
167            .on_drop(move |state: &mut State, from: usize| {
168                let to = if gap_index <= from {
169                    gap_index
170                } else {
171                    gap_index - 1
172                };
173                if to == from {
174                    // The gap immediately before or after the source: dropping
175                    // there is the source's own current slot.
176                    return;
177                }
178                let callback = on_reorder.borrow().clone();
179                if let Some(callback) = callback {
180                    callback(state, from, to);
181                }
182            });
183    keyed(gap_key(gap_index), target)
184}
185
186/// A stable identity for the gap at `gap_index`, distinct from any row key a
187/// caller plausibly hashes from its own data (an id, a string name, a plain
188/// index) by the string tag folded into the hash.
189fn gap_key(gap_index: usize) -> ChildKey {
190    ChildKey::new(("frust-widgets-reorderable-gap", gap_index))
191}
192
193impl<State: 'static> ReorderableListView<State> {
194    /// Run `f` against the app state when a row drops onto a gap that moves
195    /// it — never for a drop back onto its own current slot (see the
196    /// [module docs](self#index-convention)).
197    pub fn on_reorder<F: Fn(&mut State, usize, usize) + 'static>(self, f: F) -> Self {
198        *self.on_reorder.borrow_mut() = Some(Rc::new(f));
199        self
200    }
201
202    /// Wrap the list in its own scroll surface bound to `controller`, then
203    /// enclose *that* surface in edge auto-scroll for sessions on this
204    /// list's coordinator — so the zone's edge bands measure the surface's
205    /// own (bounded) viewport, not the list's full, usually taller, content.
206    /// See [`super::auto_scroll_zone()`] and the [module docs](self#limits)
207    /// for the contract this puts on the caller: bound this view's height
208    /// from outside (a fixed-size ancestor), and do not also wrap it in a
209    /// second scroll surface bound to the same controller.
210    pub fn auto_scroll(mut self, controller: ScrollController) -> Self {
211        let surface = scroll_view(self.inner).controller(controller.clone());
212        self.inner = any(auto_scroll_zone(
213            surface,
214            self.coordinator.clone(),
215            controller,
216        ));
217        self
218    }
219}
220
221impl<State: 'static> View<State> for ReorderableListView<State> {
222    type Element = Box<dyn Widget>;
223
224    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
225        self.inner.build(ctx)
226    }
227
228    fn rebuild(
229        &self,
230        prev: &Self,
231        element: &mut Self::Element,
232        ctx: &mut BuildCtx<'_>,
233    ) -> ChangeFlags {
234        self.inner.rebuild(&prev.inner, element, ctx)
235    }
236
237    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
238        self.inner.teardown(element, ctx);
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::drag::DragPhase;
246    use frust_core::{FrameTime, InputEvent, Key, KeyEvent, Modifiers, NamedKey, RenderRoot};
247    use kurbo::{Point, Size};
248    use std::cell::RefCell as StdRefCell;
249
250    const WINDOW: Size = Size::new(400.0, 600.0);
251    const ROW_PX: f64 = 40.0;
252
253    type Log = Rc<StdRefCell<Vec<(usize, usize)>>>;
254
255    struct App {
256        coordinator: DragCoordinator,
257        reorders: Log,
258    }
259
260    fn build_view(state: &mut App) -> ReorderableListView<App> {
261        let items: Vec<(ChildKey, AnyView<App>)> = (0..3u32)
262            .map(|id| {
263                (
264                    ChildKey::new(id),
265                    any(SizedBox::<App>(Some(WINDOW.width), Some(ROW_PX))),
266                )
267            })
268            .collect();
269        let log = Rc::clone(&state.reorders);
270        reorderable_list(state.coordinator.clone(), items)
271            .on_reorder(move |_: &mut App, from, to| log.borrow_mut().push((from, to)))
272    }
273
274    struct Harness {
275        root: RenderRoot<App, ReorderableListView<App>>,
276        state: App,
277        clock_ms: f64,
278    }
279
280    impl Harness {
281        fn new() -> Self {
282            let mut harness = Harness {
283                root: RenderRoot::new(),
284                state: App {
285                    coordinator: DragCoordinator::new(),
286                    reorders: Rc::new(StdRefCell::new(Vec::new())),
287                },
288                clock_ms: 0.0,
289            };
290            harness.frame();
291            harness
292        }
293
294        fn frame(&mut self) -> crate::test_support::RecordingScene {
295            self.root.rebuild(&mut build_view, &mut self.state);
296            self.root.layout(WINDOW);
297            self.clock_ms += 16.0;
298            let mut scene = crate::test_support::RecordingScene::default();
299            self.root.paint(
300                &mut scene,
301                FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
302            );
303            scene
304        }
305
306        fn mouse(&mut self, phase: frust_core::PointerPhase, x: f64, y: f64) {
307            self.root.event(
308                &mut self.state,
309                &InputEvent::Pointer(frust_core::PointerEvent {
310                    phase,
311                    position: Point::new(x, y),
312                    button: frust_core::PointerButton::Primary,
313                }),
314            );
315        }
316
317        fn key(&mut self, key: Key) {
318            self.root.event(
319                &mut self.state,
320                &InputEvent::Key(KeyEvent {
321                    key,
322                    modifiers: Modifiers::default(),
323                    repeat: false,
324                }),
325            );
326        }
327
328        fn phase(&self) -> DragPhase {
329            self.state.coordinator.phase()
330        }
331
332        fn reorders(&self) -> Vec<(usize, usize)> {
333            self.state.reorders.borrow().clone()
334        }
335    }
336
337    // Row `i` occupies `[8 + i*48, 48 + i*48)`; gap `g` occupies
338    // `[g*48, g*48 + 8)` — see `GAP_PX`/`ROW_PX` above. Gap 0 is the band
339    // `[0, 8)`, row 0 is `[8, 48)`, gap 1 is `[48, 56)`, and so on.
340    fn gap_y(gap_index: usize) -> f64 {
341        gap_index as f64 * (ROW_PX + GAP_PX) + GAP_PX / 2.0
342    }
343
344    fn row_y(row_index: usize) -> f64 {
345        GAP_PX + row_index as f64 * (ROW_PX + GAP_PX) + ROW_PX / 2.0
346    }
347
348    #[test]
349    fn dragging_row_0_past_row_2_reports_the_adjusted_index() {
350        use frust_core::PointerPhase;
351        let mut h = Harness::new();
352        h.mouse(PointerPhase::Down, 50.0, row_y(0));
353        // One move past row 2, into the gap right after it (gap 3).
354        h.mouse(PointerPhase::Move, 50.0, gap_y(3));
355        assert_eq!(h.phase(), DragPhase::Dragging);
356        h.mouse(PointerPhase::Up, 50.0, gap_y(3));
357        h.frame();
358        assert_eq!(h.phase(), DragPhase::Idle, "the target claimed the drop");
359        assert_eq!(h.reorders(), vec![(0, 2)]);
360    }
361
362    #[test]
363    fn dropping_on_the_gap_touching_the_source_reports_nothing() {
364        use frust_core::PointerPhase;
365        let mut h = Harness::new();
366        h.mouse(PointerPhase::Down, 50.0, row_y(0));
367        // Gap 1 sits right after row 0 — the source's own current slot.
368        h.mouse(PointerPhase::Move, 50.0, gap_y(1));
369        h.mouse(PointerPhase::Up, 50.0, gap_y(1));
370        h.frame();
371        assert_eq!(h.phase(), DragPhase::Idle);
372        assert_eq!(
373            h.reorders(),
374            Vec::new(),
375            "dropping at the original slot is a no-op"
376        );
377    }
378
379    #[test]
380    fn the_gap_indicator_tracks_the_hovered_gap_only() {
381        use frust_core::PointerPhase;
382        let mut h = Harness::new();
383        let idle = h.frame();
384        assert_eq!(idle.transforms.len(), 0, "nothing hovered, nothing drawn");
385
386        h.mouse(PointerPhase::Down, 50.0, row_y(0));
387        h.mouse(PointerPhase::Move, 50.0, gap_y(2));
388        let hovering_gap = h.frame();
389        assert_eq!(
390            hovering_gap.transforms.len(),
391            1,
392            "exactly the hovered gap paints its highlight"
393        );
394
395        h.mouse(PointerPhase::Move, 50.0, row_y(1));
396        let hovering_row = h.frame();
397        assert_eq!(
398            hovering_row.transforms.len(),
399            0,
400            "no gap is hovered while the pointer sits over a row"
401        );
402
403        h.mouse(PointerPhase::Move, 50.0, gap_y(3));
404        let hovering_again = h.frame();
405        assert_eq!(
406            hovering_again.transforms.len(),
407            1,
408            "a later gap takes over the indicator"
409        );
410    }
411
412    #[test]
413    fn auto_scroll_measures_the_bounded_viewport_not_the_full_content() {
414        use crate::SizedBoxView;
415        use frust_core::PointerPhase;
416
417        // 20 rows plus 21 gaps is 968 px of content (20 * ROW_PX + 21 *
418        // GAP_PX) — far taller than the 300 px viewport below. A zone that
419        // (wrongly) measured the full scrolled content instead of the
420        // viewport would need the pointer roughly 650 px further down than
421        // this to ever see a non-zero velocity.
422        const ROWS: u32 = 20;
423        const VIEWPORT_PX: f64 = 300.0;
424
425        struct ScrollApp {
426            coordinator: DragCoordinator,
427            controller: ScrollController,
428        }
429
430        fn build_scrolling_view(state: &mut ScrollApp) -> SizedBoxView<ScrollApp> {
431            let items: Vec<(ChildKey, AnyView<ScrollApp>)> = (0..ROWS)
432                .map(|id| {
433                    (
434                        ChildKey::new(id),
435                        any(SizedBox::<ScrollApp>(Some(WINDOW.width), Some(ROW_PX))),
436                    )
437                })
438                .collect();
439            let list = reorderable_list(state.coordinator.clone(), items)
440                .auto_scroll(state.controller.clone());
441            SizedBox::<ScrollApp>(Some(WINDOW.width), Some(VIEWPORT_PX)).child(list)
442        }
443
444        struct ScrollHarness {
445            root: RenderRoot<ScrollApp, SizedBoxView<ScrollApp>>,
446            state: ScrollApp,
447            clock_ms: f64,
448        }
449
450        impl ScrollHarness {
451            fn new() -> Self {
452                let mut harness = ScrollHarness {
453                    root: RenderRoot::new(),
454                    state: ScrollApp {
455                        coordinator: DragCoordinator::new(),
456                        controller: ScrollController::new(),
457                    },
458                    clock_ms: 0.0,
459                };
460                harness.frame();
461                harness
462            }
463
464            fn frame(&mut self) {
465                self.root
466                    .rebuild(&mut build_scrolling_view, &mut self.state);
467                self.root.layout(WINDOW);
468                self.clock_ms += 16.0;
469                let mut scene = crate::test_support::RecordingScene::default();
470                self.root.paint(
471                    &mut scene,
472                    FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
473                );
474            }
475
476            fn mouse(&mut self, phase: PointerPhase, x: f64, y: f64) {
477                self.root.event(
478                    &mut self.state,
479                    &InputEvent::Pointer(frust_core::PointerEvent {
480                        phase,
481                        position: Point::new(x, y),
482                        button: frust_core::PointerButton::Primary,
483                    }),
484                );
485            }
486        }
487
488        let mut h = ScrollHarness::new();
489        assert!(
490            h.state.controller.max_offset() > 0.0,
491            "the content must be taller than the viewport for this to be a\
492             meaningful test"
493        );
494
495        // Press row 0, take it past its own slop with a small move (so the
496        // draggable — not the enclosing scroll surface's own gesture
497        // recognition — claims the session), then carry it down to the
498        // *viewport's* own bottom edge, well short of the list's full
499        // content.
500        h.mouse(PointerPhase::Down, 50.0, row_y(0));
501        h.mouse(PointerPhase::Move, 50.0, row_y(0) + 8.0);
502        assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
503        h.mouse(PointerPhase::Move, 50.0, VIEWPORT_PX - 5.0);
504        assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
505
506        for _ in 0..4 {
507            h.frame();
508        }
509
510        assert!(
511            h.state.controller.offset() > 0.0,
512            "a pointer at the viewport's own bottom edge must auto-scroll"
513        );
514    }
515
516    #[test]
517    fn a_keyboard_lift_cycle_and_drop_reports_the_adjusted_index() {
518        use frust_core::PointerPhase;
519        let mut h = Harness::new();
520        // Focus row 1 with a plain tap — no drag, no pointer capture.
521        h.mouse(PointerPhase::Down, 50.0, row_y(1));
522        h.mouse(PointerPhase::Up, 50.0, row_y(1));
523        assert!(h.root.is_focus_active());
524
525        h.key(Key::Named(NamedKey::Enter));
526        assert_eq!(
527            h.phase(),
528            DragPhase::Dragging,
529            "Enter lifts the focused row"
530        );
531
532        // Cycle forward to gap 3: gap 0, 1, 2, then 3.
533        for _ in 0..4 {
534            h.key(Key::Named(NamedKey::ArrowDown));
535        }
536        h.key(Key::Named(NamedKey::Enter));
537        h.frame();
538        assert_eq!(h.phase(), DragPhase::Idle);
539        assert_eq!(h.reorders(), vec![(1, 2)]);
540    }
541}