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 on
27//! Linux asks for `WindowDecorations::Client`. On macOS AppKit still drags the
28//! window by its transparent titlebar, and a [`grip`] there is redundant.
29//!
30//! ```ignore
31//! titlebar::titlebar("titlebar", true, window)
32//!     .px(px(8.0))
33//!     .child(/* … */)
34//!     .child(titlebar::grip("titlebar-grip", &self.drag, window))
35//!     .child(titlebar::controls(CaptionSide::Right, window, cx))
36//! ```
37
38use std::{cell::Cell, rc::Rc};
39
40use gpui::{
41    App, Div, ElementId, MAX_BUTTONS_PER_SIDE, MouseButton, Stateful, Window, WindowButton,
42    WindowButtonLayout, WindowControlArea, WindowControls, div, prelude::*, px, rgb,
43};
44
45use theme::Theme;
46
47/// Appearance of app-drawn caption buttons; native window actions stay the same.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum CaptionStyle {
50    #[default]
51    Rectangular,
52    /// Left-aligned close, minimise, zoom discs within full-size caption targets.
53    Lights,
54}
55
56impl gpui::Global for CaptionStyle {}
57
58impl CaptionStyle {
59    /// Resolves button order and permissions. Only rectangular captions use the desktop layout.
60    pub fn button_layout(
61        self,
62        desktop: Option<WindowButtonLayout>,
63        allowed: WindowControls,
64    ) -> WindowButtonLayout {
65        let mut layout = match self {
66            Self::Rectangular => desktop.unwrap_or(TRAILING),
67            Self::Lights => WindowButtonLayout {
68                left: [
69                    Some(WindowButton::Close),
70                    Some(WindowButton::Minimize),
71                    Some(WindowButton::Maximize),
72                ],
73                right: [None; MAX_BUTTONS_PER_SIDE],
74            },
75        };
76        for button in layout.left.iter_mut().chain(layout.right.iter_mut()) {
77            if matches!(button, Some(WindowButton::Minimize) if !allowed.minimize)
78                || matches!(button, Some(WindowButton::Maximize) if !allowed.maximize)
79            {
80                *button = None;
81            }
82        }
83        layout
84    }
85}
86
87/// Full left strip for three lights, including the space around their discs.
88/// Targets keep the platform caption width and reach the left edge without padding.
89pub const LIGHTS_WIDTH: f32 = 3.0 * Theme::CAPTION_BUTTON_WIDTH;
90
91/// Width occupied by left-side lights for this window, accounting for permissions
92/// and fullscreen. Reserve it only when positioning controls as an overlay;
93/// [`controls`] already occupies this space in normal layout.
94pub fn lights_width(window: &Window) -> f32 {
95    if window.is_fullscreen() {
96        return 0.0;
97    }
98    CaptionStyle::Lights
99        .button_layout(None, window.window_controls())
100        .left
101        .into_iter()
102        .flatten()
103        .count() as f32
104        * Theme::CAPTION_BUTTON_WIDTH
105}
106
107pub(crate) fn caption_style(cx: &App) -> CaptionStyle {
108    cx.try_global::<CaptionStyle>().copied().unwrap_or_default()
109}
110
111const CAPTION_GROUP: &str = "caption-controls";
112
113/// Whether the press on a [`grip`] is still a candidate for a window move.
114///
115/// Shaped like [`crate::scroll::FollowState`] and for the same reason: it
116/// mutates through `&self`, so the element carries the whole gesture and the
117/// view holds one field.
118#[derive(Clone, Default)]
119pub struct DragState(Rc<Cell<bool>>);
120
121/// The strip: full width, [`Theme::TITLEBAR_HEIGHT`] tall, and inert. What it
122/// holds is the caller's, including [`grip`], without which the window has no
123/// handle off macOS.
124///
125/// `traffic_lights` reserves the leading inset for the macOS buttons — pass it
126/// on the one strip they sit over, and it stands down in full screen, where
127/// they are gone and the gap would be a hole.
128pub fn titlebar(id: impl Into<ElementId>, traffic_lights: bool, window: &Window) -> Stateful<Div> {
129    div()
130        .id(id)
131        .w_full()
132        .h(px(Theme::TITLEBAR_HEIGHT))
133        .flex()
134        .flex_row()
135        .items_center()
136        .when(traffic_lights && !window.is_fullscreen(), |bar| {
137            bar.pl(px(Theme::TRAFFIC_LIGHT_INSET))
138        })
139}
140
141/// The bare stretch of a titlebar that drags its window, zooms it on a double
142/// click and opens the desktop's window menu on a right press.
143///
144/// Takes the free space in the bar, so it is the room the content leaves. A
145/// bar whose content fills it has no handle — the window can then only be
146/// moved by its own edges, where the system has any.
147///
148/// Nothing needs to opt out of it: a control beside a grip is not inside it,
149/// and the platform hit test only ever lands in one of them.
150pub fn grip(id: impl Into<ElementId>, drag: &DragState, window: &Window) -> Stateful<Div> {
151    let (armed, disarm, release) = (drag.0.clone(), drag.0.clone(), drag.0.clone());
152    let moving = drag.0.clone();
153    let zoomable = window.window_controls().maximize && window.is_resizable();
154    div()
155        .id(id)
156        .flex_1()
157        .self_stretch()
158        .on_mouse_down(MouseButton::Left, move |_, _, _| armed.set(true))
159        .on_mouse_up(MouseButton::Left, move |_, _, _| release.set(false))
160        // A press that leaves the grip is not a window move either — without
161        // this the flag survives, and the next stray motion over it drags the
162        // window with no button held.
163        .on_mouse_down_out(move |_, _, _| disarm.set(false))
164        .on_mouse_move(move |_, window, _| {
165            if moving.replace(false) {
166                window.start_window_move();
167            }
168        })
169        // What Windows moves by: the area answers `WM_NCHITTEST` with
170        // `HTCAPTION`, and the system drag, the edge snap and the double-click
171        // zoom all follow from that. `start_window_move` above is the Linux
172        // path and is not implemented there at all.
173        .window_control_area(WindowControlArea::Drag)
174        // The window menu the desktop hangs off its own titlebar, where the
175        // compositor says there is one. On the press, which is where a context
176        // menu belongs, and a no-op on macOS and on Windows, where the system
177        // menu comes from the caption hit test instead.
178        .when(window.window_controls().window_menu, |grip| {
179            grip.on_mouse_down(MouseButton::Right, |event, window, _| {
180                window.show_window_menu(event.position);
181            })
182        })
183        .on_click(move |click, window, _| {
184            if click.click_count() == 2 {
185                // macOS runs whatever the user set the gesture to — zoom,
186                // minimise or nothing — and Windows zooms from `HTCAPTION`
187                // without being asked. Linux has neither, and only where the
188                // window can be zoomed at all.
189                match cfg!(any(target_os = "linux", target_os = "freebsd")) {
190                    true if zoomable => window.zoom_window(),
191                    true => {}
192                    false => window.titlebar_double_click(),
193                }
194            }
195        })
196}
197
198/// Which end of the bar a caption cluster sits at.
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum CaptionSide {
201    Left,
202    Right,
203}
204
205/// The caption buttons, for a window whose system caption is gone —
206/// `appears_transparent` on Windows, `Decorations::Client` on Linux.
207///
208/// Call it at both ends. Rectangular captions follow `App::button_layout`,
209/// falling back to the right. Lights always use close/minimise/zoom on the left.
210///
211/// Empty in full screen, and short whatever `Window::window_controls` says the
212/// compositor will refuse. Close is never refused.
213///
214/// The cluster takes its own width in the bar, so nothing is reserved for it
215/// the way [`Theme::TRAFFIC_LIGHT_INSET`] is reserved for AppKit's lights —
216/// those are painted over the client area by someone else, and these are not.
217///
218/// ```ignore
219/// titlebar::titlebar("titlebar", true, window)
220///     .child(titlebar::controls(CaptionSide::Left, window, cx))
221///     .child(titlebar::grip("titlebar-grip", &self.drag, window))
222///     .child(titlebar::controls(CaptionSide::Right, window, cx))
223/// ```
224pub fn controls(side: CaptionSide, window: &Window, cx: &App) -> Div {
225    let row = div()
226        .group(CAPTION_GROUP)
227        .flex()
228        .flex_row()
229        .flex_none()
230        .items_center()
231        .h_full();
232    if window.is_fullscreen() {
233        return row;
234    }
235
236    let layout = caption_style(cx).button_layout(cx.button_layout(), window.window_controls());
237    let buttons = match side {
238        CaptionSide::Left => layout.left,
239        CaptionSide::Right => layout.right,
240    };
241
242    buttons.into_iter().flatten().fold(row, |row, button| {
243        row.child(caption_button(button, window, cx))
244    })
245}
246
247/// What a platform with no layout of its own gets: all three, trailing.
248const TRAILING: WindowButtonLayout = WindowButtonLayout {
249    left: [None; MAX_BUTTONS_PER_SIDE],
250    right: [
251        Some(WindowButton::Minimize),
252        Some(WindowButton::Maximize),
253        Some(WindowButton::Close),
254    ],
255};
256
257/// The caption glyphs are drawn to their own scale, not the type ladder's —
258/// Windows sets them at 10px whatever the shell font is doing.
259const CAPTION_GLYPH: f32 = 10.0;
260
261/// One caption button: the glyph, the hover wash, and the hitbox the platform
262/// reads.
263///
264/// The click is wired everywhere but Windows, where answering `WM_NCHITTEST`
265/// with `HTCLOSE` and friends has already handed the press to the system —
266/// acting on it here as well would minimise and restore in one gesture.
267fn caption_button(button: WindowButton, window: &Window, cx: &App) -> Stateful<Div> {
268    let theme = Theme::of(cx);
269    let (area, glyph, hover) = match button {
270        WindowButton::Close => (WindowControlArea::Close, icons::glyph::X, theme.danger),
271        WindowButton::Minimize => (
272            WindowControlArea::Min,
273            icons::glyph::Minus,
274            theme.element_hover,
275        ),
276        // The restore mark is two offset squares, which is what `copy` draws.
277        WindowButton::Maximize => (
278            WindowControlArea::Max,
279            match window.is_maximized() {
280                true => icons::glyph::Copy,
281                false => icons::glyph::Square,
282            },
283            theme.element_hover,
284        ),
285    };
286
287    div()
288        .id(button.id())
289        .debug_selector(move || format!("caption-{}", button.id()))
290        .flex()
291        .flex_none()
292        .items_center()
293        .justify_center()
294        .w(px(Theme::CAPTION_BUTTON_WIDTH))
295        .h_full()
296        .map(|control| match caption_style(cx) {
297            CaptionStyle::Rectangular => control.hover(|button| button.bg(hover)).child(
298                icons::icon(glyph)
299                    .size(px(CAPTION_GLYPH))
300                    .text_color(theme.text),
301            ),
302            CaptionStyle::Lights => {
303                let (color, mark) = match button {
304                    WindowButton::Close => (0xff5f57, icons::glyph::X),
305                    WindowButton::Minimize => (0xfebc2e, icons::glyph::Minus),
306                    WindowButton::Maximize => (0x28c840, icons::glyph::Plus),
307                };
308                control.child(
309                    div()
310                        .size(px(12.0))
311                        .rounded_full()
312                        .bg(rgb(if window.is_window_active() {
313                            color
314                        } else {
315                            0x999999
316                        }))
317                        .flex()
318                        .items_center()
319                        .justify_center()
320                        .child(
321                            div()
322                                .invisible()
323                                .group_hover(CAPTION_GROUP, |mark| mark.visible())
324                                .child(icons::icon(mark).size(px(8.0)).text_color(rgb(0x333333))),
325                        ),
326                )
327            }
328        })
329        .window_control_area(area)
330        .when(!cfg!(target_os = "windows"), |control| {
331            control.on_click(move |_, window, _| match button {
332                WindowButton::Close => window.remove_window(),
333                WindowButton::Minimize => window.minimize_window(),
334                WindowButton::Maximize => window.zoom_window(),
335            })
336        })
337}