A simple TUI Library written in Rust
at main 400 lines 13 kB view raw
1use std::io; 2use std::time::Duration; 3 4/// Opaque handle for raw terminal mode. Holds the saved termios settings 5/// so they can be restored. 6pub struct RawMode { 7 saved: libc::termios, 8} 9 10/// A decoded keypress from the terminal. 11#[derive(Debug, Clone, PartialEq, Eq)] 12pub enum Key { 13 /// A printable character (may be multi-byte UTF-8) 14 Char(char), 15 Enter, 16 Backspace, 17 Delete, 18 Tab, 19 /// Shift+Tab 20 BackTab, 21 Escape, 22 Up, 23 Down, 24 Left, 25 Right, 26 Home, 27 End, 28 PageUp, 29 PageDown, 30 /// Ctrl+C (interrupt) 31 CtrlC, 32 /// Ctrl+D (EOF) 33 CtrlD, 34 /// Ctrl+Z (suspend) 35 CtrlZ, 36 /// Other Ctrl combinations, e.g. Ctrl('a') for Ctrl+A 37 Ctrl(char), 38 /// Function keys F1-F12 39 F(u8), 40 /// A bracketed paste event. Contains the full pasted string. 41 /// Only emitted when bracketed paste mode is enabled via 42 /// `enable_bracketed_paste`. 43 Paste(String), 44 /// Unrecognized escape sequence 45 Unknown(Vec<u8>), 46} 47 48impl RawMode { 49 /// Enter raw terminal mode. Saves current terminal settings, disables 50 /// canonical mode and echo. 51 pub fn enter() -> io::Result<Self> { 52 unsafe { 53 let mut saved: libc::termios = std::mem::zeroed(); 54 if libc::tcgetattr(libc::STDIN_FILENO, &mut saved) != 0 { 55 return Err(io::Error::last_os_error()); 56 } 57 let mut raw = saved; 58 // Disable canonical mode, echo, and signal processing 59 raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN); 60 // Disable input processing 61 raw.c_iflag &= !(libc::IXON | libc::ICRNL | libc::BRKINT | libc::INPCK | libc::ISTRIP); 62 // Note: we intentionally do NOT disable OPOST so that \n → \r\n 63 // translation is preserved for output. 64 // Set character size to 8 bits 65 raw.c_cflag |= libc::CS8; 66 // Read returns after 1 byte, no timeout 67 raw.c_cc[libc::VMIN] = 1; 68 raw.c_cc[libc::VTIME] = 0; 69 if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &raw) != 0 { 70 return Err(io::Error::last_os_error()); 71 } 72 Ok(RawMode { saved }) 73 } 74 } 75 76 /// Exit raw terminal mode, restoring saved terminal settings. 77 pub fn exit(self) { 78 unsafe { 79 libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &self.saved); 80 } 81 } 82} 83 84impl Drop for RawMode { 85 fn drop(&mut self) { 86 unsafe { 87 libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &self.saved); 88 } 89 } 90} 91 92/// Enable bracketed paste mode. In this mode the terminal wraps paste events 93/// in `\x1b[200~` ... `\x1b[201~` markers, allowing `read_key` to return a 94/// single `Key::Paste(String)` instead of individual `Key::Char` events. 95/// 96/// Call once after entering raw mode. The mode is **not** automatically 97/// disabled on drop — call `disable_bracketed_paste` before exiting or use 98/// `with_bracketed_paste`. 99pub fn enable_bracketed_paste(_mode: &RawMode) { 100 use std::io::Write; 101 let _ = io::stdout().write_all(b"\x1b[?2004h"); 102 let _ = io::stdout().flush(); 103} 104 105/// Disable bracketed paste mode, restoring normal paste behaviour. 106pub fn disable_bracketed_paste(_mode: &RawMode) { 107 use std::io::Write; 108 let _ = io::stdout().write_all(b"\x1b[?2004l"); 109 let _ = io::stdout().flush(); 110} 111 112/// Run a function with raw mode enabled. Raw mode is always restored when 113/// the function returns (via Drop). 114pub fn with_raw_mode<T>(f: impl FnOnce(&RawMode) -> T) -> io::Result<T> { 115 let mode = RawMode::enter()?; 116 let result = f(&mode); 117 mode.exit(); 118 Ok(result) 119} 120 121// --------------------------------------------------------------------------- 122// Key reading 123// --------------------------------------------------------------------------- 124 125/// Read a single keypress, blocking until one arrives. 126pub fn read_key(_mode: &RawMode) -> io::Result<Key> { 127 let byte = read_byte()?; 128 Ok(decode_key(byte)) 129} 130 131/// Read a single keypress with a timeout. 132/// Returns `Ok(None)` if the timeout expires without input. 133pub fn read_key_timeout(_mode: &RawMode, timeout: Duration) -> io::Result<Option<Key>> { 134 match read_byte_timeout(timeout)? { 135 None => Ok(None), 136 Some(byte) => Ok(Some(decode_key(byte))), 137 } 138} 139 140// --------------------------------------------------------------------------- 141// Byte reading 142// --------------------------------------------------------------------------- 143 144fn read_byte() -> io::Result<u8> { 145 let mut buf = [0u8; 1]; 146 let ret = unsafe { libc::read(libc::STDIN_FILENO, buf.as_mut_ptr() as *mut libc::c_void, 1) }; 147 if ret == 1 { 148 Ok(buf[0]) 149 } else if ret == 0 { 150 Err(io::Error::new(io::ErrorKind::UnexpectedEof, "stdin closed")) 151 } else { 152 Err(io::Error::last_os_error()) 153 } 154} 155 156fn read_byte_timeout(timeout: Duration) -> io::Result<Option<u8>> { 157 use std::os::unix::io::AsRawFd; 158 let stdin_fd = io::stdin().as_raw_fd(); 159 160 unsafe { 161 let mut fds: libc::fd_set = std::mem::zeroed(); 162 libc::FD_SET(stdin_fd, &mut fds); 163 let mut tv = libc::timeval { 164 tv_sec: timeout.as_secs() as libc::time_t, 165 tv_usec: timeout.subsec_micros() as libc::suseconds_t, 166 }; 167 let ret = libc::select( 168 stdin_fd + 1, 169 &mut fds, 170 std::ptr::null_mut(), 171 std::ptr::null_mut(), 172 &mut tv, 173 ); 174 if ret > 0 { 175 Ok(Some(read_byte()?)) 176 } else { 177 Ok(None) 178 } 179 } 180} 181 182/// Read a byte with a very short timeout for escape sequence detection. 183fn peek_byte() -> Option<u8> { 184 read_byte_timeout(Duration::from_millis(50)).ok().flatten() 185} 186 187// --------------------------------------------------------------------------- 188// Key decoding 189// --------------------------------------------------------------------------- 190 191fn decode_key(byte: u8) -> Key { 192 match byte { 193 13 => Key::Enter, 194 9 => Key::Tab, 195 127 => Key::Backspace, 196 27 => decode_escape(), 197 3 => Key::CtrlC, 198 4 => Key::CtrlD, 199 26 => Key::CtrlZ, 200 n @ 1..=26 => { 201 let letter = (n + 96) as char; 202 Key::Ctrl(letter) 203 } 204 // Multi-byte UTF-8 sequences 205 n @ 0xC0..=0xDF => decode_utf8(n, 1), 206 n @ 0xE0..=0xEF => decode_utf8(n, 2), 207 n @ 0xF0..=0xF7 => decode_utf8(n, 3), 208 // Regular printable ASCII 209 n @ 32..=126 => Key::Char(n as char), 210 _ => Key::Unknown(vec![byte]), 211 } 212} 213 214fn decode_escape() -> Key { 215 match peek_byte() { 216 None => Key::Escape, 217 Some(b'[') => decode_csi(), 218 Some(b'O') => decode_ss3(), 219 Some(byte) => Key::Unknown(vec![27, byte]), 220 } 221} 222 223fn decode_csi() -> Key { 224 let mut params = Vec::new(); 225 loop { 226 match read_byte() { 227 Ok(byte @ 0x40..=0x7E) => return match_csi(&params, byte), 228 Ok(byte) => params.push(byte), 229 Err(_) => { 230 return Key::Unknown({ 231 let mut v = vec![27, b'[']; 232 v.extend(&params); 233 v 234 }); 235 } 236 } 237 } 238} 239 240fn match_csi(params: &[u8], final_byte: u8) -> Key { 241 let params_str = std::str::from_utf8(params).unwrap_or(""); 242 match final_byte { 243 b'A' => Key::Up, 244 b'B' => Key::Down, 245 b'C' => Key::Right, 246 b'D' => Key::Left, 247 b'H' => Key::Home, 248 b'F' => Key::End, 249 b'Z' => Key::BackTab, 250 b'~' => match params_str { 251 "3" => Key::Delete, 252 "5" => Key::PageUp, 253 "6" => Key::PageDown, 254 "15" => Key::F(5), 255 "17" => Key::F(6), 256 "18" => Key::F(7), 257 "19" => Key::F(8), 258 "20" => Key::F(9), 259 "21" => Key::F(10), 260 "23" => Key::F(11), 261 "24" => Key::F(12), 262 // Bracketed paste start: read until ESC[201~ 263 "200" => read_bracketed_paste(), 264 _ => build_unknown_csi(params, final_byte), 265 }, 266 _ => build_unknown_csi(params, final_byte), 267 } 268} 269 270/// Read bytes until the bracketed paste end marker `\x1b[201~` is seen. 271/// Returns `Key::Paste(text)` with the pasted content. 272fn read_bracketed_paste() -> Key { 273 let mut buf: Vec<u8> = Vec::new(); 274 loop { 275 match read_byte() { 276 Err(_) => break, 277 Ok(0x1b) => { 278 // Could be the end marker — peek for `[201~` 279 match peek_byte() { 280 Some(b'[') => { 281 // Read the rest of the CSI sequence 282 let mut csi: Vec<u8> = Vec::new(); 283 loop { 284 match read_byte() { 285 Ok(b @ 0x40..=0x7E) => { 286 if b == b'~' { 287 let s = std::str::from_utf8(&csi).unwrap_or(""); 288 if s == "201" { 289 // End of paste 290 let text = String::from_utf8_lossy(&buf).into_owned(); 291 return Key::Paste(text); 292 } 293 // Some other CSI sequence — put chars back as literal text 294 buf.push(0x1b); 295 buf.push(b'['); 296 buf.extend(&csi); 297 buf.push(b'~'); 298 } else { 299 buf.push(0x1b); 300 buf.push(b'['); 301 buf.extend(&csi); 302 buf.push(b); 303 } 304 break; 305 } 306 Ok(b) => csi.push(b), 307 Err(_) => { 308 buf.push(0x1b); 309 buf.push(b'['); 310 buf.extend(&csi); 311 break; 312 } 313 } 314 } 315 } 316 _ => buf.push(0x1b), 317 } 318 } 319 Ok(b) => buf.push(b), 320 } 321 } 322 Key::Paste(String::from_utf8_lossy(&buf).into_owned()) 323} 324 325fn decode_ss3() -> Key { 326 match read_byte() { 327 Ok(b'P') => Key::F(1), 328 Ok(b'Q') => Key::F(2), 329 Ok(b'R') => Key::F(3), 330 Ok(b'S') => Key::F(4), 331 Ok(b'H') => Key::Home, 332 Ok(b'F') => Key::End, 333 Ok(byte) => Key::Unknown(vec![27, b'O', byte]), 334 Err(_) => Key::Unknown(vec![27, b'O']), 335 } 336} 337 338fn decode_utf8(first_byte: u8, extra: usize) -> Key { 339 let mut bytes = vec![first_byte]; 340 for _ in 0..extra { 341 match read_byte() { 342 Ok(b) => bytes.push(b), 343 Err(_) => return Key::Unknown(bytes), 344 } 345 } 346 match std::str::from_utf8(&bytes) { 347 Ok(s) => { 348 if let Some(c) = s.chars().next() { 349 Key::Char(c) 350 } else { 351 Key::Unknown(bytes) 352 } 353 } 354 Err(_) => Key::Unknown(bytes), 355 } 356} 357 358fn build_unknown_csi(params: &[u8], final_byte: u8) -> Key { 359 let mut v = vec![27, b'[']; 360 v.extend(params); 361 v.push(final_byte); 362 Key::Unknown(v) 363} 364 365// --------------------------------------------------------------------------- 366// Helpers 367// --------------------------------------------------------------------------- 368 369impl Key { 370 /// Convert a Key to a human-readable string (useful for debugging). 371 pub fn to_display_string(&self) -> String { 372 match self { 373 Key::Char(c) => format!("Char({c})"), 374 Key::Enter => "Enter".into(), 375 Key::Backspace => "Backspace".into(), 376 Key::Delete => "Delete".into(), 377 Key::Tab => "Tab".into(), 378 Key::BackTab => "BackTab".into(), 379 Key::Escape => "Escape".into(), 380 Key::Up => "Up".into(), 381 Key::Down => "Down".into(), 382 Key::Left => "Left".into(), 383 Key::Right => "Right".into(), 384 Key::Home => "Home".into(), 385 Key::End => "End".into(), 386 Key::PageUp => "PageUp".into(), 387 Key::PageDown => "PageDown".into(), 388 Key::CtrlC => "Ctrl+C".into(), 389 Key::CtrlD => "Ctrl+D".into(), 390 Key::CtrlZ => "Ctrl+Z".into(), 391 Key::Ctrl(c) => format!("Ctrl+{}", c.to_ascii_uppercase()), 392 Key::F(n) => format!("F{n}"), 393 Key::Paste(s) => format!("Paste({} chars)", s.chars().count()), 394 Key::Unknown(bytes) => { 395 let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02X}")).collect(); 396 format!("Unknown({})", hex.join(" ")) 397 } 398 } 399 } 400}