Skip to main content

gpui_base/
sheet.rs

1use crate::TestSupportExt as _;
2use std::rc::Rc;
3
4use gpui::{
5    AnyElement, App, ClickEvent, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding,
6    MouseButton, ParentElement, Pixels, RenderOnce, StyleRefinement, Styled, Window, anchored, div,
7    point, prelude::FluentBuilder as _, px,
8};
9
10use crate::{FocusTrapElement as _, StyledExt as _, actions::Cancel};
11
12const CONTEXT: &str = "Sheet";
13
14type CloseHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
15type CloseRequest = Rc<dyn Fn(&mut Window, &mut App)>;
16
17fn close(request: &CloseRequest, notify: &CloseHandler, window: &mut Window, cx: &mut App) {
18    let event = ClickEvent::default();
19    request(window, cx);
20    notify(&event, window, cx);
21}
22
23pub fn init(cx: &mut App) {
24    cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]);
25}
26
27/// An unstyled modal sheet host.
28///
29/// Applications provide the overlay and surface. The host owns focus trapping,
30/// Escape handling, overlay dismissal, and close callback ordering.
31#[derive(IntoElement)]
32pub struct Sheet {
33    base: gpui::Div,
34    style: StyleRefinement,
35    focus: FocusHandle,
36    overlay_interactive: bool,
37    overlay_closable: bool,
38    dismiss_before_y: Option<Pixels>,
39    overlay: Option<AnyElement>,
40    surface: Option<AnyElement>,
41    request_close: CloseRequest,
42    on_close: CloseHandler,
43}
44
45impl Sheet {
46    pub fn new(cx: &mut App) -> Self {
47        Self {
48            base: div(),
49            style: StyleRefinement::default(),
50            focus: cx.focus_handle(),
51            overlay_interactive: true,
52            overlay_closable: true,
53            dismiss_before_y: None,
54            overlay: None,
55            surface: None,
56            request_close: Rc::new(|_, _| {}),
57            on_close: Rc::new(|_, _, _| {}),
58        }
59    }
60
61    pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
62        self.overlay = Some(overlay.into_any_element());
63        self
64    }
65
66    pub fn surface(mut self, surface: impl IntoElement) -> Self {
67        self.surface = Some(surface.into_any_element());
68        self
69    }
70
71    pub fn overlay_closable(mut self, closable: bool) -> Self {
72        self.overlay_closable = closable;
73        self
74    }
75
76    #[doc(hidden)]
77    pub fn overlay_interactive(mut self, interactive: bool) -> Self {
78        self.overlay_interactive = interactive;
79        self
80    }
81
82    pub fn on_close(
83        mut self,
84        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
85    ) -> Self {
86        self.on_close = Rc::new(handler);
87        self
88    }
89
90    #[doc(hidden)]
91    pub fn focus_handle(mut self, focus: FocusHandle) -> Self {
92        self.focus = focus;
93        self
94    }
95
96    #[doc(hidden)]
97    pub fn dismiss_before_y(mut self, y: Pixels) -> Self {
98        self.dismiss_before_y = Some(y);
99        self
100    }
101
102    #[doc(hidden)]
103    pub fn request_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
104        self.request_close = Rc::new(handler);
105        self
106    }
107}
108
109impl Styled for Sheet {
110    fn style(&mut self) -> &mut StyleRefinement {
111        &mut self.style
112    }
113}
114
115impl RenderOnce for Sheet {
116    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
117        let viewport = window.viewport_size();
118        let request_close = self.request_close;
119        let on_close = self.on_close;
120        let escape_request = request_close.clone();
121        let escape_notify = on_close.clone();
122
123        anchored().position(point(px(0.), px(0.))).child(
124            self.base
125                .id("sheet-host")
126                .test_support()
127                .absolute()
128                .top_0()
129                .left_0()
130                .w(viewport.width)
131                .h(viewport.height)
132                .key_context(CONTEXT)
133                .track_focus(&self.focus)
134                .focus_trap("sheet", &self.focus)
135                .on_action(move |_: &Cancel, window, cx| {
136                    cx.propagate();
137                    close(&escape_request, &escape_notify, window, cx);
138                })
139                .when_some(self.overlay, |this, overlay| {
140                    let request_close = request_close.clone();
141                    let on_close = on_close.clone();
142                    let dismiss_before_y = self.dismiss_before_y;
143                    let overlay_interactive = self.overlay_interactive;
144                    let overlay_closable = self.overlay_closable;
145                    this.child(overlay).child(div().absolute().inset_0().when(
146                        overlay_interactive,
147                        |this| {
148                            this.on_any_mouse_down(move |event, window, cx| {
149                                if !overlay_interactive {
150                                    return;
151                                }
152                                if dismiss_before_y.is_some_and(|top| event.position.y < top) {
153                                    return;
154                                }
155                                cx.stop_propagation();
156                                if overlay_closable && event.button == MouseButton::Left {
157                                    close(&request_close, &on_close, window, cx);
158                                }
159                            })
160                        },
161                    ))
162                })
163                .children(self.surface)
164                .refine_style(&self.style),
165        )
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use gpui::{Context, Render, point, px};
173    use std::{cell::RefCell, rc::Rc};
174
175    struct Harness {
176        closable: bool,
177        cutoff: Option<Pixels>,
178        focus: FocusHandle,
179        events: Rc<RefCell<Vec<&'static str>>>,
180    }
181
182    impl Render for Harness {
183        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
184            let requested = self.events.clone();
185            let closed = self.events.clone();
186            Sheet::new(cx)
187                .focus_handle(self.focus.clone())
188                .overlay_closable(self.closable)
189                .request_close(move |_, _| requested.borrow_mut().push("request"))
190                .on_close(move |_, _, _| closed.borrow_mut().push("closed"))
191                .overlay(div().absolute().inset_0().occlude())
192                .surface(
193                    div()
194                        .absolute()
195                        .right_0()
196                        .top_0()
197                        .h_full()
198                        .w(px(80.))
199                        .occlude(),
200                )
201                .when_some(self.cutoff, |this, cutoff| this.dismiss_before_y(cutoff))
202        }
203    }
204
205    fn harness(
206        cx: &mut gpui::TestAppContext,
207        closable: bool,
208    ) -> (&mut gpui::VisualTestContext, Rc<RefCell<Vec<&'static str>>>) {
209        cx.update(crate::init);
210        let focus = cx.update(|cx| cx.focus_handle());
211        let events = Rc::new(RefCell::new(Vec::new()));
212        let (_, cx) = cx.add_window_view({
213            let events = events.clone();
214            let focus = focus.clone();
215            move |_, _| Harness {
216                closable,
217                cutoff: None,
218                focus,
219                events,
220            }
221        });
222        cx.update(|window, cx| focus.focus(window, cx));
223        cx.update(|window, cx| window.draw(cx).clear(cx));
224        (cx, events)
225    }
226
227    #[gpui::test]
228    fn overlay_close_requests_then_notifies(cx: &mut gpui::TestAppContext) {
229        let (cx, events) = harness(cx, true);
230        cx.simulate_click(point(px(20.), px(20.)), Default::default());
231        assert_eq!(&*events.borrow(), &["request", "closed"]);
232    }
233
234    #[gpui::test]
235    fn non_closable_overlay_does_not_request_close(cx: &mut gpui::TestAppContext) {
236        let (cx, events) = harness(cx, false);
237        cx.simulate_click(point(px(20.), px(20.)), Default::default());
238        assert!(events.borrow().is_empty());
239    }
240
241    #[gpui::test]
242    fn escape_uses_the_same_close_order_and_registers_focus_trap(cx: &mut gpui::TestAppContext) {
243        let (cx, events) = harness(cx, true);
244        assert!(cx.update(|window, cx| crate::active_focus_trap(window, cx).is_some()));
245        cx.dispatch_action(Cancel);
246        assert_eq!(&*events.borrow(), &["request", "closed"]);
247    }
248
249    #[gpui::test]
250    fn pointer_above_the_dismiss_cutoff_is_ignored(cx: &mut gpui::TestAppContext) {
251        cx.update(crate::init);
252        let focus = cx.update(|cx| cx.focus_handle());
253        let events = Rc::new(RefCell::new(Vec::new()));
254        let (_, cx) = cx.add_window_view({
255            let focus = focus.clone();
256            let events = events.clone();
257            move |_, _| Harness {
258                closable: true,
259                cutoff: Some(px(50.)),
260                focus,
261                events,
262            }
263        });
264        cx.update(|window, cx| window.draw(cx).clear(cx));
265        cx.simulate_click(point(px(20.), px(20.)), Default::default());
266        assert!(events.borrow().is_empty());
267        cx.simulate_click(point(px(20.), px(80.)), Default::default());
268        assert_eq!(&*events.borrow(), &["request", "closed"]);
269    }
270}