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