diff --git a/AGENTS.md b/AGENTS.md
index 64fd8a2..424534d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,6 +89,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`).
- Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders.
- gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box.
+- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures.
- `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
## Decisions
diff --git a/TASKS.md b/TASKS.md
index ff85d73..eebb0cc 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -42,7 +42,9 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T11b Loading · Progress & states
- [x] T11c Motion · Presence & order
- [x] T11d Motion · Effects
-- [ ] T12 Data Display
+- [ ] T12a Data Display · People & numbers
+- [ ] T12b Data Display · Records
+- [ ] T12c Data Display · Media & measures
- [ ] T13 Lists & Trees
- [ ] T14 Tables
- [ ] T15 Charts
@@ -179,3 +181,4 @@ Per-component lines live in `tasks/`. Tags there:
| T11d | 1 | FAIL | the ripple drew inverted columns in a cut corner, motes crossed rounded corners, a narrow marquee jumped, confetti burst on a falling count, and the Lottie note misread velato |
| T11d | 2 | FAIL | Marquee's doc still said its content is drawn twice |
| T11d | 3 | PASS | |
+| T12a | 1 | — | pending: codex answered 401 on every request; committed unreviewed, reviewed from its commit once codex logs in |
diff --git a/assets/icons/camera.svg b/assets/icons/camera.svg
new file mode 100644
index 0000000..1f6df0d
--- /dev/null
+++ b/assets/icons/camera.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/examples/gallery/assets/dunes-square.jpg b/examples/gallery/assets/dunes-square.jpg
new file mode 100644
index 0000000..13a4c45
Binary files /dev/null and b/examples/gallery/assets/dunes-square.jpg differ
diff --git a/examples/gallery/pages/data/mod.rs b/examples/gallery/pages/data/mod.rs
new file mode 100644
index 0000000..03409f6
--- /dev/null
+++ b/examples/gallery/pages/data/mod.rs
@@ -0,0 +1,39 @@
+mod people;
+
+use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
+
+use super::Page;
+use crate::script::Step;
+
+pub const PAGE: Page = Page {
+ number: 12,
+ slug: "data",
+ title: "Data Display",
+ summary: "People, numbers and records, shown plainly.",
+ render,
+ script: SCRIPT,
+};
+
+const SCRIPT: &[Step] = &[
+ Step::Rest,
+ Step::Click("badge-more"),
+ Step::Wait(200),
+ Step::Shot("count-rolling"),
+ Step::Hover("avatar-upload"),
+ Step::Wait(300),
+ Step::Shot("avatar-hover"),
+ Step::Click("stats-next"),
+ Step::Wait(300),
+ Step::Shot("stats-next"),
+ Step::Rest,
+];
+
+fn render(window: &mut Window, cx: &mut App) -> AnyElement {
+ div()
+ .child(people::badges(window, cx))
+ .child(people::tags(window, cx))
+ .child(people::avatars(window, cx))
+ .child(people::groups(cx))
+ .child(people::numbers(window, cx))
+ .into_any_element()
+}
diff --git a/examples/gallery/pages/data/people.rs b/examples/gallery/pages/data/people.rs
new file mode 100644
index 0000000..5228140
--- /dev/null
+++ b/examples/gallery/pages/data/people.rs
@@ -0,0 +1,243 @@
+use std::path::{Path, PathBuf};
+
+use ely_gpui_component::{
+ buttons::{Button, ButtonVariant},
+ data_display::{
+ Avatar, AvatarGroup, Badge, CountBadge, DotBadge, KpiCard, Presence, Statistic, Tag, Tone,
+ TrendIndicator, UserChip,
+ },
+ forms::AvatarUpload,
+ primitives::{Icon, IconName, Severity},
+ theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
+ typography::Caption,
+};
+use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div};
+
+use crate::{
+ probe::probe,
+ ui::{keep, row, section, set, specimen, specimens},
+};
+
+const DUNES: &str = concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/examples/gallery/assets/dunes-square.jpg"
+);
+
+pub fn badges(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let unread = keep("badge-unread", || 3usize, window, cx);
+ let (count, more) = (*unread.read(cx), unread.clone());
+ let muted = cx.theme().colors.fg_muted;
+ let bell = || Icon::new(IconName::Bell).size(IconSize::Lg).color(muted);
+ section(
+ "Badge / CountBadge / DotBadge",
+ "Small labels tinted by what they mean, counts whose digits roll, and a dot for something new.",
+ cx,
+ )
+ .child(
+ row()
+ .child(Badge::new("Draft"))
+ .child(Badge::new("Beta").tone(Tone::Accent))
+ .child(Badge::new("Live").tone(Severity::Success).dot())
+ .child(Badge::new("Review").tone(Severity::Warning).dot())
+ .child(Badge::new("Failed").tone(Severity::Danger).dot())
+ .child(Badge::new("New").tone(Severity::Info)),
+ )
+ .child(
+ specimens()
+ .child(specimen("count", CountBadge::new("count-bell", count).over(bell()), cx))
+ .child(specimen("capped", CountBadge::new("count-inbox", 128).over(Icon::new(IconName::Inbox).size(IconSize::Lg).color(muted)), cx))
+ .child(specimen("dot", DotBadge::new().over(bell()), cx))
+ .child(specimen("alone", CountBadge::new("count-alone", 7), cx)),
+ )
+ .child(row().child(probe(
+ "badge-more",
+ Button::new("badge-more", "One more")
+ .variant(ButtonVariant::Ghost)
+ .on_click(move |_, _, cx| set(&more, count + 1, cx)),
+ )))
+}
+
+pub fn tags(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ const START: [&str; 4] = ["Design", "Motion", "Accessibility", "Rust"];
+ let kept = keep("tags-kept", || START.to_vec(), window, cx);
+ let now = kept.read(cx).clone();
+ let reset = kept.clone();
+ section(
+ "Tag / Chip",
+ "Short labels for a kind, a topic or a filter; an x takes one away.",
+ cx,
+ )
+ .child(
+ row()
+ .child(Tag::new("tag-plain", "Research"))
+ .child(Tag::new("tag-icon", "Pinned").icon(IconName::Pin))
+ .child(
+ Tag::new("tag-tone", "Shipped")
+ .tone(Severity::Success)
+ .icon(IconName::Check),
+ ),
+ )
+ .child(
+ row()
+ .children(now.iter().enumerate().map(|(ix, name)| {
+ let kept = kept.clone();
+ let name = *name;
+ Tag::new(SharedString::from(format!("tag-remove-{ix}")), name).on_remove(
+ move |_, cx| {
+ let then: Vec<&str> = kept
+ .read(cx)
+ .iter()
+ .copied()
+ .filter(|tag| *tag != name)
+ .collect();
+ set(&kept, then, cx)
+ },
+ )
+ }))
+ .child(
+ Button::new("tags-reset", "Reset")
+ .variant(ButtonVariant::Ghost)
+ .on_click(move |_, _, cx| set(&reset, START.to_vec(), cx)),
+ ),
+ )
+}
+
+pub fn avatars(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let picked = keep("avatar-picked", || None::, window, cx);
+ let (chosen, take) = (picked.read(cx).clone(), picked.clone());
+ let sizes = [
+ (AvatarSize::Xs, "xs"),
+ (AvatarSize::Sm, "sm"),
+ (AvatarSize::Md, "md"),
+ (AvatarSize::Lg, "lg"),
+ (AvatarSize::Xl, "xl"),
+ ];
+ let upload = Avatar::new("upload-face", "Noor Haddad").size(AvatarSize::Xl);
+ let upload = match chosen.clone() {
+ Some(path) => upload.image(path),
+ None => upload,
+ };
+ section(
+ "Avatar / AvatarUpload",
+ "A picture, or initials on a tone the name keeps, or an icon. Presence sits on the corner. Hover the last one, then press it to choose a picture, or drop one on it.",
+ cx,
+ )
+ .child(specimens().children(sizes.map(|(size, name)| {
+ specimen(name, Avatar::new(SharedString::from(format!("avatar-{name}")), "Kofi Mensah").size(size), cx)
+ })))
+ .child(
+ specimens()
+ .child(specimen("picture", Avatar::new("avatar-photo", "Dunes").image(Path::new(DUNES)).size(AvatarSize::Lg), cx))
+ .child(specimen("online", Avatar::new("avatar-online", "Aiko Tanaka").size(AvatarSize::Lg).presence(Presence::Online), cx))
+ .child(specimen("away", Avatar::new("avatar-away", "Lucía Romero").size(AvatarSize::Lg).presence(Presence::Away), cx))
+ .child(specimen("busy", Avatar::new("avatar-busy", "Ben Carter").size(AvatarSize::Lg).presence(Presence::Busy), cx))
+ .child(specimen("team", Avatar::new("avatar-team", "Studio North").size(AvatarSize::Lg).square(), cx))
+ .child(specimen("bot", Avatar::new("avatar-bot", "Build bot").icon(IconName::Bot).size(AvatarSize::Lg), cx))
+ .child(specimen(
+ "upload",
+ probe("avatar-upload", AvatarUpload::new("avatar-upload", upload).on_change(move |path, _, cx| set(&take, Some(path), cx))),
+ cx,
+ )),
+ )
+ .child(Caption::new(match chosen {
+ Some(path) => format!("Chose {}", path.display()),
+ None => "No picture chosen yet.".to_string(),
+ }))
+}
+
+pub fn groups(cx: &mut App) -> impl IntoElement + use<> {
+ const PEOPLE: [&str; 7] = [
+ "Ada Lovelace",
+ "Alan Turing",
+ "Grace Hopper",
+ "Katherine Johnson",
+ "Tim Berners-Lee",
+ "Radia Perlman",
+ "Sophie Wilson",
+ ];
+ let theme = cx.theme();
+ let people = |prefix: &'static str| {
+ PEOPLE
+ .iter()
+ .enumerate()
+ .map(move |(ix, name)| Avatar::new(SharedString::from(format!("{prefix}-{ix}")), *name))
+ };
+ section(
+ "AvatarGroup / UserChip",
+ "People side by side, each tucked under the next, with a count of the rest; someone named inline.",
+ cx,
+ )
+ .child(
+ specimens()
+ .child(specimen("four of seven", AvatarGroup::new(people("group")), cx))
+ .child(specimen("small", AvatarGroup::new(people("group-sm")).max(5).size(AvatarSize::Sm), cx)),
+ )
+ .child(
+ div()
+ .flex()
+ .flex_wrap()
+ .items_center()
+ .gap_1p5()
+ .text_size(theme.text_size(TextSize::Base))
+ .child("Assigned to")
+ .child(UserChip::new("chip-grace", "Grace Hopper"))
+ .child("and")
+ .child(UserChip::new("chip-dunes", "Dune Studio").image(Path::new(DUNES)))
+ .child("for review."),
+ )
+}
+
+pub fn numbers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let turn = keep("stats-turn", || 0usize, window, cx);
+ let (now, next) = (*turn.read(cx), turn.clone());
+ let revenue = [48_210.0, 51_380.0, 49_905.0][now % 3];
+ let users = [3_812.0, 3_994.0, 4_120.0][now % 3];
+ let latency = [212.0, 198.0, 187.0][now % 3];
+ section(
+ "Statistic / KPI Card / Metric / TrendIndicator",
+ "Figures that matter, rolling to each new value, with how they moved. For latency, down is the good way.",
+ cx,
+ )
+ .child(
+ div()
+ .flex()
+ .gap_3()
+ .child(
+ div().flex_1().child(
+ KpiCard::new(Statistic::new("kpi-revenue", "Revenue", revenue).prefix("$").trend(TrendIndicator::new(0.124)))
+ .icon(IconName::DollarSign)
+ .caption("This month, against the last"),
+ ),
+ )
+ .child(
+ div().flex_1().child(
+ KpiCard::new(Statistic::new("kpi-users", "Active people", users).trend(TrendIndicator::new(0.031)))
+ .icon(IconName::Users)
+ .caption("Seen in the last seven days"),
+ ),
+ )
+ .child(
+ div().flex_1().child(
+ KpiCard::new(
+ Statistic::new("kpi-latency", "p95 latency", latency)
+ .suffix("ms")
+ .trend(TrendIndicator::new(-0.08).down_is_good()),
+ )
+ .icon(IconName::Gauge)
+ .caption("Across all regions"),
+ ),
+ ),
+ )
+ .child(
+ specimens()
+ .child(specimen("metric", Statistic::new("metric-uptime", "Uptime", 99.98).decimals(2).suffix("%").size(TextSize::Lg), cx))
+ .child(specimen("metric", Statistic::new("metric-queue", "In queue", 14.0).size(TextSize::Lg), cx))
+ .child(specimen("flat", TrendIndicator::new(0.0), cx)),
+ )
+ .child(row().child(probe(
+ "stats-next",
+ Button::new("stats-next", "Next week")
+ .variant(ButtonVariant::Ghost)
+ .on_click(move |_, _, cx| set(&next, now + 1, cx)),
+ )))
+}
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index 5b7dfab..276c82e 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -1,4 +1,5 @@
mod buttons;
+mod data;
mod feedback;
mod forms;
mod layout;
@@ -37,6 +38,7 @@ pub const ALL: &[Page] = &[
overlays::PAGE,
feedback::PAGE,
motion::PAGE,
+ data::PAGE,
theme::PAGE,
];
diff --git a/src/data_display/avatar.rs b/src/data_display/avatar.rs
new file mode 100644
index 0000000..91960c8
--- /dev/null
+++ b/src/data_display/avatar.rs
@@ -0,0 +1,304 @@
+use gpui::{
+ AnyElement, App, ElementId, FontWeight, Hsla, ImageSource, IntoElement, ObjectFit,
+ ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*,
+};
+
+use crate::{
+ primitives::{Icon, IconName, Image},
+ theme::{ActiveTheme, AvatarSize, IconSize, Palette, Radius, TextSize},
+};
+
+/// Whether someone is around.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Presence {
+ Online,
+ Away,
+ Busy,
+ Offline,
+}
+
+impl Presence {
+ fn color(self, colors: &Palette) -> Hsla {
+ match self {
+ Self::Online => colors.success,
+ Self::Away => colors.warning,
+ Self::Busy => colors.danger,
+ Self::Offline => colors.fg_subtle,
+ }
+ }
+}
+
+/// Up to two capitals from a name's first and last words.
+pub(crate) fn initials(name: &str) -> String {
+ let words: Vec<&str> = name.split_whitespace().collect();
+ let first = |word: &str| word.chars().next().into_iter().flat_map(char::to_uppercase);
+ match words.as_slice() {
+ [] => String::new(),
+ [only] => first(only).collect(),
+ [head, .., tail] => first(head).chain(first(tail)).collect(),
+ }
+}
+
+/// The chart color a name keeps, so the same person reads the same way everywhere.
+fn tint(name: &str, colors: &Palette) -> Hsla {
+ let hash = name.bytes().fold(0u32, |hash, byte| {
+ hash.wrapping_mul(31).wrapping_add(u32::from(byte))
+ });
+ colors.chart[hash as usize % colors.chart.len()]
+}
+
+/// A person or a team at a glance: their picture, or their initials on a tone kept by the name, or an icon. A presence dot can sit on the corner; squares suit teams.
+#[derive(IntoElement)]
+pub struct Avatar {
+ id: ElementId,
+ name: SharedString,
+ image: Option,
+ icon: Option,
+ size: AvatarSize,
+ square: bool,
+ ring: bool,
+ presence: Option,
+}
+
+impl Avatar {
+ /// The name gives the initials and the tone when there is no picture.
+ pub fn new(id: impl Into, name: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ name: name.into(),
+ image: None,
+ icon: None,
+ size: AvatarSize::default(),
+ square: false,
+ ring: false,
+ presence: None,
+ }
+ }
+
+ /// A square picture: gpui clips pictures to rectangles, so a cropped one keeps square corners.
+ pub fn image(mut self, source: impl Into) -> Self {
+ self.image = Some(source.into());
+ self
+ }
+
+ /// Shown instead of initials, for a bot or a place.
+ pub fn icon(mut self, icon: IconName) -> Self {
+ self.icon = Some(icon);
+ self
+ }
+
+ pub fn size(mut self, size: AvatarSize) -> Self {
+ self.size = size;
+ self
+ }
+
+ /// A rounded square, as teams and apps take.
+ pub fn square(mut self) -> Self {
+ self.square = true;
+ self
+ }
+
+ /// A ring in the page color, so avatars that overlap stay apart.
+ pub fn ring(mut self) -> Self {
+ self.ring = true;
+ self
+ }
+
+ pub fn presence(mut self, presence: Presence) -> Self {
+ self.presence = Some(presence);
+ self
+ }
+}
+
+impl RenderOnce for Avatar {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let side = theme.avatar_size(self.size);
+ let rounded = |element: gpui::Div| {
+ if self.square {
+ element.rounded(theme.radius(Radius::Md))
+ } else {
+ element.rounded_full()
+ }
+ };
+ let tone = tint(&self.name, colors);
+ let text = match self.size {
+ AvatarSize::Xs | AvatarSize::Sm => TextSize::Xs,
+ AvatarSize::Md => TextSize::Sm,
+ AvatarSize::Lg => TextSize::Md,
+ AvatarSize::Xl => TextSize::Lg,
+ };
+ let face: AnyElement = match (self.image, self.icon) {
+ (Some(source), _) => {
+ let mut picture = Image::new((self.id.clone(), "picture"), source)
+ .fit(ObjectFit::Cover)
+ .size_full();
+ picture = if self.square {
+ picture.rounded(theme.radius(Radius::Md))
+ } else {
+ picture.rounded_full()
+ };
+ picture.into_any_element()
+ }
+ (None, Some(icon)) => Icon::new(icon)
+ .size(IconSize::Md)
+ .color(colors.fg_muted)
+ .into_any_element(),
+ (None, None) => initials(&self.name).into_any_element(),
+ };
+ let dot = self.presence.map(|presence| {
+ div()
+ .absolute()
+ .right_0()
+ .bottom_0()
+ .size(side * 0.28)
+ .rounded_full()
+ .border_2()
+ .border_color(colors.bg)
+ .bg(presence.color(colors))
+ });
+ div()
+ .relative()
+ .flex_none()
+ .size(side)
+ .child(
+ rounded(div())
+ .size_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .bg(tone.opacity(0.16))
+ .text_color(tone)
+ .text_size(theme.text_size(text))
+ .font_weight(FontWeight::SEMIBOLD)
+ .when(self.ring, |face| face.border_2().border_color(colors.bg))
+ .child(face),
+ )
+ .children(dot)
+ }
+}
+
+/// People side by side, each tucked under the next, and a count of the rest.
+#[derive(IntoElement)]
+pub struct AvatarGroup {
+ people: Vec,
+ max: usize,
+ size: AvatarSize,
+}
+
+impl AvatarGroup {
+ pub fn new(people: impl IntoIterator- ) -> Self {
+ Self {
+ people: people.into_iter().collect(),
+ max: 4,
+ size: AvatarSize::default(),
+ }
+ }
+
+ /// How many show before the rest become +N.
+ pub fn max(mut self, max: usize) -> Self {
+ assert!(max > 0, "a group shows at least one avatar");
+ self.max = max;
+ self
+ }
+
+ pub fn size(mut self, size: AvatarSize) -> Self {
+ self.size = size;
+ self
+ }
+}
+
+impl RenderOnce for AvatarGroup {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let rest = self.people.len().saturating_sub(self.max);
+ let size = self.size;
+ let tuck = |element: gpui::Div, first: bool| match (first, size) {
+ (true, _) => element,
+ (false, AvatarSize::Xs | AvatarSize::Sm) => element.ml_neg_1(),
+ (false, AvatarSize::Md) => element.ml_neg_2(),
+ (false, AvatarSize::Lg | AvatarSize::Xl) => element.ml_neg_3(),
+ };
+ let shown = self
+ .people
+ .into_iter()
+ .take(self.max)
+ .enumerate()
+ .map(|(ix, person)| tuck(div(), ix == 0).child(person.size(size).ring()));
+ let more = (rest > 0).then(|| {
+ tuck(div(), false).child(
+ div()
+ .flex()
+ .items_center()
+ .justify_center()
+ .size(theme.avatar_size(size))
+ .rounded_full()
+ .border_2()
+ .border_color(theme.colors.bg)
+ .bg(theme.colors.hover)
+ .text_size(theme.text_size(TextSize::Xs))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme.colors.fg_muted)
+ .child(format!("+{rest}")),
+ )
+ });
+ div().flex().items_center().children(shown).children(more)
+ }
+}
+
+/// Someone named in a line or a field: a small avatar and the name, in a pill.
+#[derive(IntoElement)]
+pub struct UserChip {
+ avatar: Avatar,
+ name: SharedString,
+}
+
+impl UserChip {
+ pub fn new(id: impl Into, name: impl Into) -> Self {
+ let name = name.into();
+ Self {
+ avatar: Avatar::new(id, name.clone()).size(AvatarSize::Xs),
+ name,
+ }
+ }
+
+ pub fn image(mut self, source: impl Into) -> Self {
+ self.avatar = self.avatar.image(source);
+ self
+ }
+}
+
+impl RenderOnce for UserChip {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ div()
+ .flex()
+ .flex_none()
+ .items_center()
+ .gap_1p5()
+ .pl_0p5()
+ .pr_2()
+ .py_0p5()
+ .rounded_full()
+ .bg(theme.colors.hover)
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(theme.colors.fg)
+ .child(self.avatar)
+ .child(self.name)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::initials;
+
+ #[test]
+ fn initials_take_the_first_and_last_words() {
+ assert_eq!(initials("Ada Lovelace"), "AL");
+ assert_eq!(initials("Grace Brewster Murray Hopper"), "GH");
+ assert_eq!(initials("mia"), "M");
+ assert_eq!(initials(" "), "");
+ assert_eq!(initials("élodie durand"), "ÉD");
+ }
+}
diff --git a/src/data_display/badge.rs b/src/data_display/badge.rs
new file mode 100644
index 0000000..fa9aeac
--- /dev/null
+++ b/src/data_display/badge.rs
@@ -0,0 +1,342 @@
+use std::rc::Rc;
+
+use gpui::{
+ AnyElement, App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton,
+ ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
+ prelude::*,
+};
+
+use crate::{
+ primitives::{Icon, IconName, Severity},
+ theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize},
+ typography::AnimatedNumber,
+};
+
+/// How a badge or a tag is tinted: quiet, the accent, or a severity.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Tone {
+ #[default]
+ Neutral,
+ Accent,
+ Info,
+ Success,
+ Warning,
+ Danger,
+}
+
+impl From for Tone {
+ fn from(severity: Severity) -> Self {
+ match severity {
+ Severity::Info => Self::Info,
+ Severity::Success => Self::Success,
+ Severity::Warning => Self::Warning,
+ Severity::Danger => Self::Danger,
+ }
+ }
+}
+
+impl Tone {
+ /// The fill, then the text on it.
+ fn colors(self, colors: &Palette) -> (Hsla, Hsla) {
+ let severity = |severity: Severity| (severity.subtle(colors), severity.color(colors));
+ match self {
+ Self::Neutral => (colors.hover, colors.fg_muted),
+ Self::Accent => (colors.accent, colors.on_accent),
+ Self::Info => severity(Severity::Info),
+ Self::Success => severity(Severity::Success),
+ Self::Warning => severity(Severity::Warning),
+ Self::Danger => severity(Severity::Danger),
+ }
+ }
+
+ /// The color a dot of this tone takes.
+ fn dot(self, colors: &Palette) -> Hsla {
+ match self {
+ Self::Neutral => colors.fg_subtle,
+ Self::Accent => colors.accent,
+ other => other.colors(colors).1,
+ }
+ }
+}
+
+/// Sets `badge` on the top-right corner of `over`, just past its edge.
+fn corner(over: AnyElement, badge: impl IntoElement) -> AnyElement {
+ div()
+ .relative()
+ .flex_none()
+ .child(over)
+ .child(div().absolute().top_neg_1().right_neg_1().child(badge))
+ .into_any_element()
+}
+
+/// A small label for a status or a kind, tinted by its tone, with a dot when it marks a state.
+#[derive(IntoElement)]
+pub struct Badge {
+ label: SharedString,
+ tone: Tone,
+ dot: bool,
+}
+
+impl Badge {
+ pub fn new(label: impl Into) -> Self {
+ Self {
+ label: label.into(),
+ tone: Tone::default(),
+ dot: false,
+ }
+ }
+
+ pub fn tone(mut self, tone: impl Into) -> Self {
+ self.tone = tone.into();
+ self
+ }
+
+ /// Leads with a dot of the tone's color, as a status does.
+ pub fn dot(mut self) -> Self {
+ self.dot = true;
+ self
+ }
+}
+
+impl RenderOnce for Badge {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let (fill, text) = self.tone.colors(&theme.colors);
+ let dot = self.tone.dot(&theme.colors);
+ div()
+ .flex()
+ .flex_none()
+ .items_center()
+ .gap_1()
+ .px_1p5()
+ .h(theme.control_height(ControlSize::Sm) * 0.75)
+ .rounded(theme.radius(Radius::Sm))
+ .bg(fill)
+ .text_size(theme.text_size(TextSize::Xs))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(text)
+ .when(self.dot, |badge| {
+ badge.child(div().size(theme.status_dot()).rounded_full().bg(dot))
+ })
+ .child(self.label)
+ }
+}
+
+/// What a count badge reads past its cap, as 99+.
+fn capped(count: usize, max: usize) -> Option {
+ (count > max).then(|| format!("{max}+"))
+}
+
+/// A number in a pill, for unread items or a queue. Its digits roll as it changes; past `max` it reads 99+. Zero hides it. Set `over` an icon or an avatar, it sits on the top-right corner.
+#[derive(IntoElement)]
+pub struct CountBadge {
+ id: ElementId,
+ count: usize,
+ max: usize,
+ over: Option,
+}
+
+impl CountBadge {
+ pub fn new(id: impl Into, count: usize) -> Self {
+ Self {
+ id: id.into(),
+ count,
+ max: 99,
+ over: None,
+ }
+ }
+
+ /// The largest count shown as is; above it reads `max+`.
+ pub fn max(mut self, max: usize) -> Self {
+ assert!(max > 0, "a count badge needs room for one");
+ self.max = max;
+ self
+ }
+
+ pub fn over(mut self, element: impl IntoElement) -> Self {
+ self.over = Some(element.into_any_element());
+ self
+ }
+}
+
+impl RenderOnce for CountBadge {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let (fill, text) = Tone::Accent.colors(&theme.colors);
+ let height = theme.control_height(ControlSize::Sm) * 0.67;
+ let pill = (self.count > 0).then(|| {
+ div()
+ .flex()
+ .items_center()
+ .justify_center()
+ .min_w(height)
+ .h(height)
+ .px_1()
+ .rounded_full()
+ .bg(fill)
+ .text_color(text)
+ .text_size(theme.text_size(TextSize::Xs))
+ .font_weight(FontWeight::MEDIUM)
+ .child(if let Some(capped) = capped(self.count, self.max) {
+ capped.into_any_element()
+ } else {
+ AnimatedNumber::new(self.id, self.count as f64)
+ .size(TextSize::Xs)
+ .color(text)
+ .into_any_element()
+ })
+ });
+ match self.over {
+ Some(over) => corner(over, div().children(pill)),
+ None => div().children(pill).into_any_element(),
+ }
+ }
+}
+
+/// A small dot that says something is new or live. Set `over` an icon, it sits on the top-right corner.
+#[derive(IntoElement)]
+pub struct DotBadge {
+ tone: Tone,
+ over: Option,
+}
+
+impl DotBadge {
+ pub fn new() -> Self {
+ Self {
+ tone: Tone::Accent,
+ over: None,
+ }
+ }
+
+ pub fn tone(mut self, tone: impl Into) -> Self {
+ self.tone = tone.into();
+ self
+ }
+
+ pub fn over(mut self, element: impl IntoElement) -> Self {
+ self.over = Some(element.into_any_element());
+ self
+ }
+}
+
+impl Default for DotBadge {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl RenderOnce for DotBadge {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let dot = div()
+ .size(theme.status_dot() * 1.34)
+ .rounded_full()
+ .border_1()
+ .border_color(theme.colors.bg)
+ .bg(self.tone.dot(&theme.colors));
+ match self.over {
+ Some(over) => corner(over, dot),
+ None => dot.into_any_element(),
+ }
+ }
+}
+
+type OnRemove = Rc;
+
+/// A short label for a kind, a topic or a filter: an optional icon, the label, and an x when it can be removed.
+#[derive(IntoElement)]
+pub struct Tag {
+ id: ElementId,
+ label: SharedString,
+ icon: Option,
+ tone: Tone,
+ on_remove: Option,
+}
+
+impl Tag {
+ pub fn new(id: impl Into, label: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ label: label.into(),
+ icon: None,
+ tone: Tone::Neutral,
+ on_remove: None,
+ }
+ }
+
+ pub fn icon(mut self, icon: IconName) -> Self {
+ self.icon = Some(icon);
+ self
+ }
+
+ pub fn tone(mut self, tone: impl Into) -> Self {
+ self.tone = tone.into();
+ self
+ }
+
+ /// Adds an x that runs `handler`; the press stays out of focus.
+ pub fn on_remove(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.on_remove = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for Tag {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let (fill, text) = match self.tone {
+ Tone::Neutral => (colors.hover, colors.fg),
+ other => other.colors(colors),
+ };
+ let remove = self.id.clone();
+ div()
+ .flex()
+ .flex_none()
+ .items_center()
+ .gap_1()
+ .pl_2()
+ .when(self.on_remove.is_none(), |tag| tag.pr_2())
+ .when(self.on_remove.is_some(), |tag| tag.pr_1())
+ .h(theme.control_height(ControlSize::Sm))
+ .rounded(theme.radius(Radius::Sm))
+ .bg(fill)
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(text)
+ .when_some(self.icon, |tag, icon| {
+ tag.child(Icon::new(icon).size(IconSize::Xs).color(text))
+ })
+ .child(self.label)
+ .when_some(self.on_remove, |tag, remove_it| {
+ tag.child(
+ div()
+ .id(remove)
+ .rounded(theme.radius(Radius::Sm))
+ .cursor_pointer()
+ .hover(|style| style.bg(colors.active))
+ .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
+ .on_click(move |_, window, cx| {
+ cx.stop_propagation();
+ remove_it(window, cx)
+ })
+ .child(
+ Icon::new(IconName::X)
+ .size(IconSize::Xs)
+ .color(colors.fg_muted),
+ ),
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::capped;
+
+ #[test]
+ fn a_count_past_its_cap_reads_the_cap_and_a_plus() {
+ assert_eq!(capped(128, 99), Some("99+".to_string()));
+ assert_eq!(capped(99, 99), None);
+ assert_eq!(capped(10, 9), Some("9+".to_string()));
+ }
+}
diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs
new file mode 100644
index 0000000..29bdfda
--- /dev/null
+++ b/src/data_display/mod.rs
@@ -0,0 +1,7 @@
+mod avatar;
+mod badge;
+mod stat;
+
+pub use avatar::{Avatar, AvatarGroup, Presence, UserChip};
+pub use badge::{Badge, CountBadge, DotBadge, Tag, Tone};
+pub use stat::{KpiCard, Statistic, TrendIndicator};
diff --git a/src/data_display/stat.rs b/src/data_display/stat.rs
new file mode 100644
index 0000000..6819c38
--- /dev/null
+++ b/src/data_display/stat.rs
@@ -0,0 +1,237 @@
+use gpui::{
+ App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
+ Window, div, prelude::*,
+};
+
+use crate::{
+ primitives::{Icon, IconName},
+ theme::{ActiveTheme, IconSize, Radius, TextSize},
+ typography::{AnimatedNumber, format},
+};
+
+/// Whether a move is good news; `None` when nothing moved.
+fn good(change: f64, down_is_good: bool) -> Option {
+ (change != 0.0).then_some((change > 0.0) != down_is_good)
+}
+
+/// Which way a figure moved, as a signed share, and whether down is the good way.
+#[derive(IntoElement, Clone, Copy)]
+pub struct TrendIndicator {
+ change: f64,
+ down_is_good: bool,
+}
+
+impl TrendIndicator {
+ /// `change` is a share: 0.124 reads +12.4%.
+ pub fn new(change: f64) -> Self {
+ assert!(
+ change.is_finite(),
+ "a trend needs a finite change, got {change}"
+ );
+ Self {
+ change,
+ down_is_good: false,
+ }
+ }
+
+ /// For costs and wait times, where falling is better.
+ pub fn down_is_good(mut self) -> Self {
+ self.down_is_good = true;
+ self
+ }
+}
+
+impl RenderOnce for TrendIndicator {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let colors = &cx.theme().colors;
+ let icon = match self.change {
+ change if change > 0.0 => IconName::TrendingUp,
+ change if change < 0.0 => IconName::TrendingDown,
+ _ => IconName::Minus,
+ };
+ let tone = match good(self.change, self.down_is_good) {
+ None => colors.fg_muted,
+ Some(true) => colors.success,
+ Some(false) => colors.danger,
+ };
+ div()
+ .flex()
+ .flex_none()
+ .items_center()
+ .gap_1()
+ .text_size(cx.theme().text_size(TextSize::Sm))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(tone)
+ .child(Icon::new(icon).size(IconSize::Sm).color(tone))
+ .child(format::percent(self.change, 1, true))
+ }
+}
+
+/// A figure that matters: its label, the number rolling to each new value, units around it, and how it moved.
+#[derive(IntoElement)]
+pub struct Statistic {
+ id: ElementId,
+ label: SharedString,
+ value: f64,
+ decimals: usize,
+ prefix: Option,
+ suffix: Option,
+ trend: Option,
+ size: TextSize,
+}
+
+impl Statistic {
+ pub fn new(id: impl Into, label: impl Into, value: f64) -> Self {
+ Self {
+ id: id.into(),
+ label: label.into(),
+ value,
+ decimals: 0,
+ prefix: None,
+ suffix: None,
+ trend: None,
+ size: TextSize::Xxl,
+ }
+ }
+
+ pub fn decimals(mut self, decimals: usize) -> Self {
+ self.decimals = decimals;
+ self
+ }
+
+ /// Before the number, as a currency sign.
+ pub fn prefix(mut self, text: impl Into) -> Self {
+ self.prefix = Some(text.into());
+ self
+ }
+
+ /// After the number, as a unit.
+ pub fn suffix(mut self, text: impl Into) -> Self {
+ self.suffix = Some(text.into());
+ self
+ }
+
+ pub fn trend(mut self, trend: TrendIndicator) -> Self {
+ self.trend = Some(trend);
+ self
+ }
+
+ /// The number's size; a compact metric takes a smaller one.
+ pub fn size(mut self, size: TextSize) -> Self {
+ self.size = size;
+ self
+ }
+}
+
+impl RenderOnce for Statistic {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let unit = |text: SharedString| {
+ div()
+ .text_size(theme.text_size(TextSize::Md))
+ .text_color(theme.colors.fg_muted)
+ .child(text)
+ };
+ div()
+ .flex()
+ .flex_col()
+ .gap_1()
+ .child(
+ div()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(theme.colors.fg_muted)
+ .child(self.label),
+ )
+ .child(
+ div()
+ .flex()
+ .items_baseline()
+ .gap_1()
+ .children(self.prefix.map(unit))
+ .child(
+ AnimatedNumber::new(self.id, self.value)
+ .decimals(self.decimals)
+ .size(self.size),
+ )
+ .children(self.suffix.map(unit)),
+ )
+ .children(self.trend)
+ }
+}
+
+/// A statistic on a quiet card, with an icon and a line of context.
+#[derive(IntoElement)]
+pub struct KpiCard {
+ statistic: Statistic,
+ icon: Option,
+ caption: Option,
+}
+
+impl KpiCard {
+ pub fn new(statistic: Statistic) -> Self {
+ Self {
+ statistic,
+ icon: None,
+ caption: None,
+ }
+ }
+
+ pub fn icon(mut self, icon: IconName) -> Self {
+ self.icon = Some(icon);
+ self
+ }
+
+ pub fn caption(mut self, text: impl Into) -> Self {
+ self.caption = Some(text.into());
+ self
+ }
+}
+
+impl RenderOnce for KpiCard {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ div()
+ .relative()
+ .flex()
+ .flex_col()
+ .gap_3()
+ .p_4()
+ .rounded(theme.radius(Radius::Lg))
+ .border_1()
+ .border_color(colors.border)
+ .bg(colors.surface)
+ .when_some(self.icon, |card, icon| {
+ card.child(
+ div()
+ .absolute()
+ .top_4()
+ .right_4()
+ .child(Icon::new(icon).size(IconSize::Md).color(colors.fg_subtle)),
+ )
+ })
+ .child(self.statistic)
+ .when_some(self.caption, |card, caption| {
+ card.child(
+ div()
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(colors.fg_subtle)
+ .child(caption),
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::good;
+
+ #[test]
+ fn a_trend_is_good_when_it_goes_the_right_way() {
+ assert_eq!(good(0.12, false), Some(true));
+ assert_eq!(good(-0.12, false), Some(false));
+ assert_eq!(good(-0.08, true), Some(true));
+ assert_eq!(good(0.08, true), Some(false));
+ assert_eq!(good(0.0, true), None);
+ }
+}
diff --git a/src/forms/avatar.rs b/src/forms/avatar.rs
new file mode 100644
index 0000000..f5112f0
--- /dev/null
+++ b/src/forms/avatar.rs
@@ -0,0 +1,163 @@
+use std::{path::PathBuf, rc::Rc};
+
+use gpui::{
+ App, ElementId, ExternalPaths, InteractiveElement, IntoElement, ParentElement,
+ PathPromptOptions, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
+};
+
+use super::{files::hovering, path::choose};
+use crate::{
+ data_display::Avatar,
+ primitives::{FocusRing, Icon, IconName, tab_stop},
+ theme::{ActiveTheme, IconSize},
+};
+
+/// File kinds an avatar takes.
+const PICTURES: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "heic"];
+
+/// The one picture a drop or a pick brings, or why it is refused.
+pub(crate) fn picture(paths: &[PathBuf]) -> Result {
+ let [path] = paths else {
+ return Err(if paths.is_empty() {
+ "nothing came"
+ } else {
+ "one picture at a time"
+ });
+ };
+ if !path.is_file() {
+ return Err("files only, not folders");
+ }
+ let kind = path
+ .extension()
+ .and_then(|kind| kind.to_str())
+ .map(str::to_lowercase);
+ if !kind.is_some_and(|kind| PICTURES.contains(&kind.as_str())) {
+ return Err("pictures only: png, jpg, gif, webp or heic");
+ }
+ Ok(path.clone())
+}
+
+type OnPicture = Rc;
+
+/// An avatar you replace: a hover shows a camera, a press or Enter opens the picture dialog, and a picture dropped on it works too. `on_change` gets its path.
+#[derive(IntoElement)]
+pub struct AvatarUpload {
+ id: ElementId,
+ avatar: Avatar,
+ on_change: Option,
+}
+
+impl AvatarUpload {
+ pub fn new(id: impl Into, avatar: Avatar) -> Self {
+ Self {
+ id: id.into(),
+ avatar,
+ on_change: None,
+ }
+ }
+
+ pub fn on_change(mut self, handler: impl Fn(PathBuf, &mut Window, &mut App) + 'static) -> Self {
+ self.on_change = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for AvatarUpload {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
+ let what = SharedString::from(format!("avatar upload {:?}", self.id));
+ let take: OnPicture = {
+ let (what, on_change) = (what.clone(), self.on_change);
+ Rc::new(move |path, window, cx| {
+ log::info!("{what}: took {}", path.display());
+ if let Some(on_change) = &on_change {
+ on_change(path, window, cx);
+ }
+ })
+ };
+ let theme = cx.theme();
+ let group = SharedString::from(format!("{:?}", self.id));
+ let (pick, drop_in, what_drop) = (take.clone(), take, what.clone());
+ div()
+ .id(self.id)
+ .group(group.clone())
+ .relative()
+ .flex_none()
+ .rounded_full()
+ .border_1()
+ .border_color(gpui::transparent_black())
+ .cursor_pointer()
+ .track_focus(&focus)
+ .focus_ring(cx)
+ .child(self.avatar)
+ .child(
+ div()
+ .absolute()
+ .inset_0()
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded_full()
+ .bg(theme.colors.media_backdrop.opacity(0.5))
+ .opacity(0.0)
+ .group_hover(group, |style| style.opacity(1.0))
+ .child(
+ Icon::new(IconName::Camera)
+ .size(IconSize::Md)
+ .color(theme.colors.on_media),
+ ),
+ )
+ .on_click(move |_, window, cx| {
+ let pick = pick.clone();
+ let what = what.clone();
+ choose(
+ what.clone(),
+ PathPromptOptions {
+ files: true,
+ directories: false,
+ multiple: false,
+ prompt: Some("Choose".into()),
+ },
+ window,
+ cx,
+ move |paths, window, cx| match picture(&paths) {
+ Ok(path) => pick(path, window, cx),
+ Err(reason) => log::warn!("{what}: refused a pick: {reason}"),
+ },
+ );
+ })
+ .drag_over::(|style, paths, _, cx| {
+ hovering(style, picture(paths.paths()).is_ok(), cx)
+ })
+ .on_drop(
+ move |paths: &ExternalPaths, window, cx| match picture(paths.paths()) {
+ Ok(path) => drop_in(path, window, cx),
+ Err(reason) => log::info!("{what_drop}: refused a drop: {reason}"),
+ },
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use super::picture;
+
+ #[test]
+ fn an_avatar_takes_one_picture_file() {
+ let dir = std::env::temp_dir().join("ely-avatar-picture");
+ fs::create_dir_all(&dir).expect("a temp dir");
+ let (face, notes) = (dir.join("face.PNG"), dir.join("notes.txt"));
+ fs::write(&face, b"png").expect("a picture");
+ fs::write(¬es, b"text").expect("a note");
+ assert_eq!(picture(std::slice::from_ref(&face)), Ok(face.clone()));
+ assert_eq!(
+ picture(&[notes]),
+ Err("pictures only: png, jpg, gif, webp or heic")
+ );
+ assert_eq!(picture(&[face.clone(), face]), Err("one picture at a time"));
+ assert_eq!(picture(&[dir]), Err("files only, not folders"));
+ assert_eq!(picture(&[]), Err("nothing came"));
+ }
+}
diff --git a/src/forms/files.rs b/src/forms/files.rs
index 2d01a66..9a97bad 100644
--- a/src/forms/files.rs
+++ b/src/forms/files.rs
@@ -36,7 +36,7 @@ pub(crate) fn dropped(paths: &[PathBuf], multiple: bool) -> Result,
}
/// A drop target while files hover: focus-tinted when it takes them, danger-tinted when not.
-fn hovering(style: StyleRefinement, takes: bool, cx: &App) -> StyleRefinement {
+pub(super) fn hovering(style: StyleRefinement, takes: bool, cx: &App) -> StyleRefinement {
let colors = &cx.theme().colors;
if takes {
style.border_color(colors.focus).bg(colors.selection)
diff --git a/src/forms/mod.rs b/src/forms/mod.rs
index 072e846..bc5dabc 100644
--- a/src/forms/mod.rs
+++ b/src/forms/mod.rs
@@ -1,3 +1,4 @@
+mod avatar;
mod card;
mod cascade;
mod catalogs;
@@ -43,6 +44,7 @@ mod transfer;
mod unit;
mod upload;
+pub use avatar::AvatarUpload;
pub use card::{CheckboxCard, RadioCard};
pub use cascade::{Cascade, Cascader};
pub use catalogs::FontPicker;
diff --git a/src/forms/multi.rs b/src/forms/multi.rs
index ddd5783..1578b76 100644
--- a/src/forms/multi.rs
+++ b/src/forms/multi.rs
@@ -10,9 +10,9 @@ use super::{
listbox::chosen,
options::{OnValues, Pick, Popup, step},
select::{Picker, measure_anchor, moved},
- tags::chip,
};
use crate::{
+ data_display::Tag,
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
@@ -125,14 +125,13 @@ impl RenderOnce for MultiSelect {
.map(|(ix, choice)| {
let (choices, selected, commit) =
(choices.clone(), selected.clone(), commit.clone());
- chip(
- ("chip-remove", ix).into(),
+ Tag::new(
+ (self.id.clone(), format!("chip-{ix}")),
choice.label.clone(),
- Rc::new(move |window, cx| {
- commit(&chosen(&choices, &selected, ix, true), window, cx)
- }),
- cx,
)
+ .on_remove(move |window, cx| {
+ commit(&chosen(&choices, &selected, ix, true), window, cx)
+ })
})
.collect();
let (click, keys, rows, close) = (
diff --git a/src/forms/tags.rs b/src/forms/tags.rs
index 9315b8a..1361e22 100644
--- a/src/forms/tags.rs
+++ b/src/forms/tags.rs
@@ -1,15 +1,14 @@
use std::rc::Rc;
use gpui::{
- App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
- ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
- Window, div, prelude::*,
+ App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
+ RenderOnce, SharedString, Styled, Subscription, Window, div, prelude::*,
};
-use super::{InputEvent, TextInput, options::Run, text::Backspace};
+use super::{InputEvent, TextInput, text::Backspace};
use crate::{
- primitives::{Icon, IconName},
- theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
+ data_display::Tag,
+ theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
type OnChange = Rc, &mut Window, &mut App)>;
@@ -45,41 +44,6 @@ impl Tags {
}
}
-/// A small label with an x that removes it.
-pub(crate) fn chip(id: ElementId, label: SharedString, remove: Run, cx: &App) -> Div {
- let theme = cx.theme();
- let colors = &theme.colors;
- div()
- .flex()
- .items_center()
- .gap_1()
- .pl_2()
- .pr_1()
- .h(theme.control_height(ControlSize::Sm))
- .rounded(theme.radius(Radius::Sm))
- .bg(colors.hover)
- .text_size(theme.text_size(TextSize::Sm))
- .text_color(colors.fg)
- .child(label)
- .child(
- div()
- .id(id)
- .rounded(theme.radius(Radius::Sm))
- .cursor_pointer()
- .hover(|style| style.bg(colors.active))
- .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
- .on_click(move |_, window, cx| {
- cx.stop_propagation();
- remove(window, cx)
- })
- .child(
- Icon::new(IconName::X)
- .size(IconSize::Xs)
- .color(colors.fg_muted),
- ),
- )
-}
-
/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last.
#[derive(IntoElement)]
pub struct TagInput {
@@ -145,20 +109,18 @@ impl RenderOnce for TagInput {
let focus = input.read(cx).focus().clone();
let theme = cx.theme();
let colors = &theme.colors;
+ let owner = self.id.clone();
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
let (tags, on_change) = (self.tags.clone(), self.on_change.clone());
- chip(
- ("tag-remove", ix).into(),
- tag.clone(),
- Rc::new(move |window, cx| {
+ Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone()).on_remove(
+ move |window, cx| {
let mut next = tags.clone();
next.remove(ix);
log::info!("tag input: removed one, {} left", next.len());
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
- }),
- cx,
+ },
)
});
let (tags, on_change, empty_check) =
diff --git a/src/lib.rs b/src/lib.rs
index 1f3d734..004b1a3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
mod assets;
pub mod buttons;
+pub mod data_display;
pub mod feedback;
pub mod forms;
pub mod layout;
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index a7f6cee..9f600d9 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -62,6 +62,7 @@ icons! {
CalendarDays => "calendar-days",
CalendarRange => "calendar-range",
Calendar => "calendar",
+ Camera => "camera",
ChartBar => "chart-bar",
ChartCandlestick => "chart-candlestick",
ChartLine => "chart-line",
diff --git a/src/shell/activity.rs b/src/shell/activity.rs
index 94666bc..400a51c 100644
--- a/src/shell/activity.rs
+++ b/src/shell/activity.rs
@@ -6,9 +6,10 @@ use gpui::{
};
use crate::{
+ data_display::CountBadge,
motion,
primitives::{Icon, IconName, Tooltip},
- theme::{ActiveTheme, IconSize, TextSize},
+ theme::{ActiveTheme, IconSize},
};
/// One destination on an activity bar.
@@ -150,19 +151,10 @@ impl RenderOnce for ActivityBar {
)
})
.when_some(item.badge, |entry, count| {
- entry.child(
- div()
- .absolute()
- .top_1()
- .right_1()
- .px_1()
- .rounded_full()
- .bg(colors.accent)
- .text_color(colors.on_accent)
- .text_size(theme.text_size(TextSize::Xs))
- .line_height(theme.text_size(TextSize::Md))
- .child(SharedString::from(count.to_string())),
- )
+ entry.child(div().absolute().top_1().right_1().child(CountBadge::new(
+ SharedString::from(format!("activity-badge-{}", item.id)),
+ count as usize,
+ )))
})
};
let column = || div().flex().flex_col();
diff --git a/src/theme/mod.rs b/src/theme/mod.rs
index e24403b..fe76f3d 100644
--- a/src/theme/mod.rs
+++ b/src/theme/mod.rs
@@ -7,7 +7,9 @@ use gpui::{App, Global, SharedString, WindowAppearance};
use crate::motion;
pub use palette::{Mix, Palette, Syntax};
-pub use tokens::{ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize};
+pub use tokens::{
+ AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
+};
const FRAME: Duration = Duration::from_millis(8);
diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs
index 661e10d..333d47f 100644
--- a/src/theme/tokens.rs
+++ b/src/theme/tokens.rs
@@ -18,6 +18,17 @@ pub enum ControlSize {
Lg,
}
+/// How large an avatar is.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum AvatarSize {
+ Xs,
+ Sm,
+ #[default]
+ Md,
+ Lg,
+ Xl,
+}
+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconSize {
Xs,
@@ -97,6 +108,16 @@ impl Theme {
px_to_rems(base + delta)
}
+ pub fn avatar_size(&self, size: AvatarSize) -> Rems {
+ px_to_rems(match size {
+ AvatarSize::Xs => 20.0,
+ AvatarSize::Sm => 24.0,
+ AvatarSize::Md => 32.0,
+ AvatarSize::Lg => 40.0,
+ AvatarSize::Xl => 56.0,
+ })
+ }
+
pub fn icon_size(&self, size: IconSize) -> Rems {
let base = match size {
IconSize::Xs => 12.0,
diff --git a/src/typography/rolling.rs b/src/typography/rolling.rs
index b1c9565..b46d0b2 100644
--- a/src/typography/rolling.rs
+++ b/src/typography/rolling.rs
@@ -1,5 +1,5 @@
use gpui::{
- Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, ParentElement,
+ Animation, AnimationExt, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div,
};
@@ -33,6 +33,7 @@ pub struct AnimatedNumber {
value: f64,
decimals: usize,
pad: usize,
+ color: Option,
tween: Tween,
size: TextSize,
}
@@ -44,6 +45,7 @@ impl AnimatedNumber {
value,
decimals: 0,
pad: 0,
+ color: None,
tween: Tween::Roll,
size: TextSize::Xxl,
}
@@ -60,6 +62,12 @@ impl AnimatedNumber {
self
}
+ /// Defaults to the text color.
+ pub fn color(mut self, color: Hsla) -> Self {
+ self.color = Some(color);
+ self
+ }
+
/// Counts through the values instead of rolling digits.
pub fn count_up(mut self) -> Self {
self.tween = Tween::Count;
@@ -124,7 +132,7 @@ impl RenderOnce for AnimatedNumber {
.overflow_hidden()
.text_size(size)
.line_height(line)
- .text_color(theme.colors.fg);
+ .text_color(self.color.unwrap_or(theme.colors.fg));
if self.tween == Tween::Count {
return row.child(div().with_animation(
diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md
index cb6345a..1bdee08 100644
--- a/tasks/ch01-10.md
+++ b/tasks/ch01-10.md
@@ -250,7 +250,7 @@
- [x] DropZone → `forms::DropZone`
- [x] UploadList → `forms::UploadList`, built with Loading & Motion (T11b): its rows are ProgressBars
- [x] ImageUpload — preview, crop → lands with Media (T26): its crop is the ImageCropper
-- [x] AvatarUpload → lands with Data Display (T12): it is an Avatar you replace
+- [x] AvatarUpload → `forms::AvatarUpload`, built with Data Display (T12a): it is an Avatar you replace
## T06f Forms · Other
diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md
index 7a93e95..4f9c726 100644
--- a/tasks/ch11-20.md
+++ b/tasks/ch11-20.md
@@ -48,16 +48,19 @@
- [x] AnimatedGradient — slow tonal drift → `motion::AnimatedGradient`
- [x] Lottie Player — blocked: gpui 0.2.2 paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong
-## T12 Data Display — `src/data_display`
+## T12a Data Display · People & numbers — `src/data_display`
+
+- [x] Badge / CountBadge / DotBadge → `data_display::Badge`, `CountBadge`, `DotBadge`; the activity bar's count is a CountBadge
+- [x] Tag / Chip → `data_display::Tag`; the chips in TagInput and MultiSelect are Tags
+- [x] Avatar — image, initials, icon, presence — also T06e AvatarUpload → `data_display::Avatar`, `forms::AvatarUpload`
+- [x] AvatarGroup → `data_display::AvatarGroup`
+- [x] UserChip → `data_display::UserChip`
+- [x] Statistic / KPI Card → `data_display::Statistic`, `data_display::KpiCard`
+- [x] Metric → `data_display::Statistic`, at a smaller size
+- [x] TrendIndicator → `data_display::TrendIndicator`
+
+## T12b Data Display · Records
-- [ ] Badge / CountBadge / DotBadge
-- [ ] Tag / Chip
-- [ ] Avatar — image, initials, icon, presence — also T06e AvatarUpload
-- [ ] AvatarGroup
-- [ ] UserChip
-- [ ] Statistic / KPI Card
-- [ ] Metric
-- [ ] TrendIndicator
- [ ] DescriptionList
- [ ] PropertyGrid
- [ ] KeyValueList → `data_display::DescriptionList`
@@ -65,6 +68,9 @@
- [ ] Timeline / TimelineItem
- [ ] ActivityFeed
- [ ] Changelog
+
+## T12c Data Display · Media & measures
+
- [ ] Carousel
- [ ] Gallery
- [ ] Accordion list → `layout::Accordion`