Skip to main content

gpui_kit/display/
icon.rs

1//! A glyph, drawn from the bundled catalog.
2//!
3//! This is a display component: it reads a [`Glyph`] the caller chose and
4//! paints it. It installs no handlers and emits nothing. A glyph that can be
5//! clicked is [`IconButton`](crate::controls::button::IconButton), which
6//! already exists and is not reimplemented here.
7//!
8//! Three things are deliberately not caller-supplied numbers:
9//!
10//! - **Size** comes from the token control scale, the same `iconSize` step
11//!   [`Button`](crate::controls::button::Button) already resolves, so a glyph
12//!   beside a small button is the size a small button's glyph is.
13//! - **Colour** comes from a semantic role, not an `Hsla`, so a glyph cannot
14//!   name a colour the theme does not have.
15//! - **Direction** comes from the active [`LayoutDirection`], and whether the
16//!   glyph responds to it comes from the drawing via
17//!   [`Glyph::mirrors_in_rtl`].
18//!
19//! # What assistive technology hears
20//!
21//! Most glyphs in a real interface sit next to a label that already says what
22//! they mean, and announcing both says everything twice. So [`Icon::new`] is
23//! decorative: it publishes no semantic node at all, and a reader walks past
24//! it. A glyph that is the only carrier of its meaning has to be named, and
25//! [`Icon::named`] asks for both an [`Ident`] and the name, because a picture
26//! has no text to fall back on and there is no safe guess. The default is the
27//! quiet one, so forgetting to decide produces a silent icon rather than a
28//! wrong announcement.
29
30use 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/// The semantic colour role a glyph paints in.
43///
44/// Every variant names a role the theme already carries, so a glyph never
45/// introduces a colour the token document has not authorised.
46#[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/// How a glyph reaches assistive technology.
79#[derive(Debug, Clone, PartialEq, Eq)]
80enum Announcement {
81    /// Repeats something already said next to it, and is skipped.
82    Decorative,
83    /// Carries the meaning on its own, and is announced under this name.
84    Named { ident: Ident, name: SharedString },
85}
86
87/// A glyph from the bundled catalog.
88#[derive(Debug, Clone, IntoElement)]
89pub struct Icon {
90    glyph: Glyph,
91    size: ControlSize,
92    tone: IconTone,
93    announcement: Announcement,
94    follow_direction: bool,
95    /// How this glyph reports work in progress, and the identity that
96    /// animation runs under. `None` is a glyph reporting a settled state.
97    activity: Option<(motion::Activity, Ident)>,
98}
99
100impl Icon {
101    /// A glyph that repeats an adjacent label, and is not announced.
102    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    /// Turns the glyph, for a state that is still running.
114    ///
115    /// This exists because the alternative kept being chosen by accident: a
116    /// rotation glyph is the obvious drawing for "working", and a rotation
117    /// glyph that does not rotate reads as one that has jammed. Whichever
118    /// component reports running work, it reports it the same way through
119    /// here.
120    ///
121    /// The identity is asked for rather than derived because a decorative
122    /// glyph has none, and an animation needs something stable to run under.
123    pub fn spinning(mut self, ident: impl Into<Ident>) -> Self {
124        self.activity = Some((motion::Activity::Working, ident.into()));
125        self
126    }
127
128    /// Breathes the glyph, for a state that is deliberating rather than
129    /// getting through work. The quieter of the two claims.
130    pub fn breathing(mut self, ident: impl Into<Ident>) -> Self {
131        self.activity = Some((motion::Activity::Deliberating, ident.into()));
132        self
133    }
134
135    /// A glyph that is the only thing saying what it means.
136    ///
137    /// The name is a constructor argument rather than an option for the same
138    /// reason [`IconButton`](crate::controls::button::IconButton)'s is: a
139    /// picture nobody can name is a picture nobody can address.
140    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    /// Whether the active reading direction may flip this glyph.
188    ///
189    /// On by default, and the glyph still decides: only a
190    /// [`Directional`](gpui_kit_assets::Mirroring::Directional) drawing ever
191    /// flips. Turning it off is for a caller that has already rotated the
192    /// glyph into another meaning, where a horizontal flip would land it
193    /// somewhere neither reading direction points.
194    pub fn follow_direction(mut self, follow: bool) -> Self {
195        self.follow_direction = follow;
196        self
197    }
198
199    /// The painted edge length, in pixels, at the active theme and size.
200    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    /// Whether this glyph would be drawn flipped in `direction`.
209    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            // A decorative glyph is the bare drawing: no wrapper, no node, and
242            // therefore nothing for a reader to stop on and nothing extra in
243            // the layout either.
244            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
259/// One glyph, sized, coloured, and flipped or not.
260///
261/// Exposed so a component that draws a glyph inside a frame it already owns —
262/// a disclosure triangle inside a hit target, a marker inside a step dot —
263/// gets the same reading-direction behaviour without wrapping another
264/// element around it.
265pub 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
274/// Whether `glyph` should be drawn flipped when the interface reads
275/// `direction`.
276pub 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        // The scale is the one buttons already resolve, so adopting it
292        // cannot move a glyph that a button drew.
293        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        // A caller that has already rotated the glyph can decline.
324        assert!(
325            !Icon::new(Glyph::AltArrowRight)
326                .follow_direction(false)
327                .flips_in(rtl)
328        );
329    }
330}