1use 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
46fn get_terminal_width() -> usize {
52 console::Term::stderr().size().1 as usize
53}
54
55fn write_stderr(s: &str) {
57 let stderr = std::io::stderr();
58 let mut handle = stderr.lock();
59 let _ = handle.write_all(s.as_bytes());
60 let _ = handle.flush();
61}
62
63fn visible_len(text: &str) -> usize {
65 let mut len = 0;
66 let bytes = text.as_bytes();
67 let mut i = 0;
68 while i < bytes.len() {
69 if bytes[i] == b'\x1b' {
70 i += 1;
72 while i < bytes.len() && bytes[i] != b'm' {
73 i += 1;
74 }
75 if i < bytes.len() {
76 i += 1; }
78 } else {
79 len += 1;
80 i += 1;
81 }
82 }
83 len
84}
85
86fn truncate(text: &str, width: usize) -> String {
88 if visible_len(text) <= width {
89 return text.to_string();
90 }
91
92 let mut vis_pos = 0;
93 let mut cut_pos = 0;
94 let bytes = text.as_bytes();
95 let mut i = 0;
96
97 while i < bytes.len() && vis_pos < width.saturating_sub(1) {
98 if bytes[i] == b'\x1b' {
99 i += 1;
101 while i < bytes.len() && bytes[i] != b'm' {
102 i += 1;
103 }
104 if i < bytes.len() {
105 i += 1; }
107 } else {
108 vis_pos += 1;
109 i += 1;
110 }
111 cut_pos = i;
112 }
113
114 let mut result = text[..cut_pos].to_string();
115 result.push_str("\x1b[0m");
116 result
117}
118
119fn render(
125 items: &[(String, String)],
126 title: &str,
127 selected: usize,
128 _total_lines: usize,
129 first_render: bool,
130) {
131 let width = get_terminal_width();
132
133 if !first_render {
134 write_stderr("\x1b[u");
136 }
137
138 write_stderr("\x1b[s");
140
141 let line = format!(" \x1b[1m{title}\x1b[0m");
143 write_stderr(&format!("\x1b[2K{}\r\n", truncate(&line, width)));
144 write_stderr("\x1b[2K\r\n");
146
147 for (i, (label, value)) in items.iter().enumerate() {
148 write_stderr("\x1b[2K"); let line = if i == selected {
150 format!(" \x1b[1;7m > {label} \x1b[0m \x1b[2m{value}\x1b[0m")
151 } else {
152 format!(" {label} \x1b[2m{value}\x1b[0m")
153 };
154 write_stderr(&format!("{}\r\n", truncate(&line, width)));
155 }
156
157 for _ in 0..2 {
159 write_stderr("\x1b[2K\r\n");
160 }
161 write_stderr("\x1b[2A");
163}
164
165fn cleanup(total_lines: usize) {
167 write_stderr("\x1b[u");
169 for _ in 0..total_lines + 2 {
170 write_stderr("\x1b[2K\r\n");
171 }
172 write_stderr("\x1b[u");
173}
174
175#[derive(Debug, PartialEq)]
181enum Key {
182 Up,
183 Down,
184 Enter,
185 Escape,
186 CtrlC,
187 Quit,
188 Number(u8),
189 Unknown,
190}
191
192#[cfg(unix)]
194fn read_key(fd: std::os::unix::io::RawFd) -> Result<Key, std::io::Error> {
195 let mut buf = [0u8; 1];
196 let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
197 if n <= 0 {
198 return Err(std::io::Error::new(
199 std::io::ErrorKind::UnexpectedEof,
200 "EOF on stdin",
201 ));
202 }
203
204 match buf[0] {
205 b'\x1b' => {
206 let mut pollfd = libc::pollfd {
208 fd,
209 events: libc::POLLIN,
210 revents: 0,
211 };
212 let ready = unsafe { libc::poll(&mut pollfd as *mut libc::pollfd, 1, 50) };
213 if ready <= 0 {
214 return Ok(Key::Escape);
216 }
217 let mut seq1 = [0u8; 1];
218 let n = unsafe { libc::read(fd, seq1.as_mut_ptr() as *mut libc::c_void, 1) };
219 if n <= 0 {
220 return Ok(Key::Escape);
221 }
222 if seq1[0] == b'[' {
223 let mut seq2 = [0u8; 1];
224 let n = unsafe { libc::read(fd, seq2.as_mut_ptr() as *mut libc::c_void, 1) };
225 if n <= 0 {
226 return Ok(Key::Unknown);
227 }
228 match seq2[0] {
229 b'A' => Ok(Key::Up),
230 b'B' => Ok(Key::Down),
231 _ => Ok(Key::Unknown),
232 }
233 } else {
234 Ok(Key::Unknown)
235 }
236 }
237 b'\r' | b'\n' => Ok(Key::Enter),
238 0x03 => Ok(Key::CtrlC),
239 b'q' => Ok(Key::Quit),
240 c @ b'1'..=b'9' => Ok(Key::Number(c - b'0')),
241 _ => Ok(Key::Unknown),
242 }
243}
244
245#[cfg(unix)]
250fn arrow_select_unix(
251 items: &[(String, String)],
252 title: &str,
253 default_index: usize,
254) -> Option<Option<String>> {
255 use std::os::unix::io::AsRawFd;
256
257 let stdin = std::io::stdin();
258 let fd = stdin.as_raw_fd();
259
260 let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
262 if unsafe { libc::tcgetattr(fd, &mut old_termios) } != 0 {
263 return None; }
265
266 let mut selected = default_index;
267 let total_lines = items.len() + 2; write_stderr("\x1b[?25l");
271
272 let mut raw = old_termios;
274 raw.c_iflag &= !(libc::IGNBRK
276 | libc::BRKINT
277 | libc::PARMRK
278 | libc::ISTRIP
279 | libc::INLCR
280 | libc::IGNCR
281 | libc::ICRNL
282 | libc::IXON);
283 raw.c_oflag &= !libc::OPOST;
284 raw.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN);
285 raw.c_cflag &= !(libc::CSIZE | libc::PARENB);
286 raw.c_cflag |= libc::CS8;
287 raw.c_cc[libc::VMIN] = 1;
288 raw.c_cc[libc::VTIME] = 0;
289
290 if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 {
291 write_stderr("\x1b[?25h");
292 return None;
293 }
294
295 let result = (|| -> Option<String> {
296 render(items, title, selected, total_lines, true);
297
298 loop {
299 let key = match read_key(fd) {
300 Ok(k) => k,
301 Err(_) => {
302 cleanup(total_lines);
303 return None;
304 }
305 };
306
307 match key {
308 Key::Enter => {
309 cleanup(total_lines);
310 return Some(items[selected].1.clone());
311 }
312 Key::CtrlC | Key::Quit | Key::Escape => {
313 cleanup(total_lines);
314 return None;
315 }
316 Key::Up => {
317 selected = if selected == 0 {
318 items.len() - 1
319 } else {
320 selected - 1
321 };
322 render(items, title, selected, total_lines, false);
323 }
324 Key::Down => {
325 selected = (selected + 1) % items.len();
326 render(items, title, selected, total_lines, false);
327 }
328 Key::Number(n) => {
329 let idx = (n as usize) - 1;
330 if idx < items.len() {
331 cleanup(total_lines);
332 return Some(items[idx].1.clone());
333 }
334 }
335 Key::Unknown => {}
336 }
337 }
338 })();
339
340 unsafe {
342 libc::tcsetattr(fd, libc::TCSADRAIN, &old_termios);
343 }
344 write_stderr("\x1b[?25h");
346
347 Some(result)
348}
349
350fn arrow_select_fallback(
356 items: &[(String, String)],
357 title: &str,
358 default_index: usize,
359) -> Option<String> {
360 let stderr = std::io::stderr();
361 let mut out = stderr.lock();
362
363 let _ = writeln!(out, "\n {title}\n");
364 for (i, (label, value)) in items.iter().enumerate() {
365 let marker = if i == default_index { ">" } else { " " };
366 let _ = writeln!(out, " {marker} [{num}] {label} {value}", num = i + 1);
367 }
368 let _ = writeln!(out);
369 let _ = write!(out, "Select [1-{}]: ", items.len());
370 let _ = out.flush();
371
372 let mut input = String::new();
373 match std::io::stdin().read_line(&mut input) {
374 Ok(_) => {
375 let input = input.trim();
376 if input.is_empty() {
377 return Some(items[default_index].1.clone());
378 }
379 if let Ok(n) = input.parse::<usize>() {
380 let idx = n.wrapping_sub(1);
381 if idx < items.len() {
382 return Some(items[idx].1.clone());
383 }
384 }
385 None
386 }
387 Err(_) => None,
388 }
389}
390
391#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn test_visible_len_plain_text() {
401 assert_eq!(visible_len("hello"), 5);
402 assert_eq!(visible_len(""), 0);
403 assert_eq!(visible_len("abc def"), 7);
404 }
405
406 #[test]
407 fn test_visible_len_with_ansi() {
408 assert_eq!(visible_len("\x1b[1mhello\x1b[0m"), 5);
409 assert_eq!(
410 visible_len("\x1b[1;7m > foo \x1b[0m \x1b[2mbar\x1b[0m"),
411 12
412 );
413 assert_eq!(visible_len("\x1b[32m\x1b[0m"), 0);
414 }
415
416 #[test]
417 fn test_truncate_no_truncation_needed() {
418 let text = "short";
419 assert_eq!(truncate(text, 80), "short");
420 }
421
422 #[test]
423 fn test_truncate_plain_text() {
424 let text = "hello world this is a long string";
425 let result = truncate(text, 10);
426 assert!(visible_len(&result) <= 10);
428 assert!(result.ends_with("\x1b[0m"));
429 }
430
431 #[test]
432 fn test_truncate_with_ansi() {
433 let text = "\x1b[1mhello world long text\x1b[0m";
434 let result = truncate(text, 10);
435 assert!(visible_len(&result) <= 10);
436 assert!(result.ends_with("\x1b[0m"));
437 }
438
439 #[test]
440 fn test_truncate_width_one() {
441 let result = truncate("hello", 1);
442 assert!(result.ends_with("\x1b[0m"));
444 }
445
446 #[test]
447 fn test_arrow_select_empty_items() {
448 assert_eq!(arrow_select(&[], "title", 0), None);
449 }
450
451 #[test]
452 fn test_key_enum_equality() {
453 assert_eq!(Key::Up, Key::Up);
454 assert_eq!(Key::Number(3), Key::Number(3));
455 assert_ne!(Key::Up, Key::Down);
456 }
457
458 #[test]
459 fn test_fallback_default_index_clamped() {
460 let items = vec![
462 ("a".to_string(), "val_a".to_string()),
463 ("b".to_string(), "val_b".to_string()),
464 ];
465 let clamped = 10usize.min(items.len() - 1);
466 assert_eq!(clamped, 1);
467 }
468}