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))]
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 if chars.peek() == Some(&'[') {
78 chars.next(); 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#[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 let mut seq = String::from('\x1b');
111 if chars.peek() == Some(&'[') {
112 seq.push(chars.next().unwrap()); 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#[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 write_stderr("\x1b[u");
152 }
153
154 write_stderr("\x1b[s");
156
157 let line = format!(" \x1b[1m{title}\x1b[0m");
159 write_stderr(&format!("\x1b[2K{}\r\n", truncate(&line, width)));
160 write_stderr("\x1b[2K\r\n");
162
163 for (i, (label, value)) in items.iter().enumerate() {
164 write_stderr("\x1b[2K"); 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 for _ in 0..2 {
175 write_stderr("\x1b[2K\r\n");
176 }
177 write_stderr("\x1b[2A");
179}
180
181#[cfg(unix)]
183pub(crate) fn cleanup(total_lines: usize) {
184 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#[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#[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 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 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#[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; 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 cleanup(total_lines);
320
321 Some(result)
322}
323
324fn 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#[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 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 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}