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