Skip to main content

liora_components/
draggable.rs

1use gpui::{AnyElement, Bounds, Div, Hsla, Pixels, Point, div, point, prelude::*, px};
2use liora_icons::Icon;
3use liora_icons_lucide::IconName;
4
5/// Axis used by Liora's native drag helpers.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum DragAxis {
8    Horizontal,
9    Vertical,
10    Free,
11}
12
13/// Runtime pointer state for handle-based dragging.
14///
15/// The helper intentionally stores only coordinates and item positions, never
16/// rendered GPUI elements. This mirrors the safe part of drag-rs' model: a drag
17/// operation has a start point, a current cursor point and a result callback,
18/// while each UI renders its own preview/handle natively.
19#[derive(Clone, Debug, Default)]
20pub struct DragState {
21    origin_index: Option<usize>,
22    active_index: Option<usize>,
23    over_index: Option<usize>,
24    start_position: Option<Point<Pixels>>,
25    current_position: Option<Point<Pixels>>,
26    grab_offset: Option<Point<Pixels>>,
27}
28
29impl DragState {
30    pub fn start(&mut self, index: usize, position: Point<Pixels>) {
31        self.start_at(index, position, None);
32    }
33
34    pub fn start_at(
35        &mut self,
36        index: usize,
37        position: Point<Pixels>,
38        bounds: Option<Bounds<Pixels>>,
39    ) {
40        self.origin_index = Some(index);
41        self.active_index = Some(index);
42        self.over_index = Some(index);
43        self.start_position = Some(position);
44        self.current_position = Some(position);
45        self.grab_offset =
46            bounds.map(|bounds| point(position.x - bounds.origin.x, position.y - bounds.origin.y));
47    }
48
49    pub fn update_position(&mut self, position: Point<Pixels>) {
50        if self.active_index.is_some() {
51            self.current_position = Some(position);
52        }
53    }
54
55    pub fn set_over(&mut self, index: usize) {
56        if self.active_index.is_some() {
57            self.over_index = Some(index);
58        }
59    }
60
61    pub fn move_active_to(&mut self, index: usize) {
62        self.active_index = Some(index);
63        self.over_index = Some(index);
64        self.start_position = self.current_position;
65    }
66
67    pub fn finish(&mut self) -> Option<(usize, usize)> {
68        let origin = self.origin_index.take()?;
69        let active = self.active_index.take()?;
70        let target = self.over_index.take().unwrap_or(active);
71        self.start_position = None;
72        self.current_position = None;
73        self.grab_offset = None;
74        Some((origin, target))
75    }
76
77    pub fn cancel(&mut self) {
78        self.origin_index = None;
79        self.active_index = None;
80        self.over_index = None;
81        self.start_position = None;
82        self.current_position = None;
83        self.grab_offset = None;
84    }
85
86    pub fn active_index(&self) -> Option<usize> {
87        self.active_index
88    }
89
90    pub fn origin_index(&self) -> Option<usize> {
91        self.origin_index
92    }
93
94    pub fn over_index(&self) -> Option<usize> {
95        self.over_index
96    }
97
98    pub fn is_active(&self, index: usize) -> bool {
99        self.active_index == Some(index)
100    }
101
102    pub fn is_over(&self, index: usize) -> bool {
103        self.over_index == Some(index) && self.active_index != Some(index)
104    }
105
106    pub fn offset(&self, axis: DragAxis) -> (Pixels, Pixels) {
107        let Some(start) = self.start_position else {
108            return (px(0.0), px(0.0));
109        };
110        let Some(current) = self.current_position else {
111            return (px(0.0), px(0.0));
112        };
113        let dx = current.x - start.x;
114        let dy = current.y - start.y;
115        match axis {
116            DragAxis::Horizontal => (dx, px(0.0)),
117            DragAxis::Vertical => (px(0.0), dy),
118            DragAxis::Free => (dx, dy),
119        }
120    }
121
122    /// Offset the active item from its current layout slot so the original
123    /// grabbed point remains under the pointer.
124    ///
125    /// Reorderable lists may move the active item to a new slot while the
126    /// pointer is still down. Using only `current - start` makes the item jump
127    /// when that layout slot changes. When the caller can provide the active
128    /// slot's latest bounds, this method compensates by anchoring the visual
129    /// preview to the grab offset captured on mouse down.
130    pub fn offset_from_bounds(
131        &self,
132        axis: DragAxis,
133        bounds: Option<Bounds<Pixels>>,
134    ) -> (Pixels, Pixels) {
135        let Some(bounds) = bounds else {
136            return self.offset(axis);
137        };
138        let Some(current) = self.current_position else {
139            return (px(0.0), px(0.0));
140        };
141        let grab_offset = self.grab_offset.unwrap_or_else(|| point(px(0.0), px(0.0)));
142        let dx = current.x - grab_offset.x - bounds.origin.x;
143        let dy = current.y - grab_offset.y - bounds.origin.y;
144        match axis {
145            DragAxis::Horizontal => (dx, px(0.0)),
146            DragAxis::Vertical => (px(0.0), dy),
147            DragAxis::Free => (dx, dy),
148        }
149    }
150}
151
152/// Move an item inside a vector, returning whether a move happened.
153pub fn reorder_indices<T>(items: &mut Vec<T>, from: usize, to: usize) -> bool {
154    if from >= items.len() || to >= items.len() || from == to {
155        return false;
156    }
157    let item = items.remove(from);
158    items.insert(to, item);
159    true
160}
161
162/// Default front-side drag handle used by reorderable components.
163pub fn drag_handle(color: Hsla, active: bool, width: Pixels) -> Div {
164    div()
165        .flex()
166        .flex_none()
167        .w(width)
168        .items_start()
169        .items_center()
170        .justify_center()
171        .cursor_pointer()
172        .rounded_md()
173        .when(!active, |s| {
174            s.hover(|s| s.cursor_pointer().bg(gpui::black().opacity(0.018)))
175        })
176        .when(active, |s| s.cursor_pointer())
177        .child(
178            Icon::new(IconName::GripVertical)
179                .size(px(16.0))
180                .color(color),
181        )
182}
183
184/// Convenience wrapper for callers that need a boxed element handle.
185pub fn drag_handle_element(color: Hsla, active: bool, width: Pixels) -> AnyElement {
186    drag_handle(color, active, width).into_any_element()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use gpui::{Bounds, point, px, size};
193
194    #[test]
195    fn reorder_indices_moves_items() {
196        let mut items = vec![0, 1, 2, 3];
197        assert!(reorder_indices(&mut items, 0, 2));
198        assert_eq!(items, vec![1, 2, 0, 3]);
199        assert!(reorder_indices(&mut items, 3, 1));
200        assert_eq!(items, vec![1, 3, 2, 0]);
201    }
202
203    #[test]
204    fn drag_state_tracks_axis_offsets() {
205        let mut state = DragState::default();
206        state.start(2, point(px(10.0), px(20.0)));
207        state.update_position(point(px(42.0), px(12.0)));
208        assert_eq!(state.offset(DragAxis::Horizontal), (px(32.0), px(0.0)));
209        assert_eq!(state.offset(DragAxis::Vertical), (px(0.0), px(-8.0)));
210        assert_eq!(state.offset(DragAxis::Free), (px(32.0), px(-8.0)));
211    }
212
213    #[test]
214    fn drag_state_finishes_with_last_over_index() {
215        let mut state = DragState::default();
216        state.start(1, point(px(0.0), px(0.0)));
217        state.update_position(point(px(20.0), px(0.0)));
218        state.move_active_to(3);
219        assert_eq!(state.finish(), Some((1, 3)));
220        assert_eq!(state.origin_index(), None);
221        assert_eq!(state.active_index(), None);
222        assert_eq!(state.over_index(), None);
223    }
224
225    #[test]
226    fn drag_state_keeps_grab_offset_when_slot_moves_backward() {
227        let mut state = DragState::default();
228        state.start_at(
229            3,
230            point(px(310.0), px(10.0)),
231            Some(Bounds::new(
232                point(px(300.0), px(0.0)),
233                size(px(100.0), px(40.0)),
234            )),
235        );
236        state.update_position(point(px(250.0), px(10.0)));
237        state.move_active_to(2);
238
239        assert_eq!(
240            state.offset_from_bounds(
241                DragAxis::Horizontal,
242                Some(Bounds::new(
243                    point(px(200.0), px(0.0)),
244                    size(px(100.0), px(40.0)),
245                )),
246            ),
247            (px(40.0), px(0.0))
248        );
249    }
250}