Skip to main content

git_worktree_manager/
tui.rs

1//! Arrow-key TUI selector for interactive worktree selection.
2//!
3
4use std::io::{IsTerminal, Write};
5
6// ---------------------------------------------------------------------------
7// Public API
8// ---------------------------------------------------------------------------
9
10/// Arrow-key selector that renders on stderr and returns selected value.
11///
12/// # Arguments
13/// * `items` - List of (label, value) tuples
14/// * `title` - Title shown above the list
15/// * `default_index` - Initially highlighted item
16///
17/// # Returns
18/// The value of the selected item, or None if cancelled.
19pub fn arrow_select(
20    items: &[(String, String)],
21    title: &str,
22    default_index: usize,
23) -> Option<String> {
24    if items.is_empty() {
25        return None;
26    }
27
28    if !std::io::stderr().is_terminal() {
29        return None;
30    }
31
32    let default_index = default_index.min(items.len() - 1);
33
34    // Try Unix raw-mode selector first
35    #[cfg(unix)]
36    {
37        if let Some(result) = arrow_select_unix(items, title, default_index) {
38            return result;
39        }
40    }
41
42    // Fallback to numbered input
43    arrow_select_fallback(items, title, default_index)
44}
45
46// ---------------------------------------------------------------------------
47// Terminal helpers
48// ---------------------------------------------------------------------------
49
50/// Get terminal width from stderr, defaulting to 80.
51fn get_terminal_width() -> usize {
52    console::Term::stderr().size().1 as usize
53}
54
55/// Write raw bytes to stderr (unbuffered).
56fn write_stderr(s: &str) {
57    let stderr = std::io::stderr();
58    let mut handle = stderr.lock();
59    let _ = handle.write_all(s.as_bytes());
60    let _ = handle.flush();
61}
62
63/// Strip ANSI escape sequences and return the visible length.
64fn visible_len(text: &str) -> usize {
65    let mut len = 0;
66    let bytes = text.as_bytes();
67    let mut i = 0;
68    while i < bytes.len() {
69        if bytes[i] == b'\x1b' {
70            // Skip until 'm'
71            i += 1;
72            while i < bytes.len() && bytes[i] != b'm' {
73                i += 1;
74            }
75            if i < bytes.len() {
76                i += 1; // skip 'm'
77            }
78        } else {
79            len += 1;
80            i += 1;
81        }
82    }
83    len
84}
85
86/// Truncate text to fit within `width` visible characters, preserving ANSI codes.
87fn truncate(text: &str, width: usize) -> String {
88    if visible_len(text) <= width {
89        return text.to_string();
90    }
91
92    let mut vis_pos = 0;
93    let mut cut_pos = 0;
94    let bytes = text.as_bytes();
95    let mut i = 0;
96
97    while i < bytes.len() && vis_pos < width.saturating_sub(1) {
98        if bytes[i] == b'\x1b' {
99            // Skip ANSI escape sequence
100            i += 1;
101            while i < bytes.len() && bytes[i] != b'm' {
102                i += 1;
103            }
104            if i < bytes.len() {
105                i += 1; // skip 'm'
106            }
107        } else {
108            vis_pos += 1;
109            i += 1;
110        }
111        cut_pos = i;
112    }
113
114    let mut result = text[..cut_pos].to_string();
115    result.push_str("\x1b[0m");
116    result
117}
118
119// ---------------------------------------------------------------------------
120// Rendering
121// ---------------------------------------------------------------------------
122
123/// Render the selector list on stderr using ANSI escape codes.
124fn render(
125    items: &[(String, String)],
126    title: &str,
127    selected: usize,
128    _total_lines: usize,
129    first_render: bool,
130) {
131    let width = get_terminal_width();
132
133    if !first_render {
134        // Restore cursor to saved position
135        write_stderr("\x1b[u");
136    }
137
138    // Save cursor position at the start of our render area
139    write_stderr("\x1b[s");
140
141    // Title
142    let line = format!("  \x1b[1m{title}\x1b[0m");
143    write_stderr(&format!("\x1b[2K{}\r\n", truncate(&line, width)));
144    // Blank line
145    write_stderr("\x1b[2K\r\n");
146
147    for (i, (label, value)) in items.iter().enumerate() {
148        write_stderr("\x1b[2K"); // clear line
149        let line = if i == selected {
150            format!("  \x1b[1;7m > {label} \x1b[0m  \x1b[2m{value}\x1b[0m")
151        } else {
152            format!("    {label}  \x1b[2m{value}\x1b[0m")
153        };
154        write_stderr(&format!("{}\r\n", truncate(&line, width)));
155    }
156
157    // Clear any leftover lines below
158    for _ in 0..2 {
159        write_stderr("\x1b[2K\r\n");
160    }
161    // Move back up to just after our items
162    write_stderr("\x1b[2A");
163}
164
165/// Erase the rendered selector from stderr.
166fn cleanup(total_lines: usize) {
167    // Restore to saved position
168    write_stderr("\x1b[u");
169    for _ in 0..total_lines + 2 {
170        write_stderr("\x1b[2K\r\n");
171    }
172    write_stderr("\x1b[u");
173}
174
175// ---------------------------------------------------------------------------
176// Key reading
177// ---------------------------------------------------------------------------
178
179/// Recognized key events.
180#[derive(Debug, PartialEq)]
181enum Key {
182    Up,
183    Down,
184    Enter,
185    Escape,
186    CtrlC,
187    Quit,
188    Number(u8),
189    Unknown,
190}
191
192/// Read a single keypress from the given file descriptor (Unix).
193#[cfg(unix)]
194fn read_key(fd: std::os::unix::io::RawFd) -> Result<Key, std::io::Error> {
195    let mut buf = [0u8; 1];
196    let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
197    if n <= 0 {
198        return Err(std::io::Error::new(
199            std::io::ErrorKind::UnexpectedEof,
200            "EOF on stdin",
201        ));
202    }
203
204    match buf[0] {
205        b'\x1b' => {
206            // Could be escape sequence -- peek with a short timeout using select/poll
207            let mut pollfd = libc::pollfd {
208                fd,
209                events: libc::POLLIN,
210                revents: 0,
211            };
212            let ready = unsafe { libc::poll(&mut pollfd as *mut libc::pollfd, 1, 50) };
213            if ready <= 0 {
214                // Bare Escape key
215                return Ok(Key::Escape);
216            }
217            let mut seq1 = [0u8; 1];
218            let n = unsafe { libc::read(fd, seq1.as_mut_ptr() as *mut libc::c_void, 1) };
219            if n <= 0 {
220                return Ok(Key::Escape);
221            }
222            if seq1[0] == b'[' {
223                let mut seq2 = [0u8; 1];
224                let n = unsafe { libc::read(fd, seq2.as_mut_ptr() as *mut libc::c_void, 1) };
225                if n <= 0 {
226                    return Ok(Key::Unknown);
227                }
228                match seq2[0] {
229                    b'A' => Ok(Key::Up),
230                    b'B' => Ok(Key::Down),
231                    _ => Ok(Key::Unknown),
232                }
233            } else {
234                Ok(Key::Unknown)
235            }
236        }
237        b'\r' | b'\n' => Ok(Key::Enter),
238        0x03 => Ok(Key::CtrlC),
239        b'q' => Ok(Key::Quit),
240        c @ b'1'..=b'9' => Ok(Key::Number(c - b'0')),
241        _ => Ok(Key::Unknown),
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Unix raw-mode selector
247// ---------------------------------------------------------------------------
248
249#[cfg(unix)]
250fn arrow_select_unix(
251    items: &[(String, String)],
252    title: &str,
253    default_index: usize,
254) -> Option<Option<String>> {
255    use std::os::unix::io::AsRawFd;
256
257    let stdin = std::io::stdin();
258    let fd = stdin.as_raw_fd();
259
260    // Save original terminal attributes
261    let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
262    if unsafe { libc::tcgetattr(fd, &mut old_termios) } != 0 {
263        return None; // Can't get termios, fall back
264    }
265
266    let mut selected = default_index;
267    let total_lines = items.len() + 2; // title + blank + items
268
269    // Hide cursor
270    write_stderr("\x1b[?25l");
271
272    // Set raw mode
273    let mut raw = old_termios;
274    // cfmakeraw equivalent
275    raw.c_iflag &= !(libc::IGNBRK
276        | libc::BRKINT
277        | libc::PARMRK
278        | libc::ISTRIP
279        | libc::INLCR
280        | libc::IGNCR
281        | libc::ICRNL
282        | libc::IXON);
283    raw.c_oflag &= !libc::OPOST;
284    raw.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
285    raw.c_cflag &= !(libc::CSIZE | libc::PARENB);
286    raw.c_cflag |= libc::CS8;
287    raw.c_cc[libc::VMIN] = 1;
288    raw.c_cc[libc::VTIME] = 0;
289
290    if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 {
291        write_stderr("\x1b[?25h");
292        return None;
293    }
294
295    let result = (|| -> Option<String> {
296        render(items, title, selected, total_lines, true);
297
298        loop {
299            let key = match read_key(fd) {
300                Ok(k) => k,
301                Err(_) => {
302                    cleanup(total_lines);
303                    return None;
304                }
305            };
306
307            match key {
308                Key::Enter => {
309                    cleanup(total_lines);
310                    return Some(items[selected].1.clone());
311                }
312                Key::CtrlC | Key::Quit | Key::Escape => {
313                    cleanup(total_lines);
314                    return None;
315                }
316                Key::Up => {
317                    selected = if selected == 0 {
318                        items.len() - 1
319                    } else {
320                        selected - 1
321                    };
322                    render(items, title, selected, total_lines, false);
323                }
324                Key::Down => {
325                    selected = (selected + 1) % items.len();
326                    render(items, title, selected, total_lines, false);
327                }
328                Key::Number(n) => {
329                    let idx = (n as usize) - 1;
330                    if idx < items.len() {
331                        cleanup(total_lines);
332                        return Some(items[idx].1.clone());
333                    }
334                }
335                Key::Unknown => {}
336            }
337        }
338    })();
339
340    // Restore terminal
341    unsafe {
342        libc::tcsetattr(fd, libc::TCSADRAIN, &old_termios);
343    }
344    // Show cursor
345    write_stderr("\x1b[?25h");
346
347    Some(result)
348}
349
350// ---------------------------------------------------------------------------
351// Fallback: numbered list
352// ---------------------------------------------------------------------------
353
354/// Fallback numbered list with text input.
355fn arrow_select_fallback(
356    items: &[(String, String)],
357    title: &str,
358    default_index: usize,
359) -> Option<String> {
360    let stderr = std::io::stderr();
361    let mut out = stderr.lock();
362
363    let _ = writeln!(out, "\n  {title}\n");
364    for (i, (label, value)) in items.iter().enumerate() {
365        let marker = if i == default_index { ">" } else { " " };
366        let _ = writeln!(out, "  {marker} [{num}] {label}  {value}", num = i + 1);
367    }
368    let _ = writeln!(out);
369    let _ = write!(out, "Select [1-{}]: ", items.len());
370    let _ = out.flush();
371
372    let mut input = String::new();
373    match std::io::stdin().read_line(&mut input) {
374        Ok(_) => {
375            let input = input.trim();
376            if input.is_empty() {
377                return Some(items[default_index].1.clone());
378            }
379            if let Ok(n) = input.parse::<usize>() {
380                let idx = n.wrapping_sub(1);
381                if idx < items.len() {
382                    return Some(items[idx].1.clone());
383                }
384            }
385            None
386        }
387        Err(_) => None,
388    }
389}
390
391// ---------------------------------------------------------------------------
392// Tests
393// ---------------------------------------------------------------------------
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_visible_len_plain_text() {
401        assert_eq!(visible_len("hello"), 5);
402        assert_eq!(visible_len(""), 0);
403        assert_eq!(visible_len("abc def"), 7);
404    }
405
406    #[test]
407    fn test_visible_len_with_ansi() {
408        assert_eq!(visible_len("\x1b[1mhello\x1b[0m"), 5);
409        assert_eq!(
410            visible_len("\x1b[1;7m > foo \x1b[0m  \x1b[2mbar\x1b[0m"),
411            12
412        );
413        assert_eq!(visible_len("\x1b[32m\x1b[0m"), 0);
414    }
415
416    #[test]
417    fn test_truncate_no_truncation_needed() {
418        let text = "short";
419        assert_eq!(truncate(text, 80), "short");
420    }
421
422    #[test]
423    fn test_truncate_plain_text() {
424        let text = "hello world this is a long string";
425        let result = truncate(text, 10);
426        // Should be at most 9 visible chars + reset
427        assert!(visible_len(&result) <= 10);
428        assert!(result.ends_with("\x1b[0m"));
429    }
430
431    #[test]
432    fn test_truncate_with_ansi() {
433        let text = "\x1b[1mhello world long text\x1b[0m";
434        let result = truncate(text, 10);
435        assert!(visible_len(&result) <= 10);
436        assert!(result.ends_with("\x1b[0m"));
437    }
438
439    #[test]
440    fn test_truncate_width_one() {
441        let result = truncate("hello", 1);
442        // With width=1, saturating_sub(1) = 0, so no visible chars
443        assert!(result.ends_with("\x1b[0m"));
444    }
445
446    #[test]
447    fn test_arrow_select_empty_items() {
448        assert_eq!(arrow_select(&[], "title", 0), None);
449    }
450
451    #[test]
452    fn test_key_enum_equality() {
453        assert_eq!(Key::Up, Key::Up);
454        assert_eq!(Key::Number(3), Key::Number(3));
455        assert_ne!(Key::Up, Key::Down);
456    }
457
458    #[test]
459    fn test_fallback_default_index_clamped() {
460        // arrow_select clamps default_index; test the logic directly
461        let items = vec![
462            ("a".to_string(), "val_a".to_string()),
463            ("b".to_string(), "val_b".to_string()),
464        ];
465        let clamped = 10usize.min(items.len() - 1);
466        assert_eq!(clamped, 1);
467    }
468}