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