use std::io::Write;
use std::os::fd::AsFd;
use termion::{raw::IntoRawMode, input::TermRead};
extern crate self as cliask;
pub use cliask_derive::ActionEnum;
#[derive(Debug)]
pub enum AskError {
IOError(std::io::Error),
}
impl std::fmt::Display for AskError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
<AskError as std::fmt::Debug>::fmt(self, f)
}
}
impl std::error::Error for AskError {
#[allow(deprecated)]
fn cause(&self) -> Option<&dyn std::error::Error> {
match self {
Self::IOError(e) => e.cause()
}
}
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::IOError(e) => e.source()
}
}
#[allow(deprecated)]
fn description(&self) -> &str {
match self {
Self::IOError(e) => e.description()
}
}
}
impl From<std::io::Error> for AskError {
fn from(value: std::io::Error) -> Self {
Self::IOError(value)
}
}
pub trait ActionEnum: 'static {
const LABELS: &'static [&'static str];
const KEYS: &'static [char];
fn try_parse(from: char) -> Option<Self>
where
Self: Sized;
fn action_index(&self) -> usize;
}
#[derive(ActionEnum)]
pub enum YesNoAction {
Yes,
No,
}
impl From<YesNoAction> for bool {
fn from(value: YesNoAction) -> Self {
match value {
YesNoAction::Yes => true,
YesNoAction::No => false,
}
}
}
pub struct ActionPrompt<AE: ActionEnum> {
default_action: Option<AE>,
}
impl<AE: ActionEnum> ActionPrompt<AE> {
pub fn new() -> Self {
Self {
default_action: None,
}
}
pub fn with_default(mut self, action: AE) -> Self {
self.default_action = Some(action);
self
}
fn print_prompt(&self) -> Result<(), AskError> {
let mut stdout = std::io::stdout();
let reset = termion::style::Reset;
let underline = termion::style::Underline;
let red = termion::color::Fg(termion::color::Red);
let blue = termion::color::Fg(termion::color::Blue);
let green = termion::color::Fg(termion::color::Green);
let mut line = String::new();
for (label, key) in AE::LABELS.iter().zip(AE::KEYS) {
let key_string = key.to_string();
let Some((before,after)) = label.split_once(&key_string) else { continue };
if !line.is_empty() {
line.push('/');
}
line += format!("{reset}{before}{underline}{red}{key_string}{reset}{after}").as_str();
}
if let Some(default) = &self.default_action {
line += format!(" {blue}[default {}]", AE::KEYS[default.action_index()]).as_str();
}
line += format!(" {green}?{reset} ").as_str();
stdout.write_all(line.as_bytes())?;
stdout.flush()?;
Ok(())
}
fn wait_for_answer(self, cancellable: bool) -> Result<Option<AE>, AskError> {
let stdoutlock = std::io::stdout().into_raw_mode()?;
let stdin = std::io::stdin();
let mut keys = stdin.keys();
let action = loop {
let Some(key) = keys.next() else { continue };
match key? {
termion::event::Key::Esc => {
if cancellable {
return Ok(None)
}
},
termion::event::Key::Ctrl('c') => {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "CTRL-C pressed").into())
},
termion::event::Key::Char('\r') | termion::event::Key::Char('\n') => {
if let Some(default) = self.default_action {
break default
}
},
termion::event::Key::Char(c) => {
let uc = c.to_uppercase().next().unwrap();
if let Some(out) = AE::try_parse(uc) {
break out
}
},
_ => (),
}
};
drop(stdoutlock);
let reset = termion::style::Reset;
let underline = termion::style::Underline;
let red = termion::color::Fg(termion::color::Red);
println!("{red}{underline}{}{reset}", AE::KEYS[action.action_index()]);
Ok(Some(action))
}
pub fn run(self) -> Result<AE, AskError> {
self.print_prompt()?;
self.wait_for_answer(false).map(Option::unwrap)
}
pub fn run_cancellable(self) -> Result<Option<AE>, AskError> {
self.print_prompt()?;
self.wait_for_answer(true)
}
}
pub struct SelectPrompt<'l, T: std::fmt::Display> {
prompt: &'l str,
height: usize,
items: Vec<(String, T)>,
filtered_items: Vec<usize>,
alphabetize: bool,
input: String,
}
impl<'l, T: std::fmt::Display> SelectPrompt<'l, T> {
pub fn new(prompt: &'l str) -> Self {
Self {
prompt,
height: 5,
items: vec![],
filtered_items: vec![],
alphabetize: true,
input: String::new(),
}
}
pub fn with_items(mut self, items: impl IntoIterator<Item = T>) -> Self {
for item in items {
self.items.push((item.to_string(), item));
}
self
}
fn setup(&mut self, stdout: &mut std::io::Stdout) -> Result<(), AskError> {
if self.alphabetize {
self.items.sort_by_cached_key(|v| v.0.clone());
}
for _ in 0..self.height { writeln!(stdout)? };
write!(stdout, "{}{}",
termion::cursor::Up(self.height as u16),
termion::cursor::Save,
)?;
stdout.flush()?;
Ok(())
}
fn teardown(&mut self, stdout: &mut std::io::Stdout) -> Result<(), AskError> {
write!(stdout, "{restore}{reset}\r{clear_after}\r{} {red}{underline}{}{reset}\r\n",
self.prompt,
self.input,
restore = termion::cursor::Restore,
reset = termion::style::Reset,
clear_after = termion::clear::AfterCursor,
red = termion::color::Fg(termion::color::Red),
underline = termion::style::Underline,
)?;
Ok(())
}
fn refilter(&mut self) {
self.filtered_items.clear();
for (idx,item) in self.items.iter().enumerate() {
if item.0.starts_with(&self.input) {
self.filtered_items.push(idx);
}
}
}
fn append_column(remaining_width: usize, rows: &mut [String], col: &mut Vec<&str>) -> Option<usize> {
if col.is_empty() || remaining_width == 0 {
return None
}
use unicode_segmentation::UnicodeSegmentation;
let widths = col.iter().map(|v| v.graphemes(true).count()).collect::<Vec<_>>();
let max_width = *widths.iter().max().unwrap() + 2;
let newrem = remaining_width.checked_sub(max_width)?;
for (row, (col, width)) in rows.iter_mut().zip(col.drain(..).zip(widths.into_iter())) {
row.push_str(col);
for _ in 0..(max_width - width) { row.push(' ') }
}
Some(newrem)
}
fn repaint(&mut self, stdout: &mut termion::raw::RawTerminal<impl Write + AsFd>) -> Result<(), AskError> {
write!(stdout, "{restore}{reset}\r{clear}{} {underline}{red}{}{reset}{save}",
self.prompt,
self.input,
reset = termion::style::Reset,
restore = termion::cursor::Restore,
save = termion::cursor::Save,
clear = termion::clear::AfterCursor,
underline = termion::style::Underline,
red = termion::color::Fg(termion::color::Red),
)?;
stdout.flush()?;
write!(stdout, "{}", termion::color::Fg(termion::color::Yellow))?;
let (width, _height) = termion::terminal_size()?;
let mut rows = vec![String::new(); self.height];
let mut col = vec![];
let mut rwidth = width as usize;
'gencols: {
for idx in self.filtered_items.iter() {
col.push(self.items[*idx].0.as_str());
if col.len() == rows.len() {
let Some(rval) = Self::append_column(rwidth, &mut rows, &mut col) else { break 'gencols };
rwidth = rval;
}
}
if !col.is_empty() {
Self::append_column(rwidth, &mut rows, &mut col);
}
}
for row in rows {
write!(stdout, "\r\n{}", row)?;
}
write!(stdout, "{}", termion::cursor::Restore)?;
stdout.flush()?;
Ok(())
}
fn input_loop(&mut self, stdout: &mut std::io::Stdout, cancellable: bool) -> Result<Option<T>, AskError> {
let mut raw = stdout.into_raw_mode()?;
self.refilter();
self.repaint(&mut raw)?;
for key in std::io::stdin().keys() {
let key = key?;
match key {
termion::event::Key::Ctrl('c') => {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "Ctrl-C pressed").into())
},
termion::event::Key::Esc => {
if cancellable {
return Ok(None)
}
},
termion::event::Key::Delete | termion::event::Key::Backspace => {
self.input.pop();
},
termion::event::Key::Char('\r') | termion::event::Key::Char('\n') => {
self.refilter();
let pos = if self.filtered_items.len() == 1 {
self.filtered_items[0]
} else if let Some(pos) = self.items.iter().position(|p| p.0 == self.input) {
pos
} else {
continue
};
return Ok(Some(self.items.swap_remove(pos).1))
},
termion::event::Key::Ctrl('u') => {
self.input.clear();
self.refilter();
},
termion::event::Key::Char('\t') => {
self.refilter();
if !self.filtered_items.is_empty() {
let min_len = self.filtered_items.iter().map(|v| self.items[*v].0.len()).min().unwrap_or(self.input.len());
for prefix_len in (self.input.len()+1)..=min_len {
let tomatch = &self.items[self.filtered_items[0]].0[0..prefix_len];
if self.filtered_items.iter().all(|v| self.items[*v].0.starts_with(tomatch)) {
self.input.clear();
self.input.push_str(tomatch);
} else { break }
}
}
},
termion::event::Key::Char(ch) => {
self.input.push(ch);
},
_ => continue,
}
self.repaint(&mut raw)?;
}
Ok(None)
}
pub fn run(mut self) -> Result<T, AskError> {
let mut stdout = std::io::stdout();
self.setup(&mut stdout)?;
let r = self.input_loop(&mut stdout, false).map(Option::unwrap);
self.teardown(&mut stdout)?;
r
}
pub fn run_cancellable(mut self) -> Result<Option<T>, AskError> {
let mut stdout = std::io::stdout();
self.setup(&mut stdout)?;
let r = self.input_loop(&mut stdout, true);
self.teardown(&mut stdout)?;
r
}
}