A simple TUI Library written in Rust
at main 537 lines 16 kB view raw
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}