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