Skip to main content

git_worktree_manager/tui/
multi_select.rs

1//! Arrow/checkbox multi-select TUI used by `gw delete -i`.
2//!
3//! Built on the raw-mode plumbing shared from `arrow_select.rs`.
4
5use std::io::IsTerminal;
6
7#[cfg(unix)]
8use super::arrow_select::{get_terminal_width, read_key, truncate, write_stderr, Key};
9
10/// Multi-select entry point. Returns selected indices in ascending order,
11/// or `None` if the user cancelled. An empty Vec means the user confirmed
12/// with zero selections.
13pub fn multi_select(items: &[String], title: &str) -> Option<Vec<usize>> {
14    if items.is_empty() {
15        return Some(Vec::new());
16    }
17    if !std::io::stderr().is_terminal() {
18        return multi_select_fallback(items, title);
19    }
20
21    #[cfg(unix)]
22    {
23        if let Some(result) = multi_select_unix(items, title) {
24            return result;
25        }
26    }
27
28    multi_select_fallback(items, title)
29}
30
31// -- Unix raw-mode --------------------------------------------------------
32
33#[cfg(unix)]
34fn multi_select_unix(items: &[String], title: &str) -> Option<Option<Vec<usize>>> {
35    use std::os::unix::io::AsRawFd;
36
37    let stdin = std::io::stdin();
38    let fd = stdin.as_raw_fd();
39
40    // Save original terminal attributes
41    let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
42    if unsafe { libc::tcgetattr(fd, &mut old_termios) } != 0 {
43        return None;
44    }
45
46    // Enter raw mode
47    let mut raw = old_termios;
48    unsafe { libc::cfmakeraw(&mut raw) };
49    if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
50        return None;
51    }
52
53    // Hide cursor
54    write_stderr("\x1b[?25l");
55
56    let mut cursor = 0usize;
57    let mut checked: Vec<bool> = vec![false; items.len()];
58    let total_lines = items.len() + 3; // title + blank + items + hint
59
60    render(items, &checked, cursor, title, true);
61
62    let result: Option<Vec<usize>> = loop {
63        match read_key(fd) {
64            Ok(Key::Up) => {
65                cursor = cursor.saturating_sub(1);
66                render(items, &checked, cursor, title, false);
67            }
68            Ok(Key::Down) => {
69                if cursor + 1 < items.len() {
70                    cursor += 1;
71                }
72                render(items, &checked, cursor, title, false);
73            }
74            Ok(Key::Space) => {
75                checked[cursor] = !checked[cursor];
76                render(items, &checked, cursor, title, false);
77            }
78            Ok(Key::Enter) => {
79                break Some(
80                    checked
81                        .iter()
82                        .enumerate()
83                        .filter_map(|(i, &c)| if c { Some(i) } else { None })
84                        .collect(),
85                );
86            }
87            Ok(Key::Escape) | Ok(Key::Quit) | Ok(Key::CtrlC) | Err(_) => {
88                break None;
89            }
90            _ => {}
91        }
92    };
93
94    // Cleanup: show cursor, restore termios, clear our drawn lines
95    write_stderr("\x1b[?25h");
96    super::arrow_select::cleanup(total_lines);
97    unsafe {
98        libc::tcsetattr(fd, libc::TCSANOW, &old_termios);
99    }
100
101    Some(result)
102}
103
104#[cfg(unix)]
105fn render(items: &[String], checked: &[bool], cursor: usize, title: &str, first: bool) {
106    let width = get_terminal_width();
107
108    if !first {
109        write_stderr("\x1b[u");
110    }
111    write_stderr("\x1b[s");
112
113    // Title
114    let line = format!("  \x1b[1m{title}\x1b[0m");
115    write_stderr(&format!("\x1b[2K{}\r\n", truncate(&line, width)));
116    write_stderr("\x1b[2K\r\n");
117
118    for (i, label) in items.iter().enumerate() {
119        write_stderr("\x1b[2K");
120        let mark = if checked[i] { "[x]" } else { "[ ]" };
121        let line = if i == cursor {
122            format!("  \x1b[1;7m > {mark} {label} \x1b[0m")
123        } else {
124            format!("    {mark} {label}")
125        };
126        write_stderr(&format!("{}\r\n", truncate(&line, width)));
127    }
128
129    // Hint line
130    write_stderr("\x1b[2K");
131    write_stderr("  \x1b[2m(Space: toggle, Enter: confirm, Esc/q: cancel)\x1b[0m\r\n");
132
133    // Blank spacer
134    write_stderr("\x1b[2K\r\n");
135    // Move cursor back up above the trailing blank
136    write_stderr("\x1b[2A");
137}
138
139// -- Fallback (non-Unix or non-TTY) --------------------------------------
140
141fn multi_select_fallback(items: &[String], title: &str) -> Option<Vec<usize>> {
142    eprintln!("{}", title);
143    for (i, item) in items.iter().enumerate() {
144        eprintln!("  [{}] {}", i + 1, item);
145    }
146    eprintln!("Enter numbers (space- or comma-separated), 'all', or blank to cancel:");
147    let mut buf = String::new();
148    if std::io::stdin().read_line(&mut buf).is_err() {
149        return None;
150    }
151    let s = buf.trim();
152    if s.is_empty() {
153        return None;
154    }
155    if s.eq_ignore_ascii_case("all") {
156        return Some((0..items.len()).collect());
157    }
158    let mut out = Vec::new();
159    for part in s.split(|c: char| c == ',' || c.is_whitespace()) {
160        if part.is_empty() {
161            continue;
162        }
163        if let Ok(n) = part.parse::<usize>() {
164            if n >= 1 && n <= items.len() {
165                out.push(n - 1);
166            }
167        }
168    }
169    out.sort();
170    out.dedup();
171    Some(out)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn empty_items_returns_empty_selection() {
180        let out = multi_select(&[], "title");
181        assert_eq!(out, Some(Vec::new()));
182    }
183}