T12a Data Display: badges, tags, avatars, statistics (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 18:41:58 -04:00
1 parent 76da4bd39e
commit dc1d282fca
24 files changed
+1434 -83

No files matched your search

+304
View File
@@ -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<ImageSource>,
icon: Option<IconName>,
size: AvatarSize,
square: bool,
ring: bool,
presence: Option<Presence>,
}
impl Avatar {
/// The name gives the initials and the tone when there is no picture.
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> 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<ImageSource>) -> 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<Avatar>,
max: usize,
size: AvatarSize,
}
impl AvatarGroup {
pub fn new(people: impl IntoIterator<Item = Avatar>) -> 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<ElementId>, name: impl Into<SharedString>) -> Self {
let name = name.into();
Self {
avatar: Avatar::new(id, name.clone()).size(AvatarSize::Xs),
name,
}
}
pub fn image(mut self, source: impl Into<ImageSource>) -> 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");
}
}
+342
View File
@@ -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<Severity> 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<SharedString>) -> Self {
Self {
label: label.into(),
tone: Tone::default(),
dot: false,
}
}
pub fn tone(mut self, tone: impl Into<Tone>) -> 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<String> {
(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<AnyElement>,
}
impl CountBadge {
pub fn new(id: impl Into<ElementId>, 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<AnyElement>,
}
impl DotBadge {
pub fn new() -> Self {
Self {
tone: Tone::Accent,
over: None,
}
}
pub fn tone(mut self, tone: impl Into<Tone>) -> 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<dyn Fn(&mut Window, &mut App)>;
/// 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<IconName>,
tone: Tone,
on_remove: Option<OnRemove>,
}
impl Tag {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> 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<Tone>) -> 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()));
}
}
+7
View File
@@ -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};
+237
View File
@@ -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<bool> {
(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<SharedString>,
suffix: Option<SharedString>,
trend: Option<TrendIndicator>,
size: TextSize,
}
impl Statistic {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, 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<SharedString>) -> Self {
self.prefix = Some(text.into());
self
}
/// After the number, as a unit.
pub fn suffix(mut self, text: impl Into<SharedString>) -> 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<IconName>,
caption: Option<SharedString>,
}
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<SharedString>) -> 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);
}
}
+163
View File
@@ -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<PathBuf, &'static str> {
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<dyn Fn(PathBuf, &mut Window, &mut App)>;
/// 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<OnPicture>,
}
impl AvatarUpload {
pub fn new(id: impl Into<ElementId>, 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::<ExternalPaths>(|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(&notes, 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"));
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub(crate) fn dropped(paths: &[PathBuf], multiple: bool) -> Result<Vec<PathBuf>,
}
/// 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)
+2
View File
@@ -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;
+6 -7
View File
@@ -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) = (
+9 -47
View File
@@ -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<dyn Fn(Vec<SharedString>, &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) =
+1
View File
@@ -1,6 +1,7 @@
mod assets;
pub mod buttons;
pub mod data_display;
pub mod feedback;
pub mod forms;
pub mod layout;
+1
View File
@@ -62,6 +62,7 @@ icons! {
CalendarDays => "calendar-days",
CalendarRange => "calendar-range",
Calendar => "calendar",
Camera => "camera",
ChartBar => "chart-bar",
ChartCandlestick => "chart-candlestick",
ChartLine => "chart-line",
+6 -14
View File
@@ -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();
+3 -1
View File
@@ -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);
+21
View File
@@ -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,
+10 -2
View File
@@ -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<Hsla>,
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(