Skip to main content

gpui_kit/overlay/
drawer.rs

1//! A panel that slides in from one side of the window.
2//!
3//! A drawer is a [`Dialog`](crate::overlay::Dialog) that arrives from an edge
4//! instead of the centre: the same scrim, the same focus trap, the same
5//! escape and scrim dismissal, and the same body callback, because an open
6//! surface re-renders for as long as it stays open.
7//!
8//! Where it differs is the exit. A drawer slides out, and an element cannot
9//! animate after it has been dropped, so the drawer stays in the tree until
10//! [`Presence`] says the exit has finished and only then reports
11//! [`DrawerEvent::Closed`].
12
13use gpui::{
14    AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
15    IntoElement, KeyDownEvent, ParentElement, Render, SharedString, Styled, Window, div,
16    prelude::FluentBuilder, px,
17};
18use gpui_kit_semantics::{NodeSpec, Role, Semantic};
19use gpui_kit_theme::{ActiveTheme, Elevation, Space, Theme};
20
21use crate::foundation::{Ident, StyledExt};
22use crate::motion::{self, Easing, MotionSpec, Phase, Presence};
23use crate::overlay::focus::FocusTrap;
24use crate::overlay::layer::{Edge, Overlay, surface};
25use crate::overlay::panel::{self, Body};
26
27/// How wide a left or right drawer is, and how tall a top or bottom one is,
28/// before the caller says otherwise. Neither value repeats elsewhere.
29const DEFAULT_SIZE: f32 = 360.0;
30
31/// What the drawer reports. The owner decides what any of it means.
32///
33/// [`DrawerEvent::Closed`] arrives when the panel has finished sliding out,
34/// not when closing was asked for, so a subscriber that tears down state on
35/// close does not tear it down mid-animation.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DrawerEvent {
38    Opened,
39    /// The drawer was waved away, by escape or by the scrim.
40    Dismissed,
41    Closed,
42}
43
44impl EventEmitter<DrawerEvent> for Drawer {}
45
46/// A panel anchored to one side of the window.
47pub struct Drawer {
48    ident: Ident,
49    focus_handle: FocusHandle,
50    edge: Edge,
51    size: f32,
52    title: SharedString,
53    description: Option<SharedString>,
54    body: Option<Body>,
55    footer: Option<Body>,
56    dismissable: bool,
57    open: bool,
58    /// Set by `open`, cleared by the first frame that can act on it.
59    pending_focus: bool,
60    presence: Option<Presence>,
61    progress: f32,
62    stops: Vec<FocusHandle>,
63    trap: FocusTrap,
64}
65
66impl std::fmt::Debug for Drawer {
67    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        formatter
69            .debug_struct("Drawer")
70            .field("ident", &self.ident)
71            .field("edge", &self.edge)
72            .field("title", &self.title)
73            .field("has_body", &self.body.is_some())
74            .field("dismissable", &self.dismissable)
75            .field("open", &self.open)
76            .field("rendered", &self.is_rendered())
77            .finish()
78    }
79}
80
81impl Drawer {
82    pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
83        Self {
84            ident: ident.into(),
85            focus_handle: cx.focus_handle(),
86            edge: Edge::Right,
87            size: DEFAULT_SIZE,
88            title: SharedString::default(),
89            description: None,
90            body: None,
91            footer: None,
92            dismissable: true,
93            open: false,
94            pending_focus: false,
95            presence: None,
96            progress: 0.0,
97            stops: Vec::new(),
98            trap: FocusTrap::new(),
99        }
100    }
101
102    /// Which side the panel hangs from and slides in from.
103    pub fn edge(mut self, edge: Edge) -> Self {
104        self.edge = edge;
105        self
106    }
107
108    /// The width of a left or right drawer, or the height of a top or bottom
109    /// one.
110    pub fn size(mut self, size: f32) -> Self {
111        self.size = size.max(0.0);
112        self
113    }
114
115    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
116        self.title = title.into();
117        self
118    }
119
120    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
121        self.description = Some(description.into());
122        self
123    }
124
125    /// Supplies the body, rebuilt on every frame the drawer is on screen.
126    pub fn content(mut self, body: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self {
127        self.body = Some(std::rc::Rc::new(body));
128        self
129    }
130
131    /// Supplies the footer, rebuilt on every frame the drawer is on screen.
132    pub fn footer(
133        mut self,
134        footer: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
135    ) -> Self {
136        self.footer = Some(std::rc::Rc::new(footer));
137        self
138    }
139
140    /// Whether escape and the scrim close the drawer. A drawer that is not
141    /// dismissable installs neither handler.
142    pub fn dismissable(mut self, dismissable: bool) -> Self {
143        self.dismissable = dismissable;
144        self
145    }
146
147    /// The controls tab walks between while the drawer is open.
148    ///
149    /// The body is the caller's, so the caller is the only one that knows
150    /// which of its handles are focus stops.
151    pub fn focus_stops(mut self, stops: impl IntoIterator<Item = FocusHandle>) -> Self {
152        self.stops = stops.into_iter().collect();
153        self
154    }
155
156    pub fn set_focus_stops(
157        &mut self,
158        stops: impl IntoIterator<Item = FocusHandle>,
159        cx: &mut Context<Self>,
160    ) {
161        self.stops = stops.into_iter().collect();
162        cx.notify();
163    }
164
165    pub fn is_open(&self) -> bool {
166        self.open
167    }
168
169    pub fn is_dismissable(&self) -> bool {
170        self.dismissable
171    }
172
173    /// True while the panel must stay in the tree, including for the whole
174    /// slide out.
175    pub fn is_rendered(&self) -> bool {
176        self.presence
177            .as_ref()
178            .is_some_and(|presence| presence.is_rendered())
179    }
180
181    pub fn set_title(&mut self, title: impl Into<SharedString>, cx: &mut Context<Self>) {
182        self.title = title.into();
183        cx.notify();
184    }
185
186    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
187        if self.open {
188            return;
189        }
190        let theme = cx.theme().clone();
191        self.open = true;
192        self.pending_focus = true;
193        let presence = self
194            .presence
195            .get_or_insert_with(|| Presence::hidden(enter_spec(&theme), exit_spec(&theme)));
196        presence.show();
197        self.trap.engage(window, cx);
198        cx.emit(DrawerEvent::Opened);
199        cx.notify();
200    }
201
202    /// Starts the slide out. The drawer stays on screen until it finishes.
203    pub fn close(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
204        if !self.open {
205            return;
206        }
207        self.open = false;
208        self.pending_focus = false;
209        if let Some(presence) = self.presence.as_mut() {
210            presence.hide();
211        }
212        cx.notify();
213    }
214
215    /// Reports a wave-away. A drawer that is not dismissable cannot be waved
216    /// away even by a host calling this directly.
217    pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
218        if !self.open || !self.dismissable {
219            return;
220        }
221        cx.emit(DrawerEvent::Dismissed);
222        self.close(window, cx);
223    }
224
225    /// Finishes the slide that is running, for a host that wants the panel
226    /// where it is going without the frames in between.
227    pub fn settle(&mut self, cx: &mut Context<Self>) {
228        if let Some(presence) = self.presence.as_mut() {
229            presence.settle();
230            self.progress = presence.progress();
231        }
232        cx.notify();
233    }
234
235    fn on_dismiss_key(
236        &mut self,
237        event: &KeyDownEvent,
238        window: &mut Window,
239        cx: &mut Context<Self>,
240    ) {
241        if !self.open || event.keystroke.key.as_str() != "escape" {
242            return;
243        }
244        self.dismiss(window, cx);
245        cx.stop_propagation();
246    }
247
248    fn on_navigation_key(
249        &mut self,
250        event: &KeyDownEvent,
251        window: &mut Window,
252        cx: &mut Context<Self>,
253    ) {
254        if !self.open || event.keystroke.key.as_str() != "tab" {
255            return;
256        }
257        if event.keystroke.modifiers.shift {
258            self.trap.focus_prev(window, cx);
259        } else {
260            self.trap.focus_next(window, cx);
261        }
262        cx.stop_propagation();
263    }
264
265    /// How far the panel still has to travel, in pixels along its edge.
266    fn travel(&self) -> f32 {
267        self.size * (1.0 - self.progress)
268    }
269}
270
271/// A drawer is a heavy thing being pulled out, so it arrives on a spring and
272/// leaves on a curve: the pull has weight, the dismissal is just gone.
273fn enter_spec(theme: &Theme) -> MotionSpec {
274    motion::dialog_arrival(theme)
275}
276
277fn exit_spec(theme: &Theme) -> MotionSpec {
278    MotionSpec::new(theme.motion.quick_ms, Easing::Exit.curve(theme))
279}
280
281impl Focusable for Drawer {
282    fn focus_handle(&self, _cx: &App) -> FocusHandle {
283        self.focus_handle.clone()
284    }
285}
286
287impl Render for Drawer {
288    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
289        self.trap.begin_frame();
290        if self.presence.is_none() {
291            return div().into_any_element();
292        }
293
294        self.progress = self
295            .presence
296            .as_mut()
297            .map(|presence| presence.animate(window, cx))
298            .unwrap_or_default();
299        if self
300            .presence
301            .as_ref()
302            .is_none_or(|presence| presence.phase() == Phase::Gone)
303        {
304            // The exit has run its course, so the panel leaves the tree now
305            // and the keyboard goes back to whatever opened it.
306            self.presence = None;
307            self.progress = 0.0;
308            self.trap.release(window, cx);
309            cx.emit(DrawerEvent::Closed);
310            return div().into_any_element();
311        }
312
313        for stop in self.stops.clone() {
314            self.trap.register(stop);
315        }
316        if self.trap.stops().is_empty() {
317            self.trap.register(self.focus_handle.clone());
318        }
319        if self.pending_focus {
320            // The handle can only take focus once this frame has put it in the
321            // dispatch tree, which is why opening only records the intent.
322            self.pending_focus = false;
323            self.trap.focus_first(window, cx);
324        }
325
326        let theme = cx.theme().clone();
327        let title = self.title.clone();
328        let description = self.description.clone();
329        let body = self.body.clone().map(|body| body(window, cx));
330        let footer = self.footer.clone().map(|footer| footer(window, cx));
331        let horizontal = self.edge.is_horizontal();
332        let travel = self.travel();
333
334        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Dialog)
335            .expanded(self.open)
336            .focus(&self.focus_handle);
337        if !title.is_empty() {
338            spec = spec.text(title.clone());
339        }
340
341        let heading = (!title.is_empty()).then(|| panel::heading(&self.ident, &theme, title, cx));
342        let description =
343            description.map(|description| panel::description(&self.ident, &theme, description, cx));
344
345        let mut card = surface(&theme, Elevation::Modal)
346            .relative()
347            .when(horizontal, |element| element.w(px(self.size)).h_full())
348            .when(!horizontal, |element| element.h(px(self.size)).w_full())
349            .p_token(&theme, Space::Lg)
350            .gap_token(&theme, Space::Sm)
351            .track_focus(&self.focus_handle)
352            .on_key_down(cx.listener(Self::on_navigation_key));
353        card = match self.edge {
354            Edge::Left => card.left(px(-travel)),
355            Edge::Right => card.left(px(travel)),
356            Edge::Top => card.top(px(-travel)),
357            Edge::Bottom => card.top(px(travel)),
358        };
359        if self.dismissable {
360            card = card.on_key_down(cx.listener(Self::on_dismiss_key));
361        }
362        let card = card
363            .children(heading)
364            .children(description)
365            .children(body.map(|body| div().flex_1().overflow_hidden().child(body)))
366            .children(footer)
367            .semantic_in(cx, spec);
368
369        let mut overlay = Overlay::edge(self.ident.child("overlay"), self.edge).child(card);
370        if self.dismissable && self.open {
371            let drawer = cx.entity().downgrade();
372            overlay = overlay.on_dismiss(move |window, cx| {
373                drawer
374                    .update(cx, |drawer, cx| drawer.dismiss(window, cx))
375                    .ok();
376            });
377        }
378        overlay.into_any_element()
379    }
380}