Skip to main content

gpui_base/
sheet.rs

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