use crate::control::{PressReleaseExt as _, composed_button};
use crate::handlers::SharedHandler;
use crate::theme::SemanticStyledExt as _;
use gpui::{
App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
Window, div, prelude::FluentBuilder as _,
};
use gpui_component::{ActiveTheme as _, Sizable as _, StyledExt as _, h_flex, spinner::Spinner};
use std::rc::Rc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToolStatus {
#[default]
Pending,
Running,
Success,
Failed,
}
#[derive(IntoElement)]
pub struct ToolChip {
id: SharedString,
style: StyleRefinement,
label: SharedString,
detail: Option<SharedString>,
status: ToolStatus,
on_event: Option<SharedHandler<ToolChipEvent>>,
}
impl ToolChip {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
label: label.into(),
detail: None,
status: ToolStatus::default(),
on_event: None,
}
}
pub fn status(mut self, status: ToolStatus) -> Self {
self.status = status;
self
}
pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn on_event(
mut self,
handler: impl Fn(&ToolChipEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_event = Some(Rc::new(handler));
self
}
fn accessibility_label(&self) -> SharedString {
match &self.detail {
Some(detail) => format!(
"{}, {}, {}",
self.label,
self.status.accessibility_label(),
detail
)
.into(),
None => format!("{}, {}", self.label, self.status.accessibility_label()).into(),
}
}
}
impl ToolStatus {
fn accessibility_label(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Success => "completed",
Self::Failed => "failed",
}
}
}
impl Styled for ToolChip {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
fn tool_chip_frame<E: Styled>(element: E, cx: &App) -> E {
let tokens = cx.theme().semantic_tokens();
element
.px(tokens.spacing.sm)
.py(tokens.spacing.xxs)
.bg(cx.theme().secondary)
.border_1()
.border_color(cx.theme().border)
.rounded(tokens.radius.md)
}
impl RenderOnce for ToolChip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let tokens = cx.theme().semantic_tokens();
let status_color = match self.status {
ToolStatus::Pending => cx.theme().muted_foreground,
ToolStatus::Running => cx.theme().info,
ToolStatus::Success => cx.theme().success,
ToolStatus::Failed => cx.theme().danger,
};
let status_progress = crate::motion::acknowledged_state(
ElementId::from((ElementId::from(self.id.clone()), "chip-status")),
self.status as u64,
window,
cx,
);
let acknowledged = if self.status == ToolStatus::Running {
1.0
} else {
status_progress
};
let event = ToolChipEvent::Activated {
id: self.id.clone(),
};
let accessibility_label = self.accessibility_label();
let content = h_flex()
.items_center()
.gap(tokens.spacing.xs)
.text_token(tokens.typography.xs)
.font_family(cx.theme().mono_font_family.clone())
.text_color(cx.theme().foreground)
.child(
div()
.flex_none()
.size(crate::sizing::slot_xs(cx))
.flex()
.items_center()
.justify_center()
.child(match self.status {
ToolStatus::Running => Spinner::new()
.xsmall()
.color(status_color)
.into_any_element(),
_ => div()
.size_1p5()
.rounded(tokens.radius.full)
.bg(status_color)
.opacity(acknowledged)
.into_any_element(),
}),
)
.child(self.label)
.when_some(self.detail, |this, detail| {
this.child(div().text_color(cx.theme().muted_foreground).child(detail))
})
.refine_style(&self.style);
if let Some(handler) = self.on_event {
tool_chip_frame(composed_button(self.id.clone(), accessibility_label), cx)
.hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
.active(|style| style.bg(cx.theme().accent))
.focus_visible(|style| style.border_color(cx.theme().ring))
.press_release(
ElementId::from((ElementId::from(self.id.clone()), "press")),
tokens.radius.md,
window,
cx,
)
.child(content)
.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
.into_any_element()
} else {
tool_chip_frame(content, cx)
.id(self.id)
.role(Role::Status)
.aria_label(accessibility_label)
.into_any_element()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
use gpui_component::h_flex;
struct ChipProbe {
status: ToolStatus,
}
impl Render for ChipProbe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().w(px(320.)).h(px(80.)).child(
h_flex()
.debug_selector(|| "chip-hug".into())
.flex_none()
.child(ToolChip::new("probe-chip", "read_file").status(self.status)),
)
}
}
#[gpui::test]
fn chip_width_holds_across_every_status(cx: &mut TestAppContext) {
cx.update(crate::init);
let (probe, cx) = cx.add_window_view(|_, _| ChipProbe {
status: ToolStatus::Pending,
});
let cx: &mut VisualTestContext = cx;
cx.update(|window, cx| window.draw(cx).clear(cx));
let width_at = |cx: &mut VisualTestContext| {
cx.debug_bounds("chip-hug")
.expect("the chip should render")
.size
.width
};
let pending = width_at(cx);
for status in [ToolStatus::Running, ToolStatus::Success, ToolStatus::Failed] {
probe.update(cx, |probe, cx| {
probe.status = status;
cx.notify();
});
cx.executor()
.advance_clock(crate::motion::MotionTokens::DEFAULT.quick() * 2);
cx.update(|window, cx| window.draw(cx).clear(cx));
assert_eq!(
width_at(cx),
pending,
"the indicator slot must hold one width; {status:?} moved it"
);
}
}
#[test]
fn accessibility_name_carries_status_and_detail() {
assert_eq!(
ToolChip::new("read", "read pricing.md")
.status(ToolStatus::Running)
.detail("12 files")
.accessibility_label(),
"read pricing.md, running, 12 files"
);
assert_eq!(
ToolChip::new("save", "save changes")
.status(ToolStatus::Success)
.accessibility_label(),
"save changes, completed"
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolChipEvent {
Activated {
id: SharedString,
},
}