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