Skip to main content

gpui_cn/
title_bar.rs

1//! A title bar the application draws itself: a drag region with room for
2//! the platform's window controls, and whatever the application puts in
3//! it.
4
5use std::rc::Rc;
6
7use gpui_kit::{
8    AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement as _, IntoElement,
9    MouseButton, ParentElement, Render, RenderOnce, StatefulInteractiveElement as _,
10    StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, WindowOptions,
11    base::{InteractiveElementExt as _, StyledExt as _, TestSupportExt as _},
12    div, point,
13    prelude::FluentBuilder as _,
14};
15
16use crate::{ActiveTheme as _, Icon};
17use gpui_kit::assets::IconName;
18
19type CloseHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
20type DoubleClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
21
22/// A title bar the application draws itself.
23///
24/// The bar paints nothing of its own, so it takes the surface it sits on:
25/// the sidebar's when it heads a sidebar, the window's when it heads the
26/// content. Dragging it moves the window and double-clicking it does what
27/// the platform does with a title bar. On macOS it leaves room for the
28/// window controls at its left edge (see [`inset`](Self::inset)); on
29/// Windows and Linux it draws minimize, maximize, and close at its right
30/// edge.
31///
32/// Open the window with [`TitleBar::window_options`] so the platform hides
33/// its own title bar and hands dragging to this one.
34///
35/// ```
36/// use gpui_cn::TitleBar;
37/// use gpui_kit::{IntoElement, ParentElement as _, div};
38///
39/// fn header() -> impl IntoElement {
40///     TitleBar::new().child(div().child("Untitled"))
41/// }
42/// ```
43#[derive(IntoElement)]
44pub struct TitleBar {
45    children: Vec<AnyElement>,
46    style: StyleRefinement,
47    inset: bool,
48    on_close_window: Option<CloseHandler>,
49    on_double_click: Option<DoubleClickHandler>,
50}
51
52impl Default for TitleBar {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl TitleBar {
59    /// An empty title bar with the platform inset.
60    pub fn new() -> Self {
61        Self {
62            children: Vec::new(),
63            style: StyleRefinement::default(),
64            inset: true,
65            on_close_window: None,
66            on_double_click: None,
67        }
68    }
69
70    /// What a double-click on the bar does instead of the platform's title
71    /// bar behavior (zooming the window on macOS and Linux). A double-click
72    /// that starts on a control in the bar, such as a button pressed twice,
73    /// is the control's and never reaches this.
74    pub fn on_double_click(
75        mut self,
76        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
77    ) -> Self {
78        self.on_double_click = Some(Rc::new(handler));
79        self
80    }
81
82    /// The title bar options a window needs for this title bar: no system
83    /// title bar, and the macOS window controls where the bar expects them
84    /// (the theme's `window_controls_position`).
85    pub fn title_bar_options(cx: &App) -> TitlebarOptions {
86        let position = cx.theme().metrics.window_controls_position;
87        TitlebarOptions {
88            title: None,
89            appears_transparent: true,
90            traffic_light_position: Some(point(position, position)),
91        }
92    }
93
94    /// The window options a window needs for this title bar. Override the
95    /// rest with struct update syntax.
96    pub fn window_options(cx: &App) -> WindowOptions {
97        WindowOptions {
98            titlebar: Some(Self::title_bar_options(cx)),
99            // The bar moves the window itself, so the platform must not
100            // treat it as a system drag region; on macOS that would delay
101            // clicks on it while disambiguating double clicks.
102            app_owns_titlebar_drag: true,
103            ..Default::default()
104        }
105    }
106
107    /// Whether the bar leaves room for the macOS window controls at its
108    /// left edge. On by default. Turn it off for a bar that does not reach
109    /// the window's left edge: the content's title bar while a sidebar is
110    /// open. No-op on other platforms.
111    pub fn inset(mut self, inset: bool) -> Self {
112        self.inset = inset;
113        self
114    }
115
116    /// What the close control does on Linux instead of closing the window.
117    /// Other platforms handle their controls themselves.
118    pub fn on_close_window(
119        mut self,
120        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
121    ) -> Self {
122        if cfg!(target_os = "linux") {
123            self.on_close_window = Some(Rc::new(handler));
124        }
125        self
126    }
127}
128
129impl Styled for TitleBar {
130    fn style(&mut self) -> &mut StyleRefinement {
131        &mut self.style
132    }
133}
134
135impl ParentElement for TitleBar {
136    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
137        self.children.extend(elements);
138    }
139}
140
141/// What the last press on the bar was.
142struct DragState {
143    /// A press on the bar itself, waiting for the pointer to move.
144    pending: bool,
145    /// The press started on a control in the bar, which took it.
146    on_control: bool,
147}
148
149impl Render for DragState {
150    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
151        div()
152    }
153}
154
155impl RenderOnce for TitleBar {
156    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
157        let is_macos = cfg!(target_os = "macos");
158        let is_linux = cfg!(target_os = "linux");
159        let is_web = cfg!(target_family = "wasm");
160        let client_decorated = matches!(window.window_decorations(), Decorations::Client { .. });
161        let macos_inset = is_macos && self.inset && !window.is_fullscreen();
162        // The bar's own padding sits on the outer element so a caller can
163        // clear it and let a child, such as a tab strip, paint the whole
164        // bar. Where the bar draws window controls at its right edge the
165        // trailing padding and the content offset move inside, between the
166        // content and them, so the controls keep the window's top edge.
167        let draws_controls = draws_window_controls(window);
168        let (height, offset, inset, gap) = {
169            let theme = cx.theme();
170            (
171                theme.metrics.title_bar,
172                theme.metrics.title_bar_content_offset,
173                theme.metrics.window_controls_inset,
174                theme.base.spacing.xs,
175            )
176        };
177
178        let drag = window.use_state(cx, |_, _| DragState {
179            pending: false,
180            on_control: false,
181        });
182        let on_double_click = self.on_double_click.clone();
183
184        div()
185            .id("title-bar")
186            .on_double_click(window.listener_for(&drag, move |drag, event, window, cx| {
187                // Two fast clicks on a button in the bar are a double-click
188                // on the bar too; only a press on the bar itself counts.
189                if drag.on_control {
190                    return;
191                }
192                match &on_double_click {
193                    Some(handler) => handler(event, window, cx),
194                    None if is_macos => window.titlebar_double_click(),
195                    None if is_linux => window.zoom_window(),
196                    None => {}
197                }
198            }))
199            .test_support()
200            .h_flex()
201            .flex_shrink_0()
202            .w_full()
203            .h(height)
204            .when(!draws_controls, |this| this.pt(offset))
205            .map(|this| {
206                if macos_inset {
207                    this.pl(inset)
208                } else {
209                    this.pl_3()
210                }
211            })
212            .when(!draws_controls, |this| this.pr_3())
213            .refine_style(&self.style)
214            .on_mouse_down(
215                MouseButton::Left,
216                window.listener_for(&drag, |drag, _, window, _| {
217                    // A control in the bar takes the press for itself and
218                    // prevents the default; only a press on the bar moves
219                    // the window.
220                    drag.on_control = window.default_prevented();
221                    drag.pending = !drag.on_control;
222                }),
223            )
224            .on_mouse_up(
225                MouseButton::Left,
226                window.listener_for(&drag, |drag, _, _, _| drag.pending = false),
227            )
228            .on_mouse_down_out(window.listener_for(&drag, |drag, _, _, _| drag.pending = false))
229            .on_mouse_move(window.listener_for(&drag, |drag, _, window, _| {
230                if drag.pending {
231                    drag.pending = false;
232                    window.start_window_move();
233                }
234            }))
235            .child(
236                div()
237                    .id("title-bar-content")
238                    .h_flex()
239                    .flex_1()
240                    .min_w_0()
241                    .h_full()
242                    .gap(gap)
243                    .when(draws_controls, |this| this.pt(offset).pr_3())
244                    .when(!is_web, |this| {
245                        this.window_control_area(WindowControlArea::Drag).when(
246                            is_linux && client_decorated,
247                            |this| {
248                                this.on_mouse_down(MouseButton::Right, |event, window, _| {
249                                    window.show_window_menu(event.position)
250                                })
251                            },
252                        )
253                    })
254                    .children(self.children),
255            )
256            .child(WindowControls {
257                on_close_window: self.on_close_window,
258            })
259    }
260}
261
262/// The minimize, maximize, and close controls on Windows and Linux.
263#[derive(IntoElement)]
264struct WindowControls {
265    on_close_window: Option<CloseHandler>,
266}
267
268/// Whether the bar draws the window controls itself. Only Windows and
269/// Linux let the application draw them; macOS draws its own, and a phone
270/// or the web has none. Under server-side decorations on Linux the window
271/// manager draws them, and a second set here would sit on top of its.
272fn draws_window_controls(window: &Window) -> bool {
273    cfg!(target_os = "windows")
274        || (cfg!(target_os = "linux")
275            && matches!(window.window_decorations(), Decorations::Client { .. }))
276}
277
278impl RenderOnce for WindowControls {
279    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
280        if !draws_window_controls(window) {
281            return div().id("window-controls");
282        }
283        let supported = window.window_controls();
284        div()
285            .id("window-controls")
286            .h_flex()
287            .flex_shrink_0()
288            .h_full()
289            .when(supported.minimize, |this| this.child(Control::Minimize))
290            .when(supported.maximize, |this| {
291                this.child(if window.is_maximized() {
292                    Control::Restore
293                } else {
294                    Control::Maximize
295                })
296            })
297            .child(Control::Close {
298                on_close_window: self.on_close_window,
299            })
300    }
301}
302
303/// One window control. On Windows the platform handles the press through
304/// its control area. On Linux the control acts itself.
305#[derive(IntoElement, Clone)]
306enum Control {
307    Minimize,
308    Maximize,
309    Restore,
310    Close {
311        on_close_window: Option<CloseHandler>,
312    },
313}
314
315impl Control {
316    fn id(&self) -> &'static str {
317        match self {
318            Self::Minimize => "minimize",
319            Self::Maximize => "maximize",
320            Self::Restore => "restore",
321            Self::Close { .. } => "close",
322        }
323    }
324
325    fn icon(&self) -> Icon {
326        Icon::from(match self {
327            Self::Minimize => IconName::WindowMinimize,
328            Self::Maximize => IconName::WindowMaximize,
329            Self::Restore => IconName::WindowRestore,
330            Self::Close { .. } => IconName::WindowClose,
331        })
332    }
333
334    fn area(&self) -> WindowControlArea {
335        match self {
336            Self::Minimize => WindowControlArea::Min,
337            Self::Maximize | Self::Restore => WindowControlArea::Max,
338            Self::Close { .. } => WindowControlArea::Close,
339        }
340    }
341
342    fn is_close(&self) -> bool {
343        matches!(self, Self::Close { .. })
344    }
345}
346
347impl RenderOnce for Control {
348    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
349        let theme = cx.theme();
350        let (hover_bg, hover_fg): (Hsla, Hsla) = if self.is_close() {
351            (theme.destructive(), theme.destructive_foreground())
352        } else {
353            (theme.secondary(), theme.foreground())
354        };
355        let active_bg = if self.is_close() {
356            theme.destructive().opacity(0.8)
357        } else {
358            theme.selected
359        };
360        let control = self.clone();
361        let on_close_window = match &self {
362            Self::Close { on_close_window } => on_close_window.clone(),
363            _ => None,
364        };
365        div()
366            .id(self.id())
367            .h_flex()
368            .justify_center()
369            .flex_shrink_0()
370            .w(theme.metrics.window_control_width)
371            .h_full()
372            .text_color(theme.muted_foreground())
373            .hover(|style| style.bg(hover_bg).text_color(hover_fg))
374            .active(|style| style.bg(active_bg).text_color(hover_fg))
375            .when(cfg!(target_os = "windows"), |this| {
376                this.window_control_area(self.area())
377            })
378            .when(cfg!(target_os = "linux"), |this| {
379                this.on_mouse_down(MouseButton::Left, |_, window, cx| {
380                    window.prevent_default();
381                    cx.stop_propagation();
382                })
383                .on_click(move |_, window, cx| {
384                    cx.stop_propagation();
385                    match &control {
386                        Control::Minimize => window.minimize_window(),
387                        Control::Maximize | Control::Restore => window.zoom_window(),
388                        Control::Close { .. } => match &on_close_window {
389                            Some(handler) => handler(&ClickEvent::default(), window, cx),
390                            None => window.remove_window(),
391                        },
392                    }
393                })
394            })
395            .child(self.icon().size_3p5())
396    }
397}