Skip to main content

guise/dnd/
sortable.rs

1//! `SortableList` — drag rows to reorder.
2//!
3//! Stateless: the parent owns the items; the list reports `(from, to)` and
4//! the parent applies it (usually with [`apply_reorder`](super::apply_reorder)).
5
6use std::rc::Rc;
7
8use gpui::prelude::*;
9use gpui::{div, px, AnyElement, App, ElementId, IntoElement, SharedString, Window};
10
11use super::chip::DragChip;
12use crate::devtools::Probed;
13use crate::theme::theme;
14
15type ItemBuilder = Rc<dyn Fn(usize, &mut Window, &mut App) -> AnyElement + 'static>;
16type ReorderHandler = Rc<dyn Fn(usize, usize, &mut Window, &mut App) + 'static>;
17type Labeler = Rc<dyn Fn(usize) -> SharedString + 'static>;
18
19/// The payload a sortable row drags: its list group + index.
20#[derive(Clone)]
21struct SortDrag {
22    group: SharedString,
23    index: usize,
24}
25
26/// A vertical list whose rows drag to reorder. Dropping row `from` on row
27/// `to` calls `on_reorder(from, to)` — "place it where I dropped it".
28///
29/// ```ignore
30/// let view = cx.entity().downgrade();
31/// SortableList::new("queue", self.tracks.len(), {
32///     let tracks = self.tracks.clone();
33///     move |i, _w, _cx| Text::new(tracks[i].clone()).into_any_element()
34/// })
35/// .on_reorder(move |from, to, _window, cx| {
36///     view.update(cx, |this, cx| {
37///         guise::dnd::apply_reorder(&mut this.tracks, from, to);
38///         cx.notify();
39///     })
40///     .ok();
41/// })
42/// ```
43#[derive(IntoElement)]
44pub struct SortableList {
45    id: ElementId,
46    group: SharedString,
47    count: usize,
48    item: ItemBuilder,
49    labeler: Option<Labeler>,
50    gap: f32,
51    on_reorder: Option<ReorderHandler>,
52}
53
54impl SortableList {
55    /// `group` (from `id`) guards drops: rows only accept drags from the
56    /// same list. `item` builds each row's content, re-invoked every frame.
57    pub fn new<E>(
58        id: impl Into<SharedString>,
59        count: usize,
60        item: impl Fn(usize, &mut Window, &mut App) -> E + 'static,
61    ) -> Self
62    where
63        E: IntoElement,
64    {
65        let group: SharedString = id.into();
66        SortableList {
67            id: ElementId::Name(group.clone()),
68            group,
69            count,
70            item: Rc::new(move |i, window, cx| item(i, window, cx).into_any_element()),
71            labeler: None,
72            gap: 4.0,
73            on_reorder: None,
74        }
75    }
76
77    /// Chip label for the dragged row (default "Item N").
78    pub fn label_of(mut self, labeler: impl Fn(usize) -> SharedString + 'static) -> Self {
79        self.labeler = Some(Rc::new(labeler));
80        self
81    }
82
83    /// Vertical gap between rows in px (default 4).
84    pub fn gap(mut self, gap: f32) -> Self {
85        self.gap = gap.max(0.0);
86        self
87    }
88
89    /// Called with `(from, to)` when a row is dropped on another.
90    pub fn on_reorder(
91        mut self,
92        handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
93    ) -> Self {
94        self.on_reorder = Some(Rc::new(handler));
95        self
96    }
97}
98
99impl RenderOnce for SortableList {
100    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
101        let t = theme(cx);
102        let accent = t.primary().hsla();
103
104        let mut root = div().id(self.id).flex().flex_col().gap(px(self.gap));
105        for i in 0..self.count {
106            let content = (self.item)(i, window, cx);
107            let label = match &self.labeler {
108                Some(labeler) => labeler(i),
109                None => SharedString::from(format!("Item {}", i + 1)),
110            };
111            let chip = DragChip {
112                value: SortDrag {
113                    group: self.group.clone(),
114                    index: i,
115                },
116                label,
117            };
118
119            let mut row = div()
120                .id(("guise-sortable-row", i))
121                .cursor_grab()
122                // A constant transparent top border keeps layout stable while
123                // the drag-over highlight recolors it as the insert marker.
124                .border_t_2()
125                .border_color(gpui::transparent_black())
126                .on_drag(chip, |dragged: &DragChip<SortDrag>, _off, _w, cx| {
127                    cx.new(|_| dragged.clone())
128                })
129                .drag_over::<DragChip<SortDrag>>(move |style, _drag, _window, _cx| {
130                    style.border_color(accent)
131                })
132                .child(content);
133
134            if let Some(handler) = self.on_reorder.clone() {
135                let group = self.group.clone();
136                row = row.on_drop(move |dragged: &DragChip<SortDrag>, window, cx| {
137                    if dragged.value.group == group && dragged.value.index != i {
138                        handler(dragged.value.index, i, window, cx);
139                    }
140                });
141            }
142            root = root.child(row);
143        }
144        root.probe("SortableList")
145    }
146}