A simple TUI Library written in Rust
at main 391 lines 12 kB view raw
1use std::io; 2 3use crate::block::Block; 4use crate::component::{RunError, Update}; 5use crate::input::{self, Key, RawMode}; 6use crate::style::{Line, Span, Style}; 7use crate::terminal; 8use crate::view::View; 9 10// --------------------------------------------------------------------------- 11// Trait 12// --------------------------------------------------------------------------- 13 14/// Outcome of processing a keypress inside a `FocusItem`. 15pub enum FocusEvent { 16 /// Item handled the key and updated internal state. 17 Continue, 18 /// Item finished (e.g. user pressed Enter on a text field). 19 Done, 20 /// User pressed Escape/Ctrl+C — entire group should cancel. 21 Cancel, 22 /// Item did not handle this key (focus group may intercept for Tab). 23 Ignore, 24} 25 26/// A component that can participate in a `FocusGroup`. 27/// 28/// Because focus items are held as mutable references, the user retains 29/// ownership of each item and can inspect results after `FocusGroup::run`. 30pub trait FocusItem { 31 fn render(&self) -> Block; 32 fn handle_key(&mut self, key: &Key) -> FocusEvent; 33} 34 35// --------------------------------------------------------------------------- 36// FocusSlot — wraps a Component<S, R> as a FocusItem 37// --------------------------------------------------------------------------- 38 39/// Wraps a `Component`-style state machine as a `FocusItem`, storing 40/// the final result so it can be retrieved after `FocusGroup::run`. 41pub struct FocusSlot<S: Clone, R> { 42 state: S, 43 render_fn: Box<dyn Fn(&S) -> Block>, 44 update_fn: Box<dyn Fn(&S, &Key) -> Update<S, R>>, 45 pub result: Option<R>, 46 pub done: bool, 47} 48 49impl<S: Clone, R> FocusSlot<S, R> { 50 pub fn new( 51 state: S, 52 render: impl Fn(&S) -> Block + 'static, 53 update: impl Fn(&S, &Key) -> Update<S, R> + 'static, 54 ) -> Self { 55 FocusSlot { 56 state, 57 render_fn: Box::new(render), 58 update_fn: Box::new(update), 59 result: None, 60 done: false, 61 } 62 } 63 64 pub fn state(&self) -> &S { 65 &self.state 66 } 67} 68 69impl<S: Clone, R> FocusItem for FocusSlot<S, R> { 70 fn render(&self) -> Block { 71 (self.render_fn)(&self.state) 72 } 73 74 fn handle_key(&mut self, key: &Key) -> FocusEvent { 75 match (self.update_fn)(&self.state, key) { 76 Update::Continue(s) => { 77 self.state = s; 78 FocusEvent::Continue 79 } 80 Update::Emit(s, r) => { 81 self.state = s; 82 self.result = Some(r); 83 FocusEvent::Continue 84 } 85 Update::Finish(r) => { 86 self.result = Some(r); 87 self.done = true; 88 FocusEvent::Done 89 } 90 Update::Cancel => FocusEvent::Cancel, 91 Update::Ignore => FocusEvent::Ignore, 92 } 93 } 94} 95 96// --------------------------------------------------------------------------- 97// FocusGroup 98// --------------------------------------------------------------------------- 99 100/// Style options for the focus indicator shown next to each item. 101pub struct FocusGroupStyle { 102 /// Prefix shown next to the focused item (e.g. `"▶ "`). 103 pub focused_prefix: String, 104 /// Prefix shown next to unfocused items (spaces to match width). 105 pub unfocused_prefix: String, 106 pub focused_prefix_style: Style, 107 pub unfocused_prefix_style: Style, 108} 109 110impl Default for FocusGroupStyle { 111 fn default() -> Self { 112 use crate::color::Color; 113 FocusGroupStyle { 114 focused_prefix: "".into(), 115 unfocused_prefix: " ".into(), 116 focused_prefix_style: Style::new().bold().fg(Color::Cyan), 117 unfocused_prefix_style: Style::new(), 118 } 119 } 120} 121 122/// Manages a list of `FocusItem` mutable references, routing keypresses to 123/// the focused item and handling `Tab` / `BackTab` to move focus. 124/// 125/// Returns when: 126/// - `Ctrl+Enter` is pressed (all items are considered submitted as-is). 127/// - `Escape` or `Ctrl+C` is received from any item. 128/// - All items have signalled `Done`. 129pub struct FocusGroup<'a> { 130 items: Vec<&'a mut dyn FocusItem>, 131 focused: usize, 132 style: FocusGroupStyle, 133} 134 135impl<'a> FocusGroup<'a> { 136 pub fn new(items: Vec<&'a mut dyn FocusItem>) -> Self { 137 FocusGroup { 138 items, 139 focused: 0, 140 style: FocusGroupStyle::default(), 141 } 142 } 143 144 pub fn style(self, style: FocusGroupStyle) -> Self { 145 Self { style, ..self } 146 } 147 148 /// Run the interactive event loop. 149 /// 150 /// - `Tab` / `BackTab` moves focus between items. 151 /// - `Ctrl+Enter` (sent as `Ctrl('j')` or `Ctrl('m')`) submits the group. 152 /// - Escape / Ctrl+C from any item cancels the entire group. 153 pub fn run(mut self, mode: &RawMode) -> Result<(), RunError> { 154 let width = terminal::get_size().map(|(c, _)| c as usize).unwrap_or(80); 155 let initial_block = self.render_all(); 156 let mut view = View::create(&initial_block, width); 157 158 let result = self.event_loop(mode, &mut view); 159 160 match &result { 161 Ok(_) => view.finish(), 162 Err(RunError::Cancelled) => view.erase(), 163 Err(RunError::InputError(_)) => view.finish(), 164 } 165 result 166 } 167 168 fn render_all(&self) -> Block { 169 let n = self.items.len(); 170 let blocks: Vec<Block> = self 171 .items 172 .iter() 173 .enumerate() 174 .map(|(i, item)| { 175 let is_focused = i == self.focused; 176 let prefix_str = if is_focused { 177 &self.style.focused_prefix 178 } else { 179 &self.style.unfocused_prefix 180 }; 181 let prefix_style = if is_focused { 182 &self.style.focused_prefix_style 183 } else { 184 &self.style.unfocused_prefix_style 185 }; 186 let prefix_line: Line = 187 vec![Span::styled(prefix_str.clone(), prefix_style.clone())]; 188 let prefix_block = Block::text(vec![prefix_line]); 189 let content_block = item.render(); 190 Block::hstack(vec![prefix_block, content_block], 0) 191 }) 192 .collect(); 193 194 if n == 0 { 195 Block::text(vec![]) 196 } else { 197 Block::vstack(blocks, 0) 198 } 199 } 200 201 fn event_loop(&mut self, mode: &RawMode, view: &mut View) -> Result<(), RunError> { 202 loop { 203 let key = input::read_key(mode).map_err(RunError::InputError)?; 204 205 // Ctrl+Enter submits the whole group. 206 if matches!(key, Key::Ctrl('j') | Key::Ctrl('m')) { 207 return Ok(()); 208 } 209 210 // Tab / BackTab move focus. 211 if matches!(key, Key::Tab) { 212 self.focus_next(); 213 view.update(&self.render_all()); 214 continue; 215 } 216 if matches!(key, Key::BackTab) { 217 self.focus_prev(); 218 view.update(&self.render_all()); 219 continue; 220 } 221 222 // Route to focused item. 223 let event = self.items[self.focused].handle_key(&key); 224 match event { 225 FocusEvent::Continue => { 226 view.update(&self.render_all()); 227 } 228 FocusEvent::Done => { 229 // Auto-advance focus to next unfocused item. 230 if !self.focus_next_undone() { 231 // All items done — finish. 232 return Ok(()); 233 } 234 view.update(&self.render_all()); 235 } 236 FocusEvent::Cancel => return Err(RunError::Cancelled), 237 FocusEvent::Ignore => {} 238 } 239 } 240 } 241 242 fn focus_next(&mut self) { 243 if !self.items.is_empty() { 244 self.focused = (self.focused + 1) % self.items.len(); 245 } 246 } 247 248 fn focus_prev(&mut self) { 249 if !self.items.is_empty() { 250 let n = self.items.len(); 251 self.focused = (self.focused + n - 1) % n; 252 } 253 } 254 255 /// Advance focus to the next item that hasn't signalled Done. 256 /// Returns `true` if such an item was found. 257 fn focus_next_undone(&mut self) -> bool { 258 let n = self.items.len(); 259 if let Some(offset) = (1..=n).next() { 260 let idx = (self.focused + offset) % n; 261 // We can't inspect `done` without downcasting unless FocusItem exposes it. 262 // For simplicity: always advance to next item. 263 self.focused = idx; 264 return true; 265 } 266 false 267 } 268} 269 270// --------------------------------------------------------------------------- 271// RunError impls 272// --------------------------------------------------------------------------- 273 274impl From<io::Error> for RunError { 275 fn from(e: io::Error) -> Self { 276 RunError::InputError(e) 277 } 278} 279 280#[cfg(test)] 281mod tests { 282 use super::*; 283 284 struct CounterItem { 285 count: usize, 286 done_at: usize, 287 pub final_count: Option<usize>, 288 } 289 290 impl CounterItem { 291 fn new(done_at: usize) -> Self { 292 CounterItem { 293 count: 0, 294 done_at, 295 final_count: None, 296 } 297 } 298 } 299 300 impl FocusItem for CounterItem { 301 fn render(&self) -> Block { 302 Block::text(vec![vec![Span::plain(format!("count={}", self.count))]]) 303 } 304 305 fn handle_key(&mut self, key: &Key) -> FocusEvent { 306 match key { 307 Key::Char(' ') => { 308 self.count += 1; 309 if self.count >= self.done_at { 310 self.final_count = Some(self.count); 311 FocusEvent::Done 312 } else { 313 FocusEvent::Continue 314 } 315 } 316 Key::Escape | Key::CtrlC => FocusEvent::Cancel, 317 _ => FocusEvent::Ignore, 318 } 319 } 320 } 321 322 #[test] 323 fn focus_slot_stores_result() { 324 use crate::component::Update; 325 326 let mut slot: FocusSlot<String, String> = FocusSlot::new( 327 "hello".to_string(), 328 |s| Block::text(vec![vec![Span::plain(s.clone())]]), 329 |s, key| match key { 330 Key::Enter => Update::Finish(s.clone()), 331 Key::CtrlC => Update::Cancel, 332 _ => Update::Ignore, 333 }, 334 ); 335 336 assert!(slot.result.is_none()); 337 assert!(!slot.done); 338 339 // Enter → Done 340 let event = slot.handle_key(&Key::Enter); 341 assert!(matches!(event, FocusEvent::Done)); 342 assert_eq!(slot.result.as_deref(), Some("hello")); 343 assert!(slot.done); 344 } 345 346 #[test] 347 fn focus_slot_cancel() { 348 use crate::component::Update; 349 350 let mut slot: FocusSlot<String, String> = FocusSlot::new( 351 "".to_string(), 352 |s| Block::text(vec![vec![Span::plain(s.clone())]]), 353 |_, key| match key { 354 Key::CtrlC => Update::Cancel, 355 _ => Update::Ignore, 356 }, 357 ); 358 359 assert!(matches!(slot.handle_key(&Key::CtrlC), FocusEvent::Cancel)); 360 } 361 362 #[test] 363 fn focus_slot_continue() { 364 use crate::component::Update; 365 366 let mut slot: FocusSlot<usize, ()> = FocusSlot::new( 367 0usize, 368 |n| Block::text(vec![vec![Span::plain(n.to_string())]]), 369 |&n, key| match key { 370 Key::Char(' ') => Update::Continue(n + 1), 371 _ => Update::Ignore, 372 }, 373 ); 374 375 slot.handle_key(&Key::Char(' ')); 376 assert_eq!(*slot.state(), 1); 377 } 378 379 #[test] 380 fn counter_item_done_event() { 381 let mut item = CounterItem::new(2); 382 383 let e1 = item.handle_key(&Key::Char(' ')); 384 assert!(matches!(e1, FocusEvent::Continue)); 385 assert!(item.final_count.is_none()); 386 387 let e2 = item.handle_key(&Key::Char(' ')); 388 assert!(matches!(e2, FocusEvent::Done)); 389 assert_eq!(item.final_count, Some(2)); 390 } 391}