Skip to main content

ui/components/
modal.rs

1use crate::{IconButtonShape, prelude::*};
2
3use gpui::{prelude::FluentBuilder, *};
4use smallvec::SmallVec;
5use theme::ActiveTheme;
6
7#[derive(IntoElement, RegisterComponent)]
8pub struct Modal {
9    id: ElementId,
10    header: ModalHeader,
11    children: SmallVec<[AnyElement; 2]>,
12    footer: Option<ModalFooter>,
13    container_id: ElementId,
14    container_scroll_handler: Option<ScrollHandle>,
15}
16
17impl Modal {
18    pub fn new(id: impl Into<SharedString>, scroll_handle: Option<ScrollHandle>) -> Self {
19        let id = id.into();
20
21        let container_id = ElementId::Name(format!("{}_container", id).into());
22        Self {
23            id: ElementId::Name(id),
24            header: ModalHeader::new(),
25            children: SmallVec::new(),
26            footer: None,
27            container_id,
28            container_scroll_handler: scroll_handle,
29        }
30    }
31
32    pub fn header(mut self, header: ModalHeader) -> Self {
33        self.header = header;
34        self
35    }
36
37    pub fn section(mut self, section: Section) -> Self {
38        self.children.push(section.into_any_element());
39        self
40    }
41
42    pub fn footer(mut self, footer: ModalFooter) -> Self {
43        self.footer = Some(footer);
44        self
45    }
46
47    pub fn show_dismiss(mut self, show: bool) -> Self {
48        self.header.show_dismiss_button = show;
49        self
50    }
51
52    pub fn show_back(mut self, show: bool) -> Self {
53        self.header.show_back_button = show;
54        self
55    }
56}
57
58impl ParentElement for Modal {
59    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
60        self.children.extend(elements)
61    }
62}
63
64impl RenderOnce for Modal {
65    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
66        v_flex()
67            .id(self.id.clone())
68            .size_full()
69            .flex_1()
70            .overflow_hidden()
71            .bg(semantic::elevated_surface(cx))
72            .border_1()
73            .border_color(semantic::border(cx))
74            .rounded_lg()
75            .shadow_level(crate::styles::Shadow::Xl)
76            .child(self.header)
77            .child(
78                v_flex()
79                    .id(self.container_id.clone())
80                    .w_full()
81                    .flex_1()
82                    .p(DynamicSpacing::Base24.rems(cx))
83                    .gap(DynamicSpacing::Base08.rems(cx))
84                    .when_some(
85                        self.container_scroll_handler,
86                        |this, container_scroll_handle| {
87                            this.overflow_y_scroll()
88                                .track_scroll(&container_scroll_handle)
89                        },
90                    )
91                    .children(self.children),
92            )
93            .children(self.footer)
94    }
95}
96
97#[derive(IntoElement)]
98pub struct ModalHeader {
99    icon: Option<Icon>,
100    headline: Option<SharedString>,
101    description: Option<SharedString>,
102    children: SmallVec<[AnyElement; 2]>,
103    show_dismiss_button: bool,
104    show_back_button: bool,
105}
106
107impl Default for ModalHeader {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113impl ModalHeader {
114    pub fn new() -> Self {
115        Self {
116            icon: None,
117            headline: None,
118            description: None,
119            children: SmallVec::new(),
120            show_dismiss_button: false,
121            show_back_button: false,
122        }
123    }
124
125    pub fn icon(mut self, icon: Icon) -> Self {
126        self.icon = Some(icon);
127        self
128    }
129
130    /// Set the headline of the modal.
131    ///
132    /// This will insert the headline as the first item
133    /// of `children` if it is not already present.
134    pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
135        self.headline = Some(headline.into());
136        self
137    }
138
139    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
140        self.description = Some(description.into());
141        self
142    }
143
144    pub fn show_dismiss_button(mut self, show: bool) -> Self {
145        self.show_dismiss_button = show;
146        self
147    }
148
149    pub fn show_back_button(mut self, show: bool) -> Self {
150        self.show_back_button = show;
151        self
152    }
153}
154
155impl ParentElement for ModalHeader {
156    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
157        self.children.extend(elements)
158    }
159}
160
161impl RenderOnce for ModalHeader {
162    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
163        let mut children = self.children;
164
165        if let Some(headline) = self.headline {
166            children.insert(
167                0,
168                Headline::new(headline)
169                    .size(HeadlineSize::Small)
170                    .color(Color::Muted)
171                    .into_any_element(),
172            );
173        }
174
175        h_flex()
176            .min_w_0()
177            .flex_none()
178            .justify_between()
179            .w_full()
180            .px(DynamicSpacing::Base12.rems(cx))
181            .pt(DynamicSpacing::Base08.rems(cx))
182            .pb(DynamicSpacing::Base04.rems(cx))
183            .gap(DynamicSpacing::Base08.rems(cx))
184            .border_b_1()
185            .border_color(semantic::border_muted(cx))
186            .when(self.show_back_button, |this| {
187                this.child(
188                    IconButton::new("back", IconName::ArrowLeft)
189                        .shape(IconButtonShape::Square)
190                        .on_click(|_, window, cx| {
191                            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
192                        }),
193                )
194            })
195            .child(
196                v_flex()
197                    .min_w_0()
198                    .flex_1()
199                    .child(
200                        h_flex()
201                            .w_full()
202                            .gap_1()
203                            .justify_between()
204                            .child(
205                                h_flex()
206                                    .gap_1()
207                                    .when_some(self.icon, |this, icon| this.child(icon))
208                                    .children(children),
209                            )
210                            .when(self.show_dismiss_button, |this| {
211                                this.child(
212                                    IconButton::new("dismiss", IconName::Close)
213                                        .icon_size(IconSize::Small)
214                                        .on_click(|_, window, cx| {
215                                            window.dispatch_action(menu::Cancel.boxed_clone(), cx);
216                                        }),
217                                )
218                            }),
219                    )
220                    .when_some(self.description, |this, description| {
221                        this.child(Label::new(description).color(Color::Muted).mb_2().flex_1())
222                    }),
223            )
224    }
225}
226
227#[derive(IntoElement)]
228pub struct ModalRow {
229    children: SmallVec<[AnyElement; 2]>,
230}
231
232impl Default for ModalRow {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238impl ModalRow {
239    pub fn new() -> Self {
240        Self {
241            children: SmallVec::new(),
242        }
243    }
244}
245
246impl ParentElement for ModalRow {
247    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
248        self.children.extend(elements)
249    }
250}
251
252impl RenderOnce for ModalRow {
253    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
254        h_flex().w_full().py_1().children(self.children)
255    }
256}
257
258#[derive(IntoElement)]
259pub struct ModalFooter {
260    start_slot: Option<AnyElement>,
261    end_slot: Option<AnyElement>,
262}
263
264impl Default for ModalFooter {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl ModalFooter {
271    pub fn new() -> Self {
272        Self {
273            start_slot: None,
274            end_slot: None,
275        }
276    }
277
278    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
279        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
280        self
281    }
282
283    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
284        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
285        self
286    }
287}
288
289impl RenderOnce for ModalFooter {
290    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
291        h_flex()
292            .w_full()
293            .p(DynamicSpacing::Base08.rems(cx))
294            .flex_none()
295            .justify_between()
296            .gap(DynamicSpacing::Base12.rems(cx))
297            .border_t_1()
298            .border_color(semantic::border_muted(cx))
299            .child(div().when_some(self.start_slot, |this, start_slot| this.child(start_slot)))
300            .child(div().when_some(self.end_slot, |this, end_slot| this.child(end_slot)))
301    }
302}
303
304#[derive(IntoElement)]
305pub struct Section {
306    contained: bool,
307    padded: bool,
308    header: Option<SectionHeader>,
309    meta: Option<SharedString>,
310    children: SmallVec<[AnyElement; 2]>,
311}
312
313impl Default for Section {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319impl Section {
320    pub fn new() -> Self {
321        Self {
322            contained: false,
323            padded: true,
324            header: None,
325            meta: None,
326            children: SmallVec::new(),
327        }
328    }
329
330    pub fn new_contained() -> Self {
331        Self {
332            contained: true,
333            padded: true,
334            header: None,
335            meta: None,
336            children: SmallVec::new(),
337        }
338    }
339
340    pub fn contained(mut self, contained: bool) -> Self {
341        self.contained = contained;
342        self
343    }
344
345    pub fn header(mut self, header: SectionHeader) -> Self {
346        self.header = Some(header);
347        self
348    }
349
350    pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
351        self.meta = Some(meta.into());
352        self
353    }
354    pub fn padded(mut self, padded: bool) -> Self {
355        self.padded = padded;
356        self
357    }
358}
359
360impl ParentElement for Section {
361    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
362        self.children.extend(elements)
363    }
364}
365
366impl RenderOnce for Section {
367    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
368        let mut section_bg = cx.theme().colors().text;
369        section_bg.fade_out(0.96);
370
371        let children = if self.contained {
372            v_flex()
373                .flex_1()
374                .when(self.padded, |this| this.px(DynamicSpacing::Base12.rems(cx)))
375                .child(
376                    v_flex()
377                        .w_full()
378                        .rounded_sm()
379                        .border_1()
380                        .border_color(cx.theme().colors().border)
381                        .bg(section_bg)
382                        .child(
383                            div()
384                                .flex()
385                                .flex_1()
386                                .pb_2()
387                                .size_full()
388                                .children(self.children),
389                        ),
390                )
391        } else {
392            v_flex()
393                .w_full()
394                .flex_1()
395                .gap_y(DynamicSpacing::Base04.rems(cx))
396                .pb_2()
397                .when(self.padded, |this| {
398                    this.px(DynamicSpacing::Base06.rems(cx) + DynamicSpacing::Base06.rems(cx))
399                })
400                .children(self.children)
401        };
402
403        v_flex()
404            .size_full()
405            .flex_1()
406            .child(
407                v_flex()
408                    .flex_none()
409                    .px(DynamicSpacing::Base12.rems(cx))
410                    .children(self.header)
411                    .when_some(self.meta, |this, meta| {
412                        this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
413                    }),
414            )
415            .child(children)
416            // fill any leftover space
417            .child(div().flex().flex_1())
418    }
419}
420
421#[derive(IntoElement)]
422pub struct SectionHeader {
423    /// The label of the header.
424    label: SharedString,
425    /// A slot for content that appears after the label, usually on the other side of the header.
426    /// This might be a button, a disclosure arrow, a face pile, etc.
427    end_slot: Option<AnyElement>,
428}
429
430impl SectionHeader {
431    pub fn new(label: impl Into<SharedString>) -> Self {
432        Self {
433            label: label.into(),
434            end_slot: None,
435        }
436    }
437
438    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
439        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
440        self
441    }
442}
443
444impl RenderOnce for SectionHeader {
445    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
446        h_flex()
447            .id(self.label.clone())
448            .w_full()
449            .px(DynamicSpacing::Base08.rems(cx))
450            .child(
451                div()
452                    .h_7()
453                    .flex()
454                    .items_center()
455                    .justify_between()
456                    .w_full()
457                    .gap(DynamicSpacing::Base04.rems(cx))
458                    .child(
459                        div().flex_1().child(
460                            Label::new(self.label.clone())
461                                .size(LabelSize::Small)
462                                .into_element(),
463                        ),
464                    )
465                    .child(h_flex().children(self.end_slot)),
466            )
467    }
468}
469
470impl From<SharedString> for SectionHeader {
471    fn from(val: SharedString) -> Self {
472        SectionHeader::new(val)
473    }
474}
475
476impl From<&'static str> for SectionHeader {
477    fn from(val: &'static str) -> Self {
478        let label: SharedString = val.into();
479        SectionHeader::new(label)
480    }
481}
482
483impl component::Component for Modal {
484    fn scope() -> ComponentScope {
485        ComponentScope::Overlays
486    }
487
488    fn description() -> Option<&'static str> {
489        Some("A dialog surface with header/body/footer sections, raised above the app content.")
490    }
491
492    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
493        Some(
494            v_flex()
495                .gap_6()
496                .child(example_group(vec![single_example(
497                    "Basic",
498                    div()
499                        .w(px(420.))
500                        .h(px(280.))
501                        .child(
502                            Modal::new("modal-preview", None)
503                                .header(
504                                    ModalHeader::new()
505                                        .headline("Modal title")
506                                        .show_dismiss_button(true),
507                                )
508                                .section(
509                                    Section::new()
510                                        .child(Label::new("Modal body content goes here.")),
511                                )
512                                .footer(
513                                    ModalFooter::new().end_slot(
514                                        h_flex()
515                                            .gap_2()
516                                            .child(
517                                                Button::new("cancel", "Cancel").color(Color::Muted),
518                                            )
519                                            .child(Button::new("confirm", "Confirm").primary()),
520                                    ),
521                                ),
522                        )
523                        .into_any_element(),
524                )]))
525                .into_any_element(),
526        )
527    }
528}