Skip to main content

gpui_kit_material/components/
title_bar.rs

1//! A title bar the application draws in its own colors.
2//!
3//! The system title bar is made transparent and the window's content draws
4//! the bar, so it always matches the page beneath it in every palette. macOS
5//! keeps its traffic lights, centred in the bar; Windows and Linux get
6//! [`CaptionButtons`]. The bar moves the window when dragged and zooms it on a
7//! double click, the way each platform's own title bar does. Controls placed
8//! in it take their presses with [`title_bar_control`].
9use super::caption::{Caption, CaptionButtons, CaptionLabels, CloseRequest};
10use gpui::{
11    AnyElement, App, Bounds, InteractiveElement as _, IntoElement, MouseButton, ParentElement,
12    Pixels, Point, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
13    StyleRefinement, Styled, TitlebarOptions, Window, WindowBounds, WindowControlArea,
14    WindowDecorations, WindowOptions, div, point, px,
15};
16use std::rc::Rc;
17
18/// The macOS traffic lights: three 14px buttons, 6px apart.
19const TRAFFIC_LIGHT: f32 = 14.;
20const TRAFFIC_LIGHT_GAP: f32 = 6.;
21/// The lights sit at most this far from the window's leading edge.
22const TRAFFIC_LIGHT_MAX_INSET: f32 = 20.;
23/// Open space between the lights, or the window edge, and the bar's content.
24const CONTENT_GAP: f32 = 16.;
25
26/// Where the traffic lights go in a bar `height` tall: centred vertically,
27/// and as far from the leading edge as from the top, up to 20px.
28pub fn traffic_light_position(height: Pixels) -> Point<Pixels> {
29    let inset = ((f32::from(height) - TRAFFIC_LIGHT) / 2.).max(0.);
30    point(px(inset.min(TRAFFIC_LIGHT_MAX_INSET)), px(inset))
31}
32
33/// Window options for a window whose content draws a [`TitleBar`] `height`
34/// tall, centred on the main display at `window_size`. Extend them with
35/// struct update syntax for anything else the window needs.
36///
37/// The application owns title-bar drags: otherwise AppKit also treats the bar
38/// as a native title bar, zooming twice on a double click and delaying every
39/// click in it. Linux windows use client-side decorations.
40pub fn title_bar_window_options(
41    title: impl Into<SharedString>,
42    height: Pixels,
43    window_size: gpui::Size<Pixels>,
44    cx: &App,
45) -> WindowOptions {
46    WindowOptions {
47        window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
48            None,
49            window_size,
50            cx,
51        ))),
52        titlebar: Some(TitlebarOptions {
53            title: Some(title.into()),
54            appears_transparent: true,
55            traffic_light_position: Some(traffic_light_position(height)),
56        }),
57        app_owns_titlebar_drag: true,
58        window_decorations: Some(WindowDecorations::Client),
59        ..Default::default()
60    }
61}
62
63/// Where the bar's own content starts: after the macOS traffic lights, or a
64/// 16px inset where the platform puts its buttons at the trailing edge.
65pub fn title_bar_leading(height: Pixels, window: &Window) -> f32 {
66    leading_for(cfg!(target_os = "macos") && !window.is_fullscreen(), height)
67}
68
69fn leading_for(traffic_lights: bool, height: Pixels) -> f32 {
70    if traffic_lights {
71        f32::from(traffic_light_position(height).x)
72            + 3. * TRAFFIC_LIGHT
73            + 2. * TRAFFIC_LIGHT_GAP
74            + CONTENT_GAP
75    } else {
76        CONTENT_GAP
77    }
78}
79
80/// An application-drawn window title bar. Lay out its children with the usual
81/// style methods; it adds the leading and trailing space the platform's
82/// buttons need and draws [`CaptionButtons`] where the platform has none.
83/// Open the window with [`title_bar_window_options`] for the same height.
84///
85/// The bar sets its own height and horizontal padding after the caller's
86/// styles, so `.h()`, `.px()`, `.pl()` and `.pr()` on it have no effect.
87#[derive(gpui::IntoElement)]
88pub struct TitleBar {
89    id: gpui::ElementId,
90    base: Stateful<gpui::Div>,
91    height: Pixels,
92    labels: CaptionLabels,
93    caption: Option<Caption>,
94    on_close: Option<CloseRequest>,
95}
96
97impl TitleBar {
98    /// A bar `height` tall at the top of the window. `labels` are the
99    /// accessible names of the Windows and Linux caption buttons, in the
100    /// application's language.
101    pub fn new(id: impl Into<gpui::ElementId>, height: Pixels, labels: CaptionLabels) -> Self {
102        let id = id.into();
103        Self {
104            base: div().id(id.clone()),
105            id,
106            height,
107            labels,
108            caption: None,
109            on_close: None,
110        }
111    }
112
113    /// Force a caption style instead of detecting it, e.g. to compare the
114    /// Windows and Linux bars from another platform.
115    pub fn caption(mut self, caption: Caption) -> Self {
116        self.caption = Some(caption);
117        self
118    }
119
120    /// Replace the accessible names of the Windows and Linux caption buttons.
121    pub fn labels(mut self, labels: CaptionLabels) -> Self {
122        self.labels = labels;
123        self
124    }
125
126    /// Asked before the Linux close button removes the window, as
127    /// [`CaptionButtons::on_close`].
128    pub fn on_close(mut self, request: impl Fn(&mut Window, &mut App) -> bool + 'static) -> Self {
129        self.on_close = Some(Rc::new(request));
130        self
131    }
132}
133
134impl Styled for TitleBar {
135    fn style(&mut self) -> &mut StyleRefinement {
136        self.base.style()
137    }
138}
139
140impl ParentElement for TitleBar {
141    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
142        self.base.extend(elements)
143    }
144}
145
146/// A press on the bar that may become a window drag once the pointer moves.
147#[derive(Default)]
148struct DragIntent(bool);
149
150/// Lets the platform move the window with the pointer. Tests count the moves
151/// instead: the test platform does not implement them.
152fn start_window_move(window: &mut Window) {
153    #[cfg(test)]
154    {
155        let _ = window;
156        tests::MOVES.with(|moves| moves.set(moves.get() + 1));
157    }
158    #[cfg(not(test))]
159    window.start_window_move();
160}
161
162impl RenderOnce for TitleBar {
163    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
164        let intent = window.use_keyed_state(
165            gpui::ElementId::NamedChild(self.id.into(), "drag".into()),
166            cx,
167            |_, _| DragIntent::default(),
168        );
169        let press = intent.clone();
170        let release = intent.clone();
171        let mut caption = CaptionButtons::new(self.height, self.labels);
172        let trailing = match self.caption {
173            Some(style) => {
174                caption = caption.caption(style);
175                CaptionButtons::trailing_for_style(style, window)
176            }
177            None => CaptionButtons::trailing(window),
178        };
179        if let Some(request) = self.on_close {
180            caption = caption.on_close(move |window, cx| request(window, cx));
181        }
182        self.base
183            .flex()
184            .flex_shrink_0()
185            .items_center()
186            .h(self.height)
187            .pl(px(title_bar_leading(self.height, window)))
188            .pr(px(trailing))
189            // Windows hit-tests this as the caption: the system moves the
190            // window and zooms on a double click.
191            .window_control_area(WindowControlArea::Drag)
192            .on_mouse_down(MouseButton::Left, move |_, _, cx| {
193                press.update(cx, |intent, _| intent.0 = true)
194            })
195            .on_mouse_up(MouseButton::Left, move |_, _, cx| {
196                release.update(cx, |intent, _| intent.0 = false)
197            })
198            .on_mouse_move(move |event, window, cx| {
199                let started = intent.update(cx, |intent, _| std::mem::take(&mut intent.0));
200                if started && event.dragging() && !cfg!(target_os = "windows") {
201                    start_window_move(window);
202                }
203            })
204            .on_mouse_down(MouseButton::Right, |event, window, _| {
205                if cfg!(any(target_os = "linux", target_os = "freebsd")) {
206                    window.show_window_menu(event.position);
207                }
208            })
209            .on_click(|event, window, _| {
210                if event.click_count() != 2 {
211                    return;
212                }
213                if cfg!(target_os = "macos") {
214                    // Follows the user's "double-click a window's title bar" setting.
215                    window.titlebar_double_click();
216                } else if !cfg!(any(target_os = "windows", target_family = "wasm")) {
217                    window.zoom_window();
218                }
219            })
220            // Out of the flow, so the caller's gap does not reach it: the
221            // buttons place themselves at the window's top-right corner.
222            .child(div().absolute().child(caption))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::components::caption::tests::test_labels;
230    use crate::components::{ButtonVariant, material_button, title_bar_control};
231    use gpui::{Context, Modifiers, Render, TestAppContext, size};
232    use std::cell::Cell;
233
234    thread_local! {
235        /// Window moves the bar started on this test's thread.
236        pub(super) static MOVES: Cell<usize> = const { Cell::new(0) };
237    }
238
239    #[test]
240    fn traffic_lights_are_centred_in_the_bar() {
241        assert_eq!(traffic_light_position(px(32.)), point(px(9.), px(9.)));
242        // Taller bars keep the lights near the corner, as macOS places them.
243        assert_eq!(traffic_light_position(px(64.)), point(px(20.), px(25.)));
244    }
245
246    #[test]
247    fn content_starts_after_the_traffic_lights() {
248        // 20px inset, three 14px lights 6px apart, then 16px of open space.
249        assert_eq!(leading_for(true, px(64.)), 20. + 42. + 12. + 16.);
250        assert_eq!(leading_for(false, px(64.)), 16.);
251    }
252
253    #[gpui::test]
254    fn window_options_let_the_application_own_title_bar_drags(cx: &mut TestAppContext) {
255        let options = cx
256            .update(|cx| title_bar_window_options("Title", px(64.), size(px(800.), px(600.)), cx));
257        assert!(options.app_owns_titlebar_drag);
258        let titlebar = options.titlebar.expect("a title bar");
259        assert!(titlebar.appears_transparent);
260        assert_eq!(
261            titlebar.traffic_light_position,
262            Some(point(px(20.), px(25.)))
263        );
264    }
265
266    struct Harness {
267        clicks: usize,
268    }
269
270    impl Render for Harness {
271        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
272            div().size_full().child(
273                TitleBar::new("bar", px(48.), test_labels())
274                    .w_full()
275                    .gap(px(16.))
276                    .child(title_bar_control(
277                        material_button("in-bar", ButtonVariant::Text)
278                            .debug_selector(|| "in-bar".into())
279                            .child("Action")
280                            .on_click(cx.listener(|this, _, _, _| this.clicks += 1)),
281                    ))
282                    .child(div().flex_1())
283                    .child(div().debug_selector(|| "last".into()).size(px(24.))),
284            )
285        }
286    }
287
288    #[gpui::test]
289    fn controls_in_the_bar_take_their_clicks(cx: &mut TestAppContext) {
290        let (view, cx) = cx.add_window_view(|_, _| Harness { clicks: 0 });
291        cx.run_until_parked();
292        let center = cx
293            .debug_bounds("in-bar")
294            .expect("the bar's button")
295            .center();
296        cx.simulate_click(center, Modifiers::default());
297        // Empty bar space takes presses without reaching the control.
298        cx.simulate_click(point(px(400.), px(24.)), Modifiers::default());
299        view.read_with(cx, |v, _| assert_eq!(v.clicks, 1));
300    }
301
302    fn moves() -> usize {
303        MOVES.with(Cell::get)
304    }
305
306    #[gpui::test]
307    fn only_empty_bar_space_starts_a_window_drag(cx: &mut TestAppContext) {
308        MOVES.with(|moves| moves.set(0));
309        let (_, cx) = cx.add_window_view(|_, _| Harness { clicks: 0 });
310        cx.run_until_parked();
311        let control = cx
312            .debug_bounds("in-bar")
313            .expect("the bar's button")
314            .center();
315        let empty = point(px(400.), px(24.));
316        let left = Some(MouseButton::Left);
317        // Dragging from a control never moves the window.
318        cx.simulate_mouse_down(control, MouseButton::Left, Modifiers::default());
319        cx.simulate_mouse_move(control + point(px(8.), px(0.)), left, Modifiers::default());
320        cx.simulate_mouse_up(control, MouseButton::Left, Modifiers::default());
321        assert_eq!(moves(), 0);
322        // A press and release on empty space, then a hover, does not either.
323        cx.simulate_mouse_down(empty, MouseButton::Left, Modifiers::default());
324        cx.simulate_mouse_up(empty, MouseButton::Left, Modifiers::default());
325        cx.simulate_mouse_move(empty, None, Modifiers::default());
326        assert_eq!(moves(), 0);
327        // Dragging from empty space moves it, except where Windows does.
328        cx.simulate_mouse_down(empty, MouseButton::Left, Modifiers::default());
329        cx.simulate_mouse_move(empty + point(px(8.), px(0.)), left, Modifiers::default());
330        assert_eq!(moves(), usize::from(!cfg!(target_os = "windows")));
331    }
332
333    #[gpui::test]
334    fn the_caller_gap_stops_at_the_last_child(cx: &mut TestAppContext) {
335        let (_, cx) = cx.add_window_view(|_, _| Harness { clicks: 0 });
336        cx.run_until_parked();
337        let last = cx.debug_bounds("last").expect("the last child");
338        let (width, trailing) = cx.update(|window, _| {
339            (
340                window.viewport_size().width,
341                CaptionButtons::trailing(window),
342            )
343        });
344        assert_eq!(last.right(), width - px(trailing));
345    }
346}