Skip to main content

tui_lipan/widgets/
badge.rs

1//! Badge widget.
2
3use std::sync::Arc;
4
5use crate::core::element::{Element, IntoElement};
6use crate::style::{BorderStyle, Color, Length, Padding, Style};
7use crate::widgets::{CapSides, CapStyle, Frame, HStack, Spacer, Text, VStack, ZStack};
8
9use super::segment_cap::segment_cap;
10
11/// Badge position relative to its child.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
13pub enum BadgePosition {
14    /// Top-left corner.
15    TopStart,
16    /// Top-right corner.
17    #[default]
18    TopEnd,
19    /// Bottom-left corner.
20    BottomStart,
21    /// Bottom-right corner.
22    BottomEnd,
23}
24
25/// A badge widget.
26#[derive(Clone)]
27pub struct Badge {
28    content: Arc<str>,
29    child: Element,
30    style: Style,
31    text_style: Style,
32    border: bool,
33    border_style: BorderStyle,
34    padding: Padding,
35    offset: Padding,
36    position: BadgePosition,
37    width: Length,
38    height: Length,
39    cap_style: CapStyle,
40    cap_sides: CapSides,
41    cap_behind: Color,
42    cap_same_color: bool,
43}
44
45impl Badge {
46    /// Create a new badge with the given content.
47    pub fn new(content: impl Into<Arc<str>>) -> Self {
48        Self {
49            content: content.into(),
50            child: crate::widgets::Spacer::new().into(),
51            style: Style::default(),
52            text_style: Style::default(),
53            border: false,
54            border_style: BorderStyle::Plain,
55            padding: 0.into(),
56            offset: 0.into(),
57            position: BadgePosition::TopEnd,
58            width: Length::Auto,
59            height: Length::Auto,
60            cap_style: CapStyle::Padded,
61            cap_sides: CapSides::Both,
62            cap_behind: Color::Reset,
63            cap_same_color: false,
64        }
65    }
66
67    /// Set the child element.
68    pub fn child(mut self, child: impl IntoElement) -> Self {
69        self.child = child.into();
70        self
71    }
72
73    /// Set badge style.
74    pub fn style(mut self, style: Style) -> Self {
75        self.style = style;
76        self
77    }
78
79    /// Set badge text style.
80    pub fn text_style(mut self, style: Style) -> Self {
81        self.text_style = style;
82        self
83    }
84
85    /// Draw a border around the badge.
86    pub fn border(mut self, border: bool) -> Self {
87        self.border = border;
88        self
89    }
90
91    /// Set badge border style.
92    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
93        self.border_style = border_style;
94        self
95    }
96
97    /// Set badge padding.
98    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
99        self.padding = padding.into();
100        self
101    }
102
103    /// Set offset from the chosen corner.
104    pub fn offset(mut self, offset: impl Into<Padding>) -> Self {
105        self.offset = offset.into();
106        self
107    }
108
109    /// Set badge position.
110    pub fn position(mut self, position: BadgePosition) -> Self {
111        self.position = position;
112        self
113    }
114
115    /// Set width.
116    pub fn width(mut self, width: Length) -> Self {
117        self.width = width;
118        self
119    }
120
121    /// Set height.
122    pub fn height(mut self, height: Length) -> Self {
123        self.height = height;
124        self
125    }
126
127    /// Set the cap style used around the badge segment.
128    ///
129    /// [`CapStyle::Round`] and [`CapStyle::Arrow`] use Nerd Font Powerline glyphs;
130    /// call [`CapStyle::font_safe`] when a font-independent fallback is needed.
131    /// With automatic width, each rendered cap can replace one cell of padding
132    /// or one edge space in the label. Caps sit outside an explicit inner width.
133    pub fn cap(mut self, cap_style: CapStyle) -> Self {
134        self.cap_style = cap_style;
135        self
136    }
137
138    /// Set which sides of the badge segment receive caps.
139    pub fn cap_sides(mut self, cap_sides: CapSides) -> Self {
140        self.cap_sides = cap_sides;
141        self
142    }
143
144    /// Set the color painted behind cap glyphs.
145    pub fn cap_behind(mut self, color: Color) -> Self {
146        self.cap_behind = color;
147        self
148    }
149
150    /// Keep the left seam visible when this badge and its neighbor share a background.
151    ///
152    /// Arrow caps use the Powerline thin separator (`U+E0B3`); other styles use
153    /// the font-safe left eighth block (`U+258F`).
154    pub fn cap_same_color(mut self, same_color: bool) -> Self {
155        self.cap_same_color = same_color;
156        self
157    }
158}
159
160impl From<Badge> for Element {
161    fn from(badge: Badge) -> Self {
162        let badge_style = badge.style;
163        let text_style = badge_style.patch(badge.text_style);
164        let badge_bg = badge_style
165            .bg
166            .map(crate::style::Paint::color)
167            .unwrap_or(crate::style::Color::Reset);
168
169        let (content, padding) = replace_padding_with_caps(
170            badge.content,
171            badge.padding,
172            badge.cap_style,
173            badge.cap_sides,
174            badge.cap_same_color,
175        );
176
177        let badge_el = Frame::new()
178            .border(badge.border)
179            .border_style(badge.border_style)
180            .padding(padding)
181            .style(badge_style)
182            .child(Text::new(content).style(text_style))
183            .width(badge.width)
184            .height(badge.height);
185
186        let badge_el = segment_cap(
187            badge_el.into(),
188            badge.cap_style,
189            badge.cap_sides,
190            badge_bg,
191            badge.cap_behind,
192            badge.cap_same_color,
193        );
194
195        let overlay_row = match badge.position {
196            BadgePosition::TopStart | BadgePosition::BottomStart => {
197                HStack::new().child(badge_el).child(Spacer::new())
198            }
199            BadgePosition::TopEnd | BadgePosition::BottomEnd => {
200                HStack::new().child(Spacer::new()).child(badge_el)
201            }
202        };
203
204        let overlay_column = match badge.position {
205            BadgePosition::TopStart | BadgePosition::TopEnd => {
206                VStack::new().child(overlay_row).child(Spacer::new())
207            }
208            BadgePosition::BottomStart | BadgePosition::BottomEnd => {
209                VStack::new().child(Spacer::new()).child(overlay_row)
210            }
211        };
212
213        let overlay = overlay_column.padding(badge.offset);
214
215        ZStack::new()
216            .passthrough(true)
217            .child(badge.child)
218            .child(overlay)
219            .into()
220    }
221}
222
223/// Reduce auto-sized inner content so an outer cap can occupy the same measured
224/// cell. Prefer frame padding, then the edge spaces used by labels like `" MAIN "`.
225fn replace_padding_with_caps(
226    mut content: Arc<str>,
227    mut padding: Padding,
228    cap_style: CapStyle,
229    cap_sides: CapSides,
230    same_color_left: bool,
231) -> (Arc<str>, Padding) {
232    let has_glyphs = cap_style.glyphs().is_some();
233    let replace_left = cap_sides.has_left() && (has_glyphs || same_color_left);
234    let replace_right = cap_sides.has_right() && has_glyphs;
235
236    if replace_left {
237        if padding.left > 0 {
238            padding.left -= 1;
239        } else if let Some(stripped) = content.strip_prefix(' ') {
240            content = Arc::from(stripped);
241        }
242    }
243    if replace_right {
244        if padding.right > 0 {
245            padding.right -= 1;
246        } else if let Some(stripped) = content.strip_suffix(' ') {
247            content = Arc::from(stripped);
248        }
249    }
250
251    (content, padding)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn caps_replace_label_spaces_without_changing_intrinsic_width() {
260        let (content, padding) = replace_padding_with_caps(
261            Arc::from(" MAIN "),
262            Padding::default(),
263            CapStyle::Half,
264            CapSides::Both,
265            false,
266        );
267
268        assert_eq!(content.as_ref(), "MAIN");
269        assert_eq!(padding, Padding::default());
270        assert_eq!(
271            2 + content.chars().count() + padding.horizontal() as usize,
272            6
273        );
274    }
275
276    #[test]
277    fn caps_prefer_explicit_padding_without_changing_intrinsic_width() {
278        let (content, padding) = replace_padding_with_caps(
279            Arc::from(" MAIN "),
280            Padding::from((0, 1)),
281            CapStyle::Round,
282            CapSides::Both,
283            false,
284        );
285
286        assert_eq!(content.as_ref(), " MAIN ");
287        assert_eq!(padding, Padding::default());
288        assert_eq!(
289            2 + content.chars().count() + padding.horizontal() as usize,
290            8
291        );
292    }
293
294    #[test]
295    fn padded_same_color_separator_replaces_left_padding() {
296        let (content, padding) = replace_padding_with_caps(
297            Arc::from(" READY "),
298            Padding::default(),
299            CapStyle::Padded,
300            CapSides::Both,
301            true,
302        );
303
304        assert_eq!(content.as_ref(), "READY ");
305        assert_eq!(padding, Padding::default());
306        assert_eq!(
307            1 + content.chars().count() + padding.horizontal() as usize,
308            7
309        );
310    }
311}