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