Skip to main content

herogpui_core/
enums.rs

1//! Shared enums for HeroGPUI — the v3 prop vocabularies.
2//!
3//! v3 does not use one variant enum everywhere. It uses a small number of
4//! distinct vocabularies, each modelled separately here so an invalid
5//! combination cannot be expressed:
6//!
7//! * [`Variant`] — button emphasis (`primary | secondary | … | danger`)
8//! * [`FieldVariant`] — form-control emphasis (`primary | secondary`)
9//! * [`Prominence`] — container prominence (`transparent | default | …`)
10//! * [`Backdrop`] — overlay scrim style (`opaque | blur | transparent`)
11//! * [`Color`] — semantic color role (`default | accent | … | danger`)
12
13/// Semantic color roles — HeroUI v3.
14///
15/// `Accent` is the brand color (v2 `primary`). `Secondary` as a *color* was
16/// removed in v3; the `secondary` *variant* uses `default`.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18pub enum Color {
19    #[default]
20    /// The neutral role.
21    Default,
22    /// The brand role (v2 `primary`).
23    Accent,
24    /// The success role.
25    Success,
26    /// The warning role.
27    Warning,
28    /// The danger role.
29    Danger,
30}
31
32impl Color {
33    /// Every color role, in declaration order.
34    pub const ALL: [Color; 5] = [
35        Color::Default,
36        Color::Accent,
37        Color::Success,
38        Color::Warning,
39        Color::Danger,
40    ];
41
42    /// The v3 token name of this role.
43    pub fn token(self) -> &'static str {
44        match self {
45            Color::Default => "default",
46            Color::Accent => "accent",
47            Color::Success => "success",
48            Color::Warning => "warning",
49            Color::Danger => "danger",
50        }
51    }
52
53    /// A human-readable label for this role, e.g. `"Accent"`.
54    pub fn label(self) -> &'static str {
55        match self {
56            Color::Default => "Default",
57            Color::Accent => "Accent",
58            Color::Success => "Success",
59            Color::Warning => "Warning",
60            Color::Danger => "Danger",
61        }
62    }
63
64    /// Parses a v3 token name (`"default"`, `"accent"`, `"success"`,
65    /// `"warning"`, `"danger"`), the inverse of [`Color::token`].
66    ///
67    /// Exact and case-sensitive, like the CSS variable names it mirrors. An
68    /// unknown name is `None`; nothing falls back to `accent`.
69    pub fn from_token(token: &str) -> Option<Color> {
70        Color::ALL.into_iter().find(|c| c.token() == token)
71    }
72}
73
74/// The error [`Color`]'s [`FromStr`](std::str::FromStr) returns for a name
75/// that is not one of the five v3 role tokens.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct UnknownColorError {
78    /// The rejected name.
79    pub name: String,
80}
81
82impl std::fmt::Display for UnknownColorError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(
85            f,
86            "unknown colour role {:?} (expected default, accent, success, warning or danger)",
87            self.name
88        )
89    }
90}
91
92impl std::error::Error for UnknownColorError {}
93
94/// `"success".parse::<Color>()`; see [`Color::from_token`].
95impl std::str::FromStr for Color {
96    type Err = UnknownColorError;
97
98    fn from_str(s: &str) -> Result<Self, Self::Err> {
99        Color::from_token(s).ok_or_else(|| UnknownColorError { name: s.to_owned() })
100    }
101}
102
103/// Button emphasis variant — `Button`, and the vocabulary `ButtonGroup`
104/// inherits to its direct children (the pinned group context takes
105/// `ButtonProps["variant"]`, every value on this enum).
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
107pub enum Variant {
108    /// Filled with `accent`.
109    #[default]
110    Primary,
111    /// Filled with `default`, accent-tinted label.
112    Secondary,
113    /// Transparent until hovered.
114    Tertiary,
115    /// Bordered, transparent fill.
116    Outline,
117    /// No border, no fill; soft hover only.
118    Ghost,
119    /// Filled with `danger`.
120    Danger,
121    /// `danger` at 15% over the surface, with danger-colored text.
122    DangerSoft,
123}
124
125impl Variant {
126    /// Every button variant, in declaration order.
127    pub const ALL: [Variant; 7] = [
128        Variant::Primary,
129        Variant::Secondary,
130        Variant::Tertiary,
131        Variant::Outline,
132        Variant::Ghost,
133        Variant::Danger,
134        Variant::DangerSoft,
135    ];
136
137    /// Every Button variant can be inherited by `ButtonGroup` members.
138    pub const GROUP: [Variant; 7] = Self::ALL;
139
140    /// A human-readable label for this variant.
141    pub fn label(self) -> &'static str {
142        match self {
143            Variant::Primary => "Primary",
144            Variant::Secondary => "Secondary",
145            Variant::Tertiary => "Tertiary",
146            Variant::Outline => "Outline",
147            Variant::Ghost => "Ghost",
148            Variant::Danger => "Danger",
149            Variant::DangerSoft => "Danger Soft",
150        }
151    }
152}
153
154/// Form-control emphasis — `primary` carries the field shadow, `secondary` is
155/// the flat low-emphasis style for use inside a `Surface`.
156#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
157pub enum FieldVariant {
158    #[default]
159    /// Carries the field shadow.
160    Primary,
161    /// Flat low-emphasis style, for use inside a `Surface`.
162    Secondary,
163}
164
165impl FieldVariant {
166    /// Every field variant, in declaration order.
167    pub const ALL: [FieldVariant; 2] = [FieldVariant::Primary, FieldVariant::Secondary];
168
169    /// A human-readable label for this variant.
170    pub fn label(self) -> &'static str {
171        match self {
172            FieldVariant::Primary => "Primary",
173            FieldVariant::Secondary => "Secondary",
174        }
175    }
176}
177
178/// Container prominence — `Surface` and `Card`. [`Separator`] uses the same
179/// ladder minus `transparent`.
180///
181/// [`Separator`]: ../herogpui_components/struct.Separator.html
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
183pub enum Prominence {
184    /// No background — for overlays and custom-painted containers.
185    Transparent,
186    /// `bg-surface`
187    #[default]
188    Default,
189    /// `bg-surface-secondary`
190    Secondary,
191    /// `bg-surface-tertiary`
192    Tertiary,
193}
194
195impl Prominence {
196    /// Every prominence level, in declaration order.
197    pub const ALL: [Prominence; 4] = [
198        Prominence::Transparent,
199        Prominence::Default,
200        Prominence::Secondary,
201        Prominence::Tertiary,
202    ];
203
204    /// A human-readable label for this prominence.
205    pub fn label(self) -> &'static str {
206        match self {
207            Prominence::Transparent => "Transparent",
208            Prominence::Default => "Default",
209            Prominence::Secondary => "Secondary",
210            Prominence::Tertiary => "Tertiary",
211        }
212    }
213}
214
215/// Overlay scrim style — `Modal`, `Drawer` and `AlertDialog`.
216#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
217pub enum Backdrop {
218    #[default]
219    /// A solid, opaque scrim.
220    Opaque,
221    /// A blurred scrim.
222    Blur,
223    /// No visible scrim.
224    Transparent,
225}
226
227impl Backdrop {
228    /// Every backdrop style, in declaration order.
229    pub const ALL: [Backdrop; 3] = [Backdrop::Opaque, Backdrop::Blur, Backdrop::Transparent];
230
231    /// A human-readable label for this backdrop.
232    pub fn label(self) -> &'static str {
233        match self {
234            Backdrop::Opaque => "Opaque",
235            Backdrop::Blur => "Blur",
236            Backdrop::Transparent => "Transparent",
237        }
238    }
239}
240
241/// The `sm | md | lg` scale used by most components.
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
243pub enum Size {
244    /// Small.
245    Sm,
246    #[default]
247    /// Medium.
248    Md,
249    /// Large.
250    Lg,
251}
252
253impl Size {
254    /// Every size, in declaration order.
255    pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
256
257    /// Control height: sm 32px, md 36px, lg 40px.
258    ///
259    /// These are v3's *desktop* heights. Its sheet is mobile-first — `.button`
260    /// is `h-10 md:h-9`, `.button--sm` is `h-9 md:h-8`, `.button--lg` is
261    /// `h-11 md:h-10` — and a desktop app is past every breakpoint, so the `md`
262    /// value is the one to match. Reading the base value made every control a
263    /// step too tall.
264    pub fn control_height(self) -> gpui::Pixels {
265        match self {
266            Size::Sm => gpui::px(32.0),
267            Size::Md => gpui::px(36.0),
268            Size::Lg => gpui::px(40.0),
269        }
270    }
271
272    /// Icon-only controls are square at the control height.
273    pub fn icon_control_size(self) -> gpui::Pixels {
274        self.control_height()
275    }
276
277    /// The `text-xs`/`text-sm`/`text-base` ladder: sm 12px, md 14px, lg 16px.
278    ///
279    /// Not every sized component steps its type on every rung — `.button` only
280    /// steps at `lg` — so a component reads its own stylesheet rather than
281    /// assuming this one.
282    pub fn text_size(self) -> gpui::Pixels {
283        match self {
284            Size::Sm => gpui::px(12.0),
285            Size::Md => gpui::px(14.0),
286            Size::Lg => gpui::px(16.0),
287        }
288    }
289
290    /// A human-readable label for this size, e.g. `"Small"`.
291    pub fn label(self) -> &'static str {
292        match self {
293            Size::Sm => "Small",
294            Size::Md => "Medium",
295            Size::Lg => "Large",
296        }
297    }
298}
299
300/// The `xs | sm | md | lg | xl` scale used by `Spinner`, `ColorSwatch` and
301/// `ColorSwatchPicker`.
302#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
303pub enum SizeXl {
304    /// Extra small.
305    Xs,
306    /// Small.
307    Sm,
308    #[default]
309    /// Medium.
310    Md,
311    /// Large.
312    Lg,
313    /// Extra large.
314    Xl,
315}
316
317impl SizeXl {
318    /// Every size, in declaration order.
319    pub const ALL: [SizeXl; 5] = [SizeXl::Xs, SizeXl::Sm, SizeXl::Md, SizeXl::Lg, SizeXl::Xl];
320
321    /// A colour swatch's edge: `size-4 / 6 / 8 / 9 / 10` from
322    /// `color-swatch.css`, so 16 / 24 / 32 / 36 / 40.
323    ///
324    /// Named for the component on purpose: v3 declares its sizes per sheet, and
325    /// a swatch's `sm` (24px) is not a spinner's (16px, and `Spinner` has its own
326    /// `SpinnerSize` for exactly that reason). The shared `px()` this replaces
327    /// was 16/20/24/32/40 and matched neither sheet.
328    pub fn swatch_px(self) -> gpui::Pixels {
329        match self {
330            SizeXl::Xs => gpui::px(16.0),
331            SizeXl::Sm => gpui::px(24.0),
332            SizeXl::Md => gpui::px(32.0),
333            SizeXl::Lg => gpui::px(36.0),
334            SizeXl::Xl => gpui::px(40.0),
335        }
336    }
337
338    /// A human-readable label for this size.
339    pub fn label(self) -> &'static str {
340        match self {
341            SizeXl::Xs => "Xs",
342            SizeXl::Sm => "Sm",
343            SizeXl::Md => "Md",
344            SizeXl::Lg => "Lg",
345            SizeXl::Xl => "Xl",
346        }
347    }
348}
349
350/// Orientation for separators, toolbars, sliders and groups.
351#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
352pub enum Orientation {
353    #[default]
354    /// Laid out along the horizontal axis.
355    Horizontal,
356    /// Laid out along the vertical axis.
357    Vertical,
358}
359
360impl Orientation {
361    /// Every orientation, in declaration order.
362    pub const ALL: [Orientation; 2] = [Orientation::Horizontal, Orientation::Vertical];
363
364    /// Whether this is `Horizontal`.
365    pub fn is_horizontal(self) -> bool {
366        matches!(self, Orientation::Horizontal)
367    }
368
369    /// A human-readable label for this orientation.
370    pub fn label(self) -> &'static str {
371        match self {
372            Orientation::Horizontal => "Horizontal",
373            Orientation::Vertical => "Vertical",
374        }
375    }
376}
377
378/// How many items a collection lets the user select.
379#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
380pub enum SelectionMode {
381    /// No item can be selected.
382    None,
383    #[default]
384    /// At most one item can be selected.
385    Single,
386    /// Any number of items can be selected.
387    Multiple,
388}
389
390/// How a multi-select collection changes selection when an item is activated.
391///
392/// This is React Stately's `selectionBehavior` prop. `Toggle` preserves the
393/// existing selection and toggles the activated key; `Replace` makes a plain
394/// pointer or keyboard activation the sole selection. Single-selection
395/// collections always retain their own replace/toggle semantics regardless of
396/// this value.
397#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
398pub enum SelectionBehavior {
399    /// Add or remove the activated key while preserving other selected keys.
400    #[default]
401    Toggle,
402    /// Replace the current multi-selection with the activated key.
403    Replace,
404}
405
406impl SelectionBehavior {
407    /// Every selection behavior, in declaration order.
408    pub const ALL: [SelectionBehavior; 2] = [SelectionBehavior::Toggle, SelectionBehavior::Replace];
409
410    /// A human-readable label for this behavior.
411    pub fn label(self) -> &'static str {
412        match self {
413            SelectionBehavior::Toggle => "Toggle",
414            SelectionBehavior::Replace => "Replace",
415        }
416    }
417}
418
419/// `placement` — where a floating panel sits relative to its trigger.
420///
421/// The full React Aria union v3 forwards: both physical spellings
422/// (`"bottom left"`, `"bottom right"`) and logical aliases (`"start"`,
423/// `"end top"`, …) for every side. This port has no RTL mode, so the logical
424/// start/end aliases resolve to the same pixels as their left/right
425/// spellings; the spellings stay distinct values so `ALL` enumerates the
426/// whole 22-value vocabulary a caller can name.
427#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
428pub enum Placement {
429    /// `"bottom"` — below the trigger, centred.
430    Bottom,
431    /// `"bottom start"` — below the trigger, flush with its start edge.
432    #[default]
433    BottomStart,
434    /// `"bottom left"` — below the trigger, flush with its left edge; the
435    /// physical spelling of [`Placement::BottomStart`] in this LTR-only port.
436    BottomLeft,
437    /// `"bottom end"` — below the trigger, flush with its end edge.
438    BottomEnd,
439    /// `"bottom right"` — below the trigger, flush with its right edge; the
440    /// physical spelling of [`Placement::BottomEnd`] here.
441    BottomRight,
442    /// `"top"` — above the trigger, centred.
443    Top,
444    /// `"top start"` — above the trigger, flush with its start edge.
445    TopStart,
446    /// `"top left"` — above the trigger, flush with its left edge; the
447    /// physical spelling of [`Placement::TopStart`] here.
448    TopLeft,
449    /// `"top end"` — above the trigger, flush with its end edge.
450    TopEnd,
451    /// `"top right"` — above the trigger, flush with its right edge; the
452    /// physical spelling of [`Placement::TopEnd`] here.
453    TopRight,
454    /// `"left"` — beside the trigger's left edge, vertically centred.
455    Left,
456    /// `"left top"` — beside the left edge, flush with the trigger's top.
457    LeftTop,
458    /// `"left bottom"` — beside the left edge, flush with the trigger's
459    /// bottom.
460    LeftBottom,
461    /// `"right"` — beside the trigger's right edge, vertically centred.
462    Right,
463    /// `"right top"` — beside the right edge, flush with the trigger's top.
464    RightTop,
465    /// `"right bottom"` — beside the right edge, flush with the trigger's
466    /// bottom.
467    RightBottom,
468    /// `"start"` — the logical spelling of [`Placement::Left`] in an
469    /// LTR-only port.
470    Start,
471    /// `"start top"` — the logical spelling of [`Placement::LeftTop`] here.
472    StartTop,
473    /// `"start bottom"` — the logical spelling of [`Placement::LeftBottom`]
474    /// here.
475    StartBottom,
476    /// `"end"` — the logical spelling of [`Placement::Right`] in an
477    /// LTR-only port.
478    End,
479    /// `"end top"` — the logical spelling of [`Placement::RightTop`] here.
480    EndTop,
481    /// `"end bottom"` — the logical spelling of [`Placement::RightBottom`]
482    /// here.
483    EndBottom,
484}
485
486/// How a panel lines up along the trigger's cross axis.
487///
488/// The axis is relative to the side: for the top and bottom placements it is
489/// the trigger's horizontal axis, for the side placements its vertical one.
490#[derive(Clone, Copy, Debug, PartialEq, Eq)]
491pub enum PlacementAlign {
492    /// Aligned to the start edge.
493    Start,
494    /// Centred on the trigger.
495    Center,
496    /// Aligned to the end edge.
497    End,
498}
499
500impl Placement {
501    /// Every placement, in declaration order.
502    pub const ALL: [Placement; 22] = [
503        Placement::Bottom,
504        Placement::BottomStart,
505        Placement::BottomLeft,
506        Placement::BottomEnd,
507        Placement::BottomRight,
508        Placement::Top,
509        Placement::TopStart,
510        Placement::TopLeft,
511        Placement::TopEnd,
512        Placement::TopRight,
513        Placement::Left,
514        Placement::LeftTop,
515        Placement::LeftBottom,
516        Placement::Right,
517        Placement::RightTop,
518        Placement::RightBottom,
519        Placement::Start,
520        Placement::StartTop,
521        Placement::StartBottom,
522        Placement::End,
523        Placement::EndTop,
524        Placement::EndBottom,
525    ];
526
527    /// A human-readable label for this placement.
528    pub fn label(self) -> &'static str {
529        match self {
530            Placement::Bottom => "Bottom",
531            Placement::BottomStart => "Bottom start",
532            Placement::BottomLeft => "Bottom left",
533            Placement::BottomEnd => "Bottom end",
534            Placement::BottomRight => "Bottom right",
535            Placement::Top => "Top",
536            Placement::TopStart => "Top start",
537            Placement::TopLeft => "Top left",
538            Placement::TopEnd => "Top end",
539            Placement::TopRight => "Top right",
540            Placement::Left => "Left",
541            Placement::LeftTop => "Left top",
542            Placement::LeftBottom => "Left bottom",
543            Placement::Right => "Right",
544            Placement::RightTop => "Right top",
545            Placement::RightBottom => "Right bottom",
546            Placement::Start => "Start",
547            Placement::StartTop => "Start top",
548            Placement::StartBottom => "Start bottom",
549            Placement::End => "End",
550            Placement::EndTop => "End top",
551            Placement::EndBottom => "End bottom",
552        }
553    }
554
555    /// Whether the panel opens upward.
556    pub fn is_above(self) -> bool {
557        matches!(
558            self,
559            Placement::Top
560                | Placement::TopStart
561                | Placement::TopLeft
562                | Placement::TopEnd
563                | Placement::TopRight
564        )
565    }
566
567    /// Whether the panel sits beside the trigger rather than above or below.
568    pub fn is_side(self) -> bool {
569        matches!(
570            self,
571            Placement::Left
572                | Placement::LeftTop
573                | Placement::LeftBottom
574                | Placement::Right
575                | Placement::RightTop
576                | Placement::RightBottom
577                | Placement::Start
578                | Placement::StartTop
579                | Placement::StartBottom
580                | Placement::End
581                | Placement::EndTop
582                | Placement::EndBottom
583        )
584    }
585
586    /// Whether the panel opens on the trigger's start side — the left edge,
587    /// because this port has no RTL mode.
588    pub fn is_start_side(self) -> bool {
589        matches!(
590            self,
591            Placement::Left
592                | Placement::LeftTop
593                | Placement::LeftBottom
594                | Placement::Start
595                | Placement::StartTop
596                | Placement::StartBottom
597        )
598    }
599
600    /// The alignment along the trigger's cross axis: horizontal for the top
601    /// and bottom placements, vertical for the side ones.
602    pub fn align(self) -> PlacementAlign {
603        match self {
604            Placement::BottomStart
605            | Placement::BottomLeft
606            | Placement::TopStart
607            | Placement::TopLeft
608            | Placement::LeftTop
609            | Placement::RightTop
610            | Placement::StartTop
611            | Placement::EndTop => PlacementAlign::Start,
612            Placement::BottomEnd
613            | Placement::BottomRight
614            | Placement::TopEnd
615            | Placement::TopRight
616            | Placement::LeftBottom
617            | Placement::RightBottom
618            | Placement::StartBottom
619            | Placement::EndBottom => PlacementAlign::End,
620            _ => PlacementAlign::Center,
621        }
622    }
623}
624
625#[cfg(test)]
626mod color_token_tests {
627    use super::*;
628
629    #[test]
630    fn every_role_round_trips_through_its_token() {
631        for color in Color::ALL {
632            assert_eq!(Color::from_token(color.token()), Some(color));
633            assert_eq!(color.token().parse::<Color>(), Ok(color));
634        }
635    }
636
637    #[test]
638    fn a_misspelt_role_is_an_error_not_accent() {
639        assert_eq!(Color::from_token("sucess"), None);
640        assert_eq!(Color::from_token("Accent"), None);
641        let err = "primary".parse::<Color>().unwrap_err();
642        assert_eq!(err.name, "primary");
643        assert!(err.to_string().contains("\"primary\""), "{err}");
644    }
645}