git_worktree_manager/tui/
arrow_select.rs1use std::io::{IsTerminal, Write};
5
6pub 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 #[cfg(unix)]
36 {
37 if let Some(result) = arrow_select_unix(items, title, default_index) {
38 return result;
39 }
40 }
41
42 arrow_select_fallback(items, title, default_index)
44}
45
46#[cfg(unix)]
52pub(crate) fn get_terminal_width() -> usize {
53 console::Term::stderr().size().1 as usize
54}
55
56#[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#[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 i += 1;
75 while i < bytes.len() && bytes[i] != b'm' {
76 i += 1;
77 }
78 if i < bytes.len() {
79 i += 1; }
81 } else {
82 len += 1;
83 i += 1;
84 }
85 }
86 len
87}
88
89#[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 i += 1;
105 while i < bytes.len() && bytes[i] != b'm' {
106 i += 1;
107 }
108 if i < bytes.len() {
109 i += 1; }
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#[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 write_stderr("\x1b[u");
141 }
142
143 write_stderr("\x1b[s");
145
146 let line = format!(" \x1b[1m{title}\x1b[0m");
148 write_stderr(&format!("\x1b[2K{}\r\n", truncate(&line, width)));
149 write_stderr("\x1b[2K\r\n");
151
152 for (i, (label, value)) in items.iter().enumerate() {
153 write_stderr("\x1b[2K"); 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 for _ in 0..2 {
164 write_stderr("\x1b[2K\r\n");
165 }
166 write_stderr("\x1b[2A");
168}
169
170#[cfg(unix)]
172pub(crate) fn cleanup(total_lines: usize) {
173 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#[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#[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 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 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#[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 let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
271 if unsafe { libc::tcgetattr(fd, &mut old_termios) } != 0 {
272 return None; }
274
275 let mut selected = default_index;
276 let total_lines = items.len() + 2; write_stderr("\x1b[?25l");
280
281 let mut raw = old_termios;
283 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 unsafe {
351 libc::tcsetattr(fd, libc::TCSADRAIN, &old_termios);
352 }
353 write_stderr("\x1b[?25h");
355
356 Some(result)
357}
358
359fn 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#[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 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 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}