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`