git_worktree_manager/tui/
multi_select.rs1use std::io::IsTerminal;
6
7#[cfg(unix)]
8use super::arrow_select::{get_terminal_width, read_key, truncate, write_stderr, Key};
9
10pub 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#[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; 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 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 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 write_stderr("\x1b[2K");
114 write_stderr(" \x1b[2m(Space: toggle, Enter: confirm, Esc/q: cancel)\x1b[0m\r\n");
115
116 write_stderr("\x1b[2K\r\n");
118 write_stderr("\x1b[2A");
120}
121
122fn 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}