use gpui::{
point, prelude::*, px, AbsoluteLength, AnyElement, App, Bounds, DefiniteLength, Display, Edges,
Element, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId, Length,
ParentElement, Pixels, Position, RenderOnce, Style, Styled, Window,
};
use herogpui_core::{Color, Size};
use herogpui_theme::{ActiveTheme, ThemeColors};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BadgePlacement {
TopLeft,
#[default]
TopRight,
BottomLeft,
BottomRight,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BadgeVariant {
#[default]
Primary,
Secondary,
Soft,
}
impl BadgeVariant {
pub const ALL: [BadgeVariant; 3] = [
BadgeVariant::Primary,
BadgeVariant::Secondary,
BadgeVariant::Soft,
];
pub fn label(self) -> &'static str {
match self {
BadgeVariant::Primary => "Primary",
BadgeVariant::Secondary => "Secondary",
BadgeVariant::Soft => "Soft",
}
}
}
#[derive(IntoElement)]
pub struct BadgeAnchor {
children: Vec<AnyElement>,
sx: Option<Box<gpui::StyleRefinement>>,
}
impl BadgeAnchor {
pub fn new() -> Self {
Self {
children: Vec::new(),
sx: None,
}
}
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
self.sx = Some(crate::util::capture_sx(style));
self
}
}
impl Default for BadgeAnchor {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for BadgeAnchor {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for BadgeAnchor {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let el = gpui::div()
.relative()
.flex()
.flex_shrink_0()
.debug_selector(|| "badge-anchor".to_owned())
.children(self.children);
crate::util::apply_sx(el, &self.sx)
}
}
#[derive(IntoElement)]
pub struct BadgeLabel {
children: Vec<AnyElement>,
sx: Option<Box<gpui::StyleRefinement>>,
}
impl BadgeLabel {
pub fn new() -> Self {
Self {
children: Vec::new(),
sx: None,
}
}
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
self.sx = Some(crate::util::capture_sx(style));
self
}
}
impl Default for BadgeLabel {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for BadgeLabel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for BadgeLabel {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let el = gpui::div()
.debug_selector(|| "badge-label".to_owned())
.px(px(2.))
.children(self.children);
crate::util::apply_sx(el, &self.sx)
}
}
#[derive(IntoElement)]
pub struct Badge {
color: Color,
variant: BadgeVariant,
size: Size,
placement: BadgePlacement,
children: Vec<AnyElement>,
text_size: Option<Pixels>,
radius: Option<Pixels>,
sx: Option<Box<gpui::StyleRefinement>>,
}
impl Badge {
pub fn new() -> Self {
Self {
color: Color::Default,
variant: BadgeVariant::Primary,
size: Size::Md,
placement: BadgePlacement::TopRight,
children: Vec::new(),
text_size: None,
radius: None,
sx: None,
}
}
pub fn color(mut self, c: Color) -> Self {
self.color = c;
self
}
pub fn variant(mut self, v: BadgeVariant) -> Self {
self.variant = v;
self
}
pub fn size(mut self, s: Size) -> Self {
self.size = s;
self
}
pub fn placement(mut self, p: BadgePlacement) -> Self {
self.placement = p;
self
}
pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
self.radius = Some(radius.into());
self
}
pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
self.text_size = Some(size.into());
self
}
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
self.sx = Some(crate::util::capture_sx(style));
self
}
}
impl Default for Badge {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for Badge {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
fn paint(colors: &ThemeColors, variant: BadgeVariant, color: Color) -> (Hsla, Hsla) {
let role = colors.role(color.token());
let muted_foreground = || {
if color == Color::Default {
colors.default.foreground
} else {
role.soft_foreground(colors.foreground)
}
};
match variant {
BadgeVariant::Primary => (role.color, role.foreground),
BadgeVariant::Secondary => (colors.default.color, muted_foreground()),
BadgeVariant::Soft => (role.soft(), muted_foreground()),
}
}
struct PlacedBadge {
placement: BadgePlacement,
badge: AnyElement,
}
impl Element for PlacedBadge {
type RequestLayoutState = LayoutId;
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let badge = self.badge.request_layout(window, cx);
let pinned = || Length::Definite(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.))));
let mut inset = Edges::<Length>::auto();
match self.placement {
BadgePlacement::TopRight => {
inset.top = pinned();
inset.right = pinned();
}
BadgePlacement::TopLeft => {
inset.top = pinned();
inset.left = pinned();
}
BadgePlacement::BottomRight => {
inset.bottom = pinned();
inset.right = pinned();
}
BadgePlacement::BottomLeft => {
inset.bottom = pinned();
inset.left = pinned();
}
}
let layout = window.request_layout(
Style {
position: Position::Absolute,
display: Display::Flex,
inset,
..Style::default()
},
[badge],
cx,
);
(layout, badge)
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
badge: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let size = window.layout_bounds(*badge).size;
let (dx, dy) = match self.placement {
BadgePlacement::TopRight => (size.width / 4., -(size.height / 4.)),
BadgePlacement::TopLeft => (-(size.width / 4.), -(size.height / 4.)),
BadgePlacement::BottomRight => (size.width / 4., size.height / 4.),
BadgePlacement::BottomLeft => (-(size.width / 4.), size.height / 4.),
};
window.with_element_offset(point(dx, dy), |window| {
self.badge.prepaint(window, cx);
});
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
self.badge.paint(window, cx);
}
}
impl IntoElement for PlacedBadge {
type Element = Self;
fn into_element(self) -> Self {
self
}
}
impl RenderOnce for Badge {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = cx.colors();
let (size_px, font, radius, leading) = match self.size {
Size::Sm => (
px(16.),
px(10.),
crate::util::small_radius(cx),
DefiniteLength::Fraction(1.34),
),
Size::Md => (
px(28.),
px(12.),
crate::util::control_radius(cx),
DefiniteLength::Fraction(1.34),
),
Size::Lg => (
px(32.),
px(14.),
crate::util::soft_radius(cx),
DefiniteLength::Fraction(1.43),
),
};
let radius = self.radius.unwrap_or(radius);
let font = self.text_size.unwrap_or(font);
let (top, bottom, left, right) = match self.placement {
BadgePlacement::TopRight => (Some(px(0.)), None, None, Some(px(0.))),
BadgePlacement::TopLeft => (Some(px(0.)), None, Some(px(0.)), None),
BadgePlacement::BottomRight => (None, Some(px(0.)), None, Some(px(0.))),
BadgePlacement::BottomLeft => (None, Some(px(0.)), Some(px(0.)), None),
};
let (bg, fg) = paint(colors, self.variant, self.color);
let badge = gpui::div()
.absolute()
.debug_selector(|| "badge".to_owned())
.min_w(size_px)
.min_h(size_px)
.gap(px(2.))
.rounded(radius)
.bg(bg)
.text_color(fg)
.text_size(font)
.line_height(leading)
.font_weight(gpui::FontWeight::MEDIUM)
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.border_1()
.border_color(colors.background)
.when_some(top, |b, t| b.top(t))
.when_some(bottom, |b, v| b.bottom(v))
.when_some(left, |b, l| b.left(l))
.when_some(right, |b, r| b.right(r));
let badge = if self.children.is_empty() {
badge.size(size_px).max_w(size_px)
} else {
badge.children(self.children)
};
PlacedBadge {
placement: self.placement,
badge: crate::util::apply_sx(badge, &self.sx).into_any_element(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paint_matrix_matches_the_badge_css_cascade() {
for colors in [ThemeColors::light(), ThemeColors::dark()] {
for color in Color::ALL {
let role = colors.role(color.token());
assert_eq!(
paint(&colors, BadgeVariant::Primary, color),
(role.color, role.foreground),
"primary×{color:?} must fill with the role and label in its foreground"
);
assert_eq!(
paint(&colors, BadgeVariant::Secondary, color).0,
colors.default.color,
"secondary×{color:?} must keep the default fill"
);
assert_eq!(
paint(&colors, BadgeVariant::Secondary, color).1,
if color == Color::Default {
colors.default.foreground
} else {
role.soft_foreground(colors.foreground)
},
"secondary×{color:?} must label in the colour's soft foreground"
);
assert_eq!(
paint(&colors, BadgeVariant::Soft, color),
(role.soft(), role.soft_foreground(colors.foreground)),
"soft×{color:?} must fill with the soft mix and label in the soft foreground"
);
assert!(
role.soft() != role.color,
"the soft fill of {color:?} must not equal the solid fill"
);
}
}
let colors = ThemeColors::light();
for (a, b) in [
(Color::Default, Color::Accent),
(Color::Accent, Color::Success),
(Color::Success, Color::Warning),
(Color::Warning, Color::Danger),
] {
assert_ne!(
colors.role(a.token()).color,
colors.role(b.token()).color,
"the {a:?} and {b:?} roles must not share a fill"
);
}
}
}