Component 58 of 60
Toolbar
Groups controls for a related set of actions.
Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.
fn toolbar_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let input = demo_input("toolbar.search", "", "Find…", window, cx);
Toolbar::new("tools", "Formatting", cx)
.button(
ToolbarButton::new("bold", "Bold", cx)
.child("Bold")
.on_click(cx.listener(|this, _, _, cx| {
this.demo_action = "Bold".into();
cx.notify();
})),
)
.button(ToolbarButton::new("italic", "Italic", cx).child("Italic"))
.separator()
.input(&input, "Find text", false)
.button(ToolbarButton::new("more", "More options", cx).child("More"))
}
Installation
After the one-time setup, run the command from your app root. The CLI
writes the modules and keeps src/ui/mod.rs in step.
gpuicn add toolbar
If you prefer to copy the files by hand, declare them yourself:
// src/ui/mod.rs
pub mod theme;
pub mod input;
pub mod toolbar;
// src/main.rs
mod ui;
Usage
Put this in a view module and return example(cx) from its render method.
use crate::ui::toolbar::*;
use gpui_kit::{App, IntoElement, ParentElement};
fn example(cx: &App) -> impl IntoElement {
Toolbar::new("formatting", "Formatting", cx)
.button(ToolbarButton::new("bold", "Bold", cx).child("B"))
.button(ToolbarButton::new("italic", "Italic", cx).child("I"))
}
API reference
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, cx: &App) -> Self
Creates a `Toolbar` with a stable caller-owned ID.
pub fn axis(mut self, axis: Axis) -> Self
Selects horizontal or vertical layout and keyboard navigation.
pub fn button(mut self, button: ToolbarButton) -> Self
Appends a toolbar action.
pub fn input(
mut self,
state: &Entity<InputState>,
label: impl Into<SharedString>,
disabled: bool,
) -> Self
Appends a retained input with its accessible label and disabled state.
pub fn separator(mut self) -> Self
Appends a divider between toolbar controls.
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, cx: &App) -> Self
Creates a `ToolbarButton` with a stable caller-owned ID.
pub fn disabled(mut self, value: bool) -> Self
Disables interaction and applies the disabled appearance.
pub fn on_click(
mut self,
callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self
Handles activation by pointer, keyboard or an accessibility action.
Installed source, 278 lines
//! Nova toolbar with roving focus and GPUI Kit buttons and editors.
use super::{
input::{Input, InputState},
theme::{UiTheme, primitives::disabled},
};
use gpui_kit::base::Button as BaseButton;
use gpui_kit::{
AnyElement, App, Axis, ClickEvent, Div, ElementId, Entity, FocusHandle, Focusable as _,
InteractiveElement as _, IntoElement, ParentElement, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder as _, px,
};
#[derive(IntoElement)]
/// A named group of buttons and editors with roving keyboard focus.
pub struct Toolbar {
id: ElementId,
base: Div,
label: SharedString,
axis: Axis,
children: Vec<ToolbarItem>,
}
enum ToolbarItem {
Button(Box<ToolbarButton>),
Input(Entity<InputState>, SharedString, bool),
Separator,
}
impl Toolbar {
/// Creates a `Toolbar` with a stable caller-owned ID.
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, cx: &App) -> Self {
let theme = UiTheme::read(cx);
Self {
id: id.into(),
label: label.into(),
axis: Axis::Horizontal,
children: Vec::new(),
base: div()
.flex()
.items_center()
.gap(theme.space(1.))
.p(theme.space(1.))
.border_1()
.border_color(theme.colors.border)
.rounded(theme.radius.lg)
.bg(theme.colors.background)
.font_family(theme.fonts.body.clone()),
}
}
/// Selects horizontal or vertical layout and keyboard navigation.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Appends a toolbar action.
pub fn button(mut self, button: ToolbarButton) -> Self {
self.children.push(ToolbarItem::Button(Box::new(button)));
self
}
/// Appends a retained input with its accessible label and disabled state.
pub fn input(
mut self,
state: &Entity<InputState>,
label: impl Into<SharedString>,
disabled: bool,
) -> Self {
self.children
.push(ToolbarItem::Input(state.clone(), label.into(), disabled));
self
}
/// Appends a divider between toolbar controls.
pub fn separator(mut self) -> Self {
self.children.push(ToolbarItem::Separator);
self
}
}
impl Styled for Toolbar {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
#[derive(IntoElement)]
/// A Nova toolbar action backed by a Kit button.
pub struct ToolbarButton {
id: ElementId,
button: BaseButton,
disabled: bool,
}
impl ToolbarButton {
/// Creates a `ToolbarButton` with a stable caller-owned ID.
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, cx: &App) -> Self {
let id = id.into();
let theme = UiTheme::read(cx);
let colors = theme.colors;
Self {
id: id.clone(),
disabled: false,
button: BaseButton::new(id)
.accessibility_label(label)
.h(theme.space(7.))
.px(theme.space(2.))
.rounded(theme.radius.sm)
.border_1()
.border_color(colors.background.opacity(0.))
.text_size(theme.text(14.))
.text_color(colors.foreground)
.cursor_pointer()
.hover(move |s| s.bg(colors.muted))
.focus_visible(move |s| s.border_color(colors.ring)),
}
}
/// Disables interaction and applies the disabled appearance.
pub fn disabled(mut self, value: bool) -> Self {
self.disabled = value;
self
}
/// Handles activation by pointer, keyboard or an accessibility action.
pub fn on_click(
mut self,
callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.button = self.button.on_click(callback);
self
}
}
impl Styled for ToolbarButton {
fn style(&mut self) -> &mut StyleRefinement {
self.button.style()
}
}
impl ParentElement for ToolbarButton {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.button.extend(elements);
}
}
impl RenderOnce for ToolbarButton {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.button
.disabled(self.disabled)
.when(self.disabled, disabled::style)
}
}
impl RenderOnce for Toolbar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
// Keep the last active item as the one tab stop when focus leaves the toolbar.
let active =
window.use_keyed_state((self.id.clone(), "active"), cx, |_, _| None::<FocusHandle>);
let mut handles = Vec::new();
let mut inputs = Vec::new();
let mut item_focus = Vec::new();
for item in &self.children {
let (focus, disabled, input) = match item {
ToolbarItem::Button(button) => (
window
.use_keyed_state((button.id.clone(), "focus"), cx, |_, cx| {
cx.focus_handle()
})
.read(cx)
.clone(),
button.disabled,
None,
),
ToolbarItem::Input(state, _, disabled) => (
state.read(cx).focus_handle(cx),
*disabled,
Some(state.clone()),
),
ToolbarItem::Separator => {
item_focus.push(None);
continue;
}
};
item_focus.push(Some(focus.clone()));
if !disabled {
handles.push(focus);
inputs.push(input);
} else {
focus.tab_stop(false);
}
}
let selected = handles
.iter()
.position(|h| h.is_focused(window))
.or_else(|| {
handles
.iter()
.position(|h| Some(h) == active.read(cx).as_ref())
})
.unwrap_or(0);
if let Some(focus) = handles.get(selected) {
active.update(cx, |value, _| *value = Some(focus.clone()));
}
for (index, focus) in handles.iter().enumerate() {
focus.clone().tab_stop(index == selected);
}
let theme = UiTheme::read(cx);
let color = theme.colors.border;
let axis = self.axis;
let children = self
.children
.into_iter()
.zip(item_focus)
.map(|(item, focus)| match item {
ToolbarItem::Button(mut button) => {
let focus = focus.unwrap().clone();
button.button = button.button.track_focus(&focus).tab_stop(focus.tab_stop);
(*button).into_any_element()
}
ToolbarItem::Input(state, label, disabled) => Input::new(&state)
.aria_label(label)
.disabled(disabled)
.w(theme.space(30.))
.h(theme.space(7.))
.into_any_element(),
ToolbarItem::Separator => div()
.bg(color)
.when(axis == Axis::Horizontal, |d| d.w(px(1.)).h(theme.space(4.)))
.when(axis == Axis::Vertical, |d| d.h(px(1.)).w_full())
.into_any_element(),
});
self.base
.id(self.id)
.role(Role::Toolbar)
.aria_label(self.label)
.aria_orientation(if axis == Axis::Horizontal {
gpui_kit::Orientation::Horizontal
} else {
gpui_kit::Orientation::Vertical
})
.when(axis == Axis::Vertical, |d| d.flex_col())
.capture_key_down(move |event, window, cx| {
if event.keystroke.modifiers.modified() {
return;
}
let key = event.keystroke.key.as_str();
let Some(current) = handles.iter().position(|h| h.is_focused(window)) else {
return;
};
let movement: isize = match (axis, key) {
(Axis::Horizontal, "left") | (Axis::Vertical, "up") => -1,
(Axis::Horizontal, "right") | (Axis::Vertical, "down") => 1,
(_, "home") => -2,
(_, "end") => 2,
_ => return,
};
if let Some(state) = &inputs[current] {
let state = state.read(cx);
if movement.abs() == 2 {
return;
}
if axis == Axis::Horizontal
&& (!state.selected_range().is_empty()
|| (movement < 0 && state.cursor() != 0)
|| (movement > 0 && state.cursor() != state.text().len()))
{
return;
}
}
let next = match movement {
-2 => 0,
2 => handles.len() - 1,
_ => (current as isize + movement).rem_euclid(handles.len() as isize) as usize,
};
handles[current].clone().tab_stop(false);
let target = handles[next].clone().tab_stop(true);
target.focus(window, cx);
if let Some(state) = &inputs[next] {
state.update(cx, |state, cx| state.select_all(window, cx));
}
active.update(cx, |value, _| *value = Some(target));
window.refresh();
cx.stop_propagation();
})
.children(children)
}
}
Platform notes
Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation:
crates/ui/registry/toolbar/toolbar.rs, using GPUI Kit 0.6.4.
Toolbar composes Kit buttons and retained Kit InputState editors. One enabled item participates in Tab order; arrows and Home/End move between controls. Editing keys remain in a focused input. The application owns actions and editor subscriptions.
The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.