diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 392fbae..083ec1a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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"] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 73715ac..4606a75 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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; diff --git a/crates/core/src/status_dot.rs b/crates/core/src/status_dot.rs new file mode 100644 index 0000000..e60d970 --- /dev/null +++ b/crates/core/src/status_dot.rs @@ -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, + style: StyleRefinement, +} + +impl StatusDot { + /// Creates an idle dot. + pub fn new(id: I) -> Self + where + I: Into, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + 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) + } +} diff --git a/crates/core/tests/status_dot.rs b/crates/core/tests/status_dot.rs new file mode 100644 index 0000000..9ed3f61 --- /dev/null +++ b/crates/core/tests/status_dot.rs @@ -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) -> 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 + ); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 0552560..7d836c5 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -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`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index af4b5e0..9332b2f 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -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) -> 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, }