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);