feat: add status dot component

This commit is contained in:
2026-11-03 19:44:54 +07:00
parent 0c8d7f05d4
commit 7d1bceefd5
6 changed files with 255 additions and 11 deletions
+2
View File
@@ -34,6 +34,7 @@ full = [
"segmented",
"spinner",
"field",
"status_dot",
"tooltip",
"menu",
"select",
@@ -59,6 +60,7 @@ scroll_area = []
segmented = []
spinner = []
field = []
status_dot = []
tooltip = []
menu = ["scroll_area"]
select = ["menu"]
+3
View File
@@ -72,6 +72,9 @@ pub mod spinner;
#[cfg(feature = "field")]
pub mod field;
#[cfg(feature = "status_dot")]
pub mod status_dot;
#[cfg(feature = "toast")]
pub mod toast;
+105
View File
@@ -0,0 +1,105 @@
//! The status dot: a small colored bead for row state.
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ElementId, Hsla, InteractiveElement as _, IntoElement, RenderOnce, SharedString,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
};
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
use crate::common::{Sizable, StyledExt};
/// How loud a dot is, the same vocabulary as [`crate::badge::BadgeVariant`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StatusDotVariant {
#[default]
Neutral,
Accent,
Success,
Warning,
Danger,
}
impl StatusDotVariant {
fn color(self, theme: &Theme) -> Hsla {
match self {
Self::Neutral => theme.colors.muted_foreground,
Self::Accent => theme.ramps.primary.base,
Self::Success => theme.ramps.success.base,
Self::Warning => theme.ramps.warning.base,
Self::Danger => theme.ramps.destructive.base,
}
}
}
/// A small circular bead marking a row's state.
#[derive(IntoElement)]
pub struct StatusDot {
id: ElementId,
variant: StatusDotVariant,
size: Size,
label: Option<SharedString>,
style: StyleRefinement,
}
impl StatusDot {
/// Creates an idle dot.
pub fn new<I>(id: I) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
variant: StatusDotVariant::default(),
size: Size::default(),
label: None,
style: StyleRefinement::default(),
}
}
/// Sets how loud the dot is.
pub fn variant(mut self, variant: StatusDotVariant) -> Self {
self.variant = variant;
self
}
/// The state the dot announces to screen readers.
pub fn label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.label = Some(label.into());
self
}
}
impl Sizable for StatusDot {
fn apply_size(&mut self, size: Size) {
self.size = size;
}
}
impl Styled for StatusDot {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for StatusDot {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let metrics = theme.sizes.resolve(self.size);
let side = metrics.icon_size / 2.;
let color = self.variant.color(theme);
div()
.id(self.id.clone())
.debug_selector(move || self.id.to_string())
.flex_none()
.size(side)
.rounded(theme.radius.full)
.bg(color)
.when_some(self.label, |dot, label| dot.aria_label(label))
.refine_style(&self.style)
}
}
+60
View File
@@ -0,0 +1,60 @@
use gpui::{
AppContext as _, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext,
VisualTestContext, Window, div,
};
use gpui_native_core::status_dot::{StatusDot, StatusDotVariant};
use gpui_native_theme::{ActiveTheme as _, Theme};
struct DotHost;
impl Render for DotHost {
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
div().size_full().p_4().child(
StatusDot::new("test-dot")
.variant(StatusDotVariant::Success)
.label("Synced"),
)
}
}
fn dot_window() -> (TestAppContext, gpui::AnyWindowHandle) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let window = cx.add_window(|_, _| DotHost);
let window: gpui::AnyWindowHandle = window.into();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
(cx, window)
}
/// A medium dot is half the theme's icon size — big enough to read a state
/// from, small enough to sit beside row text. The label must not grow it.
#[test]
fn the_dot_is_half_the_icon_size() {
let (cx, window) = dot_window();
let mut visual = VisualTestContext::from_window(window, &cx);
let dot = visual.debug_bounds("test-dot").expect("dot missing");
let side = cx.update(|cx| cx.theme().sizes.medium.icon_size) / 2.;
assert!(
((dot.size.width - side).abs() <= gpui::px(0.5))
&& ((dot.size.height - side).abs() <= gpui::px(0.5)),
"the dot must be {side:?} wide, got {:?}",
dot.size
);
}
#[test]
fn the_dot_keeps_a_square_footprint() {
let (cx, window) = dot_window();
let mut visual = VisualTestContext::from_window(window, &cx);
let dot = visual.debug_bounds("test-dot").expect("dot missing");
assert!(
(dot.size.width - dot.size.height).abs() <= gpui::px(0.5),
"the dot must stay circular: {:?}",
dot.size
);
}