use std::io::{self, Write};
use crossterm::{
cursor, event,
event::{Event, KeyCode, KeyEventKind, KeyModifiers},
execute, queue, style,
style::Stylize,
terminal,
};
use crate::error::Error;
use super::{PickItem, PickOutcome, Picker, PickerError};
pub struct TerminalPicker;
impl TerminalPicker {
pub fn new() -> Self {
Self
}
}
impl Default for TerminalPicker {
fn default() -> Self {
Self::new()
}
}
impl Picker for TerminalPicker {
fn pick(&mut self, title: &str, items: &mut [PickItem<'_>]) -> Result<PickOutcome, Error> {
let _guard = TerminalGuard::enter()?;
let mut stdout = io::stdout();
let mut cursor_row: usize = 0;
let choice_col_width = max_choice_label_width(items);
loop {
render(&mut stdout, title, items, cursor_row, choice_col_width)
.map_err(PickerError::Io)?;
match read_action().map_err(PickerError::Io)? {
Action::CursorUp => {
if !items.is_empty() {
cursor_row = if cursor_row == 0 {
items.len() - 1
} else {
cursor_row - 1
};
}
}
Action::CursorDown => {
if !items.is_empty() {
cursor_row = (cursor_row + 1) % items.len();
}
}
Action::Cycle => {
if let Some(item) = items.get_mut(cursor_row)
&& item.choices.len() > 1
{
item.choice = (item.choice + 1) % item.choices.len();
}
}
Action::Apply => return Ok(PickOutcome::Apply),
Action::Abort => return Ok(PickOutcome::Abort),
Action::Ignore => {}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
CursorUp,
CursorDown,
Cycle,
Apply,
Abort,
Ignore,
}
fn classify_event(ev: Event) -> Action {
let Event::Key(key) = ev else {
return Action::Ignore;
};
if key.kind == KeyEventKind::Release {
return Action::Ignore;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c' | 'd') => Action::Abort,
_ => Action::Ignore,
};
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => Action::CursorUp,
KeyCode::Down | KeyCode::Char('j') => Action::CursorDown,
KeyCode::Char(' ') => Action::Cycle,
KeyCode::Enter => Action::Apply,
KeyCode::Esc | KeyCode::Char('q') => Action::Abort,
_ => Action::Ignore,
}
}
fn read_action() -> io::Result<Action> {
loop {
match classify_event(event::read()?) {
Action::Ignore => continue,
a => return Ok(a),
}
}
}
fn max_choice_label_width(items: &[PickItem<'_>]) -> usize {
items
.iter()
.flat_map(|i| i.choices.iter())
.map(|c| c.label.chars().count())
.max()
.unwrap_or(0)
}
fn render(
out: &mut io::Stdout,
title: &str,
items: &[PickItem<'_>],
cursor_row: usize,
choice_col_width: usize,
) -> io::Result<()> {
queue!(
out,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0),
)?;
writeln!(out, "{}", style::style(title).bold())?;
queue!(out, cursor::MoveToColumn(0))?;
writeln!(out)?;
queue!(out, cursor::MoveToColumn(0))?;
for (idx, item) in items.iter().enumerate() {
let is_cursor = idx == cursor_row;
let pointer = if is_cursor { "›" } else { " " };
let choice = &item.choices[item.choice];
let bracket = format!("[{:<width$}]", choice.label, width = choice_col_width);
let bracket_styled = if item.choices.len() == 1 {
style::style(bracket).dim().to_string()
} else if is_cursor {
style::style(bracket).cyan().to_string()
} else {
bracket
};
write!(out, "{pointer} {bracket_styled} {}", item.label)?;
if let Some(note) = &item.note {
write!(out, " {}", style::style(format!("({note})")).dim())?;
}
writeln!(out)?;
queue!(out, cursor::MoveToColumn(0))?;
}
writeln!(out)?;
queue!(out, cursor::MoveToColumn(0))?;
let hint = "↑↓ move space cycle enter apply q abort";
writeln!(out, "{}", style::style(hint).dim())?;
out.flush()
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> Result<Self, PickerError> {
terminal::enable_raw_mode().map_err(PickerError::Io)?;
if let Err(e) = execute!(io::stdout(), terminal::EnterAlternateScreen, cursor::Hide,) {
let _ = terminal::disable_raw_mode();
return Err(PickerError::Io(e));
}
Ok(Self)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use similar_asserts::assert_eq;
use super::*;
use crate::picker::ChoiceLabel;
fn key(code: KeyCode, mods: KeyModifiers) -> Event {
Event::Key(KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
#[test]
fn arrow_and_letter_keys_move_cursor() {
assert_eq!(
classify_event(key(KeyCode::Up, KeyModifiers::NONE)),
Action::CursorUp,
);
assert_eq!(
classify_event(key(KeyCode::Char('k'), KeyModifiers::NONE)),
Action::CursorUp,
);
assert_eq!(
classify_event(key(KeyCode::Down, KeyModifiers::NONE)),
Action::CursorDown,
);
assert_eq!(
classify_event(key(KeyCode::Char('j'), KeyModifiers::NONE)),
Action::CursorDown,
);
}
#[test]
fn space_cycles_enter_applies_q_aborts() {
assert_eq!(
classify_event(key(KeyCode::Char(' '), KeyModifiers::NONE)),
Action::Cycle,
);
assert_eq!(
classify_event(key(KeyCode::Enter, KeyModifiers::NONE)),
Action::Apply,
);
assert_eq!(
classify_event(key(KeyCode::Char('q'), KeyModifiers::NONE)),
Action::Abort,
);
assert_eq!(
classify_event(key(KeyCode::Esc, KeyModifiers::NONE)),
Action::Abort,
);
}
#[test]
fn ctrl_c_and_ctrl_d_abort_other_modified_keys_ignored() {
assert_eq!(
classify_event(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
Action::Abort,
);
assert_eq!(
classify_event(key(KeyCode::Char('d'), KeyModifiers::CONTROL)),
Action::Abort,
);
assert_eq!(
classify_event(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
Action::Ignore,
);
assert_eq!(
classify_event(key(KeyCode::Char(' '), KeyModifiers::CONTROL)),
Action::Ignore,
);
}
#[test]
fn key_release_events_are_ignored() {
let release = Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
});
assert_eq!(classify_event(release), Action::Ignore);
}
#[test]
fn non_key_events_are_ignored() {
assert_eq!(classify_event(Event::Resize(80, 24)), Action::Ignore);
assert_eq!(classify_event(Event::FocusGained), Action::Ignore);
}
#[test]
fn max_choice_label_width_picks_longest_across_items() {
let two = [ChoiceLabel::new("a", "do"), ChoiceLabel::new("b", "skip")];
let three = [
ChoiceLabel::new("a", "remove"),
ChoiceLabel::new("b", "keep"),
];
let items = [
PickItem {
label: Cow::Borrowed("x"),
note: None,
choices: &two,
choice: 0,
},
PickItem {
label: Cow::Borrowed("y"),
note: None,
choices: &three,
choice: 0,
},
];
assert_eq!(max_choice_label_width(&items), 6);
}
#[test]
fn max_choice_label_width_empty_returns_zero() {
assert_eq!(max_choice_label_width(&[]), 0);
}
}