use std::io;
#[cfg(unix)]
use std::io::{IsTerminal, Write};
#[cfg(unix)]
use std::os::unix::io::RawFd;
#[cfg(unix)]
use crate::ui;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PickerItem {
pub label: String,
pub detail: String,
}
impl PickerItem {
pub fn new(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
label: label.into(),
detail: detail.into(),
}
}
}
#[cfg(unix)]
pub fn should_launch_picker(stdin_is_tty: bool, stdout_is_tty: bool) -> bool {
stdin_is_tty && stdout_is_tty
}
#[cfg(unix)]
pub fn available() -> bool {
should_launch_picker(io::stdin().is_terminal(), io::stdout().is_terminal())
}
#[cfg(windows)]
pub fn available() -> bool {
false
}
pub const MAX_ITEMS: usize = 20;
#[cfg(unix)]
struct RawGuard {
fd: RawFd,
orig: libc::termios,
}
#[cfg(unix)]
impl Drop for RawGuard {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig);
}
}
}
#[cfg(unix)]
fn get_termios(fd: RawFd) -> io::Result<libc::termios> {
let mut term = std::mem::MaybeUninit::<libc::termios>::uninit();
if unsafe { libc::tcgetattr(fd, term.as_mut_ptr()) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { term.assume_init() })
}
#[cfg(unix)]
fn set_termios(fd: RawFd, term: &libc::termios) -> io::Result<()> {
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, term) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(unix)]
fn read_one(fd: RawFd) -> io::Result<Option<u8>> {
let mut buf = [0u8; 1];
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n == 0 {
return Ok(None);
}
Ok(Some(buf[0]))
}
#[cfg(unix)]
enum KeyAction {
Up,
Down,
Select,
Cancel,
Ignore,
}
#[cfg(unix)]
fn interpret_key(fd: RawFd, b: u8) -> io::Result<KeyAction> {
match b {
b'\r' | b'\n' => Ok(KeyAction::Select),
0x03 => Ok(KeyAction::Cancel), b'q' | b'Q' => Ok(KeyAction::Cancel),
b'k' | b'K' => Ok(KeyAction::Up), b'j' | b'J' => Ok(KeyAction::Down),
0x1b => read_escape_sequence(fd),
_ => Ok(KeyAction::Ignore),
}
}
#[cfg(unix)]
fn read_escape_sequence(fd: RawFd) -> io::Result<KeyAction> {
let blocking = get_termios(fd)?;
let mut peek = blocking;
peek.c_cc[libc::VMIN] = 0;
peek.c_cc[libc::VTIME] = 1; set_termios(fd, &peek)?;
let next = read_one(fd)?;
set_termios(fd, &blocking)?;
if next != Some(b'[') {
return Ok(KeyAction::Cancel);
}
match read_one(fd)? {
Some(b'A') => Ok(KeyAction::Up), Some(b'B') => Ok(KeyAction::Down), _ => Ok(KeyAction::Ignore), }
}
#[cfg(unix)]
fn render_row(item: &PickerItem, selected: bool) -> String {
if selected {
format!(
"{} {} {}",
ui::bold(ui::ACCENT, ">"),
ui::bold(ui::ACCENT, &item.label),
ui::paint(ui::DIM, &item.detail)
)
} else {
format!(" {} {}", item.label, ui::paint(ui::DIM, &item.detail))
}
}
#[cfg(unix)]
fn draw_initial(out: &mut impl Write, items: &[PickerItem], selected: usize) -> io::Result<()> {
for (i, item) in items.iter().enumerate() {
writeln!(out, "\r{}", render_row(item, i == selected))?;
}
out.flush()
}
#[cfg(unix)]
fn redraw(out: &mut impl Write, items: &[PickerItem], selected: usize) -> io::Result<()> {
write!(out, "\x1b[{}A", items.len())?; for (i, item) in items.iter().enumerate() {
writeln!(out, "\r\x1b[2K{}", render_row(item, i == selected))?;
}
out.flush()
}
pub fn pick(header: &str, items: &[PickerItem]) -> io::Result<Option<usize>> {
if items.is_empty() || !available() {
return Ok(None);
}
#[cfg(unix)]
{
let fd = libc::STDIN_FILENO;
let orig = get_termios(fd)?;
let mut raw = orig;
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
raw.c_cc[libc::VMIN] = 1;
raw.c_cc[libc::VTIME] = 0;
set_termios(fd, &raw)?;
let _guard = RawGuard { fd, orig };
let mut out = io::stdout();
if !header.is_empty() {
writeln!(out, "{}", ui::bold(ui::ACCENT, header))?;
}
writeln!(
out,
"{}",
ui::paint(
ui::DIM,
" ↑/↓ or j/k to move · Enter to select · Esc/q to cancel"
)
)?;
let mut selected = 0usize;
draw_initial(&mut out, items, selected)?;
loop {
let Some(b) = read_one(fd)? else {
return Ok(None);
};
match interpret_key(fd, b)? {
KeyAction::Up => {
selected = selected.checked_sub(1).unwrap_or(items.len() - 1);
redraw(&mut out, items, selected)?;
}
KeyAction::Down => {
selected = (selected + 1) % items.len();
redraw(&mut out, items, selected)?;
}
KeyAction::Select => return Ok(Some(selected)),
KeyAction::Cancel => return Ok(None),
KeyAction::Ignore => {}
}
}
}
#[cfg(windows)]
{
let _ = header;
Ok(None)
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn gating_requires_both_stdin_and_stdout_tty() {
assert!(should_launch_picker(true, true));
assert!(!should_launch_picker(false, true));
assert!(!should_launch_picker(true, false));
assert!(!should_launch_picker(false, false));
}
#[test]
fn read_one_reports_a_closed_pipe_as_eof_not_a_hang() {
let mut fds = [0i32; 2];
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
assert_eq!(rc, 0, "pipe(2) failed: {}", io::Error::last_os_error());
let (read_fd, write_fd) = (fds[0], fds[1]);
unsafe { libc::close(write_fd) };
let result = read_one(read_fd);
unsafe { libc::close(read_fd) };
assert!(
matches!(result, Ok(None)),
"expected Ok(None) (EOF) on a closed pipe's read end, got {result:?}"
);
}
#[test]
fn picker_item_new_stores_both_fields() {
let item = PickerItem::new("alias", "vendor/slug");
assert_eq!(item.label, "alias");
assert_eq!(item.detail, "vendor/slug");
}
#[test]
fn render_row_marks_the_selected_row_and_only_that_one() {
let item = PickerItem::new("sess-1", "2m ago");
let sel = render_row(&item, true);
let unsel = render_row(&item, false);
assert_ne!(sel, unsel);
assert!(unsel.contains("sess-1"));
assert!(sel.contains("sess-1"));
}
#[test]
fn available_is_false_under_the_test_harness() {
assert!(!available());
}
}