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}