Skip to main content

gpui_component/
sheet.rs

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