diff --git a/Cargo.toml b/Cargo.toml index 8dfd11b..55e72d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,17 +8,20 @@ documentation = "https://github.com/SpacehuhnTech/Huhnitor" edition = "2018" [dependencies] -tokio = { version = "0.2.21", features = [ "full" ] } -tokio-util = { version = "0.3.1", features = [ "codec" ] } -tokio-serial = "4.3.3" +tokio = { version = "1.37.0", features = ["full"] } +tokio-util = { version = "0.7.10", features = ["codec"] } +tokio-serial = "5.4.4" -serialport = "3.3.0" +serialport = "4.3.0" futures = "0.3.5" -bytes = "0.5.4" -webbrowser = "0.5.2" +bytes = "1.6.0" +webbrowser = "1.0.1" lazy_static = "1.4.0" structopt = "0.3.15" regex = "1.3.9" termcolor = "1.1" -rustyline = "6.3.0" +rustyline = "14.0.0" +crossterm = "0.27.0" +ratatui = "0.26.1" +unicode-width = "0.1.11" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0618346 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,381 @@ +use crossterm::{ + event::{ + self, Event, KeyCode, KeyEventKind, KeyModifiers, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, Paragraph}, + Frame, Terminal, +}; +use regex::RegexSet; +use std::{ + collections::VecDeque, + io::{self, Stdout}, + time::{Duration, Instant}, +}; +use std::io::ErrorKind; +use crossterm::event::KeyEvent; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +lazy_static::lazy_static! { + static ref REGSET: RegexSet = RegexSet::new([ + r"^(\x60|\.|:|/|-|\+|o|s|h|d|y| ){50,}", // ASCII Chicken + r"^# ", // # command + r"(?m)^\s*(-|=|#)+\s*$", // ================ + r"^\[ =+ ?.* ?=+ \]", // [ ===== Headline ====== ] + r"^> \w+", // > Finished job + r"^(ERROR)|(WARNING): ", // ERROR: something went wrong :( + r"^.*: +.*", // -arg: value + r"^\[.*\]", // [default=something] + r"(?m)^\S+( \[?-\S*( <\S*>)?\]?)*\s*$", // command [-arg ] [-flag] + ]).unwrap(); + + static ref COLORSET: [(Color, Modifier);9] = [ + (Color::White, Modifier::empty()), // # command + (Color::White, Modifier::BOLD), // # command + (Color::Blue, Modifier::empty()), // ================ + (Color::Yellow, Modifier::BOLD), // [ ===== Headline ====== ] + (Color::Cyan, Modifier::empty()), // > Finished job + (Color::Red, Modifier::empty()), // ERROR: something went wrong :( + (Color::Green, Modifier::empty()), // -arg value + (Color::Green, Modifier::BOLD), // [default=something] + (Color::Yellow, Modifier::empty()), // command [-arg ] [-flag] + ]; +} + +struct InterruptHandler { + spam: VecDeque, + cap: usize, +} + +impl InterruptHandler { + fn new(cap: usize) -> Self { + Self { spam: VecDeque::with_capacity(cap), cap } + } + fn interrupted(&mut self) -> bool { + if self.spam.len() == self.cap { + if let Some(time) = self.spam.pop_back() { + if Instant::now() - time <= Duration::new(3, 0) { + true + } else { + self.spam.push_front(Instant::now()); + false + } + } else { false } + } else { + self.spam.push_front(Instant::now()); + false + } + } +} + +struct History { + hist: Vec, + index: usize, +} + +impl History { + fn new() -> Self { + Self { + hist: vec!["".to_string()], + index: 0, + } + } + fn prev_cmd(&mut self) -> String { + if self.index > 0 { + self.index -= 1; + } + self.hist[self.index].to_string() + } + fn next_cmd(&mut self) -> String { + if self.index < self.hist.len() - 1 { + self.index += 1; + } + self.hist[self.index].to_string() + } + fn add(&mut self, entry: String) { + self.hist.insert(self.hist.len() - 1, entry) + } + fn reset(&mut self) { + self.index = self.hist.len() - 1 + } +} + +#[derive(PartialEq)] +enum InputMode { + Normal, + Insert, +} + +/// App holds the state of the application +pub struct App { + /// Current value of the input box + input: String, + /// All application output + output: Vec, + /// History of commands entered + cmd_history: History, + /// User-controlled scrolling + manual_scroll: bool, + /// Scrollbar State + scrollbar: ScrollbarState, + /// Scroll position + scroll_pos: usize, + /// Cursor Position + cursor_pos: usize, + /// Input Mode + input_mode: InputMode, +} + +impl<'a> App { + pub fn new() -> Self { + Self { + input: String::default(), + output: Vec::new(), + cmd_history: History::new(), + manual_scroll: false, + scrollbar: ScrollbarState::default(), + scroll_pos: 0, + cursor_pos: 0, + input_mode: InputMode::Insert, + } + } + + fn delete_char(&mut self) { + if self.cursor_pos != 0 { + self.remove_char(self.cursor_pos) + } + } + + fn submit(&mut self) -> String { + let entr_txt: String = self.input.drain(..).collect(); + + self.output.push(entr_txt.clone()); + self.cmd_history.add(entr_txt.clone()); + self.cmd_history.reset(); + self.cursor_reset(); + + entr_txt + } + + fn put_char(&mut self, c: char) { + self.input.insert(self.cursor_pos, c); + self.cursor_right(); + } + + fn cursor_left(&mut self) { + self.cursor_pos = self.cursor_pos.saturating_sub(1).clamp(0, self.input.len()); + } + + fn cursor_right(&mut self) { + self.cursor_pos = self.cursor_pos.saturating_add(1).clamp(0, self.input.len()); + } + + fn cursor_reset(&mut self) { + self.cursor_pos = 0 + } + + fn scroll_up(&mut self) { + self.scroll_pos = self.scroll_pos.saturating_sub(1); + self.scrollbar = self.scrollbar.position(self.scroll_pos); + self.manual_scroll = true; + } + + fn scroll_down(&mut self) { + self.scroll_pos = self.scroll_pos.saturating_add(1); + self.scrollbar = self.scrollbar.position(self.scroll_pos); + } + + fn remove_char(&mut self, idx: usize) { + let left_idx = self.cursor_pos - 1; + + let left_part = self.input.chars().take(left_idx); + let right_part = self.input.chars().skip(idx); + + self.input = left_part.chain(right_part).collect(); + self.cursor_left(); + } + + fn parse>(s: S) -> Line<'a> { + let matches: Vec<_> = REGSET.matches(s.as_ref()).into_iter().collect(); + + let (color, modf) = if !matches.is_empty() { + COLORSET[matches[0]] + } else { + (Color::White, Modifier::empty()) + }; + Line::styled( + s.as_ref().to_string(), + Style::default().fg(color).add_modifier(modf), + ) + } + + fn event_handler(&mut self, key: KeyEvent, spam_handler: &mut InterruptHandler, input_tx: &UnboundedSender) -> io::Result { + if key.kind == KeyEventKind::Press && self.input_mode == InputMode::Insert { + match key.code { + KeyCode::Enter => { + let entr_txt: String = self.submit(); + input_tx.send(format!("{}\r\n", entr_txt.clone())).unwrap(); + if entr_txt.to_uppercase() == "EXIT" { + return Ok(false); + } + } + KeyCode::Char('c') + if key.modifiers == KeyModifiers::from_name("CONTROL").unwrap() => { + if input_tx.send("stop\n".to_string()).is_err() { + self.output.push("Couldn't stop!".to_string()); + } + if spam_handler.interrupted() { + let res: io::Result = match input_tx.send("EXIT".to_string()) { + Ok(_) => Ok(false), + Err(e) => Err(io::Error::new(ErrorKind::Other, e.0)) + }; + return res; + } + } + KeyCode::Char(c) => self.put_char(c), + KeyCode::Backspace => self.delete_char(), + KeyCode::Up => { + self.input = self.cmd_history.prev_cmd(); + self.cursor_pos = self.input.len(); + } + KeyCode::Down => { + self.input = self.cmd_history.next_cmd(); + self.cursor_pos = self.input.len(); + } + KeyCode::Left => self.cursor_left(), + KeyCode::Right => self.cursor_right(), + KeyCode::PageUp => self.scroll_up(), + KeyCode::PageDown => self.scroll_down(), + KeyCode::Esc => self.input_mode = InputMode::Normal, + + _ => (), + } + } else if key.kind == KeyEventKind::Press && self.input_mode == InputMode::Normal { + match key.code { + KeyCode::Up | KeyCode::PageUp => self.scroll_up(), + KeyCode::Down | KeyCode::PageDown => self.scroll_down(), + KeyCode::Esc => self.input_mode = InputMode::Insert, + _ => () + } + } + Ok(true) + } + + /// Start render loop + pub async fn run( + mut self, + input_tx: UnboundedSender, + mut output_rx: UnboundedReceiver, + tick_rate: Duration, + ) -> io::Result<()> { + let mut spam_handler = InterruptHandler::new(2); + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let mut prev_tick = Instant::now(); + let mut res: io::Result<()> = Ok(()); + + // setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + + loop { + terminal.draw(|f| self.ui(f))?; + + if let Ok(str) = output_rx.try_recv() { + self.output.push(str) + } + + let timeout = tick_rate.saturating_sub(prev_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + match self.event_handler(key, &mut spam_handler, &input_tx) { + Ok(false) => break, + Err(e) => { + res = Err(e); + break; + } + _ => () + } + } + } + + if prev_tick.elapsed() >= tick_rate { + prev_tick = Instant::now(); + } + } + Self::shutdown(terminal)?; + + res + } + + fn ui(&mut self, f: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([Constraint::Min(1), Constraint::Length(3)].as_ref()) + .split(f.size()); + + let (msg_color, input_color) = match self.input_mode { + InputMode::Insert => (Color::Yellow, Color::White), + InputMode::Normal => (Color::White, Color::Yellow) + }; + + // Set scroll position + let lines: Vec = self.output.iter().map(Self::parse).collect(); + let box_height = chunks[0].height as usize; + let visible_len = (lines.len() as isize - box_height as isize + 2).clamp(0, lines.len() as isize); + if !self.manual_scroll { + self.scroll_pos = visible_len as usize; + } else if self.scroll_pos >= visible_len as usize { + self.manual_scroll = false; + } + self.scrollbar = self.scrollbar.content_length(lines.len()); + + // Message Box + let messages = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(msg_color)).title("Messages")) + .scroll((self.scroll_pos as u16, 0)); + f.render_widget(messages, chunks[0]); + f.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("^")) + .end_symbol(Some("v")), + chunks[0], + &mut self.scrollbar, + ); + + // Input Box + let input = Paragraph::new(self.input.as_str()) + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(input_color)).title("Input")); + f.render_widget(input, chunks[1]); + // Show cursor + f.set_cursor( + // Put cursor after input text + chunks[1].x + self.cursor_pos as u16 + 1, + // Leave room for border + chunks[1].y + 1, + ); + } + + /// restore terminal + fn shutdown(mut terminal: Terminal>) -> io::Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen + )?; + terminal.show_cursor()?; + Ok(()) + } +} diff --git a/src/handler.rs b/src/handler.rs index 0290088..e5e0fcf 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -7,7 +7,7 @@ pub fn handle(command: String) -> String { let words = command.split(' ').collect::>(); let len = words.len(); if let Some(key) = words.get(1) { - match key.to_uppercase().trim().as_ref() { + match key.to_uppercase().trim() { "READ" => { if len > 2 { let mut out = String::new(); diff --git a/src/input.rs b/src/input.rs index 7883572..fc1379e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,47 +1,24 @@ use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; -use std::collections::VecDeque; -use std::time::{Instant, Duration}; +use rustyline::{Cmd, KeyCode, KeyEvent, Modifiers}; use crate::error; pub fn receiver(sender: UnboundedSender) { - let mut exitspam: VecDeque = VecDeque::with_capacity(3); + let mut rl = rustyline::DefaultEditor::new().expect("Unable to start command history"); + rl.bind_sequence(KeyEvent(KeyCode::Up, Modifiers::empty()), Cmd::LineUpOrPreviousHistory(1)); + rl.bind_sequence(KeyEvent(KeyCode::Down, Modifiers::empty()), Cmd::LineDownOrNextHistory(1)); - let mut rl = rustyline::Editor::<()>::new(); - rl.bind_sequence(rustyline::KeyPress::Up, rustyline::Cmd::LineUpOrPreviousHistory(1)); - rl.bind_sequence(rustyline::KeyPress::Down, rustyline::Cmd::LineDownOrNextHistory(1)); - - loop { - match rl.readline(">> ") { - Ok(line) => { - rl.add_history_entry(&line); - if sender.send(format!("{}\r\n", line.clone())).is_err() { - error!("Couldn't report input to main thread!"); - } - - if line.trim().to_uppercase() == "EXIT" { - break; - } - }, - Err(rustyline::error::ReadlineError::Interrupted) => { - sender.send("stop\n".to_string()).expect("Couldn't stop!"); - - if exitspam.len() == 3 { - if let Some(time) = exitspam.pop_back() { - if Instant::now() - time <= Duration::new(3, 0) { - sender.send("EXIT".to_string()).expect("Couldn't exit!"); - break; - } else { - exitspam.push_front(Instant::now()); - } - } - } else { - exitspam.push_front(Instant::now()); - } + match rl.readline(">> ") { + Ok(line) => { + rl.add_history_entry(&line).expect("Unable to add history entry"); + if sender.send(format!("{}\r\n", line.clone())).is_err() { + error!("Couldn't report input to main thread!"); } - Err(e) => error!(e) - } + Err(rustyline::error::ReadlineError::Interrupted) => { + sender.send("stop\n".to_string()).expect("Couldn't stop!"); + } + Err(e) => error!(e) } } diff --git a/src/main.rs b/src/main.rs index c367a07..b43767b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,48 @@ +use crate::app::App; use handler::handle; -use serialport::prelude::*; use std::env; use std::time::Duration; +use serialport::{DataBits, FlowControl, Parity, StopBits}; use structopt::StructOpt; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +mod app; #[macro_use] mod handler; mod input; mod output; mod port; -async fn monitor(cmd_port: Option, auto: bool, no_welcome: bool, out: &output::Preferences) { - let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); - - std::thread::spawn(|| input::receiver(sender)); +async fn monitor( + cmd_port: Option, + auto: bool, + no_welcome: bool, + out: &output::Preferences, + app: App, +) { + let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel(); + let (output_tx, output_rx) = tokio::sync::mpsc::unbounded_channel::(); + let input_clone = input_tx.clone(); - let settings = tokio_serial::SerialPortSettings { - baud_rate: 115200, - data_bits: DataBits::Eight, - flow_control: FlowControl::None, - parity: Parity::None, - stop_bits: StopBits::One, - timeout: Duration::from_secs(10), - }; + std::thread::spawn(|| input::receiver(input_clone)); let tty_path = if cmd_port.is_some() { cmd_port } else if auto { - port::auto(&mut receiver, out).await + port::auto(&mut input_rx, out).await } else { - port::manual(&mut receiver, out).await + port::manual(&mut input_rx, out).await }; if let Some(inner_tty_path) = tty_path { + let settings = tokio_serial::new(&inner_tty_path, 115200) + .data_bits(DataBits::Eight) + .flow_control(FlowControl::None) + .parity(Parity::None) + .stop_bits(StopBits::One) + .timeout(Duration::from_secs(10)); #[allow(unused_mut)] // Ignore warning from windows compilers - if let Ok(mut port) = tokio_serial::Serial::from_path(&inner_tty_path, &settings) { + if let Ok(mut port) = tokio_serial::SerialStream::open(&settings) { #[cfg(unix)] port.set_exclusive(false) .expect("Unable to set serial port exclusive to false"); @@ -44,12 +51,12 @@ async fn monitor(cmd_port: Option, auto: bool, no_welcome: bool, out: &o out.connected(&inner_tty_path); - if !no_welcome { - if let Err(_) = port.write("welcome\r\n".as_bytes()).await { - out.print("Couldn't send welcome command!"); - } + if !no_welcome && port.write("welcome\r\n".as_bytes()).await.is_err() { + out.print("Couldn't send welcome command!"); } + tokio::spawn(async move { app.run(input_tx, output_rx, Duration::from_millis(15)).await }); + let mut buf = Vec::new(); loop { tokio::select! { @@ -59,7 +66,7 @@ async fn monitor(cmd_port: Option, auto: bool, no_welcome: bool, out: &o }, Ok(_) => { let input = String::from_utf8_lossy(&buf).to_string(); - out.print(&input); + output_tx.send(input).unwrap(); buf = Vec::new(); }, Err(e) => { @@ -68,7 +75,7 @@ async fn monitor(cmd_port: Option, auto: bool, no_welcome: bool, out: &o } }, - Some(text) = receiver.recv() => { + Some(text) = input_rx.recv() => { if text.trim().to_uppercase() == "EXIT" { break; } else if text.trim().to_uppercase() == "CLEAR" { @@ -111,7 +118,7 @@ struct Opt { /// Select port #[structopt(short, long)] port: Option, - + /// Disable welcome command #[structopt(short = "w", long = "no-welcome")] no_welcome: bool, @@ -131,7 +138,8 @@ async fn main() { if args.driver { out.driver(); } else { - monitor(args.port, !args.auto, args.no_welcome, &out).await; + let app = App::new(); + monitor(args.port, !args.auto, args.no_welcome, &out, app).await; } out.goodbye(); diff --git a/src/output.rs b/src/output.rs index 796db8d..d19cb46 100644 --- a/src/output.rs +++ b/src/output.rs @@ -12,7 +12,7 @@ macro_rules! error { // Statically compile regex to avoid repetetive compiling // Rust Regex can be tested here: https://rustexp.lpil.uk/ lazy_static::lazy_static! { - static ref REGSET: RegexSet = RegexSet::new(&[ + static ref REGSET: RegexSet = RegexSet::new([ r"^(\x60|\.|:|/|-|\+|o|s|h|d|y| ){50,}", // ASCII Chicken r"^# ", // # command r"(?m)^\s*(-|=|#)+\s*$", // ================ @@ -77,7 +77,7 @@ pub struct Preferences { impl Preferences { pub fn print(&self, s: &str) { if self.color_enabled { - parse(&s); + parse(s); } else { print!("{}", s); } diff --git a/src/port.rs b/src/port.rs index f81c288..8c840f2 100644 --- a/src/port.rs +++ b/src/port.rs @@ -6,11 +6,11 @@ use crate::output; async fn detect_port(ports: &mut Vec) -> Option { loop { - tokio::time::delay_for(std::time::Duration::from_millis(500)).await; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; if let Ok(new_ports) = available_ports() { for path in &new_ports { - if !ports.contains(&path) { + if !ports.contains(path) { return Some(path.port_name.clone()); } }