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