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
124impl ParentElement for WindowBorder {
125    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
126        self.children.extend(elements);
127    }
128}
129
130impl RenderOnce for WindowBorder {
131    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
132        let decorations = window.window_decorations();
133        let mut children = self.children;
134        if !uses_client_border(decorations) {
135            return match children.len() {
136                1 => children.pop().unwrap(),
137                _ => div().size_full().children(children).into_any_element(),
138            };
139        }
140        let Decorations::Client { tiling } = decorations else {
141            unreachable!("client border requires client decorations");
142        };
143
144        // Keep the platform client inset stable. When the window is tiled on all sides we stop drawing
145        // shadow padding, but `set_client_inset` must still use the full shadow size. Clearing it
146        // makes the first resize after restore double-count the shadow in `compute_outer_size`, and
147        // the window jumps larger.
148        let platform_inset = self.shadow_size;
149        let visual_shadow = if tiling.top && tiling.bottom && tiling.left && tiling.right {
150            px(0.0)
151        } else {
152            self.shadow_size
153        };
154        let resize_hit_size = self.resize_hit_size;
155        window.set_client_inset(platform_inset);
156        let window_size = window.window_bounds().get_bounds().size;
157        let is_window_active = window.is_window_active();
158        let border_color = if cx.theme().is_dark() {
159            Hsla {
160                h: 0.,
161                s: 0.,
162                l: 0.2,
163                a: 1.0,
164            }
165        } else {
166            Hsla {
167                h: 0.,
168                s: 0.,
169                l: 0.8,
170                a: 1.0,
171            }
172        };
173
174        div()
175            .id("window-backdrop")
176            .bg(gpui::transparent_black())
177            .flex()
178            .flex_col()
179            .overflow_hidden()
180            .when(!(tiling.top || tiling.right), |div| {
181                div.rounded_tr(BORDER_RADIUS)
182            })
183            .when(!(tiling.top || tiling.left), |div| {
184                div.rounded_tl(BORDER_RADIUS)
185            })
186            .when(!tiling.top, |div| div.pt(visual_shadow))
187            .when(!tiling.bottom, |div| div.pb(visual_shadow))
188            .when(!tiling.left, |div| div.pl(visual_shadow))
189            .when(!tiling.right, |div| div.pr(visual_shadow))
190            .on_mouse_down(MouseButton::Left, move |_, window, _| {
191                let Decorations::Client { tiling } = window.window_decorations() else {
192                    return;
193                };
194                if tiling.top && tiling.bottom && tiling.left && tiling.right {
195                    return;
196                }
197                let size = window.window_bounds().get_bounds().size;
198                let pos = window.mouse_position();
199                let insets = client_frame_insets(platform_inset, &tiling);
200
201                match resize_edge(pos, size, insets, &tiling, resize_hit_size) {
202                    Some(edge) => window.start_window_resize(edge),
203                    None => {}
204                };
205            })
206            .size_full()
207            .child(
208                div()
209                    .cursor(CursorStyle::default())
210                    .flex_1()
211                    .min_h_0()
212                    .min_w_0()
213                    .overflow_hidden()
214                    .when(!(tiling.top || tiling.right), |div| {
215                        div.rounded_tr(BORDER_RADIUS)
216                    })
217                    .when(!(tiling.top || tiling.left), |div| {
218                        div.rounded_tl(BORDER_RADIUS)
219                    })
220                    .border_color(border_color)
221                    .when(!tiling.top, |div| div.border_t(BORDER_SIZE))
222                    .when(!tiling.bottom, |div| div.border_b(BORDER_SIZE))
223                    .when(!tiling.left, |div| div.border_l(BORDER_SIZE))
224                    .when(!tiling.right, |div| div.border_r(BORDER_SIZE))
225                    .when(!tiling.is_tiled(), |div| {
226                        let opacity = if is_window_active { 1.0 } else { 0.7 };
227                        div.shadow(vec![
228                            // Keep the effective outer reach below SHADOW_SIZE. GPUI
229                            // does not grow the paint bounds for blur, so a larger blur
230                            // or offset would be visibly cut off by the window surface.
231                            gpui::BoxShadow {
232                                color: Hsla {
233                                    h: 0.,
234                                    s: 0.,
235                                    l: 0.,
236                                    a: 0.18 * opacity,
237                                },
238                                // GNOME-style ambient shadow: horizontally centered
239                                // with only a slight downward bias.
240                                blur_radius: px(10.),
241                                spread_radius: px(-1.),
242                                offset: point(px(0.0), px(2.0)),
243                                inset: false,
244                            },
245                            // The contact layer adds definition without increasing the
246                            // space between the content and the outer window bounds.
247                            gpui::BoxShadow {
248                                color: Hsla {
249                                    h: 0.,
250                                    s: 0.,
251                                    l: 0.,
252                                    a: 0.18 * opacity,
253                                },
254                                blur_radius: px(3.),
255                                spread_radius: px(0.),
256                                offset: point(px(0.0), px(1.0)),
257                                inset: false,
258                            },
259                        ])
260                    })
261                    .on_mouse_move(|_e, _, cx| {
262                        cx.stop_propagation();
263                    })
264                    .bg(gpui::transparent_black())
265                    .children(children),
266            )
267            .child(div().absolute().size_full().children(resize_hit_zones(
268                window_size,
269                platform_inset,
270                resize_hit_size,
271                &tiling,
272            )))
273            .into_any_element()
274    }
275}
276
277fn uses_client_border(decorations: Decorations) -> bool {
278    matches!(decorations, Decorations::Client { .. })
279}
280
281fn cursor_style_for_resize_edge(edge: ResizeEdge) -> CursorStyle {
282    match edge {
283        ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
284        ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
285        ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
286        ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn only_client_decorated_windows_use_the_client_border() {
296        assert!(!uses_client_border(Decorations::Server));
297        assert!(uses_client_border(Decorations::Client {
298            tiling: Tiling::default(),
299        }));
300    }
301}
302
303/// Cursor-only overlay for each resize edge/corner. Resize starts from the backdrop
304/// `on_mouse_down` via [`resize_edge`]. `.cursor()` updates immediately on hitbox changes
305/// without `window.refresh()` (PR #617).
306fn resize_hit_zones(
307    window_size: Size<Pixels>,
308    shadow_size: Pixels,
309    hit_size: Pixels,
310    tiling: &Tiling,
311) -> Vec<AnyElement> {
312    if tiling.top && tiling.bottom && tiling.left && tiling.right {
313        return Vec::new();
314    }
315
316    let insets = client_frame_insets(shadow_size, tiling);
317    let inner_left = insets.left;
318    let inner_right = window_size.width - insets.right;
319    let inner_top = insets.top;
320    let inner_bottom = window_size.height - insets.bottom;
321    // Overlay is laid out in the padded content box; convert from window coords.
322    let frame_origin = point(insets.left, insets.top);
323    let band = hit_size + hit_size;
324    let span_x = inner_right - inner_left + band;
325    let span_y = inner_bottom - inner_top + band;
326
327    let mut zones: Vec<AnyElement> = Vec::new();
328
329    let mut push_zone = |edge: ResizeEdge, origin: Point<Pixels>, zone_size: Size<Pixels>| {
330        let origin = origin - frame_origin;
331        zones.push(
332            div()
333                .absolute()
334                .left(origin.x)
335                .top(origin.y)
336                .w(zone_size.width)
337                .h(zone_size.height)
338                .cursor(cursor_style_for_resize_edge(edge))
339                .into_any_element(),
340        );
341    };
342
343    if !tiling.top {
344        push_zone(
345            ResizeEdge::Top,
346            point(inner_left - hit_size, inner_top - hit_size),
347            Size::new(span_x, band),
348        );
349    }
350    if !tiling.bottom {
351        push_zone(
352            ResizeEdge::Bottom,
353            point(inner_left - hit_size, inner_bottom - hit_size),
354            Size::new(span_x, band),
355        );
356    }
357    if !tiling.left {
358        push_zone(
359            ResizeEdge::Left,
360            point(inner_left - hit_size, inner_top - hit_size),
361            Size::new(band, span_y),
362        );
363    }
364    if !tiling.right {
365        push_zone(
366            ResizeEdge::Right,
367            point(inner_right - hit_size, inner_top - hit_size),
368            Size::new(band, span_y),
369        );
370    }
371
372    // Corners are pushed after edge strips so hit-testing prefers them over adjacent edges.
373    if !tiling.top && !tiling.left {
374        push_zone(
375            ResizeEdge::TopLeft,
376            point(inner_left - hit_size, inner_top - hit_size),
377            Size::new(band, band),
378        );
379    }
380    if !tiling.top && !tiling.right {
381        push_zone(
382            ResizeEdge::TopRight,
383            point(inner_right - hit_size, inner_top - hit_size),
384            Size::new(band, band),
385        );
386    }
387    if !tiling.bottom && !tiling.left {
388        push_zone(
389            ResizeEdge::BottomLeft,
390            point(inner_left - hit_size, inner_bottom - hit_size),
391            Size::new(band, band),
392        );
393    }
394    if !tiling.bottom && !tiling.right {
395        push_zone(
396            ResizeEdge::BottomRight,
397            point(inner_right - hit_size, inner_bottom - hit_size),
398            Size::new(band, band),
399        );
400    }
401
402    zones
403}
404
405/// Hit-test resize edges on a narrow band around the visible inner frame, not the full shadow padding.
406fn resize_edge(
407    pos: Point<Pixels>,
408    size: Size<Pixels>,
409    insets: Edges<Pixels>,
410    tiling: &Tiling,
411    hit_size: Pixels,
412) -> Option<ResizeEdge> {
413    let inner_left = insets.left;
414    let inner_right = size.width - insets.right;
415    let inner_top = insets.top;
416    let inner_bottom = size.height - insets.bottom;
417
418    // Each edge only applies along its corresponding inner-frame segment; it does not extend along the "extension lines" of the shadow padding.
419    let on_left = pos.x >= inner_left - hit_size
420        && pos.x <= inner_left + hit_size
421        && pos.y >= inner_top - hit_size
422        && pos.y <= inner_bottom + hit_size;
423    let on_right = pos.x >= inner_right - hit_size
424        && pos.x <= inner_right + hit_size
425        && pos.y >= inner_top - hit_size
426        && pos.y <= inner_bottom + hit_size;
427    let on_top = pos.y >= inner_top - hit_size
428        && pos.y <= inner_top + hit_size
429        && pos.x >= inner_left - hit_size
430        && pos.x <= inner_right + hit_size;
431    let on_bottom = pos.y >= inner_bottom - hit_size
432        && pos.y <= inner_bottom + hit_size
433        && pos.x >= inner_left - hit_size
434        && pos.x <= inner_right + hit_size;
435
436    if !tiling.top && !tiling.left && on_top && on_left {
437        return Some(ResizeEdge::TopLeft);
438    }
439    if !tiling.top && !tiling.right && on_top && on_right {
440        return Some(ResizeEdge::TopRight);
441    }
442    if !tiling.bottom && !tiling.left && on_bottom && on_left {
443        return Some(ResizeEdge::BottomLeft);
444    }
445    if !tiling.bottom && !tiling.right && on_bottom && on_right {
446        return Some(ResizeEdge::BottomRight);
447    }
448    if !tiling.top && on_top {
449        return Some(ResizeEdge::Top);
450    }
451    if !tiling.bottom && on_bottom {
452        return Some(ResizeEdge::Bottom);
453    }
454    if !tiling.left && on_left {
455        return Some(ResizeEdge::Left);
456    }
457    if !tiling.right && on_right {
458        return Some(ResizeEdge::Right);
459    }
460    None
461}