Skip to main content

herogpui_components/
title_bar.rs

1//! `TitleBar` and `WindowBorder` — custom window chrome for frameless windows
2//! (HeroGPUI extension; HeroUI v3 is a web library and has no window chrome).
3//! The platform split follows gpui-kit's `title_bar.rs` and
4//! `window_border.rs`, checked against the pinned `gpui-pre` 0.3.5 platform
5//! sources.
6//!
7//! Open the window with [`TitleBar::window_options`] (or copy its fields into
8//! your own `WindowOptions`): it hides the system title bar
9//! (`appears_transparent`), positions the macOS traffic lights inside the
10//! bar, marks the title bar as app-owned (`app_owns_titlebar_drag`, so AppKit
11//! neither drags from it nor delays its clicks) and asks Linux compositors for
12//! client-side decorations. Then render a `TitleBar` at the top of the root
13//! view, inside a [`WindowBorder`] on Linux.
14//!
15//! **Dragging.** The bar's empty area moves the window. On macOS, X11 and
16//! Wayland a press followed by a move calls `Window::start_window_move`; on
17//! Windows the area is reported to the OS as the caption
18//! (`WindowControlArea::Drag`, `HTCAPTION`), which drags natively. Children
19//! of the bar stop presses from reaching the drag area, so buttons in the
20//! bar stay clickable.
21//!
22//! **Double-click** zooms: on macOS through `Window::titlebar_double_click`,
23//! which honours the user's "double-click a window's title bar to" setting;
24//! on Linux through `Window::zoom_window`; on Windows the OS does it on its
25//! caption. Right-click on a Linux client-decorated bar opens the
26//! compositor's window menu.
27//!
28//! **Controls** ([`TitleBarControls`]):
29//!
30//! | | `Auto` (default) | `Custom` | `Hidden` |
31//! |---|---|---|---|
32//! | macOS | none: the native traffic lights, with 80px reserved for them | drawn buttons | none |
33//! | Windows | drawn buttons reported as `Min`/`Max`/`Close` control areas; the OS performs them (and shows the Windows 11 snap layouts on Max) | drawn buttons | none |
34//! | Linux | drawn buttons, only when the window is client-decorated | drawn buttons | none |
35//! | Web | none | drawn buttons | none |
36//!
37//! Drawn buttons respect `Window::window_controls()` (a tiling compositor may
38//! offer no minimize or maximize) and show Restore while the window is
39//! maximized. What each drawn button, the drag and the double-click do is a
40//! [`WindowAction`]; [`TitleBar::on_window_action`] replaces the default
41//! (`WindowAction::perform`) — to confirm before closing, say. The OS-handled
42//! Windows `Auto` buttons and caption never reach it; intercept a close there
43//! with `Window::on_window_should_close`.
44//!
45//! **Limits.** The pinned GPUI draws no resize border on Windows or macOS
46//! (the OS keeps resizing), and none on Linux unless the window is wrapped in
47//! [`WindowBorder`]. The browser has no window to move, zoom or close: the web
48//! platform's `start_window_move` is a no-op and its `minimize`/`zoom` log a
49//! warning. The macOS traffic lights stay whenever the window has a title
50//! bar; `Custom` there suits a window opened with `titlebar: None`.
51//!
52//! **Accessibility.** The bar reports `Role::TitleBar` named by its `title`,
53//! and each drawn control `Role::Button` named "Minimize", "Maximize" or
54//! "Restore", and the `Close` UI string. The controls are not tab stops, as
55//! native caption buttons are not.
56
57use std::sync::Arc;
58
59use gpui::{
60    div, prelude::FluentBuilder as _, px, AnyElement, App, Decorations, Edges, ElementId,
61    InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
62    ResizeEdge, SharedString, StatefulInteractiveElement, Styled, Tiling, TitlebarOptions, Window,
63    WindowControlArea, WindowDecorations, WindowOptions,
64};
65use herogpui_core::element_id;
66use herogpui_theme::ActiveTheme;
67
68use crate::a11y::{self, A11y as _};
69use crate::{util, IconName};
70
71/// The default height of a [`TitleBar`].
72pub const TITLE_BAR_HEIGHT: Pixels = px(36.);
73/// The width reserved on macOS for the native traffic lights.
74pub const TRAFFIC_LIGHTS_WIDTH: Pixels = px(80.);
75/// The width of one drawn window control.
76const CONTROL_WIDTH: Pixels = px(46.);
77
78type ActionCallback = Arc<dyn Fn(&WindowAction, &mut Window, &mut App) + 'static>;
79
80/// Which window controls a [`TitleBar`] draws. See the [module docs](self).
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub enum TitleBarControls {
83    /// The platform's convention.
84    #[default]
85    Auto,
86    /// Drawn buttons on every platform, performed by the bar.
87    Custom,
88    /// No controls.
89    Hidden,
90}
91
92/// Something the title bar asks the window to do.
93#[derive(Clone, Copy, Debug, PartialEq)]
94#[non_exhaustive]
95pub enum WindowAction {
96    /// The minimize button.
97    Minimize,
98    /// The maximize / restore button.
99    Zoom,
100    /// The close button.
101    Close,
102    /// A drag began on the bar's empty area.
103    Move,
104    /// A double-click on the bar's empty area.
105    DoubleClick,
106    /// A right-click on a Linux client-decorated bar, at this window position.
107    ShowWindowMenu(Point<Pixels>),
108}
109
110impl WindowAction {
111    /// The platform default: minimize, zoom, remove the window, start a
112    /// window move, the double-click action (the macOS user setting;
113    /// zoom elsewhere), or open the window menu.
114    pub fn perform(&self, window: &mut Window) {
115        match *self {
116            WindowAction::Minimize => window.minimize_window(),
117            WindowAction::Zoom => window.zoom_window(),
118            WindowAction::Close => window.remove_window(),
119            WindowAction::Move => window.start_window_move(),
120            WindowAction::DoubleClick => {
121                if cfg!(target_os = "macos") {
122                    window.titlebar_double_click();
123                } else {
124                    window.zoom_window();
125                }
126            }
127            WindowAction::ShowWindowMenu(at) => window.show_window_menu(at),
128        }
129    }
130}
131
132/// The host platform, as far as window chrome cares.
133#[derive(Clone, Copy, PartialEq, Eq, Debug)]
134enum Host {
135    Mac,
136    Windows,
137    Linux,
138    Web,
139}
140
141impl Host {
142    fn current() -> Self {
143        if cfg!(target_family = "wasm") {
144            Host::Web
145        } else if cfg!(target_os = "macos") {
146            Host::Mac
147        } else if cfg!(target_os = "windows") {
148            Host::Windows
149        } else {
150            Host::Linux
151        }
152    }
153}
154
155/// Whether the bar draws buttons, and whether the OS performs them.
156#[derive(Clone, Copy, PartialEq, Eq, Debug)]
157enum ControlsPlan {
158    None,
159    /// Buttons whose presses the bar performs.
160    Drawn,
161    /// Buttons the OS hit-tests and performs (Windows).
162    Native,
163}
164
165fn controls_plan(controls: TitleBarControls, host: Host, client_decorated: bool) -> ControlsPlan {
166    match controls {
167        TitleBarControls::Hidden => ControlsPlan::None,
168        TitleBarControls::Custom => ControlsPlan::Drawn,
169        TitleBarControls::Auto => match host {
170            Host::Mac | Host::Web => ControlsPlan::None,
171            Host::Windows => ControlsPlan::Native,
172            Host::Linux if client_decorated => ControlsPlan::Drawn,
173            Host::Linux => ControlsPlan::None,
174        },
175    }
176}
177
178/// A custom window title bar. See the [module docs](self).
179#[must_use]
180#[derive(IntoElement)]
181pub struct TitleBar {
182    id: ElementId,
183    title: Option<SharedString>,
184    children: Vec<AnyElement>,
185    height: Pixels,
186    controls: TitleBarControls,
187    on_window_action: Option<ActionCallback>,
188    sx: Option<Box<gpui::StyleRefinement>>,
189}
190
191impl TitleBar {
192    /// A title bar keyed by `id`.
193    pub fn new(id: impl Into<ElementId>) -> Self {
194        Self {
195            id: id.into(),
196            title: None,
197            children: Vec::new(),
198            height: TITLE_BAR_HEIGHT,
199            controls: TitleBarControls::Auto,
200            on_window_action: None,
201            sx: None,
202        }
203    }
204
205    /// The window title drawn at the start of the bar, and the bar's
206    /// accessible name.
207    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
208        self.title = Some(title.into());
209        self
210    }
211
212    /// The bar's height; [`TITLE_BAR_HEIGHT`] by default.
213    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
214        self.height = height.into();
215        self
216    }
217
218    /// Which window controls to draw; [`TitleBarControls::Auto`] by default.
219    pub fn controls(mut self, controls: TitleBarControls) -> Self {
220        self.controls = controls;
221        self
222    }
223
224    /// Replaces the default [`WindowAction::perform`] for every action the
225    /// bar itself performs. Call `action.perform(window)` to keep the
226    /// default after your own handling.
227    pub fn on_window_action(
228        mut self,
229        f: impl Fn(&WindowAction, &mut Window, &mut App) + 'static,
230    ) -> Self {
231        self.on_window_action = Some(Arc::new(f));
232        self
233    }
234
235    /// Caller-owned styling refined over the bar after the theme's values.
236    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
237        util::refine_sx(&mut self.sx, style);
238        self
239    }
240
241    /// `WindowOptions` for a window that draws a `TitleBar`: no system title
242    /// bar, traffic lights inside the bar on macOS, an app-owned title-bar
243    /// drag, and client-side decorations requested on Linux.
244    pub fn window_options() -> WindowOptions {
245        WindowOptions {
246            titlebar: Some(Self::titlebar_options()),
247            app_owns_titlebar_drag: true,
248            window_decorations: Some(WindowDecorations::Client),
249            ..Default::default()
250        }
251    }
252
253    /// The `TitlebarOptions` half of [`TitleBar::window_options`].
254    pub fn titlebar_options() -> TitlebarOptions {
255        TitlebarOptions {
256            title: None,
257            appears_transparent: true,
258            traffic_light_position: Some(gpui::point(px(12.), px(12.))),
259        }
260    }
261}
262
263impl ParentElement for TitleBar {
264    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
265        self.children.extend(elements);
266    }
267}
268
269/// Runs `action` through the caller's hook, or performs it.
270fn dispatch(
271    hook: &Option<ActionCallback>,
272    action: WindowAction,
273    window: &mut Window,
274    cx: &mut App,
275) {
276    match hook {
277        Some(f) => f(&action, window, cx),
278        None => action.perform(window),
279    }
280}
281
282/// The debug-selector spelling of an id: its name when it has one.
283fn selector_base(id: &ElementId) -> String {
284    match id {
285        ElementId::Name(name) => name.to_string(),
286        other => format!("{other:?}"),
287    }
288}
289
290impl RenderOnce for TitleBar {
291    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
292        let base = self.id.clone();
293        let selector = selector_base(&base);
294        let host = Host::current();
295        let decorations = window.window_decorations();
296        let client_decorated = matches!(decorations, Decorations::Client { .. });
297        let plan = controls_plan(self.controls, host, client_decorated);
298        // A press armed for a window move: the move starts on the first
299        // pointer motion after it, so a plain click never becomes a drag.
300        let armed = window.use_keyed_state(element_id::scoped(&base, "armed"), cx, |_, _| false);
301        let colors = cx.colors().clone();
302        let layout = cx.layout().clone();
303        let hook = self.on_window_action.clone();
304        let leading = if host == Host::Mac
305            && self.controls == TitleBarControls::Auto
306            && !window.is_fullscreen()
307        {
308            TRAFFIC_LIGHTS_WIDTH
309        } else {
310            px(12.)
311        };
312
313        // The drag area lies behind the content, so a child's own hitbox
314        // keeps its presses (children block the mouse below).
315        let mut drag = div()
316            .id(element_id::scoped(&base, "drag"))
317            .absolute()
318            .inset_0()
319            .debug_selector({
320                let name = format!("{selector}-drag");
321                move || name
322            });
323        if host != Host::Web {
324            drag = drag.window_control_area(WindowControlArea::Drag);
325        }
326        {
327            let armed_down = armed.clone();
328            let hook_down = hook.clone();
329            drag = drag.on_mouse_down(MouseButton::Left, move |event, window, cx| {
330                if event.click_count >= 2 {
331                    armed_down.update(cx, |a, _| *a = false);
332                    if host != Host::Windows {
333                        dispatch(&hook_down, WindowAction::DoubleClick, window, cx);
334                    }
335                } else {
336                    armed_down.update(cx, |a, _| *a = true);
337                }
338            });
339            let armed_up = armed.clone();
340            drag = drag.on_mouse_up(MouseButton::Left, move |_, _, cx| {
341                armed_up.update(cx, |a, _| *a = false);
342            });
343            let armed_out = armed.clone();
344            drag = drag.on_mouse_down_out(move |_, _, cx| {
345                armed_out.update(cx, |a, _| *a = false);
346            });
347            let hook_move = hook.clone();
348            drag = drag.on_mouse_move(move |event, window, cx| {
349                if event.pressed_button != Some(MouseButton::Left) || !*armed.read(cx) {
350                    return;
351                }
352                armed.update(cx, |a, _| *a = false);
353                dispatch(&hook_move, WindowAction::Move, window, cx);
354            });
355            if host == Host::Linux && client_decorated {
356                let hook_menu = hook.clone();
357                drag = drag.on_mouse_down(MouseButton::Right, move |event, window, cx| {
358                    dispatch(
359                        &hook_menu,
360                        WindowAction::ShowWindowMenu(event.position),
361                        window,
362                        cx,
363                    );
364                });
365            }
366        }
367
368        let mut content = div()
369            .relative()
370            .flex()
371            .flex_1()
372            .min_w_0()
373            .h_full()
374            .items_center()
375            .gap(px(8.))
376            .pl(leading)
377            .pr(px(8.));
378        if let Some(title) = &self.title {
379            content = content.child(
380                div()
381                    .flex_shrink_0()
382                    .text_size(px(13.))
383                    .font_weight(gpui::FontWeight::MEDIUM)
384                    .text_color(colors.foreground)
385                    .whitespace_nowrap()
386                    .child(title.clone()),
387            );
388        }
389        if !self.children.is_empty() {
390            content = content.child(
391                div()
392                    .id(element_id::scoped(&base, "content"))
393                    .flex()
394                    .items_center()
395                    .gap(px(8.))
396                    .min_w_0()
397                    // Presses on the caller's controls stay theirs: nothing
398                    // below them (the drag area) sees them.
399                    .block_mouse_except_scroll()
400                    .children(self.children),
401            );
402        }
403
404        let mut bar = div()
405            .id(base.clone())
406            .a11y_named(a11y::Role::TitleBar, &a11y::Name::maybe(self.title.clone()))
407            .relative()
408            .flex()
409            .flex_row()
410            .items_center()
411            .w_full()
412            .h(self.height)
413            .flex_shrink_0()
414            .bg(colors.surface.background)
415            .border_b(layout.border_width)
416            .border_color(colors.separator)
417            .debug_selector({
418                let name = format!("{selector}-title-bar");
419                move || name
420            })
421            .child(drag)
422            .child(content);
423
424        if plan != ControlsPlan::None {
425            let supported = window.window_controls();
426            let maximized = window.is_maximized();
427            let mut controls = div()
428                .relative()
429                .flex()
430                .h_full()
431                .flex_shrink_0()
432                .block_mouse_except_scroll();
433            let mut buttons: Vec<(WindowAction, &'static str, SharedString, IconName)> = Vec::new();
434            if supported.minimize {
435                buttons.push((
436                    WindowAction::Minimize,
437                    "minimize",
438                    "Minimize".into(),
439                    IconName::Minus,
440                ));
441            }
442            if supported.maximize {
443                let (name, icon) = if maximized {
444                    ("Restore", IconName::Copy)
445                } else {
446                    ("Maximize", IconName::Square)
447                };
448                buttons.push((WindowAction::Zoom, "zoom", name.into(), icon));
449            }
450            buttons.push((
451                WindowAction::Close,
452                "close",
453                crate::i18n::ui_string(crate::i18n::UiString::Close, cx),
454                IconName::X,
455            ));
456            for (action, part, name, icon) in buttons {
457                let is_close = action == WindowAction::Close;
458                let (hover_bg, hover_fg) = if is_close {
459                    (colors.danger.color, colors.danger.foreground)
460                } else {
461                    (colors.default.color, colors.foreground)
462                };
463                let mut button = div()
464                    .id(element_id::scoped(&base, part))
465                    .a11y_named(a11y::Role::Button, &a11y::Name::labelled(name))
466                    .flex()
467                    .items_center()
468                    .justify_center()
469                    .w(CONTROL_WIDTH)
470                    .h_full()
471                    .text_color(colors.foreground)
472                    .hover(move |s| s.bg(hover_bg).text_color(hover_fg))
473                    .debug_selector({
474                        let name = format!("{selector}-{part}");
475                        move || name
476                    })
477                    .child(
478                        gpui::svg()
479                            .size(px(14.))
480                            .path(icon)
481                            .text_color(colors.foreground),
482                    );
483                match plan {
484                    ControlsPlan::Native => {
485                        button = button.window_control_area(match action {
486                            WindowAction::Minimize => WindowControlArea::Min,
487                            WindowAction::Zoom => WindowControlArea::Max,
488                            _ => WindowControlArea::Close,
489                        });
490                    }
491                    _ => {
492                        let hook = hook.clone();
493                        button = button
494                            .on_mouse_down(MouseButton::Left, |_, window, cx| {
495                                window.prevent_default();
496                                cx.stop_propagation();
497                            })
498                            .on_click(move |_, window, cx| {
499                                cx.stop_propagation();
500                                dispatch(&hook, action, window, cx);
501                            });
502                    }
503                }
504                controls = controls.child(button);
505            }
506            bar = bar.child(controls);
507        }
508        util::apply_sx(bar, &self.sx)
509    }
510}
511
512/// The default shadow reach of a Linux client-decorated [`WindowBorder`].
513pub const WINDOW_SHADOW_SIZE: Pixels = px(12.);
514/// Half the width of the band around the frame edge that starts a resize.
515const RESIZE_HIT: Pixels = px(4.);
516
517/// A frame for a client-decorated Linux window: shadow, a 1px border and
518/// resize edges. Everywhere else, and on Linux under server-side
519/// decorations, it renders its children unchanged — the OS draws the frame.
520/// See the [module docs](self).
521#[must_use]
522#[derive(IntoElement)]
523pub struct WindowBorder {
524    children: Vec<AnyElement>,
525    shadow_size: Pixels,
526}
527
528impl WindowBorder {
529    /// A frame around the window's content.
530    pub fn new() -> Self {
531        Self {
532            children: Vec::new(),
533            shadow_size: WINDOW_SHADOW_SIZE,
534        }
535    }
536
537    /// How far the shadow reaches outside the frame (the client inset the
538    /// compositor is told about); [`WINDOW_SHADOW_SIZE`] by default.
539    pub fn shadow_size(mut self, size: impl Into<Pixels>) -> Self {
540        self.shadow_size = size.into();
541        self
542    }
543}
544
545impl Default for WindowBorder {
546    fn default() -> Self {
547        Self::new()
548    }
549}
550
551impl ParentElement for WindowBorder {
552    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
553        self.children.extend(elements);
554    }
555}
556
557/// Per side, how far the frame sits inside the window: the shadow, except on
558/// a side tiled against the screen edge or another window.
559fn frame_insets(shadow: Pixels, tiling: Tiling) -> Edges<Pixels> {
560    Edges {
561        top: if tiling.top { px(0.) } else { shadow },
562        right: if tiling.right { px(0.) } else { shadow },
563        bottom: if tiling.bottom { px(0.) } else { shadow },
564        left: if tiling.left { px(0.) } else { shadow },
565    }
566}
567
568/// Which edge a press at `pos` resizes: within [`RESIZE_HIT`] of the frame
569/// edge (inside or out), corners first. Tiled sides do not resize.
570fn resize_edge(
571    pos: Point<Pixels>,
572    size: gpui::Size<Pixels>,
573    insets: Edges<Pixels>,
574    tiling: Tiling,
575) -> Option<ResizeEdge> {
576    let near = |distance: Pixels| distance.abs() <= RESIZE_HIT;
577    let top = !tiling.top && near(pos.y - insets.top);
578    let bottom = !tiling.bottom && near(size.height - insets.bottom - pos.y);
579    let left = !tiling.left && near(pos.x - insets.left);
580    let right = !tiling.right && near(size.width - insets.right - pos.x);
581    // A corner is a band's width either way along both edges.
582    let corner = RESIZE_HIT * 4.;
583    let near_top = !tiling.top && (pos.y - insets.top).abs() <= corner;
584    let near_bottom = !tiling.bottom && (size.height - insets.bottom - pos.y).abs() <= corner;
585    let near_left = !tiling.left && (pos.x - insets.left).abs() <= corner;
586    let near_right = !tiling.right && (size.width - insets.right - pos.x).abs() <= corner;
587    match () {
588        _ if (top && near_left) || (left && near_top) => Some(ResizeEdge::TopLeft),
589        _ if (top && near_right) || (right && near_top) => Some(ResizeEdge::TopRight),
590        _ if (bottom && near_left) || (left && near_bottom) => Some(ResizeEdge::BottomLeft),
591        _ if (bottom && near_right) || (right && near_bottom) => Some(ResizeEdge::BottomRight),
592        _ if top => Some(ResizeEdge::Top),
593        _ if bottom => Some(ResizeEdge::Bottom),
594        _ if left => Some(ResizeEdge::Left),
595        _ if right => Some(ResizeEdge::Right),
596        _ => None,
597    }
598}
599
600fn resize_cursor(edge: ResizeEdge) -> gpui::CursorStyle {
601    use gpui::CursorStyle;
602    match edge {
603        ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
604        ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
605        ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
606        ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
607    }
608}
609
610impl RenderOnce for WindowBorder {
611    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
612        let Decorations::Client { tiling } = window.window_decorations() else {
613            return div().size_full().flex().flex_col().children(self.children);
614        };
615        let shadow = self.shadow_size;
616        // The platform inset stays the full shadow even while tiled, so the
617        // first resize after a restore does not count the shadow twice.
618        window.set_client_inset(shadow);
619        let insets = frame_insets(shadow, tiling);
620        let colors = cx.colors().clone();
621        let layout = cx.layout().clone();
622        let size = window.window_bounds().get_bounds().size;
623        let active = window.is_window_active();
624        let cursor = resize_edge(window.mouse_position(), size, insets, tiling)
625            .map(resize_cursor)
626            .unwrap_or_default();
627
628        div()
629            .size_full()
630            .flex()
631            .flex_col()
632            .bg(gpui::transparent_black())
633            .pt(insets.top)
634            .pr(insets.right)
635            .pb(insets.bottom)
636            .pl(insets.left)
637            .cursor(cursor)
638            .on_mouse_move(|_, window, _| window.refresh())
639            .on_mouse_down(MouseButton::Left, move |event, window, _| {
640                if let Some(edge) = resize_edge(event.position, size, insets, tiling) {
641                    window.start_window_resize(edge);
642                }
643            })
644            .child(
645                div()
646                    .flex()
647                    .flex_col()
648                    .flex_1()
649                    .min_h_0()
650                    .min_w_0()
651                    .overflow_hidden()
652                    .cursor(gpui::CursorStyle::default())
653                    .bg(colors.background)
654                    .border_color(colors.border)
655                    .when(!tiling.top, |el| el.border_t(layout.border_width))
656                    .when(!tiling.right, |el| el.border_r(layout.border_width))
657                    .when(!tiling.bottom, |el| el.border_b(layout.border_width))
658                    .when(!tiling.left, |el| el.border_l(layout.border_width))
659                    .when(!tiling.is_tiled(), |el| {
660                        el.shadow(vec![gpui::BoxShadow {
661                            color: gpui::black().opacity(if active { 0.2 } else { 0.12 }),
662                            blur_radius: shadow / 2.,
663                            spread_radius: px(0.),
664                            offset: gpui::point(px(0.), px(1.)),
665                            inset: false,
666                        }])
667                    })
668                    .children(self.children),
669            )
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn auto_controls_follow_the_platform_convention() {
679        use ControlsPlan::*;
680        let auto = TitleBarControls::Auto;
681        assert_eq!(controls_plan(auto, Host::Mac, false), None);
682        assert_eq!(controls_plan(auto, Host::Web, true), None);
683        assert_eq!(controls_plan(auto, Host::Windows, false), Native);
684        assert_eq!(controls_plan(auto, Host::Linux, true), Drawn);
685        assert_eq!(controls_plan(auto, Host::Linux, false), None);
686        for host in [Host::Mac, Host::Windows, Host::Linux, Host::Web] {
687            assert_eq!(controls_plan(TitleBarControls::Custom, host, false), Drawn);
688            assert_eq!(controls_plan(TitleBarControls::Hidden, host, true), None);
689        }
690    }
691
692    #[test]
693    fn resize_edges_are_bands_around_the_frame_and_corners_win() {
694        let size = gpui::size(px(800.), px(600.));
695        let tiling = Tiling::default();
696        let insets = frame_insets(px(12.), tiling);
697        let at = |x: f32, y: f32| resize_edge(gpui::point(px(x), px(y)), size, insets, tiling);
698        assert_eq!(at(400., 12.), Some(ResizeEdge::Top));
699        assert_eq!(at(400., 9.), Some(ResizeEdge::Top), "outside the frame too");
700        assert_eq!(at(400., 588.), Some(ResizeEdge::Bottom));
701        assert_eq!(at(12., 300.), Some(ResizeEdge::Left));
702        assert_eq!(at(788., 300.), Some(ResizeEdge::Right));
703        assert_eq!(at(14., 14.), Some(ResizeEdge::TopLeft));
704        assert_eq!(at(786., 586.), Some(ResizeEdge::BottomRight));
705        assert_eq!(at(400., 300.), None, "the content is not an edge");
706        assert_eq!(at(400., 40.), None);
707    }
708
709    #[test]
710    fn tiled_sides_neither_inset_nor_resize() {
711        let size = gpui::size(px(800.), px(600.));
712        let tiling = Tiling {
713            top: true,
714            left: true,
715            right: false,
716            bottom: false,
717        };
718        let insets = frame_insets(px(12.), tiling);
719        assert_eq!(insets.top, px(0.));
720        assert_eq!(insets.right, px(12.));
721        let at = |x: f32, y: f32| resize_edge(gpui::point(px(x), px(y)), size, insets, tiling);
722        assert_eq!(at(400., 0.), None);
723        assert_eq!(at(0., 300.), None);
724        assert_eq!(at(788., 300.), Some(ResizeEdge::Right));
725    }
726}
727
728crate::util::impl_component_styled!(TitleBar);