A simple TUI Library written in Rust
at main 296 lines 10 kB view raw
1use std::io; 2use std::sync::mpsc; 3use std::time::Duration; 4 5use crate::block::Block; 6use crate::input::{self, Key, RawMode}; 7use crate::terminal; 8use crate::view::View; 9 10/// A Component is a pure state machine combined with a renderer. 11/// Uses closures (matching Gleam's record-of-functions pattern). 12pub struct Component<S, R> { 13 pub state: S, 14 pub render: Box<dyn Fn(&S) -> Block>, 15 pub update: Box<dyn Fn(&S, &Key) -> Update<S, R>>, 16} 17 18/// The result of processing a key event in a component. 19pub enum Update<S, R> { 20 /// Key handled, new state -- keep the event loop running 21 Continue(S), 22 /// Yield a value while continuing (for multi-step flows) 23 Emit(S, R), 24 /// Component is done -- return this final value 25 Finish(R), 26 /// User cancelled (Escape/CtrlC) 27 Cancel, 28 /// Key not handled by this component (for focus management) 29 Ignore, 30} 31 32/// Errors that can occur when running a component. 33#[derive(Debug)] 34pub enum RunError { 35 /// User pressed Escape or Ctrl+C 36 Cancelled, 37 /// Error reading from the terminal 38 InputError(io::Error), 39} 40 41impl<S, R> Component<S, R> { 42 pub fn new( 43 state: S, 44 render: impl Fn(&S) -> Block + 'static, 45 update: impl Fn(&S, &Key) -> Update<S, R> + 'static, 46 ) -> Self { 47 Component { 48 state, 49 render: Box::new(render), 50 update: Box::new(update), 51 } 52 } 53 54 /// Run this component's interactive event loop. 55 /// Installs a SIGWINCH handler so that terminal resizes cause an immediate 56 /// redraw at the new width. 57 pub fn run(self, mode: &RawMode) -> Result<R, RunError> { 58 let width = terminal::get_size() 59 .map(|(cols, _)| cols as usize) 60 .unwrap_or(80); 61 terminal::install_resize_handler(); 62 self.run_with_width(mode, width) 63 } 64 65 /// Run with an explicit rendering width. 66 pub fn run_with_width(self, mode: &RawMode, width: usize) -> Result<R, RunError> { 67 let Component { 68 state, 69 render, 70 update, 71 } = self; 72 let initial_block = (render)(&state); 73 let mut view = View::create(&initial_block, width); 74 let result = event_loop(&render, &update, state, &mut view, mode); 75 match &result { 76 Ok(_) => view.finish(), 77 Err(RunError::Cancelled) => view.erase(), 78 Err(RunError::InputError(_)) => view.finish(), 79 } 80 result 81 } 82 83 /// Run with periodic tick updates (for animated components like spinners). 84 pub fn run_animated( 85 self, 86 mode: &RawMode, 87 tick_ms: u64, 88 on_tick: impl Fn(S) -> S, 89 ) -> Result<R, RunError> { 90 let width = terminal::get_size() 91 .map(|(cols, _)| cols as usize) 92 .unwrap_or(80); 93 let Component { 94 state, 95 render, 96 update, 97 } = self; 98 let initial_block = (render)(&state); 99 let mut view = View::create(&initial_block, width); 100 let result = animated_loop(&render, &update, state, &mut view, mode, tick_ms, &on_tick); 101 match &result { 102 Ok(_) => view.finish(), 103 Err(RunError::Cancelled) => view.erase(), 104 Err(RunError::InputError(_)) => view.finish(), 105 } 106 result 107 } 108 109 /// Run with a message channel. The event loop multiplexes between stdin 110 /// keypresses and messages from background threads. 111 /// 112 /// `on_msg` is called for each received message and produces an `Update`, 113 /// just like the `update` function for keypresses. 114 /// 115 /// Uses a short `read_key_timeout` interval (10 ms) to poll the channel 116 /// between key reads, keeping message latency low without busy-spinning. 117 pub fn run_with_channel<Msg>( 118 self, 119 mode: &RawMode, 120 rx: mpsc::Receiver<Msg>, 121 on_msg: impl Fn(&S, Msg) -> Update<S, R> + 'static, 122 ) -> Result<R, RunError> { 123 let width = terminal::get_size() 124 .map(|(cols, _)| cols as usize) 125 .unwrap_or(80); 126 terminal::install_resize_handler(); 127 let Component { 128 state, 129 render, 130 update, 131 } = self; 132 let initial_block = (render)(&state); 133 let mut view = View::create(&initial_block, width); 134 let result = channel_loop(&render, &update, &on_msg, state, &mut view, mode, &rx); 135 match &result { 136 Ok(_) => view.finish(), 137 Err(RunError::Cancelled) => view.erase(), 138 Err(RunError::InputError(_)) => view.finish(), 139 } 140 result 141 } 142 143 /// Transform the result type of this component. 144 pub fn map<R2: 'static>(self, f: impl Fn(R) -> R2 + 'static) -> Component<S, R2> 145 where 146 S: 'static, 147 R: 'static, 148 { 149 let update = self.update; 150 Component { 151 state: self.state, 152 render: self.render, 153 update: Box::new(move |state, key| match (update)(state, key) { 154 Update::Continue(s) => Update::Continue(s), 155 Update::Emit(s, r) => Update::Emit(s, f(r)), 156 Update::Finish(r) => Update::Finish(f(r)), 157 Update::Cancel => Update::Cancel, 158 Update::Ignore => Update::Ignore, 159 }), 160 } 161 } 162 163 /// Set the initial state. 164 pub fn with_state(self, state: S) -> Self { 165 Component { state, ..self } 166 } 167} 168 169fn event_loop<S, R>( 170 render: &dyn Fn(&S) -> Block, 171 update: &dyn Fn(&S, &Key) -> Update<S, R>, 172 mut state: S, 173 view: &mut View, 174 mode: &RawMode, 175) -> Result<R, RunError> { 176 loop { 177 // Check for terminal resize before blocking on the next key 178 if terminal::take_resize() { 179 let new_width = terminal::get_size() 180 .map(|(c, _)| c as usize) 181 .unwrap_or(view.width()); 182 let block = (render)(&state); 183 view.resize(&block, new_width); 184 } 185 let key = input::read_key(mode).map_err(RunError::InputError)?; 186 match (update)(&state, &key) { 187 Update::Continue(new_state) => { 188 state = new_state; 189 let block = (render)(&state); 190 view.update(&block); 191 } 192 Update::Emit(_, result) => return Ok(result), 193 Update::Finish(result) => return Ok(result), 194 Update::Cancel => return Err(RunError::Cancelled), 195 Update::Ignore => {} 196 } 197 } 198} 199 200fn channel_loop<S, R, Msg>( 201 render: &dyn Fn(&S) -> Block, 202 update: &dyn Fn(&S, &Key) -> Update<S, R>, 203 on_msg: &dyn Fn(&S, Msg) -> Update<S, R>, 204 mut state: S, 205 view: &mut View, 206 mode: &RawMode, 207 rx: &mpsc::Receiver<Msg>, 208) -> Result<R, RunError> { 209 // Poll interval — short enough for responsive messages, not a busy spin. 210 let poll_ms = Duration::from_millis(10); 211 loop { 212 // Handle terminal resize 213 if terminal::take_resize() { 214 let new_width = terminal::get_size() 215 .map(|(c, _)| c as usize) 216 .unwrap_or(view.width()); 217 let block = (render)(&state); 218 view.resize(&block, new_width); 219 } 220 221 // Drain all pending channel messages 222 loop { 223 match rx.try_recv() { 224 Ok(msg) => match on_msg(&state, msg) { 225 Update::Continue(new_state) => { 226 state = new_state; 227 let block = (render)(&state); 228 view.update(&block); 229 } 230 Update::Emit(_, result) => return Ok(result), 231 Update::Finish(result) => return Ok(result), 232 Update::Cancel => return Err(RunError::Cancelled), 233 Update::Ignore => {} 234 }, 235 Err(mpsc::TryRecvError::Empty) => break, 236 Err(mpsc::TryRecvError::Disconnected) => break, 237 } 238 } 239 240 // Wait briefly for a keypress 241 match input::read_key_timeout(mode, poll_ms).map_err(RunError::InputError)? { 242 None => {} // timeout — loop again to check channel 243 Some(key) => match (update)(&state, &key) { 244 Update::Continue(new_state) => { 245 state = new_state; 246 let block = (render)(&state); 247 view.update(&block); 248 } 249 Update::Emit(_, result) => return Ok(result), 250 Update::Finish(result) => return Ok(result), 251 Update::Cancel => return Err(RunError::Cancelled), 252 Update::Ignore => {} 253 }, 254 } 255 } 256} 257 258fn animated_loop<S, R>( 259 render: &dyn Fn(&S) -> Block, 260 update: &dyn Fn(&S, &Key) -> Update<S, R>, 261 mut state: S, 262 view: &mut View, 263 mode: &RawMode, 264 tick_ms: u64, 265 on_tick: &dyn Fn(S) -> S, 266) -> Result<R, RunError> { 267 let timeout = Duration::from_millis(tick_ms); 268 loop { 269 // Handle terminal resize 270 if terminal::take_resize() { 271 let new_width = terminal::get_size() 272 .map(|(c, _)| c as usize) 273 .unwrap_or(view.width()); 274 let block = (render)(&state); 275 view.resize(&block, new_width); 276 } 277 match input::read_key_timeout(mode, timeout).map_err(RunError::InputError)? { 278 None => { 279 state = on_tick(state); 280 let block = (render)(&state); 281 view.update(&block); 282 } 283 Some(key) => match (update)(&state, &key) { 284 Update::Continue(new_state) => { 285 state = new_state; 286 let block = (render)(&state); 287 view.update(&block); 288 } 289 Update::Emit(_, result) => return Ok(result), 290 Update::Finish(result) => return Ok(result), 291 Update::Cancel => return Err(RunError::Cancelled), 292 Update::Ignore => {} 293 }, 294 } 295 } 296}