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#[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}