Skip to main content

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