Skip to main content

gpui_component/
title_bar.rs

1use std::rc::Rc;
2
3use crate::{
4    ActiveTheme, Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex,
5};
6use gpui::{
7    AnyElement, App, Background, ClickEvent, Context, Decorations, Hsla, InteractiveElement,
8    IntoElement, MouseButton, ParentElement, Pixels, Render, RenderOnce, Rgba,
9    StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
10    WindowControlArea, WindowOptions, div, linear_color_stop, linear_gradient,
11    prelude::FluentBuilder as _, px,
12};
13use gpui_base::TestSupportExt as _;
14use smallvec::SmallVec;
15
16pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
17#[cfg(target_os = "macos")]
18const TITLE_BAR_LEFT_PADDING: Pixels = px(80.);
19#[cfg(not(target_os = "macos"))]
20const TITLE_BAR_LEFT_PADDING: Pixels = px(12.);
21
22fn default_title_bar_background(title_bar: Hsla, background: Hsla) -> Background {
23    let title_bar_rgb = title_bar.to_rgb();
24    let background_rgb = background.to_rgb();
25    let mixed = Hsla::from(Rgba {
26        r: title_bar_rgb.r * 0.55 + background_rgb.r * 0.45,
27        g: title_bar_rgb.g * 0.55 + background_rgb.g * 0.45,
28        b: title_bar_rgb.b * 0.55 + background_rgb.b * 0.45,
29        a: title_bar_rgb.a * 0.55 + background_rgb.a * 0.45,
30    });
31
32    linear_gradient(
33        180.,
34        linear_color_stop(mixed, 0.),
35        linear_color_stop(title_bar, 1.),
36    )
37}
38
39/// TitleBar used to customize the appearance of the title bar.
40///
41/// We can put some elements inside the title bar.
42#[derive(IntoElement)]
43pub struct TitleBar {
44    style: StyleRefinement,
45    children: SmallVec<[AnyElement; 1]>,
46    on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
47}
48
49impl TitleBar {
50    /// Create a new TitleBar.
51    pub fn new() -> Self {
52        Self {
53            style: StyleRefinement::default(),
54            children: SmallVec::new(),
55            on_close_window: None,
56        }
57    }
58
59    /// Returns the default title bar options for compatible with the [`crate::TitleBar`].
60    pub fn title_bar_options() -> TitlebarOptions {
61        TitlebarOptions {
62            title: None,
63            appears_transparent: true,
64            traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
65        }
66    }
67
68    /// Returns the default window options for compatible with the [`crate::TitleBar`].
69    ///
70    /// Use this as the base of the [`WindowOptions`] of any window that renders a
71    /// [`crate::TitleBar`], so the title bar owns dragging and double clicking itself:
72    ///
73    /// ```no_run
74    /// # mod gpui_kit { pub use gpui::*; pub extern crate gpui_component as component; }
75    /// # use gpui_kit::WindowOptions;
76    /// # use gpui_kit::component::TitleBar;
77    /// let options = WindowOptions {
78    ///     window_min_size: None,
79    ///     ..TitleBar::window_options()
80    /// };
81    /// ```
82    pub fn window_options() -> WindowOptions {
83        WindowOptions {
84            titlebar: Some(Self::title_bar_options()),
85            // The title bar draws itself and moves the window via `start_window_move`,
86            // so AppKit must not treat it as a system window-move region. Otherwise macOS
87            // handles title bar double clicks on its own (in addition to `on_double_click`
88            // below) and delays title bar clicks while disambiguating double clicks.
89            app_owns_titlebar_drag: true,
90            ..Default::default()
91        }
92    }
93
94    /// Add custom for close window event, default is None, then click X button will call `window.remove_window()`.
95    /// Linux only, this will do nothing on other platforms.
96    pub fn on_close_window(
97        mut self,
98        f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
99    ) -> Self {
100        if cfg!(target_os = "linux") {
101            self.on_close_window = Some(Rc::new(Box::new(f)));
102        }
103        self
104    }
105}
106
107// The Windows control buttons have a fixed width of 35px.
108//
109// We don't need implementation the click event for the control buttons.
110// If user clicked in the bounds, the window event will be triggered.
111#[derive(IntoElement, Clone)]
112enum ControlIcon {
113    Minimize,
114    Restore,
115    Maximize,
116    Close {
117        on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
118    },
119}
120
121impl ControlIcon {
122    fn minimize() -> Self {
123        Self::Minimize
124    }
125
126    fn restore() -> Self {
127        Self::Restore
128    }
129
130    fn maximize() -> Self {
131        Self::Maximize
132    }
133
134    fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>) -> Self {
135        Self::Close { on_close_window }
136    }
137
138    fn id(&self) -> &'static str {
139        match self {
140            Self::Minimize => "minimize",
141            Self::Restore => "restore",
142            Self::Maximize => "maximize",
143            Self::Close { .. } => "close",
144        }
145    }
146
147    fn icon(&self) -> IconName {
148        match self {
149            Self::Minimize => IconName::WindowMinimize,
150            Self::Restore => IconName::WindowRestore,
151            Self::Maximize => IconName::WindowMaximize,
152            Self::Close { .. } => IconName::WindowClose,
153        }
154    }
155
156    fn window_control_area(&self) -> WindowControlArea {
157        match self {
158            Self::Minimize => WindowControlArea::Min,
159            Self::Restore | Self::Maximize => WindowControlArea::Max,
160            Self::Close { .. } => WindowControlArea::Close,
161        }
162    }
163
164    fn is_close(&self) -> bool {
165        matches!(self, Self::Close { .. })
166    }
167
168    #[inline]
169    fn hover_fg(&self, cx: &App) -> Hsla {
170        if self.is_close() {
171            cx.theme().danger_foreground
172        } else {
173            cx.theme().secondary_foreground
174        }
175    }
176
177    #[inline]
178    fn hover_bg(&self, cx: &App) -> Hsla {
179        if self.is_close() {
180            cx.theme().danger
181        } else {
182            cx.theme().secondary_hover
183        }
184    }
185
186    #[inline]
187    fn active_bg(&self, cx: &mut App) -> Hsla {
188        if self.is_close() {
189            cx.theme().danger_active
190        } else {
191            cx.theme().secondary_active
192        }
193    }
194}
195
196impl RenderOnce for ControlIcon {
197    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
198        let is_linux = cfg!(target_os = "linux");
199        let is_windows = cfg!(target_os = "windows");
200        let hover_fg = self.hover_fg(cx);
201        let hover_bg = self.hover_bg(cx);
202        let active_bg = self.active_bg(cx);
203        let icon = self.clone();
204        let on_close_window = match &self {
205            ControlIcon::Close { on_close_window } => on_close_window.clone(),
206            _ => None,
207        };
208
209        div()
210            .id(self.id())
211            .test_support()
212            .flex()
213            .w(TITLE_BAR_HEIGHT)
214            .h_full()
215            .flex_shrink_0()
216            .justify_center()
217            .content_center()
218            .items_center()
219            .text_color(cx.theme().foreground)
220            .hover(|style| style.bg(hover_bg).text_color(hover_fg))
221            .active(|style| style.bg(active_bg).text_color(hover_fg))
222            .when(is_windows, |this| {
223                this.window_control_area(self.window_control_area())
224            })
225            .when(is_linux, |this| {
226                this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
227                    window.prevent_default();
228                    cx.stop_propagation();
229                })
230                .on_click(move |_, window, cx| {
231                    cx.stop_propagation();
232                    match icon {
233                        Self::Minimize => window.minimize_window(),
234                        Self::Restore | Self::Maximize => window.zoom_window(),
235                        Self::Close { .. } => {
236                            if let Some(f) = on_close_window.clone() {
237                                f(&ClickEvent::default(), window, cx);
238                            } else {
239                                window.remove_window();
240                            }
241                        }
242                    }
243                })
244            })
245            .child(Icon::new(self.icon()).small())
246    }
247}
248
249#[derive(IntoElement)]
250struct WindowControls {
251    on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
252}
253
254impl RenderOnce for WindowControls {
255    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
256        if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
257            return div().id("window-controls").test_support();
258        }
259
260        // Under server-side decorations the window manager already renders
261        // its own title bar, complete with min/max/close; drawing ours as
262        // well stacks a duplicate set of controls on top of it (most
263        // visibly two close buttons). gpui falls back to server-side
264        // decorations on X11 sessions without a compositor, and a Wayland
265        // compositor may grant server mode even when client mode was
266        // requested. Skip ours unless this window is actually client-side
267        // decorated, mirroring the `is_client_decorated` gating of the
268        // title bar's window-menu overlay.
269        #[cfg(target_os = "linux")]
270        if !matches!(window.window_decorations(), Decorations::Client { .. }) {
271            return div().id("window-controls").test_support();
272        }
273
274        // The window manager declares which controls it can honor; a tiling
275        // compositor may support neither minimize nor maximize. Close is
276        // always ours to offer.
277        let supported = window.window_controls();
278
279        h_flex()
280            .id("window-controls")
281            .test_support()
282            .items_center()
283            .flex_shrink_0()
284            .h_full()
285            .when(supported.minimize, |this| {
286                this.child(ControlIcon::minimize())
287            })
288            .when(supported.maximize, |this| {
289                this.child(if window.is_maximized() {
290                    ControlIcon::restore()
291                } else {
292                    ControlIcon::maximize()
293                })
294            })
295            .child(ControlIcon::close(self.on_close_window))
296    }
297}
298
299impl Styled for TitleBar {
300    fn style(&mut self) -> &mut gpui::StyleRefinement {
301        &mut self.style
302    }
303}
304
305impl ParentElement for TitleBar {
306    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
307        self.children.extend(elements);
308    }
309}
310
311struct TitleBarState {
312    should_move: bool,
313}
314
315// TODO: Remove this when GPUI has released v0.2.3
316impl Render for TitleBarState {
317    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
318        div()
319    }
320}
321
322impl RenderOnce for TitleBar {
323    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
324        let is_client_decorated = matches!(window.window_decorations(), Decorations::Client { .. });
325        let is_web = cfg!(target_family = "wasm");
326        let is_linux = cfg!(target_os = "linux");
327        let is_macos = cfg!(target_os = "macos");
328
329        let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
330
331        div().flex_shrink_0().child(
332            div()
333                .id("title-bar")
334                .flex()
335                .flex_row()
336                .items_center()
337                .justify_between()
338                .h(TITLE_BAR_HEIGHT)
339                .pl(TITLE_BAR_LEFT_PADDING)
340                .border_b_1()
341                .border_color(cx.theme().title_bar_border)
342                .bg(default_title_bar_background(
343                    cx.theme().title_bar,
344                    cx.theme().background,
345                ))
346                .refine_style(&self.style)
347                .when(is_linux, |this| {
348                    this.on_double_click(|_, window, _| window.zoom_window())
349                })
350                .when(is_macos, |this| {
351                    this.on_double_click(|_, window, _| window.titlebar_double_click())
352                })
353                // After `on_double_click`, which is implemented for `Stateful` only.
354                .test_support()
355                .on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
356                    state.should_move = false;
357                }))
358                .on_mouse_down(
359                    MouseButton::Left,
360                    window.listener_for(&state, |state, _, _, _| {
361                        state.should_move = true;
362                    }),
363                )
364                .on_mouse_up(
365                    MouseButton::Left,
366                    window.listener_for(&state, |state, _, _, _| {
367                        state.should_move = false;
368                    }),
369                )
370                .on_mouse_move(window.listener_for(&state, |state, _, window, _| {
371                    if state.should_move {
372                        state.should_move = false;
373                        window.start_window_move();
374                    }
375                }))
376                .child(
377                    h_flex()
378                        .id("bar")
379                        .h_full()
380                        .justify_between()
381                        .flex_shrink_0()
382                        .flex_1()
383                        .when(!is_web, |this| {
384                            this.window_control_area(WindowControlArea::Drag)
385                                .when(window.is_fullscreen(), |this| this.pl_3())
386                                .when(is_linux && is_client_decorated, |this| {
387                                    this.child(
388                                        div()
389                                            .top_0()
390                                            .left_0()
391                                            .absolute()
392                                            .size_full()
393                                            .h_full()
394                                            .on_mouse_down(
395                                                MouseButton::Right,
396                                                move |ev, window, _| {
397                                                    window.show_window_menu(ev.position)
398                                                },
399                                            ),
400                                    )
401                                })
402                        })
403                        .children(self.children),
404                )
405                .child(WindowControls {
406                    on_close_window: self.on_close_window,
407                }),
408        )
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use gpui::{Rgba, linear_color_stop, linear_gradient};
416
417    #[test]
418    fn test_default_title_bar_background() {
419        let title_bar = Hsla::black();
420        let background = Hsla::white();
421
422        assert_eq!(
423            default_title_bar_background(title_bar, background),
424            linear_gradient(
425                180.,
426                linear_color_stop(
427                    Hsla::from(Rgba {
428                        r: 0.45,
429                        g: 0.45,
430                        b: 0.45,
431                        a: 1.,
432                    }),
433                    0.,
434                ),
435                linear_color_stop(title_bar, 1.),
436            )
437        );
438    }
439}