feat: add status dot component
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+13
-11
@@ -3,13 +3,13 @@
|
||||
The living queue. Every element lands through the review gate: API sketch →
|
||||
implementation → tests → gallery panel → review. Conventions and rules live in
|
||||
[AGENTS.md](../AGENTS.md); the gpui pin lives in the workspace `Cargo.toml`
|
||||
(zed @ `ec86047d67acd6fddd81392f44ed6ebcc6e7f531`). [signed](https://git.reya.info/reya/signed)
|
||||
(resolved in `Cargo.lock`, zed @ `504a7f2cf25b817cda3998cab93473adef15f93c`).
|
||||
[signed](https://git.reya.info/reya/signed)
|
||||
is the first downstream consumer; its screens drive the order.
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] **Field wrapper** (M9) — label / description / error around any control;
|
||||
the settings dialog.
|
||||
- [ ] **Status dot** (M10) — sync-drift beads on rows.
|
||||
- [x] API sketch
|
||||
- [x] Implementation (theme-baked, motion inline per AGENTS.md)
|
||||
- [x] Tests
|
||||
@@ -18,17 +18,15 @@ is the first downstream consumer; its screens drive the order.
|
||||
|
||||
## Queue — to follow
|
||||
|
||||
- [ ] **Tag** (M10) — removable server and relay entries.
|
||||
- [ ] **Status dot** (M11) — sync-drift beads on rows.
|
||||
- [ ] **Combobox** (M12) — type-to-filter pickers; adopt gpuikit's
|
||||
- [ ] **Combobox** (M11) — type-to-filter pickers; adopt gpuikit's
|
||||
`on_create` / `keep_unmatched` blur modes.
|
||||
- [ ] **Command palette** (M13) — filtered actions; the matcher stays
|
||||
- [ ] **Command palette** (M12) — filtered actions; the matcher stays
|
||||
caller-owned.
|
||||
- [ ] **VirtualList** (M14, `data`) — thin `uniform_list` binding for commit
|
||||
- [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit
|
||||
histories and the inbox.
|
||||
- [ ] **Code view** (M15, opens `crates/editor`) — tree-sitter highlighting.
|
||||
- [ ] **Diff view** (M16, `editor`).
|
||||
- [ ] **Markdown view** (M17, `editor`) — only if a consumer confirms.
|
||||
- [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting.
|
||||
- [ ] **Diff view** (M15, `editor`).
|
||||
- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms.
|
||||
- [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at
|
||||
unit time).
|
||||
- [ ] **Layout follow-ups** — persistence, pane zoom, bottom dock,
|
||||
@@ -62,6 +60,10 @@ is the first downstream consumer; its screens drive the order.
|
||||
- [x] ScrollArea, Popover, Menu, Tooltip, Dialog / AlertDialog, Toast,
|
||||
Alert + alerts stack
|
||||
- [x] Select, Segmented, ContextMenu, EmptyState, Badge
|
||||
- [x] Field — label / description / error around any control, block and
|
||||
settings-row variants
|
||||
- [x] Status dot — colored state bead, Badge's variant vocabulary, optional
|
||||
screen-reader label
|
||||
|
||||
### data (`crates/data`)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use gpui_native_core::segmented::{Segmented, SegmentedOption};
|
||||
use gpui_native_core::select::{Select, SelectOption};
|
||||
use gpui_native_core::slider::Slider;
|
||||
use gpui_native_core::spinner::{Spinner, SpinnerVariant};
|
||||
use gpui_native_core::status_dot::{StatusDot, StatusDotVariant};
|
||||
use gpui_native_core::switch::Switch;
|
||||
use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView};
|
||||
use gpui_native_core::toggle::Toggle;
|
||||
@@ -53,6 +54,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
||||
("button", "Button"),
|
||||
("input", "Input"),
|
||||
("field", "Field"),
|
||||
("status-dot", "Status dot"),
|
||||
("checkbox", "Checkbox"),
|
||||
("radio", "Radio"),
|
||||
("switch", "Switch"),
|
||||
@@ -388,6 +390,12 @@ impl Gallery {
|
||||
PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()),
|
||||
cx,
|
||||
);
|
||||
dock.register_panel(
|
||||
PanelDescriptor::new("status-dot", "Status dot", |_, cx| {
|
||||
cx.new(|_| StatusDotDemo).into()
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.register_panel(
|
||||
PanelDescriptor::new("checkbox", "Checkbox", |_, cx| {
|
||||
cx.new(|_| CheckboxDemo { checked: false }).into()
|
||||
@@ -948,6 +956,70 @@ impl Render for FieldDemo {
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusDotDemo;
|
||||
|
||||
impl Render for StatusDotDemo {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.theme().colors;
|
||||
|
||||
let bead = |id: &'static str, variant: StatusDotVariant| {
|
||||
StatusDot::new(id).variant(variant).label("Sync state")
|
||||
};
|
||||
|
||||
ScrollArea::new("status-dot-demo")
|
||||
.size_full()
|
||||
.p_4()
|
||||
.child(demo_section(
|
||||
"variants",
|
||||
colors,
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(bead("dot-demo-neutral", StatusDotVariant::Neutral))
|
||||
.child(bead("dot-demo-accent", StatusDotVariant::Accent))
|
||||
.child(bead("dot-demo-success", StatusDotVariant::Success))
|
||||
.child(bead("dot-demo-warning", StatusDotVariant::Warning))
|
||||
.child(bead("dot-demo-danger", StatusDotVariant::Danger)),
|
||||
))
|
||||
.child(demo_section(
|
||||
"sizes",
|
||||
colors,
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(StatusDot::new("dot-demo-small").small().label("Sync state"))
|
||||
.child(StatusDot::new("dot-demo-medium").label("Sync state"))
|
||||
.child(StatusDot::new("dot-demo-large").large().label("Sync state")),
|
||||
))
|
||||
.child(demo_section(
|
||||
"on rows",
|
||||
colors,
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(bead("dot-demo-row-sync", StatusDotVariant::Success))
|
||||
.child("reya/signed — synced"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(bead("dot-demo-row-drift", StatusDotVariant::Warning))
|
||||
.child("reya/gpui-native-kit — 2 commits drifting"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(bead("dot-demo-row-fail", StatusDotVariant::Danger))
|
||||
.child("reya/notes — push failed"),
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckboxDemo {
|
||||
checked: bool,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user