Skip to main content

gpui_kit_material/components/
caption.rs

1//! Caption buttons for a window that draws its own title bar.
2//!
3//! macOS keeps its native traffic lights in the bar's leading edge. Windows
4//! has nothing of its own left once the system title bar is hidden, and
5//! Linux under client-side decorations neither, so [`CaptionButtons`] draws
6//! minimize, maximize or restore, and close at the trailing edge, once for
7//! the whole window.
8//!
9//! They paint and hit-test above pages and dialogs: GPUI Kit dialogs are
10//! deferred layers (priority 10 and up) whose backdrop is itself a drag
11//! area, so the buttons are a deferred layer of a higher priority, the way
12//! the system's own buttons stay usable over an app's modal content. Menus,
13//! popovers (`gpui_base::POPUP_PRIORITY`, 100 and up) and tooltips stay
14//! above them: where an open menu reaches the top of the window, a press
15//! there chooses its item.
16//!
17//! On Windows the buttons are window control areas: GPUI answers the
18//! system's non-client hit test with `HTMINBUTTON` / `HTMAXBUTTON` /
19//! `HTCLOSE` for them, so Windows itself minimizes, maximizes (with the snap
20//! layouts flyout) and closes. On Linux the buttons act themselves.
21//!
22//! Both styles sit on the bar's vertical center, like everything else in it.
23//! The glyphs are the platform's own shapes, not Material Symbols: symbols
24//! have different optical sizes on their 24dp grid (`remove` is 7/12 of the
25//! box, `crop_square` 3/4, and `filter_none` sits off-center), which at
26//! caption size reads as a crooked row. Each glyph here fills its box
27//! edge to edge and strokes on whole device pixels.
28use crate::color::ColorRole::{self, *};
29use crate::theme::color;
30use crate::tokens::state;
31use gpui::{
32    AnyElement, App, Bounds, Decorations, ElementId, InteractiveElement as _, IntoElement,
33    MouseButton, ParentElement as _, PathBuilder, Pixels, RenderOnce, Rgba, SharedString,
34    StatefulInteractiveElement as _, Styled, Window, WindowControlArea, canvas, div, fill, point,
35    prelude::FluentBuilder as _, px, size,
36};
37use std::rc::Rc;
38
39/// Which caption buttons the window needs from the application.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Caption {
42    /// The system draws them: macOS traffic lights, or a Linux window
43    /// manager's server-side title bar.
44    System,
45    /// Windows: 46×32px buttons along the top of the bar's trailing edge.
46    Windows,
47    /// Linux under client-side decorations: round buttons, as GNOME and KDE
48    /// draw them.
49    Linux,
50}
51
52impl Caption {
53    /// The caption this window needs on the current platform.
54    pub fn detect(window: &Window) -> Self {
55        if cfg!(target_os = "windows") {
56            Caption::Windows
57        } else if cfg!(any(target_os = "linux", target_os = "freebsd"))
58            && matches!(window.window_decorations(), Decorations::Client { .. })
59        {
60            Caption::Linux
61        } else {
62            Caption::System
63        }
64    }
65}
66
67/// Windows caption buttons are 46×32px at 100% display scaling, as Windows
68/// 11 draws them; a taller bar centers them vertically with its content.
69/// Logical pixels follow the display scaling; the text scale does not change
70/// them.
71const WINDOWS_BUTTON_WIDTH: f32 = 46.;
72const WINDOWS_BUTTON_HEIGHT: f32 = 32.;
73/// Above every dialog layer, below popups and tooltips.
74const CAPTION_PRIORITY: usize = 50;
75/// A Linux caption button's target, the round shape inside it, the gap
76/// between two of them and the space after the last.
77const LINUX_BUTTON: f32 = 32.;
78const LINUX_SHAPE: f32 = 24.;
79const LINUX_GAP: f32 = 4.;
80const LINUX_END: f32 = 12.;
81/// The glyph box and stroke of each style: Windows draws 10px glyphs with
82/// hairlines (Segoe Fluent's chrome glyphs), libadwaita 8px ones with a
83/// heavier line in its round button.
84const WINDOWS_GLYPH: f32 = 10.;
85const WINDOWS_STROKE: f32 = 1.;
86const LINUX_GLYPH: f32 = 8.;
87const LINUX_STROKE: f32 = 1.5;
88
89/// A platform color, not a Material role: Windows 11 turns a hovered
90/// caption close button red (`#C42B1C`) behind a white glyph whatever the
91/// app's palette, as its own title bars and WinUI 3's `TitleBar` caption
92/// buttons do. Pressed keeps the red at 90% opacity.
93const WINDOWS_CLOSE_RED: Rgba = Rgba {
94    r: 0xc4 as f32 / 255.,
95    g: 0x2b as f32 / 255.,
96    b: 0x1c as f32 / 255.,
97    a: 1.,
98};
99/// The glyph over [`WINDOWS_CLOSE_RED`].
100const WINDOWS_CLOSE_GLYPH: Rgba = Rgba {
101    r: 1.,
102    g: 1.,
103    b: 1.,
104    a: 1.,
105};
106
107fn close_hover() -> Rgba {
108    WINDOWS_CLOSE_RED
109}
110
111fn close_pressed() -> Rgba {
112    Rgba {
113        a: 0.9,
114        ..WINDOWS_CLOSE_RED
115    }
116}
117
118/// One caption button.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum CaptionButton {
121    Minimize,
122    Maximize,
123    Restore,
124    Close,
125}
126
127impl CaptionButton {
128    fn id(self) -> &'static str {
129        match self {
130            Self::Minimize => "caption-minimize",
131            Self::Maximize => "caption-maximize",
132            Self::Restore => "caption-restore",
133            Self::Close => "caption-close",
134        }
135    }
136
137    fn area(self) -> WindowControlArea {
138        match self {
139            Self::Minimize => WindowControlArea::Min,
140            Self::Maximize | Self::Restore => WindowControlArea::Max,
141            Self::Close => WindowControlArea::Close,
142        }
143    }
144}
145
146/// Accessible names of the caption buttons. The kit has no locale of its
147/// own, so there is no default: applications pass their translations.
148///
149/// ```compile_fail
150/// let _ = gpui_kit_material::components::CaptionLabels::default();
151/// ```
152#[derive(Clone, Debug)]
153pub struct CaptionLabels {
154    pub minimize: SharedString,
155    pub maximize: SharedString,
156    pub restore: SharedString,
157    pub close: SharedString,
158}
159
160impl CaptionLabels {
161    fn get(&self, button: CaptionButton) -> SharedString {
162        match button {
163            CaptionButton::Minimize => self.minimize.clone(),
164            CaptionButton::Maximize => self.maximize.clone(),
165            CaptionButton::Restore => self.restore.clone(),
166            CaptionButton::Close => self.close.clone(),
167        }
168    }
169}
170
171/// The buttons a preview shows: a window's full set, whatever the current
172/// window offers, and none for a caption the system draws.
173fn preview_buttons(caption: Caption) -> Vec<CaptionButton> {
174    if caption == Caption::System {
175        return Vec::new();
176    }
177    vec![
178        CaptionButton::Minimize,
179        CaptionButton::Maximize,
180        CaptionButton::Close,
181    ]
182}
183
184/// The buttons in order, as far as the window and its window manager allow.
185fn buttons(caption: Caption, window: &Window) -> Vec<CaptionButton> {
186    if caption == Caption::System || window.is_fullscreen() {
187        return Vec::new();
188    }
189    let (minimize, maximize) = match caption {
190        // Windows itself acts on these buttons.
191        Caption::Windows => (true, true),
192        _ => {
193            let supported = window.window_controls();
194            (supported.minimize, supported.maximize)
195        }
196    };
197    let zoom = if window.is_maximized() {
198        CaptionButton::Restore
199    } else {
200        CaptionButton::Maximize
201    };
202    [
203        minimize.then_some(CaptionButton::Minimize),
204        maximize.then_some(zoom),
205        Some(CaptionButton::Close),
206    ]
207    .into_iter()
208    .flatten()
209    .collect()
210}
211
212fn width_of(caption: Caption, count: usize) -> f32 {
213    match (caption, count) {
214        (_, 0) | (Caption::System, _) => 0.,
215        (Caption::Windows, count) => WINDOWS_BUTTON_WIDTH * count as f32,
216        (Caption::Linux, count) => {
217            LINUX_BUTTON * count as f32 + LINUX_GAP * (count - 1) as f32 + LINUX_END
218        }
219    }
220}
221
222fn trailing_for(caption_width: f32) -> f32 {
223    if caption_width > 0. {
224        caption_width + 8.
225    } else {
226        16.
227    }
228}
229
230pub(super) type CloseRequest = Rc<dyn Fn(&mut Window, &mut App) -> bool>;
231
232/// The caption buttons over the trailing end of a title bar, above any
233/// dialog, or nothing where the system draws them. Render it once per
234/// window, as a child of the root; a [`super::TitleBar`] renders its own, so
235/// a window with one needs no other.
236///
237/// [`CaptionButtons::preview`] instead draws them in place, e.g. in a
238/// catalog page: inert, in the page's flow, never over the real title bar.
239#[derive(gpui::IntoElement)]
240pub struct CaptionButtons {
241    height: Pixels,
242    labels: CaptionLabels,
243    caption: Option<Caption>,
244    on_close: Option<CloseRequest>,
245    preview: Option<ElementId>,
246}
247
248impl CaptionButtons {
249    /// Buttons for a title bar `height` tall, announced with `labels`.
250    pub fn new(height: Pixels, labels: CaptionLabels) -> Self {
251        Self {
252            height,
253            labels,
254            caption: None,
255            on_close: None,
256            preview: None,
257        }
258    }
259
260    /// Draw the buttons inline, where the parent lays them out, instead of
261    /// pinning them to the window's top-right corner above every dialog. A
262    /// preview shows minimize, maximize and close for its caption style
263    /// (nothing for [`Caption::System`]) and keeps the hover and press
264    /// states, but its buttons are not window control areas and do not act
265    /// on the window. `id` keeps several previews in one window apart.
266    pub fn preview(mut self, id: impl Into<ElementId>) -> Self {
267        self.preview = Some(id.into());
268        self
269    }
270
271    pub fn labels(mut self, labels: CaptionLabels) -> Self {
272        self.labels = labels;
273        self
274    }
275
276    /// Force a caption style instead of detecting it, e.g. for screenshots.
277    pub fn caption(mut self, caption: Caption) -> Self {
278        self.caption = Some(caption);
279        self
280    }
281
282    /// Asked before the Linux close button removes the window; return
283    /// `false` to keep it (e.g. to save first). Windows routes close through
284    /// the system, which the application observes as a window close.
285    pub fn on_close(mut self, request: impl Fn(&mut Window, &mut App) -> bool + 'static) -> Self {
286        self.on_close = Some(Rc::new(request));
287        self
288    }
289
290    /// The width the buttons take from the title bar's trailing edge.
291    pub fn width(window: &Window) -> f32 {
292        let caption = Caption::detect(window);
293        width_of(caption, buttons(caption, window).len())
294    }
295
296    /// Where the title bar's own content should end: before the buttons with
297    /// a little open space, or a 16px inset where the system draws them.
298    pub fn trailing(window: &Window) -> f32 {
299        trailing_for(Self::width(window))
300    }
301
302    /// [`Self::trailing`] for a forced caption style.
303    pub(super) fn trailing_for_style(caption: Caption, window: &Window) -> f32 {
304        trailing_for(width_of(caption, buttons(caption, window).len()))
305    }
306}
307
308impl RenderOnce for CaptionButtons {
309    fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
310        let caption = self.caption.unwrap_or_else(|| Caption::detect(window));
311        let live = self.preview.is_none();
312        let buttons = if live {
313            buttons(caption, window)
314        } else {
315            preview_buttons(caption)
316        };
317        if buttons.is_empty() {
318            return div().into_any_element();
319        }
320        let glyph_color = color(if window.is_window_active() {
321            ON_SURFACE
322        } else {
323            ON_SURFACE_VARIANT
324        });
325        let id = self
326            .preview
327            .clone()
328            .unwrap_or_else(|| ElementId::from("window-caption"));
329        let row = gpui_base::h_flex()
330            .id(id)
331            .flex_shrink_0()
332            .text_color(glyph_color);
333        let row = match caption {
334            Caption::Windows => row.h(self.height).items_center().children(
335                buttons
336                    .into_iter()
337                    .map(|b| windows_button(b, &self.labels, self.height, live)),
338            ),
339            _ => {
340                row.h(self.height)
341                    .items_center()
342                    .pr(px(LINUX_END))
343                    .gap(px(LINUX_GAP))
344                    .children(buttons.into_iter().map(|button| {
345                        linux_button(button, &self.labels, live, self.on_close.clone())
346                    }))
347            }
348        };
349        if live {
350            caption_layer(row, window)
351        } else {
352            row.into_any_element()
353        }
354    }
355}
356
357/// Makes a control inside a title bar take the press itself. The whole bar
358/// is a drag area: on Windows a press there arrives as a caption press, and
359/// one no element stops goes on to the system's move-window loop (and
360/// maximizes on a double click). Empty bar space still moves the window.
361pub fn title_bar_control<E: gpui::InteractiveElement>(element: E) -> E {
362    element.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
363}
364
365/// The straight strokes of a glyph in a `glyph` px box, relative to the box.
366/// Lines are `stroke` thick; [`CaptionButton::Close`] has none, its diagonals
367/// are stroked as a path. The ink always fills the box, so it is centered in
368/// the box up to one stroke's parity.
369fn glyph_rects(button: CaptionButton, glyph: f32, stroke: f32) -> Vec<Bounds<Pixels>> {
370    let rect = |x: f32, y: f32, width: f32, height: f32| Bounds {
371        origin: point(px(x), px(y)),
372        size: size(px(width), px(height)),
373    };
374    let frame = |x: f32, y: f32, side: f32| {
375        [
376            rect(x, y, side, stroke),
377            rect(x, y + side - stroke, side, stroke),
378            rect(x, y + stroke, stroke, side - 2. * stroke),
379            rect(x + side - stroke, y + stroke, stroke, side - 2. * stroke),
380        ]
381    };
382    match button {
383        CaptionButton::Minimize => vec![rect(0., (glyph - stroke) / 2., glyph, stroke)],
384        CaptionButton::Maximize => frame(0., 0., glyph).to_vec(),
385        CaptionButton::Restore => {
386            // The front window at the lower left, and what shows of the one
387            // behind it: its top and right edges.
388            let offset = (glyph * 0.2).round();
389            let mut rects = frame(0., offset, glyph - offset).to_vec();
390            rects.push(rect(offset, 0., glyph - offset, stroke));
391            rects.push(rect(
392                glyph - stroke,
393                stroke,
394                stroke,
395                glyph - offset - stroke,
396            ));
397            rects
398        }
399        CaptionButton::Close => Vec::new(),
400    }
401}
402
403/// A caption glyph in the surrounding text color, so it follows the button's
404/// hover and press. Strokes stay on whole device pixels at any display scale.
405fn glyph(button: CaptionButton, glyph: f32, stroke: f32) -> impl IntoElement {
406    let ink_box = div()
407        .when(cfg!(test), move |ink_box| {
408            ink_box.debug_selector(move || format!("{}-glyph", button.id()))
409        })
410        .flex_shrink_0()
411        .size(px(glyph));
412    ink_box.child(
413        canvas(
414            |_, _, _| {},
415            move |bounds, _, window, _| {
416                let ink = window.text_style().color;
417                let scale = window.scale_factor();
418                let snap = |value: f32| (value * scale).round() / scale;
419                let stroke = ((stroke * scale).round() / scale).max(1. / scale);
420                let origin = point(
421                    px(snap(f32::from(bounds.origin.x))),
422                    px(snap(f32::from(bounds.origin.y))),
423                );
424                for rect in glyph_rects(button, glyph, stroke) {
425                    let snapped = Bounds {
426                        origin: point(
427                            origin.x + px(snap(f32::from(rect.origin.x))),
428                            origin.y + px(snap(f32::from(rect.origin.y))),
429                        ),
430                        size: rect.size,
431                    };
432                    window.paint_quad(fill(snapped, ink));
433                }
434                if button == CaptionButton::Close {
435                    let side = px(glyph);
436                    for (from, to) in [
437                        (point(px(0.), px(0.)), point(side, side)),
438                        (point(side, px(0.)), point(px(0.), side)),
439                    ] {
440                        let mut path = PathBuilder::stroke(px(stroke));
441                        path.move_to(origin + from);
442                        path.line_to(origin + to);
443                        if let Ok(path) = path.build() {
444                            window.paint_path(path, ink);
445                        }
446                    }
447                }
448            },
449        )
450        .size_full(),
451    )
452}
453
454/// The row at the window's top-right corner, after every dialog layer.
455fn caption_layer(row: impl IntoElement, window: &Window) -> AnyElement {
456    let trailing = gpui::point(window.viewport_size().width, px(0.));
457    gpui::deferred(
458        gpui::anchored()
459            .anchor(gpui::Anchor::TopRight)
460            .position(trailing)
461            .child(row),
462    )
463    .with_priority(CAPTION_PRIORITY)
464    .into_any_element()
465}
466
467/// A Windows caption button: a window control area, so the system acts on
468/// it, unless it is only a preview. It occludes the drag area behind it.
469fn windows_button(
470    button: CaptionButton,
471    labels: &CaptionLabels,
472    bar_height: Pixels,
473    live: bool,
474) -> gpui::Stateful<gpui::Div> {
475    let close = button == CaptionButton::Close;
476    let ink = color(ON_SURFACE);
477    let (hover, pressed) = if close {
478        (close_hover(), close_pressed())
479    } else {
480        let clear = Rgba { a: 0., ..ink };
481        (state::hovered(clear, ink), state::pressed(clear, ink))
482    };
483    let on_close = WINDOWS_CLOSE_GLYPH;
484    div()
485        .id(button.id())
486        .role(gpui::Role::Button)
487        .aria_label(labels.get(button))
488        .occlude()
489        .when(live, |button_area| {
490            button_area.window_control_area(button.area())
491        })
492        .flex()
493        .flex_shrink_0()
494        .items_center()
495        .justify_center()
496        .w(px(WINDOWS_BUTTON_WIDTH))
497        .h(px(WINDOWS_BUTTON_HEIGHT).min(bar_height))
498        .hover(move |style| {
499            let style = style.bg(hover);
500            if close {
501                style.text_color(on_close)
502            } else {
503                style
504            }
505        })
506        .active(move |style| {
507            let style = style.bg(pressed);
508            if close {
509                style.text_color(on_close)
510            } else {
511                style
512            }
513        })
514        .when(cfg!(test), |area| {
515            area.debug_selector(move || button.id().to_string())
516        })
517        .child(glyph(button, WINDOWS_GLYPH, WINDOWS_STROKE))
518}
519
520/// A Linux caption button: GPUI does not hit-test window controls there, so
521/// the button acts itself, unless it is only a preview. As libadwaita draws
522/// them, every button, close included, takes the same neutral hover and
523/// press.
524fn linux_button(
525    button: CaptionButton,
526    labels: &CaptionLabels,
527    live: bool,
528    on_close: Option<CloseRequest>,
529) -> gpui::Stateful<gpui::Div> {
530    let group = button.id();
531    let shape: ColorRole = SURFACE_CONTAINER_HIGHEST;
532    let (hover, pressed) = (
533        state::hovered(color(shape), color(ON_SURFACE)),
534        state::pressed(color(shape), color(ON_SURFACE)),
535    );
536    div()
537        .id(button.id())
538        .group(group)
539        .role(gpui::Role::Button)
540        .aria_label(labels.get(button))
541        .occlude()
542        .flex()
543        .flex_shrink_0()
544        .items_center()
545        .justify_center()
546        .size(px(LINUX_BUTTON))
547        .child(
548            div()
549                .id("shape")
550                .flex()
551                .items_center()
552                .justify_center()
553                .size(px(LINUX_SHAPE))
554                .rounded_full()
555                .bg(color(shape))
556                .group_hover(group, move |style| style.bg(hover))
557                .group_active(group, move |style| style.bg(pressed))
558                .when(cfg!(test), |shape| {
559                    shape.debug_selector(move || format!("{}-shape", button.id()))
560                })
561                .child(glyph(button, LINUX_GLYPH, LINUX_STROKE)),
562        )
563        .when(cfg!(test), |target| {
564            target.debug_selector(move || button.id().to_string())
565        })
566        .on_mouse_down(MouseButton::Left, |_, window, cx| {
567            window.prevent_default();
568            cx.stop_propagation();
569        })
570        .on_click(move |_, window, cx| {
571            cx.stop_propagation();
572            if !live {
573                return;
574            }
575            match button {
576                CaptionButton::Minimize => window.minimize_window(),
577                CaptionButton::Maximize | CaptionButton::Restore => window.zoom_window(),
578                CaptionButton::Close => {
579                    if on_close.as_ref().is_none_or(|request| request(window, cx)) {
580                        window.remove_window();
581                    }
582                }
583            }
584        })
585}
586
587#[cfg(test)]
588pub(crate) mod tests {
589    use super::*;
590    use gpui::{Context, Modifiers, Render, TestAppContext};
591
592    pub(crate) fn test_labels() -> CaptionLabels {
593        CaptionLabels {
594            minimize: "Minimize".into(),
595            maximize: "Maximize".into(),
596            restore: "Restore".into(),
597            close: "Close".into(),
598        }
599    }
600
601    /// A modal dialog as gpui-component lays it out: a deferred layer at
602    /// priority 10 whose full-window backdrop takes every press.
603    struct DialogHarness {
604        backdrop_presses: usize,
605        menu_presses: usize,
606    }
607
608    impl Render for DialogHarness {
609        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
610            let backdrop = div()
611                .id("backdrop")
612                .absolute()
613                .top_0()
614                .left_0()
615                .size_full()
616                .occlude()
617                .window_control_area(WindowControlArea::Drag)
618                .on_mouse_down(
619                    MouseButton::Left,
620                    cx.listener(|this, _, _, _| this.backdrop_presses += 1),
621                );
622            // A tall menu clamped to the window's top-right corner, over the
623            // close button, as gpui-component's popups place it.
624            let menu = div()
625                .id("menu")
626                .absolute()
627                .top(px(8.))
628                .right(px(8.))
629                .w(px(20.))
630                .h(px(200.))
631                .occlude()
632                .on_mouse_down(
633                    MouseButton::Left,
634                    cx.listener(|this, _, _, _| this.menu_presses += 1),
635                );
636            let labels = test_labels();
637            let row = gpui_base::h_flex().h(px(WINDOWS_BUTTON_HEIGHT)).children(
638                [
639                    CaptionButton::Minimize,
640                    CaptionButton::Maximize,
641                    CaptionButton::Close,
642                ]
643                .map(|button| windows_button(button, &labels, px(WINDOWS_BUTTON_HEIGHT), true)),
644            );
645            div()
646                .size_full()
647                .child(gpui::deferred(backdrop).with_priority(10))
648                .child(caption_layer(row, window))
649                .child(gpui::deferred(menu).with_priority(gpui_base::POPUP_PRIORITY))
650        }
651    }
652
653    #[gpui::test]
654    fn caption_buttons_take_presses_over_a_dialog_backdrop(cx: &mut TestAppContext) {
655        let (view, cx) = cx.add_window_view(|_, _| DialogHarness {
656            backdrop_presses: 0,
657            menu_presses: 0,
658        });
659        cx.run_until_parked();
660        let width = cx.update(|window, _| f32::from(window.viewport_size().width));
661        // Close (where the menu leaves it), maximize and minimize, from the
662        // trailing edge.
663        for offset in [36., 69., 115.] {
664            cx.simulate_click(
665                gpui::point(px(width - offset), px(16.)),
666                Modifiers::default(),
667            );
668        }
669        cx.update(|_, cx| assert_eq!(view.read(cx).backdrop_presses, 0));
670        // Beside them the dialog's backdrop still takes the press.
671        cx.simulate_click(gpui::point(px(width - 160.), px(16.)), Modifiers::default());
672        // Below the 32px buttons, too.
673        cx.simulate_click(gpui::point(px(width - 69.), px(40.)), Modifiers::default());
674        cx.update(|_, cx| assert_eq!(view.read(cx).backdrop_presses, 2));
675        // An open menu over a caption button takes the press itself.
676        cx.simulate_click(gpui::point(px(width - 18.), px(16.)), Modifiers::default());
677        cx.update(|_, cx| {
678            assert_eq!(view.read(cx).menu_presses, 1);
679            assert_eq!(view.read(cx).backdrop_presses, 2);
680        });
681    }
682
683    /// Two previews in a page's flow, as a catalog shows them.
684    struct PreviewHarness {
685        page_presses: usize,
686    }
687
688    impl Render for PreviewHarness {
689        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
690            div()
691                .id("page")
692                .size_full()
693                .flex()
694                .flex_col()
695                .items_start()
696                .on_mouse_down(
697                    MouseButton::Left,
698                    cx.listener(|this, _, _, _| this.page_presses += 1),
699                )
700                .child(
701                    div()
702                        .debug_selector(|| "windows-preview".into())
703                        .flex()
704                        .child(
705                            CaptionButtons::new(px(48.), test_labels())
706                                .caption(Caption::Windows)
707                                .preview("windows-preview"),
708                        ),
709                )
710                .child(
711                    div()
712                        .debug_selector(|| "linux-preview".into())
713                        .flex()
714                        .child(
715                            CaptionButtons::new(px(48.), test_labels())
716                                .caption(Caption::Linux)
717                                .on_close(|_, _| panic!("a preview must not close"))
718                                .preview("linux-preview"),
719                        ),
720                )
721                .child(
722                    div().debug_selector(|| "system-preview".into()).child(
723                        CaptionButtons::new(px(48.), test_labels())
724                            .caption(Caption::System)
725                            .preview("system-preview"),
726                    ),
727                )
728        }
729    }
730
731    #[gpui::test]
732    fn a_preview_lays_out_in_place_and_does_not_act_on_the_window(cx: &mut TestAppContext) {
733        let (view, cx) = cx.add_window_view(|_, _| PreviewHarness { page_presses: 0 });
734        cx.run_until_parked();
735        let windows = cx.debug_bounds("windows-preview").unwrap();
736        let linux = cx.debug_bounds("linux-preview").unwrap();
737        let system = cx.debug_bounds("system-preview").unwrap();
738        // In flow at the page's leading edge, not at the window's corner.
739        assert_eq!(windows.origin, gpui::point(px(0.), px(0.)));
740        assert_eq!(f32::from(windows.size.width), width_of(Caption::Windows, 3));
741        assert_eq!(windows.size.height, px(48.));
742        assert_eq!(linux.origin.y, windows.size.height);
743        assert_eq!(f32::from(linux.size.width), width_of(Caption::Linux, 3));
744        assert_eq!(system.size.height, px(0.));
745        // Pressing the Linux preview's close button keeps the window open
746        // and does not reach the page behind it.
747        let close = gpui::point(
748            linux.origin.x + px(width_of(Caption::Linux, 3) - LINUX_END - LINUX_BUTTON / 2.),
749            linux.origin.y + px(24.),
750        );
751        cx.simulate_click(close, Modifiers::default());
752        cx.update(|window, cx| {
753            assert!(window.viewport_size().width > px(0.));
754            assert_eq!(view.read(cx).page_presses, 0);
755        });
756    }
757
758    /// Asserts one row of buttons: every button is on the bar's vertical
759    /// center at the same size, and every glyph box on its button's center.
760    fn assert_centered(
761        cx: &mut gpui::VisualTestContext,
762        (width, height): (f32, f32),
763        glyph_size: f32,
764        round: bool,
765    ) {
766        let bar = cx.debug_bounds("bar").expect("the bar is rendered");
767        for button in [
768            CaptionButton::Minimize,
769            CaptionButton::Maximize,
770            CaptionButton::Close,
771        ] {
772            let id = button.id();
773            let selector = |suffix: &str| -> &'static str {
774                Box::leak(format!("{id}{suffix}").into_boxed_str())
775            };
776            let target = cx.debug_bounds(id).expect("the button is rendered");
777            assert_eq!(target.size, size(px(width), px(height)), "{id}");
778            assert_eq!(
779                target.center().y,
780                bar.center().y,
781                "{id} on the bar's center"
782            );
783            let glyph = cx.debug_bounds(selector("-glyph")).expect("a glyph");
784            assert_eq!(glyph.size, size(px(glyph_size), px(glyph_size)), "{id}");
785            assert_eq!(glyph.center(), target.center(), "{id}'s glyph is centered");
786            if round {
787                let shape = cx.debug_bounds(selector("-shape")).expect("a shape");
788                assert_eq!(shape.center(), target.center(), "{id}'s shape is centered");
789            }
790        }
791    }
792
793    /// One caption style in a bar.
794    struct BarHarness(Caption, Pixels);
795
796    impl Render for BarHarness {
797        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
798            div()
799                .debug_selector(|| "bar".into())
800                .flex()
801                .w_full()
802                .h(self.1)
803                .justify_end()
804                .child(
805                    CaptionButtons::new(self.1, test_labels())
806                        .caption(self.0)
807                        .preview("preview"),
808                )
809        }
810    }
811
812    #[gpui::test]
813    fn windows_buttons_and_glyphs_center_in_the_bar(cx: &mut TestAppContext) {
814        for height in [32., 40., 48., 56., 64.] {
815            let (_, cx) = cx.add_window_view(|_, _| BarHarness(Caption::Windows, px(height)));
816            cx.run_until_parked();
817            assert_centered(cx, (46., 32.), 10., false);
818        }
819    }
820
821    #[gpui::test]
822    fn linux_buttons_and_glyphs_center_in_the_bar(cx: &mut TestAppContext) {
823        for height in [32., 40., 48., 56., 64.] {
824            let (_, cx) = cx.add_window_view(|_, _| BarHarness(Caption::Linux, px(height)));
825            cx.run_until_parked();
826            assert_centered(cx, (32., 32.), 8., true);
827        }
828    }
829
830    #[gpui::test]
831    fn a_bar_shorter_than_the_windows_button_clamps_it(cx: &mut TestAppContext) {
832        let (_, cx) = cx.add_window_view(|_, _| BarHarness(Caption::Windows, px(28.)));
833        cx.run_until_parked();
834        assert_centered(cx, (46., 28.), 10., false);
835    }
836
837    #[test]
838    fn every_glyph_fills_its_box_symmetrically() {
839        for (glyph, stroke) in [(10., 1.), (8., 2.), (8., 1.5)] {
840            for button in [
841                CaptionButton::Minimize,
842                CaptionButton::Maximize,
843                CaptionButton::Restore,
844            ] {
845                let rects = glyph_rects(button, glyph, stroke);
846                let (mut left, mut top, mut right, mut bottom) = (glyph, glyph, 0_f32, 0_f32);
847                for rect in &rects {
848                    left = left.min(f32::from(rect.origin.x));
849                    top = top.min(f32::from(rect.origin.y));
850                    right = right.max(f32::from(rect.origin.x + rect.size.width));
851                    bottom = bottom.max(f32::from(rect.origin.y + rect.size.height));
852                }
853                assert_eq!((left, right), (0., glyph), "{button:?} spans the box");
854                assert_eq!(
855                    (top + bottom) / 2.,
856                    glyph / 2.,
857                    "{button:?} is centered vertically"
858                );
859                if button != CaptionButton::Minimize {
860                    assert_eq!((top, bottom), (0., glyph), "{button:?} fills the box");
861                }
862            }
863        }
864        // The X is two strokes corner to corner, centered by construction.
865        assert!(glyph_rects(CaptionButton::Close, 10., 1.).is_empty());
866    }
867
868    #[test]
869    fn a_preview_shows_the_full_set_whatever_the_window_offers() {
870        assert_eq!(
871            preview_buttons(Caption::Windows),
872            [
873                CaptionButton::Minimize,
874                CaptionButton::Maximize,
875                CaptionButton::Close
876            ]
877        );
878        assert_eq!(preview_buttons(Caption::Linux).len(), 3);
879        assert!(preview_buttons(Caption::System).is_empty());
880    }
881
882    #[test]
883    fn windows_buttons_take_the_system_width_and_leave_open_space_before_them() {
884        assert_eq!(width_of(Caption::Windows, 3), 138.);
885        assert_eq!(trailing_for(width_of(Caption::Windows, 3)), 146.);
886    }
887
888    #[test]
889    fn a_system_title_bar_keeps_the_ordinary_inset() {
890        assert_eq!(width_of(Caption::System, 3), 0.);
891        assert_eq!(trailing_for(0.), 16.);
892    }
893
894    #[test]
895    fn linux_buttons_follow_what_the_window_manager_offers() {
896        // Close alone on a tiling compositor, all three otherwise.
897        assert_eq!(width_of(Caption::Linux, 1), 44.);
898        assert_eq!(width_of(Caption::Linux, 3), 32. * 3. + 4. * 2. + 12.);
899    }
900}