Skip to main content

revue/widget/display/
text.rs

1//! Text widget
2//!
3//! A simple text widget that internally uses RichText for rendering.
4//! This ensures consistent text rendering across all widgets.
5
6use super::richtext::{RichText, Style};
7use crate::style::Color;
8use crate::widget::traits::{RenderContext, View, WidgetProps};
9use crate::{impl_props_builders, impl_styled_view};
10
11/// Text alignment
12#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
13pub enum Alignment {
14    /// Left-aligned text (default)
15    #[default]
16    Left,
17    /// Center-aligned text
18    Center,
19    /// Right-aligned text
20    Right,
21    /// Justified text (both edges aligned)
22    Justify,
23}
24
25/// A text display widget
26#[derive(Clone, Debug)]
27pub struct Text {
28    content: String,
29    fg: Option<Color>,
30    bg: Option<Color>,
31    bold: bool,
32    italic: bool,
33    underline: bool,
34    dim: bool,
35    reverse: bool,
36    align: Alignment,
37    /// CSS styling properties (id, classes)
38    props: WidgetProps,
39}
40
41impl Text {
42    /// Create a new text widget
43    pub fn new(content: impl Into<String>) -> Self {
44        Self {
45            content: content.into(),
46            fg: None,
47            bg: None,
48            bold: false,
49            italic: false,
50            underline: false,
51            dim: false,
52            reverse: false,
53            align: Alignment::Left,
54            props: WidgetProps::new(),
55        }
56    }
57
58    // ─────────────────────────────────────────────────────────────────────────
59    // Preset builders
60    // ─────────────────────────────────────────────────────────────────────────
61
62    /// Create a heading (bold white text)
63    pub fn heading(content: impl Into<String>) -> Self {
64        Self::new(content).bold().fg(Color::WHITE)
65    }
66
67    /// Create muted/secondary text (dimmed gray)
68    pub fn muted(content: impl Into<String>) -> Self {
69        Self::new(content).fg(Color::rgb(128, 128, 128))
70    }
71
72    /// Create error text (red)
73    pub fn error(content: impl Into<String>) -> Self {
74        Self::new(content).fg(Color::RED)
75    }
76
77    /// Create success text (green)
78    pub fn success(content: impl Into<String>) -> Self {
79        Self::new(content).fg(Color::GREEN)
80    }
81
82    /// Create warning text (yellow)
83    pub fn warning(content: impl Into<String>) -> Self {
84        Self::new(content).fg(Color::YELLOW)
85    }
86
87    /// Create info text (cyan)
88    pub fn info(content: impl Into<String>) -> Self {
89        Self::new(content).fg(Color::CYAN)
90    }
91
92    /// Create a label (bold)
93    pub fn label(content: impl Into<String>) -> Self {
94        Self::new(content).bold()
95    }
96
97    // ─────────────────────────────────────────────────────────────────────────
98    // Builder methods
99    // ─────────────────────────────────────────────────────────────────────────
100
101    /// Set foreground color
102    pub fn fg(mut self, color: Color) -> Self {
103        self.fg = Some(color);
104        self
105    }
106
107    /// Set background color
108    pub fn bg(mut self, color: Color) -> Self {
109        self.bg = Some(color);
110        self
111    }
112
113    /// Make text bold
114    pub fn bold(mut self) -> Self {
115        self.bold = true;
116        self
117    }
118
119    /// Make text italic
120    pub fn italic(mut self) -> Self {
121        self.italic = true;
122        self
123    }
124
125    /// Underline text
126    pub fn underline(mut self) -> Self {
127        self.underline = true;
128        self
129    }
130
131    /// Dim text (reduced intensity/bright)
132    pub fn dim(mut self) -> Self {
133        self.dim = true;
134        self
135    }
136
137    /// Reverse video (swap foreground/background colors)
138    pub fn reverse(mut self) -> Self {
139        self.reverse = true;
140        self
141    }
142
143    /// Set text alignment
144    pub fn align(mut self, align: Alignment) -> Self {
145        self.align = align;
146        self
147    }
148
149    /// Get the text content
150    pub fn content(&self) -> &str {
151        &self.content
152    }
153}
154
155impl Text {
156    /// Convert to RichText for rendering with CSS support
157    fn to_rich_text_with_ctx(&self, ctx: &RenderContext) -> RichText {
158        let mut style = Style::new();
159
160        // Get foreground color: inline > CSS > none
161        let fg = self.fg.or_else(|| {
162            ctx.style.and_then(|s| {
163                let c = s.visual.color;
164                if c != Color::default() {
165                    Some(c)
166                } else {
167                    None
168                }
169            })
170        });
171        if let Some(fg) = fg {
172            style = style.fg(fg);
173        }
174
175        // Get background color: inline > CSS > none
176        let bg = self.bg.or_else(|| {
177            ctx.style.and_then(|s| {
178                let c = s.visual.background;
179                if c != Color::default() {
180                    Some(c)
181                } else {
182                    None
183                }
184            })
185        });
186        if let Some(bg) = bg {
187            style = style.bg(bg);
188        }
189
190        if self.bold {
191            style = style.bold();
192        }
193        if self.italic {
194            style = style.italic();
195        }
196        if self.underline {
197            style = style.underline();
198        }
199        if self.dim {
200            style = style.dim();
201        }
202        if self.reverse {
203            style = style.reverse();
204        }
205
206        RichText::new().push(&self.content, style)
207    }
208
209    /// Render text with justify alignment (distribute space between words)
210    fn render_justified(&self, ctx: &mut RenderContext) {
211        use crate::render::{Cell, Modifier};
212        use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
213
214        let area = ctx.area;
215        let words: Vec<&str> = self.content.split_whitespace().collect();
216
217        // If no words or single word, fall back to left alignment
218        if words.len() <= 1 {
219            let rich_text = self.to_rich_text_with_ctx(ctx);
220            rich_text.render(ctx);
221            return;
222        }
223
224        // Calculate total text width (without spaces)
225        let text_width: usize = words.iter().map(|w| w.width()).sum();
226        let available_width = area.width as usize;
227
228        // If text is too wide, fall back to left alignment
229        if text_width >= available_width {
230            let rich_text = self.to_rich_text_with_ctx(ctx);
231            rich_text.render(ctx);
232            return;
233        }
234
235        // Calculate space distribution
236        let total_space = available_width - text_width;
237        let gap_count = words.len() - 1;
238        let base_space = total_space / gap_count;
239        let extra_spaces = total_space % gap_count;
240
241        // Build modifier from style
242        let mut modifier = Modifier::empty();
243        if self.bold {
244            modifier |= Modifier::BOLD;
245        }
246        if self.italic {
247            modifier |= Modifier::ITALIC;
248        }
249        if self.underline {
250            modifier |= Modifier::UNDERLINE;
251        }
252        if self.dim {
253            modifier |= Modifier::DIM;
254        }
255        if self.reverse {
256            modifier |= Modifier::REVERSE;
257        }
258
259        // Render words with distributed spacing
260        let mut x = area.x;
261        for (i, word) in words.iter().enumerate() {
262            // Render word
263            for ch in word.chars() {
264                if x >= area.x + area.width {
265                    break;
266                }
267                let mut cell = Cell::new(ch);
268                cell.fg = self.fg;
269                cell.bg = self.bg;
270                cell.modifier = modifier;
271                ctx.buffer.set(x, area.y, cell);
272                x += UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
273            }
274
275            // Add spacing after word (except last word)
276            if i < gap_count {
277                let spaces = base_space + if i < extra_spaces { 1 } else { 0 };
278                x += spaces as u16;
279            }
280        }
281    }
282}
283
284impl View for Text {
285    fn render(&self, ctx: &mut RenderContext) {
286        let area = ctx.area;
287        if area.width == 0 || area.height == 0 {
288            return;
289        }
290
291        // Handle Justify alignment specially
292        if self.align == Alignment::Justify {
293            self.render_justified(ctx);
294            return;
295        }
296
297        // Extract CSS colors before creating adjusted context (avoids borrow conflict)
298        let rich_text = self.to_rich_text_with_ctx(ctx);
299
300        // Calculate start position based on alignment
301        let text_width = unicode_width::UnicodeWidthStr::width(self.content.as_str()) as u16;
302        let x_offset = match self.align {
303            Alignment::Left | Alignment::Justify => 0,
304            Alignment::Center => area.width.saturating_sub(text_width) / 2,
305            Alignment::Right => area.width.saturating_sub(text_width),
306        };
307
308        // Create adjusted context with alignment offset
309        let adjusted_area = crate::layout::Rect::new(
310            area.x + x_offset,
311            area.y,
312            area.width.saturating_sub(x_offset),
313            area.height,
314        );
315        let mut adjusted_ctx = RenderContext::new(ctx.buffer, adjusted_area);
316
317        // Delegate to RichText for actual rendering
318        rich_text.render(&mut adjusted_ctx);
319    }
320
321    crate::impl_view_meta!("Text");
322}
323
324impl Default for Text {
325    fn default() -> Self {
326        Self::new("")
327    }
328}
329
330impl_styled_view!(Text);
331impl_props_builders!(Text);
332
333// Most tests moved to tests/widget_tests.rs
334// Tests below access private fields and must stay inline
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_text_builder() {
342        let text = Text::new("Test")
343            .fg(Color::RED)
344            .bold()
345            .align(Alignment::Center);
346
347        assert_eq!(text.fg, Some(Color::RED));
348        assert!(text.bold);
349        assert_eq!(text.align, Alignment::Center);
350    }
351
352    // Edge case tests for text content handling
353    #[test]
354    fn test_text_empty_content() {
355        // Text widget should handle empty content gracefully
356        let text = Text::new("");
357        assert_eq!(text.content, "");
358    }
359
360    #[test]
361    fn test_text_whitespace_only() {
362        // Text widget should handle whitespace-only content
363        let text = Text::new("   ");
364        assert_eq!(text.content, "   ");
365    }
366
367    #[test]
368    fn test_text_newlines() {
369        // Text widget should handle newlines
370        let text = Text::new("line1\nline2\nline3");
371        assert_eq!(text.content, "line1\nline2\nline3");
372    }
373
374    #[test]
375    fn test_text_tabs() {
376        // Text widget should handle tabs
377        let text = Text::new("col1\tcol2");
378        assert_eq!(text.content, "col1\tcol2");
379    }
380
381    #[test]
382    fn test_text_special_characters() {
383        // Text widget should handle special characters
384        let special = "!@#$%^&*()_+-=[]{}|;':\",./<>?";
385        let text = Text::new(special);
386        assert_eq!(text.content, special);
387    }
388
389    #[test]
390    fn test_text_emoji() {
391        // Text widget should handle emoji (multi-byte UTF-8)
392        let emoji = "😀😁😂🤣";
393        let text = Text::new(emoji);
394        assert_eq!(text.content, emoji);
395    }
396
397    #[test]
398    fn test_text_mixed_unicode() {
399        // Text widget should handle mixed ASCII and Unicode
400        let mixed = "Hello 世界! 🌍";
401        let text = Text::new(mixed);
402        assert_eq!(text.content, mixed);
403    }
404
405    #[test]
406    fn test_text_zero_width_joiners() {
407        // Text widget should handle zero-width joiners
408        let text = Text::new("e\u{200d}"); // 'e' + zero-width joiner
409        assert_eq!(text.content, "e\u{200d}");
410    }
411
412    #[test]
413    fn test_text_very_long_single_line() {
414        // Text widget should handle very long single lines
415        let long = "x".repeat(10000);
416        let text = Text::new(&long);
417        assert_eq!(text.content, long);
418    }
419
420    #[test]
421    fn test_text_null_bytes_not_allowed() {
422        // Rust strings don't allow null bytes, but we should handle
423        // edge case where external input might contain them
424        // This just verifies our type system handles it correctly
425        let text = Text::new("valid string");
426        assert_eq!(text.content, "valid string");
427    }
428
429    #[test]
430    fn test_text_with_styled_modifiers() {
431        // Text widget should handle modifiers with edge case content
432        let text = Text::new("  ").bold().italic();
433        assert_eq!(text.content, "  ");
434        assert!(text.bold);
435        assert!(text.italic);
436    }
437
438    #[test]
439    fn test_text_builder_chaining() {
440        // Text builder should support method chaining
441        let text = Text::new("Test")
442            .fg(Color::RED)
443            .bg(Color::BLUE)
444            .bold()
445            .italic()
446            .underline()
447            .dim();
448
449        assert_eq!(text.content, "Test");
450        assert_eq!(text.fg, Some(Color::RED));
451        assert_eq!(text.bg, Some(Color::BLUE));
452        assert!(text.bold);
453        assert!(text.italic);
454        assert!(text.underline);
455        assert!(text.dim);
456    }
457
458    #[test]
459    fn test_text_all_alignments() {
460        // Test all alignment options work
461        for align in &[Alignment::Left, Alignment::Center, Alignment::Right] {
462            let text = Text::new("Test").align(*align);
463            assert_eq!(text.align, *align);
464        }
465    }
466
467    #[test]
468    fn test_text_with_ansi_codes() {
469        // Text widget should handle ANSI escape sequences
470        let ansi = "\x1b[31mRed text\x1b[0m";
471        let text = Text::new(ansi);
472        assert_eq!(text.content, ansi);
473    }
474
475    #[test]
476    fn test_text_combining_diacritics() {
477        // Text widget should handle combining diacritical marks
478        let text = Text::new("café"); // precomposed é
479        assert_eq!(text.content, "café");
480
481        let text2 = Text::new("cafe\u{301}"); // e + combining acute
482        assert_eq!(text2.content, "cafe\u{301}");
483    }
484}