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    let _guard = super::raw_mode::RawModeGuard::enter(fd, true)?;
41
42    let mut cursor = 0usize;
43    let mut checked: Vec<bool> = vec![false; items.len()];
44    let total_lines = items.len() + 3; // title + blank + items + hint
45
46    render(items, &checked, cursor, title, true);
47
48    let result: Option<Vec<usize>> = loop {
49        match read_key(fd) {
50            Ok(Key::Up) => {
51                cursor = cursor.saturating_sub(1);
52                render(items, &checked, cursor, title, false);
53            }
54            Ok(Key::Down) => {
55                if cursor + 1 < items.len() {
56                    cursor += 1;
57                }
58                render(items, &checked, cursor, title, false);
59            }
60            Ok(Key::Space) => {
61                checked[cursor] = !checked[cursor];
62                render(items, &checked, cursor, title, false);
63            }
64            Ok(Key::Enter) => {
65                break Some(
66                    checked
67                        .iter()
68                        .enumerate()
69                        .filter_map(|(i, &c)| if c { Some(i) } else { None })
70                        .collect(),
71                );
72            }
73            Ok(Key::Escape) | Ok(Key::Quit) | Ok(Key::CtrlC) | Err(_) => {
74                break None;
75            }
76            _ => {}
77        }
78    };
79
80    // Clear our drawn lines on the happy path. Terminal mode + cursor are
81    // handled by `_guard` going out of scope.
82    super::arrow_select::cleanup(total_lines);
83
84    Some(result)
85}
86
87#[cfg(unix)]
88fn render(items: &[String], checked: &[bool], cursor: usize, title: &str, first: bool) {
89    let width = get_terminal_width();
90
91    if !first {
92        write_stderr("\x1b[u");
93    }
94    write_stderr("\x1b[s");
95
96    // Title
97    let line = format!("  \x1b[1m{title}\x1b[0m");
98    write_stderr(&format!("\x1b[2K{}\r\n", truncate(&line, width)));
99    write_stderr("\x1b[2K\r\n");
100
101    for (i, label) in items.iter().enumerate() {
102        write_stderr("\x1b[2K");
103        let mark = if checked[i] { "[x]" } else { "[ ]" };
104        let line = if i == cursor {
105            format!("  \x1b[1;7m > {mark} {label} \x1b[0m")
106        } else {
107            format!("    {mark} {label}")
108        };
109        write_stderr(&format!("{}\r\n", truncate(&line, width)));
110    }
111
112    // Hint line
113    write_stderr("\x1b[2K");
114    write_stderr("  \x1b[2m(Space: toggle, Enter: confirm, Esc/q: cancel)\x1b[0m\r\n");
115
116    // Blank spacer
117    write_stderr("\x1b[2K\r\n");
118    // Move cursor back up above the trailing blank
119    write_stderr("\x1b[2A");
120}
121
122// -- Fallback (non-Unix or non-TTY) --------------------------------------
123
124fn multi_select_fallback(items: &[String], title: &str) -> Option<Vec<usize>> {
125    eprintln!("{}", title);
126    for (i, item) in items.iter().enumerate() {
127        eprintln!("  [{}] {}", i + 1, item);
128    }
129    eprintln!("Enter numbers (space- or comma-separated), 'all', or blank to cancel:");
130    let mut buf = String::new();
131    if std::io::stdin().read_line(&mut buf).is_err() {
132        return None;
133    }
134    let s = buf.trim();
135    if s.is_empty() {
136        return None;
137    }
138    if s.eq_ignore_ascii_case("all") {
139        return Some((0..items.len()).collect());
140    }
141    let mut out = Vec::new();
142    for part in s.split(|c: char| c == ',' || c.is_whitespace()) {
143        if part.is_empty() {
144            continue;
145        }
146        if let Ok(n) = part.parse::<usize>() {
147            if n >= 1 && n <= items.len() {
148                out.push(n - 1);
149            }
150        }
151    }
152    out.sort();
153    out.dedup();
154    Some(out)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn empty_items_returns_empty_selection() {
163        let out = multi_select(&[], "title");
164        assert_eq!(out, Some(Vec::new()));
165    }
166}