1use gpui::{
31 App, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Svg, Transformation,
32 Window, div, prelude::FluentBuilder, px, size,
33};
34use gpui_kit_assets::{Icon as Glyph, icon as glyph_svg};
35use gpui_kit_semantics::{NodeSpec, Role, Semantic};
36use gpui_kit_theme::{ActiveTheme, ControlSize, SemanticColor, TextTone, Theme};
37
38use crate::foundation::direction::{ActiveDirection, LayoutDirection};
39use crate::foundation::{Ident, Sizable};
40use crate::motion;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum IconTone {
48 #[default]
49 Primary,
50 Muted,
51 Faint,
52 OnAccent,
53 Accent,
54 AccentStrong,
55 Danger,
56 Warning,
57 Success,
58 Info,
59}
60
61impl IconTone {
62 pub fn color(self, theme: &Theme) -> Hsla {
63 match self {
64 Self::Primary => theme.text_color(TextTone::Primary),
65 Self::Muted => theme.text_color(TextTone::Muted),
66 Self::Faint => theme.text_color(TextTone::Faint),
67 Self::OnAccent => theme.text_color(TextTone::OnAccent),
68 Self::Accent => theme.semantic_color(SemanticColor::Accent),
69 Self::AccentStrong => theme.semantic_color(SemanticColor::AccentStrong),
70 Self::Danger => theme.semantic_color(SemanticColor::Danger),
71 Self::Warning => theme.semantic_color(SemanticColor::Warning),
72 Self::Success => theme.semantic_color(SemanticColor::Success),
73 Self::Info => theme.semantic_color(SemanticColor::Info),
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80enum Announcement {
81 Decorative,
83 Named { ident: Ident, name: SharedString },
85}
86
87#[derive(Debug, Clone, IntoElement)]
89pub struct Icon {
90 glyph: Glyph,
91 size: ControlSize,
92 tone: IconTone,
93 announcement: Announcement,
94 follow_direction: bool,
95 activity: Option<(motion::Activity, Ident)>,
98}
99
100impl Icon {
101 pub fn new(glyph: Glyph) -> Self {
103 Self {
104 glyph,
105 size: ControlSize::default(),
106 tone: IconTone::default(),
107 announcement: Announcement::Decorative,
108 follow_direction: true,
109 activity: None,
110 }
111 }
112
113 pub fn spinning(mut self, ident: impl Into<Ident>) -> Self {
124 self.activity = Some((motion::Activity::Working, ident.into()));
125 self
126 }
127
128 pub fn breathing(mut self, ident: impl Into<Ident>) -> Self {
131 self.activity = Some((motion::Activity::Deliberating, ident.into()));
132 self
133 }
134
135 pub fn named(ident: impl Into<Ident>, glyph: Glyph, name: impl Into<SharedString>) -> Self {
141 Self {
142 announcement: Announcement::Named {
143 ident: ident.into(),
144 name: name.into(),
145 },
146 ..Self::new(glyph)
147 }
148 }
149
150 pub fn tone(mut self, tone: IconTone) -> Self {
151 self.tone = tone;
152 self
153 }
154
155 pub fn muted(self) -> Self {
156 self.tone(IconTone::Muted)
157 }
158
159 pub fn faint(self) -> Self {
160 self.tone(IconTone::Faint)
161 }
162
163 pub fn on_accent(self) -> Self {
164 self.tone(IconTone::OnAccent)
165 }
166
167 pub fn accent(self) -> Self {
168 self.tone(IconTone::Accent)
169 }
170
171 pub fn danger(self) -> Self {
172 self.tone(IconTone::Danger)
173 }
174
175 pub fn warning(self) -> Self {
176 self.tone(IconTone::Warning)
177 }
178
179 pub fn success(self) -> Self {
180 self.tone(IconTone::Success)
181 }
182
183 pub fn info(self) -> Self {
184 self.tone(IconTone::Info)
185 }
186
187 pub fn follow_direction(mut self, follow: bool) -> Self {
195 self.follow_direction = follow;
196 self
197 }
198
199 pub fn resolved_size(&self, theme: &Theme) -> f32 {
201 theme.control.get(self.size).icon_size
202 }
203
204 pub fn resolved_color(&self, theme: &Theme) -> Hsla {
205 self.tone.color(theme)
206 }
207
208 pub fn flips_in(&self, direction: LayoutDirection) -> bool {
210 self.follow_direction && direction.is_rtl() && self.glyph.mirrors_in_rtl()
211 }
212}
213
214impl Sizable for Icon {
215 fn control_size(mut self, size: ControlSize) -> Self {
216 self.size = size;
217 self
218 }
219}
220
221impl RenderOnce for Icon {
222 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
223 let theme = cx.theme().clone();
224 let direction = cx.layout_direction();
225 let edge = self.resolved_size(&theme);
226 let drawing = paint(
227 self.glyph,
228 edge,
229 self.resolved_color(&theme),
230 self.flips_in(direction),
231 );
232 let drawing = match self.activity {
233 Some((motion::Activity::Working, ident)) => {
234 motion::spin(drawing, ident.element_id(), &theme, cx)
235 }
236 Some((_, ident)) => motion::breathe(drawing, ident.element_id(), &theme, cx),
237 None => drawing.into_any_element(),
238 };
239
240 match self.announcement {
241 Announcement::Decorative => drawing,
245 Announcement::Named { ident, name } => div()
246 .flex()
247 .flex_none()
248 .size(px(edge))
249 .child(drawing)
250 .semantic_in(
251 cx,
252 NodeSpec::new(ident.semantic_id(), Role::Image).text(name),
253 )
254 .into_any_element(),
255 }
256 }
257}
258
259pub fn paint(glyph: Glyph, edge: f32, color: Hsla, flipped: bool) -> Svg {
266 glyph_svg(glyph)
267 .size(px(edge))
268 .text_color(color)
269 .when(flipped, |svg| {
270 svg.with_transformation(Transformation::scale(size(-1.0, 1.0)))
271 })
272}
273
274pub fn flips(glyph: Glyph, direction: LayoutDirection) -> bool {
277 direction.is_rtl() && glyph.mirrors_in_rtl()
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn the_same_glyph_at_two_token_sizes_is_two_sizes() {
286 let theme = Theme::studio_dark();
287 let small = Icon::new(Glyph::Check).small();
288 let large = Icon::new(Glyph::Check).large();
289 assert_ne!(small.resolved_size(&theme), large.resolved_size(&theme));
290 assert!(small.resolved_size(&theme) < large.resolved_size(&theme));
291 assert_eq!(
294 Icon::new(Glyph::Check).medium().resolved_size(&theme),
295 theme.control.get(ControlSize::Md).icon_size
296 );
297 }
298
299 #[test]
300 fn a_tone_names_a_role_the_theme_carries() {
301 let theme = Theme::studio_dark();
302 assert_eq!(
303 Icon::new(Glyph::Danger).danger().resolved_color(&theme),
304 theme.colors.danger
305 );
306 assert_eq!(
307 Icon::new(Glyph::Check).muted().resolved_color(&theme),
308 theme.colors.text_muted
309 );
310 assert_ne!(
311 Icon::new(Glyph::Check).resolved_color(&theme),
312 Icon::new(Glyph::Check).muted().resolved_color(&theme)
313 );
314 }
315
316 #[test]
317 fn a_directional_glyph_flips_and_a_symbol_does_not() {
318 let rtl = LayoutDirection::RightToLeft;
319 let ltr = LayoutDirection::LeftToRight;
320 assert!(Icon::new(Glyph::AltArrowRight).flips_in(rtl));
321 assert!(!Icon::new(Glyph::AltArrowRight).flips_in(ltr));
322 assert!(!Icon::new(Glyph::Check).flips_in(rtl));
323 assert!(
325 !Icon::new(Glyph::AltArrowRight)
326 .follow_direction(false)
327 .flips_in(rtl)
328 );
329 }
330}