Skip to main content

liora_components/
tag.rs

1use crate::gpui_compat::element_id;
2use gpui::{
3    AnyElement, App, Component, IntoElement, Pixels, RenderOnce, SharedString, Window, div,
4    prelude::*, px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum TagType {
12    #[default]
13    Info,
14    Success,
15    Warning,
16    Danger,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum TagSize {
21    Small,
22    #[default]
23    Default,
24    Large,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum TagEffect {
29    Dark,
30    #[default]
31    Light,
32    Plain,
33}
34
35pub struct Tag {
36    label: SharedString,
37    tag_type: TagType,
38    size: TagSize,
39    effect: TagEffect,
40    closable: bool,
41    round: bool,
42    on_close: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
43}
44
45impl Tag {
46    pub fn new(label: impl Into<SharedString>) -> Self {
47        Self {
48            label: label.into(),
49            tag_type: TagType::Info,
50            size: TagSize::Default,
51            effect: TagEffect::Light,
52            closable: false,
53            round: false,
54            on_close: None,
55        }
56    }
57
58    pub fn tag_type(mut self, t: TagType) -> Self {
59        self.tag_type = t;
60        self
61    }
62
63    pub fn success(mut self) -> Self {
64        self.tag_type = TagType::Success;
65        self
66    }
67
68    pub fn warning(mut self) -> Self {
69        self.tag_type = TagType::Warning;
70        self
71    }
72
73    pub fn danger(mut self) -> Self {
74        self.tag_type = TagType::Danger;
75        self
76    }
77
78    pub fn info(mut self) -> Self {
79        self.tag_type = TagType::Info;
80        self
81    }
82
83    pub fn size(mut self, s: TagSize) -> Self {
84        self.size = s;
85        self
86    }
87
88    pub fn small(mut self) -> Self {
89        self.size = TagSize::Small;
90        self
91    }
92
93    pub fn large(mut self) -> Self {
94        self.size = TagSize::Large;
95        self
96    }
97
98    pub fn effect(mut self, e: TagEffect) -> Self {
99        self.effect = e;
100        self
101    }
102
103    pub fn dark(mut self) -> Self {
104        self.effect = TagEffect::Dark;
105        self
106    }
107
108    pub fn plain(mut self) -> Self {
109        self.effect = TagEffect::Plain;
110        self
111    }
112
113    pub fn closable(mut self, c: bool) -> Self {
114        self.closable = c;
115        self
116    }
117
118    pub fn round(mut self, r: bool) -> Self {
119        self.round = r;
120        self
121    }
122
123    pub fn on_close(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
124        self.on_close = Some(Box::new(f));
125        self
126    }
127}
128
129impl RenderOnce for Tag {
130    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
131        let theme = cx.global::<Config>().theme.clone();
132        let on_close = self.on_close;
133
134        let color = match self.tag_type {
135            TagType::Info => theme.primary.base,
136            TagType::Success => theme.success.base,
137            TagType::Warning => theme.warning.base,
138            TagType::Danger => theme.danger.base,
139        };
140
141        let (bg, border, text_color) = match self.effect {
142            TagEffect::Light => (color.opacity(0.1), color.opacity(0.2), color),
143            TagEffect::Dark => (color, color, theme.neutral.text_1.opacity(1.0)),
144            TagEffect::Plain => (theme.neutral.body, color.opacity(0.4), color),
145        };
146
147        let actual_text_color = if self.effect == TagEffect::Dark {
148            theme.neutral.inverted
149        } else {
150            text_color
151        };
152
153        let (padding_x, height, text_size) = match self.size {
154            TagSize::Small => (px(8.0), px(20.0), px(11.0)),
155            TagSize::Default => (px(10.0), px(24.0), px(12.0)),
156            TagSize::Large => (px(12.0), px(32.0), px(14.0)),
157        };
158
159        let radius = if self.round {
160            height / 2.0
161        } else {
162            px(theme.radius.sm)
163        };
164
165        div()
166            .flex()
167            .items_center()
168            .justify_center()
169            .h(height)
170            .px(padding_x)
171            .bg(bg)
172            .border_1()
173            .border_color(border)
174            .rounded(radius)
175            .text_size(text_size)
176            .text_color(actual_text_color)
177            .child(div().child(self.label.clone()))
178            .when(self.closable, |s| {
179                let label = self.label.clone();
180                s.child(
181                    div()
182                        .id(element_id(format!("close-btn-{}", label)))
183                        .ml_1()
184                        .flex()
185                        .items_center()
186                        .justify_center()
187                        .cursor_pointer()
188                        .child(
189                            Icon::new(IconName::X)
190                                .size(px(10.0))
191                                .color(actual_text_color),
192                        )
193                        .hover(|s| s.bg(actual_text_color.opacity(0.2)).rounded(px(2.0)))
194                        .on_click(move |_, window, cx| {
195                            if let Some(ref f) = on_close {
196                                f(window, cx);
197                            }
198                        }),
199                )
200            })
201    }
202}
203
204impl IntoElement for Tag {
205    type Element = Component<Self>;
206    fn into_element(self) -> Self::Element {
207        Component::new(self)
208    }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
212pub enum TagFlowAlign {
213    #[default]
214    Start,
215    Center,
216    End,
217}
218
219pub struct TagFlow {
220    tags: Vec<AnyElement>,
221    gap: Pixels,
222    align: TagFlowAlign,
223    max_rows: Option<usize>,
224    estimated_items_per_row: usize,
225    collapsed: bool,
226    overflow_indicator: Option<SharedString>,
227}
228
229impl TagFlow {
230    pub fn new(tags: impl IntoIterator<Item = Tag>) -> Self {
231        Self {
232            tags: tags.into_iter().map(|tag| tag.into_any_element()).collect(),
233            gap: px(8.0),
234            align: TagFlowAlign::Start,
235            max_rows: None,
236            estimated_items_per_row: 4,
237            collapsed: false,
238            overflow_indicator: None,
239        }
240    }
241
242    pub fn from_elements(tags: impl IntoIterator<Item = impl IntoElement>) -> Self {
243        Self {
244            tags: tags.into_iter().map(|tag| tag.into_any_element()).collect(),
245            gap: px(8.0),
246            align: TagFlowAlign::Start,
247            max_rows: None,
248            estimated_items_per_row: 4,
249            collapsed: false,
250            overflow_indicator: None,
251        }
252    }
253
254    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
255        self.gap = gap.into().max(px(0.0));
256        self
257    }
258
259    pub fn align(mut self, align: TagFlowAlign) -> Self {
260        self.align = align;
261        self
262    }
263
264    pub fn center(self) -> Self {
265        self.align(TagFlowAlign::Center)
266    }
267
268    pub fn end(self) -> Self {
269        self.align(TagFlowAlign::End)
270    }
271
272    pub fn max_rows(mut self, rows: usize) -> Self {
273        self.max_rows = Some(rows.max(1));
274        self.collapsed = true;
275        self
276    }
277
278    pub fn estimated_items_per_row(mut self, count: usize) -> Self {
279        self.estimated_items_per_row = count.max(1);
280        self
281    }
282
283    pub fn collapsed(mut self, collapsed: bool) -> Self {
284        self.collapsed = collapsed;
285        self
286    }
287
288    pub fn expanded(self) -> Self {
289        self.collapsed(false)
290    }
291
292    pub fn overflow_indicator(mut self, label: impl Into<SharedString>) -> Self {
293        self.overflow_indicator = Some(label.into());
294        self
295    }
296
297    fn visible_count(&self) -> usize {
298        if !self.collapsed {
299            return self.tags.len();
300        }
301        self.max_rows
302            .map(|rows| rows.saturating_mul(self.estimated_items_per_row))
303            .unwrap_or(self.tags.len())
304            .min(self.tags.len())
305    }
306}
307
308impl RenderOnce for TagFlow {
309    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
310        let visible_count = self.visible_count();
311        let hidden_count = self.tags.len().saturating_sub(visible_count);
312        let overflow_label = self
313            .overflow_indicator
314            .clone()
315            .unwrap_or_else(|| format!("+{hidden_count}").into());
316        let tags = self
317            .tags
318            .into_iter()
319            .take(visible_count)
320            .chain((hidden_count > 0).then(|| {
321                Tag::new(overflow_label)
322                    .plain()
323                    .round(true)
324                    .into_any_element()
325            }));
326
327        div()
328            .flex()
329            .flex_wrap()
330            .gap(self.gap)
331            .when(self.align == TagFlowAlign::Center, |s| s.justify_center())
332            .when(self.align == TagFlowAlign::End, |s| s.justify_end())
333            .children(tags)
334    }
335}
336
337impl IntoElement for TagFlow {
338    type Element = Component<Self>;
339
340    fn into_element(self) -> Self::Element {
341        Component::new(self)
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn tag_flow_tracks_gap_and_alignment() {
351        let flow = TagFlow::new([Tag::new("A"), Tag::new("B")])
352            .gap(px(12.0))
353            .center();
354
355        assert_eq!(flow.gap, px(12.0));
356        assert_eq!(flow.align, TagFlowAlign::Center);
357        assert_eq!(flow.tags.len(), 2);
358    }
359
360    #[test]
361    fn tag_flow_tracks_collapse_options() {
362        let flow = TagFlow::new([
363            Tag::new("A"),
364            Tag::new("B"),
365            Tag::new("C"),
366            Tag::new("D"),
367            Tag::new("E"),
368        ])
369        .max_rows(2)
370        .estimated_items_per_row(2)
371        .overflow_indicator("more");
372
373        assert_eq!(flow.visible_count(), 4);
374        assert_eq!(flow.max_rows, Some(2));
375        assert_eq!(flow.estimated_items_per_row, 2);
376        assert!(flow.collapsed);
377    }
378}