use gpui::{
App, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Svg, Transformation,
Window, div, prelude::FluentBuilder, px, size,
};
use gpui_kit_assets::{Icon as Glyph, icon as glyph_svg};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, SemanticColor, TextTone, Theme};
use crate::foundation::direction::{ActiveDirection, LayoutDirection};
use crate::foundation::{Ident, Sizable};
use crate::motion;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IconTone {
#[default]
Primary,
Muted,
Faint,
OnAccent,
Accent,
AccentStrong,
Danger,
Warning,
Success,
Info,
}
impl IconTone {
pub fn color(self, theme: &Theme) -> Hsla {
match self {
Self::Primary => theme.text_color(TextTone::Primary),
Self::Muted => theme.text_color(TextTone::Muted),
Self::Faint => theme.text_color(TextTone::Faint),
Self::OnAccent => theme.text_color(TextTone::OnAccent),
Self::Accent => theme.semantic_color(SemanticColor::Accent),
Self::AccentStrong => theme.semantic_color(SemanticColor::AccentStrong),
Self::Danger => theme.semantic_color(SemanticColor::Danger),
Self::Warning => theme.semantic_color(SemanticColor::Warning),
Self::Success => theme.semantic_color(SemanticColor::Success),
Self::Info => theme.semantic_color(SemanticColor::Info),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Announcement {
Decorative,
Named { ident: Ident, name: SharedString },
}
#[derive(Debug, Clone, IntoElement)]
pub struct Icon {
glyph: Glyph,
size: ControlSize,
tone: IconTone,
announcement: Announcement,
follow_direction: bool,
activity: Option<(motion::Activity, Ident)>,
}
impl Icon {
pub fn new(glyph: Glyph) -> Self {
Self {
glyph,
size: ControlSize::default(),
tone: IconTone::default(),
announcement: Announcement::Decorative,
follow_direction: true,
activity: None,
}
}
pub fn spinning(mut self, ident: impl Into<Ident>) -> Self {
self.activity = Some((motion::Activity::Working, ident.into()));
self
}
pub fn breathing(mut self, ident: impl Into<Ident>) -> Self {
self.activity = Some((motion::Activity::Deliberating, ident.into()));
self
}
pub fn named(ident: impl Into<Ident>, glyph: Glyph, name: impl Into<SharedString>) -> Self {
Self {
announcement: Announcement::Named {
ident: ident.into(),
name: name.into(),
},
..Self::new(glyph)
}
}
pub fn tone(mut self, tone: IconTone) -> Self {
self.tone = tone;
self
}
pub fn muted(self) -> Self {
self.tone(IconTone::Muted)
}
pub fn faint(self) -> Self {
self.tone(IconTone::Faint)
}
pub fn on_accent(self) -> Self {
self.tone(IconTone::OnAccent)
}
pub fn accent(self) -> Self {
self.tone(IconTone::Accent)
}
pub fn danger(self) -> Self {
self.tone(IconTone::Danger)
}
pub fn warning(self) -> Self {
self.tone(IconTone::Warning)
}
pub fn success(self) -> Self {
self.tone(IconTone::Success)
}
pub fn info(self) -> Self {
self.tone(IconTone::Info)
}
pub fn follow_direction(mut self, follow: bool) -> Self {
self.follow_direction = follow;
self
}
pub fn resolved_size(&self, theme: &Theme) -> f32 {
theme.control.get(self.size).icon_size
}
pub fn resolved_color(&self, theme: &Theme) -> Hsla {
self.tone.color(theme)
}
pub fn flips_in(&self, direction: LayoutDirection) -> bool {
self.follow_direction && direction.is_rtl() && self.glyph.mirrors_in_rtl()
}
}
impl Sizable for Icon {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for Icon {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let direction = cx.layout_direction();
let edge = self.resolved_size(&theme);
let drawing = paint(
self.glyph,
edge,
self.resolved_color(&theme),
self.flips_in(direction),
);
let drawing = match self.activity {
Some((motion::Activity::Working, ident)) => {
motion::spin(drawing, ident.element_id(), &theme, cx)
}
Some((_, ident)) => motion::breathe(drawing, ident.element_id(), &theme, cx),
None => drawing.into_any_element(),
};
match self.announcement {
Announcement::Decorative => drawing,
Announcement::Named { ident, name } => div()
.flex()
.flex_none()
.size(px(edge))
.child(drawing)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Image).text(name),
)
.into_any_element(),
}
}
}
pub fn paint(glyph: Glyph, edge: f32, color: Hsla, flipped: bool) -> Svg {
glyph_svg(glyph)
.size(px(edge))
.text_color(color)
.when(flipped, |svg| {
svg.with_transformation(Transformation::scale(size(-1.0, 1.0)))
})
}
pub fn flips(glyph: Glyph, direction: LayoutDirection) -> bool {
direction.is_rtl() && glyph.mirrors_in_rtl()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_same_glyph_at_two_token_sizes_is_two_sizes() {
let theme = Theme::studio_dark();
let small = Icon::new(Glyph::Check).small();
let large = Icon::new(Glyph::Check).large();
assert_ne!(small.resolved_size(&theme), large.resolved_size(&theme));
assert!(small.resolved_size(&theme) < large.resolved_size(&theme));
assert_eq!(
Icon::new(Glyph::Check).medium().resolved_size(&theme),
theme.control.get(ControlSize::Md).icon_size
);
}
#[test]
fn a_tone_names_a_role_the_theme_carries() {
let theme = Theme::studio_dark();
assert_eq!(
Icon::new(Glyph::Danger).danger().resolved_color(&theme),
theme.colors.danger
);
assert_eq!(
Icon::new(Glyph::Check).muted().resolved_color(&theme),
theme.colors.text_muted
);
assert_ne!(
Icon::new(Glyph::Check).resolved_color(&theme),
Icon::new(Glyph::Check).muted().resolved_color(&theme)
);
}
#[test]
fn a_directional_glyph_flips_and_a_symbol_does_not() {
let rtl = LayoutDirection::RightToLeft;
let ltr = LayoutDirection::LeftToRight;
assert!(Icon::new(Glyph::AltArrowRight).flips_in(rtl));
assert!(!Icon::new(Glyph::AltArrowRight).flips_in(ltr));
assert!(!Icon::new(Glyph::Check).flips_in(rtl));
assert!(
!Icon::new(Glyph::AltArrowRight)
.follow_direction(false)
.flips_in(rtl)
);
}
}