T12a Data Display: badges, tags, avatars, statistics (review pending: codex 401)
This commit is contained in:
24 files changed
+1434
-83
No files matched your search
@@ -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`).
|
- 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.
|
- 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.
|
- 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.
|
- `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
|
## Decisions
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T11b Loading · Progress & states
|
- [x] T11b Loading · Progress & states
|
||||||
- [x] T11c Motion · Presence & order
|
- [x] T11c Motion · Presence & order
|
||||||
- [x] T11d Motion · Effects
|
- [x] T11d Motion · Effects
|
||||||
- [ ] T12 Data Display
|
- [ ] T12a Data Display · People & numbers
|
||||||
|
- [ ] T12b Data Display · Records
|
||||||
|
- [ ] T12c Data Display · Media & measures
|
||||||
- [ ] T13 Lists & Trees
|
- [ ] T13 Lists & Trees
|
||||||
- [ ] T14 Tables
|
- [ ] T14 Tables
|
||||||
- [ ] T15 Charts
|
- [ ] 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 | 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 | 2 | FAIL | Marquee's doc still said its content is drawn twice |
|
||||||
| T11d | 3 | PASS | |
|
| T11d | 3 | PASS | |
|
||||||
|
| T12a | 1 | — | pending: codex answered 401 on every request; committed unreviewed, reviewed from its commit once codex logs in |
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-camera"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" />
|
||||||
|
<circle cx="12" cy="13" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 514 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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()
|
||||||
|
}
|
||||||
@@ -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::<PathBuf>, 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)),
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod buttons;
|
mod buttons;
|
||||||
|
mod data;
|
||||||
mod feedback;
|
mod feedback;
|
||||||
mod forms;
|
mod forms;
|
||||||
mod layout;
|
mod layout;
|
||||||
@@ -37,6 +38,7 @@ pub const ALL: &[Page] = &[
|
|||||||
overlays::PAGE,
|
overlays::PAGE,
|
||||||
feedback::PAGE,
|
feedback::PAGE,
|
||||||
motion::PAGE,
|
motion::PAGE,
|
||||||
|
data::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¬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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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.
|
/// 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;
|
let colors = &cx.theme().colors;
|
||||||
if takes {
|
if takes {
|
||||||
style.border_color(colors.focus).bg(colors.selection)
|
style.border_color(colors.focus).bg(colors.selection)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod avatar;
|
||||||
mod card;
|
mod card;
|
||||||
mod cascade;
|
mod cascade;
|
||||||
mod catalogs;
|
mod catalogs;
|
||||||
@@ -43,6 +44,7 @@ mod transfer;
|
|||||||
mod unit;
|
mod unit;
|
||||||
mod upload;
|
mod upload;
|
||||||
|
|
||||||
|
pub use avatar::AvatarUpload;
|
||||||
pub use card::{CheckboxCard, RadioCard};
|
pub use card::{CheckboxCard, RadioCard};
|
||||||
pub use cascade::{Cascade, Cascader};
|
pub use cascade::{Cascade, Cascader};
|
||||||
pub use catalogs::FontPicker;
|
pub use catalogs::FontPicker;
|
||||||
|
|||||||
+6
-7
@@ -10,9 +10,9 @@ use super::{
|
|||||||
listbox::chosen,
|
listbox::chosen,
|
||||||
options::{OnValues, Pick, Popup, step},
|
options::{OnValues, Pick, Popup, step},
|
||||||
select::{Picker, measure_anchor, moved},
|
select::{Picker, measure_anchor, moved},
|
||||||
tags::chip,
|
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
data_display::Tag,
|
||||||
primitives::{Icon, IconName, tab_stop},
|
primitives::{Icon, IconName, tab_stop},
|
||||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||||
};
|
};
|
||||||
@@ -125,14 +125,13 @@ impl RenderOnce for MultiSelect {
|
|||||||
.map(|(ix, choice)| {
|
.map(|(ix, choice)| {
|
||||||
let (choices, selected, commit) =
|
let (choices, selected, commit) =
|
||||||
(choices.clone(), selected.clone(), commit.clone());
|
(choices.clone(), selected.clone(), commit.clone());
|
||||||
chip(
|
Tag::new(
|
||||||
("chip-remove", ix).into(),
|
(self.id.clone(), format!("chip-{ix}")),
|
||||||
choice.label.clone(),
|
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();
|
.collect();
|
||||||
let (click, keys, rows, close) = (
|
let (click, keys, rows, close) = (
|
||||||
|
|||||||
+9
-47
@@ -1,15 +1,14 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
|
RenderOnce, SharedString, Styled, Subscription, Window, div, prelude::*,
|
||||||
Window, div, prelude::*,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{InputEvent, TextInput, options::Run, text::Backspace};
|
use super::{InputEvent, TextInput, text::Backspace};
|
||||||
use crate::{
|
use crate::{
|
||||||
primitives::{Icon, IconName},
|
data_display::Tag,
|
||||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnChange = Rc<dyn Fn(Vec<SharedString>, &mut Window, &mut App)>;
|
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.
|
/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct TagInput {
|
pub struct TagInput {
|
||||||
@@ -145,20 +109,18 @@ impl RenderOnce for TagInput {
|
|||||||
let focus = input.read(cx).focus().clone();
|
let focus = input.read(cx).focus().clone();
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
|
let owner = self.id.clone();
|
||||||
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
|
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
|
||||||
let (tags, on_change) = (self.tags.clone(), self.on_change.clone());
|
let (tags, on_change) = (self.tags.clone(), self.on_change.clone());
|
||||||
chip(
|
Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone()).on_remove(
|
||||||
("tag-remove", ix).into(),
|
move |window, cx| {
|
||||||
tag.clone(),
|
|
||||||
Rc::new(move |window, cx| {
|
|
||||||
let mut next = tags.clone();
|
let mut next = tags.clone();
|
||||||
next.remove(ix);
|
next.remove(ix);
|
||||||
log::info!("tag input: removed one, {} left", next.len());
|
log::info!("tag input: removed one, {} left", next.len());
|
||||||
if let Some(on_change) = &on_change {
|
if let Some(on_change) = &on_change {
|
||||||
on_change(next, window, cx);
|
on_change(next, window, cx);
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
cx,
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let (tags, on_change, empty_check) =
|
let (tags, on_change, empty_check) =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod assets;
|
mod assets;
|
||||||
|
|
||||||
pub mod buttons;
|
pub mod buttons;
|
||||||
|
pub mod data_display;
|
||||||
pub mod feedback;
|
pub mod feedback;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ icons! {
|
|||||||
CalendarDays => "calendar-days",
|
CalendarDays => "calendar-days",
|
||||||
CalendarRange => "calendar-range",
|
CalendarRange => "calendar-range",
|
||||||
Calendar => "calendar",
|
Calendar => "calendar",
|
||||||
|
Camera => "camera",
|
||||||
ChartBar => "chart-bar",
|
ChartBar => "chart-bar",
|
||||||
ChartCandlestick => "chart-candlestick",
|
ChartCandlestick => "chart-candlestick",
|
||||||
ChartLine => "chart-line",
|
ChartLine => "chart-line",
|
||||||
|
|||||||
+6
-14
@@ -6,9 +6,10 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
data_display::CountBadge,
|
||||||
motion,
|
motion,
|
||||||
primitives::{Icon, IconName, Tooltip},
|
primitives::{Icon, IconName, Tooltip},
|
||||||
theme::{ActiveTheme, IconSize, TextSize},
|
theme::{ActiveTheme, IconSize},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One destination on an activity bar.
|
/// One destination on an activity bar.
|
||||||
@@ -150,19 +151,10 @@ impl RenderOnce for ActivityBar {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when_some(item.badge, |entry, count| {
|
.when_some(item.badge, |entry, count| {
|
||||||
entry.child(
|
entry.child(div().absolute().top_1().right_1().child(CountBadge::new(
|
||||||
div()
|
SharedString::from(format!("activity-badge-{}", item.id)),
|
||||||
.absolute()
|
count as usize,
|
||||||
.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())),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let column = || div().flex().flex_col();
|
let column = || div().flex().flex_col();
|
||||||
|
|||||||
+3
-1
@@ -7,7 +7,9 @@ use gpui::{App, Global, SharedString, WindowAppearance};
|
|||||||
|
|
||||||
use crate::motion;
|
use crate::motion;
|
||||||
pub use palette::{Mix, Palette, Syntax};
|
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);
|
const FRAME: Duration = Duration::from_millis(8);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ pub enum ControlSize {
|
|||||||
Lg,
|
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)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum IconSize {
|
pub enum IconSize {
|
||||||
Xs,
|
Xs,
|
||||||
@@ -97,6 +108,16 @@ impl Theme {
|
|||||||
px_to_rems(base + delta)
|
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 {
|
pub fn icon_size(&self, size: IconSize) -> Rems {
|
||||||
let base = match size {
|
let base = match size {
|
||||||
IconSize::Xs => 12.0,
|
IconSize::Xs => 12.0,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, ParentElement,
|
Animation, AnimationExt, App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement,
|
||||||
RenderOnce, SharedString, Styled, Window, div,
|
RenderOnce, SharedString, Styled, Window, div,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ pub struct AnimatedNumber {
|
|||||||
value: f64,
|
value: f64,
|
||||||
decimals: usize,
|
decimals: usize,
|
||||||
pad: usize,
|
pad: usize,
|
||||||
|
color: Option<Hsla>,
|
||||||
tween: Tween,
|
tween: Tween,
|
||||||
size: TextSize,
|
size: TextSize,
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,7 @@ impl AnimatedNumber {
|
|||||||
value,
|
value,
|
||||||
decimals: 0,
|
decimals: 0,
|
||||||
pad: 0,
|
pad: 0,
|
||||||
|
color: None,
|
||||||
tween: Tween::Roll,
|
tween: Tween::Roll,
|
||||||
size: TextSize::Xxl,
|
size: TextSize::Xxl,
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,12 @@ impl AnimatedNumber {
|
|||||||
self
|
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.
|
/// Counts through the values instead of rolling digits.
|
||||||
pub fn count_up(mut self) -> Self {
|
pub fn count_up(mut self) -> Self {
|
||||||
self.tween = Tween::Count;
|
self.tween = Tween::Count;
|
||||||
@@ -124,7 +132,7 @@ impl RenderOnce for AnimatedNumber {
|
|||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.text_size(size)
|
.text_size(size)
|
||||||
.line_height(line)
|
.line_height(line)
|
||||||
.text_color(theme.colors.fg);
|
.text_color(self.color.unwrap_or(theme.colors.fg));
|
||||||
|
|
||||||
if self.tween == Tween::Count {
|
if self.tween == Tween::Count {
|
||||||
return row.child(div().with_animation(
|
return row.child(div().with_animation(
|
||||||
|
|||||||
+1
-1
@@ -250,7 +250,7 @@
|
|||||||
- [x] DropZone → `forms::DropZone`
|
- [x] DropZone → `forms::DropZone`
|
||||||
- [x] UploadList → `forms::UploadList`, built with Loading & Motion (T11b): its rows are ProgressBars
|
- [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] 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
|
## T06f Forms · Other
|
||||||
|
|
||||||
|
|||||||
+15
-9
@@ -48,16 +48,19 @@
|
|||||||
- [x] AnimatedGradient — slow tonal drift → `motion::AnimatedGradient`
|
- [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
|
- [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
|
- [ ] DescriptionList
|
||||||
- [ ] PropertyGrid
|
- [ ] PropertyGrid
|
||||||
- [ ] KeyValueList → `data_display::DescriptionList`
|
- [ ] KeyValueList → `data_display::DescriptionList`
|
||||||
@@ -65,6 +68,9 @@
|
|||||||
- [ ] Timeline / TimelineItem
|
- [ ] Timeline / TimelineItem
|
||||||
- [ ] ActivityFeed
|
- [ ] ActivityFeed
|
||||||
- [ ] Changelog
|
- [ ] Changelog
|
||||||
|
|
||||||
|
## T12c Data Display · Media & measures
|
||||||
|
|
||||||
- [ ] Carousel
|
- [ ] Carousel
|
||||||
- [ ] Gallery
|
- [ ] Gallery
|
||||||
- [ ] Accordion list → `layout::Accordion`
|
- [ ] Accordion list → `layout::Accordion`
|
||||||
|
|||||||
Reference in new issue
Block a user