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