diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1fe5cc3..9d242cc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -26,6 +26,7 @@ full = [ "slider", "progress", "avatar", + "badge", "input", "popover", "scroll_area", @@ -44,6 +45,7 @@ link = [] slider = [] progress = [] avatar = [] +badge = [] input = [] popover = [] scroll_area = [] diff --git a/crates/core/src/badge.rs b/crates/core/src/badge.rs new file mode 100644 index 0000000..3edda3f --- /dev/null +++ b/crates/core/src/badge.rs @@ -0,0 +1,107 @@ +use gpui::{ + App, Hsla, IntoElement, ParentElement as _, Refineable as _, RenderOnce, SharedString, + StyleRefinement, Styled as _, Window, +}; +use gpui_native_theme::{ActiveTheme as _, Size, Theme}; + +use crate::common::{Sizable, h_flex}; + +/// How loud a badge is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BadgeVariant { + /// The default; quiet muted fill for counts. + #[default] + Neutral, + /// The accent ramp; something active or newly added. + Accent, + /// The success ramp. + Success, + /// The warning ramp. + Warning, + /// The destructive ramp. + Danger, +} + +impl BadgeVariant { + fn tokens(self, theme: &Theme) -> (Hsla, Hsla) { + match self { + Self::Neutral => (theme.colors.muted, theme.colors.muted_foreground), + Self::Accent => (theme.ramps.primary.base, theme.colors.primary_foreground), + Self::Success => (theme.ramps.success.base, theme.colors.success_foreground), + Self::Warning => (theme.ramps.warning.base, theme.colors.warning_foreground), + Self::Danger => ( + theme.ramps.destructive.base, + theme.colors.destructive_foreground, + ), + } + } +} + +/// A small pill for a count or a state. +#[derive(IntoElement)] +pub struct Badge { + label: SharedString, + variant: BadgeVariant, + size: Size, + style: StyleRefinement, +} + +impl Badge { + /// Creates a badge showing `label` — a count or state word the caller formats. + pub fn new(label: L) -> Self + where + L: Into, + { + Self { + label: label.into(), + variant: BadgeVariant::default(), + size: Size::default(), + style: StyleRefinement::default(), + } + } + + /// Sets how loud the badge is. + pub fn variant(mut self, variant: BadgeVariant) -> Self { + self.variant = variant; + self + } +} + +impl Sizable for Badge { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + +impl gpui::Styled for Badge { + fn style(&mut self) -> &mut gpui::StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Badge { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let metrics = theme.sizes.resolve(self.size); + let (background, foreground) = self.variant.tokens(theme); + + // Counts read smaller than control labels, like every native badge. + let label_size = metrics.text_size * 0.8; + + let mut pill = h_flex() + .flex_none() + .justify_center() + .rounded(theme.radius.full) + .bg(background) + .text_color(foreground) + .text_size(label_size) + .line_height(label_size * 1.25) + .px(metrics.padding_x * 0.5) + .py_0p5() + .child(self.label); + + pill.style().refine(&self.style); + + pill + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 28bd568..52dee57 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -33,6 +33,9 @@ pub mod progress; #[cfg(feature = "avatar")] pub mod avatar; +#[cfg(feature = "badge")] +pub mod badge; + #[cfg(feature = "input")] pub mod input; diff --git a/docs/PLAN.md b/docs/PLAN.md index f95404f..1c6a22e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -260,7 +260,7 @@ Wave M1 — unblocks the port; each through the review gate: | Unit | Crate | Component | Signed consumer | Survey notes | | --- | --- | --- | --- | --- | -| M1 | core | Badge | unsynced-count pills on sidebar repo rows | bezel ships quiet + active variants; gpuikit secondary/outline/destructive. D22's deferred consumer arrives — token needs decided at review | +| M1 | core | Badge ✅ Landed (2026-11-01) | unsynced-count pills on sidebar repo rows | bezel ships quiet + active variants; gpuikit secondary/outline/destructive. Shipped five semantic variants — Neutral (muted fill), Accent, Success, Warning, Danger (the ramps) — painted from existing roles, no badge palette (D22); pill = `Radius.full`, size-table text at 0.8×, caller-refinable via `Styled` | | M2 | core | Select | default-branch chooser, event-fetch-strategy setting, explore filter | bezel: field-shaped trigger over a caller-assembled menu; gpuikit: placeholder + clear. Composes our Popover + Menu — no new overlay machinery | | M3 | core | Segmented control | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select first, multiple waits | | M4 | core | Context menu | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Reuses the Menu panel | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 5ae710a..e255918 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -8,6 +8,7 @@ use gpui::{ }; use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; use gpui_native_core::avatar::Avatar; +use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; @@ -968,6 +969,27 @@ impl Render for Gallery { .child(Avatar::new("desk@reya.info").name("Help Desk").large()), ), ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label( + "badge · native refs — WinUI InfoBadge, macOS app badge, GTK count pill", + colors, + )) + .child( + h_flex() + .gap_3() + .child(Badge::new("3")) + .child(Badge::new("new").variant(BadgeVariant::Accent)) + .child(Badge::new("synced").variant(BadgeVariant::Success)) + .child(Badge::new("drift").variant(BadgeVariant::Warning)) + .child(Badge::new("12").variant(BadgeVariant::Danger)) + .child(Badge::new("small").small()) + .child(Badge::new("large").large()), + ), + ) .child( div() .flex()