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