gpui_component/
sheet.rs

1use std::{rc::Rc, time::Duration};
2
3use gpui::{
4    anchored, div, point, prelude::FluentBuilder as _, px, Animation, AnimationExt as _,
5    AnyElement, App, Axis, ClickEvent, DefiniteLength, DismissEvent, Div, EventEmitter,
6    FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement,
7    Pixels, RenderOnce, Styled, Window,
8};
9
10use crate::{
11    actions::Cancel,
12    button::{Button, ButtonVariants as _},
13    dialog::overlay_color,
14    h_flex,
15    title_bar::TITLE_BAR_HEIGHT,
16    v_flex, ActiveTheme, IconName, Placement, Sizable, StyledExt as _, WindowExt as _,
17};
18
19const CONTEXT: &str = "Sheet";
20pub(crate) fn init(cx: &mut App) {
21    cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
22}
23
24/// Sheet component that slides in from the side of the window.
25#[derive(IntoElement)]
26pub struct Sheet {
27    pub(crate) focus_handle: FocusHandle,
28    pub(crate) placement: Placement,
29    pub(crate) size: DefiniteLength,
30    resizable: bool,
31    on_close: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
32    title: Option<AnyElement>,
33    footer: Option<AnyElement>,
34    content: Div,
35    margin_top: Pixels,
36    overlay: bool,
37    overlay_closable: bool,
38}
39
40impl Sheet {
41    /// Creates a new Sheet.
42    pub fn new(_: &mut Window, cx: &mut App) -> Self {
43        Self {
44            focus_handle: cx.focus_handle(),
45            placement: Placement::Right,
46            size: DefiniteLength::Absolute(px(350.).into()),
47            resizable: true,
48            title: None,
49            footer: None,
50            content: v_flex().px_4().py_3(),
51            margin_top: TITLE_BAR_HEIGHT,
52            overlay: true,
53            overlay_closable: true,
54            on_close: Rc::new(|_, _, _| {}),
55        }
56    }
57
58    /// Sets the title of the sheet.
59    pub fn title(mut self, title: impl IntoElement) -> Self {
60        self.title = Some(title.into_any_element());
61        self
62    }
63
64    /// Set the footer of the sheet.
65    pub fn footer(mut self, footer: impl IntoElement) -> Self {
66        self.footer = Some(footer.into_any_element());
67        self
68    }
69
70    /// Sets the size of the sheet, default is 350px.
71    pub fn size(mut self, size: impl Into<DefiniteLength>) -> Self {
72        self.size = size.into();
73        self
74    }
75
76    /// Sets the margin top of the sheet, default is 0px.
77    ///
78    /// This is used to let Sheet be placed below a Windows Title, you can give the height of the title bar.
79    pub fn margin_top(mut self, top: Pixels) -> Self {
80        self.margin_top = top;
81        self
82    }
83
84    /// Sets whether the sheet is resizable, default is `true`.
85    pub fn resizable(mut self, resizable: bool) -> Self {
86        self.resizable = resizable;
87        self
88    }
89
90    /// Set whether the sheet should have an overlay, default is `true`.
91    pub fn overlay(mut self, overlay: bool) -> Self {
92        self.overlay = overlay;
93        self
94    }
95
96    /// Set whether the sheet should be closable by clicking the overlay, default is `true`.
97    pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
98        self.overlay_closable = overlay_closable;
99        self
100    }
101
102    /// Listen to the close event of the sheet.
103    pub fn on_close(
104        mut self,
105        on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
106    ) -> Self {
107        self.on_close = Rc::new(on_close);
108        self
109    }
110}
111
112impl EventEmitter<DismissEvent> for Sheet {}
113impl ParentElement for Sheet {
114    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
115        self.content.extend(elements);
116    }
117}
118impl Styled for Sheet {
119    fn style(&mut self) -> &mut gpui::StyleRefinement {
120        self.content.style()
121    }
122}
123
124impl RenderOnce for Sheet {
125    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
126        let placement = self.placement;
127        let titlebar_height = self.margin_top;
128        let window_paddings = crate::window_border::window_paddings(window);
129        let size = window.viewport_size()
130            - gpui::size(
131                window_paddings.left + window_paddings.right,
132                window_paddings.top + window_paddings.bottom,
133            );
134        let on_close = self.on_close.clone();
135
136        anchored()
137            .position(point(
138                window_paddings.left,
139                window_paddings.top + titlebar_height,
140            ))
141            .snap_to_window()
142            .child(
143                div()
144                    .occlude()
145                    .w(size.width)
146                    .h(size.height - titlebar_height)
147                    .bg(overlay_color(self.overlay, cx))
148                    .when(self.overlay, |this| {
149                        this.on_any_mouse_down({
150                            let on_close = self.on_close.clone();
151                            move |event, window, cx| {
152                                cx.stop_propagation();
153
154                                if self.overlay_closable && event.button == MouseButton::Left {
155                                    on_close(&ClickEvent::default(), window, cx);
156                                    window.close_sheet(cx);
157                                }
158                            }
159                        })
160                    })
161                    .child(
162                        v_flex()
163                            .id("sheet")
164                            .tab_group()
165                            .key_context(CONTEXT)
166                            .track_focus(&self.focus_handle)
167                            .on_action({
168                                let on_close = self.on_close.clone();
169                                move |_: &Cancel, window, cx| {
170                                    cx.propagate();
171
172                                    on_close(&ClickEvent::default(), window, cx);
173                                    window.close_sheet(cx);
174                                }
175                            })
176                            .absolute()
177                            .occlude()
178                            .bg(cx.theme().background)
179                            .border_color(cx.theme().border)
180                            .shadow_xl()
181                            .map(|this| {
182                                // Set the size of the sheet.
183                                if placement.is_horizontal() {
184                                    this.h_full().w(self.size)
185                                } else {
186                                    this.w_full().h(self.size)
187                                }
188                            })
189                            .map(|this| match self.placement {
190                                Placement::Top => this.top_0().left_0().right_0().border_b_1(),
191                                Placement::Right => this.top_0().right_0().bottom_0().border_l_1(),
192                                Placement::Bottom => {
193                                    this.bottom_0().left_0().right_0().border_t_1()
194                                }
195                                Placement::Left => this.top_0().left_0().bottom_0().border_r_1(),
196                            })
197                            .child(
198                                // TitleBar
199                                h_flex()
200                                    .justify_between()
201                                    .pl_4()
202                                    .pr_3()
203                                    .py_2()
204                                    .w_full()
205                                    .font_semibold()
206                                    .child(self.title.unwrap_or(div().into_any_element()))
207                                    .child(
208                                        Button::new("close")
209                                            .small()
210                                            .ghost()
211                                            .icon(IconName::Close)
212                                            .on_click(move |_, window, cx| {
213                                                on_close(&ClickEvent::default(), window, cx);
214                                                window.close_sheet(cx);
215                                            }),
216                                    ),
217                            )
218                            .child(
219                                // Body
220                                div()
221                                    .flex_1()
222                                    .overflow_hidden()
223                                    .child(v_flex().scrollable(Axis::Vertical).child(self.content)),
224                            )
225                            .when_some(self.footer, |this, footer| {
226                                // Footer
227                                this.child(
228                                    h_flex()
229                                        .justify_between()
230                                        .px_4()
231                                        .py_3()
232                                        .w_full()
233                                        .child(footer),
234                                )
235                            })
236                            .with_animation(
237                                "slide",
238                                Animation::new(Duration::from_secs_f64(0.15)),
239                                move |this, delta| {
240                                    let y = px(-100.) + delta * px(100.);
241                                    this.map(|this| match placement {
242                                        Placement::Top => this.top(y),
243                                        Placement::Right => this.right(y),
244                                        Placement::Bottom => this.bottom(y),
245                                        Placement::Left => this.left(y),
246                                    })
247                                },
248                            ),
249                    ),
250            )
251    }
252}