1use super::dialog::{
28 OverlayFrame, container_state, header_icon_button, modal_focus, overlay_area, overlay_frame,
29 place_host, scrim, with_container_probe,
30};
31use crate::{
32 color::ColorRole,
33 icons::{Icon, IconName, Style, Symbol},
34 motion::{self, MotionToken},
35 theme::color,
36 tokens::{
37 elevation,
38 typography::{TypeRole, rem},
39 },
40};
41use gpui::{prelude::*, *};
42use std::{rc::Rc, sync::OnceLock};
43
44#[cfg(not(target_family = "wasm"))]
45use std::time::Instant;
46#[cfg(target_family = "wasm")]
47use web_time::Instant;
48
49type Handler = Rc<dyn Fn(&mut Window, &mut App)>;
50type DetentHandler = Rc<dyn Fn(BottomSheetDetent, &mut Window, &mut App)>;
51
52const SIDE_SHEET_RADIUS: Pixels = px(16.);
54const BOTTOM_SHEET_RADIUS: Pixels = px(28.);
55const BOTTOM_SHEET_MAX_WIDTH: f32 = 640.;
57const DRAG_SLOP: f32 = 4.;
59
60fn typed<E: Styled>(element: E, role: TypeRole) -> E {
61 let scale = role.scale();
62 element
63 .font_family(scale.font_family)
64 .text_size(scale.font_size)
65 .line_height(scale.line_height)
66 .font_weight(FontWeight(scale.font_weight as f32))
67}
68
69fn arrow_back() -> Icon {
70 static PATH: OnceLock<String> = OnceLock::new();
71 Icon::new(SharedString::new_static(
72 PATH.get_or_init(|| {
73 Symbol::named("arrow_back")
74 .expect("a Material Symbol")
75 .icon()
76 .style(Style::Rounded)
77 .asset_path()
78 })
79 .as_str(),
80 ))
81}
82
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85pub enum SideSheetEdge {
86 Start,
87 #[default]
89 End,
90}
91
92fn sheet_hidden(open: bool, reveal: f32) -> bool {
94 !open && reveal == 0.
95}
96
97#[derive(gpui::IntoElement)]
103pub struct MaterialSideSheet {
104 id: ElementId,
105 open: bool,
106 modal: bool,
107 specimen: bool,
108 edge: SideSheetEdge,
109 contained: bool,
110 dismiss_on_scrim: bool,
111 dismiss_on_escape: bool,
112 width: Rems,
113 headline: Option<SharedString>,
114 label: Option<SharedString>,
115 back: Option<(SharedString, Handler)>,
116 close_label: Option<SharedString>,
117 actions: Vec<AnyElement>,
118 on_dismiss: Option<Handler>,
119 children: Vec<AnyElement>,
120}
121
122impl MaterialSideSheet {
123 pub fn new(id: impl Into<ElementId>) -> Self {
124 Self {
125 id: id.into(),
126 open: false,
127 modal: false,
128 specimen: false,
129 edge: SideSheetEdge::End,
130 contained: false,
131 dismiss_on_scrim: true,
132 dismiss_on_escape: true,
133 width: rem(320.),
134 headline: None,
135 label: None,
136 back: None,
137 close_label: None,
138 actions: Vec::new(),
139 on_dismiss: None,
140 children: vec![],
141 }
142 }
143 pub fn open(mut self, open: bool) -> Self {
144 self.open = open;
145 self
146 }
147 pub fn width(mut self, width: Rems) -> Self {
148 self.width = width;
149 self
150 }
151 pub fn modal(mut self, modal: bool) -> Self {
154 self.modal = modal;
155 self
156 }
157 pub fn specimen(mut self, specimen: bool) -> Self {
159 self.specimen = specimen;
160 self
161 }
162 pub fn edge(mut self, edge: SideSheetEdge) -> Self {
164 self.edge = edge;
165 self
166 }
167 pub fn contained(mut self, contained: bool) -> Self {
170 self.contained = contained;
171 self
172 }
173 pub fn dismiss_on_scrim(mut self, dismiss: bool) -> Self {
175 self.dismiss_on_scrim = dismiss;
176 self
177 }
178 pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
180 self.dismiss_on_escape = dismiss;
181 self
182 }
183 pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
185 self.headline = Some(headline.into());
186 self
187 }
188 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
191 self.label = Some(label.into());
192 self
193 }
194 pub fn back(
196 mut self,
197 label: impl Into<SharedString>,
198 on_back: impl Fn(&mut Window, &mut App) + 'static,
199 ) -> Self {
200 self.back = Some((label.into(), Rc::new(on_back)));
201 self
202 }
203 pub fn close_button(mut self, label: impl Into<SharedString>) -> Self {
205 self.close_label = Some(label.into());
206 self
207 }
208 pub fn action(mut self, action: impl IntoElement) -> Self {
210 self.actions.push(action.into_any_element());
211 self
212 }
213 pub fn on_dismiss(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
214 self.on_dismiss = Some(Rc::new(f));
215 self
216 }
217}
218
219impl ParentElement for MaterialSideSheet {
220 fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
221 self.children.extend(children);
222 }
223}
224
225impl MaterialSideSheet {
226 fn has_header(&self) -> bool {
227 self.headline.is_some() || self.back.is_some() || self.close_label.is_some()
228 }
229
230 fn body(self) -> Div {
232 let has_header = self.has_header();
233 let dismiss = self.on_dismiss.clone();
234 let back = self.back;
235 let header = has_header.then(|| {
236 div()
237 .flex()
238 .items_start()
239 .pt(rem(12.))
240 .pr(rem(12.))
241 .pb(rem(16.))
242 .pl(rem(if back.is_some() { 4. } else { 24. }))
243 .when_some(back, |header, (label, on_back)| {
244 header.child(header_icon_button(
245 "sheet-back",
246 arrow_back(),
247 label,
248 move |_, window, cx| on_back(window, cx),
249 ))
250 })
251 .child(div().flex_1().min_w_0().h(rem(40.)).pt(rem(12.)).when_some(
252 self.headline,
253 |content, headline| {
254 content.child(
255 typed(div(), TypeRole::TitleLarge)
256 .truncate()
257 .text_color(color(ColorRole::ON_SURFACE_VARIANT))
258 .child(headline),
259 )
260 },
261 ))
262 .when_some(self.close_label, |header, label| {
263 header.child(header_icon_button(
264 "sheet-close",
265 IconName::Close.icon(),
266 label,
267 move |_, window, cx| {
268 if let Some(dismiss) = &dismiss {
269 dismiss(window, cx);
270 }
271 },
272 ))
273 })
274 });
275 let has_actions = !self.actions.is_empty();
276 div()
277 .flex()
278 .flex_col()
279 .size_full()
280 .when(has_actions, |body| body.pb(rem(20.)))
281 .children(header)
282 .child(
283 div()
284 .flex()
285 .flex_col()
286 .flex_1()
287 .min_h_0()
288 .children(self.children),
289 )
290 .when(has_actions, |body| {
291 body.child(
292 div()
293 .h(px(1.))
294 .w_full()
295 .bg(color(ColorRole::OUTLINE_VARIANT)),
296 )
297 .child(
298 div()
299 .flex()
300 .items_center()
301 .gap(rem(8.))
302 .pt(rem(12.))
303 .px(rem(24.))
304 .children(self.actions),
305 )
306 })
307 }
308
309 fn modal_surface(self, corners: Corners<Pixels>) -> Div {
311 let width = self.width;
312 div()
313 .w(width)
314 .h_full()
315 .overflow_hidden()
316 .rounded_tl(corners.top_left)
317 .rounded_tr(corners.top_right)
318 .rounded_br(corners.bottom_right)
319 .rounded_bl(corners.bottom_left)
320 .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
321 .child(self.body())
322 }
323
324 fn standard_surface(self) -> Div {
327 let start = self.edge == SideSheetEdge::Start;
328 div()
329 .size_full()
330 .flex()
331 .when(start, |sheet| sheet.flex_row_reverse())
332 .bg(color(ColorRole::SURFACE))
333 .child(
334 div()
335 .w(px(1.))
336 .h_full()
337 .flex_shrink_0()
338 .bg(color(ColorRole::OUTLINE_VARIANT)),
339 )
340 .child(div().flex_1().min_w_0().h_full().child(self.body()))
341 }
342}
343
344impl RenderOnce for MaterialSideSheet {
345 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
346 if self.specimen {
347 let width = self.width;
348 let id = self.id.clone();
349 return if self.modal {
350 div()
351 .id(id)
352 .h_full()
353 .child(self.modal_surface(Corners::all(SIDE_SHEET_RADIUS)))
354 .into_any_element()
355 } else {
356 div()
357 .id(id)
358 .w(width)
359 .h_full()
360 .child(self.standard_surface())
361 .into_any_element()
362 };
363 }
364 let start = self.edge == SideSheetEdge::Start;
365 if self.modal {
366 let id = self.id.clone();
367 let open = self.open;
368 let container = self.contained.then(|| container_state(&id, window, cx));
369 let frame = overlay_frame(&id, open, window, cx);
370 let focus = modal_focus(&id, open, window, cx);
371 if frame.settled_closed(open) {
372 return with_container_probe(container, div().into_any_element());
373 }
374 let Some(area) = overlay_area(container.as_ref(), window, cx) else {
375 return with_container_probe(container, div().into_any_element());
376 };
377 let width = self.width.to_pixels(window.rem_size());
378 let dismiss = self.on_dismiss.clone().filter(|_| open);
379 let dismissals = Dismissals {
380 escape: dismiss.clone().filter(|_| self.dismiss_on_escape),
381 scrim: dismiss.filter(|_| self.dismiss_on_scrim),
382 };
383 let name = self.label.clone().or_else(|| self.headline.clone());
384 let corners = if start {
385 Corners {
386 top_right: SIDE_SHEET_RADIUS,
387 bottom_right: SIDE_SHEET_RADIUS,
388 ..Corners::default()
389 }
390 } else {
391 Corners {
392 top_left: SIDE_SHEET_RADIUS,
393 bottom_left: SIDE_SHEET_RADIUS,
394 ..Corners::default()
395 }
396 };
397 let inset = width * (frame.spatial - 1.);
398 let surface = div()
399 .id(id)
400 .role(Role::Dialog)
401 .when_some(name, |surface, name| surface.aria_label(name))
402 .absolute()
403 .top_0()
404 .bottom_0()
405 .map(|surface| {
406 if start {
407 surface.left(inset)
408 } else {
409 surface.right(inset)
410 }
411 })
412 .opacity(frame.opacity())
413 .occlude()
414 .child(self.modal_surface(corners));
415 let area = container.is_some().then_some(area);
416 return with_container_probe(
417 container,
418 modal_host(focus, frame, dismissals, area, surface).into_any_element(),
419 );
420 }
421 let reveal = motion::value(
422 (self.id.clone(), "reveal"),
423 if self.open { 1. } else { 0. },
424 window,
425 cx,
426 );
427 let progress = reveal.clamp(0., 1.);
428 let width = self.width.to_pixels(window.rem_size());
429 let hidden = sheet_hidden(self.open, reveal);
430 let id = self.id.clone();
431 let inset = width * (progress - 1.);
432 div()
433 .id(id)
434 .absolute()
435 .top_0()
436 .bottom_0()
437 .map(|sheet| {
438 if start {
439 sheet.left(inset)
440 } else {
441 sheet.right(inset)
442 }
443 })
444 .w(width)
445 .opacity(progress)
446 .when(!hidden, |sheet| sheet.child(self.standard_surface()))
450 .into_any_element()
451 }
452}
453
454struct Dismissals {
456 escape: Option<Handler>,
457 scrim: Option<Handler>,
458}
459
460fn modal_host(
463 focus: FocusHandle,
464 frame: OverlayFrame,
465 dismissals: Dismissals,
466 area: Option<Bounds<Pixels>>,
467 surface: impl IntoElement,
468) -> impl IntoElement {
469 deferred(
470 ModalSheetHost {
471 focus,
472 fade: frame.fade,
473 dismissals,
474 area,
475 surface: surface.into_any_element(),
476 }
477 .into_any_element(),
478 )
479 .with_priority(10)
480}
481
482#[derive(gpui::IntoElement)]
483struct ModalSheetHost {
484 focus: FocusHandle,
485 fade: f32,
486 dismissals: Dismissals,
487 area: Option<Bounds<Pixels>>,
488 surface: AnyElement,
489}
490
491impl RenderOnce for ModalSheetHost {
492 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
493 let Dismissals {
494 escape,
495 scrim: on_scrim,
496 } = self.dismissals;
497 let overlay = scrim(self.fade).on_mouse_down(MouseButton::Left, move |_, window, cx| {
501 cx.stop_propagation();
502 if let Some(dismiss) = &on_scrim {
503 dismiss(window, cx);
504 }
505 });
506 let host = gpui_base::Sheet::new(cx)
507 .focus_handle(self.focus)
508 .overlay_interactive(false)
509 .overlay_closable(false)
510 .on_close(move |_, window, cx| {
511 if let Some(dismiss) = &escape {
512 dismiss(window, cx);
513 }
514 })
515 .overlay(overlay)
516 .surface(self.surface);
517 match self.area {
518 Some(area) => place_host(host, area),
519 None => host,
520 }
521 }
522}
523
524#[derive(Clone, Copy, Debug, PartialEq)]
526struct Drag {
527 start: f32,
528 offset: f32,
529 moved: bool,
530}
531
532#[derive(Clone, Copy, Debug)]
534struct Settle {
535 from: f32,
536 started: Instant,
537}
538
539struct BottomSheetState {
540 height: Pixels,
541 measured: bool,
544 expanded: bool,
545 applied: Option<BottomSheetDetent>,
547 dragged: bool,
550 drag: Option<Drag>,
551 settle: Option<Settle>,
552}
553
554#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
556pub enum BottomSheetDetent {
557 #[default]
559 Hidden,
560 Partial,
563 Expanded,
564}
565
566#[derive(Clone, Copy, Debug, PartialEq, Eq)]
568pub(crate) enum SnapTarget {
569 Expanded,
570 Partial,
571 Dismiss,
572}
573
574pub(crate) fn snap_target(position: f32, height: f32, partial: Option<f32>) -> SnapTarget {
577 let mut best = (SnapTarget::Expanded, position.abs());
578 if let Some(partial) = partial.filter(|&p| p < height) {
579 let at = height - partial;
580 if (position - at).abs() < best.1 {
581 best = (SnapTarget::Partial, (position - at).abs());
582 }
583 }
584 if (position - height).abs() < best.1 {
585 best = (SnapTarget::Dismiss, 0.);
586 }
587 best.0
588}
589
590#[derive(gpui::IntoElement)]
595pub struct MaterialBottomSheet {
596 id: ElementId,
597 open: Option<bool>,
598 detent: Option<BottomSheetDetent>,
599 modal: bool,
600 contained: bool,
601 dismiss_on_scrim: bool,
602 dismiss_on_escape: bool,
603 label: Option<SharedString>,
604 drag_handle: Option<SharedString>,
605 partial: Option<Rems>,
606 max_width: Rems,
607 on_dismiss: Option<Handler>,
608 on_detent_change: Option<DetentHandler>,
609 children: Vec<AnyElement>,
610}
611
612impl MaterialBottomSheet {
613 pub fn new(id: impl Into<ElementId>) -> Self {
614 Self {
615 id: id.into(),
616 open: None,
617 detent: None,
618 modal: false,
619 contained: false,
620 dismiss_on_scrim: true,
621 dismiss_on_escape: true,
622 label: None,
623 drag_handle: None,
624 partial: None,
625 max_width: rem(BOTTOM_SHEET_MAX_WIDTH),
626 on_dismiss: None,
627 on_detent_change: None,
628 children: Vec::new(),
629 }
630 }
631 pub fn open(mut self, open: bool) -> Self {
634 self.open = Some(open);
635 self
636 }
637 pub fn detent(mut self, detent: BottomSheetDetent) -> Self {
641 self.detent = Some(detent);
642 self.open = Some(detent != BottomSheetDetent::Hidden);
643 self
644 }
645 pub fn on_detent_change(
648 mut self,
649 f: impl Fn(BottomSheetDetent, &mut Window, &mut App) + 'static,
650 ) -> Self {
651 self.on_detent_change = Some(Rc::new(f));
652 self
653 }
654 pub fn modal(mut self, modal: bool) -> Self {
655 self.modal = modal;
656 self
657 }
658 pub fn contained(mut self, contained: bool) -> Self {
661 self.contained = contained;
662 self
663 }
664 pub fn dismiss_on_scrim(mut self, dismiss: bool) -> Self {
666 self.dismiss_on_scrim = dismiss;
667 self
668 }
669 pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
671 self.dismiss_on_escape = dismiss;
672 self
673 }
674 pub fn max_width(mut self, width: Rems) -> Self {
676 self.max_width = width;
677 self
678 }
679 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
681 self.label = Some(label.into());
682 self
683 }
684 pub fn drag_handle(mut self, label: impl Into<SharedString>) -> Self {
687 self.drag_handle = Some(label.into());
688 self
689 }
690 pub fn partial_height(mut self, height: Rems) -> Self {
693 self.partial = Some(height);
694 self
695 }
696 pub fn on_dismiss(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
698 self.on_dismiss = Some(Rc::new(f));
699 self
700 }
701}
702
703impl ParentElement for MaterialBottomSheet {
704 fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
705 self.children.extend(children);
706 }
707}
708
709fn drag_handle_bar() -> Div {
710 div()
711 .w(rem(32.))
712 .h(rem(4.))
713 .rounded_full()
714 .bg(color(ColorRole::OUTLINE))
715}
716
717impl MaterialBottomSheet {
718 fn surface(
722 modal: bool,
723 max_width: Rems,
724 drag_layer: Option<AnyElement>,
725 handle: Option<AnyElement>,
726 children: Vec<AnyElement>,
727 ) -> Div {
728 div()
729 .relative()
730 .w_full()
731 .max_w(max_width)
732 .flex()
733 .flex_col()
734 .items_center()
735 .rounded_t(BOTTOM_SHEET_RADIUS)
736 .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
737 .when(!modal, |sheet| sheet.shadow(elevation::level1()))
738 .children(drag_layer)
739 .children(handle)
740 .child(
741 div()
742 .w_full()
743 .flex()
744 .flex_col()
745 .block_mouse_except_scroll()
746 .children(children),
747 )
748 }
749}
750
751fn settle_progress(elapsed: f32, reduced: bool) -> f32 {
753 if reduced {
754 return 1.;
755 }
756 let curve = motion::curve(MotionToken::DefaultSpatial);
757 let duration = curve.duration().as_secs_f32().max(f32::EPSILON);
758 if elapsed >= duration {
759 1.
760 } else {
761 curve.ease()(elapsed / duration)
762 }
763}
764
765impl RenderOnce for MaterialBottomSheet {
766 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
767 let modal = self.modal;
768 let max_width = self.max_width;
769 let Some(open) = self.open else {
770 let handle = self.drag_handle.map(|_| {
771 div()
772 .w_full()
773 .flex()
774 .justify_center()
775 .py(rem(16.))
776 .child(drag_handle_bar())
777 .into_any_element()
778 });
779 return div()
780 .id(self.id)
781 .w_full()
782 .h_full()
783 .flex()
784 .flex_col()
785 .justify_end()
786 .items_center()
787 .child(Self::surface(modal, max_width, None, handle, self.children).flex_1())
788 .into_any_element();
789 };
790 let id = self.id.clone();
791 let container = (modal && self.contained).then(|| container_state(&id, window, cx));
792 let frame = overlay_frame(&id, open, window, cx);
793 let focus = modal.then(|| modal_focus(&id, open, window, cx));
794 let state = window.use_keyed_state((id.clone(), "bottom-sheet"), cx, |window, _| {
795 BottomSheetState {
796 height: window.viewport_size().height,
797 measured: false,
798 expanded: false,
799 applied: None,
800 dragged: false,
801 drag: None,
802 settle: None,
803 }
804 });
805 if let Some(detent) = self.detent
807 && state.read(cx).applied != Some(detent)
808 {
809 state.update(cx, |state, _| {
810 state.applied = Some(detent);
811 if detent != BottomSheetDetent::Hidden {
812 state.expanded = detent == BottomSheetDetent::Expanded;
813 state.drag = None;
814 state.settle = None;
815 }
816 });
817 }
818 if frame.settled_closed(open) {
819 state.update(cx, |state, _| {
820 state.expanded = false;
821 state.drag = None;
822 state.settle = None;
823 });
824 return with_container_probe(container, div().into_any_element());
825 }
826 let Some(area) = overlay_area(container.as_ref(), window, cx) else {
827 return with_container_probe(container, div().into_any_element());
828 };
829 let on_detent = self.on_detent_change.clone();
830 let reduced = cx.reduce_motion();
831 let partial = self
832 .partial
833 .map(|partial| f32::from(partial.to_pixels(window.rem_size())));
834 let (height, measured, expanded, drag, settle) = {
835 let s = state.read(cx);
836 (
837 f32::from(s.height),
838 s.measured,
839 s.expanded,
840 s.drag,
841 s.settle,
842 )
843 };
844 let visible = match partial {
845 Some(partial) if !expanded => partial.min(height),
846 _ => height,
847 };
848 let rest = height - visible;
849 let residual = match (drag, settle) {
850 (Some(drag), _) if drag.moved => drag.offset,
851 (_, Some(settle)) => {
852 let t = settle_progress(settle.started.elapsed().as_secs_f32(), reduced);
853 if t >= 1. {
854 state.update(cx, |state, _| state.settle = None);
855 0.
856 } else {
857 window.request_animation_frame();
858 settle.from * (1. - t)
859 }
860 }
861 _ => 0.,
862 };
863 let position = (rest + residual).max(0.);
865 let offset = position + (1. - frame.spatial) * height;
866 let report = on_detent.clone();
867 let dismiss: Option<Handler> = open
868 .then(|| -> Option<Handler> {
869 match (self.on_dismiss.clone(), report) {
870 (None, None) => None,
871 (dismiss, report) => Some(Rc::new(move |window: &mut Window, cx: &mut App| {
872 if let Some(dismiss) = &dismiss {
873 dismiss(window, cx);
874 }
875 if let Some(report) = &report {
876 report(BottomSheetDetent::Hidden, window, cx);
877 }
878 })),
879 }
880 })
881 .flatten();
882
883 let handle = self.drag_handle.map(|label| {
884 let toggle = state.clone();
885 let press = state.clone();
886 let report = on_detent.clone();
887 div()
888 .w_full()
889 .flex()
890 .justify_center()
891 .child(
892 gpui_base::Button::new("drag-handle")
893 .debug_selector(|| "drag-handle".into())
894 .accessibility_label(label)
895 .w(rem(48.))
896 .h(rem(36.))
897 .flex()
898 .items_center()
899 .justify_center()
900 .cursor_grab()
901 .child(drag_handle_bar())
902 .on_mouse_down(MouseButton::Left, move |event, window, cx| {
905 let y = f32::from(event.position.y);
906 press.update(cx, |state, _| {
907 state.drag = Some(Drag {
908 start: y,
909 offset: 0.,
910 moved: false,
911 });
912 state.dragged = false;
913 state.settle = None;
914 });
915 window.refresh();
916 })
917 .on_click(move |_, window, cx| {
918 if partial.is_some() && !toggle.read(cx).dragged {
919 let expanded = toggle.update(cx, |state, _| {
920 state.expanded = !state.expanded;
921 state.settle = None;
922 state.expanded
923 });
924 window.refresh();
925 if let Some(report) = &report {
926 report(
927 if expanded {
928 BottomSheetDetent::Expanded
929 } else {
930 BottomSheetDetent::Partial
931 },
932 window,
933 cx,
934 );
935 }
936 }
937 }),
938 )
939 .into_any_element()
940 });
941
942 let measure = state.clone();
943 let tracker = state.clone();
944 let release_dismiss = dismiss.clone();
945 let release_report = on_detent.clone();
946 let dragging = drag.is_some();
947 let starter = state.clone();
948 let drag_layer = canvas(
949 |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
950 move |_, hitbox, window, _| {
951 let starter = starter.clone();
952 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
953 if phase != DispatchPhase::Bubble
954 || event.button != MouseButton::Left
955 || !hitbox.is_hovered(window)
956 {
957 return;
958 }
959 let y = f32::from(event.position.y);
960 starter.update(cx, |state, _| {
961 state.drag = Some(Drag {
962 start: y,
963 offset: 0.,
964 moved: false,
965 });
966 state.dragged = false;
967 state.settle = None;
968 });
969 window.refresh();
970 });
971 },
972 )
973 .absolute()
974 .size_full()
975 .into_any_element();
976 let sheet = div()
977 .id(id.clone())
978 .when(modal, |sheet| {
979 sheet
980 .role(Role::Dialog)
981 .when_some(self.label, |sheet, label| sheet.aria_label(label))
982 })
983 .absolute()
984 .left_0()
985 .right_0()
986 .bottom(px(-offset))
987 .flex()
988 .justify_center()
989 .opacity(frame.opacity())
990 .when(!measured, |sheet| sheet.invisible())
993 .occlude()
994 .child(
995 Self::surface(modal, max_width, Some(drag_layer), handle, self.children).child(
996 canvas(
997 |_, _, _| {},
998 move |_, _, window, _| {
999 if !dragging {
1000 return;
1001 }
1002 let moving = tracker.clone();
1003 window.on_mouse_event(
1004 move |event: &MouseMoveEvent, phase, window, cx| {
1005 if phase != DispatchPhase::Bubble {
1006 return;
1007 }
1008 moving.update(cx, |state, _| {
1009 if let Some(drag) = state.drag.as_mut() {
1010 let delta = f32::from(event.position.y) - drag.start;
1011 drag.moved |= delta.abs() > DRAG_SLOP;
1012 drag.offset = delta;
1013 }
1014 });
1015 window.refresh();
1016 },
1017 );
1018 let releasing = tracker.clone();
1019 let dismiss = release_dismiss.clone();
1020 let report = release_report.clone();
1021 window.on_mouse_event(move |_: &MouseUpEvent, phase, window, cx| {
1022 if phase != DispatchPhase::Bubble {
1023 return;
1024 }
1025 let Some(drag) = releasing.update(cx, |state, _| state.drag.take())
1026 else {
1027 return;
1028 };
1029 window.refresh();
1030 if !drag.moved {
1031 return;
1032 }
1033 releasing.update(cx, |state, _| state.dragged = true);
1034 let position = (rest + drag.offset).max(0.);
1035 let target = snap_target(position, height, partial);
1036 let new_rest = match target {
1037 SnapTarget::Expanded => 0.,
1038 SnapTarget::Partial => height - partial.unwrap_or(height),
1039 SnapTarget::Dismiss => height,
1040 };
1041 releasing.update(cx, |state, _| {
1042 state.expanded = target == SnapTarget::Expanded;
1043 let from = position
1044 - if target == SnapTarget::Dismiss {
1045 rest
1046 } else {
1047 new_rest
1048 };
1049 state.settle = Some(Settle {
1050 from,
1051 started: Instant::now(),
1052 });
1053 });
1054 match target {
1055 SnapTarget::Dismiss => {
1056 if let Some(dismiss) = &dismiss {
1057 dismiss(window, cx);
1058 }
1059 }
1060 SnapTarget::Expanded | SnapTarget::Partial => {
1061 if let Some(report) = &report {
1062 report(
1063 if target == SnapTarget::Expanded
1064 || partial.is_none()
1065 {
1066 BottomSheetDetent::Expanded
1067 } else {
1068 BottomSheetDetent::Partial
1069 },
1070 window,
1071 cx,
1072 );
1073 }
1074 }
1075 }
1076 });
1077 },
1078 )
1079 .absolute()
1080 .size_full(),
1081 ),
1082 )
1083 .child(
1084 canvas(
1086 move |bounds, window, cx| {
1087 let height = bounds.size.height;
1088 let stale = {
1089 let state = measure.read(cx);
1090 !state.measured || state.height != height
1091 };
1092 if stale {
1093 measure.update(cx, |state, _| {
1094 state.height = height;
1095 state.measured = true;
1096 });
1097 window.request_animation_frame();
1100 }
1101 },
1102 |_, _, _, _| {},
1103 )
1104 .absolute()
1105 .top_0()
1106 .left_0()
1107 .w(px(1.))
1108 .h_full(),
1109 );
1110
1111 match focus {
1112 Some(focus) => {
1113 let dismissals = Dismissals {
1114 escape: dismiss.clone().filter(|_| self.dismiss_on_escape),
1115 scrim: dismiss.filter(|_| self.dismiss_on_scrim),
1116 };
1117 let area = container.is_some().then_some(area);
1118 with_container_probe(
1119 container,
1120 modal_host(focus, frame, dismissals, area, sheet).into_any_element(),
1121 )
1122 }
1123 None => div()
1124 .absolute()
1125 .inset_0()
1126 .overflow_hidden()
1127 .child(sheet)
1128 .into_any_element(),
1129 }
1130 }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use super::*;
1136 use core::prelude::v1::test;
1137 use gpui::{Context, TestAppContext, VisualTestContext};
1138
1139 fn draw(cx: &mut VisualTestContext) {
1140 cx.update(|window, cx| {
1141 window.refresh();
1142 window.draw(cx).clear(cx);
1143 });
1144 cx.run_until_parked();
1145 }
1146
1147 struct SheetHarness(bool);
1148 impl Render for SheetHarness {
1149 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1150 div().relative().size_full().overflow_hidden().child(
1151 MaterialSideSheet::new("sheet")
1152 .open(self.0)
1153 .width(rem(100.))
1154 .child(div().debug_selector(|| "sheet-body".into()).size_full()),
1155 )
1156 }
1157 }
1158 #[gpui::test]
1159 fn reduced_motion_sheet_opens_and_closes_outside_viewport(cx: &mut TestAppContext) {
1160 cx.update(|cx| cx.set_reduce_motion(true));
1161 let (view, cx) = cx.add_window_view(|_, _| SheetHarness(false));
1162 draw(cx);
1163 assert!(cx.debug_bounds("sheet-body").is_none());
1164 view.update(cx, |v, cx| {
1165 v.0 = true;
1166 cx.notify();
1167 });
1168 draw(cx);
1169 let open = cx.debug_bounds("sheet-body").unwrap();
1170 let viewport = cx.update(|window, _| window.viewport_size());
1171 assert_eq!(open.right(), viewport.width, "an open sheet meets the edge");
1172 view.update(cx, |v, cx| {
1173 v.0 = false;
1174 cx.notify();
1175 });
1176 draw(cx);
1177 assert!(
1178 cx.debug_bounds("sheet-body").is_none(),
1179 "a settled closed sheet renders no content"
1180 );
1181 }
1182
1183 #[test]
1184 fn the_sheet_keeps_its_content_until_the_close_settles() {
1185 assert!(
1186 !sheet_hidden(true, 0.),
1187 "opening starts from 0 with content"
1188 );
1189 assert!(
1190 !sheet_hidden(false, 0.4),
1191 "closing animates its content out"
1192 );
1193 assert!(!sheet_hidden(false, -0.01), "an overshoot is still moving");
1194 assert!(sheet_hidden(false, 0.));
1195 }
1196
1197 struct FocusSheetHarness {
1198 open: bool,
1199 focus: FocusHandle,
1200 }
1201 impl Render for FocusSheetHarness {
1202 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1203 div().relative().size_full().child(
1204 MaterialSideSheet::new("focus-sheet").open(self.open).child(
1205 div()
1206 .id("sheet-control")
1207 .track_focus(&self.focus)
1208 .debug_selector(|| "sheet-control".into())
1209 .size(px(20.)),
1210 ),
1211 )
1212 }
1213 }
1214
1215 #[gpui::test]
1216 fn a_closed_sheet_offers_no_tab_stops(cx: &mut TestAppContext) {
1217 cx.update(|cx| cx.set_reduce_motion(true));
1218 let (view, cx) = cx.add_window_view(|_, cx| FocusSheetHarness {
1219 open: true,
1220 focus: cx.focus_handle(),
1221 });
1222 draw(cx);
1223 assert!(cx.debug_bounds("sheet-control").is_some());
1224 view.update(cx, |v, cx| {
1225 v.open = false;
1226 cx.notify();
1227 });
1228 draw(cx);
1229 assert!(cx.debug_bounds("sheet-control").is_none());
1230 cx.update(|window, cx| {
1231 window.focus_next(cx);
1232 let focus = view.read(cx).focus.clone();
1233 assert!(!focus.is_focused(window));
1234 });
1235 }
1236
1237 #[test]
1238 fn released_sheets_snap_to_the_nearest_rest_or_dismiss() {
1239 assert_eq!(snap_target(30., 400., Some(200.)), SnapTarget::Expanded);
1241 assert_eq!(snap_target(180., 400., Some(200.)), SnapTarget::Partial);
1242 assert_eq!(snap_target(330., 400., Some(200.)), SnapTarget::Dismiss);
1243 assert_eq!(snap_target(190., 400., None), SnapTarget::Expanded);
1245 assert_eq!(snap_target(210., 400., None), SnapTarget::Dismiss);
1246 assert_eq!(snap_target(250., 400., Some(500.)), SnapTarget::Dismiss);
1248 }
1249
1250 #[test]
1251 fn reduced_motion_settles_at_once() {
1252 assert_eq!(settle_progress(0., true), 1.);
1253 assert!(settle_progress(0., false) < 1.);
1254 assert_eq!(settle_progress(10., false), 1.);
1255 }
1256
1257 struct BottomHarness {
1258 open: bool,
1259 dismissals: usize,
1260 }
1261 impl Render for BottomHarness {
1262 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1263 div().relative().size(px(400.)).child(
1264 MaterialBottomSheet::new("bottom")
1265 .open(self.open)
1266 .drag_handle("Drag handle")
1267 .on_dismiss({
1268 let this = cx.entity().downgrade();
1269 move |_, cx| {
1270 let _ = this.update(cx, |this, cx| {
1271 this.dismissals += 1;
1272 this.open = false;
1273 cx.notify();
1274 });
1275 }
1276 })
1277 .child(div().debug_selector(|| "bottom-body".into()).h(px(200.))),
1278 )
1279 }
1280 }
1281
1282 #[gpui::test]
1283 fn dragging_a_bottom_sheet_down_dismisses_it(cx: &mut TestAppContext) {
1284 cx.update(|cx| {
1285 gpui_base::init(cx);
1286 cx.set_reduce_motion(true);
1287 });
1288 let (view, cx) = cx.add_window_view(|_, _| BottomHarness {
1289 open: true,
1290 dismissals: 0,
1291 });
1292 draw(cx);
1293 draw(cx);
1294 let body = cx.debug_bounds("bottom-body").unwrap();
1295 assert_eq!(body.bottom(), px(400.), "an open sheet meets the bottom");
1296 let start = point(body.left() + px(20.), body.top() - px(10.));
1298 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1299 draw(cx);
1300 cx.simulate_mouse_move(
1301 point(start.x, start.y + px(60.)),
1302 Some(MouseButton::Left),
1303 Modifiers::default(),
1304 );
1305 draw(cx);
1306 let dragged = cx.debug_bounds("bottom-body").unwrap();
1307 assert_eq!(dragged.top() - body.top(), px(60.), "the sheet follows");
1308 cx.simulate_mouse_up(
1309 point(start.x, start.y + px(60.)),
1310 MouseButton::Left,
1311 Modifiers::default(),
1312 );
1313 draw(cx);
1314 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0, "snapped back");
1315 assert_eq!(cx.debug_bounds("bottom-body").unwrap().top(), body.top());
1316 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1317 draw(cx);
1318 cx.simulate_mouse_move(
1319 point(start.x, start.y + px(180.)),
1320 Some(MouseButton::Left),
1321 Modifiers::default(),
1322 );
1323 draw(cx);
1324 cx.simulate_mouse_up(
1325 point(start.x, start.y + px(180.)),
1326 MouseButton::Left,
1327 Modifiers::default(),
1328 );
1329 draw(cx);
1330 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 1);
1331 cx.executor()
1333 .advance_clock(std::time::Duration::from_secs(1));
1334 draw(cx);
1335 draw(cx);
1336 assert!(cx.debug_bounds("bottom-body").is_none());
1337 }
1338
1339 #[gpui::test]
1340 fn pressing_content_does_not_drag_the_sheet(cx: &mut TestAppContext) {
1341 cx.update(|cx| {
1342 gpui_base::init(cx);
1343 cx.set_reduce_motion(true);
1344 });
1345 let (view, cx) = cx.add_window_view(|_, _| BottomHarness {
1346 open: true,
1347 dismissals: 0,
1348 });
1349 draw(cx);
1350 draw(cx);
1351 let body = cx.debug_bounds("bottom-body").unwrap();
1352 let start = body.center();
1353 cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1354 draw(cx);
1355 let end = point(start.x, start.y + px(180.));
1356 cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
1357 draw(cx);
1358 assert_eq!(
1359 cx.debug_bounds("bottom-body").unwrap().top(),
1360 body.top(),
1361 "a press on the content (text, a slider) moved the sheet"
1362 );
1363 cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1364 draw(cx);
1365 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0);
1366 }
1367
1368 struct PartialHarness;
1369 impl Render for PartialHarness {
1370 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1371 div().relative().size(px(400.)).child(
1372 MaterialBottomSheet::new("partial")
1373 .open(true)
1374 .partial_height(rem(100.))
1375 .child(div().debug_selector(|| "partial-body".into()).h(px(300.))),
1376 )
1377 }
1378 }
1379
1380 #[gpui::test]
1381 fn a_bottom_sheet_opens_at_its_measured_detent(cx: &mut TestAppContext) {
1382 cx.update(|cx| {
1383 gpui_base::init(cx);
1384 cx.set_reduce_motion(true);
1385 });
1386 let (_, cx) = cx.add_window_view(|window, _| {
1387 window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1388 PartialHarness
1389 });
1390 cx.update(|window, cx| {
1391 window.refresh();
1392 window.draw(cx).clear(cx);
1393 });
1394 let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1396 assert!(frames > 0, "the measured height was stored silently");
1397 cx.update(|window, cx| window.draw(cx).clear(cx));
1398 let body = cx.debug_bounds("partial-body").unwrap();
1399 assert_eq!(body.top(), px(300.), "{body:?}");
1401 let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1402 assert_eq!(frames, 0, "a settled sheet requests nothing");
1403 }
1404
1405 struct EdgeHarness {
1406 modal: bool,
1407 escape: bool,
1408 dismissals: usize,
1409 }
1410 impl Render for EdgeHarness {
1411 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1412 let this = cx.entity().downgrade();
1413 div().size_full().pl(px(100.)).child(
1414 div()
1415 .relative()
1416 .debug_selector(|| "frame".into())
1417 .w(px(600.))
1418 .h(px(400.))
1419 .child(
1420 MaterialSideSheet::new("edge")
1421 .open(true)
1422 .modal(self.modal)
1423 .edge(SideSheetEdge::Start)
1424 .width(rem(200.))
1425 .contained(true)
1426 .dismiss_on_escape(self.escape)
1427 .on_dismiss(move |_, cx| {
1428 let _ = this.update(cx, |this, cx| {
1429 this.dismissals += 1;
1430 cx.notify();
1431 });
1432 })
1433 .child(div().debug_selector(|| "edge-body".into()).size_full()),
1434 ),
1435 )
1436 }
1437 }
1438
1439 #[gpui::test]
1440 fn a_start_sheet_docks_left_and_escape_dismissal_is_optional(cx: &mut TestAppContext) {
1441 cx.update(|cx| {
1442 gpui_base::init(cx);
1443 cx.set_reduce_motion(true);
1444 });
1445 let (view, cx) = cx.add_window_view(|window, _| {
1446 window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1447 EdgeHarness {
1448 modal: false,
1449 escape: false,
1450 dismissals: 0,
1451 }
1452 });
1453 draw(cx);
1454 let frame = cx.debug_bounds("frame").unwrap();
1455 let body = cx.debug_bounds("edge-body").unwrap();
1456 assert_eq!(
1457 body.right(),
1458 frame.left() + px(199.),
1459 "the divider faces the content"
1460 );
1461 view.update(cx, |v, cx| {
1463 v.modal = true;
1464 cx.notify();
1465 });
1466 for _ in 0..3 {
1467 draw(cx);
1468 }
1469 let body = cx.debug_bounds("edge-body").unwrap();
1470 assert_eq!(body.left(), frame.left());
1471 cx.simulate_keystrokes("escape");
1472 draw(cx);
1473 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0, "Escape is off");
1474 view.update(cx, |v, cx| {
1475 v.escape = true;
1476 cx.notify();
1477 });
1478 draw(cx);
1479 cx.simulate_keystrokes("escape");
1480 draw(cx);
1481 assert_eq!(view.read_with(cx, |v, _| v.dismissals), 1);
1482 }
1483
1484 struct DetentHarness {
1485 detent: BottomSheetDetent,
1486 reported: Vec<BottomSheetDetent>,
1487 }
1488 impl Render for DetentHarness {
1489 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1490 let this = cx.entity().downgrade();
1491 div().relative().size(px(400.)).child(
1492 MaterialBottomSheet::new("detent")
1493 .detent(self.detent)
1494 .partial_height(rem(100.))
1495 .max_width(rem(300.))
1496 .drag_handle("Drag handle")
1497 .on_detent_change(move |detent, _, cx| {
1498 let _ = this.update(cx, |this, cx| {
1499 this.reported.push(detent);
1500 this.detent = detent;
1501 cx.notify();
1502 });
1503 })
1504 .child(div().debug_selector(|| "detent-body".into()).h(px(300.))),
1505 )
1506 }
1507 }
1508
1509 #[gpui::test]
1510 fn a_controlled_bottom_sheet_follows_and_reports_its_detent(cx: &mut TestAppContext) {
1511 cx.update(|cx| {
1512 gpui_base::init(cx);
1513 cx.set_reduce_motion(true);
1514 });
1515 let (view, cx) = cx.add_window_view(|window, _| {
1516 window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1517 DetentHarness {
1518 detent: BottomSheetDetent::Expanded,
1519 reported: Vec::new(),
1520 }
1521 });
1522 for _ in 0..3 {
1523 draw(cx);
1524 }
1525 let body = cx.debug_bounds("detent-body").unwrap();
1526 assert_eq!(body.bottom(), px(400.), "expanded");
1527 assert_eq!(body.size.width, px(300.), "capped at its maximum width");
1528 view.update(cx, |v, cx| {
1529 v.detent = BottomSheetDetent::Partial;
1530 cx.notify();
1531 });
1532 for _ in 0..3 {
1533 draw(cx);
1534 }
1535 let partial = cx.debug_bounds("detent-body").unwrap();
1536 assert_eq!(partial.top(), px(336.), "{partial:?}");
1538 let handle = cx.debug_bounds("drag-handle").unwrap();
1540 cx.simulate_click(handle.center(), Modifiers::default());
1541 for _ in 0..3 {
1542 draw(cx);
1543 }
1544 assert_eq!(
1545 view.read_with(cx, |v, _| v.reported.clone()),
1546 [BottomSheetDetent::Expanded]
1547 );
1548 assert_eq!(cx.debug_bounds("detent-body").unwrap().bottom(), px(400.));
1549 view.update(cx, |v, cx| {
1551 v.detent = BottomSheetDetent::Hidden;
1552 cx.notify();
1553 });
1554 cx.executor()
1555 .advance_clock(std::time::Duration::from_secs(1));
1556 for _ in 0..3 {
1557 draw(cx);
1558 }
1559 assert!(cx.debug_bounds("detent-body").is_none());
1560 }
1561}