Kaynağa Gözat

Initial functional version using termion.

Kestrel 1 gün önce
ebeveyn
işleme
a77ee3adfc
5 değiştirilmiş dosya ile 335 ekleme ve 33 silme
  1. 5 1
      Cargo.toml
  2. 8 2
      derive/src/lib.rs
  3. 15 3
      examples/action_prompt.rs
  4. 10 0
      examples/select_prompt.rs
  5. 297 27
      src/lib.rs

+ 5 - 1
Cargo.toml

@@ -6,4 +6,8 @@ edition = "2024"
 [dependencies]
 cliask-derive = { path = "derive", version = "0.1.0" }
 
-console = { version = "0.16", default-features = false, features = [ "std" ] }
+termion = { version = "4" }
+unicode-segmentation = { version = "1" }
+
+# console = { version = "0.16", default-features = false, features = [ "std", "unicode-width" ] }
+# crossterm = { version = "0.29.0", default-features = false, features = [ "events", "filedescriptor" ] }

+ 8 - 2
derive/src/lib.rs

@@ -27,13 +27,13 @@ fn do_action(input: syn::DeriveInput) -> Result<proc_macro::TokenStream, syn::Er
     }
 
     let enum_ident = input.ident;
-    let action_count = enumdata.variants.len();
     let idents = enumdata.variants.iter().map(|v| v.ident.clone()).collect::<Vec<_>>();
     let labels = enumdata.variants.iter().map(|v| v.ident.to_string()).collect::<Vec<_>>();
 
+    let indicies = 0..(enumdata.variants.len());
+
     Ok(quote! {
         impl ::cliask::ActionEnum for #enum_ident {
-            const ACTION_COUNT : usize = #action_count;
             const LABELS : &'static [&'static str] = &[ #(#labels),* ];
             const KEYS : &'static [char] = &[ #(#keys),* ];
 
@@ -45,6 +45,12 @@ fn do_action(input: syn::DeriveInput) -> Result<proc_macro::TokenStream, syn::Er
                     _ => None
                 }
             }
+
+            fn action_index(&self) -> usize {
+                match self {
+                    #( Self :: #idents => #indicies ),*
+                }
+            }
         }
     }.into())
 }

+ 15 - 3
examples/action_prompt.rs

@@ -1,10 +1,22 @@
-
 #[derive(cliask::ActionEnum)]
 enum YesNoPrompt {
     Yes,
-    No
+    No,
+}
+
+#[derive(cliask::ActionEnum)]
+enum MultiChoicePrompt {
+    Skip,
+    Abort,
+    Retry,
+    Fail,
+    Ignore,
+    Explode,
+    ExtraJuice
 }
 
 fn main() {
-    let answer : Result<YesNoPrompt, _> = cliask::ActionPrompt::new().run();
+    let ynanswer: Result<YesNoPrompt, _> = cliask::ActionPrompt::new().run();
+
+    let mcanswer : Result<MultiChoicePrompt, _> = cliask::ActionPrompt::new().with_default(MultiChoicePrompt::Explode).run();
 }

+ 10 - 0
examples/select_prompt.rs

@@ -0,0 +1,10 @@
+fn main() {
+    cliask::SelectPrompt::new("Choose a fruit:")
+        .with_items(["Grapefruit", "Guava", "Lychee"])
+        .run().unwrap();
+
+    let numbers = (0..100).collect::<Vec<_>>();
+    cliask::SelectPrompt::new("Choose a number:")
+        .with_items(numbers.iter())
+        .run_cancellable().unwrap();
+}

+ 297 - 27
src/lib.rs

@@ -1,9 +1,13 @@
 use std::io::Write;
+use std::os::fd::AsFd;
+use termion::{raw::IntoRawMode, input::TermRead};
 
 pub use cliask_derive::ActionEnum;
 
+#[derive(Debug)]
 pub enum AskError {
-    IOError(std::io::Error)
+    EscapeRequest,
+    IOError(std::io::Error),
 }
 
 impl From<std::io::Error> for AskError {
@@ -13,51 +17,317 @@ impl From<std::io::Error> for AskError {
 }
 
 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>,
-    _ghost: std::marker::PhantomData<&'l AE>
 }
 
-impl<'l, AE: ActionEnum> ActionPrompt<'l, AE> {
+impl<AE: ActionEnum> ActionPrompt<AE> {
     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 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,
+    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
+    }
+}