Skip to main content

ui/tabs/
reorder.rs

1//! A tab carried along its strip: it follows the pointer, and the tabs it
2//! passes slide aside into the place it left.
3
4use std::{cell::RefCell, rc::Rc};
5
6use gpui::{
7    AnyElement, App, Bounds, Div, Pixels, Point, Stateful, canvas, deferred, prelude::*, px,
8};
9use motion::{Painter, TAB_SLIDE};
10use theme::Theme;
11use web_time::Instant;
12
13use super::{GAP, Strip};
14
15/// Redraw rate while tabs slide.
16const SLIDE_FPS: f32 = 120.0;
17
18/// The tab being carried.
19struct Carried<Id> {
20    id: Id,
21    /// Where the press landed, from the tab's left edge.
22    grab: Pixels,
23    /// The pointer's last `x`. `None` until the first drag move.
24    pointer: Option<Pixels>,
25}
26
27/// One tab's place, as the last frame measured it.
28struct Slot<Id> {
29    id: Id,
30    /// Left edge of the slot, the offset taken out. `None` until measured.
31    home: Option<Pixels>,
32    width: Pixels,
33    /// The offset the last render painted the tab at.
34    painted: Pixels,
35    slide: Option<Slide>,
36}
37
38/// A tab gliding from `from` back to its slot.
39#[derive(Clone, Copy)]
40struct Slide {
41    from: Pixels,
42    since: Instant,
43}
44
45impl Slide {
46    /// The offset `now`, and `None` once the slide has landed.
47    fn at(self, now: Instant) -> Option<Pixels> {
48        let total = TAB_SLIDE.total().mul_f32(motion::speed_scale());
49        let elapsed = now.saturating_duration_since(self.since);
50        if elapsed >= total {
51            return None;
52        }
53        let progress = TAB_SLIDE.progress(elapsed.as_secs_f32() / total.as_secs_f32());
54        Some(self.from * (1.0 - progress))
55    }
56}
57
58struct State<Id> {
59    painter: Painter,
60    carried: Option<Carried<Id>>,
61    slots: Vec<Slot<Id>>,
62}
63
64impl<Id: PartialEq> State<Id> {
65    fn slot(&self, id: &Id) -> Option<&Slot<Id>> {
66        self.slots.iter().find(|slot| slot.id == *id)
67    }
68
69    fn slot_mut(&mut self, id: &Id) -> Option<&mut Slot<Id>> {
70        self.slots.iter_mut().find(|slot| slot.id == *id)
71    }
72
73    /// Where the carried tab sits against its slot, if `id` is the one carried.
74    fn carried_offset(&self, id: &Id) -> Option<Pixels> {
75        let carried = self.carried.as_ref().filter(|carried| carried.id == *id)?;
76        let home = self.slot(id).and_then(|slot| slot.home);
77        Some(match (carried.pointer, home) {
78            (Some(pointer), Some(home)) => pointer - carried.grab - home,
79            _ => px(0.0),
80        })
81    }
82}
83
84/// A strip's tabs in motion: the one being dragged, and the ones sliding
85/// aside for it. Kept by the view beside its [`Strip`], one per strip.
86///
87/// The order changes while the drag is held, so a drop needs no handler: the
88/// tab is already where it was let go. Four places to wire:
89///
90/// ```ignore
91/// tabs::bar("panel-tabs")
92///     .on_drag_move(cx.listener(|view, event: &DragMoveEvent<TabDrag>, _, cx| {
93///         view.reorder.follow(&mut view.strip, event.event.position, cx);
94///         cx.notify();
95///     }))
96///     .children(self.strip.tabs().iter().map(|id| {
97///         let tab = tabs::tab(&theme, key, label, state).on_drag(TabDrag(*id), {
98///             let (reorder, id) = (self.reorder.clone(), *id);
99///             move |_, at, _, cx| {
100///                 reorder.grab(id, at);
101///                 cx.new(|_| gpui::Empty)
102///             }
103///         });
104///         self.reorder.tab(id, tab, &theme, cx)
105///     }))
106/// ```
107///
108/// The ghost `on_drag` returns is still painted at the pointer; an empty one
109/// leaves the carried tab as the only thing that moves.
110///
111/// `follow` runs for every move of the drag, wherever the pointer is. Pointer
112/// travel off the strip's axis is ignored.
113///
114/// The carried tab paints deferred, over its neighbours and outside the
115/// strip's clip.
116pub struct Reorder<Id>(Rc<RefCell<State<Id>>>);
117
118impl<Id> Clone for Reorder<Id> {
119    fn clone(&self) -> Self {
120        Self(self.0.clone())
121    }
122}
123
124impl<Id: Clone + PartialEq + 'static> Reorder<Id> {
125    /// `painter` is the view the strip renders in: it is redrawn while tabs
126    /// slide.
127    pub fn new(painter: Painter) -> Self {
128        Self(Rc::new(RefCell::new(State {
129            painter,
130            carried: None,
131            slots: Vec::new(),
132        })))
133    }
134
135    /// Pick `id` up. Call it from the tab's `on_drag` constructor, with the
136    /// press offset that constructor is handed.
137    pub fn grab(&self, id: Id, at: Point<Pixels>) {
138        self.0.borrow_mut().carried = Some(Carried {
139            id,
140            grab: at.x,
141            pointer: None,
142        });
143    }
144
145    /// Carry the held tab to `pointer`, reordering `strip` as it passes its
146    /// neighbours. `true` when the order changed.
147    pub fn follow(&self, strip: &mut Strip<Id>, pointer: Point<Pixels>, cx: &App) -> bool {
148        let mut state = self.0.borrow_mut();
149        state.slots.retain(|slot| strip.contains(&slot.id));
150        let Some(carried) = state.carried.as_mut() else {
151            return false;
152        };
153        carried.pointer = Some(pointer.x);
154        let (id, grab) = (carried.id.clone(), carried.grab);
155        let Some(slot) = state.slot(&id) else {
156            return false;
157        };
158        let (Some(home), width) = (slot.home, slot.width) else {
159            return false;
160        };
161
162        let travel = (pointer.x - grab - home).as_f32();
163        let (passed, left) = strip.carry(&id, travel, |tab| {
164            state
165                .slot(tab)
166                .filter(|slot| slot.home.is_some())
167                .map(|slot| slot.width.as_f32())
168        });
169        if passed.is_empty() {
170            return false;
171        }
172
173        // Each tab passed moves one carried tab over, the other way; its
174        // offset takes the move back out so it starts from where it was seen.
175        let shift = px(travel.signum() * (width.as_f32() + GAP));
176        let now = cx.background_executor().now();
177        let still = cx.reduce_motion();
178        for tab in &passed {
179            if let Some(slot) = state.slot_mut(tab) {
180                slot.home = slot.home.map(|home| home - shift);
181                let seen = slot
182                    .slide
183                    .and_then(|slide| slide.at(now))
184                    .unwrap_or_default();
185                slot.slide = (!still).then_some(Slide {
186                    from: seen + shift,
187                    since: now,
188                });
189            }
190        }
191        if let Some(slot) = state.slot_mut(&id) {
192            slot.home = Some(pointer.x - grab - px(left));
193        }
194        true
195    }
196
197    /// `tab`, placed: at the pointer while carried, sliding while it makes way,
198    /// in its slot otherwise.
199    pub fn tab(&self, id: &Id, tab: Stateful<Div>, theme: &Theme, cx: &mut App) -> AnyElement {
200        let now = cx.background_executor().now();
201        let mut state = self.0.borrow_mut();
202
203        // The drag going away is the end of the gesture, however it ended.
204        if !cx.has_active_drag()
205            && let Some(carried) = state.carried.take()
206        {
207            let home = state.slot(&carried.id).and_then(|slot| slot.home);
208            let from = match (carried.pointer, home) {
209                (Some(pointer), Some(home)) => pointer - carried.grab - home,
210                _ => px(0.0),
211            };
212            if let Some(slot) = state.slot_mut(&carried.id)
213                && !cx.reduce_motion()
214                && from != px(0.0)
215            {
216                slot.slide = Some(Slide { from, since: now });
217            }
218        }
219
220        let carried = state.carried_offset(id);
221        if state.slot(id).is_none() {
222            state.slots.push(Slot {
223                id: id.clone(),
224                home: None,
225                width: px(0.0),
226                painted: px(0.0),
227                slide: None,
228            });
229        }
230        let painter = state.painter;
231        let Some(slot) = state.slot_mut(id) else {
232            unreachable!("pushed above");
233        };
234        let offset = match carried {
235            Some(offset) => offset,
236            None => match slot.slide.and_then(|slide| slide.at(now)) {
237                Some(offset) => offset,
238                None => {
239                    slot.slide = None;
240                    px(0.0)
241                }
242            },
243        };
244        slot.painted = offset;
245        let sliding = slot.slide.is_some();
246        drop(state);
247        if sliding {
248            painter.lease(SLIDE_FPS, TAB_SLIDE.total(), cx);
249        }
250
251        let measure = canvas(
252            {
253                let (state, id) = (self.0.clone(), id.clone());
254                move |bounds: Bounds<Pixels>, _, _| {
255                    if let Some(slot) = state.borrow_mut().slot_mut(&id) {
256                        slot.home = Some(bounds.left() - slot.painted);
257                        slot.width = bounds.size.width;
258                    }
259                }
260            },
261            |_, _, _, _| {},
262        )
263        .absolute()
264        // Out over the tab's 1px border, so the box is the tab's own.
265        .top(px(-1.0))
266        .bottom(px(-1.0))
267        .left(px(-1.0))
268        .right(px(-1.0));
269
270        let tab = tab.relative().left(offset).child(measure);
271        match carried {
272            Some(_) => deferred(tab.bg(theme.surface_raised)).into_any_element(),
273            None => tab.into_any_element(),
274        }
275    }
276}