console_prompt/
lib.rs

1use crossterm::{
2    terminal,
3    cursor,
4    ExecutableCommand,
5    QueueableCommand,
6    csi,
7    Command as ctCommand,
8};
9use std::error::Error;
10use std::fmt;
11use std::io;
12use std::io::Write;
13use rustyline::error::ReadlineError;
14use std::any::Any;
15use std::collections::HashMap;
16
17pub struct DynamicContext {
18    values: HashMap<String, Box<dyn Any>>,
19}
20
21impl DynamicContext {
22    pub fn new() -> Self {
23        DynamicContext {
24            values: HashMap::new(),
25        }
26    }
27
28    pub fn set<T: 'static>(&mut self, key: &str, value: T) {
29        self.values.insert(key.to_owned(), Box::new(value));
30    }
31
32    pub fn get<T: 'static>(&self, key: &str) -> Option<&T> {
33        self.values
34            .get(key)
35            .and_then(|value| value.downcast_ref::<T>())
36    }
37
38    pub fn get_mut<T: 'static>(&mut self, key: &str) -> Option<&mut T> {
39        self.values
40            .get_mut(key)
41            .and_then(|value| value.downcast_mut::<T>())
42    }
43}
44
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47struct SetScrollingRegion(pub u16, pub u16);
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct SetScrollingAll();
51
52#[derive(Debug)]
53pub enum CrosstermError {
54  UnimplementedInWindows,
55}
56
57impl std::error::Error for CrosstermError {}
58
59impl fmt::Display for CrosstermError {
60  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
61    match self {
62      CrosstermError::UnimplementedInWindows => write!(f, 
63          "This command is unimplemented for Windows"),
64    }
65  }
66}
67
68
69/// A command that restricts terminal output scrolling within the given 
70/// starting and ending rows.
71impl ctCommand for SetScrollingRegion {
72    fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
73        write!(f, csi!("{};{}r"), self.0, self.1)
74    }
75
76    #[cfg(windows)]
77    fn execute_winapi(&self) -> Result<(), CrosstermError> {
78        Err(CrosstermError::UnimplementedInWindows)
79    }
80}
81
82/// Enables scrolling for the entire screen.  
83/// This is called after running SetScrollingRegion
84/// to re-enable terminal scrolling for the entire screen.  
85impl ctCommand for SetScrollingAll {
86    fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
87        write!(f, csi!("r"))
88    }
89
90    #[cfg(windows)]
91    fn execute_winapi(&self) -> Result<(), CrosstermError> {
92        Err(CrosstermError::UnimplementedInWindows)
93    }
94}
95
96
97/// Runs the actual command loop, providing readline output via rustyline.
98/// The context arg allows for the passing of additional 'context' information
99/// to maintain a state during subcalls if needed.
100pub fn command_loop(commands: &Vec<Command>, context: &mut DynamicContext) -> Result<(), Box<dyn Error>>{
101    setup_screen()?;
102    let default= String::from(">> ");
103
104    println!("info: type 'help' to for a list of commands");
105    let help_str = build_help_str(&commands);
106    loop { // command loop
107        if let Err(err) = setup_screen(){
108            eprintln!("error during screen setup: {}", err.to_string());
109        }
110        let mut rl = rustyline::Editor::<()>::new().unwrap();
111        let prompt = context.get::<String>("prompt").unwrap_or(&default);
112        match rl.readline(prompt){
113            Ok(line)=>{
114                if line.is_empty(){continue}
115
116                let mut input_split = line.split(' ').collect::<Vec<_>>(); // TODO needs better
117                                                                           // tokenization
118                let input_command = input_split.remove(0);
119                let input_args = &input_split;
120                
121                // check for the help command
122                if input_command.eq("help") || input_command.eq("?") {
123                    write_output(help_str.clone(), None)?;
124                    continue;
125                }
126                if input_command.eq("exit") {
127                    break;
128                }
129
130                for cmd in commands.into_iter().filter(|cmd| cmd.command.eq(input_command)) {
131                    let output = (cmd.func)(&input_args, context);
132                    match output {
133                        Err(err) => eprintln!("error executing '{}': {}", input_command, err.to_string()),
134                        Ok(output_str) => write_output(output_str, None).expect("error writing output"),
135                    }
136                }
137            },
138            Err(ReadlineError::Interrupted) => std::process::exit(0),
139            Err(err)=>{
140                eprintln!("error during readline: {}",err.to_string());
141                break;
142            }
143        }
144    }
145
146    Ok(())
147}
148
149fn build_help_str(commands: &Vec<Command>) -> String {
150    let mut help_output = String::from("---help output------------\n");
151    commands.into_iter().for_each(|cmd| help_output.push_str(&format!("{}\n", cmd.help_output)));
152    help_output.push_str("exit - exit the current prompt");
153    help_output
154}
155
156
157/// This function will print a line to the screen, one line above the
158/// bottom-most row of the terminal.  
159/// Optionally, a prefix can be provided in case you would like to add
160/// additional context to the output line.
161pub fn write_output(output: String, prefix: Option<String>)->Result<(),Box<dyn Error>>{
162    let mut sout = io::stdout().lock();
163
164    // return order (columns, rows)
165    let size = crossterm::terminal::size()?;
166    let stdout_end = size.1-1;
167    let mut final_output = String::new();
168
169    // add the prefix to the output if it was provided
170    if let Some(line) = prefix {
171        final_output.push_str(line.as_str());
172        final_output.push_str(": ");
173    }
174
175    final_output.push_str(output.as_str());
176
177    sout.queue(cursor::SavePosition)?;
178
179    // restrict scrolling to a specific area of the screen
180    // this is run every time in case the screen size changes at some point
181    sout.queue(SetScrollingRegion(1,stdout_end))?
182        .queue(terminal::ScrollUp(1))? 
183        .queue(cursor::MoveTo(0, stdout_end-1))?; // move to the line right above stdin
184
185    print!("{}", final_output);
186    sout.queue(SetScrollingAll())?
187        .queue(cursor::RestorePosition)?;
188    sout.flush()?;
189    Ok(())
190}
191
192/// Sets the cursor location to the bottom-most row and the column to 1.  
193/// This gets run automatically in command_loop().
194pub fn setup_screen()->Result<(),Box<dyn Error>>{
195    let size = crossterm::terminal::size()?;
196    let mut sout = std::io::stdout().lock();
197    sout.execute(cursor::MoveToRow(size.1))?;
198    Ok(())
199}
200
201pub struct Command<'r> {
202    pub command: &'r str,
203    pub func: fn(&[&str], &mut DynamicContext)->Result<String, Box<dyn Error>>,
204    pub help_output: &'r str,
205}