Files
Ely-GPUI-Components/src/data_display/badge.rs
T

343 lines
10 KiB
Rust

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.
pub(super) 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.
pub(super) 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()));
}
}