Skip to main content

herogpui_components/
drawer.rs

1//! Drawer — port of `@heroui/drawer`.
2//!
3//! Window-edge overlay panel, including when composed inside a clipped or
4//! positioned container, like [`Modal`](crate::modal::Modal).
5
6use gpui::{
7    prelude::*, px, AnyElement, App, Bounds, IntoElement, ParentElement, RenderOnce, SharedString,
8    Styled, Window,
9};
10use herogpui_theme::ActiveTheme;
11use web_time::Instant;
12
13use herogpui_core::{element_id, Backdrop};
14
15use crate::a11y::{self, A11y as _};
16use crate::modal::{DismissReason, OnClose, OnOpenChange};
17
18/// Which edge the drawer is anchored to (`placement`).
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum DrawerPlacement {
21    /// Anchored to the left edge.
22    Left,
23    /// Anchored to the right edge.
24    Right,
25    /// Anchored to the top edge.
26    Top,
27    /// Anchored to the bottom edge.
28    #[default]
29    Bottom,
30}
31
32/// The desktop side drawer width in v3's `drawer.css`.
33const SIDE_EXTENT: gpui::Pixels = px(384.);
34
35const DRAG_ACTIVATION: f32 = 8.;
36
37const DRAG_DISMISS_FRACTION: f32 = 0.3;
38
39const DRAG_VELOCITY: f32 = 0.5;
40
41#[derive(Clone, Copy)]
42struct DragState {
43    start: f32,
44    offset: f32,
45    last: f32,
46    last_at: Instant,
47    velocity: f32,
48    active: bool,
49}
50
51/// `Drawer.CloseTrigger` — the drawer's close slot, `absolute end-4 top-4`.
52///
53/// v3 spells visibility by composing or omitting the part: a composed
54/// [`DrawerCloseTrigger`] renders in the slot and an omitted one leaves it
55/// bare panel padding — there is no `hideCloseButton` and no automatic
56/// stand-in. The part is v3's wired `CloseButton` (`slot="close"`), always
57/// wired to the drawer's dismissal paths ([`Drawer::on_close`] plus
58/// [`Drawer::on_open_change`], the same report Escape, the drag release and
59/// the backdrop make) and closing regardless of `is_dismissible`, like v3's
60/// composed trigger. Custom `children` only replace the button's glyph — the
61/// press still runs the drawer's close action. With neither dismissal
62/// callback on the drawer — or composed outside a [`Drawer`] — the part
63/// draws nothing.
64pub struct DrawerCloseTrigger {
65    on_dismiss: Option<OnClose>,
66    /// The id of the dialog this trigger was pulled out of; see
67    /// [`crate::modal::CloseTriggerPart::wire`].
68    owner: Option<gpui::ElementId>,
69    /// This trigger's index within its dialog; see
70    /// [`crate::modal::CloseTriggerPart::wire`].
71    slot: usize,
72    children: Vec<AnyElement>,
73}
74
75impl DrawerCloseTrigger {
76    /// Creates a close trigger with no children.
77    pub fn new() -> Self {
78        Self {
79            on_dismiss: None,
80            owner: None,
81            slot: 0,
82            children: Vec::new(),
83        }
84    }
85}
86
87crate::modal::close_trigger_part!(DrawerCloseTrigger, "close-trigger");
88
89/// HeroUI Drawer (controlled).
90#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
91#[derive(IntoElement)]
92pub struct Drawer {
93    /// Keys this dialog's own state; see [`Drawer::id`].
94    id: gpui::ElementId,
95    is_open: bool,
96    placement: DrawerPlacement,
97    is_dismissible: bool,
98    backdrop: Backdrop,
99    is_keyboard_dismiss_disabled: bool,
100    on_open_change: Option<OnOpenChange>,
101    title: Option<SharedString>,
102    body: Vec<AnyElement>,
103    footer: Vec<(AnyElement, bool)>,
104    on_close: Option<OnClose>,
105    /// The `sx` slot, refined over the root style at the end of render.
106    sx: Option<Box<gpui::StyleRefinement>>,
107}
108
109impl Drawer {
110    /// The element id this dialog's state is keyed by.
111    ///
112    /// Not a v3 prop: gpui needs an explicit id, and the exit phase, the focus
113    /// handle and the drag offset are all keyed by it. Two dialogs on screen
114    /// with the same key share all three -- which is what
115    /// `HEROGPUI_OPEN_OVERLAYS=1` puts on screen.
116    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
117        self.id = id.into();
118        self
119    }
120
121    /// Creates a closed drawer.
122    pub fn new() -> Self {
123        Self {
124            id: gpui::ElementId::Name("drawer".into()),
125            is_open: false,
126            placement: DrawerPlacement::Bottom,
127            is_dismissible: true,
128            backdrop: Backdrop::Opaque,
129            is_keyboard_dismiss_disabled: false,
130            on_open_change: None,
131            title: None,
132            body: Vec::new(),
133            footer: Vec::new(),
134            on_close: None,
135            sx: None,
136        }
137    }
138
139    /// Sets whether the drawer is open (v3 `isOpen`).
140    pub fn is_open(mut self, v: bool) -> Self {
141        self.is_open = v;
142        self
143    }
144
145    /// Sets the edge the drawer is anchored to (v3 `placement`).
146    pub fn placement(mut self, p: DrawerPlacement) -> Self {
147        self.placement = p;
148        self
149    }
150
151    /// `isKeyboardDismissDisabled` — stops Escape from closing the drawer.
152    pub fn is_keyboard_dismiss_disabled(mut self, v: bool) -> Self {
153        self.is_keyboard_dismiss_disabled = v;
154        self
155    }
156
157    /// `variant` on `Drawer.Backdrop` — the scrim style.
158    pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
159        self.backdrop = backdrop;
160        self
161    }
162
163    /// `onOpenChange` — fires with `false` on every dismissal path, alongside
164    /// [`Drawer::on_close`].
165    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
166        self.on_open_change = Some(std::sync::Arc::new(f));
167        self
168    }
169
170    /// Sets whether the backdrop and dragging can dismiss the drawer.
171    pub fn is_dismissible(mut self, v: bool) -> Self {
172        self.is_dismissible = v;
173        self
174    }
175
176    /// Sets the title text.
177    pub fn title(mut self, t: impl Into<SharedString>) -> Self {
178        self.title = Some(t.into());
179        self
180    }
181
182    /// Appends an element to the footer.
183    pub fn footer_child(mut self, el: impl IntoElement) -> Self {
184        let mut element = el.into_any_element();
185        let interactive = element
186            .downcast_mut::<gpui::Stateful<gpui::Div>>()
187            .is_some();
188        self.footer.push((element, interactive));
189        self
190    }
191
192    /// Enables the dismissal paths: the composed close trigger, Escape, the
193    /// backdrop and a drag past the threshold all report through it,
194    /// alongside [`Drawer::on_open_change`]. The [`DismissReason`] says which
195    /// path fired.
196    pub fn on_close(mut self, f: impl Fn(&DismissReason, &mut Window, &mut App) + 'static) -> Self {
197        self.on_close = Some(std::sync::Arc::new(f));
198        self
199    }
200
201    /// The one slot for caller-owned low-level styling: GPUI's styling methods
202    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
203    /// applied to the drawer's root element — the full-window overlay the
204    /// panel and the backdrop sit in — after every value the placement, the
205    /// backdrop and the active theme chose, so they win.
206    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
207        crate::util::refine_sx(&mut self.sx, style);
208        self
209    }
210}
211
212impl Default for Drawer {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl ParentElement for Drawer {
219    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
220        self.body.extend(elements);
221    }
222}
223
224impl RenderOnce for Drawer {
225    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
226        // v3 keeps a closing panel on screen for its `slide-out-to-*` run.
227        let (phase, dismissal_token) = crate::util::overlay_scope_with_exit(
228            window,
229            cx,
230            crate::modal::dialog_key(&self.id, "phase"),
231            self.is_open,
232            true,
233            crate::anim::Motion::DRAWER_OUT.ms,
234        );
235        if phase == crate::util::OverlayPhase::Closed {
236            // Every close path lands here, including a caller flipping
237            // `is_open`, so the focus goes back from one place.
238            crate::modal::release_dialog_focus(&self.id, window, cx);
239            return gpui::div().into_any_element();
240        }
241        let exiting = phase == crate::util::OverlayPhase::Exiting;
242
243        // Escape has to reach the overlay, and key events only travel to the
244        // focused element and its ancestors. Claiming focus while nothing
245        // inside holds it makes Escape work immediately; once a field inside
246        // takes focus the event still bubbles up to here.
247        let focus =
248            window.use_keyed_state(crate::modal::dialog_key(&self.id, "focus"), cx, |_, cx| {
249                cx.focus_handle()
250            });
251        let focus_handle = focus.read(cx).clone();
252        crate::modal::claim_dialog_focus(&self.id, &focus_handle, window, cx);
253
254        // A drag in progress: where it started along the dismissal axis, and how
255        // far it has come. `use_keyed_state` takes `cx` mutably, so it precedes
256        // the theme tokens.
257        let drag =
258            window.use_keyed_state(crate::modal::dialog_key(&self.id, "drag"), cx, |_, _| {
259                None::<DragState>
260            });
261        let drag_now = *drag.read(cx);
262        // How far the panel has been pulled toward its edge, which is what the
263        // panel is offset by while the pointer is down.
264        let drag_offset = drag_now.map_or(0.0, |state| state.offset.max(0.0));
265
266        let panel_bounds = window.use_keyed_state(
267            crate::modal::dialog_key(&self.id, "panel-bounds"),
268            cx,
269            |_, _| Bounds::default(),
270        );
271        let colors = cx.colors();
272
273        // Every dismissal path reports through both callbacks.
274        let dismiss: Option<OnClose> = match (self.on_close.clone(), self.on_open_change.clone()) {
275            (None, None) => None,
276            (close, open_change) => Some(crate::util::shared(
277                move |reason: &DismissReason, window: &mut Window, cx: &mut App| {
278                    if let Some(f) = &close {
279                        f(reason, window, cx);
280                    }
281                    if let Some(f) = &open_change {
282                        f(&false, window, cx);
283                    }
284                },
285            )),
286        };
287        // The panel's outside-press dismissal; the drag release moves the
288        // shared `dismiss` below, so this clone is where the two split.
289        let press_out_dismiss = dismiss.clone();
290
291        // v3 composes the close trigger as a child part. Pull every composed
292        // `DrawerCloseTrigger` out of the body children and the footer row —
293        // so neither slot swallows it — and hand each the drawer's dismissal
294        // paths to wire the default `CloseButton` with, regardless of
295        // `is_dismissible`.
296        let mut footer_els: Vec<AnyElement> = std::mem::take(&mut self.footer)
297            .into_iter()
298            .map(|(child, _)| child)
299            .collect();
300        let mut close_triggers = crate::modal::take_close_triggers::<DrawerCloseTrigger>(
301            &mut self.body,
302            dismiss.clone(),
303            &self.id,
304            0,
305        );
306        close_triggers.extend(crate::modal::take_close_triggers::<DrawerCloseTrigger>(
307            &mut footer_els,
308            dismiss.clone(),
309            &self.id,
310            close_triggers.len(),
311        ));
312        // The composed triggers are out of the row; the interactivity mark is
313        // recomputed for what remains.
314        self.footer = footer_els
315            .into_iter()
316            .map(|mut child| {
317                let interactive = child.downcast_mut::<gpui::Stateful<gpui::Div>>().is_some();
318                (child, interactive)
319            })
320            .collect();
321
322        // `.drawer__header` is `flex flex-col gap-3` with no padding of its
323        // own: the dialog's `p-6` is the inset, and the close trigger is
324        // positioned against the dialog rather than sitting in the header.
325        let mut header = gpui::div().flex().flex_col().gap(px(12.));
326        if let Some(title) = &self.title {
327            header = header.child(
328                gpui::div()
329                    .text_size(px(16.))
330                    .line_height(px(24.))
331                    .font_weight(gpui::FontWeight::MEDIUM)
332                    .child(title.to_string()),
333            );
334        }
335        let has_header = self.title.is_some();
336
337        if self.is_dismissible {
338            let axis = self.placement;
339            let held_down = drag.clone();
340            header = header.on_mouse_down(
341                gpui::MouseButton::Left,
342                move |ev: &gpui::MouseDownEvent, _window, cx| {
343                    let at = drag_axis_position(axis, ev.position);
344                    held_down.update(cx, |v, cx| {
345                        *v = Some(DragState {
346                            start: at,
347                            offset: 0.,
348                            last: at,
349                            last_at: Instant::now(),
350                            velocity: 0.,
351                            active: false,
352                        });
353                        cx.notify();
354                    });
355                },
356            );
357        }
358
359        let has_body = !self.body.is_empty();
360        let mut handle = gpui::div().flex().items_center().justify_center().child(
361            gpui::div()
362                .h(px(4.))
363                .w(px(36.))
364                .rounded(crate::util::hairline_radius(cx))
365                .bg(colors.separator),
366        );
367        if self.is_dismissible {
368            let axis = self.placement;
369            let held_down = drag.clone();
370            handle = handle.on_mouse_down(
371                gpui::MouseButton::Left,
372                move |ev: &gpui::MouseDownEvent, _window, cx| {
373                    let at = drag_axis_position(axis, ev.position);
374                    held_down.update(cx, |v, cx| {
375                        *v = Some(DragState {
376                            start: at,
377                            offset: 0.,
378                            last: at,
379                            last_at: Instant::now(),
380                            velocity: 0.,
381                            active: false,
382                        });
383                        cx.notify();
384                    });
385                },
386            );
387        }
388        // The stylesheet keeps the base handle's bottom padding for the
389        // bottom sheet. The top sheet overrides the dialog's bottom padding
390        // and removes the handle padding, leaving the handle flush with the
391        // moving top edge while preserving the 8px sheet inset below content.
392        let handle = match self.placement {
393            DrawerPlacement::Top => handle.pb(px(0.)),
394            _ => handle.pb(px(8.)),
395        };
396        let measured_bounds = panel_bounds.clone();
397        let mut panel = gpui::div()
398            // `drawer/drawer.js` is the modal's anatomy on an edge: RAC
399            // `Modal`/`ModalOverlay` around a `Dialog`, so
400            // `react-aria/.../dialog/useDialog.js` gives it the same
401            // `role="dialog"` named by the composed `Heading`.
402            .id(element_id::scoped(&self.id, "dialog"))
403            .a11y_named(a11y::Role::Dialog, &a11y::Name::maybe(self.title.clone()))
404            .relative()
405            .flex()
406            .flex_col()
407            .p(px(24.))
408            .when(self.placement == DrawerPlacement::Top, |panel| {
409                panel.pb(px(8.))
410            })
411            .bg(colors.overlay.background)
412            .text_color(colors.foreground)
413            .shadow(cx.layout().overlay_shadow.clone())
414            .when(self.placement == DrawerPlacement::Bottom, |panel| {
415                panel
416                    .rounded_tl(cx.layout().radius_2xl().min(px(32.)))
417                    .rounded_tr(cx.layout().radius_2xl().min(px(32.)))
418            })
419            .when(self.placement == DrawerPlacement::Top, |panel| {
420                panel
421                    .rounded_bl(cx.layout().radius_2xl().min(px(32.)))
422                    .rounded_br(cx.layout().radius_2xl().min(px(32.)))
423            })
424            .overflow_hidden()
425            // `.drawer__handle` is `flex items-center justify-center pb-2` with
426            // an `h-1 w-9 rounded-xs bg-separator` bar: the affordance that says
427            // the sheet can be dragged shut. `--top` moves it to the bottom
428            // edge, which is the edge that moves.
429            .child(
430                gpui::canvas(
431                    move |bounds, _, cx| {
432                        measured_bounds.update(cx, |value, cx| {
433                            if *value != bounds {
434                                *value = bounds;
435                                cx.notify();
436                            }
437                        });
438                    },
439                    |_, _, _, _| {},
440                )
441                .absolute()
442                .inset_0(),
443            )
444            .child(handle)
445            .when(has_header, |p| p.child(header))
446            .when(has_body, |p| {
447                p.child(
448                    gpui::div()
449                        .id(crate::modal::dialog_key(&self.id, "body-scroll"))
450                        .flex()
451                        .flex_col()
452                        .flex_1()
453                        .min_h(px(0.))
454                        .mx(px(-3.))
455                        .p(px(3.))
456                        // `.drawer__header + .drawer__body` is `mt-2`.
457                        .when(has_header, |b| b.mt(px(8.)))
458                        .text_size(px(14.))
459                        // `leading-[1.43]` on `text-sm`.
460                        .line_height(px(20.))
461                        .text_color(colors.muted)
462                        // `.drawer__body` is the native scrolling surface;
463                        // keeping the drag start listeners on its siblings
464                        // leaves wheel and pointer scrolling conflict-free.
465                        .overflow_y_scroll()
466                        .children(self.body),
467                )
468            });
469
470        // `.drawer__footer` has no border: the separator this used to draw is
471        // not in v3's sheet, and `+ .drawer__footer` is `mt-5`.
472        if !self.footer.is_empty() {
473            // GPUI's `id().on_click(...)` divs are `Stateful<Div>` elements,
474            // while a plain `Div` has no hitbox. Mark only that stateful div
475            // shape as an interactive footer descendant; ordinary full-width
476            // content remains on the drawer drag surface.
477            let footer_content = gpui::div().flex().items_center().gap(px(8.)).children(
478                self.footer.into_iter().map(|(child, interactive)| {
479                    if interactive {
480                        gpui::div()
481                            .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
482                                cx.stop_propagation();
483                            })
484                            .child(child)
485                            .into_any_element()
486                    } else {
487                        gpui::div().child(child).into_any_element()
488                    }
489                }),
490            );
491            let mut footer = gpui::div()
492                .flex()
493                .items_center()
494                .justify_end()
495                .when(has_header || has_body, |f| f.mt(px(20.)));
496            if self.is_dismissible {
497                let axis = self.placement;
498                let held_down = drag.clone();
499                footer = footer.on_mouse_down(
500                    gpui::MouseButton::Left,
501                    move |ev: &gpui::MouseDownEvent, window, cx| {
502                        if window.default_prevented() {
503                            return;
504                        }
505                        let at = drag_axis_position(axis, ev.position);
506                        held_down.update(cx, |v, cx| {
507                            *v = Some(DragState {
508                                start: at,
509                                offset: 0.,
510                                last: at,
511                                last_at: Instant::now(),
512                                velocity: 0.,
513                                active: false,
514                            });
515                            cx.notify();
516                        });
517                    },
518                );
519            }
520            panel = panel.child(footer.child(footer_content));
521        }
522
523        // `.drawer__close-trigger` is `absolute end-4 top-4`. v3 renders a
524        // close affordance only where the caller composes the part; an
525        // omitted trigger leaves the spot bare panel padding.
526        for trigger in close_triggers {
527            panel = panel.child(
528                gpui::div()
529                    .absolute()
530                    .top(px(16.))
531                    .right(px(16.))
532                    .child(trigger),
533            );
534        }
535
536        // anchor to the requested edge, pulled out by however far the drag has
537        // come: a div cannot be translated in this gpui, so the inset moves.
538        let pulled = px(-drag_offset);
539        let viewport = window.viewport_size();
540        let side_extent = SIDE_EXTENT.min(viewport.width * 0.85);
541        // `.drawer__content` is the positioned box the dialog slides in, and its
542        // `--top`/`--bottom`/`--left`/`--right` variants are this match.
543        let anchored = match self.placement {
544            DrawerPlacement::Left => panel
545                .w(side_extent)
546                .max_w(viewport.width * 0.85)
547                .h_full()
548                .absolute()
549                .left(pulled)
550                .top(px(0.))
551                .bottom(px(0.)),
552            DrawerPlacement::Right => panel
553                .w(side_extent)
554                .max_w(viewport.width * 0.85)
555                .h_full()
556                .absolute()
557                .right(pulled)
558                .top(px(0.))
559                .bottom(px(0.)),
560            DrawerPlacement::Top => panel
561                .max_h(viewport.height * 0.85)
562                .w_full()
563                .absolute()
564                .top(pulled)
565                .left(px(0.))
566                .right(px(0.)),
567            DrawerPlacement::Bottom => panel
568                .max_h(viewport.height * 0.85)
569                .w_full()
570                .absolute()
571                .bottom(pulled)
572                .left(px(0.))
573                .right(px(0.)),
574        };
575
576        // Backdrop dismissal lives on the **panel**, not on the backdrop: gpui
577        // has no hitbox occlusion, so an `on_click` on the full-window
578        // backdrop fired for a press on this sheet as well, and any pull at
579        // all — even a sub-threshold one — reported a close. `on_mouse_down_out`
580        // reads the panel's own bounds instead, so it only fires for a press
581        // on the dimmed region around the sheet. `is_dismissible` gates it,
582        // and the exit phase gets none: the drawer is already closing.
583        let anchored = if self.is_dismissible && !exiting {
584            if let Some(on_close) = press_out_dismiss {
585                crate::util::dismiss_on_press_outside_with_token(
586                    anchored,
587                    dismissal_token.clone(),
588                    move |window, cx| {
589                        on_close(&DismissReason::Backdrop, window, cx);
590                        crate::util::DismissResult::Handled
591                    },
592                )
593            } else {
594                anchored
595            }
596        } else {
597            anchored
598        };
599
600        // v3 backdrop variants; gpui has no backdrop-filter, so `Blur` renders a
601        // lighter scrim than `Opaque` to keep the layering readable.
602        let backdrop_bg = match self.backdrop {
603            Backdrop::Opaque => colors.backdrop,
604            Backdrop::Blur => colors.backdrop.alpha(colors.backdrop.a * 0.6),
605            Backdrop::Transparent => gpui::transparent_black(),
606        };
607
608        let keyboard_dismiss = if self.is_keyboard_dismiss_disabled {
609            None
610        } else {
611            dismiss.clone()
612        };
613
614        // `Tab` cycles the drawer's own controls; see `util::trap_tab`.
615        let mut overlay = crate::util::trap_tab(
616            gpui::div().absolute().inset_0().track_focus(&focus_handle),
617            &focus_handle,
618        );
619        if let Some(on_escape) = keyboard_dismiss {
620            overlay = crate::util::dismiss_on_escape_with_token(
621                overlay,
622                dismissal_token,
623                move |window, cx| {
624                    on_escape(&DismissReason::Escape, window, cx);
625                    crate::util::DismissResult::Handled
626                },
627            );
628        }
629
630        let measured_extent = match self.placement {
631            DrawerPlacement::Left | DrawerPlacement::Right => {
632                f32::from(panel_bounds.read(cx).size.width)
633            }
634            DrawerPlacement::Top | DrawerPlacement::Bottom => {
635                f32::from(panel_bounds.read(cx).size.height)
636            }
637        };
638        let fallback_extent = match self.placement {
639            DrawerPlacement::Left | DrawerPlacement::Right => f32::from(side_extent),
640            DrawerPlacement::Top | DrawerPlacement::Bottom => f32::from(viewport.height * 0.85),
641        };
642        let dismiss_extent = if measured_extent > 0. {
643            measured_extent
644        } else {
645            fallback_extent
646        };
647
648        // GPUI's ordinary mouse-move listeners are bubble-phase and require
649        // their hitbox to remain hovered. Registering the move/up listeners in
650        // the canvas paint callback makes them capture-phase window listeners,
651        // so a pull can continue past the moving edge and release outside it.
652        if self.is_dismissible {
653            let axis = self.placement;
654            let held_move = drag.clone();
655            let held_up = drag;
656            let release = dismiss.clone();
657            let global_drag = gpui::canvas(
658                |_, _, _| (),
659                move |_, _, window, _| {
660                    let held_move = held_move.clone();
661                    window.on_mouse_event(move |ev: &gpui::MouseMoveEvent, phase, _window, cx| {
662                        if phase != gpui::DispatchPhase::Capture {
663                            return;
664                        }
665                        let Some(state) = *held_move.read(cx) else {
666                            return;
667                        };
668                        let at = drag_axis_position(axis, ev.position);
669                        let next = match axis {
670                            DrawerPlacement::Left | DrawerPlacement::Top => state.start - at,
671                            DrawerPlacement::Right | DrawerPlacement::Bottom => at - state.start,
672                        }
673                        .max(0.0);
674                        let now = Instant::now();
675                        let elapsed_ms = now.duration_since(state.last_at).as_secs_f32() * 1000.;
676                        let movement = (at - state.last).abs();
677                        let velocity = if next > state.offset && elapsed_ms > 0. {
678                            movement / elapsed_ms
679                        } else {
680                            0.
681                        };
682                        let active = state.active || next >= DRAG_ACTIVATION;
683                        let offset = if active { next } else { 0. };
684                        if (offset - state.offset).abs() >= 1.
685                            || active != state.active
686                            || velocity > 0.
687                        {
688                            held_move.update(cx, |value, cx| {
689                                *value = Some(DragState {
690                                    start: state.start,
691                                    offset,
692                                    last: at,
693                                    last_at: now,
694                                    velocity,
695                                    active,
696                                });
697                                cx.notify();
698                            });
699                        }
700                    });
701
702                    let held_up = held_up.clone();
703                    window.on_mouse_event(move |ev: &gpui::MouseUpEvent, phase, window, cx| {
704                        if phase != gpui::DispatchPhase::Capture
705                            || ev.button != gpui::MouseButton::Left
706                        {
707                            return;
708                        }
709                        let Some(state) = *held_up.read(cx) else {
710                            return;
711                        };
712                        held_up.update(cx, |value, cx| {
713                            *value = None;
714                            cx.notify();
715                        });
716                        if state.active
717                            && (state.offset >= dismiss_extent * DRAG_DISMISS_FRACTION
718                                || state.velocity > DRAG_VELOCITY)
719                        {
720                            if let Some(f) = &release {
721                                f(&DismissReason::Drag, window, cx);
722                            }
723                        }
724                    });
725                },
726            )
727            .absolute()
728            .inset_0();
729            overlay = overlay.child(global_drag);
730        }
731        // `.drawer__backdrop` (its `--opaque`/`--blur`/`--transparent`
732        // variants are the Backdrop enum) is a bare scrim — dimmed but
733        // press-less, exactly like the modal's: the panel's `on_mouse_down_out`
734        // dismisses on a press outside the sheet, and a scrim with its own
735        // click listener would only double-report. v3 fades it in alongside
736        // the panel.
737        let scrim = gpui::div()
738            .id(element_id::scoped(&self.id, "backdrop"))
739            .absolute()
740            .inset_0()
741            .bg(backdrop_bg);
742        overlay = overlay.child(if exiting {
743            crate::anim::exiting(
744                scrim,
745                "drawer-backdrop-out",
746                crate::anim::ZoomBox::default(),
747                crate::anim::Motion::DRAWER_BACKDROP_OUT,
748                cx,
749            )
750        } else {
751            crate::anim::entering(
752                scrim,
753                "drawer-backdrop-anim",
754                crate::anim::Motion::DRAWER_BACKDROP_IN,
755                cx,
756            )
757        });
758        // Drawers slide in from the edge they are anchored to.
759        let edge = match self.placement {
760            DrawerPlacement::Left => crate::anim::Edge::Left,
761            DrawerPlacement::Right => crate::anim::Edge::Right,
762            DrawerPlacement::Top => crate::anim::Edge::Top,
763            DrawerPlacement::Bottom => crate::anim::Edge::Bottom,
764        };
765        // A pulled panel is being placed by the drag, so it must not also be
766        // running its entry animation from the same edge.
767        overlay = overlay.child(if exiting {
768            crate::anim::exiting_to(
769                anchored,
770                "drawer-panel-out",
771                edge,
772                px(dismiss_extent),
773                crate::anim::Motion::DRAWER_OUT,
774                cx,
775            )
776        } else {
777            crate::anim::entering_from(
778                anchored,
779                "drawer-panel",
780                edge,
781                px(dismiss_extent),
782                crate::anim::Motion::DRAWER_IN,
783                cx,
784            )
785        });
786
787        overlay = crate::util::apply_sx(overlay, &self.sx);
788        crate::util::window_overlay(overlay, window).into_any_element()
789    }
790}
791
792/// Where a pointer sits along the axis a drawer is dismissed on.
793fn drag_axis_position(placement: DrawerPlacement, at: gpui::Point<gpui::Pixels>) -> f32 {
794    match placement {
795        DrawerPlacement::Left | DrawerPlacement::Right => f32::from(at.x),
796        DrawerPlacement::Top | DrawerPlacement::Bottom => f32::from(at.y),
797    }
798}
799
800crate::util::impl_component_styled!(Drawer);