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,
pub preview: String,
}
impl PickerItem {
pub fn new(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
label: label.into(),
detail: detail.into(),
preview: String::new(),
}
}
pub fn with_preview(mut self, preview: impl Into<String>) -> Self {
self.preview = preview.into();
self
}
}
pub fn filter_items(items: &[PickerItem], query: &str) -> Vec<usize> {
let terms: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
items
.iter()
.enumerate()
.filter(|(_, item)| {
if terms.is_empty() {
return true;
}
let hay = format!("{} {} {}", item.label, item.detail, item.preview).to_lowercase();
terms.iter().all(|t| hay.contains(t.as_str()))
})
.map(|(i, _)| i)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickOutcome {
Selected(usize),
Cancelled,
Widen,
}
#[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,
Type(char),
Erase,
Widen,
}
#[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 interpret_search_key(fd: RawFd, b: u8) -> io::Result<KeyAction> {
match b {
b'\r' | b'\n' => Ok(KeyAction::Select),
0x03 => Ok(KeyAction::Cancel), 0x17 => Ok(KeyAction::Widen), 0x7f | 0x08 => Ok(KeyAction::Erase),
0x1b => read_escape_sequence(fd),
b if (0x20..0x7f).contains(&b) => Ok(KeyAction::Type(b as char)),
_ => 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 | KeyAction::Type(_) | KeyAction::Erase | KeyAction::Widen => {}
}
}
}
#[cfg(windows)]
{
let _ = header;
Ok(None)
}
}
pub fn pick_searchable(
header: &str,
items: &[PickerItem],
widen_hint: Option<&str>,
) -> io::Result<PickOutcome> {
if items.is_empty() || !available() {
return Ok(PickOutcome::Cancelled);
}
#[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))?;
}
let mut hint =
" type to search · ↑/↓ to move · Enter to select · Esc to cancel".to_string();
if let Some(label) = widen_hint {
hint.push_str(&format!(" · Ctrl-W for {label}"));
}
writeln!(out, "{}", ui::paint(ui::DIM, &hint))?;
let mut query = String::new();
let mut selected = 0usize;
let mut drawn = 0usize;
loop {
let visible = filter_items(items, &query);
if selected >= visible.len() {
selected = visible.len().saturating_sub(1);
}
drawn = draw_search(&mut out, items, &visible, selected, &query, drawn)?;
let Some(b) = read_one(fd)? else {
return Ok(PickOutcome::Cancelled);
};
match interpret_search_key(fd, b)? {
KeyAction::Up => {
if !visible.is_empty() {
selected = selected.checked_sub(1).unwrap_or(visible.len() - 1);
}
}
KeyAction::Down => {
if !visible.is_empty() {
selected = (selected + 1) % visible.len();
}
}
KeyAction::Select => {
if let Some(index) = visible.get(selected) {
return Ok(PickOutcome::Selected(*index));
}
}
KeyAction::Cancel => return Ok(PickOutcome::Cancelled),
KeyAction::Widen if widen_hint.is_some() => return Ok(PickOutcome::Widen),
KeyAction::Type(c) => {
query.push(c);
selected = 0;
}
KeyAction::Erase => {
query.pop();
selected = 0;
}
KeyAction::Widen | KeyAction::Ignore => {}
}
}
}
#[cfg(windows)]
{
let _ = (header, widen_hint);
Ok(PickOutcome::Cancelled)
}
}
#[cfg(unix)]
fn draw_search(
out: &mut impl Write,
items: &[PickerItem],
visible: &[usize],
selected: usize,
query: &str,
previous: usize,
) -> io::Result<usize> {
if previous > 0 {
write!(out, "\x1b[{previous}A")?;
}
let mut lines = 0usize;
writeln!(
out,
"\r\x1b[2K{} {}",
ui::bold(ui::ACCENT, "search:"),
if query.is_empty() { "…" } else { query }
)?;
lines += 1;
if visible.is_empty() {
writeln!(out, "\r\x1b[2K{}", ui::paint(ui::DIM, " (no match)"))?;
lines += 1;
}
for (row, index) in visible.iter().enumerate() {
writeln!(
out,
"\r\x1b[2K{}",
render_row(&items[*index], row == selected)
)?;
lines += 1;
if row == selected && !items[*index].preview.is_empty() {
writeln!(
out,
"\r\x1b[2K {}",
ui::paint(ui::DIM, &items[*index].preview)
)?;
lines += 1;
}
}
for _ in lines..previous {
writeln!(out, "\r\x1b[2K")?;
lines += 1;
}
out.flush()?;
Ok(lines)
}
#[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 filter_matches_label_detail_and_preview_case_insensitively() {
let items = vec![
PickerItem::new("abc123 2m ago", "fix the parser").with_preview("why is x nil?"),
PickerItem::new("def456 1h ago", "add a flag"),
];
assert_eq!(filter_items(&items, ""), vec![0, 1]);
assert_eq!(filter_items(&items, "PARSER"), vec![0]);
assert_eq!(filter_items(&items, "def"), vec![1]);
assert_eq!(
filter_items(&items, "nil"),
vec![0],
"preview is searched too"
);
assert_eq!(filter_items(&items, "abc parser"), vec![0]);
assert!(filter_items(&items, "abc flag").is_empty());
assert!(filter_items(&items, "zzz").is_empty());
}
#[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());
}
}