A simple TUI Library written in Rust
1use crate::style::{self, Align, Line, Span};
2
3// --- Types ---
4
5#[derive(Debug, Clone)]
6pub struct BorderChars {
7 pub top: &'static str,
8 pub bottom: &'static str,
9 pub left: &'static str,
10 pub right: &'static str,
11 pub top_left: &'static str,
12 pub top_right: &'static str,
13 pub bottom_left: &'static str,
14 pub bottom_right: &'static str,
15}
16
17#[derive(Debug, Clone)]
18pub enum BorderStyle {
19 NoBorder,
20 Single,
21 Double,
22 Rounded,
23 Thick,
24 Ascii,
25 Custom(BorderChars),
26}
27
28#[derive(Debug, Clone)]
29pub enum Block {
30 Text(Vec<Line>),
31 Box {
32 content: Box<Block>,
33 border: BorderStyle,
34 title: Option<Line>,
35 },
36 HStack {
37 children: Vec<Block>,
38 gap: usize,
39 },
40 VStack {
41 children: Vec<Block>,
42 gap: usize,
43 },
44 Empty {
45 height: usize,
46 },
47 Sized {
48 content: Box<Block>,
49 min_width: Option<usize>,
50 min_height: Option<usize>,
51 },
52 Padded {
53 content: Box<Block>,
54 top: usize,
55 bottom: usize,
56 left: usize,
57 right: usize,
58 },
59}
60
61// --- Border resolution ---
62
63impl BorderStyle {
64 pub fn chars(&self) -> BorderChars {
65 match self {
66 BorderStyle::NoBorder => BorderChars {
67 top: "",
68 bottom: "",
69 left: "",
70 right: "",
71 top_left: "",
72 top_right: "",
73 bottom_left: "",
74 bottom_right: "",
75 },
76 BorderStyle::Single => BorderChars {
77 top: "─",
78 bottom: "─",
79 left: "│",
80 right: "│",
81 top_left: "┌",
82 top_right: "┐",
83 bottom_left: "└",
84 bottom_right: "┘",
85 },
86 BorderStyle::Double => BorderChars {
87 top: "═",
88 bottom: "═",
89 left: "║",
90 right: "║",
91 top_left: "╔",
92 top_right: "╗",
93 bottom_left: "╚",
94 bottom_right: "╝",
95 },
96 BorderStyle::Rounded => BorderChars {
97 top: "─",
98 bottom: "─",
99 left: "│",
100 right: "│",
101 top_left: "╭",
102 top_right: "╮",
103 bottom_left: "╰",
104 bottom_right: "╯",
105 },
106 BorderStyle::Thick => BorderChars {
107 top: "━",
108 bottom: "━",
109 left: "┃",
110 right: "┃",
111 top_left: "┏",
112 top_right: "┓",
113 bottom_left: "┗",
114 bottom_right: "┛",
115 },
116 BorderStyle::Ascii => BorderChars {
117 top: "-",
118 bottom: "-",
119 left: "|",
120 right: "|",
121 top_left: "+",
122 top_right: "+",
123 bottom_left: "+",
124 bottom_right: "+",
125 },
126 BorderStyle::Custom(chars) => chars.clone(),
127 }
128 }
129}
130
131// --- Convenience constructors ---
132
133impl Block {
134 pub fn text(lines: Vec<Line>) -> Self {
135 Block::Text(lines)
136 }
137
138 pub fn text_plain(content: &str) -> Self {
139 let lines: Vec<Line> = content.split('\n').map(|s| vec![Span::plain(s)]).collect();
140 Block::Text(lines)
141 }
142
143 pub fn empty(height: usize) -> Self {
144 Block::Empty { height }
145 }
146
147 pub fn boxed(content: Block, border: BorderStyle, title: Option<Line>) -> Self {
148 Block::Box {
149 content: Box::new(content),
150 border,
151 title,
152 }
153 }
154
155 pub fn hstack(children: Vec<Block>, gap: usize) -> Self {
156 Block::HStack { children, gap }
157 }
158
159 pub fn vstack(children: Vec<Block>, gap: usize) -> Self {
160 Block::VStack { children, gap }
161 }
162
163 pub fn sized(content: Block, min_width: Option<usize>, min_height: Option<usize>) -> Self {
164 Block::Sized {
165 content: Box::new(content),
166 min_width,
167 min_height,
168 }
169 }
170
171 pub fn padded(content: Block, top: usize, bottom: usize, left: usize, right: usize) -> Self {
172 Block::Padded {
173 content: Box::new(content),
174 top,
175 bottom,
176 left,
177 right,
178 }
179 }
180
181 // --- Measurement ---
182
183 pub fn min_width(&self) -> usize {
184 match self {
185 Block::Text(lines) => lines.iter().map(style::measure_line).max().unwrap_or(0),
186 Block::Box {
187 content, border, ..
188 } => match border {
189 BorderStyle::NoBorder => content.min_width(),
190 _ => content.min_width() + 2,
191 },
192 Block::HStack { children, gap } => {
193 let n = children.len();
194 let total_gap = if n > 1 { (n - 1) * gap } else { 0 };
195 let widths_sum: usize = children.iter().map(|c| c.min_width()).sum();
196 widths_sum + total_gap
197 }
198 Block::VStack { children, .. } => {
199 children.iter().map(|c| c.min_width()).max().unwrap_or(0)
200 }
201 Block::Empty { .. } => 0,
202 Block::Sized {
203 content, min_width, ..
204 } => content.min_width().max(min_width.unwrap_or(0)),
205 Block::Padded {
206 content,
207 left,
208 right,
209 ..
210 } => content.min_width() + left + right,
211 }
212 }
213
214 pub fn height(&self) -> usize {
215 match self {
216 Block::Text(lines) => lines.len(),
217 Block::Box {
218 content, border, ..
219 } => match border {
220 BorderStyle::NoBorder => content.height(),
221 _ => content.height() + 2,
222 },
223 Block::HStack { children, .. } => {
224 children.iter().map(|c| c.height()).max().unwrap_or(0)
225 }
226 Block::VStack { children, gap } => {
227 let n = children.len();
228 let total_gap = if n > 1 { (n - 1) * gap } else { 0 };
229 let heights_sum: usize = children.iter().map(|c| c.height()).sum();
230 heights_sum + total_gap
231 }
232 Block::Empty { height } => *height,
233 Block::Sized {
234 content,
235 min_height,
236 ..
237 } => content.height().max(min_height.unwrap_or(0)),
238 Block::Padded {
239 content,
240 top,
241 bottom,
242 ..
243 } => content.height() + top + bottom,
244 }
245 }
246
247 // --- Rendering ---
248
249 pub fn render(&self, width: usize) -> Vec<String> {
250 match self {
251 Block::Text(lines) => render_text_lines(lines, width),
252 Block::Box {
253 content,
254 border,
255 title,
256 } => render_box(content, border, title.as_ref(), width),
257 Block::HStack { children, gap } => render_hstack(children, *gap, width),
258 Block::VStack { children, gap } => render_vstack(children, *gap, width),
259 Block::Empty { height } => vec![blank_line(width); *height],
260 Block::Sized {
261 content,
262 min_width,
263 min_height,
264 } => render_sized(content, *min_width, *min_height, width),
265 Block::Padded {
266 content,
267 top,
268 bottom,
269 left,
270 right,
271 } => render_padded(content, *top, *bottom, *left, *right, width),
272 }
273 }
274}
275
276// --- Internal helpers ---
277
278fn render_text_lines(lines: &[Line], width: usize) -> Vec<String> {
279 lines
280 .iter()
281 .map(|line| {
282 let truncated = style::truncate_line(line, width);
283 let padded = style::pad_line(&truncated, width, Align::Left);
284 style::render_line(&padded)
285 })
286 .collect()
287}
288
289fn render_box(
290 content: &Block,
291 border: &BorderStyle,
292 title: Option<&Line>,
293 width: usize,
294) -> Vec<String> {
295 match border {
296 BorderStyle::NoBorder => content.render(width),
297 _ => {
298 let chars = border.chars();
299 let inner_width = width.saturating_sub(2);
300 let top = render_top_border(&chars, inner_width, title);
301 let bottom = render_bottom_border(&chars, inner_width);
302 let content_lines = content.render(inner_width);
303 let mut result = vec![top];
304 for line in &content_lines {
305 result.push(format!("{}{}{}", chars.left, line, chars.right));
306 }
307 result.push(bottom);
308 result
309 }
310 }
311}
312
313fn render_top_border(chars: &BorderChars, inner_width: usize, title: Option<&Line>) -> String {
314 match title {
315 None => format!(
316 "{}{}{}",
317 chars.top_left,
318 chars.top.repeat(inner_width),
319 chars.top_right
320 ),
321 Some(title_line) => {
322 let available_for_title = inner_width.saturating_sub(4);
323 if available_for_title == 0 {
324 return format!(
325 "{}{}{}",
326 chars.top_left,
327 chars.top.repeat(inner_width),
328 chars.top_right
329 );
330 }
331 let truncated = style::truncate_line(title_line, available_for_title);
332 let title_str = style::render_line(&truncated);
333 let title_visible_width = style::measure_line(&truncated);
334 let left_deco = format!("{}{} ", chars.top, chars.top);
335 let right_fill_width = inner_width.saturating_sub(3 + title_visible_width + 1);
336 let right_deco = format!(" {}", chars.top.repeat(right_fill_width));
337 format!(
338 "{}{}{}{}{}",
339 chars.top_left, left_deco, title_str, right_deco, chars.top_right
340 )
341 }
342 }
343}
344
345fn render_bottom_border(chars: &BorderChars, inner_width: usize) -> String {
346 format!(
347 "{}{}{}",
348 chars.bottom_left,
349 chars.bottom.repeat(inner_width),
350 chars.bottom_right
351 )
352}
353
354fn render_hstack(children: &[Block], gap: usize, width: usize) -> Vec<String> {
355 match children.len() {
356 0 => vec![],
357 1 => children[0].render(width),
358 n => {
359 let total_gap = (n - 1) * gap;
360 let available = width.saturating_sub(total_gap);
361 let base_width = available / n;
362 let remainder = available % n;
363
364 let widths: Vec<usize> = (0..n)
365 .map(|i| {
366 if i == n - 1 {
367 base_width + remainder
368 } else {
369 base_width
370 }
371 })
372 .collect();
373
374 let rendered: Vec<Vec<String>> = children
375 .iter()
376 .zip(widths.iter())
377 .map(|(child, &w)| child.render(w))
378 .collect();
379
380 let max_height = rendered.iter().map(|r| r.len()).max().unwrap_or(0);
381
382 let padded: Vec<Vec<String>> = rendered
383 .into_iter()
384 .zip(widths.iter())
385 .map(|(mut col, &w)| {
386 while col.len() < max_height {
387 col.push(blank_line(w));
388 }
389 col
390 })
391 .collect();
392
393 let gap_str = " ".repeat(gap);
394 (0..max_height)
395 .map(|row| {
396 padded
397 .iter()
398 .map(|col| col[row].as_str())
399 .collect::<Vec<_>>()
400 .join(&gap_str)
401 })
402 .collect()
403 }
404 }
405}
406
407fn render_vstack(children: &[Block], gap: usize, width: usize) -> Vec<String> {
408 let gap_lines: Vec<String> = vec![blank_line(width); gap];
409 let mut result = Vec::new();
410 for (i, child) in children.iter().enumerate() {
411 if i > 0 {
412 result.extend(gap_lines.iter().cloned());
413 }
414 result.extend(child.render(width));
415 }
416 result
417}
418
419fn render_sized(
420 content: &Block,
421 min_width: Option<usize>,
422 min_height: Option<usize>,
423 width: usize,
424) -> Vec<String> {
425 let effective_width = width.max(min_width.unwrap_or(0));
426 let mut lines = content.render(effective_width);
427 let target_height = min_height.unwrap_or(0);
428 while lines.len() < target_height {
429 lines.push(blank_line(effective_width));
430 }
431 lines
432}
433
434fn render_padded(
435 content: &Block,
436 top: usize,
437 bottom: usize,
438 left: usize,
439 right: usize,
440 width: usize,
441) -> Vec<String> {
442 let inner_width = width.saturating_sub(left + right);
443 let left_pad = " ".repeat(left);
444 let right_pad = " ".repeat(right);
445 let content_lines = content.render(inner_width);
446 let mut result = Vec::new();
447 // Top padding rows
448 for _ in 0..top {
449 result.push(blank_line(width));
450 }
451 for line in &content_lines {
452 result.push(format!("{}{}{}", left_pad, line, right_pad));
453 }
454 // Bottom padding rows
455 for _ in 0..bottom {
456 result.push(blank_line(width));
457 }
458 result
459}
460
461fn blank_line(width: usize) -> String {
462 " ".repeat(width)
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn text_block_renders() {
471 let block = Block::text(vec![vec![Span::plain("hello")], vec![Span::plain("world")]]);
472 let lines = block.render(10);
473 assert_eq!(lines.len(), 2);
474 assert_eq!(lines[0].len(), 10); // padded to width
475 }
476
477 #[test]
478 fn text_plain_splits_newlines() {
479 let block = Block::text_plain("a\nb\nc");
480 assert_eq!(block.height(), 3);
481 }
482
483 #[test]
484 fn empty_block() {
485 let block = Block::empty(3);
486 let lines = block.render(5);
487 assert_eq!(lines.len(), 3);
488 assert!(lines.iter().all(|l| l == " "));
489 }
490
491 #[test]
492 fn box_single_border() {
493 let inner = Block::text(vec![vec![Span::plain("hi")]]);
494 let boxed = Block::boxed(inner, BorderStyle::Single, None);
495 let lines = boxed.render(10);
496 assert_eq!(lines.len(), 3); // top + content + bottom
497 assert!(lines[0].starts_with('┌'));
498 assert!(lines[2].starts_with('└'));
499 }
500
501 #[test]
502 fn vstack_with_gap() {
503 let a = Block::text(vec![vec![Span::plain("a")]]);
504 let b = Block::text(vec![vec![Span::plain("b")]]);
505 let stack = Block::vstack(vec![a, b], 1);
506 let lines = stack.render(5);
507 assert_eq!(lines.len(), 3); // a + gap + b
508 }
509
510 #[test]
511 fn hstack_two_children() {
512 let a = Block::text(vec![vec![Span::plain("L")]]);
513 let b = Block::text(vec![vec![Span::plain("R")]]);
514 let stack = Block::hstack(vec![a, b], 1);
515 let lines = stack.render(11);
516 assert_eq!(lines.len(), 1);
517 }
518
519 #[test]
520 fn min_width_measurement() {
521 let block = Block::text(vec![
522 vec![Span::plain("short")],
523 vec![Span::plain("longer text")],
524 ]);
525 assert_eq!(block.min_width(), 11);
526 }
527
528 #[test]
529 fn height_measurement() {
530 let block = Block::text(vec![
531 vec![Span::plain("a")],
532 vec![Span::plain("b")],
533 vec![Span::plain("c")],
534 ]);
535 assert_eq!(block.height(), 3);
536 }
537}