Skip to main content

ui/
titlebar.rs

1//! [`titlebar`] — the strip a window with no system titlebar moves itself by.
2//!
3//! The strip does not move the window. A [`grip`] in it does, and everything
4//! else in the bar is an ordinary element.
5//!
6//! That split is forced by how the platform asks. Windows answers
7//! `WM_NCHITTEST` out of a flat list of control areas and takes the first one
8//! the pointer falls in, parent before child — so a bar that is itself one
9//! drag area turns every control in it into a window handle, unless each
10//! control blocks the mouse, and blocking the mouse also takes the scroll
11//! wheel from everything behind it. Naming the drag surface instead is what
12//! AppKit does with a drag gesture on a view, and it costs no control
13//! anything.
14//!
15//! Three platforms, three mechanisms, all of them on the grip: AppKit drags by
16//! itself, Linux is told to with `start_window_move` on the first *motion*
17//! after a press rather than on the press, and Windows implements neither and
18//! reads `WindowControlArea::Drag` back out of the hit test.
19//!
20//! The macOS traffic lights need [`Theme::TRAFFIC_LIGHT_INSET`] of leading
21//! room, which is nothing until the window goes full screen and AppKit takes
22//! them away. Off macOS the buttons are the app's to paint: [`controls`] is
23//! the cluster, and the frame around it — border, corners, shadow, resize
24//! edges — is [`crate::window::frame`].
25//!
26//! The window it belongs to opens with `appears_transparent: true` and
27//! **`app_owns_titlebar_drag: true`** — the second one stops AppKit from
28//! dragging the window *and* from delaying titlebar clicks while it waits to
29//! see a double-click.
30//!
31//! ```ignore
32//! titlebar::titlebar("titlebar", true, window)
33//!     .px(px(8.0))
34//!     .child(/* … */)
35//!     .child(titlebar::grip("titlebar-grip", &self.drag, window))
36//!     .child(titlebar::controls(CaptionSide::Right, window, cx))
37//! ```
38
39use std::{cell::Cell, rc::Rc};
40
41use gpui::{
42    App, Div, ElementId, MAX_BUTTONS_PER_SIDE, MouseButton, Stateful, Window, WindowButton,
43    WindowButtonLayout, WindowControlArea, div, prelude::*, px,
44};
45
46use theme::Theme;
47
48/// Whether the press on a [`grip`] is still a candidate for a window move.
49///
50/// Shaped like [`crate::scroll::FollowState`] and for the same reason: it
51/// mutates through `&self`, so the element carries the whole gesture and the
52/// view holds one field.
53#[derive(Clone, Default)]
54pub struct DragState(Rc<Cell<bool>>);
55
56/// The strip: full width, [`Theme::TITLEBAR_HEIGHT`] tall, and inert. What it
57/// holds is the caller's, including [`grip`], without which the window has no
58/// handle off macOS.
59///
60/// `traffic_lights` reserves the leading inset for the macOS buttons — pass it
61/// on the one strip they sit over, and it stands down in full screen, where
62/// they are gone and the gap would be a hole.
63pub fn titlebar(id: impl Into<ElementId>, traffic_lights: bool, window: &Window) -> Stateful<Div> {
64    div()
65        .id(id)
66        .w_full()
67        .h(px(Theme::TITLEBAR_HEIGHT))
68        .flex()
69        .flex_row()
70        .items_center()
71        .when(traffic_lights && !window.is_fullscreen(), |bar| {
72            bar.pl(px(Theme::TRAFFIC_LIGHT_INSET))
73        })
74}
75
76/// The bare stretch of a titlebar that drags its window, zooms it on a double
77/// click and opens the desktop's window menu on a right press.
78///
79/// Takes the free space in the bar, so it is the room the content leaves. A
80/// bar whose content fills it has no handle — the window can then only be
81/// moved by its own edges, where the system has any.
82///
83/// Nothing needs to opt out of it: a control beside a grip is not inside it,
84/// and the platform hit test only ever lands in one of them.
85pub fn grip(id: impl Into<ElementId>, drag: &DragState, window: &Window) -> Stateful<Div> {
86    let (armed, disarm, release) = (drag.0.clone(), drag.0.clone(), drag.0.clone());
87    let moving = drag.0.clone();
88    let zoomable = window.window_controls().maximize && window.is_resizable();
89    div()
90        .id(id)
91        .flex_1()
92        .self_stretch()
93        .on_mouse_down(MouseButton::Left, move |_, _, _| armed.set(true))
94        .on_mouse_up(MouseButton::Left, move |_, _, _| release.set(false))
95        // A press that leaves the grip is not a window move either — without
96        // this the flag survives, and the next stray motion over it drags the
97        // window with no button held.
98        .on_mouse_down_out(move |_, _, _| disarm.set(false))
99        .on_mouse_move(move |_, window, _| {
100            if moving.replace(false) {
101                window.start_window_move();
102            }
103        })
104        // What Windows moves by: the area answers `WM_NCHITTEST` with
105        // `HTCAPTION`, and the system drag, the edge snap and the double-click
106        // zoom all follow from that. `start_window_move` above is the Linux
107        // path and is not implemented there at all.
108        .window_control_area(WindowControlArea::Drag)
109        // The window menu the desktop hangs off its own titlebar, where the
110        // compositor says there is one. On the press, which is where a context
111        // menu belongs, and a no-op on macOS and on Windows, where the system
112        // menu comes from the caption hit test instead.
113        .when(window.window_controls().window_menu, |grip| {
114            grip.on_mouse_down(MouseButton::Right, |event, window, _| {
115                window.show_window_menu(event.position);
116            })
117        })
118        .on_click(move |click, window, _| {
119            if click.click_count() == 2 {
120                // macOS runs whatever the user set the gesture to — zoom,
121                // minimise or nothing — and Windows zooms from `HTCAPTION`
122                // without being asked. Linux has neither, and only where the
123                // window can be zoomed at all.
124                match cfg!(any(target_os = "linux", target_os = "freebsd")) {
125                    true if zoomable => window.zoom_window(),
126                    true => {}
127                    false => window.titlebar_double_click(),
128                }
129            }
130        })
131}
132
133/// Which end of the bar a caption cluster sits at.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum CaptionSide {
136    Left,
137    Right,
138}
139
140/// The caption buttons, for a window whose system caption is gone —
141/// `appears_transparent` on Windows, `Decorations::Client` on Linux.
142///
143/// Call it at both ends of the bar and let the desktop decide which end fills:
144/// `App::button_layout` reads GNOME's `gtk-decoration-layout`, and a platform
145/// that reports no layout at all puts the three on the right.
146///
147/// Empty in full screen, and short whatever `Window::window_controls` says the
148/// compositor will refuse. Close is never refused.
149///
150/// The cluster takes its own width in the bar, so nothing is reserved for it
151/// the way [`Theme::TRAFFIC_LIGHT_INSET`] is reserved for AppKit's lights —
152/// those are painted over the client area by someone else, and these are not.
153///
154/// ```ignore
155/// titlebar::titlebar("titlebar", &self.drag, true, window)
156///     .child(titlebar::controls(CaptionSide::Left, window, cx))
157///     .child(div().flex_1().child(/* … */))
158///     .child(titlebar::controls(CaptionSide::Right, window, cx))
159/// ```
160pub fn controls(side: CaptionSide, window: &Window, cx: &App) -> Div {
161    let row = div().flex().flex_row().items_center().h_full();
162    if window.is_fullscreen() {
163        return row;
164    }
165
166    let allowed = window.window_controls();
167    let layout = cx.button_layout().unwrap_or(TRAILING);
168    let buttons = match side {
169        CaptionSide::Left => layout.left,
170        CaptionSide::Right => layout.right,
171    };
172
173    buttons
174        .into_iter()
175        .flatten()
176        .filter(|button| match button {
177            WindowButton::Close => true,
178            WindowButton::Maximize => allowed.maximize,
179            WindowButton::Minimize => allowed.minimize,
180        })
181        .fold(row, |row, button| {
182            row.child(caption_button(button, window, cx))
183        })
184}
185
186/// What a platform with no layout of its own gets: all three, trailing.
187const TRAILING: WindowButtonLayout = WindowButtonLayout {
188    left: [None; MAX_BUTTONS_PER_SIDE],
189    right: [
190        Some(WindowButton::Minimize),
191        Some(WindowButton::Maximize),
192        Some(WindowButton::Close),
193    ],
194};
195
196/// The caption glyphs are drawn to their own scale, not the type ladder's —
197/// Windows sets them at 10px whatever the shell font is doing.
198const CAPTION_GLYPH: f32 = 10.0;
199
200/// One caption button: the glyph, the hover wash, and the hitbox the platform
201/// reads.
202///
203/// The click is wired everywhere but Windows, where answering `WM_NCHITTEST`
204/// with `HTCLOSE` and friends has already handed the press to the system —
205/// acting on it here as well would minimise and restore in one gesture.
206fn caption_button(button: WindowButton, window: &Window, cx: &App) -> Stateful<Div> {
207    let theme = Theme::of(cx);
208    let (area, glyph, hover) = match button {
209        WindowButton::Close => (WindowControlArea::Close, icons::glyph::X, theme.danger),
210        WindowButton::Minimize => (
211            WindowControlArea::Min,
212            icons::glyph::Minus,
213            theme.element_hover,
214        ),
215        // The restore mark is two offset squares, which is what `copy` draws.
216        WindowButton::Maximize => (
217            WindowControlArea::Max,
218            match window.is_maximized() {
219                true => icons::glyph::Copy,
220                false => icons::glyph::Square,
221            },
222            theme.element_hover,
223        ),
224    };
225
226    div()
227        .id(button.id())
228        .flex()
229        .items_center()
230        .justify_center()
231        .w(px(Theme::CAPTION_BUTTON_WIDTH))
232        .h_full()
233        .hover(|button| button.bg(hover))
234        .child(
235            icons::icon(glyph)
236                .size(px(CAPTION_GLYPH))
237                .text_color(theme.text),
238        )
239        .window_control_area(area)
240        .when(!cfg!(target_os = "windows"), |control| {
241            control.on_click(move |_, window, _| match button {
242                WindowButton::Close => window.remove_window(),
243                WindowButton::Minimize => window.minimize_window(),
244                WindowButton::Maximize => window.zoom_window(),
245            })
246        })
247}