Skip to main content

gpui_kit/display/
tag.rs

1//! A removable label for one item in a set the typist assembled.
2
3use std::rc::Rc;
4
5use gpui::{
6    App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
7    div, prelude::FluentBuilder, px,
8};
9use gpui_kit_assets::{Icon, icon};
10use gpui_kit_semantics::{NodeSpec, Role, Semantic};
11use gpui_kit_theme::{ActiveTheme, Radius, Space, TypeScale};
12
13use crate::display::badge::Tone;
14use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Selectable, StyledExt, text};
15use crate::strings::{ActiveStrings, StringKey};
16
17type RemoveHandler = Rc<dyn Fn(&mut Window, &mut App)>;
18
19/// A label with an optional remove action.
20///
21/// The remove handler is only installed when the tag is removable and enabled,
22/// so a tag the host will not let go of shows no way to let go of it.
23#[derive(IntoElement)]
24pub struct Tag {
25    ident: Ident,
26    label: SharedString,
27    tone: Tone,
28    disabled: bool,
29    selected: bool,
30    on_remove: Option<RemoveHandler>,
31}
32
33impl std::fmt::Debug for Tag {
34    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        formatter
36            .debug_struct("Tag")
37            .field("ident", &self.ident)
38            .field("label", &self.label)
39            .field("tone", &self.tone)
40            .field("removable", &self.on_remove.is_some())
41            .finish()
42    }
43}
44
45impl Tag {
46    pub fn new(ident: impl Into<Ident>, label: impl Into<SharedString>) -> Self {
47        Self {
48            ident: ident.into(),
49            label: label.into(),
50            tone: Tone::Neutral,
51            disabled: false,
52            selected: false,
53            on_remove: None,
54        }
55    }
56
57    pub fn tone(mut self, tone: Tone) -> Self {
58        self.tone = tone;
59        self
60    }
61
62    pub fn on_remove(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
63        self.on_remove = Some(Rc::new(handler));
64        self
65    }
66}
67
68impl Disableable for Tag {
69    fn disabled(mut self, disabled: bool) -> Self {
70        self.disabled = disabled;
71        self
72    }
73}
74
75/// A tag the keyboard has singled out, which is not the same as a tag that is
76/// gone: the selection is what the next keystroke would act on.
77impl Selectable for Tag {
78    fn selected(mut self, selected: bool) -> Self {
79        self.selected = selected;
80        self
81    }
82}
83
84impl RenderOnce for Tag {
85    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
86        let theme = cx.theme().clone();
87        let color = self.tone.color(&theme);
88        let removable = !self.disabled && self.on_remove.is_some();
89        let remove_ident = self.ident.child("remove");
90
91        let remove = removable.then(|| {
92            let handler = self.on_remove.clone().expect("checked above");
93            let mut button = div()
94                .id(remove_ident.element_id())
95                .flex()
96                .items_center()
97                .justify_center()
98                .cursor_pointer()
99                .tab_index(0)
100                .focus_ring(&theme)
101                .pressable(cx)
102                .child(icon(Icon::Close).size(px(10.0)).text_color(color))
103                .semantic_in(
104                    cx,
105                    NodeSpec::new(remove_ident.semantic_id(), Role::Button)
106                        .parent(self.ident.semantic_id())
107                        .text(cx.strings().format(StringKey::TagRemove, &[&self.label])),
108                );
109            let click = Rc::clone(&handler);
110            button
111                .interactivity()
112                .on_click(move |_, window, cx| click(window, cx));
113            button
114                .interactivity()
115                .on_key_down(move |event, window, cx| {
116                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
117                        handler(window, cx);
118                        cx.stop_propagation();
119                    }
120                });
121            button
122        });
123
124        div()
125            .flex()
126            .flex_row()
127            .items_center()
128            .flex_none()
129            .gap(px(theme.space(Space::Xs)))
130            .px(px(theme.space(Space::Sm)))
131            .py(px(2.0))
132            .radius(&theme, Radius::Pill)
133            // Selection is carried by the depth of the block rather than by an
134            // outline drawn round it, so the two states differ by more than a
135            // line a reader has to look for.
136            .bg(color.opacity(if self.selected { 0.34 } else { 0.14 }))
137            .when(self.selected, |element| {
138                element.shadow(theme.selected_ring())
139            })
140            .when(self.disabled, |element| {
141                element.opacity(theme.opacity.disabled)
142            })
143            .child(text(&theme, TypeScale::Label, self.label.clone()).text_color(color))
144            .children(remove)
145            .semantic_in(
146                cx,
147                NodeSpec::new(self.ident.semantic_id(), Role::Text)
148                    .disabled(self.disabled)
149                    .selected(self.selected)
150                    .text(self.label.clone()),
151            )
152    }
153}