Skip to main content

gpui_kit/overlay/
layer.rs

1//! Placement, stacking, and dismissal for surfaces that float above content.
2//!
3//! Paint order comes from the `zIndex` tokens rather than from the order in
4//! which a view happens to build its children, so a tooltip raised inside a
5//! modal still paints above it.
6
7use std::rc::Rc;
8
9use gpui::{
10    Anchor, AnyElement, App, ClickEvent, Div, ElementId, IntoElement, Pixels, Point, RenderOnce,
11    Stateful, Window, div, prelude::*, px,
12};
13use gpui_kit_theme::{Elevation, Layer, Theme};
14
15use crate::foundation::{ActiveTheme, Ident, StyledExt};
16
17type DismissHandler = Rc<dyn Fn(&mut Window, &mut App)>;
18
19/// One side of the window.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Edge {
22    Left,
23    Right,
24    Top,
25    Bottom,
26}
27
28impl Edge {
29    /// True when the surface stretches vertically and is pinned horizontally.
30    pub fn is_horizontal(self) -> bool {
31        matches!(self, Self::Left | Self::Right)
32    }
33
34    /// True when the surface hangs off the low end of its axis.
35    pub fn is_leading(self) -> bool {
36        matches!(self, Self::Left | Self::Top)
37    }
38}
39
40/// Where a floating surface sits.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum Placement {
43    /// Below the anchor element, left edges aligned.
44    Below,
45    /// Above the anchor element, left edges aligned.
46    Above,
47    /// At an absolute window position, such as a cursor. A surface that would
48    /// leave the viewport flips to the other side of that position.
49    At(Point<Pixels>),
50    /// Centered in the window.
51    Center,
52    /// Pinned to one side of the window and stretched along it.
53    Edge(Edge),
54}
55
56/// A floating surface.
57///
58/// The caller owns whether the overlay exists at all; this type owns only
59/// where it paints, what sits behind it, and how a dismissal is reported.
60#[derive(IntoElement)]
61pub struct Overlay {
62    ident: Ident,
63    layer: Layer,
64    placement: Placement,
65    window_snap_margin: Option<Pixels>,
66    scrim: bool,
67    content: Option<AnyElement>,
68    on_dismiss: Option<DismissHandler>,
69}
70
71impl std::fmt::Debug for Overlay {
72    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        formatter
74            .debug_struct("Overlay")
75            .field("ident", &self.ident)
76            .field("layer", &self.layer)
77            .field("placement", &self.placement)
78            .field("window_snap_margin", &self.window_snap_margin)
79            .field("scrim", &self.scrim)
80            .field("dismissible", &self.on_dismiss.is_some())
81            .finish()
82    }
83}
84
85impl Overlay {
86    pub fn new(ident: impl Into<Ident>) -> Self {
87        Self {
88            ident: ident.into(),
89            layer: Layer::Popover,
90            placement: Placement::Below,
91            window_snap_margin: None,
92            scrim: false,
93            content: None,
94            on_dismiss: None,
95        }
96    }
97
98    /// A dialog: centered, on the modal layer, behind a scrim.
99    pub fn modal(ident: impl Into<Ident>) -> Self {
100        Self::new(ident)
101            .layer(Layer::Modal)
102            .placement(Placement::Center)
103            .scrim(true)
104    }
105
106    /// A drawer: pinned to one side of the window, on the modal layer, behind
107    /// a scrim.
108    pub fn edge(ident: impl Into<Ident>, edge: Edge) -> Self {
109        Self::new(ident)
110            .layer(Layer::Modal)
111            .placement(Placement::Edge(edge))
112            .scrim(true)
113    }
114
115    pub fn layer(mut self, layer: Layer) -> Self {
116        self.layer = layer;
117        self
118    }
119
120    pub fn placement(mut self, placement: Placement) -> Self {
121        self.placement = placement;
122        self
123    }
124
125    /// Keeps an already side-resolved anchored surface inside the window.
126    ///
127    /// Choosing above or below remains the caller's policy because only the
128    /// caller knows the surface's effective height. This is the final collision
129    /// guard for the window edges.
130    pub(crate) fn window_snap_margin(mut self, margin: Pixels) -> Self {
131        self.window_snap_margin = Some(margin);
132        self
133    }
134
135    /// Dims and blocks the content behind the overlay.
136    pub fn scrim(mut self, scrim: bool) -> Self {
137        self.scrim = scrim;
138        self
139    }
140
141    pub fn child(mut self, content: impl IntoElement) -> Self {
142        self.content = Some(content.into_any_element());
143        self
144    }
145
146    /// Reports a click on the scrim. Escape is the caller's to bind, because
147    /// only the caller knows which action closing should dispatch.
148    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
149        self.on_dismiss = Some(Rc::new(handler));
150        self
151    }
152
153    fn anchor(&self) -> Anchor {
154        match self.placement {
155            Placement::Above => Anchor::BottomLeft,
156            _ => Anchor::TopLeft,
157        }
158    }
159}
160
161impl RenderOnce for Overlay {
162    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
163        let theme = cx.theme().clone();
164        // The overlay is painted out of its parent's layout, so the scrim has
165        // to be sized to the window rather than inherited from a parent.
166        let viewport = window.viewport_size();
167        let element_id: ElementId = self.ident.element_id();
168        let anchor = self.anchor();
169        let content = self.content.unwrap_or_else(|| div().into_any_element());
170
171        let surface = div()
172            .id(element_id)
173            .occlude()
174            .child(content)
175            .into_any_element();
176
177        // An anchored surface flips to the opposite corner rather than being
178        // slid along the edge, so a menu that would leave the viewport still
179        // hangs off its anchor instead of covering it.
180        let mut anchored = gpui::anchored().anchor(anchor);
181        if let Placement::At(position) = self.placement {
182            anchored = anchored.position(position);
183        }
184        if let Some(margin) = self.window_snap_margin {
185            anchored = anchored.snap_to_window_with_margin(margin);
186        } else if self.placement == Placement::Center {
187            anchored = anchored.snap_to_window_with_margin(px(theme.spacing.sm));
188        }
189
190        let placed = match self.placement {
191            Placement::Center => scrim_frame(&theme, viewport, self.scrim, self.on_dismiss.clone())
192                .items_center()
193                .justify_center()
194                .child(surface)
195                .into_any_element(),
196            // The surface keeps its own size along the pinned axis and is
197            // left to stretch across the other one, which is what makes a
198            // drawer reach both ends of the side it hangs from.
199            Placement::Edge(edge) => {
200                scrim_frame(&theme, viewport, self.scrim, self.on_dismiss.clone())
201                    .map(|frame| {
202                        if edge.is_horizontal() {
203                            frame.flex_row()
204                        } else {
205                            frame.flex_col()
206                        }
207                    })
208                    .map(|frame| {
209                        if edge.is_leading() {
210                            frame.justify_start()
211                        } else {
212                            frame.justify_end()
213                        }
214                    })
215                    .child(surface)
216                    .into_any_element()
217            }
218            _ if self.scrim => scrim_frame(&theme, viewport, true, self.on_dismiss.clone())
219                .child(anchored.child(surface))
220                .into_any_element(),
221            _ => anchored.child(surface).into_any_element(),
222        };
223
224        // Deferred painting is what lifts the overlay out of its parent's
225        // stacking context; the token layer decides the order among overlays.
226        pinned(
227            gpui::deferred(placed)
228                .priority(priority(&theme, self.layer))
229                .into_any_element(),
230        )
231    }
232}
233
234/// A surface at one elevation, sized to its content.
235pub fn surface(theme: &Theme, elevation: Elevation) -> Div {
236    div()
237        .column()
238        .bg(theme.colors.overlay)
239        .radius(theme, gpui_kit_theme::Radius::Card)
240        .elevation(theme, elevation)
241        .overflow_hidden()
242        .text_color(theme.colors.text)
243}
244
245/// Maps a token layer onto GPUI's deferred paint priority.
246pub fn priority(theme: &Theme, layer: Layer) -> usize {
247    theme.layer(layer).max(0) as usize
248}
249
250fn scrim_frame(
251    theme: &Theme,
252    viewport: gpui::Size<Pixels>,
253    visible: bool,
254    on_dismiss: Option<DismissHandler>,
255) -> Stateful<Div> {
256    let mut frame = div()
257        .id("overlay.scrim")
258        .occlude()
259        .absolute()
260        .top_0()
261        .left_0()
262        .w(viewport.width)
263        .h(viewport.height)
264        .flex();
265    if visible {
266        frame = frame.bg(gpui::black().opacity(theme.opacity.scrim));
267    }
268    if let Some(handler) = on_dismiss {
269        frame = frame.on_click(move |_: &ClickEvent, window, cx| handler(window, cx));
270    }
271    frame
272}
273
274/// Anchors the deferred subtree to the window origin without occupying layout
275/// space in the parent.
276pub(crate) fn pinned(layer: AnyElement) -> AnyElement {
277    div()
278        .absolute()
279        .top_0()
280        .left_0()
281        .size_0()
282        .child(layer)
283        .into_any_element()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn layers_paint_in_token_order() {
292        let theme = Theme::studio_dark();
293        assert!(priority(&theme, Layer::Tooltip) > priority(&theme, Layer::Popover));
294        assert!(priority(&theme, Layer::Toast) > priority(&theme, Layer::Modal));
295        assert_eq!(priority(&theme, Layer::Content), 0);
296    }
297
298    #[test]
299    fn a_modal_defaults_to_a_centered_scrimmed_dialog() {
300        let overlay = Overlay::modal("confirm");
301        assert_eq!(overlay.layer, Layer::Modal);
302        assert_eq!(overlay.placement, Placement::Center);
303        assert!(overlay.scrim);
304    }
305
306    #[test]
307    fn placement_decides_which_edge_the_surface_hangs_from() {
308        assert_eq!(
309            Overlay::new("menu").placement(Placement::Above).anchor(),
310            Anchor::BottomLeft
311        );
312        assert_eq!(
313            Overlay::new("menu").placement(Placement::Below).anchor(),
314            Anchor::TopLeft
315        );
316    }
317}