Skip to main content

gpui_kit/layout/
toolbar.rs

1//! A horizontal bar of grouped actions, with the rest behind an overflow menu.
2//!
3//! # Why overflow is declared, not measured
4//!
5//! A truthful overflow needs to know how wide every item is before it decides
6//! which ones fit. GPUI measures during layout, which happens after the
7//! element tree has been built, and a toolbar child is an `AnyElement` that
8//! can be consumed exactly once — so a builder cannot measure a child and then
9//! still move it into a menu. Guessing at widths would produce a bar that
10//! claims to have dropped items it in fact drew, which is worse than not
11//! overflowing at all.
12//!
13//! So the caller declares the cut with [`Toolbar::overflow_after`], and the
14//! toolbar guarantees the part it can: an item past the cut is **moved**, never
15//! dropped. It becomes a row in the overflow [`Menu`],
16//! keeping its identity, its label, and its refusal, and the trigger publishes
17//! how many items went there. With no menu to move them into, every item is
18//! drawn inline, because losing an action is never the better failure.
19
20use gpui::{
21    AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
22    SharedString, Styled, Window, div, px,
23};
24use gpui_kit_assets::Icon;
25use gpui_kit_semantics::{NodeSpec, Role, Semantic};
26use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Space, Surface, Theme};
27
28use crate::foundation::{Ident, Sizable, StyledExt};
29use crate::overlay::{Menu, MenuItem};
30use crate::strings::{ActiveStrings, StringKey};
31
32/// One action in the bar.
33///
34/// The element is what the bar draws; the id and label are what the overflow
35/// menu needs to offer the same action when the item does not fit.
36pub struct ToolbarItem {
37    id: SharedString,
38    label: SharedString,
39    icon: Option<Icon>,
40    shortcut: Option<SharedString>,
41    disabled: bool,
42    content: AnyElement,
43}
44
45impl std::fmt::Debug for ToolbarItem {
46    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        formatter
48            .debug_struct("ToolbarItem")
49            .field("id", &self.id)
50            .field("label", &self.label)
51            .field("disabled", &self.disabled)
52            .finish()
53    }
54}
55
56impl ToolbarItem {
57    /// `id` is the action's business identity, and is what the overflow menu
58    /// reports when the item is taken from there instead of from the bar.
59    pub fn new(
60        id: impl Into<SharedString>,
61        label: impl Into<SharedString>,
62        content: impl IntoElement,
63    ) -> Self {
64        Self {
65            id: id.into(),
66            label: label.into(),
67            icon: None,
68            shortcut: None,
69            disabled: false,
70            content: content.into_any_element(),
71        }
72    }
73
74    /// The glyph the overflow row carries. The inline control draws its own.
75    pub fn icon(mut self, glyph: Icon) -> Self {
76        self.icon = Some(glyph);
77        self
78    }
79
80    pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
81        self.shortcut = Some(keystroke.into());
82        self
83    }
84
85    pub fn disabled(mut self, disabled: bool) -> Self {
86        self.disabled = disabled;
87        self
88    }
89
90    pub fn id(&self) -> &SharedString {
91        &self.id
92    }
93
94    pub fn label(&self) -> &SharedString {
95        &self.label
96    }
97
98    fn menu_row(&self) -> MenuItem {
99        let mut row =
100            MenuItem::command(self.id.clone(), self.label.clone()).disabled(self.disabled);
101        if let Some(glyph) = self.icon {
102            row = row.icon(glyph);
103        }
104        if let Some(shortcut) = self.shortcut.clone() {
105            row = row.shortcut(shortcut);
106        }
107        row
108    }
109}
110
111/// What sits between two groups.
112enum Slot {
113    Group {
114        id: SharedString,
115        items: Vec<ToolbarItem>,
116    },
117    /// Pushes everything after it to the far end of the bar.
118    Spacer,
119}
120
121/// A bar of grouped actions.
122#[derive(IntoElement)]
123pub struct Toolbar {
124    ident: Ident,
125    label: Option<SharedString>,
126    slots: Vec<Slot>,
127    size: ControlSize,
128    overflow_after: Option<usize>,
129    overflow_menu: Option<Entity<Menu>>,
130}
131
132impl std::fmt::Debug for Toolbar {
133    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        formatter
135            .debug_struct("Toolbar")
136            .field("ident", &self.ident)
137            .field("items", &self.item_count())
138            .field("overflow_after", &self.overflow_after)
139            .field("has_overflow_menu", &self.overflow_menu.is_some())
140            .finish()
141    }
142}
143
144impl Toolbar {
145    pub fn new(ident: impl Into<Ident>) -> Self {
146        Self {
147            ident: ident.into(),
148            label: None,
149            slots: Vec::new(),
150            size: ControlSize::Md,
151            overflow_after: None,
152            overflow_menu: None,
153        }
154    }
155
156    /// What the bar is called, for a reader that has only the tree.
157    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
158        self.label = Some(label.into());
159        self
160    }
161
162    /// A run of related actions, separated from its neighbours by a rule.
163    pub fn group(
164        mut self,
165        id: impl Into<SharedString>,
166        items: impl IntoIterator<Item = ToolbarItem>,
167    ) -> Self {
168        self.slots.push(Slot::Group {
169            id: id.into(),
170            items: items.into_iter().collect(),
171        });
172        self
173    }
174
175    /// Flexible space: everything after it sits at the far end of the bar.
176    pub fn spacer(mut self) -> Self {
177        self.slots.push(Slot::Spacer);
178        self
179    }
180
181    /// Keeps the first `count` items in the bar and moves the rest into the
182    /// overflow menu. See the module documentation for why the cut is declared
183    /// rather than measured.
184    pub fn overflow_after(mut self, count: usize) -> Self {
185        self.overflow_after = Some(count);
186        self
187    }
188
189    /// The menu the overflowed items are moved into.
190    ///
191    /// It is caller-owned because whether it is open outlives a frame.
192    pub fn overflow_menu(mut self, menu: Entity<Menu>) -> Self {
193        self.overflow_menu = Some(menu);
194        self
195    }
196
197    /// How many actions the bar holds, drawn or overflowed.
198    pub fn item_count(&self) -> usize {
199        self.slots
200            .iter()
201            .map(|slot| match slot {
202                Slot::Group { items, .. } => items.len(),
203                Slot::Spacer => 0,
204            })
205            .sum()
206    }
207
208    /// Where the cut falls, which is nowhere when there is no menu to move
209    /// anything into.
210    fn cut(&self) -> usize {
211        match (self.overflow_after, self.overflow_menu.is_some()) {
212            (Some(cut), true) => cut,
213            _ => usize::MAX,
214        }
215    }
216}
217
218impl Sizable for Toolbar {
219    fn control_size(mut self, size: ControlSize) -> Self {
220        self.size = size;
221        self
222    }
223}
224
225impl RenderOnce for Toolbar {
226    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
227        let theme = cx.theme().clone();
228        let total = self.item_count();
229        let cut = self.cut();
230        let ident = self.ident.clone();
231
232        let mut drawn: Vec<AnyElement> = Vec::new();
233        let mut overflowed: Vec<MenuItem> = Vec::new();
234        let mut index = 0usize;
235        let mut previous_group = false;
236
237        for slot in self.slots {
238            match slot {
239                Slot::Spacer => {
240                    drawn.push(div().flex_1().into_any_element());
241                    previous_group = false;
242                }
243                Slot::Group { id, items } => {
244                    let group = ident.child(id.as_ref());
245                    let mut inline: Vec<AnyElement> = Vec::new();
246                    for item in items {
247                        if index >= cut {
248                            overflowed.push(item.menu_row());
249                        } else {
250                            inline.push(item.content);
251                        }
252                        index += 1;
253                    }
254                    if inline.is_empty() {
255                        continue;
256                    }
257                    if previous_group {
258                        drawn.push(rule(&group, &theme, &ident, cx));
259                    }
260                    drawn.push(
261                        div()
262                            .flex()
263                            .flex_row()
264                            .items_center()
265                            .gap(px(theme.space(Space::Xs)))
266                            .children(inline)
267                            .semantic_in(
268                                cx,
269                                NodeSpec::new(group.semantic_id(), Role::Group)
270                                    .parent(ident.semantic_id()),
271                            )
272                            .into_any_element(),
273                    );
274                    previous_group = true;
275                }
276            }
277        }
278
279        let hidden = overflowed.len();
280        let overflow = self.overflow_menu.filter(|_| hidden > 0).map(|menu| {
281            let rows = overflowed;
282            if menu.read(cx).offered() != rows.as_slice() {
283                menu.update(cx, |menu, cx| menu.set_items(rows, cx));
284            }
285            menu.clone()
286        });
287        let overflow_ident = ident.child("overflow");
288
289        div()
290            .id(ident.element_id())
291            .flex()
292            .flex_row()
293            .items_center()
294            .w_full()
295            .gap(px(theme.space(Space::Sm)))
296            .px(px(theme.space(Space::Sm)))
297            .py(px(theme.space(Space::Xs)))
298            .frame(&theme, Surface::Panel, Elevation::Raised)
299            .children(drawn)
300            .children(overflow.map(|menu| {
301                div()
302                    .flex()
303                    .flex_none()
304                    .child(menu)
305                    // The trigger says how many actions moved here, so a
306                    // snapshot shows that they were relocated and not lost.
307                    .semantic_in(
308                        cx,
309                        NodeSpec::new(overflow_ident.semantic_id(), Role::Group)
310                            .parent(ident.semantic_id())
311                            .text(cx.strings().text(StringKey::MoreActions))
312                            .value(hidden.to_string()),
313                    )
314            }))
315            .semantic_in(cx, {
316                let mut spec =
317                    NodeSpec::new(ident.semantic_id(), Role::Toolbar).value(total.to_string());
318                if let Some(label) = self.label {
319                    spec = spec.text(label);
320                }
321                spec
322            })
323    }
324}
325
326fn rule(group: &Ident, theme: &Theme, toolbar: &Ident, cx: &App) -> AnyElement {
327    div()
328        .flex_none()
329        .w(px(theme.borders.hairline))
330        .h(px(theme.control.get(ControlSize::Sm).height))
331        .bg(theme.colors.hairline)
332        .semantic_in(
333            cx,
334            NodeSpec::new(group.child("rule").semantic_id(), Role::Separator)
335                .parent(toolbar.semantic_id()),
336        )
337        .into_any_element()
338}