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