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}