Skip to main content

ui/
control_bar.rs

1//! [`control_bar`] — the floating control bar: a glass surface holding a
2//! leading cluster, an optional centre, and a trailing cluster. Apple Music's
3//! transport, a desktop agent app's composer, a floating toolbar.
4//!
5//! [`Shape`] is the only thing that differs between those; everything below is
6//! shape-blind, which is why the module is named for the job rather than for
7//! the stadium it started as.
8//!
9//! Two things it exists to get right.
10//!
11//! **The blur corners follow the border.** [`crate::material::material`] takes
12//! a corner radius and paints the backdrop blur to it, and a mismatch frosts
13//! square corners outside a round border. One radius comes out of [`Shape`] and
14//! feeds both, so there is no second number to keep in step.
15//!
16//! **The centre is centred on the bar, not on what the clusters leave.** The
17//! two rails are equal-flex and the centre is not: clusters of five controls
18//! and three then keep the middle on axis. Flexing the centre between them
19//! instead is the classic toolbar bug — it lands wherever the wider cluster
20//! pushes it.
21//!
22//! That second rule is why the bar takes the **width it is given** rather than
23//! hugging its controls. Equal rails need free space to be equal *about*; a
24//! shrink-to-fit bar has none, and its middle then lands wherever the clusters
25//! happen to put it. So width is the caller's, and a `max_w` is how a wide
26//! window gets a floating bar instead of a docked one.
27//!
28//! Placement is the caller's too, and it is four lines. This bar floats *over*
29//! content and must never reflow it — the same overlay-never-a-gutter rule
30//! [`crate::scroll`] follows. A bar that *does* reflow its content is a dock,
31//! not this: no blur, no float, and nothing here to reuse.
32//!
33//! ```ignore
34//! div().relative().size_full()
35//!     .child(page)
36//!     .child(
37//!         div().absolute().bottom(px(20.0)).left_0().right_0()
38//!             .flex().justify_center()
39//!             .child(div().w_full().max_w(px(880.0)).child(
40//!                 control_bar::control_bar(&theme, Shape::Pill, leading, Some(centre), trailing),
41//!             )),
42//!     )
43//! ```
44
45use gpui::{AnyElement, IntoElement, ParentElement as _, Styled as _, div, px};
46use theme::{Theme, hairline};
47
48/// Height of the bar, and so half the radius of a [`Shape::Pill`]. One number
49/// rather than a parameter: a stadium's radius has to be derived from it for
50/// the material's blur to match the border, and a caller free to pick a height
51/// is a caller free to get that wrong.
52pub const BAR_HEIGHT: f32 = 56.0;
53
54/// Gap between controls in a cluster, and the bar's own end inset. The inset
55/// matches the gap so the first control sits as far from the bar's edge as it
56/// does from its neighbour.
57const BAR_GAP: f32 = 8.0;
58
59/// How the bar's corners are cut. Two named cases rather than a radius, because
60/// this is a choice between two shapes and not a continuum — and because a bare
61/// number at the call site says nothing about which one you meant.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Shape {
64    /// A stadium — the radius is half the height. Apple Music's transport.
65    Pill,
66    /// A rounded rectangle at [`Theme::bubble_radius`], the radius this library
67    /// already gives floating rounded things. What most composers want; a
68    /// stadium reads as a media control and a composer is not one.
69    Rounded,
70}
71
72impl Shape {
73    fn radius(self) -> f32 {
74        match self {
75            Shape::Pill => BAR_HEIGHT / 2.0,
76            Shape::Rounded => Theme::bubble_radius(),
77        }
78    }
79}
80
81/// The bar. `centre` is optional — a toolbar with only clusters passes `None`
82/// and the rails still hold their ends.
83pub fn control_bar(
84    theme: &Theme,
85    shape: Shape,
86    leading: Vec<AnyElement>,
87    centre: Option<AnyElement>,
88    trailing: Vec<AnyElement>,
89) -> AnyElement {
90    let radius = shape.radius();
91
92    let rail = || div().flex().flex_row().items_center().gap(px(BAR_GAP));
93
94    let bar = div()
95        .h(px(BAR_HEIGHT))
96        .rounded(px(radius))
97        .border_1()
98        .border_color(hairline(0.10))
99        .shadow_lg()
100        .overflow_hidden()
101        .px(px(BAR_GAP))
102        .flex()
103        .flex_row()
104        .items_center()
105        .text_size(px(13.0))
106        .text_color(theme.text)
107        .bg(if theme.is_glass() {
108            theme.glass_overlay()
109        } else {
110            theme.surface_overlay
111        })
112        .child(rail().flex_1().justify_start().children(leading))
113        .children(centre.map(|centre| div().flex_none().px(px(BAR_GAP)).child(centre)))
114        .child(rail().flex_1().justify_end().children(trailing));
115
116    crate::material::material(radius, crate::material::MENU_BLUR, bar).into_any_element()
117}
118
119/// A circular control inside a bar: the ring, and its glyph at half the
120/// diameter. `diameter` is a parameter because a transport's primary action is
121/// deliberately bigger than its neighbours — that size difference is what makes
122/// the cluster readable at a glance.
123///
124/// It builds the icon rather than taking one, the way [`row_tile`](crate::widgets::Scaffolding::row_tile)
125/// does, because `tint` is not optional in the way it looks: gpui reads an
126/// svg's colour off that element's own style and paints **nothing** when it is
127/// unset, so a colour set on this button would silently not reach the glyph.
128///
129/// Caller adds id, click and its own `.hover(..)`: gpui panics on a second
130/// hover call, and the wash differs by state (a lit toggle is not a resting
131/// one). [`Theme::glass_hover`] is the wash to reach for.
132pub fn bar_button(icon: &'static str, diameter: f32, tint: gpui::Hsla) -> gpui::Div {
133    div()
134        .flex_none()
135        .size(px(diameter))
136        .rounded_full()
137        .flex()
138        .items_center()
139        .justify_center()
140        .cursor_pointer()
141        .child(
142            crate::icons::icon(icon)
143                .size(px(diameter / 2.0))
144                .text_color(tint),
145        )
146}