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}