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