mod error;
mod item;
mod key;
mod raw_mode;
#[cfg(test)]
mod tests;
use std::io;
use tau_term_screen::screen::Screen;
use tau_term_screen::style::StyledText;
use tau_term_screen::truncate_to_width;
pub use crate::error::PickerError;
pub use crate::item::PickerItem;
use crate::key::{PickerEvent, PickerKey, read_byte_key, read_terminal_event};
use crate::raw_mode::{RawModeCleanup, RawModeGuard};
pub fn pick(prompt: &str, items: &[PickerItem]) -> Result<usize, PickerError> {
pick_with_raw_mode(
prompt,
items,
io::stderr(),
RawModeGuard::enable,
read_terminal_event,
terminal_size,
)
}
pub fn pick_with_writer(
prompt: &str,
items: &[PickerItem],
writer: impl io::Write,
) -> Result<usize, PickerError> {
pick_with_raw_mode(
prompt,
items,
writer,
RawModeGuard::enable,
read_terminal_event,
terminal_size,
)
}
pub fn pick_with_io(
prompt: &str,
items: &[PickerItem],
writer: impl io::Write,
mut reader: impl io::Read,
) -> Result<usize, PickerError> {
pick_with_event_reader(
prompt,
items,
writer,
|| read_byte_key(&mut reader).map(PickerEvent::Key),
terminal_size,
)
}
fn pick_with_raw_mode<G: RawModeCleanup>(
prompt: &str,
items: &[PickerItem],
writer: impl io::Write,
enable_raw_mode: impl FnOnce() -> io::Result<G>,
read_event: impl FnMut() -> io::Result<PickerEvent>,
current_size: impl FnMut() -> (usize, usize),
) -> Result<usize, PickerError> {
validate_items(items)?;
let mut raw = enable_raw_mode()?;
let result = pick_with_event_reader(prompt, items, writer, read_event, current_size);
let restore_result = raw.restore_raw_mode();
match restore_result {
Ok(()) => result,
Err(err) => Err(PickerError::Io(err)),
}
}
fn validate_items(items: &[PickerItem]) -> Result<usize, PickerError> {
if items.is_empty() {
return Err(PickerError::Empty);
}
items
.iter()
.position(PickerItem::is_enabled)
.ok_or(PickerError::NoEnabledItems)
}
fn pick_with_event_reader(
prompt: &str,
items: &[PickerItem],
mut writer: impl io::Write,
mut read_event: impl FnMut() -> io::Result<PickerEvent>,
mut current_size: impl FnMut() -> (usize, usize),
) -> Result<usize, PickerError> {
let mut selected = validate_items(items)?;
let (mut width, mut height) = current_size();
let mut screen = Screen::new(width);
let result = (|| -> Result<usize, PickerError> {
render(&mut screen, &mut writer, prompt, items, selected, height)?;
loop {
match read_event()? {
PickerEvent::Key(PickerKey::Down) => {
selected = adjacent_enabled_item(items, selected, NavigationDirection::Forward);
}
PickerEvent::Key(PickerKey::Up) => {
selected =
adjacent_enabled_item(items, selected, NavigationDirection::Backward);
}
PickerEvent::Key(PickerKey::Enter) => {
clear_picker_frame(&mut screen, &mut writer)?;
return Ok(selected);
}
PickerEvent::Key(PickerKey::Cancelled) => {
return Err(PickerError::Cancelled);
}
PickerEvent::Key(PickerKey::Ignored) => {}
PickerEvent::Resize {
width: new_width,
height: new_height,
} => {
let new_width = resize_dimension(new_width, width);
let new_height = resize_dimension(new_height, height);
screen.erase_all(&mut writer)?;
screen.invalidate();
if new_width != width {
screen.set_width(new_width);
width = new_width;
}
height = new_height;
}
}
render(&mut screen, &mut writer, prompt, items, selected, height)?;
}
})();
if result.is_err() {
let _ = force_clear_picker_frame(&mut screen, &mut writer);
}
result
}
fn clear_picker_frame(screen: &mut Screen, writer: &mut impl io::Write) -> io::Result<()> {
screen.update(writer, &[], (0, 0))?;
writer.flush()
}
fn force_clear_picker_frame(screen: &mut Screen, writer: &mut impl io::Write) -> io::Result<()> {
screen.erase_all(writer)?;
screen.invalidate();
writer.flush()
}
fn render(
screen: &mut Screen,
writer: &mut impl io::Write,
prompt: &str,
items: &[PickerItem],
selected: usize,
terminal_height: usize,
) -> io::Result<()> {
let (lines, cursor_row) =
picker_lines(prompt, items, selected, screen.width(), terminal_height);
screen.update(writer, &lines, (cursor_row, 0))?;
writer.flush()
}
fn picker_lines(
prompt: &str,
items: &[PickerItem],
selected: usize,
width: usize,
terminal_height: usize,
) -> (Vec<tau_term_screen::CellRow>, usize) {
if terminal_height <= 1 {
let item = &items[selected];
let marker = if item.is_enabled() { '>' } else { 'X' };
let line = truncate_to_width(&format!("{marker} {} — ? {prompt}", item.label()), width);
return (vec![StyledText::from(line).to_cells().into()], 0);
}
let visible = terminal_height.saturating_sub(1).max(1);
let window = visible_window(items.len(), selected, visible);
let mut lines = Vec::with_capacity(window.len() + 1);
lines.push(
StyledText::from(truncate_to_width(&format!("? {prompt}"), width))
.to_cells()
.into(),
);
for (idx, item) in items.iter().enumerate().take(window.end).skip(window.start) {
let marker = if !item.is_enabled() {
'X'
} else if idx == selected {
'>'
} else {
' '
};
let line = truncate_to_width(&format!("{marker} {}", item.label()), width);
lines.push(StyledText::from(line).to_cells().into());
}
(lines, selected - window.start + 1)
}
fn visible_window(total: usize, selected: usize, visible: usize) -> std::ops::Range<usize> {
if total <= visible {
return 0..total;
}
let half = visible / 2;
let mut start = selected.saturating_sub(half);
if total < start + visible {
start = total - visible;
}
start..start + visible
}
enum NavigationDirection {
Forward,
Backward,
}
fn adjacent_enabled_item(
items: &[PickerItem],
selected: usize,
direction: NavigationDirection,
) -> usize {
for offset in 1..items.len() {
let idx = match direction {
NavigationDirection::Forward => (selected + offset) % items.len(),
NavigationDirection::Backward => (selected + items.len() - offset) % items.len(),
};
if items[idx].is_enabled() {
return idx;
}
}
selected
}
fn resize_dimension(reported: u16, current: usize) -> usize {
if 0 < reported {
usize::from(reported)
} else {
current.max(1)
}
}
const DEFAULT_TERMINAL_WIDTH: usize = 80;
const DEFAULT_TERMINAL_HEIGHT: usize = 24;
fn terminal_size() -> (usize, usize) {
crossterm::terminal::size().map_or(
(DEFAULT_TERMINAL_WIDTH, DEFAULT_TERMINAL_HEIGHT),
|(w, h)| (usize::from(w).max(1), usize::from(h).max(1)),
)
}