|
@@ -1,9 +1,13 @@
|
|
use std::io::Write;
|
|
use std::io::Write;
|
|
|
|
+use std::os::fd::AsFd;
|
|
|
|
+use termion::{raw::IntoRawMode, input::TermRead};
|
|
|
|
|
|
pub use cliask_derive::ActionEnum;
|
|
pub use cliask_derive::ActionEnum;
|
|
|
|
|
|
|
|
+#[derive(Debug)]
|
|
pub enum AskError {
|
|
pub enum AskError {
|
|
- IOError(std::io::Error)
|
|
|
|
|
|
+ EscapeRequest,
|
|
|
|
+ IOError(std::io::Error),
|
|
}
|
|
}
|
|
|
|
|
|
impl From<std::io::Error> for AskError {
|
|
impl From<std::io::Error> for AskError {
|
|
@@ -13,51 +17,317 @@ impl From<std::io::Error> for AskError {
|
|
}
|
|
}
|
|
|
|
|
|
pub trait ActionEnum: 'static {
|
|
pub trait ActionEnum: 'static {
|
|
- const ACTION_COUNT : usize;
|
|
|
|
- const LABELS : &'static [&'static str];
|
|
|
|
- const KEYS : &'static [char];
|
|
|
|
- fn try_parse(from: char) -> Option<Self> where Self: Sized;
|
|
|
|
|
|
+ const LABELS: &'static [&'static str];
|
|
|
|
+ const KEYS: &'static [char];
|
|
|
|
+ fn try_parse(from: char) -> Option<Self>
|
|
|
|
+ where
|
|
|
|
+ Self: Sized;
|
|
|
|
+ fn action_index(&self) -> usize;
|
|
}
|
|
}
|
|
|
|
|
|
-pub struct ActionPrompt<'l, AE: ActionEnum> {
|
|
|
|
|
|
+pub struct ActionPrompt<AE: ActionEnum> {
|
|
default_action: Option<AE>,
|
|
default_action: Option<AE>,
|
|
- _ghost: std::marker::PhantomData<&'l AE>
|
|
|
|
}
|
|
}
|
|
|
|
|
|
-impl<'l, AE: ActionEnum> ActionPrompt<'l, AE> {
|
|
|
|
|
|
+impl<AE: ActionEnum> ActionPrompt<AE> {
|
|
pub fn new() -> Self {
|
|
pub fn new() -> Self {
|
|
- Self { default_action: None, _ghost: std::marker::PhantomData }
|
|
|
|
|
|
+ Self {
|
|
|
|
+ default_action: None,
|
|
|
|
+ }
|
|
}
|
|
}
|
|
- pub fn run(self) -> Result<AE, AskError> {
|
|
|
|
- let mut stdout = console::Term::stdout();
|
|
|
|
|
|
+ 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 options = String::new();
|
|
|
|
- let mut first = true;
|
|
|
|
- for (label,key) in AE::LABELS.iter().zip(AE::KEYS) {
|
|
|
|
|
|
+ let mut line = String::new();
|
|
|
|
+ for (label, key) in AE::LABELS.iter().zip(AE::KEYS) {
|
|
let key_string = key.to_string();
|
|
let key_string = key.to_string();
|
|
- let emphasized_label = label.replace(&key_string, format!("({})", console::style(&key_string).underlined()).as_str());
|
|
|
|
- if first {
|
|
|
|
- first = false
|
|
|
|
- } else {
|
|
|
|
- options.push('/');
|
|
|
|
|
|
+ let Some((before,after)) = label.split_once(&key_string) else { continue };
|
|
|
|
+
|
|
|
|
+ if line.len() > 0 {
|
|
|
|
+ line.push_str("/");
|
|
}
|
|
}
|
|
- options += format!("{}", emphasized_label).as_str();
|
|
|
|
|
|
+
|
|
|
|
+ line += format!("{reset}{before}{underline}{red}{key_string}{reset}{after}").as_str();
|
|
}
|
|
}
|
|
|
|
|
|
- options.push('?');
|
|
|
|
|
|
+ if let Some(default) = &self.default_action {
|
|
|
|
+ line += format!(" {blue}[default {}]", AE::KEYS[default.action_index()]).as_str();
|
|
|
|
+ }
|
|
|
|
|
|
- stdout.write(options.as_bytes())?;
|
|
|
|
|
|
+ line += format!(" {green}?{reset} ").as_str();
|
|
|
|
+
|
|
|
|
+ stdout.write(line.as_bytes())?;
|
|
|
|
+ stdout.flush()?;
|
|
|
|
+
|
|
|
|
+ Ok(())
|
|
|
|
+ }
|
|
|
|
|
|
- loop {
|
|
|
|
- if let Some(action) = AE::try_parse(stdout.read_char()?) {
|
|
|
|
- return Ok(action)
|
|
|
|
|
|
+ fn wait_for_answer(self, cancellable: bool) -> Result<Option<AE>, AskError> {
|
|
|
|
+ let _rawlock = 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(_rawlock);
|
|
|
|
+
|
|
|
|
+ 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> {
|
|
|
|
|
|
+pub struct SelectPrompt<'l, T: ?Sized + std::fmt::Display> {
|
|
prompt: &'l str,
|
|
prompt: &'l str,
|
|
|
|
+ height: usize,
|
|
|
|
+ items: Vec<(String, &'l T)>,
|
|
|
|
+ filtered_items: Vec<(String, &'l T)>,
|
|
|
|
+ alphabetize: bool,
|
|
|
|
+ input: String,
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+impl<'l, T: ?Sized + 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 = &'l 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 item in &self.items {
|
|
|
|
+ if item.0.starts_with(&self.input) {
|
|
|
|
+ self.filtered_items.push(item.clone());
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ fn append_column(remaining_width: usize, rows: &mut Vec<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 Some(newrem) = remaining_width.checked_sub(max_width) else { return None };
|
|
|
|
+
|
|
|
|
+ for (row, (col, width)) in rows.iter_mut().zip(col.drain(..).zip(widths.into_iter())) {
|
|
|
|
+ row.push_str(col);
|
|
|
|
+ // pad as needed
|
|
|
|
+ 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 item in self.filtered_items.iter() {
|
|
|
|
+ col.push(item.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<&'l T>, AskError> {
|
|
|
|
+ let mut keys = std::io::stdin().keys();
|
|
|
|
+ let mut raw = stdout.into_raw_mode()?;
|
|
|
|
+ self.refilter();
|
|
|
|
+ self.repaint(&mut raw)?;
|
|
|
|
+ while let Some(key) = keys.next() {
|
|
|
|
+ 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();
|
|
|
|
+ if self.filtered_items.len() == 1 {
|
|
|
|
+ return Ok(Some(self.filtered_items[0].1))
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ termion::event::Key::Ctrl('u') => {
|
|
|
|
+ self.input.clear();
|
|
|
|
+ self.refilter();
|
|
|
|
+ },
|
|
|
|
+ termion::event::Key::Char('\t') => {
|
|
|
|
+ self.refilter();
|
|
|
|
+ // see if we can extend the input unambiguously
|
|
|
|
+ let min_len = self.filtered_items.iter().map(|v| v.0.len()).min().unwrap_or(self.input.len());
|
|
|
|
+ for prefix_len in (self.input.len()+1)..=min_len {
|
|
|
|
+ if self.filtered_items.iter().all(|v| v.0.starts_with(&self.filtered_items[0].0[0..prefix_len])) {
|
|
|
|
+ self.input.clear();
|
|
|
|
+ self.input.push_str(&self.filtered_items[0].0[0..prefix_len]);
|
|
|
|
+ } else { break }
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ termion::event::Key::Char(ch) => {
|
|
|
|
+ self.input.push(ch);
|
|
|
|
+ },
|
|
|
|
+ _ => continue,
|
|
|
|
+ }
|
|
|
|
+ self.repaint(&mut raw)?;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ Ok(None)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ pub fn run(mut self) -> Result<&'l 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<&'l 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
|
|
|
|
+ }
|
|
|
|
+}
|