Skip to main content

gpui_component/
sheet.rs

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