mod render;
mod state;
#[cfg(test)]
mod tests;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
TerminalOptions, Viewport,
crossterm::{
ExecutableCommand,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
},
};
use std::time::Duration;
pub use render::render_picker;
pub use state::PickerState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionMode {
#[default]
Checkbox,
Radio,
}
pub struct Section {
pub title: String,
pub items: Vec<SectionItem>,
pub selection_mode: SelectionMode,
pub collapsed: bool,
}
impl Section {
pub fn new(title: impl Into<String>, items: Vec<SectionItem>) -> Self {
Self {
title: title.into(),
items,
selection_mode: SelectionMode::Checkbox,
collapsed: false,
}
}
pub fn radio(mut self) -> Self {
self.selection_mode = SelectionMode::Radio;
self
}
pub fn collapsed(mut self) -> Self {
self.collapsed = true;
self
}
}
pub struct SectionItem {
pub label: String,
pub checked: bool,
pub description: Option<String>,
}
impl SectionItem {
pub fn new(label: impl Into<String>, checked: bool) -> Self {
Self {
label: label.into(),
checked,
description: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
pub struct ActionContext<'a> {
section_idx: usize,
item_idx: usize,
terminal: &'a mut ratatui::DefaultTerminal,
}
impl ActionContext<'_> {
pub fn section(&self) -> usize {
self.section_idx
}
pub fn item(&self) -> usize {
self.item_idx
}
pub fn terminal(&mut self) -> &mut ratatui::DefaultTerminal {
self.terminal
}
}
pub type ActionHandler<'a> = Box<dyn FnMut(&mut ActionContext<'_>) + 'a>;
pub struct PickerAction<'a> {
pub key: char,
pub label: &'a str,
pub handler: ActionHandler<'a>,
}
pub enum PickerOutcome {
Confirmed(Vec<Vec<bool>>),
Cancelled,
}
pub fn run_picker(
title: &str,
sections: Vec<Section>,
actions: Vec<PickerAction<'_>>,
) -> anyhow::Result<PickerOutcome> {
let height: u16 = sections
.iter()
.map(|sec| sec.items.len() as u16 + 2)
.sum::<u16>()
+ 2;
let mut state = PickerState::new(sections);
if state.is_empty() {
return Ok(PickerOutcome::Confirmed(Vec::new()));
}
let mut is_fullscreen = false;
let viewport = if let Ok((_, r)) = ratatui::crossterm::terminal::size()
&& r > height
{
Viewport::Inline(height)
} else {
is_fullscreen = true;
Viewport::Fullscreen
};
let mut terminal = ratatui::init_with_options(TerminalOptions { viewport });
enable_raw_mode()?;
if is_fullscreen {
terminal.backend_mut().execute(EnterAlternateScreen)?;
}
let result = run_picker_loop(&mut terminal, title, &mut state, actions);
disable_raw_mode()?;
if is_fullscreen {
terminal.backend_mut().execute(LeaveAlternateScreen)?;
}
result
}
fn run_picker_loop(
terminal: &mut ratatui::DefaultTerminal,
title: &str,
state: &mut PickerState,
mut actions: Vec<PickerAction<'_>>,
) -> anyhow::Result<PickerOutcome> {
let action_keys: Vec<char> = actions.iter().map(|a| a.key).collect();
loop {
terminal.draw(|frame| render_picker(frame, title, state, &actions))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(PickerOutcome::Cancelled);
}
state.clear_confirm_error();
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
KeyCode::Down | KeyCode::Char('j') => state.move_down(),
KeyCode::Char(' ') => state.toggle(),
KeyCode::Left => state.collapse_current(),
KeyCode::Right => state.expand_current(),
KeyCode::Backspace => state.backspace(),
KeyCode::Enter => {
if !state.has_any_checked()
&& state.current_section_mode() != SelectionMode::Radio
{
state.toggle();
}
match state.try_confirm() {
Ok(results) => return Ok(PickerOutcome::Confirmed(results)),
Err(msg) => state.set_confirm_error(msg),
}
}
KeyCode::Esc | KeyCode::Char('q') => {
return Ok(PickerOutcome::Cancelled);
}
KeyCode::Char('a') => state.toggle_current_section(),
KeyCode::Char(c) => {
if let Some(idx) = action_keys.iter().position(|&k| k == c) {
let (section_idx, item_idx) = state.current_coordinates();
let mut ctx = ActionContext {
section_idx,
item_idx,
terminal,
};
(actions[idx].handler)(&mut ctx);
}
}
_ => {}
}
}
}
}