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}