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 theme::{TextStyle, Theme, Typeset, hairline};
46
47use crate::surface::Surfaced as _;
48
49/// Height of the bar, and so half the radius of a [`Shape::Pill`]. One number
50/// rather than a parameter: a stadium's radius has to be derived from it for
51/// the material's blur to match the border, and a caller free to pick a height
52/// is a caller free to get that wrong.
53pub const BAR_HEIGHT: f32 = 56.0;
54
55/// Gap between controls in a cluster, and the bar's own end inset. The inset
56/// matches the gap so the first control sits as far from the bar's edge as it
57/// does from its neighbour.
58const BAR_GAP: f32 = 8.0;
59
60/// How the bar's corners are cut. Two named cases rather than a radius, because
61/// this is a choice between two shapes and not a continuum — and because a bare
62/// number at the call site says nothing about which one you meant.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Shape {
65 /// A stadium — the radius is half the height. Apple Music's transport.
66 Pill,
67 /// A rounded rectangle at [`Theme::bubble_radius`], the radius this library
68 /// already gives floating rounded things. What most composers want; a
69 /// stadium reads as a media control and a composer is not one.
70 Rounded,
71}
72
73impl Shape {
74 fn radius(self) -> f32 {
75 match self {
76 Shape::Pill => BAR_HEIGHT / 2.0,
77 Shape::Rounded => Theme::bubble_radius(),
78 }
79 }
80}
81
82/// The bar. `centre` is optional — a toolbar with only clusters passes `None`
83/// and the rails still hold their ends.
84pub fn control_bar(
85 theme: &Theme,
86 shape: Shape,
87 leading: Vec<AnyElement>,
88 centre: Option<AnyElement>,
89 trailing: Vec<AnyElement>,
90) -> AnyElement {
91 let radius = shape.radius();
92
93 let rail = || div().flex().flex_row().items_center().gap(px(BAR_GAP));
94
95 let bar = div()
96 .h(px(BAR_HEIGHT))
97 .rounded(px(radius))
98 .border_1()
99 .border_color(hairline(0.10))
100 .shadow_lg()
101 .overflow_hidden()
102 .px(px(BAR_GAP))
103 .flex()
104 .flex_row()
105 .items_center()
106 .text_style(TextStyle::Body)
107 .text_color(theme.text)
108 .bg(if theme.glass {
109 theme.glass_overlay()
110 } else {
111 theme.surface_overlay
112 })
113 .child(rail().flex_1().justify_start().children(leading))
114 .children(centre.map(|centre| div().flex_none().px(px(BAR_GAP)).child(centre)))
115 .child(rail().flex_1().justify_end().children(trailing));
116
117 bar.surface(theme, theme.popover_surface).into_any_element()
118}
119
120/// A circular control inside a bar: the ring, and its glyph at half the
121/// diameter. `diameter` is a parameter because a transport's primary action is
122/// deliberately bigger than its neighbours — that size difference is what makes
123/// the cluster readable at a glance.
124///
125/// It builds the icon rather than taking one, the way [`row_icon`](crate::widgets::Scaffolding::row_icon)
126/// does, because `tint` is not optional in the way it looks: gpui reads an
127/// svg's colour off that element's own style and paints **nothing** when it is
128/// unset, so a colour set on this button would silently not reach the glyph.
129///
130/// Caller adds id, click and its own `.hover(..)`: gpui panics on a second
131/// hover call, and the wash differs by state (a lit toggle is not a resting
132/// one). [`Theme::element_hover`] is the wash to reach for.
133pub fn bar_button(icon: &'static str, diameter: f32, tint: gpui::Hsla) -> gpui::Div {
134 div()
135 .flex_none()
136 .size(px(diameter))
137 .rounded_full()
138 .flex()
139 .items_center()
140 .justify_center()
141 .cursor_pointer()
142 .child(
143 crate::icons::icon(icon)
144 .size(px(diameter / 2.0))
145 .text_color(tint),
146 )
147}