revue/widget/
tag.rs

1//! Tag/Chip widget for labels and categories
2
3use super::traits::{RenderContext, View, WidgetProps};
4use crate::render::{Cell, Modifier};
5use crate::style::Color;
6use crate::{impl_props_builders, impl_styled_view};
7
8/// Tag style variant
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum TagStyle {
11    /// Filled background (default)
12    #[default]
13    Filled,
14    /// Outlined with border
15    Outlined,
16    /// Subtle/light background
17    Subtle,
18}
19
20/// A tag/chip widget for categories and labels
21///
22/// # Example
23///
24/// ```rust,ignore
25/// use revue::prelude::*;
26///
27/// hstack()
28///     .child(tag("Rust").color(Color::BLUE))
29///     .child(tag("TUI").outlined())
30///     .child(tag("Framework").closable())
31/// ```
32pub struct Tag {
33    /// Label text
34    text: String,
35    /// Color
36    color: Color,
37    /// Text color (auto-calculated if not set)
38    text_color: Option<Color>,
39    /// Style
40    style: TagStyle,
41    /// Is closable (shows x)
42    closable: bool,
43    /// Icon before text
44    icon: Option<char>,
45    /// Is selected/active
46    selected: bool,
47    /// Is disabled
48    disabled: bool,
49    /// Widget props for CSS integration
50    props: WidgetProps,
51}
52
53impl Tag {
54    /// Create a new tag
55    pub fn new(text: impl Into<String>) -> Self {
56        Self {
57            text: text.into(),
58            color: Color::rgb(80, 80, 80),
59            text_color: None,
60            style: TagStyle::Filled,
61            closable: false,
62            icon: None,
63            selected: false,
64            disabled: false,
65            props: WidgetProps::new(),
66        }
67    }
68
69    /// Set color
70    pub fn color(mut self, color: Color) -> Self {
71        self.color = color;
72        self
73    }
74
75    /// Set text color
76    pub fn text_color(mut self, color: Color) -> Self {
77        self.text_color = Some(color);
78        self
79    }
80
81    /// Set style
82    pub fn style(mut self, style: TagStyle) -> Self {
83        self.style = style;
84        self
85    }
86
87    /// Outlined style shorthand
88    pub fn outlined(mut self) -> Self {
89        self.style = TagStyle::Outlined;
90        self
91    }
92
93    /// Subtle style shorthand
94    pub fn subtle(mut self) -> Self {
95        self.style = TagStyle::Subtle;
96        self
97    }
98
99    /// Make closable
100    pub fn closable(mut self) -> Self {
101        self.closable = true;
102        self
103    }
104
105    /// Set icon
106    pub fn icon(mut self, icon: char) -> Self {
107        self.icon = Some(icon);
108        self
109    }
110
111    /// Mark as selected
112    pub fn selected(mut self) -> Self {
113        self.selected = true;
114        self
115    }
116
117    /// Mark as disabled
118    pub fn disabled(mut self) -> Self {
119        self.disabled = true;
120        self
121    }
122
123    /// Blue color preset
124    pub fn blue(mut self) -> Self {
125        self.color = Color::rgb(60, 120, 200);
126        self
127    }
128
129    /// Green color preset
130    pub fn green(mut self) -> Self {
131        self.color = Color::rgb(40, 160, 80);
132        self
133    }
134
135    /// Red color preset
136    pub fn red(mut self) -> Self {
137        self.color = Color::rgb(200, 60, 60);
138        self
139    }
140
141    /// Yellow color preset
142    pub fn yellow(mut self) -> Self {
143        self.color = Color::rgb(200, 180, 40);
144        self
145    }
146
147    /// Purple color preset
148    pub fn purple(mut self) -> Self {
149        self.color = Color::rgb(140, 80, 180);
150        self
151    }
152
153    /// Get effective colors
154    fn effective_colors(&self) -> (Option<Color>, Color) {
155        let text_color = self.text_color.unwrap_or(Color::WHITE);
156
157        if self.disabled {
158            return (Some(Color::rgb(60, 60, 60)), Color::rgb(120, 120, 120));
159        }
160
161        match self.style {
162            TagStyle::Filled => (Some(self.color), text_color),
163            TagStyle::Outlined => (None, self.color),
164            TagStyle::Subtle => {
165                // Lighten the color for background
166                let light_bg = Color::rgb(
167                    self.color.r.saturating_add(180),
168                    self.color.g.saturating_add(180),
169                    self.color.b.saturating_add(180),
170                );
171                (Some(light_bg), self.color)
172            }
173        }
174    }
175}
176
177impl Default for Tag {
178    fn default() -> Self {
179        Self::new("")
180    }
181}
182
183impl View for Tag {
184    crate::impl_view_meta!("Tag");
185
186    fn render(&self, ctx: &mut RenderContext) {
187        let area = ctx.area;
188        let (bg, fg) = self.effective_colors();
189
190        let mut content = String::new();
191
192        // Icon
193        if let Some(icon) = self.icon {
194            content.push(icon);
195            content.push(' ');
196        }
197
198        // Text
199        content.push_str(&self.text);
200
201        // Close button
202        if self.closable {
203            content.push_str(" ×");
204        }
205
206        let _text_len = content.chars().count() as u16;
207
208        // Border characters for outlined
209        let (left_char, right_char) = match self.style {
210            TagStyle::Outlined => ('⟨', '⟩'),
211            _ => (' ', ' '),
212        };
213
214        // Render
215        let mut x = area.x;
216
217        // Left padding/border
218        let mut left = Cell::new(left_char);
219        if let Some(bg_color) = bg {
220            left.bg = Some(bg_color);
221        }
222        left.fg = Some(fg);
223        ctx.buffer.set(x, area.y, left);
224        x += 1;
225
226        // Content
227        for ch in content.chars() {
228            if x >= area.x + area.width - 1 {
229                break;
230            }
231            let mut cell = Cell::new(ch);
232            cell.fg = Some(fg);
233            if let Some(bg_color) = bg {
234                cell.bg = Some(bg_color);
235            }
236            if self.selected {
237                cell.modifier |= Modifier::BOLD;
238            }
239            if self.disabled {
240                cell.modifier |= Modifier::DIM;
241            }
242            ctx.buffer.set(x, area.y, cell);
243            x += 1;
244        }
245
246        // Right padding/border
247        if x < area.x + area.width {
248            let mut right = Cell::new(right_char);
249            if let Some(bg_color) = bg {
250                right.bg = Some(bg_color);
251            }
252            right.fg = Some(fg);
253            ctx.buffer.set(x, area.y, right);
254        }
255    }
256}
257
258impl_styled_view!(Tag);
259impl_props_builders!(Tag);
260
261/// Create a new tag
262pub fn tag(text: impl Into<String>) -> Tag {
263    Tag::new(text)
264}
265
266/// Create a new chip (alias for tag)
267pub fn chip(text: impl Into<String>) -> Tag {
268    Tag::new(text)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::layout::Rect;
275    use crate::render::Buffer;
276
277    #[test]
278    fn test_tag_new() {
279        let t = Tag::new("Rust");
280        assert_eq!(t.text, "Rust");
281        assert!(!t.closable);
282    }
283
284    #[test]
285    fn test_tag_styles() {
286        let t = tag("Test").outlined();
287        assert_eq!(t.style, TagStyle::Outlined);
288
289        let t = tag("Test").subtle();
290        assert_eq!(t.style, TagStyle::Subtle);
291    }
292
293    #[test]
294    fn test_tag_colors() {
295        let t = tag("Test").blue();
296        assert_eq!(t.color, Color::rgb(60, 120, 200));
297
298        let t = tag("Test").red();
299        assert_eq!(t.color, Color::rgb(200, 60, 60));
300    }
301
302    #[test]
303    fn test_tag_closable() {
304        let t = tag("Test").closable();
305        assert!(t.closable);
306    }
307
308    #[test]
309    fn test_tag_icon() {
310        let t = tag("Rust").icon('🦀');
311        assert_eq!(t.icon, Some('🦀'));
312    }
313
314    #[test]
315    fn test_tag_selected_disabled() {
316        let t = tag("Test").selected().disabled();
317        assert!(t.selected);
318        assert!(t.disabled);
319    }
320
321    #[test]
322    fn test_tag_render() {
323        let mut buffer = Buffer::new(20, 1);
324        let area = Rect::new(0, 0, 20, 1);
325        let mut ctx = RenderContext::new(&mut buffer, area);
326
327        let t = tag("Rust").blue();
328        t.render(&mut ctx);
329
330        let text: String = (0..20)
331            .filter_map(|x| buffer.get(x, 0).map(|c| c.symbol))
332            .collect();
333        assert!(text.contains("Rust"));
334    }
335
336    #[test]
337    fn test_helper_functions() {
338        let t = tag("A");
339        assert_eq!(t.text, "A");
340
341        let c = chip("B");
342        assert_eq!(c.text, "B");
343    }
344}