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