Skip to main content

herogpui_components/
tag_group.rs

1//! TagGroup — port of `@heroui/tag-group` (v3).
2//!
3//! A focusable list of tags with optional selection and removal. Mirrors the
4//! React API: `selectionMode`, `selectedKeys`, `disabledKeys`, `isDisabled`,
5//! `onRemove`, `onSelectionChange`, `size` and the `default | surface` variant.
6
7use std::collections::HashSet;
8use std::sync::Arc;
9
10use gpui::{
11    div, prelude::*, px, AnyElement, App, ElementId, InteractiveElement, IntoElement, Pixels,
12    RenderOnce, SharedString, Styled, Window,
13};
14use herogpui_core::{element_id, SelectionMode, Size};
15use herogpui_theme::ActiveTheme;
16
17use crate::{
18    a11y::{self, A11y as _},
19    icons, EscapeKeyBehavior,
20};
21
22/// Visual variant of the tags in a group.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum TagVariant {
25    /// Filled with `default`.
26    #[default]
27    Default,
28    /// Flat on the surface.
29    Surface,
30}
31
32impl TagVariant {
33    /// Every variant, in display order.
34    pub const ALL: [TagVariant; 2] = [TagVariant::Default, TagVariant::Surface];
35
36    /// The display name of this variant.
37    pub fn label(self) -> &'static str {
38        match self {
39            TagVariant::Default => "Default",
40            TagVariant::Surface => "Surface",
41        }
42    }
43}
44
45/// One tag in a [`TagGroup`].
46#[must_use = "builder methods return a new value; pass the tag to its group"]
47#[derive(Clone)]
48pub struct Tag {
49    key: SharedString,
50    label: SharedString,
51    icon: Option<SharedString>,
52    remove_content: Option<Arc<dyn Fn() -> AnyElement + 'static>>,
53    is_disabled: bool,
54}
55
56impl Tag {
57    /// Creates a tag from a key and a label.
58    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
59        Self {
60            key: key.into(),
61            label: label.into(),
62            icon: None,
63            remove_content: None,
64            is_disabled: false,
65        }
66    }
67
68    /// Sets the icon asset path shown in the tag.
69    pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
70        self.icon = Some(path.into());
71        self
72    }
73
74    /// `Tag.RemoveButton` children, replacing the default close glyph.
75    pub fn remove_content(mut self, render: impl Fn() -> AnyElement + 'static) -> Self {
76        self.remove_content = Some(Arc::new(render));
77        self
78    }
79
80    /// Sets whether the tag is disabled (v3 `isDisabled`).
81    pub fn is_disabled(mut self, v: bool) -> Self {
82        self.is_disabled = v;
83        self
84    }
85
86    /// Returns the tag's key.
87    pub fn key(&self) -> &SharedString {
88        &self.key
89    }
90}
91
92type OnSelectionChange = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
93type OnRemove = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
94
95/// Pinned React Stately `useMultipleSelectionState`'s anchor record: where a
96/// Shift extension reaches from, how far the last one went, and whether the
97/// selection is the raw `all` a `selectAll` produced.
98#[derive(Clone, Debug, Default)]
99struct TagSelectionRange {
100    anchor: Option<SharedString>,
101    current: Option<SharedString>,
102    is_all: bool,
103}
104
105impl TagSelectionRange {
106    /// Seats the range on `target`: the anchor stays whatever a first
107    /// extension chose, the cursor lands on `target`, and a raw `all` ends.
108    fn seat(&mut self, target: SharedString) {
109        if self.anchor.is_none() {
110            self.anchor = Some(target.clone());
111        }
112        self.current = Some(target);
113        self.is_all = false;
114    }
115}
116
117/// The selection after extending to `target` from `range`'s anchor.
118///
119/// The old anchor..current range is *replaced* by anchor..target, so extending
120/// backwards shrinks again; a raw `all` collapses to the new key; a first
121/// extension without an anchor selects from the target itself, which is what
122/// the pinned SelectionManager does when nothing anchors it yet. Only
123/// `selectable` keys enter the range, so disabled tags are skipped.
124fn extend_selection_range(
125    current: &HashSet<SharedString>,
126    collection: &[SharedString],
127    selectable: &HashSet<SharedString>,
128    range: &TagSelectionRange,
129    target: &SharedString,
130) -> HashSet<SharedString> {
131    if range.is_all {
132        return HashSet::from([target.clone()]);
133    }
134    let anchor = range.anchor.as_ref().unwrap_or(target);
135    let previous = range.current.as_ref().unwrap_or(target);
136    let anchor_at = collection.iter().position(|key| key == anchor);
137    let previous_at = collection.iter().position(|key| key == previous);
138    let target_at = collection.iter().position(|key| key == target);
139    let between = |from: Option<usize>, to: Option<usize>| {
140        from.zip(to)
141            .map(|(from, to)| if from <= to { from..=to } else { to..=from })
142    };
143    let mut next = current.clone();
144    if let Some(previous_range) = between(anchor_at, previous_at) {
145        for index in previous_range {
146            next.remove(&collection[index]);
147        }
148    }
149    if let Some(target_range) = between(anchor_at, target_at) {
150        for index in target_range {
151            let key = &collection[index];
152            if selectable.contains(key) {
153                next.insert(key.clone());
154            }
155        }
156    }
157    next
158}
159
160/// Pinned React Aria 3.51.0 `useSelectableCollection` registers Home and End
161/// only for the chords each platform's handler admits: none, Shift, Alt, and
162/// Alt+Shift on macOS -- no Meta or Control handler exists -- and none,
163/// Shift, Control, and Control+Shift on Windows and Linux. The upstream
164/// matcher reads exactly the browser's canonical modifier flags -- Alt,
165/// Control, Meta, Shift -- so GPUI's `function` flag is ignored here: a
166/// browser exposes no Fn state for it to read, so vetoing on the flag would
167/// claim a pinned guard that does not exist, and the framework delivers an
168/// Fn-bearing press with every matched modifier flag still false. A chord
169/// outside the registration is entirely inert: no focus move, no selection,
170/// no preventDefault. `macos` is simulated explicitly so every platform's
171/// unit tests can prove both maps.
172fn home_end_registered(modifiers: gpui::Modifiers, macos: bool) -> bool {
173    if macos {
174        !modifiers.control && !modifiers.platform
175    } else {
176        !modifiers.alt && !modifiers.platform
177    }
178}
179
180/// Pinned `useSelectableCollection` (`isCtrlKeyPressed`): a Shift move
181/// extends the range on the collection's navigation keys, while Home and
182/// End extend only from Control+Shift on Windows and Linux. macOS registers
183/// no Home/End extension at all -- its Shift and Alt+Shift chords move the
184/// focus alone -- so the platform is an explicit bool rather than a `cfg!`.
185fn shift_home_end_extends(key_name: &str, control: bool, macos: bool) -> bool {
186    !matches!(key_name, "home" | "end") || (!macos && control)
187}
188
189/// HeroUI TagGroup.
190#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
191#[derive(IntoElement)]
192pub struct TagGroup {
193    id: ElementId,
194    tags: Vec<Tag>,
195    label: Option<SharedString>,
196    description: Option<SharedString>,
197    selection_mode: SelectionMode,
198    selected_keys: HashSet<SharedString>,
199    default_selected_keys: HashSet<SharedString>,
200    is_controlled: bool,
201    disallow_empty_selection: bool,
202    escape_key_behavior: EscapeKeyBehavior,
203    disabled_keys: HashSet<SharedString>,
204    is_disabled: bool,
205    size: Size,
206    /// The tag chip's corner radius, in place of the size step's radius. The
207    /// remove button inside stays a circle.
208    radius: Option<Pixels>,
209    variant: TagVariant,
210    /// The fill a hovered selectable tag takes, in place of the variant's
211    /// hover colour.
212    hover_bg: Option<gpui::Hsla>,
213    /// The fill a hovered remove button takes, in place of
214    /// `--default-hover`.
215    remove_hover_bg: Option<gpui::Hsla>,
216    /// `Tag`'s `children`-as-a-function: handed the interactive state and drawn
217    /// in place of the label.
218    tag_content: Option<Arc<dyn Fn(&Tag, crate::util::InteractiveState) -> AnyElement + 'static>>,
219    /// Shown in place of the list when `tags` is empty.
220    empty_state: Option<SharedString>,
221    on_selection_change: Option<OnSelectionChange>,
222    on_remove: Option<OnRemove>,
223    /// Expands the root to the available width.
224    full_width: bool,
225    /// The `sx` slot, refined over the root style at the end of render.
226    sx: Option<Box<gpui::StyleRefinement>>,
227}
228
229impl TagGroup {
230    /// Creates a tag group from an element id and its tags.
231    pub fn new(id: impl Into<ElementId>, tags: Vec<Tag>) -> Self {
232        Self {
233            id: id.into(),
234            tags,
235            tag_content: None,
236            label: None,
237            description: None,
238            selection_mode: SelectionMode::None,
239            selected_keys: HashSet::new(),
240            default_selected_keys: HashSet::new(),
241            is_controlled: false,
242            disallow_empty_selection: false,
243            escape_key_behavior: EscapeKeyBehavior::ClearSelection,
244            disabled_keys: HashSet::new(),
245            is_disabled: false,
246            size: Size::Md,
247            radius: None,
248            variant: TagVariant::Default,
249            hover_bg: None,
250            remove_hover_bg: None,
251            empty_state: None,
252            on_selection_change: None,
253            on_remove: None,
254            full_width: false,
255            sx: None,
256        }
257    }
258
259    /// Sets the group label.
260    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
261        self.label = Some(text.into());
262        self
263    }
264
265    /// Sets the group description.
266    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
267        self.description = Some(text.into());
268        self
269    }
270
271    /// Sets the selection mode (v3 `selectionMode`).
272    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
273        self.selection_mode = mode;
274        self
275    }
276
277    /// Sets the selected keys (v3 `selectedKeys`), making the selection controlled.
278    pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
279        self.selected_keys = keys.into_iter().collect();
280        self.is_controlled = true;
281        self
282    }
283
284    /// `defaultSelectedKeys` — seeds the group's own selection state.
285    pub fn default_selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
286        self.default_selected_keys = keys.into_iter().collect();
287        self
288    }
289
290    /// Prevents selection from becoming empty through a tag toggle or Escape.
291    pub fn disallow_empty_selection(mut self, v: bool) -> Self {
292        self.disallow_empty_selection = v;
293        self
294    }
295
296    /// `escapeKeyBehavior` — whether unmodified Escape clears selection.
297    pub fn escape_key_behavior(mut self, behavior: EscapeKeyBehavior) -> Self {
298        self.escape_key_behavior = behavior;
299        self
300    }
301
302    /// Sets the keys of tags that are disabled (v3 `disabledKeys`).
303    pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
304        self.disabled_keys = keys.into_iter().collect();
305        self
306    }
307
308    /// Sets whether the whole group is disabled.
309    pub fn is_disabled(mut self, v: bool) -> Self {
310        self.is_disabled = v;
311        self
312    }
313
314    /// Sets the size (v3 `size`).
315    pub fn size(mut self, size: Size) -> Self {
316        self.size = size;
317        self
318    }
319
320    /// The tag chip's corner radius, in place of the size step's radius. The
321    /// step (`rounded-xl`, `rounded-2xl` on `Lg`) stays the fallback, and the
322    /// remove button inside a chip is an inner part that stays a circle. Not a
323    /// v3 prop; the removed v2 `radius` prop is prohibited and this is a
324    /// per-component repository extension.
325    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
326        self.radius = Some(radius.into());
327        self
328    }
329
330    /// v3's render function for a tag's children, handed `isHovered`,
331    /// `isPressed`, `isFocused`, `isFocusVisible` and `isSelected` -- and the tag
332    /// itself, which the closure needs to know what it is drawing.
333    ///
334    /// The hover and the press are a frame behind the pointer: gpui reports both
335    /// to a handler, not to the render that draws them.
336    pub fn tag_content(
337        mut self,
338        render: impl Fn(&Tag, crate::util::InteractiveState) -> AnyElement + 'static,
339    ) -> Self {
340        self.tag_content = Some(Arc::new(render));
341        self
342    }
343
344    /// Sets the visual variant (v3 `variant`).
345    pub fn variant(mut self, variant: TagVariant) -> Self {
346        self.variant = variant;
347        self
348    }
349
350    /// The fill a hovered selectable tag takes, in place of the variant's
351    /// hover colour. Resting colours (including a selected tag's soft accent)
352    /// are unchanged.
353    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
354        self.hover_bg = Some(color.into());
355        self
356    }
357
358    /// The fill a hovered remove button takes, in place of
359    /// `--default-hover`.
360    pub fn remove_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
361        self.remove_hover_bg = Some(color.into());
362        self
363    }
364
365    /// `fullWidth` — expands the root to the available width without
366    /// redistributing the children.
367    pub fn full_width(mut self, v: bool) -> Self {
368        self.full_width = v;
369        self
370    }
371
372    /// The one slot for caller-owned low-level styling: GPUI's styling methods
373    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
374    /// applied to the group's root element after every value the size, the
375    /// variant and the active theme chose, so they win.
376    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
377        crate::util::refine_sx(&mut self.sx, style);
378        self
379    }
380
381    /// `TagGroup.List` renders this when there is nothing to show.
382    pub fn empty_state(mut self, text: impl Into<SharedString>) -> Self {
383        self.empty_state = Some(text.into());
384        self
385    }
386
387    /// Sets the handler called with the new set of selected keys (v3 `onSelectionChange`).
388    pub fn on_selection_change(
389        mut self,
390        handler: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
391    ) -> Self {
392        self.on_selection_change = Some(Arc::new(handler));
393        self
394    }
395
396    /// Adds a remove button to every tag. The button reports its own key;
397    /// Delete or Backspace reports the whole selection when the focused tag is
398    /// selected, and otherwise reports only the focused key.
399    pub fn on_remove(
400        mut self,
401        handler: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
402    ) -> Self {
403        self.on_remove = Some(Arc::new(handler));
404        self
405    }
406
407    /// `(px, py, text, leading)` from `.tag--sm` / `--md` / `--lg`.
408    ///
409    /// v3 gives a tag no height: it is padding around one line, which is why
410    /// this returns a vertical padding rather than the box it used to force.
411    fn metrics(size: Size) -> (Pixels, Pixels, Pixels, Pixels) {
412        match size {
413            Size::Sm => (px(8.), px(2.), px(12.), px(16.)),
414            Size::Md => (px(8.), px(4.), px(12.), px(16.)),
415            Size::Lg => (px(10.), px(6.), px(14.), px(20.)),
416        }
417    }
418
419    /// `rounded-xl` on `.tag`, `rounded-2xl` on `.tag--lg`. Named for the step
420    /// rather than for what it returns, because [`TagGroup::radius`] is the
421    /// builder that replaces its value.
422    fn step_radius(size: Size, cx: &App) -> Pixels {
423        match size {
424            Size::Sm | Size::Md => crate::util::small_radius(cx),
425            Size::Lg => crate::util::soft_radius(cx),
426        }
427    }
428}
429
430impl RenderOnce for TagGroup {
431    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
432        // A tag group is *one* tab stop: React Aria roves the tabindex, so Tab
433        // enters the group once and the arrows move inside it. Which tag claims
434        // the handle is held here, because a handle's `tab_stop` is fixed where
435        // the handle is made. `use_keyed_state` takes `cx` mutably, so both
436        // precede the theme.
437        let group_focus =
438            crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
439        let cursor =
440            window.use_keyed_state(element_id::scoped(&self.id, "cursor"), cx, |_, _| 0usize);
441        // The Shift-range anchor lives beside the cursor, keyed off the same
442        // instance id so two groups never share an anchor.
443        let selection_range =
444            window.use_keyed_state(element_id::scoped(&self.id, "range"), cx, |_, _| {
445                TagSelectionRange::default()
446            });
447        let (selected_keys, selection_own) = crate::util::controlled(
448            window,
449            cx,
450            element_id::scoped(&self.id, "selected"),
451            self.is_controlled.then(|| self.selected_keys.clone()),
452            self.default_selected_keys.clone(),
453        );
454        self.selected_keys = selected_keys;
455        // Removing a tag shortens the list and disabled tags take no focus, so
456        // the stop lands on the first enabled tag at or after the cursor.
457        let enabled: Vec<usize> = self
458            .tags
459            .iter()
460            .enumerate()
461            .filter(|(_, t)| {
462                !(self.is_disabled || t.is_disabled || self.disabled_keys.contains(&t.key))
463            })
464            .map(|(i, _)| i)
465            .collect();
466        let at = *cursor.read(cx);
467        let cursor_index = enabled
468            .iter()
469            .copied()
470            .find(|i| *i >= at)
471            .or_else(|| enabled.first().copied());
472        let enabled_keys: HashSet<SharedString> = enabled
473            .iter()
474            .map(|index| self.tags[*index].key.clone())
475            .collect();
476        // The collection order the range is resolved against — every tag's
477        // key, including disabled ones: disabled keys keep their collection
478        // positions so range span traversal preserves indexes, while the
479        // `selectable` filter keeps their insertions out of the range.
480        let collection_keys: Vec<SharedString> =
481            self.tags.iter().map(|tag| tag.key.clone()).collect();
482        let owns_focus = window.is_window_active() && group_focus.is_focused(window);
483        // One hover/press slot per tag. The slot is also the source for the
484        // pinned 100ms background transition, so plain tags and tags with a
485        // `tag_content` render prop take exactly the same hover path. Keeping
486        // the slot on the stable element lets the animated fill change its id
487        // without dropping the interaction listener or focus state.
488        let interaction: Vec<crate::util::Interaction> = (0..self.tags.len())
489            .map(|index| {
490                crate::util::interaction(
491                    element_id::scoped(&element_id::indexed(&self.id, "tag", index), "interaction"),
492                    window,
493                    cx,
494                )
495            })
496            .collect();
497        for (index, slot) in interaction.iter().enumerate() {
498            let tag = &self.tags[index];
499            if (self.is_disabled || tag.is_disabled || self.disabled_keys.contains(&tag.key))
500                && *slot.read(cx) != (false, false)
501            {
502                slot.update(cx, |state, _| *state = (false, false));
503            }
504        }
505        let remove_focus_handles = if self.on_remove.is_some() {
506            self.tags
507                .iter()
508                .map(|tag| {
509                    crate::util::tab_stop_handle(
510                        element_id::scoped(
511                            &element_id::scoped(
512                                &element_id::scoped(&self.id, "tag"),
513                                tag.key.clone(),
514                            ),
515                            "remove-focus",
516                        ),
517                        window,
518                        cx,
519                    )
520                })
521                .collect()
522        } else {
523            Vec::new()
524        };
525        let ring_visible = crate::util::focus_visible(cx);
526        // The hover animation mutably borrows `cx` while it registers keyed
527        // state. Keep a snapshot of the semantic tokens so that borrow does
528        // not span that call; theme values are immutable for this render.
529        let colors = cx.colors().clone();
530        let disabled_opacity = cx.layout().disabled_opacity;
531        let (pad_x, pad_y, text_size, leading) = Self::metrics(self.size);
532        // The size step stays the fallback; an instance radius replaces it on
533        // the chip alone — the remove button inside stays a circle.
534        let tag_radius = self
535            .radius
536            .unwrap_or_else(|| Self::step_radius(self.size, cx));
537
538        // `.tag-group` is `flex flex-col gap-1`: the label, the list and the
539        // description.
540        let mut root = div().relative().flex().flex_col().gap(px(4.));
541        if self.full_width {
542            root = root.w_full();
543        }
544
545        if let Some(label) = &self.label {
546            root = root.child(
547                div()
548                    .text_size(px(14.))
549                    .line_height(px(20.))
550                    .font_weight(gpui::FontWeight::MEDIUM)
551                    .text_color(colors.foreground)
552                    .child(label.to_string()),
553            );
554        }
555
556        if self.tags.is_empty() {
557            let text = self
558                .empty_state
559                .unwrap_or_else(|| SharedString::from("No tags"));
560            root = root.child(
561                div()
562                    .id(element_id::scoped(&self.id, "list"))
563                    // `react-aria/dist/private/tag/useTagGroup.mjs` chooses
564                    // the list's role by whether the collection is empty:
565                    // `role: state.collection.size ? 'grid' : 'group'`. This
566                    // is the empty half. Its `aria-live`/`aria-atomic`/
567                    // `aria-relevant` neighbours are recorded omissions —
568                    // gpui exposes no live-region builder at all.
569                    .a11y_named(a11y::Role::Group, &a11y::Name::maybe(self.label.clone()))
570                    // `.empty-state` is `p-2 text-sm text-muted`.
571                    .p(px(8.))
572                    .text_size(px(14.))
573                    .line_height(px(20.))
574                    .text_color(colors.muted)
575                    .child(text.to_string()),
576            );
577            return crate::util::apply_sx(root, &self.sx);
578        }
579
580        let mut list = div().relative().flex().flex_row().flex_wrap().gap(px(6.));
581
582        for (index, tag) in self.tags.iter().enumerate() {
583            let disabled =
584                self.is_disabled || tag.is_disabled || self.disabled_keys.contains(&tag.key);
585            let selected = self.selected_keys.contains(&tag.key);
586            let selectable = self.selection_mode != SelectionMode::None;
587            let interactive = selectable && !disabled;
588            let tag_foreground = if selected {
589                colors.accent.soft_foreground(colors.foreground)
590            } else {
591                match self.variant {
592                    TagVariant::Default => colors.default.foreground,
593                    TagVariant::Surface => colors.surface.foreground,
594                }
595            };
596
597            let mut chip = div()
598                .id(element_id::indexed(&self.id, "tag", index))
599                // A `Tag` is a grid-list item: `useTag.mjs` builds on
600                // `.../gridlist/useGridListItem.mjs`, which is `role: 'row'`
601                // with `'aria-label': node['aria-label'] || node.textValue`
602                // and `'aria-selected': canSelectItem ? isSelected :
603                // undefined`. The `role: 'gridcell'` node that hook also
604                // returns has no counterpart element here — this port draws
605                // the tag's contents straight into the row — and its
606                // `aria-disabled` has no gpui builder.
607                .a11y_named(a11y::Role::Row, &a11y::Name::labelled(tag.label.clone()))
608                .when(selectable, |c| c.a11y_selected(selected))
609                // One handle roves the group and a cursor picks the tag it
610                // stands on, which is upstream's virtual focus; gpui states
611                // that relation on the descendant.
612                .when(cursor_index == Some(index), |c| c.a11y_active_descendant())
613                .when(!disabled && cursor_index == Some(index), |c| {
614                    c.track_focus(&group_focus)
615                })
616                .flex()
617                .flex_row()
618                .items_center()
619                .gap(px(4.))
620                .px(pad_x)
621                .py(pad_y)
622                .rounded(tag_radius)
623                .text_size(text_size)
624                .line_height(leading)
625                .font_weight(gpui::FontWeight::MEDIUM)
626                .whitespace_nowrap();
627
628            chip = if selected {
629                chip.bg(colors.accent.soft()).text_color(tag_foreground)
630            } else {
631                match self.variant {
632                    TagVariant::Default => chip.bg(colors.default.color).text_color(tag_foreground),
633                    TagVariant::Surface => chip
634                        .bg(colors.surface.background)
635                        .text_color(tag_foreground),
636                }
637            };
638
639            if disabled {
640                chip = chip.opacity(disabled_opacity);
641            } else {
642                let hover = self.hover_bg.unwrap_or_else(|| {
643                    if selected {
644                        colors.accent.soft_hover()
645                    } else {
646                        match self.variant {
647                            TagVariant::Default => colors.default.hover(),
648                            TagVariant::Surface => colors.surface.hover(),
649                        }
650                    }
651                });
652                chip = chip.cursor(crate::util::interactive_cursor(cx));
653                // `.tag` declares `background-color 100ms var(--ease-smooth)`.
654                // Keep the chip itself as the stable hover listener and put
655                // only the interpolated fill in a rounded child. This avoids
656                // the keyed animation wrapper resetting the tag's focus and
657                // render-prop state, and the child inherits the same radius so
658                // a mid-transition frame cannot leak square corners.
659                if let Some(slot) = interaction.get(index) {
660                    chip = crate::anim::hover_fade_with_duration_and_easing(
661                        chip,
662                        element_id::scoped(&element_id::indexed(&self.id, "tag", index), "fade"),
663                        (
664                            if selected {
665                                colors.accent.soft()
666                            } else {
667                                match self.variant {
668                                    TagVariant::Default => colors.default.color,
669                                    TagVariant::Surface => colors.surface.background,
670                                }
671                            },
672                            hover,
673                        ),
674                        Some(slot),
675                        None,
676                        move |fill| fill.rounded(tag_radius),
677                        Some(100),
678                        crate::anim::HoverFadeEasing::EaseSmooth,
679                        window,
680                        cx,
681                    );
682                }
683            }
684
685            if let Some(path) = &tag.icon {
686                chip = chip.child(
687                    gpui::svg()
688                        .size(px(12.))
689                        .path(path.clone())
690                        .flex_shrink_0()
691                        .text_color(tag_foreground),
692                );
693            }
694
695            chip = match &self.tag_content {
696                Some(render) => {
697                    let (is_hovered, is_pressed) = if interactive {
698                        interaction
699                            .get(index)
700                            .map(|slot| *slot.read(cx))
701                            .unwrap_or_default()
702                    } else {
703                        (false, false)
704                    };
705                    let focused = !disabled && owns_focus && cursor_index == Some(index);
706                    chip.child(render(
707                        tag,
708                        crate::util::InteractiveState {
709                            is_hovered,
710                            is_pressed,
711                            is_focused: focused,
712                            is_focus_visible: focused && ring_visible,
713                            is_selected: selected,
714                            is_disabled: disabled,
715                            is_pending: false,
716                            is_indeterminate: false,
717                        },
718                    ))
719                }
720                None => chip.child(tag.label.to_string()),
721            };
722            if !disabled {
723                if let Some(slot) = interaction.get(index) {
724                    chip = crate::util::track_interaction(chip, slot);
725                }
726            }
727
728            if let Some(on_remove) = self.on_remove.clone() {
729                let key = tag.key.clone();
730                let remove_content = tag.remove_content.as_ref().map_or_else(
731                    || {
732                        gpui::svg()
733                            .size(px(12.))
734                            .path(icons::CLOSE)
735                            .text_color(tag_foreground)
736                            .into_any_element()
737                    },
738                    |render| render(),
739                );
740                // `.tag__remove-button` is `size-3`.
741                let mut remove_visual = div()
742                    .size(px(12.))
743                    .rounded_full()
744                    .flex()
745                    .items_center()
746                    .justify_center()
747                    .flex_shrink_0()
748                    .child(remove_content);
749                let mut close = div()
750                    .id(element_id::scoped(
751                        &element_id::indexed(&self.id, "tag", index),
752                        "remove",
753                    ))
754                    // `useTag.mjs`'s `removeButtonProps` is
755                    // `'aria-label': stringFormatter.format(
756                    // 'removeButtonLabel')` — `Remove` in the pinned en-US
757                    // strings (`react-aria/dist/private/intl/tag/en-US.mjs`)
758                    // — plus an `aria-labelledby` pointing at the button and
759                    // the row together, which needs the id graph gpui does
760                    // not have. The button itself is an RAC `Button`.
761                    .a11y_named(a11y::Role::Button, &a11y::Name::labelled(crate::i18n::ui_string(crate::i18n::UiString::Remove, cx)))
762                    .group("tag-remove")
763                    .flex()
764                    .items_center()
765                    .justify_center()
766                    // v3.2.5's `touch-target` extends 6px on each side of
767                    // the glyph. A 12px parent retains the layout footprint.
768                    .size(px(24.))
769                    .absolute()
770                    .top(px(-6.))
771                    .left(px(-6.))
772                    .flex_shrink_0();
773                if !disabled {
774                    let hover_bg = self.remove_hover_bg.unwrap_or(colors.default.hover());
775                    let remove_focus = &remove_focus_handles[index];
776                    let focus_for_remove = group_focus.clone();
777                    let cursor_for_remove = cursor.clone();
778                    remove_visual =
779                        remove_visual.group_hover("tag-remove", move |s| s.bg(hover_bg));
780                    close = close
781                        .track_focus(remove_focus)
782                        .cursor(crate::util::interactive_cursor(cx))
783                        // React Aria's grid-list stops row key handling while
784                        // a child button owns the focus, except for Tab.
785                        .on_key_down(|event, _, cx| {
786                            if event.keystroke.key != "tab" {
787                                cx.stop_propagation();
788                            }
789                        })
790                        // The press belongs to the button. Stopping it here
791                        // keeps the tag body's mouse-down -- which seats the
792                        // group's focus and cursor -- out of a remove press.
793                        .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
794                            cx.stop_propagation();
795                        })
796                        .on_click(move |_, window, cx| {
797                            // The remove button is an action inside a
798                            // selectable tag. Its press belongs to the button,
799                            // never to the tag behind it.
800                            cx.stop_propagation();
801                            on_remove(&HashSet::from([key.clone()]), window, cx);
802                            // Pinned `useSelectableItem` only isolates the
803                            // child's press -- DOM focus goes to the button.
804                            // This port seats the owning tag itself because
805                            // the report-only Rust model has no persisting
806                            // native child and keyboard continuity needs a
807                            // stable roving target. Removal only reports;
808                            // the selection is not this click's to change.
809                            window.focus(&focus_for_remove, cx);
810                            cursor_for_remove.update(cx, |v, cx| {
811                                *v = index;
812                                cx.notify();
813                            });
814                        });
815                    // The ring rides the 12px glyph rather than the 24px
816                    // touch target that owns the focus handle: the glyph is
817                    // the element carrying `rounded_full`, and the ring is
818                    // concentric only with the shape it is drawn around. A
819                    // ring on the touch target would circle a footprint the
820                    // pinned sheet never paints. `rounded_full` on a 12px box
821                    // resolves to a 6px radius.
822                    remove_visual = crate::util::ring_overlay_if_focused(
823                        remove_visual,
824                        remove_focus,
825                        true,
826                        px(6.),
827                        Vec::new(),
828                        window,
829                        cx,
830                    );
831                    close = crate::util::record_focus_bounds(close, remove_focus, window, cx);
832                }
833                chip = chip.child(
834                    div()
835                        .relative()
836                        .size(px(12.))
837                        .flex_shrink_0()
838                        .child(close.child(remove_visual)),
839                );
840            }
841
842            // React Aria's TagGroup: the arrows move between tags. Delete or
843            // Backspace removes the selection when the focused tag belongs to
844            // it, and otherwise removes only the focused tag.
845            if !disabled {
846                let stops = enabled.clone();
847                let moved = cursor.clone();
848                let remove = self.on_remove.clone();
849                let key_for_remove = tag.key.clone();
850                let mode = self.selection_mode;
851                let disallow_empty = self.disallow_empty_selection;
852                let escape_key_behavior = self.escape_key_behavior;
853                let selected_now = self.selected_keys.clone();
854                let selectable_keys = enabled_keys.clone();
855                let collection_for_keys = collection_keys.clone();
856                let range_for_keys = selection_range.clone();
857                let on_selection_change = self.on_selection_change.clone();
858                let selection_own_for_keys = selection_own.clone();
859                let range_for_all = selection_range.clone();
860                let range_for_escape = selection_range.clone();
861                chip = chip.on_key_down(move |event, window, cx| {
862                    let key_name = event.keystroke.key.as_str();
863                    // Pinned React Aria 3.51 routes TagGroup through
864                    // `useSelectableCollection`, including Mod+A select-all.
865                    if key_name == "a"
866                        && event.keystroke.modifiers.secondary()
867                        && !event.keystroke.modifiers.shift
868                        && !event.keystroke.modifiers.alt
869                        && !event.keystroke.modifiers.function
870                        && if cfg!(target_os = "macos") {
871                            !event.keystroke.modifiers.control
872                        } else {
873                            !event.keystroke.modifiers.platform
874                        }
875                        && mode == SelectionMode::Multiple
876                    {
877                        let all_selected =
878                            selectable_keys.iter().all(|key| selected_now.contains(key));
879                        if !all_selected {
880                            if let Some(held) = &selection_own_for_keys {
881                                held.update(cx, |value, cx| {
882                                    *value = selectable_keys.clone();
883                                    cx.notify();
884                                });
885                            }
886                            if let Some(change) = &on_selection_change {
887                                change(&selectable_keys, window, cx);
888                            }
889                            // The raw `all`: the next Shift move collapses to
890                            // its target instead of extending across
891                            // everything. A redundant Mod+A over an already
892                            // complete selection is idempotent and keeps the
893                            // anchor, like pinned `SelectionManager::selectAll`.
894                            range_for_all.update(cx, |range, _| {
895                                *range = TagSelectionRange {
896                                    is_all: true,
897                                    ..TagSelectionRange::default()
898                                };
899                            });
900                        }
901                        cx.stop_propagation();
902                        return;
903                    }
904                    // `useSelectableCollection` also clears a nonempty
905                    // selection on unmodified Escape by default.
906                    if key_name == "escape"
907                        && !event.keystroke.modifiers.modified()
908                        && escape_key_behavior == EscapeKeyBehavior::ClearSelection
909                        && crate::selection::reports_changes(mode)
910                        && !disallow_empty
911                        && !selected_now.is_empty()
912                    {
913                        let next = HashSet::new();
914                        if let Some(held) = &selection_own_for_keys {
915                            held.update(cx, |value, cx| {
916                                *value = next.clone();
917                                cx.notify();
918                            });
919                        }
920                        if let Some(change) = &on_selection_change {
921                            change(&next, window, cx);
922                        }
923                        range_for_escape.update(cx, |range, _| {
924                            *range = TagSelectionRange::default();
925                        });
926                        cx.stop_propagation();
927                        return;
928                    }
929                    match key_name {
930                        "delete" | "backspace" => {
931                            if let Some(cb) = &remove {
932                                cx.stop_propagation();
933                                if selected_now.contains(&key_for_remove) {
934                                    cb(&selected_now, window, cx);
935                                } else {
936                                    cb(&HashSet::from([key_for_remove.clone()]), window, cx);
937                                }
938                            }
939                        }
940                        key @ ("left" | "right" | "home" | "end") => {
941                            // The pinned registrations install no Home/End
942                            // handler for an unregistered chord -- Cmd- or
943                            // Ctrl-bearing on macOS, Alt- or platform-bearing
944                            // elsewhere -- so the whole event stays inert: no
945                            // focus move, no selection, no preventDefault,
946                            // and no consumption of the key either.
947                            if matches!(key, "home" | "end")
948                                && !home_end_registered(
949                                    event.keystroke.modifiers,
950                                    cfg!(target_os = "macos"),
951                                )
952                            {
953                                return;
954                            }
955                            let key = match key {
956                                "right" => "down",
957                                "left" => "up",
958                                other => other,
959                            };
960                            // React Aria gives TagGroup a horizontal
961                            // keyboard delegate. The list owns its axis
962                            // and Home/End; Up/Down fall through to an
963                            // enclosing scroller.
964                            cx.stop_propagation();
965                            let crate::list_nav::Move::To(next) =
966                                crate::list_nav::resolve(&stops, Some(index), key, true)
967                            else {
968                                return;
969                            };
970                            // Pinned `useSelectableCollection`: Shift extends a
971                            // multiple selection from the anchor with no other
972                            // chord, so plain Shift navigation is exact. A
973                            // wrap-to-self move or a Home/End
974                            // already at its target still extends: pinned
975                            // `extendSelection` replaces the anchor..target
976                            // range even when the cursor does not move, and
977                            // the unchanged-selection guard below is what
978                            // keeps true no-ops silent.
979                            let modifiers = event.keystroke.modifiers;
980                            let exact_shift_navigation = if cfg!(target_os = "macos") {
981                                !modifiers.control && !modifiers.platform && !modifiers.function
982                            } else {
983                                !modifiers.alt && !modifiers.platform && !modifiers.function
984                            };
985                            let extends_selection = modifiers.shift
986                                && mode == SelectionMode::Multiple
987                                && exact_shift_navigation
988                                && shift_home_end_extends(
989                                    key_name,
990                                    modifiers.control,
991                                    cfg!(target_os = "macos"),
992                                );
993                            if extends_selection {
994                                if let Some(target) = collection_for_keys.get(next) {
995                                    let range = range_for_keys.read(cx).clone();
996                                    let next_selection = extend_selection_range(
997                                        &selected_now,
998                                        &collection_for_keys,
999                                        &selectable_keys,
1000                                        &range,
1001                                        target,
1002                                    );
1003                                    range_for_keys
1004                                        .update(cx, |range, _| range.seat(target.clone()));
1005                                    if next_selection != selected_now {
1006                                        if let Some(held) = &selection_own_for_keys {
1007                                            held.update(cx, |value, cx| {
1008                                                *value = next_selection.clone();
1009                                                cx.notify();
1010                                            });
1011                                        }
1012                                        if let Some(change) = &on_selection_change {
1013                                            change(&next_selection, window, cx);
1014                                        }
1015                                    }
1016                                }
1017                            }
1018                            // No refocusing: the next render has the tag
1019                            // at `next` claim the group's handle, so the
1020                            // focus goes with it.
1021                            moved.update(cx, |v, cx| {
1022                                *v = next;
1023                                cx.notify();
1024                            });
1025                        }
1026                        _ => {}
1027                    }
1028                });
1029                // React Aria seats a collection on pointer-down: pressing a
1030                // tag's body moves the roving cursor to it and takes the
1031                // group's focus, so the arrows and Space answer the tag the
1032                // user pressed with no Tab first. A child that prevented the
1033                // press -- the remove button -- keeps the body out of it, and
1034                // preventing the default here is the other half of the seat:
1035                // gpui's own focus-on-press for `track_focus` elements works
1036                // the same way, so an ancestor's -- the app root's -- press
1037                // focus cannot steal the handle back in the same dispatch.
1038                let focus_for_seat = group_focus.clone();
1039                let cursor_for_seat = cursor.clone();
1040                chip = chip.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1041                    if window.default_prevented() {
1042                        return;
1043                    }
1044                    window.focus(&focus_for_seat, cx);
1045                    window.prevent_default();
1046                    cursor_for_seat.update(cx, |v, cx| {
1047                        *v = index;
1048                        cx.notify();
1049                    });
1050                });
1051            }
1052
1053            if selectable && !disabled {
1054                let key = tag.key.clone();
1055                let mode = self.selection_mode;
1056                let disallow_empty = self.disallow_empty_selection;
1057                let current = self.selected_keys.clone();
1058                let on_change = self.on_selection_change.clone();
1059                let selection_own = selection_own.clone();
1060                let collection_for_click = collection_keys.clone();
1061                let selectable_for_click = enabled_keys.clone();
1062                let range_for_click = selection_range.clone();
1063                chip = chip.on_click(move |ev, window, cx| {
1064                    let was_selected = current.contains(&key);
1065                    // A Shift click extends from the anchor in multiple mode;
1066                    // an ordinary click toggles and re-anchors instead.
1067                    let extends_selection = ev.modifiers().shift && mode == SelectionMode::Multiple;
1068                    let next = if extends_selection {
1069                        let range = range_for_click.read(cx).clone();
1070                        extend_selection_range(
1071                            &current,
1072                            &collection_for_click,
1073                            &selectable_for_click,
1074                            &range,
1075                            &key,
1076                        )
1077                    } else {
1078                        match mode {
1079                            SelectionMode::None => current.clone(),
1080                            SelectionMode::Single => {
1081                                if current.contains(&key) && !disallow_empty {
1082                                    HashSet::new()
1083                                } else {
1084                                    HashSet::from([key.clone()])
1085                                }
1086                            }
1087                            SelectionMode::Multiple => {
1088                                let mut set = current.clone();
1089                                if set.remove(&key) {
1090                                    if disallow_empty && set.is_empty() {
1091                                        set.insert(key.clone());
1092                                    }
1093                                } else {
1094                                    set.insert(key.clone());
1095                                }
1096                                set
1097                            }
1098                        }
1099                    };
1100                    // A controlled selection only reports; the owner's prop
1101                    // stays in charge until it feeds the value back.
1102                    if next != current {
1103                        if let Some(held) = &selection_own {
1104                            held.update(cx, |value, cx| {
1105                                *value = next.clone();
1106                                cx.notify();
1107                            });
1108                        }
1109                        if let Some(change) = &on_change {
1110                            change(&next, window, cx);
1111                        }
1112                    }
1113                    if extends_selection {
1114                        range_for_click.update(cx, |range, _| range.seat(key.clone()));
1115                    } else if mode == SelectionMode::Multiple && !was_selected {
1116                        range_for_click.update(cx, |range, _| {
1117                            range.anchor = Some(key.clone());
1118                            range.current = Some(key.clone());
1119                            range.is_all = false;
1120                        });
1121                    } else if mode == SelectionMode::Multiple {
1122                        range_for_click.update(cx, |range, _| {
1123                            if range.is_all {
1124                                *range = TagSelectionRange::default();
1125                            }
1126                        });
1127                    }
1128                });
1129            }
1130
1131            // `.tag:focus-visible` is `status-focused`.
1132            let mut chip = crate::util::with_focus_ring_overlay(
1133                chip,
1134                !disabled && ring_visible && cursor_index == Some(index) && owns_focus,
1135                true,
1136                tag_radius,
1137                Vec::new(),
1138                cx,
1139            );
1140            if !disabled && cursor_index == Some(index) {
1141                chip = crate::util::record_focus_bounds(chip, &group_focus, window, cx);
1142            }
1143            list = list.child(chip);
1144        }
1145
1146        // The id and the role go on at the end rather than in the chain
1147        // above: `.shots/design_audit.py` reads `.tag-group__list`'s gap with
1148        // a regex spelling `let mut list = div().relative().flex()...` with
1149        // no room between the calls, and it correctly reported the line as
1150        // unreadable when they were spliced in.
1151        //
1152        // This is the populated half of `useTagGroup.mjs`'s role switch
1153        // (`role: state.collection.size ? 'grid' : 'group'`); the grid comes
1154        // from `useGridList` underneath it, and RAC's `TagGroup.mjs` spreads
1155        // those props onto the `TagList` unmodified.
1156        root = root.child(
1157            list.id(element_id::scoped(&self.id, "list"))
1158                .a11y_named(a11y::Role::Grid, &a11y::Name::maybe(self.label.clone())),
1159        );
1160
1161        if let Some(description) = &self.description {
1162            root = root.child(
1163                div()
1164                    .p(px(4.))
1165                    .text_size(px(12.))
1166                    .line_height(px(16.))
1167                    .text_color(colors.muted)
1168                    .child(description.to_string()),
1169            );
1170        }
1171
1172        root = crate::util::apply_sx(root, &self.sx);
1173        root
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    /// The Home/End gate takes the platform as an explicit bool, so this
1182    /// truth table is free of `cfg!` and mechanically proves both maps from
1183    /// any host: no macOS chord ever extends -- Shift and Alt+Shift move the
1184    /// focus alone -- while Windows and Linux extend exactly from
1185    /// Control+Shift.
1186    #[test]
1187    fn shift_home_end_extends_only_from_control_outside_macos() {
1188        for key in ["home", "end"] {
1189            assert!(
1190                !shift_home_end_extends(key, true, true),
1191                "macOS registers no Home/End extension"
1192            );
1193            assert!(!shift_home_end_extends(key, false, true));
1194            assert!(
1195                shift_home_end_extends(key, true, false),
1196                "Control+Shift+{key} must extend on Windows and Linux"
1197            );
1198            assert!(
1199                !shift_home_end_extends(key, false, false),
1200                "plain Shift+{key} must only move the focus"
1201            );
1202        }
1203    }
1204
1205    /// The horizontal delegate's arrows never consult the Home/End gate:
1206    /// their forbidden extra chords are rejected earlier, by
1207    /// `exact_shift_navigation`.
1208    #[test]
1209    fn shift_navigation_keys_do_not_consult_the_home_end_gate() {
1210        for key in ["left", "right"] {
1211            assert!(shift_home_end_extends(key, false, true));
1212            assert!(shift_home_end_extends(key, true, false));
1213        }
1214    }
1215
1216    /// The registration gate takes `Modifiers`, so the pinned chord map can
1217    /// be spelled out: macOS registers none, Shift, Alt, and Alt+Shift and
1218    /// every Control- or Meta-bearing chord is entirely inert, while
1219    /// Windows and Linux register none, Shift, Control, and Control+Shift
1220    /// and reject every Alt- or Meta-bearing chord. The upstream matcher
1221    /// sees only the browser's Alt/Control/Meta/Shift flags, so GPUI's
1222    /// `function` flag is ignored: `fn` stays registered on both maps, and
1223    /// it never rescues a chord the platform itself rejects.
1224    #[test]
1225    fn home_end_registration_matches_the_pinned_chord_map() {
1226        let none = gpui::Modifiers::none();
1227        let shift = gpui::Modifiers {
1228            shift: true,
1229            ..none
1230        };
1231        let alt = gpui::Modifiers { alt: true, ..none };
1232        let alt_shift = gpui::Modifiers { shift: true, ..alt };
1233        let function = gpui::Modifiers {
1234            function: true,
1235            ..none
1236        };
1237        let function_alt = gpui::Modifiers {
1238            alt: true,
1239            ..function
1240        };
1241        for modifiers in [none, shift, alt, alt_shift, function, function_alt] {
1242            assert!(
1243                home_end_registered(modifiers, true),
1244                "macOS must register {modifiers:?}"
1245            );
1246        }
1247        let control = gpui::Modifiers {
1248            control: true,
1249            ..none
1250        };
1251        let control_shift = gpui::Modifiers {
1252            shift: true,
1253            ..control
1254        };
1255        let platform = gpui::Modifiers {
1256            platform: true,
1257            ..none
1258        };
1259        let platform_shift = gpui::Modifiers {
1260            shift: true,
1261            ..platform
1262        };
1263        for modifiers in [control, control_shift, platform, platform_shift] {
1264            assert!(
1265                !home_end_registered(modifiers, true),
1266                "macOS must not register {modifiers:?}"
1267            );
1268        }
1269        for modifiers in [none, shift, control, control_shift, function] {
1270            assert!(
1271                home_end_registered(modifiers, false),
1272                "Windows and Linux must register {modifiers:?}"
1273            );
1274        }
1275        for modifiers in [alt, alt_shift, function_alt, platform, platform_shift] {
1276            assert!(
1277                !home_end_registered(modifiers, false),
1278                "Windows and Linux must not register {modifiers:?}"
1279            );
1280        }
1281    }
1282
1283    /// The keystroke spellings real events hand the gate: `ctrl` parses to
1284    /// the Control field the Windows/Linux registration admits and macOS
1285    /// vetoes, `cmd` to the platform field macOS vetoes, `alt-shift` to
1286    /// the chord that stays registered (focus-only) on macOS alone, and
1287    /// `fn` to the flag the browser matcher never sees, so it registers
1288    /// exactly like the bare key on both maps.
1289    #[test]
1290    fn keystroke_spellings_reach_the_registration_gate() {
1291        let ctrl_shift_home = gpui::Keystroke::parse("ctrl-shift-home").unwrap();
1292        assert!(home_end_registered(ctrl_shift_home.modifiers, false));
1293        assert!(!home_end_registered(ctrl_shift_home.modifiers, true));
1294        let cmd_shift_home = gpui::Keystroke::parse("cmd-shift-home").unwrap();
1295        assert!(!home_end_registered(cmd_shift_home.modifiers, true));
1296        let alt_shift_end = gpui::Keystroke::parse("alt-shift-end").unwrap();
1297        assert!(home_end_registered(alt_shift_end.modifiers, true));
1298        assert!(!home_end_registered(alt_shift_end.modifiers, false));
1299        let fn_home = gpui::Keystroke::parse("fn-home").unwrap();
1300        assert!(fn_home.modifiers.function);
1301        assert!(home_end_registered(fn_home.modifiers, true));
1302        assert!(home_end_registered(fn_home.modifiers, false));
1303    }
1304}
1305
1306crate::util::impl_component_styled!(TagGroup);