revue/widget/display/
text.rs1use 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#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
13pub enum Alignment {
14 #[default]
16 Left,
17 Center,
19 Right,
21 Justify,
23}
24
25#[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 props: WidgetProps,
39}
40
41impl Text {
42 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 pub fn heading(content: impl Into<String>) -> Self {
64 Self::new(content).bold().fg(Color::WHITE)
65 }
66
67 pub fn muted(content: impl Into<String>) -> Self {
69 Self::new(content).fg(Color::rgb(128, 128, 128))
70 }
71
72 pub fn error(content: impl Into<String>) -> Self {
74 Self::new(content).fg(Color::RED)
75 }
76
77 pub fn success(content: impl Into<String>) -> Self {
79 Self::new(content).fg(Color::GREEN)
80 }
81
82 pub fn warning(content: impl Into<String>) -> Self {
84 Self::new(content).fg(Color::YELLOW)
85 }
86
87 pub fn info(content: impl Into<String>) -> Self {
89 Self::new(content).fg(Color::CYAN)
90 }
91
92 pub fn label(content: impl Into<String>) -> Self {
94 Self::new(content).bold()
95 }
96
97 pub fn fg(mut self, color: Color) -> Self {
103 self.fg = Some(color);
104 self
105 }
106
107 pub fn bg(mut self, color: Color) -> Self {
109 self.bg = Some(color);
110 self
111 }
112
113 pub fn bold(mut self) -> Self {
115 self.bold = true;
116 self
117 }
118
119 pub fn italic(mut self) -> Self {
121 self.italic = true;
122 self
123 }
124
125 pub fn underline(mut self) -> Self {
127 self.underline = true;
128 self
129 }
130
131 pub fn dim(mut self) -> Self {
133 self.dim = true;
134 self
135 }
136
137 pub fn reverse(mut self) -> Self {
139 self.reverse = true;
140 self
141 }
142
143 pub fn align(mut self, align: Alignment) -> Self {
145 self.align = align;
146 self
147 }
148
149 pub fn content(&self) -> &str {
151 &self.content
152 }
153}
154
155impl Text {
156 fn to_rich_text_with_ctx(&self, ctx: &RenderContext) -> RichText {
158 let mut style = Style::new();
159
160 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 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 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 words.len() <= 1 {
219 let rich_text = self.to_rich_text_with_ctx(ctx);
220 rich_text.render(ctx);
221 return;
222 }
223
224 let text_width: usize = words.iter().map(|w| w.width()).sum();
226 let available_width = area.width as usize;
227
228 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 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 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 let mut x = area.x;
261 for (i, word) in words.iter().enumerate() {
262 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 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 if self.align == Alignment::Justify {
293 self.render_justified(ctx);
294 return;
295 }
296
297 let rich_text = self.to_rich_text_with_ctx(ctx);
299
300 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 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 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#[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 #[test]
354 fn test_text_empty_content() {
355 let text = Text::new("");
357 assert_eq!(text.content, "");
358 }
359
360 #[test]
361 fn test_text_whitespace_only() {
362 let text = Text::new(" ");
364 assert_eq!(text.content, " ");
365 }
366
367 #[test]
368 fn test_text_newlines() {
369 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 let text = Text::new("col1\tcol2");
378 assert_eq!(text.content, "col1\tcol2");
379 }
380
381 #[test]
382 fn test_text_special_characters() {
383 let special = "!@#$%^&*()_+-=[]{}|;':\",./<>?";
385 let text = Text::new(special);
386 assert_eq!(text.content, special);
387 }
388
389 #[test]
390 fn test_text_emoji() {
391 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 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 let text = Text::new("e\u{200d}"); assert_eq!(text.content, "e\u{200d}");
410 }
411
412 #[test]
413 fn test_text_very_long_single_line() {
414 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 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 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 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 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 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 let text = Text::new("café"); assert_eq!(text.content, "café");
480
481 let text2 = Text::new("cafe\u{301}"); assert_eq!(text2.content, "cafe\u{301}");
483 }
484}