Skip to main content

gpui_component/
window_border.rs

1// From:
2// https://github.com/zed-industries/zed/blob/56daba28d40301ee4c05546fadb691d070b7b2b6/crates/gpui/examples/window_shadow.rs
3use gpui::{
4    AnyElement, App, Corners, CursorStyle, Decorations, Edges, Hsla, InteractiveElement as _,
5    IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, ResizeEdge, Size,
6    Styled as _, Tiling, Window, div, point, prelude::FluentBuilder as _, px,
7};
8
9use crate::ActiveTheme;
10
11#[cfg(not(target_os = "linux"))]
12pub(crate) const SHADOW_SIZE: Pixels = px(0.0);
13#[cfg(target_os = "linux")]
14pub(crate) const SHADOW_SIZE: Pixels = px(20.0);
15const BORDER_SIZE: Pixels = px(1.0);
16/// Half-width of the resize hit band on each side of the visible frame (inner border).
17const RESIZE_HIT_SIZE: Pixels = px(4.0);
18
19/// The window's current corner radii: the theme's `window.radius`
20/// ([`crate::Theme::window_radius`]) for every corner of a client-decorated
21/// window that isn't next to a tiled edge, zero otherwise.
22///
23/// GPUI clips overflowing children to a rectangular content mask, so a non-zero
24/// radius only rounds the frame itself. Elements with a background that touch a
25/// window corner (the root surface, [`crate::TitleBar`], an application's bottom
26/// bar) must round that corner too, for example
27/// `div().rounded_bl(radii.bottom_left)`. The default stays square.
28pub fn window_corner_radii(window: &Window, cx: &App) -> Corners<Pixels> {
29    corner_radii(cx.theme().window_radius, window.window_decorations())
30}
31
32fn corner_radii(radius: Pixels, decorations: Decorations) -> Corners<Pixels> {
33    let Decorations::Client { tiling } = decorations else {
34        return Corners::default();
35    };
36    let corner = |rounded: bool| if rounded { radius } else { px(0.0) };
37    Corners {
38        top_left: corner(!tiling.top && !tiling.left),
39        top_right: corner(!tiling.top && !tiling.right),
40        bottom_left: corner(!tiling.bottom && !tiling.left),
41        bottom_right: corner(!tiling.bottom && !tiling.right),
42    }
43}
44
45/// Create a new window border.
46pub fn window_border() -> WindowBorder {
47    WindowBorder::new()
48}
49
50/// Renders a custom window border and shadow on Linux.
51#[derive(IntoElement)]
52pub struct WindowBorder {
53    shadow_size: Pixels,
54    resize_hit_size: Pixels,
55    children: Vec<AnyElement>,
56}
57
58impl Default for WindowBorder {
59    fn default() -> Self {
60        Self {
61            shadow_size: SHADOW_SIZE,
62            resize_hit_size: RESIZE_HIT_SIZE,
63            children: Vec::new(),
64        }
65    }
66}
67
68impl WindowBorder {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Set the shadow size for typical Linux client-side decorations.
74    ///
75    /// Default: [`SHADOW_SIZE`]
76    pub fn shadow_size(mut self, size: impl Into<Pixels>) -> Self {
77        self.shadow_size = size.into();
78        self
79    }
80
81    /// Set the resize hit band half-width around the visible inner frame edge.
82    ///
83    /// Default: [`RESIZE_HIT_SIZE`]
84    pub fn resize_hit_size(mut self, size: impl Into<Pixels>) -> Self {
85        self.resize_hit_size = size.into();
86        self
87    }
88}
89
90/// Per-side inset of the visible frame from the outer window bounds.
91fn client_frame_insets(shadow_size: Pixels, tiling: &Tiling) -> Edges<Pixels> {
92    let mut insets = Edges::all(shadow_size);
93    if tiling.top {
94        insets.top = px(0.0);
95    }
96    if tiling.bottom {
97        insets.bottom = px(0.0);
98    }
99    if tiling.left {
100        insets.left = px(0.0);
101    }
102    if tiling.right {
103        insets.right = px(0.0);
104    }
105    insets
106}
107
108/// Get the window paddings.
109pub fn window_paddings(window: &Window) -> Edges<Pixels> {
110    let shadow_size = window.client_inset().unwrap_or(SHADOW_SIZE);
111    match window.window_decorations() {
112        Decorations::Server => Edges::all(px(0.0)),
113        Decorations::Client { tiling } => client_frame_insets(shadow_size, &tiling),
114    }
115}
116
117/// Per-side inset from the window bounds to the visible frame's content area.
118///
119/// This is [`window_paddings`] plus the frame's own border, which the window
120/// wrapper draws on every side it is not tiled against. Allows to lay an element
121/// flush against the inside of the window frame.
122pub(crate) fn window_content_insets(window: &Window) -> Edges<Pixels> {
123    let shadow_size = window.client_inset().unwrap_or(SHADOW_SIZE);
124    match window.window_decorations() {
125        Decorations::Server => Edges::all(px(0.0)),
126        Decorations::Client { tiling } => {
127            let mut insets = client_frame_insets(shadow_size, &tiling);
128            if !tiling.top {
129                insets.top += BORDER_SIZE;
130            }
131            if !tiling.bottom {
132                insets.bottom += BORDER_SIZE;
133            }
134            if !tiling.left {
135                insets.left += BORDER_SIZE;
136            }
137            if !tiling.right {
138                insets.right += BORDER_SIZE;
139            }
140            insets
141        }
142    }
143}
144
145/// Per-side inset from the window bounds to the part of the window that is
146/// fully visible: not under the system bars, a display cutout or the on-screen
147/// keyboard ([`Window::fully_visible_bounds`]). Zero on desktop.
148pub(crate) fn window_safe_insets(window: &Window) -> Edges<Pixels> {
149    let viewport = window.viewport_size();
150    let visible = window.fully_visible_bounds();
151    Edges {
152        top: visible.top(),
153        right: viewport.width - visible.right(),
154        bottom: viewport.height - visible.bottom(),
155        left: visible.left(),
156    }
157}
158
159impl ParentElement for WindowBorder {
160    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
161        self.children.extend(elements);
162    }
163}
164
165impl RenderOnce for WindowBorder {
166    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
167        let decorations = window.window_decorations();
168        let children = self.children;
169        if !uses_client_border(decorations) {
170            // Keep the same `window-backdrop` id as the client-decorated frame below. The
171            // decorations can change at runtime (an app switching to server-side decorations,
172            // or a compositor granting a different mode), and without the shared id every
173            // element id path in the window would change, dropping all keyed element state.
174            return div()
175                .id("window-backdrop")
176                .size_full()
177                .children(children)
178                .into_any_element();
179        }
180        let Decorations::Client { tiling } = decorations else {
181            unreachable!("client border requires client decorations");
182        };
183
184        // Keep the platform client inset stable. When the window is tiled on all sides we stop drawing
185        // shadow padding, but `set_client_inset` must still use the full shadow size. Clearing it
186        // makes the first resize after restore double-count the shadow in `compute_outer_size`, and
187        // the window jumps larger.
188        let platform_inset = self.shadow_size;
189        let visual_shadow = if tiling.top && tiling.bottom && tiling.left && tiling.right {
190            px(0.0)
191        } else {
192            self.shadow_size
193        };
194        let resize_hit_size = self.resize_hit_size;
195        window.set_client_inset(platform_inset);
196        let radii = window_corner_radii(window, cx);
197        // Lets the compositor treat the rounded corners as transparent.
198        window.set_client_corner_radii(radii);
199        let window_size = window.window_bounds().get_bounds().size;
200        let is_window_active = window.is_window_active();
201        let border_color = cx.theme().window_border;
202
203        div()
204            .id("window-backdrop")
205            .bg(gpui::transparent_black())
206            .flex()
207            .flex_col()
208            .overflow_hidden()
209            .rounded_tl(radii.top_left)
210            .rounded_tr(radii.top_right)
211            .rounded_bl(radii.bottom_left)
212            .rounded_br(radii.bottom_right)
213            .when(!tiling.top, |div| div.pt(visual_shadow))
214            .when(!tiling.bottom, |div| div.pb(visual_shadow))
215            .when(!tiling.left, |div| div.pl(visual_shadow))
216            .when(!tiling.right, |div| div.pr(visual_shadow))
217            .on_mouse_down(MouseButton::Left, move |_, window, _| {
218                let Decorations::Client { tiling } = window.window_decorations() else {
219                    return;
220                };
221                if !window.is_resizable() {
222                    return;
223                }
224                if tiling.top && tiling.bottom && tiling.left && tiling.right {
225                    return;
226                }
227                let size = window.window_bounds().get_bounds().size;
228                let pos = window.mouse_position();
229                let insets = client_frame_insets(platform_inset, &tiling);
230
231                match resize_edge(pos, size, insets, &tiling, resize_hit_size) {
232                    Some(edge) => window.start_window_resize(edge),
233                    None => {}
234                };
235            })
236            .size_full()
237            .child(
238                div()
239                    .cursor(CursorStyle::default())
240                    .flex_1()
241                    .min_h_0()
242                    .min_w_0()
243                    .overflow_hidden()
244                    .rounded_tl(radii.top_left)
245                    .rounded_tr(radii.top_right)
246                    .rounded_bl(radii.bottom_left)
247                    .rounded_br(radii.bottom_right)
248                    .border_color(border_color)
249                    .when(!tiling.top, |div| div.border_t(BORDER_SIZE))
250                    .when(!tiling.bottom, |div| div.border_b(BORDER_SIZE))
251                    .when(!tiling.left, |div| div.border_l(BORDER_SIZE))
252                    .when(!tiling.right, |div| div.border_r(BORDER_SIZE))
253                    .when(!tiling.is_tiled(), |div| {
254                        let opacity = if is_window_active { 1.0 } else { 0.7 };
255                        div.shadow(vec![
256                            // Keep the effective outer reach below SHADOW_SIZE. GPUI
257                            // does not grow the paint bounds for blur, so a larger blur
258                            // or offset would be visibly cut off by the window surface.
259                            gpui::BoxShadow {
260                                color: Hsla {
261                                    h: 0.,
262                                    s: 0.,
263                                    l: 0.,
264                                    a: 0.18 * opacity,
265                                },
266                                // GNOME-style ambient shadow: horizontally centered
267                                // with only a slight downward bias.
268                                blur_radius: px(10.),
269                                spread_radius: px(-1.),
270                                offset: point(px(0.0), px(2.0)),
271                                inset: false,
272                            },
273                            // The contact layer adds definition without increasing the
274                            // space between the content and the outer window bounds.
275                            gpui::BoxShadow {
276                                color: Hsla {
277                                    h: 0.,
278                                    s: 0.,
279                                    l: 0.,
280                                    a: 0.18 * opacity,
281                                },
282                                blur_radius: px(3.),
283                                spread_radius: px(0.),
284                                offset: point(px(0.0), px(1.0)),
285                                inset: false,
286                            },
287                        ])
288                    })
289                    .on_mouse_move(|_e, _, cx| {
290                        cx.stop_propagation();
291                    })
292                    .bg(gpui::transparent_black())
293                    .children(children),
294            )
295            .when(window.is_resizable(), |parent| {
296                parent.child(div().absolute().size_full().children(resize_hit_zones(
297                    window_size,
298                    platform_inset,
299                    resize_hit_size,
300                    &tiling,
301                )))
302            })
303            .into_any_element()
304    }
305}
306
307fn uses_client_border(decorations: Decorations) -> bool {
308    matches!(decorations, Decorations::Client { .. })
309}
310
311fn cursor_style_for_resize_edge(edge: ResizeEdge) -> CursorStyle {
312    match edge {
313        ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
314        ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
315        ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
316        ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    #[test]
323    fn corners_round_only_next_to_untiled_edges() {
324        use super::corner_radii;
325        use gpui::{Corners, Decorations, Tiling, px};
326
327        let radius = px(10.0);
328        assert_eq!(
329            corner_radii(
330                radius,
331                Decorations::Client {
332                    tiling: Tiling::default()
333                }
334            ),
335            Corners::all(radius)
336        );
337        let top_tiled = Tiling {
338            top: true,
339            ..Tiling::default()
340        };
341        assert_eq!(
342            corner_radii(radius, Decorations::Client { tiling: top_tiled }),
343            Corners {
344                top_left: px(0.0),
345                top_right: px(0.0),
346                bottom_left: radius,
347                bottom_right: radius,
348            }
349        );
350        assert_eq!(
351            corner_radii(radius, Decorations::Server),
352            Corners::default()
353        );
354    }
355
356    use super::*;
357
358    #[test]
359    fn only_client_decorated_windows_use_the_client_border() {
360        assert!(!uses_client_border(Decorations::Server));
361        assert!(uses_client_border(Decorations::Client {
362            tiling: Tiling::default(),
363        }));
364    }
365}
366
367/// Cursor-only overlay for each resize edge/corner. Resize starts from the backdrop
368/// `on_mouse_down` via [`resize_edge`]. `.cursor()` updates immediately on hitbox changes
369/// without `window.refresh()` (PR #617).
370fn resize_hit_zones(
371    window_size: Size<Pixels>,
372    shadow_size: Pixels,
373    hit_size: Pixels,
374    tiling: &Tiling,
375) -> Vec<AnyElement> {
376    if tiling.top && tiling.bottom && tiling.left && tiling.right {
377        return Vec::new();
378    }
379
380    let insets = client_frame_insets(shadow_size, tiling);
381    let inner_left = insets.left;
382    let inner_right = window_size.width - insets.right;
383    let inner_top = insets.top;
384    let inner_bottom = window_size.height - insets.bottom;
385    // Overlay is laid out in the padded content box; convert from window coords.
386    let frame_origin = point(insets.left, insets.top);
387    let band = hit_size + hit_size;
388    let span_x = inner_right - inner_left + band;
389    let span_y = inner_bottom - inner_top + band;
390
391    let mut zones: Vec<AnyElement> = Vec::new();
392
393    let mut push_zone = |edge: ResizeEdge, origin: Point<Pixels>, zone_size: Size<Pixels>| {
394        let origin = origin - frame_origin;
395        zones.push(
396            div()
397                .absolute()
398                .left(origin.x)
399                .top(origin.y)
400                .w(zone_size.width)
401                .h(zone_size.height)
402                .cursor(cursor_style_for_resize_edge(edge))
403                .into_any_element(),
404        );
405    };
406
407    if !tiling.top {
408        push_zone(
409            ResizeEdge::Top,
410            point(inner_left - hit_size, inner_top - hit_size),
411            Size::new(span_x, band),
412        );
413    }
414    if !tiling.bottom {
415        push_zone(
416            ResizeEdge::Bottom,
417            point(inner_left - hit_size, inner_bottom - hit_size),
418            Size::new(span_x, band),
419        );
420    }
421    if !tiling.left {
422        push_zone(
423            ResizeEdge::Left,
424            point(inner_left - hit_size, inner_top - hit_size),
425            Size::new(band, span_y),
426        );
427    }
428    if !tiling.right {
429        push_zone(
430            ResizeEdge::Right,
431            point(inner_right - hit_size, inner_top - hit_size),
432            Size::new(band, span_y),
433        );
434    }
435
436    // Corners are pushed after edge strips so hit-testing prefers them over adjacent edges.
437    if !tiling.top && !tiling.left {
438        push_zone(
439            ResizeEdge::TopLeft,
440            point(inner_left - hit_size, inner_top - hit_size),
441            Size::new(band, band),
442        );
443    }
444    if !tiling.top && !tiling.right {
445        push_zone(
446            ResizeEdge::TopRight,
447            point(inner_right - hit_size, inner_top - hit_size),
448            Size::new(band, band),
449        );
450    }
451    if !tiling.bottom && !tiling.left {
452        push_zone(
453            ResizeEdge::BottomLeft,
454            point(inner_left - hit_size, inner_bottom - hit_size),
455            Size::new(band, band),
456        );
457    }
458    if !tiling.bottom && !tiling.right {
459        push_zone(
460            ResizeEdge::BottomRight,
461            point(inner_right - hit_size, inner_bottom - hit_size),
462            Size::new(band, band),
463        );
464    }
465
466    zones
467}
468
469/// Hit-test resize edges on a narrow band around the visible inner frame, not the full shadow padding.
470fn resize_edge(
471    pos: Point<Pixels>,
472    size: Size<Pixels>,
473    insets: Edges<Pixels>,
474    tiling: &Tiling,
475    hit_size: Pixels,
476) -> Option<ResizeEdge> {
477    let inner_left = insets.left;
478    let inner_right = size.width - insets.right;
479    let inner_top = insets.top;
480    let inner_bottom = size.height - insets.bottom;
481
482    // Each edge only applies along its corresponding inner-frame segment; it does not extend along the "extension lines" of the shadow padding.
483    let on_left = pos.x >= inner_left - hit_size
484        && pos.x <= inner_left + hit_size
485        && pos.y >= inner_top - hit_size
486        && pos.y <= inner_bottom + hit_size;
487    let on_right = pos.x >= inner_right - hit_size
488        && pos.x <= inner_right + hit_size
489        && pos.y >= inner_top - hit_size
490        && pos.y <= inner_bottom + hit_size;
491    let on_top = pos.y >= inner_top - hit_size
492        && pos.y <= inner_top + hit_size
493        && pos.x >= inner_left - hit_size
494        && pos.x <= inner_right + hit_size;
495    let on_bottom = pos.y >= inner_bottom - hit_size
496        && pos.y <= inner_bottom + hit_size
497        && pos.x >= inner_left - hit_size
498        && pos.x <= inner_right + hit_size;
499
500    if !tiling.top && !tiling.left && on_top && on_left {
501        return Some(ResizeEdge::TopLeft);
502    }
503    if !tiling.top && !tiling.right && on_top && on_right {
504        return Some(ResizeEdge::TopRight);
505    }
506    if !tiling.bottom && !tiling.left && on_bottom && on_left {
507        return Some(ResizeEdge::BottomLeft);
508    }
509    if !tiling.bottom && !tiling.right && on_bottom && on_right {
510        return Some(ResizeEdge::BottomRight);
511    }
512    if !tiling.top && on_top {
513        return Some(ResizeEdge::Top);
514    }
515    if !tiling.bottom && on_bottom {
516        return Some(ResizeEdge::Bottom);
517    }
518    if !tiling.left && on_left {
519        return Some(ResizeEdge::Left);
520    }
521    if !tiling.right && on_right {
522        return Some(ResizeEdge::Right);
523    }
524    None
525}