A simple TUI Library written in Rust
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(¶ms, byte),
228 Ok(byte) => params.push(byte),
229 Err(_) => {
230 return Key::Unknown({
231 let mut v = vec![27, b'['];
232 v.extend(¶ms);
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}