Skip to main content

ui/popover/
modal.rs

1//! Modals, and sheets pinned to an edge.
2
3use super::*;
4
5/// Modal/overlay scrim at the *current* appearance, quoted in dark-mode terms
6/// like [`ink`]/[`hairline`] — for callers (`modal`, the attachment lightbox)
7/// that paint from a `deferred`/`anchored` layer with no `Theme`/`cx` in
8/// scope. Mirrors [`Theme::scrim`], which is pinned at `X = 0.6` dark /
9/// `0.32` light; other dark-mode alphas scale the light side by the same
10/// ratio so the *dark* result is always exactly `alpha_dark` (never routed
11/// through [`Hsla::opacity`], whose `0..=1` clamp would clip a
12/// larger-than-0.6 alpha before it could scale the light side).
13pub(crate) fn scrim_alpha(alpha_dark: f32) -> gpui::Hsla {
14    theme::scrim(alpha_dark)
15}
16
17/// Full-window modal: dim scrim + centered card with the `dialog-in` entrance.
18/// The scrim swallows clicks; the caller wires its own dismiss/confirm.
19/// `viewport` is the window size (an `anchored` layer sizes to its children,
20/// so the scrim needs explicit dimensions). The frost radius matches
21/// [`dialog_card`]'s 16px rounding.
22/// `on_dismiss` is the scrim press. It is a parameter rather than the caller's
23/// `.on_mouse_down_out`, for the same reason [`sheet`]'s is: the scrim lives
24/// inside this deferred layer, so nothing outside can reach it. Without it a
25/// dialog could not be dismissed by clicking away from it *by any caller* —
26/// which is how this one shipped, and what it looked like was a dialog that
27/// only closed on its own buttons.
28pub fn modal(
29    id: impl Into<ElementId>,
30    viewport: gpui::Size<Pixels>,
31    card: AnyElement,
32    on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
33) -> AnyElement {
34    modal_with(id, viewport, card, DIALOG_RADIUS, 0.6, on_dismiss)
35}
36
37/// [`modal`] for glass-tinted cards (the add-space palette): a LIGHTER scrim,
38/// so the material card reads like the popovers — the standard 0.6 dim buried
39/// the backdrop hue under the blur and the palette came out a flat grey slab
40/// next to the hue-inheriting menus (user report).
41///
42/// The radius is [`Theme::surface_radius`], not a parameter: a glass-tinted
43/// modal *is* a popover surface, and the parameter this used to take carried
44/// the doc line "must match the card's rounding" — a footgun handed to the
45/// caller in writing.
46pub fn modal_glass(
47    id: impl Into<ElementId>,
48    viewport: gpui::Size<Pixels>,
49    card: AnyElement,
50    on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
51) -> AnyElement {
52    modal_with(
53        id,
54        viewport,
55        card,
56        Theme::surface_radius(),
57        0.35,
58        on_dismiss,
59    )
60}
61
62pub(super) fn modal_with(
63    id: impl Into<ElementId>,
64    viewport: gpui::Size<Pixels>,
65    card: AnyElement,
66    corner_radius: f32,
67    scrim: f32,
68    on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
69) -> AnyElement {
70    let card = crate::surface::popover(corner_radius, card).into_any_element();
71    gpui::deferred(
72        gpui::anchored()
73            .position(gpui::point(px(0.0), px(0.0)))
74            .child(
75                div()
76                    .occlude()
77                    .w(viewport.width)
78                    .h(viewport.height)
79                    .bg(scrim_alpha(scrim))
80                    .flex()
81                    .items_center()
82                    .justify_center()
83                    .child(crate::cover::cover())
84                    // On the card's wrapper, not the scrim: a press inside the
85                    // card is not "out", so the dialog's own buttons keep
86                    // working with no occluding overlay and no propagation
87                    // games. The scrim covers the viewport and occludes, so
88                    // "outside the card" and "on the scrim" are the same press.
89                    .child(motion::dialog_in(
90                        id,
91                        div().child(card).on_mouse_down_out(on_dismiss),
92                    )),
93            ),
94    )
95    .priority(2)
96    .into_any_element()
97}
98
99// ---------------------------------------------------------------------------
100// Sheet — a dialog pinned to an edge
101// ---------------------------------------------------------------------------
102
103/// Which edge a [`sheet`] slides in from.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Side {
106    Left,
107    Right,
108    /// Up from the bottom edge, full width — the shape a phone puts a picker
109    /// or a share list in, and what a narrow window wants instead of a side
110    /// panel that leaves no room for the page behind it.
111    Bottom,
112}
113
114impl Side {
115    /// Which way the panel travels. The extent a [`sheet`] is given is read
116    /// along this axis: a width for the vertical edges, a height for the
117    /// horizontal one.
118    fn axis(self) -> gpui::Axis {
119        match self {
120            Side::Left | Side::Right => gpui::Axis::Horizontal,
121            Side::Bottom => gpui::Axis::Vertical,
122        }
123    }
124}
125
126/// Corner rounding of [`dialog_card`], and of a [`sheet_panel`]'s two inner
127/// corners (the two on the window edge are off-screen). One number rather than
128/// two that happen to match: a sheet *is* the dialog card, pinned to an edge
129/// instead of centred. Read three times over — the card, the sheet panel, and
130/// the blur under each — which is exactly why it is not a literal.
131pub(super) const DIALOG_RADIUS: f32 = 16.0;
132
133/// The panel body of a [`sheet`]: glass card chrome rounded and hairlined on
134/// its *inner* edge only — the corners against the window edge are off-screen
135/// — so it reads as pulled out of the window rather than floating near it.
136pub fn sheet_panel(theme: &Theme, side: Side) -> gpui::Div {
137    let card = div()
138        .size_full()
139        .flex()
140        .flex_col()
141        .shadow_lg()
142        .text_color(theme.text);
143    let card = match side {
144        Side::Left => card
145            .rounded_r(px(DIALOG_RADIUS))
146            .border_r_1()
147            .border_color(hairline(0.10)),
148        Side::Right => card
149            .rounded_l(px(DIALOG_RADIUS))
150            .border_l_1()
151            .border_color(hairline(0.10)),
152        Side::Bottom => card
153            .rounded_t(px(DIALOG_RADIUS))
154            .border_t_1()
155            .border_color(hairline(0.10)),
156    };
157    if theme.glass {
158        card.bg(theme.glass_overlay())
159    } else {
160        card.bg(theme.surface_overlay)
161    }
162}
163
164/// A panel over a dim scrim — [`modal`] pinned to an edge. It slides in over
165/// [`motion::DIALOG_IN`] and, once the caller's [`Popup`] enters its exit
166/// phase, back out over [`motion::MENU_OUT`] — which it must, because
167/// [`Popup::finish_close`] reaps on that spec's span.
168///
169/// `extent` is measured across the edge the sheet is pinned to: a width on
170/// [`Side::Left`] and [`Side::Right`], a height on [`Side::Bottom`]. The other
171/// axis always spans the viewport.
172///
173/// `on_dismiss` is the scrim click. Unlike the anchored menus, dismissal
174/// cannot be the caller's `.on_mouse_down_out`: the scrim lives inside this
175/// deferred layer, so nothing outside can reach it.
176///
177/// The slide is written here rather than as a `motion` helper because
178/// only the *spec* is motion — which inset carries it is layout, and it
179/// differs per side.
180pub fn sheet(
181    id: impl Into<SharedString>,
182    viewport: gpui::Size<Pixels>,
183    side: Side,
184    extent: Pixels,
185    content: AnyElement,
186    closing: Option<web_time::Instant>,
187    on_dismiss: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
188) -> AnyElement {
189    let id = id.into();
190    let exit = closing.map(exit_progress);
191    // The panel spans the edge it is pinned to and takes `extent` across it,
192    // which is why the argument is not called a width: on [`Side::Bottom`] it
193    // is the height.
194    let panel = div().absolute();
195    let panel = match side.axis() {
196        gpui::Axis::Horizontal => panel.top_0().bottom_0().w(extent),
197        gpui::Axis::Vertical => panel.left_0().right_0().h(extent),
198    };
199    let panel = panel.child(crate::surface::popover(DIALOG_RADIUS, content));
200    // `t` runs 0 (fully off-screen) → 1 (seated against the edge).
201    let seat = move |el: gpui::Div, t: f32| {
202        let inset = extent * (t - 1.0);
203        match side {
204            Side::Left => el.left(inset),
205            Side::Right => el.right(inset),
206            Side::Bottom => el.bottom(inset),
207        }
208    };
209    let panel = if let Some(t) = exit {
210        // The dying panel must not take clicks — same overlay `menu_motion`
211        // puts over an exiting menu.
212        let panel = seat(panel, 1.0 - t).child(div().absolute().inset_0().occlude());
213        panel
214            .with_animation(
215                SharedString::from(format!("{id}-out")),
216                motion::MENU_OUT.animation(),
217                move |el, _| el,
218            )
219            .into_any_element()
220    } else {
221        panel
222            .with_animation(id.clone(), motion::DIALOG_IN.animation(), seat)
223            .into_any_element()
224    };
225
226    gpui::deferred(
227        gpui::anchored()
228            .position(gpui::point(px(0.0), px(0.0)))
229            .child(
230                div()
231                    .id(SharedString::from(format!("{id}-scrim")))
232                    .occlude()
233                    .relative()
234                    .w(viewport.width)
235                    .h(viewport.height)
236                    .bg(scrim_alpha(0.6 * (1.0 - exit.unwrap_or(0.0))))
237                    .on_click(on_dismiss)
238                    .child(crate::cover::cover())
239                    .child(panel),
240            ),
241    )
242    .priority(2)
243    .into_any_element()
244}