Skip to main content

saorsa_tui/widget/
label.rs

1//! Label widget — a single line of styled text.
2
3use crate::buffer::ScreenBuffer;
4use crate::cell::Cell;
5use crate::geometry::Rect;
6use crate::style::Style;
7use unicode_width::UnicodeWidthStr;
8
9use super::Widget;
10
11/// Text alignment.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum Alignment {
14    /// Align to the left (default).
15    #[default]
16    Left,
17    /// Center the text.
18    Center,
19    /// Align to the right.
20    Right,
21}
22
23/// A single-line text label widget.
24#[derive(Clone, Debug)]
25pub struct Label {
26    /// The text to display.
27    text: String,
28    /// The style for the text.
29    style: Style,
30    /// Text alignment within the available area.
31    alignment: Alignment,
32}
33
34impl Label {
35    /// Create a new label with the given text.
36    pub fn new(text: impl Into<String>) -> Self {
37        Self {
38            text: text.into(),
39            style: Style::default(),
40            alignment: Alignment::Left,
41        }
42    }
43
44    /// Set the style.
45    #[must_use]
46    pub fn style(mut self, style: Style) -> Self {
47        self.style = style;
48        self
49    }
50
51    /// Set the alignment.
52    #[must_use]
53    pub fn alignment(mut self, alignment: Alignment) -> Self {
54        self.alignment = alignment;
55        self
56    }
57
58    /// Get the text.
59    pub fn text(&self) -> &str {
60        &self.text
61    }
62
63    /// Set new text content.
64    pub fn set_text(&mut self, text: impl Into<String>) {
65        self.text = text.into();
66    }
67
68    /// Get the current style.
69    pub fn style_ref(&self) -> &Style {
70        &self.style
71    }
72
73    /// Set the style.
74    pub fn set_style(&mut self, style: Style) {
75        self.style = style;
76    }
77
78    /// Get the current alignment.
79    pub fn alignment_value(&self) -> Alignment {
80        self.alignment
81    }
82
83    /// Set the alignment.
84    pub fn set_alignment(&mut self, alignment: Alignment) {
85        self.alignment = alignment;
86    }
87}
88
89impl Widget for Label {
90    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
91        if area.size.width == 0 || area.size.height == 0 {
92            return;
93        }
94
95        let width = usize::from(area.size.width);
96        let text_width = UnicodeWidthStr::width(self.text.as_str());
97
98        // If a background is set, fill the entire row. This makes
99        // `background` styling behave like a block element in the terminal.
100        if self.style.bg.is_some() {
101            for x in 0..area.size.width {
102                buf.set(
103                    area.position.x + x,
104                    area.position.y,
105                    Cell::new(" ", self.style.clone()),
106                );
107            }
108        }
109
110        // Truncate with ellipsis if needed
111        let display_text = if text_width > width {
112            truncate_with_ellipsis(&self.text, width)
113        } else {
114            self.text.clone()
115        };
116
117        let display_width = UnicodeWidthStr::width(display_text.as_str());
118
119        // Calculate horizontal offset based on alignment
120        let offset = match self.alignment {
121            Alignment::Left => 0,
122            Alignment::Center => (width.saturating_sub(display_width)) / 2,
123            Alignment::Right => width.saturating_sub(display_width),
124        };
125
126        // Write characters to buffer
127        let mut col = 0usize;
128        for ch in display_text.chars() {
129            let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
130            let x = area.position.x + (offset + col) as u16;
131            if x >= area.position.x + area.size.width {
132                break;
133            }
134            buf.set(
135                x,
136                area.position.y,
137                Cell::new(ch.to_string(), self.style.clone()),
138            );
139            col += ch_width;
140        }
141    }
142}
143
144/// Truncate a string to fit within `max_width` columns, adding an ellipsis.
145fn truncate_with_ellipsis(text: &str, max_width: usize) -> String {
146    if max_width == 0 {
147        return String::new();
148    }
149    if max_width == 1 {
150        return "\u{2026}".to_string(); // …
151    }
152
153    let target = max_width - 1; // leave room for ellipsis
154    let mut result = String::new();
155    let mut current_width = 0usize;
156
157    for ch in text.chars() {
158        let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
159        if current_width + ch_width > target {
160            break;
161        }
162        result.push(ch);
163        current_width += ch_width;
164    }
165    result.push('\u{2026}'); // …
166    result
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::color::{Color, NamedColor};
173    use crate::geometry::Size;
174
175    #[test]
176    fn label_renders_left_aligned() {
177        let label = Label::new("Hello");
178        let mut buf = ScreenBuffer::new(Size::new(10, 1));
179        label.render(Rect::new(0, 0, 10, 1), &mut buf);
180        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("H"));
181        assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some("o"));
182    }
183
184    #[test]
185    fn label_renders_center_aligned() {
186        let label = Label::new("Hi").alignment(Alignment::Center);
187        let mut buf = ScreenBuffer::new(Size::new(10, 1));
188        label.render(Rect::new(0, 0, 10, 1), &mut buf);
189        // "Hi" is 2 wide, centered in 10 → offset 4
190        assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some("H"));
191        assert_eq!(buf.get(5, 0).map(|c| c.grapheme.as_str()), Some("i"));
192    }
193
194    #[test]
195    fn label_renders_right_aligned() {
196        let label = Label::new("Hi").alignment(Alignment::Right);
197        let mut buf = ScreenBuffer::new(Size::new(10, 1));
198        label.render(Rect::new(0, 0, 10, 1), &mut buf);
199        // "Hi" is 2 wide, right-aligned in 10 → offset 8
200        assert_eq!(buf.get(8, 0).map(|c| c.grapheme.as_str()), Some("H"));
201        assert_eq!(buf.get(9, 0).map(|c| c.grapheme.as_str()), Some("i"));
202    }
203
204    #[test]
205    fn label_truncates_with_ellipsis() {
206        let label = Label::new("Hello, World!");
207        let mut buf = ScreenBuffer::new(Size::new(8, 1));
208        label.render(Rect::new(0, 0, 8, 1), &mut buf);
209        // Should truncate to "Hello, \u{2026}" (7 chars + ellipsis)
210        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("H"));
211        assert_eq!(buf.get(7, 0).map(|c| c.grapheme.as_str()), Some("\u{2026}"));
212    }
213
214    #[test]
215    fn label_with_style() {
216        let style = Style::new().fg(Color::Named(NamedColor::Red));
217        let label = Label::new("X").style(style.clone());
218        let mut buf = ScreenBuffer::new(Size::new(5, 1));
219        label.render(Rect::new(0, 0, 5, 1), &mut buf);
220        assert_eq!(buf.get(0, 0).map(|c| &c.style), Some(&style));
221    }
222
223    #[test]
224    fn label_empty_area() {
225        let label = Label::new("test");
226        let mut buf = ScreenBuffer::new(Size::new(10, 1));
227        // Should not crash on zero-width area
228        label.render(Rect::new(0, 0, 0, 1), &mut buf);
229    }
230
231    #[test]
232    fn label_set_text() {
233        let mut label = Label::new("before");
234        assert_eq!(label.text(), "before");
235        label.set_text("after");
236        assert_eq!(label.text(), "after");
237    }
238}