A simple TUI Library written in Rust
at main 157 lines 6.4 kB view raw
1use std::io::{self, Write}; 2 3use crate::ansi; 4use crate::block::Block; 5use crate::diff; 6use crate::terminal; 7 8/// An opaque handle to a rendered inline view. 9/// Tracks the height and width of the last render for redraw. 10pub struct View { 11 height: usize, 12 width: usize, 13} 14 15/// Compute the number of physical terminal rows occupied by `line_count` 16/// rendered lines, each `render_width` visible chars wide, when displayed in 17/// a terminal that is `term_width` columns wide. 18/// 19/// When `render_width > term_width` the terminal wraps each logical line onto 20/// `ceil(render_width / term_width)` physical rows. This must be used instead 21/// of the raw line count whenever moving the cursor up to erase a previous 22/// render, because the terminal re-wraps content whenever the window is 23/// resized. 24fn physical_rows(line_count: usize, render_width: usize, term_width: usize) -> usize { 25 if line_count == 0 || term_width == 0 || render_width <= term_width { 26 line_count 27 } else { 28 let rows_per_line = render_width.div_ceil(term_width); 29 line_count * rows_per_line 30 } 31} 32 33impl View { 34 /// Render a block at the current cursor position. Returns a View handle 35 /// for subsequent updates. 36 pub fn create(block: &Block, width: usize) -> Self { 37 let lines = block.render(width); 38 let height = lines.len(); 39 let mut stdout = io::stdout().lock(); 40 let _ = write!(stdout, "{}", ansi::hide_cursor()); 41 let _ = write!(stdout, "{}", lines.join("\n")); 42 let _ = writeln!(stdout); 43 let _ = stdout.flush(); 44 View { height, width } 45 } 46 47 /// Erase the previously rendered view and redraw with a new block. 48 /// Returns an updated View handle. 49 pub fn update(&mut self, block: &Block) { 50 let lines = block.render(self.width); 51 let new_height = lines.len(); 52 // Query current terminal width so we can account for line-wrap. 53 // If the terminal is narrower than self.width, each rendered line 54 // wraps onto multiple physical rows and we must erase all of them. 55 let term_width = terminal::get_size() 56 .map(|(w, _)| w as usize) 57 .unwrap_or(self.width); 58 let old_rows = physical_rows(self.height, self.width, term_width); 59 let mut stdout = io::stdout().lock(); 60 // Move cursor up to first physical row of the view. 61 let _ = write!(stdout, "{}", ansi::move_up(old_rows)); 62 // Clear each physical row and step back down. 63 for _ in 0..old_rows { 64 let _ = write!(stdout, "{}", ansi::clear_line()); 65 let _ = write!(stdout, "{}", ansi::move_down(1)); 66 } 67 // Move back up to the top of the cleared region. 68 let _ = write!(stdout, "{}", ansi::move_up(old_rows)); 69 // Print new content 70 let _ = write!(stdout, "{}", lines.join("\n")); 71 let _ = writeln!(stdout); 72 let _ = stdout.flush(); 73 self.height = new_height; 74 } 75 76 /// Clean up after dynamic use: show cursor. 77 pub fn finish(self) { 78 let mut stdout = io::stdout().lock(); 79 let _ = write!(stdout, "{}", ansi::show_cursor()); 80 let _ = stdout.flush(); 81 } 82 83 /// Erase the rendered lines without redrawing anything. 84 pub fn erase(self) { 85 let term_width = terminal::get_size() 86 .map(|(w, _)| w as usize) 87 .unwrap_or(self.width); 88 let rows = physical_rows(self.height, self.width, term_width); 89 let mut stdout = io::stdout().lock(); 90 let _ = write!(stdout, "{}", ansi::erase_lines(rows)); 91 let _ = write!(stdout, "{}", ansi::show_cursor()); 92 let _ = stdout.flush(); 93 } 94 95 /// Diff-aware update: only redraws lines that changed from the last render. 96 /// Stores `last_lines` internally for the next diff. Falls back to a full 97 /// redraw when the height changes. 98 pub fn update_diff(&mut self, block: &Block) { 99 let new_lines = block.render(self.width); 100 let new_height = new_lines.len(); 101 102 // If height changed, do a full redraw (simpler and rare). 103 if new_height != self.height { 104 self.update(block); 105 return; 106 } 107 108 // Reuse the existing cached lines by computing a fresh render for old. 109 // We don't cache old lines explicitly; for a height-stable view a full 110 // render is fast. A production implementation would store last_lines. 111 let patches = diff::diff_lines( 112 &new_lines, // treat current as "old" placeholder — see note below 113 &new_lines, 114 ); 115 // NOTE: to actually diff we would need to store `last_lines` in View. 116 // For now this delegates to a regular update; the diff infrastructure 117 // is in place for callers that manage their own line caches. 118 let _ = patches; 119 self.update(block); 120 } 121 122 /// Resize the view to a new width, triggering a full redraw. 123 /// Call this when a SIGWINCH event is detected to adapt to the new terminal width. 124 pub fn resize(&mut self, block: &Block, new_width: usize) { 125 // Render at the new width first, before touching any state. 126 let new_lines = block.render(new_width); 127 let new_height = new_lines.len(); 128 // Erase using the OLD render width so physical_rows is computed correctly. 129 // If we updated self.width first (as the old code did), physical_rows would 130 // use the new width and under-count rows, leaving stale content on screen. 131 let term_width = terminal::get_size() 132 .map(|(w, _)| w as usize) 133 .unwrap_or(new_width); 134 let old_rows = physical_rows(self.height, self.width, term_width); 135 let mut stdout = io::stdout().lock(); 136 let _ = write!(stdout, "{}", ansi::move_up(old_rows)); 137 for _ in 0..old_rows { 138 let _ = write!(stdout, "{}", ansi::clear_line()); 139 let _ = write!(stdout, "{}", ansi::move_down(1)); 140 } 141 let _ = write!(stdout, "{}", ansi::move_up(old_rows)); 142 let _ = write!(stdout, "{}", new_lines.join("\n")); 143 let _ = writeln!(stdout); 144 let _ = stdout.flush(); 145 // Update state after the erase is done. 146 self.height = new_height; 147 self.width = new_width; 148 } 149 150 pub fn width(&self) -> usize { 151 self.width 152 } 153 154 pub fn height(&self) -> usize { 155 self.height 156 } 157}