use std::cell::RefCell;
use std::rc::Rc;
use frust_core::{AnyView, BuildCtx, ChangeFlags, View, Widget, any};
use super::auto_scroll::auto_scroll_zone;
use super::coordinator::DragCoordinator;
use super::draggable::draggable;
use super::target::drag_target;
use crate::flex::{Axis, CrossAxisAlignment, FlexChild, FlexView};
use crate::{ChildKey, ScrollController, SizedBox, keyed, scroll_view};
const GAP_PX: f64 = 8.0;
type ReorderCallback<State> = Rc<dyn Fn(&mut State, usize, usize)>;
type ReorderCell<State> = Rc<RefCell<Option<ReorderCallback<State>>>>;
pub struct ReorderableListView<State: 'static> {
inner: AnyView<State>,
coordinator: DragCoordinator,
on_reorder: ReorderCell<State>,
}
pub fn reorderable_list<State, K, V>(
coordinator: DragCoordinator,
items: impl IntoIterator<Item = (K, V)>,
) -> ReorderableListView<State>
where
State: 'static,
K: Into<ChildKey>,
V: View<State>,
{
let on_reorder: ReorderCell<State> = Rc::new(RefCell::new(None));
let items = items.into_iter();
let count = items.size_hint().0;
let mut children: Vec<FlexChild<State>> = Vec::with_capacity(count * 2 + 1);
children.push(gap_child(0, &coordinator, &on_reorder));
for (index, (key, view)) in items.enumerate() {
let key: ChildKey = key.into();
let row = draggable(view, coordinator.clone(), move |_: &State| index);
children.push(keyed(key, row));
children.push(gap_child(index + 1, &coordinator, &on_reorder));
}
let column = FlexView::new(Axis::Vertical, children).cross_axis(CrossAxisAlignment::Stretch);
ReorderableListView {
inner: any(column),
coordinator,
on_reorder,
}
}
fn gap_child<State: 'static>(
gap_index: usize,
coordinator: &DragCoordinator,
on_reorder: &ReorderCell<State>,
) -> FlexChild<State> {
let on_reorder = Rc::clone(on_reorder);
let target =
drag_target::<usize, State, _>(SizedBox::<State>(None, Some(GAP_PX)), coordinator.clone())
.on_drop(move |state: &mut State, from: usize| {
let to = if gap_index <= from {
gap_index
} else {
gap_index - 1
};
if to == from {
return;
}
let callback = on_reorder.borrow().clone();
if let Some(callback) = callback {
callback(state, from, to);
}
});
keyed(gap_key(gap_index), target)
}
fn gap_key(gap_index: usize) -> ChildKey {
ChildKey::new(("frust-widgets-reorderable-gap", gap_index))
}
impl<State: 'static> ReorderableListView<State> {
pub fn on_reorder<F: Fn(&mut State, usize, usize) + 'static>(self, f: F) -> Self {
*self.on_reorder.borrow_mut() = Some(Rc::new(f));
self
}
pub fn auto_scroll(mut self, controller: ScrollController) -> Self {
let surface = scroll_view(self.inner).controller(controller.clone());
self.inner = any(auto_scroll_zone(
surface,
self.coordinator.clone(),
controller,
));
self
}
}
impl<State: 'static> View<State> for ReorderableListView<State> {
type Element = Box<dyn Widget>;
fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
self.inner.build(ctx)
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
self.inner.rebuild(&prev.inner, element, ctx)
}
fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
self.inner.teardown(element, ctx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::drag::DragPhase;
use frust_core::{FrameTime, InputEvent, Key, KeyEvent, Modifiers, NamedKey, RenderRoot};
use kurbo::{Point, Size};
use std::cell::RefCell as StdRefCell;
const WINDOW: Size = Size::new(400.0, 600.0);
const ROW_PX: f64 = 40.0;
type Log = Rc<StdRefCell<Vec<(usize, usize)>>>;
struct App {
coordinator: DragCoordinator,
reorders: Log,
}
fn build_view(state: &mut App) -> ReorderableListView<App> {
let items: Vec<(ChildKey, AnyView<App>)> = (0..3u32)
.map(|id| {
(
ChildKey::new(id),
any(SizedBox::<App>(Some(WINDOW.width), Some(ROW_PX))),
)
})
.collect();
let log = Rc::clone(&state.reorders);
reorderable_list(state.coordinator.clone(), items)
.on_reorder(move |_: &mut App, from, to| log.borrow_mut().push((from, to)))
}
struct Harness {
root: RenderRoot<App, ReorderableListView<App>>,
state: App,
clock_ms: f64,
}
impl Harness {
fn new() -> Self {
let mut harness = Harness {
root: RenderRoot::new(),
state: App {
coordinator: DragCoordinator::new(),
reorders: Rc::new(StdRefCell::new(Vec::new())),
},
clock_ms: 0.0,
};
harness.frame();
harness
}
fn frame(&mut self) -> crate::test_support::RecordingScene {
self.root.rebuild(&mut build_view, &mut self.state);
self.root.layout(WINDOW);
self.clock_ms += 16.0;
let mut scene = crate::test_support::RecordingScene::default();
self.root.paint(
&mut scene,
FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
);
scene
}
fn mouse(&mut self, phase: frust_core::PointerPhase, x: f64, y: f64) {
self.root.event(
&mut self.state,
&InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, y),
button: frust_core::PointerButton::Primary,
}),
);
}
fn key(&mut self, key: Key) {
self.root.event(
&mut self.state,
&InputEvent::Key(KeyEvent {
key,
modifiers: Modifiers::default(),
repeat: false,
}),
);
}
fn phase(&self) -> DragPhase {
self.state.coordinator.phase()
}
fn reorders(&self) -> Vec<(usize, usize)> {
self.state.reorders.borrow().clone()
}
}
fn gap_y(gap_index: usize) -> f64 {
gap_index as f64 * (ROW_PX + GAP_PX) + GAP_PX / 2.0
}
fn row_y(row_index: usize) -> f64 {
GAP_PX + row_index as f64 * (ROW_PX + GAP_PX) + ROW_PX / 2.0
}
#[test]
fn dragging_row_0_past_row_2_reports_the_adjusted_index() {
use frust_core::PointerPhase;
let mut h = Harness::new();
h.mouse(PointerPhase::Down, 50.0, row_y(0));
h.mouse(PointerPhase::Move, 50.0, gap_y(3));
assert_eq!(h.phase(), DragPhase::Dragging);
h.mouse(PointerPhase::Up, 50.0, gap_y(3));
h.frame();
assert_eq!(h.phase(), DragPhase::Idle, "the target claimed the drop");
assert_eq!(h.reorders(), vec![(0, 2)]);
}
#[test]
fn dropping_on_the_gap_touching_the_source_reports_nothing() {
use frust_core::PointerPhase;
let mut h = Harness::new();
h.mouse(PointerPhase::Down, 50.0, row_y(0));
h.mouse(PointerPhase::Move, 50.0, gap_y(1));
h.mouse(PointerPhase::Up, 50.0, gap_y(1));
h.frame();
assert_eq!(h.phase(), DragPhase::Idle);
assert_eq!(
h.reorders(),
Vec::new(),
"dropping at the original slot is a no-op"
);
}
#[test]
fn the_gap_indicator_tracks_the_hovered_gap_only() {
use frust_core::PointerPhase;
let mut h = Harness::new();
let idle = h.frame();
assert_eq!(idle.transforms.len(), 0, "nothing hovered, nothing drawn");
h.mouse(PointerPhase::Down, 50.0, row_y(0));
h.mouse(PointerPhase::Move, 50.0, gap_y(2));
let hovering_gap = h.frame();
assert_eq!(
hovering_gap.transforms.len(),
1,
"exactly the hovered gap paints its highlight"
);
h.mouse(PointerPhase::Move, 50.0, row_y(1));
let hovering_row = h.frame();
assert_eq!(
hovering_row.transforms.len(),
0,
"no gap is hovered while the pointer sits over a row"
);
h.mouse(PointerPhase::Move, 50.0, gap_y(3));
let hovering_again = h.frame();
assert_eq!(
hovering_again.transforms.len(),
1,
"a later gap takes over the indicator"
);
}
#[test]
fn auto_scroll_measures_the_bounded_viewport_not_the_full_content() {
use crate::SizedBoxView;
use frust_core::PointerPhase;
const ROWS: u32 = 20;
const VIEWPORT_PX: f64 = 300.0;
struct ScrollApp {
coordinator: DragCoordinator,
controller: ScrollController,
}
fn build_scrolling_view(state: &mut ScrollApp) -> SizedBoxView<ScrollApp> {
let items: Vec<(ChildKey, AnyView<ScrollApp>)> = (0..ROWS)
.map(|id| {
(
ChildKey::new(id),
any(SizedBox::<ScrollApp>(Some(WINDOW.width), Some(ROW_PX))),
)
})
.collect();
let list = reorderable_list(state.coordinator.clone(), items)
.auto_scroll(state.controller.clone());
SizedBox::<ScrollApp>(Some(WINDOW.width), Some(VIEWPORT_PX)).child(list)
}
struct ScrollHarness {
root: RenderRoot<ScrollApp, SizedBoxView<ScrollApp>>,
state: ScrollApp,
clock_ms: f64,
}
impl ScrollHarness {
fn new() -> Self {
let mut harness = ScrollHarness {
root: RenderRoot::new(),
state: ScrollApp {
coordinator: DragCoordinator::new(),
controller: ScrollController::new(),
},
clock_ms: 0.0,
};
harness.frame();
harness
}
fn frame(&mut self) {
self.root
.rebuild(&mut build_scrolling_view, &mut self.state);
self.root.layout(WINDOW);
self.clock_ms += 16.0;
let mut scene = crate::test_support::RecordingScene::default();
self.root.paint(
&mut scene,
FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
);
}
fn mouse(&mut self, phase: PointerPhase, x: f64, y: f64) {
self.root.event(
&mut self.state,
&InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, y),
button: frust_core::PointerButton::Primary,
}),
);
}
}
let mut h = ScrollHarness::new();
assert!(
h.state.controller.max_offset() > 0.0,
"the content must be taller than the viewport for this to be a\
meaningful test"
);
h.mouse(PointerPhase::Down, 50.0, row_y(0));
h.mouse(PointerPhase::Move, 50.0, row_y(0) + 8.0);
assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
h.mouse(PointerPhase::Move, 50.0, VIEWPORT_PX - 5.0);
assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
for _ in 0..4 {
h.frame();
}
assert!(
h.state.controller.offset() > 0.0,
"a pointer at the viewport's own bottom edge must auto-scroll"
);
}
#[test]
fn a_keyboard_lift_cycle_and_drop_reports_the_adjusted_index() {
use frust_core::PointerPhase;
let mut h = Harness::new();
h.mouse(PointerPhase::Down, 50.0, row_y(1));
h.mouse(PointerPhase::Up, 50.0, row_y(1));
assert!(h.root.is_focus_active());
h.key(Key::Named(NamedKey::Enter));
assert_eq!(
h.phase(),
DragPhase::Dragging,
"Enter lifts the focused row"
);
for _ in 0..4 {
h.key(Key::Named(NamedKey::ArrowDown));
}
h.key(Key::Named(NamedKey::Enter));
h.frame();
assert_eq!(h.phase(), DragPhase::Idle);
assert_eq!(h.reorders(), vec![(1, 2)]);
}
}