rab/tui/components/
text.rs1use crate::tui::Component;
2use crate::tui::Style;
3use crate::tui::util::{visible_width, wrap_text_with_ansi};
4
5pub struct Text {
8 content: String,
9 padding_x: usize,
10 padding_y: usize,
11 bg_style: Option<Style>,
12 cached_content: Option<String>,
14 cached_width: Option<usize>,
15 cached_lines: Vec<String>,
16}
17
18impl Text {
19 pub fn new(
20 content: impl Into<String>,
21 padding_x: usize,
22 padding_y: usize,
23 bg_style: Option<Style>,
24 ) -> Self {
25 Self {
26 content: content.into(),
27 padding_x,
28 padding_y,
29 bg_style,
30 cached_content: None,
31 cached_width: None,
32 cached_lines: Vec::new(),
33 }
34 }
35
36 pub fn set_text(&mut self, content: impl Into<String>) {
37 self.content = content.into();
38 self.invalidate();
39 }
40
41 pub fn set_bg_style(&mut self, bg_style: Option<Style>) {
42 self.bg_style = bg_style;
43 self.invalidate();
44 }
45}
46
47impl Component for Text {
48 fn render(&mut self, width: usize) -> Vec<String> {
49 if self.cached_content.as_deref() == Some(&self.content) && self.cached_width == Some(width)
51 {
52 return self.cached_lines.clone();
53 }
54
55 if self.content.is_empty() || self.content.trim().is_empty() {
57 return Vec::new();
58 }
59
60 let normalized = self.content.replace('\t', " ");
62
63 let content_width = width.saturating_sub(2 * self.padding_x).max(1);
65 let left_margin = " ".repeat(self.padding_x);
66
67 let wrapped = wrap_text_with_ansi(&normalized, content_width);
69
70 let mut content_lines: Vec<String> = Vec::new();
71 for line in wrapped {
72 let line_with_margins = format!("{}{}{}", left_margin, line, left_margin);
73 let vw = visible_width(&line_with_margins);
74 let padded = if vw < width {
75 format!("{}{}", line_with_margins, " ".repeat(width - vw))
76 } else {
77 line_with_margins
78 };
79 let line = match self.bg_style {
80 Some(ref style) => style.apply(&padded),
81 None => padded,
82 };
83 content_lines.push(line);
84 }
85
86 let empty_line = " ".repeat(width);
87 let empty_with_bg = self
88 .bg_style
89 .as_ref()
90 .map(|style| style.apply(&empty_line))
91 .unwrap_or_else(|| empty_line.clone());
92
93 let mut result = Vec::new();
94 for _ in 0..self.padding_y {
95 result.push(empty_with_bg.clone());
96 }
97 result.extend(content_lines);
98 for _ in 0..self.padding_y {
99 result.push(empty_with_bg.clone());
100 }
101
102 self.cached_content = Some(self.content.clone());
104 self.cached_width = Some(width);
105 self.cached_lines = result.clone();
106
107 result
108 }
109
110 fn invalidate(&mut self) {
111 self.cached_content = None;
112 self.cached_width = None;
113 self.cached_lines.clear();
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn test_basic_render() {
123 let mut text = Text::new("hello", 1, 0, None);
124 let lines = text.render(20);
125 assert!(!lines.is_empty());
126 assert!(lines[0].contains("hello"));
127 }
128
129 #[test]
130 fn test_width_respected() {
131 let mut text = Text::new("hello world this is a long line", 1, 0, None);
132 let lines = text.render(10);
133 for line in &lines {
134 assert!(visible_width(line) <= 10);
135 }
136 }
137
138 #[test]
139 fn test_padding() {
140 let mut text = Text::new("hi", 2, 1, None);
141 let lines = text.render(10);
142 assert_eq!(lines.len(), 3);
143 }
144
145 #[test]
146 fn test_cache_hit() {
147 let mut text = Text::new("hello", 1, 0, None);
148 let a = text.render(20);
149 let b = text.render(20);
150 assert_eq!(a, b);
151 }
152}