Skip to main content

ui/tabs/
reorder.rs

1//! The horizontal, single-region adapter for [`crate::drag`].
2
3use std::{cell::RefCell, rc::Rc};
4use web_time::Instant;
5
6use gpui::{
7    App, Axis, Bounds, Corners, Div, ElementId, IntoElement, Pixels, Point, RenderOnce,
8    ScrollHandle, Stateful, Window, canvas, div, fill, prelude::*, px, relative, size,
9    transparent_black,
10};
11use motion::TAB_SLIDE;
12use theme::Theme;
13
14use super::{GAP, Strip, TRACK_HEIGHT, TRACK_MIN_WIDTH, TRACK_PAD, Track};
15use crate::drag::{self, Domain};
16use crate::widgets;
17
18/// Persistent gesture and animation state, one per strip.
19///
20/// Its tabs take no `on_drag` of their own. A control inside a tab stops
21/// mouse-down propagation, as [`super::close`] does, or a press on it picks up
22/// the tab.
23pub struct Reorder<Id> {
24    domain: Domain<(), Id>,
25    scroll: ScrollHandle,
26    pill: Rc<RefCell<Pill<Id>>>,
27}
28
29impl<Id> Clone for Reorder<Id> {
30    fn clone(&self) -> Self {
31        Self {
32            domain: self.domain.clone(),
33            scroll: self.scroll.clone(),
34            pill: self.pill.clone(),
35        }
36    }
37}
38
39/// The front tab's pill on a [`Track`], drawn under the tabs so it can slide
40/// from the tab that was in front to the one that is.
41struct Pill<Id> {
42    /// Each tab's box as laid out this frame, in the window's coordinates.
43    places: Vec<(Id, Bounds<Pixels>)>,
44    /// The tab the pill stands under, and the box it was last drawn in.
45    active: Option<Id>,
46    drawn: Option<Bounds<Pixels>>,
47    /// Where a slide set out from, and when.
48    from: Option<(Bounds<Pixels>, Instant)>,
49}
50
51impl<Id> Default for Pill<Id> {
52    fn default() -> Self {
53        Self {
54            places: Vec::new(),
55            active: None,
56            drawn: None,
57            from: None,
58        }
59    }
60}
61
62impl<Id: Clone + PartialEq> Pill<Id> {
63    /// The box the pill is drawn in this frame, in the coordinates of the row
64    /// whose top left is `origin`, and whether it is still sliding. The row and
65    /// its tabs scroll together, so a tab's place in it holds while it scrolls.
66    fn place(
67        &mut self,
68        active: Option<&Id>,
69        origin: Point<Pixels>,
70        cx: &App,
71    ) -> (Option<Bounds<Pixels>>, bool) {
72        if self.active.as_ref() != active {
73            self.from = self
74                .drawn
75                .map(|drawn| (drawn, cx.background_executor().now()));
76            self.active = active.cloned();
77        }
78        let to = self
79            .places
80            .iter()
81            .find(|(id, _)| Some(id) == active)
82            .map(|(_, tab)| Bounds::new(tab.origin - origin, tab.size));
83        let Some(to) = to else {
84            self.drawn = None;
85            return (None, false);
86        };
87        let t = match self.from {
88            Some((_, since)) if !cx.reduce_motion() => {
89                let elapsed = cx
90                    .background_executor()
91                    .now()
92                    .saturating_duration_since(since);
93                let total = TAB_SLIDE.total().mul_f32(motion::speed_scale());
94                TAB_SLIDE.progress(elapsed.as_secs_f32() / total.as_secs_f32().max(f32::EPSILON))
95            }
96            _ => 1.,
97        };
98        let at = match self.from {
99            Some((from, _)) if t < 1. => Bounds::new(
100                from.origin + (to.origin - from.origin) * t,
101                size(
102                    from.size.width + (to.size.width - from.size.width) * t,
103                    from.size.height + (to.size.height - from.size.height) * t,
104                ),
105            ),
106            _ => {
107                self.from = None;
108                to
109            }
110        };
111        self.drawn = Some(at);
112        (Some(at), self.from.is_some())
113    }
114}
115
116impl<Id: Clone + PartialEq + 'static> Default for Reorder<Id> {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl<Id: Clone + PartialEq + 'static> Reorder<Id> {
123    pub fn new() -> Self {
124        Self {
125            domain: Domain::new(),
126            scroll: ScrollHandle::new(),
127            pill: Rc::default(),
128        }
129    }
130
131    /// Supply tabs in model order, each with a key stable across renders. The
132    /// preview moves live; host order changes on release.
133    pub fn bar(
134        &self,
135        id: impl Into<ElementId>,
136        strip: &Strip<Id>,
137        tabs: impl IntoIterator<Item = (Id, Stateful<Div>)>,
138    ) -> Bar<Id> {
139        let tabs: Vec<_> = tabs.into_iter().collect();
140        debug_assert!(tabs.iter().map(|(id, _)| id).eq(strip.tabs()));
141        if self
142            .domain
143            .carried()
144            .is_some_and(|carried| !strip.contains(&carried))
145        {
146            self.domain.invalidate();
147        }
148        Bar {
149            reorder: self.clone(),
150            id: id.into(),
151            tabs,
152            active: strip.active().cloned(),
153            track: None,
154            moved: None,
155            outside: None,
156        }
157    }
158}
159
160/// A committed move. The active tab remains the host's choice.
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub struct Move {
163    pub from: usize,
164    pub to: usize,
165}
166
167/// A carried tab released outside its strip; local reordering is cancelled.
168#[derive(Clone, Debug)]
169pub struct OutsideDrop<Id> {
170    pub id: Id,
171    pub position: Point<Pixels>,
172}
173
174type OnMove = dyn Fn(&Move, &mut Window, &mut App);
175type OnOutside<Id> = dyn Fn(&OutsideDrop<Id>, &mut Window, &mut App);
176
177#[derive(IntoElement)]
178pub struct Bar<Id: Clone + PartialEq + 'static> {
179    reorder: Reorder<Id>,
180    id: ElementId,
181    tabs: Vec<(Id, Stateful<Div>)>,
182    active: Option<Id>,
183    track: Option<Track>,
184    moved: Option<Rc<OnMove>>,
185    outside: Option<Rc<OnOutside<Id>>>,
186}
187
188impl<Id: Clone + PartialEq + 'static> Bar<Id> {
189    /// Stand the tabs on a track, filling the box the bar is put in.
190    pub fn on_track(mut self, track: Track) -> Self {
191        self.track = Some(track);
192        self
193    }
194
195    /// Apply the final move synchronously. Accepts `cx.listener`.
196    pub fn on_reorder(mut self, moved: impl Fn(&Move, &mut Window, &mut App) + 'static) -> Self {
197        self.moved = Some(Rc::new(moved));
198        self
199    }
200
201    pub fn on_drop_outside(
202        mut self,
203        outside: impl Fn(&OutsideDrop<Id>, &mut Window, &mut App) + 'static,
204    ) -> Self {
205        self.outside = Some(Rc::new(outside));
206        self
207    }
208}
209
210impl<Id: Clone + PartialEq + 'static> RenderOnce for Bar<Id> {
211    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
212        let theme = Theme::of(cx).clone();
213        let raised = theme.surface_raised;
214        let Reorder {
215            domain,
216            scroll,
217            pill,
218        } = self.reorder;
219        let order: Vec<Id> = self.tabs.iter().map(|(id, _)| id.clone()).collect();
220        let (track, active) = (self.track, self.active);
221        pill.borrow_mut()
222            .places
223            .retain(|(id, _)| order.contains(id));
224        let flat = track == Some(Track::Flat);
225        // On a track the front tab's pill is drawn under the tabs rather than
226        // by the tab, so it can slide between them. It is placed when painted:
227        // every tab has been laid out and measured by then, this frame.
228        let under = track.map(|_| {
229            let (pill, active, theme) = (pill.clone(), active.clone(), theme.clone());
230            canvas(
231                |_, _, _| {},
232                move |row, _, window, cx| {
233                    let (at, sliding) = pill.borrow_mut().place(active.as_ref(), row.origin, cx);
234                    if sliding {
235                        window.request_animation_frame();
236                    }
237                    let Some(at) = at else {
238                        return;
239                    };
240                    let bounds = Bounds::new(row.origin + at.origin, at.size);
241                    let corners = Corners::all(match flat {
242                        true => px(Theme::control_radius()),
243                        false => at.size.height / 2.,
244                    });
245                    window.paint_quad(fill(bounds, theme.card_selected_bg()).corner_radii(corners));
246                    window.paint_inset_shadows(bounds, corners, &theme::glass_selected_shadows());
247                },
248            )
249            .absolute()
250            .top_0()
251            .left_0()
252            .size_full()
253        });
254        // A line before every tab but the first, unless the pill is on either
255        // side of it. Inside the tab and out of its flow, so no tab moves when
256        // the pill does.
257        let parted = |at: usize| {
258            at > 0 && active.as_ref() != order.get(at) && active.as_ref() != order.get(at - 1)
259        };
260        let row = div()
261            .id("tab-row")
262            .flex_1()
263            .min_w_0()
264            .flex()
265            .flex_row()
266            .items_center()
267            .gap(px(GAP))
268            .overflow_x_scroll()
269            .track_scroll(&scroll)
270            .children(under)
271            .children(self.tabs.into_iter().enumerate().map(|(at, (id, tab))| {
272                let shown = track.is_some() && active.as_ref() == Some(&id);
273                let measure = {
274                    let (pill, id) = (pill.clone(), id.clone());
275                    canvas(
276                        move |bounds, _, _| {
277                            let mut pill = pill.borrow_mut();
278                            match pill.places.iter_mut().find(|(at, _)| *at == id) {
279                                Some((_, place)) => *place = bounds,
280                                None => pill.places.push((id, bounds)),
281                            }
282                        },
283                        |_, _, _, _| {},
284                    )
285                    .absolute()
286                    .size_full()
287                };
288                let tab = match track {
289                    None => tab.flex_none(),
290                    Some(_) => tab
291                        .when(shown, |tab| tab.bg(transparent_black()).shadow(Vec::new()))
292                        .child(measure)
293                        .relative()
294                        .flex_1()
295                        .min_w(px(TRACK_MIN_WIDTH))
296                        .h(px(widgets::on_track_height(TRACK_HEIGHT, TRACK_PAD)))
297                        .max_w(relative(1.))
298                        .justify_center()
299                        .map(|tab| match flat {
300                            true => tab.rounded(px(Theme::control_radius())),
301                            false => tab.rounded_full(),
302                        })
303                        .when(parted(at), |tab| {
304                            tab.child(
305                                div()
306                                    .absolute()
307                                    // Past the tab's own 1px border, to the
308                                    // middle of the gap.
309                                    .left(px(-(1.0 + GAP / 2.0) - 0.5))
310                                    .top(px(5.0))
311                                    .bottom(px(5.0))
312                                    .w(px(1.0))
313                                    .bg(theme.border),
314                            )
315                        }),
316                };
317                let tab = match domain.carries(&id) {
318                    true => tab.bg(raised).cursor_grabbing(),
319                    false => tab,
320                };
321                domain.handle(id, tab)
322            }));
323        let mut region = domain
324            .region("tab-region", (), Axis::Horizontal, row)
325            .axis_locked()
326            .track_scroll(&scroll)
327            .flex_1()
328            .min_w_0()
329            .min_h_0()
330            .flex();
331        if let Some(moved) = self.moved {
332            region = region.on_drop(move |event: &drag::Drop<(), Id>, window, cx| {
333                let Some(from) = order.iter().position(|id| *id == event.item) else {
334                    return;
335                };
336                let rest: Vec<&Id> = order.iter().filter(|id| **id != event.item).collect();
337                let to = match (&event.after, &event.before) {
338                    (Some(after), _) => rest.iter().position(|id| *id == after).map(|at| at + 1),
339                    (None, Some(before)) => rest.iter().position(|id| *id == before),
340                    (None, None) => Some(0),
341                };
342                if let Some(to) = to {
343                    moved(&Move { from, to }, window, cx);
344                }
345            });
346        }
347        if let Some(outside) = self.outside {
348            region = region.on_drop_outside(move |event: &drag::Outside<Id>, window, cx| {
349                outside(
350                    &OutsideDrop {
351                        id: event.item.clone(),
352                        position: event.position,
353                    },
354                    window,
355                    cx,
356                );
357            });
358        }
359        // Sized to its tabs: the bar grows into this box, not the row it sits in.
360        // On a track it fills the box it is put in.
361        let frame = match track {
362            None => div(),
363            Some(track) => widgets::track(&theme)
364                .p(px(TRACK_PAD))
365                .h(px(TRACK_HEIGHT))
366                .rounded_full()
367                .flex_1()
368                .when(track != Track::Bordered, |frame| {
369                    frame.border_color(transparent_black())
370                })
371                .when(track == Track::Flat, |frame| frame.bg(transparent_black())),
372        };
373        frame
374            .id(self.id)
375            .relative()
376            .min_w_0()
377            .min_h_0()
378            .flex()
379            .flex_row()
380            .child(super::bar("tab-bar").viewport(region.into_any_element(), &scroll))
381    }
382}