diff --git a/Cargo.lock b/Cargo.lock index 3759312..1cbd1fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.72" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" [[package]] name = "atomic" @@ -134,6 +134,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + [[package]] name = "block-buffer" version = "0.10.4" @@ -192,6 +198,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cc" version = "1.0.79" @@ -303,6 +315,31 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.0", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -363,6 +400,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -572,6 +615,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + [[package]] name = "inlinable_string" version = "0.1.15" @@ -638,6 +687,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.6" @@ -671,6 +729,16 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f" +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.20" @@ -735,6 +803,18 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "nom" version = "7.1.3" @@ -801,6 +881,35 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.3.5", + "smallvec", + "windows-targets 0.48.0", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "pathdiff" version = "0.2.1" @@ -939,13 +1048,30 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad" +dependencies = [ + "bitflags 2.4.0", + "cassowary", + "crossterm", + "indoc", + "itertools", + "paste", + "strum", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -954,7 +1080,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1009,7 +1135,7 @@ version = "0.37.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" dependencies = [ - "bitflags", + "bitflags 1.3.2", "errno", "io-lifetimes", "libc", @@ -1017,6 +1143,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "ryu" version = "1.0.13" @@ -1032,6 +1164,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.18" @@ -1097,12 +1235,70 @@ dependencies = [ "dirs", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.28", +] + [[package]] name = "syn" version = "1.0.109" @@ -1276,6 +1472,12 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "unicode-width" version = "0.1.10" @@ -1313,6 +1515,7 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" name = "vpxtool" version = "0.1.0" dependencies = [ + "anyhow", "base64", "byteorder", "bytes", @@ -1321,6 +1524,7 @@ dependencies = [ "clap", "colored", "console", + "crossterm", "dialoguer", "dirs", "encoding_rs", @@ -1338,6 +1542,7 @@ dependencies = [ "pretty_env_logger", "quick-xml", "rand", + "ratatui", "regex", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a1c4412..003cca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,9 @@ figment = { version = "0.10", features = ["toml", "env"] } toml = "0.8.0" is_executable = "1.0.1" regex = { version = "1.9.3", features = [] } +ratatui = "0.23.0" +crossterm = "0.27.0" +anyhow = "1.0.75" [dev-dependencies] pretty_assertions = "1.4.0" diff --git a/src/frontend/app.rs b/src/frontend/app.rs new file mode 100644 index 0000000..680c9e3 --- /dev/null +++ b/src/frontend/app.rs @@ -0,0 +1,52 @@ +/// Application. +#[derive(Debug, Default)] +pub struct App { + /// should the application exit? + pub should_quit: bool, + /// counter + pub counter: u8, +} + +impl App { + /// Constructs a new instance of [`App`]. + pub fn new() -> Self { + Self::default() + } + + /// Handles the tick event of the terminal. + pub fn tick(&self) {} + + /// Set running to false to quit the application. + pub fn quit(&mut self) { + self.should_quit = true; + } + + pub fn increment_counter(&mut self) { + if let Some(res) = self.counter.checked_add(1) { + self.counter = res; + } + } + + pub fn decrement_counter(&mut self) { + if let Some(res) = self.counter.checked_sub(1) { + self.counter = res; + } + } +} + +mod tests { + use super::*; + #[test] + fn test_app_increment_counter() { + let mut app = App::default(); + app.increment_counter(); + assert_eq!(app.counter, 1); + } + + #[test] + fn test_app_decrement_counter() { + let mut app = App::default(); + app.decrement_counter(); + assert_eq!(app.counter, 0); + } +} diff --git a/src/frontend/event.rs b/src/frontend/event.rs new file mode 100644 index 0000000..21dded2 --- /dev/null +++ b/src/frontend/event.rs @@ -0,0 +1,79 @@ +use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +use anyhow::Result; +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; + +/// Terminal events. +#[derive(Clone, Copy, Debug)] +pub enum Event { + /// Terminal tick. + Tick, + /// Key press. + Key(KeyEvent), + /// Mouse click/scroll. + Mouse(MouseEvent), + /// Terminal resize. + Resize(u16, u16), +} + +/// Terminal event handler. +#[derive(Debug)] +pub struct EventHandler { + /// Event sender channel. + sender: mpsc::Sender, + /// Event receiver channel. + receiver: mpsc::Receiver, + /// Event handler thread. + handler: thread::JoinHandle<()>, +} + +impl EventHandler { + /// Constructs a new instance of [`EventHandler`]. + pub fn new(tick_rate: u64) -> Self { + let tick_rate = Duration::from_millis(tick_rate); + let (sender, receiver) = mpsc::channel(); + let handler = { + let sender = sender.clone(); + thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or(tick_rate); + + if event::poll(timeout).expect("no events available") { + match event::read().expect("unable to read event") { + CrosstermEvent::Key(e) => sender.send(Event::Key(e)), + CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)), + CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)), + _ => unimplemented!(), + } + .expect("failed to send terminal event") + } + + if last_tick.elapsed() >= tick_rate { + sender.send(Event::Tick).expect("failed to send tick event"); + last_tick = Instant::now(); + } + } + }) + }; + Self { + sender, + receiver, + handler, + } + } + + /// Receive the next event from the handler thread. + /// + /// This function will always block the current thread if + /// there is no data available and it's possible for more data to be sent. + pub fn next(&self) -> Result { + Ok(self.receiver.recv()?) + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs new file mode 100644 index 0000000..c51f96a --- /dev/null +++ b/src/frontend/mod.rs @@ -0,0 +1,50 @@ +/// Application. +pub mod app; + +/// Terminal events handler. +pub mod event; + +/// Widget renderer. +pub mod ui; + +/// Terminal user interface. +pub mod tui; + +/// Application updater. +pub mod update; + +use anyhow::Result; +use app::App; +use event::{Event, EventHandler}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tui::Tui; +use update::update; + +pub fn main() -> Result<()> { + // Create an application. + let mut app = App::new(); + + // Initialize the terminal user interface. + let backend = CrosstermBackend::new(std::io::stderr()); + let terminal = Terminal::new(backend)?; + let events = EventHandler::new(250); + let mut tui = Tui::new(terminal, events); + tui.init()?; + + // Start the main loop. + while !app.should_quit { + // Render the user interface. + tui.draw(&mut app)?; + // Handle events. + match tui.events.next()? { + Event::Tick => {} + Event::Key(key_event) => update(&mut app, key_event), + Event::Mouse(_) => {} + Event::Resize(_, _) => {} + }; + } + + // Exit the user interface. + tui.exit()?; + Ok(()) +} diff --git a/src/frontend/tui.rs b/src/frontend/tui.rs new file mode 100644 index 0000000..2928ec8 --- /dev/null +++ b/src/frontend/tui.rs @@ -0,0 +1,78 @@ +use std::{io, panic}; + +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +pub type Frame<'a> = ratatui::Frame<'a, ratatui::backend::CrosstermBackend>; +pub type CrosstermTerminal = ratatui::Terminal>; + +use crate::frontend::{app::App, event::EventHandler, ui}; + +/// Representation of a terminal user interface. +/// +/// It is responsible for setting up the terminal, +/// initializing the interface and handling the draw events. +pub struct Tui { + /// Interface to the Terminal. + terminal: CrosstermTerminal, + /// Terminal event handler. + pub events: EventHandler, +} + +impl Tui { + /// Constructs a new instance of [`Tui`]. + pub fn new(terminal: CrosstermTerminal, events: EventHandler) -> Self { + Self { terminal, events } + } + + /// Initializes the terminal interface. + /// + /// It enables the raw mode and sets terminal properties. + pub fn init(&mut self) -> Result<()> { + terminal::enable_raw_mode()?; + crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; + + // Define a custom panic hook to reset the terminal properties. + // This way, you won't have your terminal messed up if an unexpected error happens. + let panic_hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic| { + Self::reset().expect("failed to reset the terminal"); + panic_hook(panic); + })); + + self.terminal.hide_cursor()?; + self.terminal.clear()?; + Ok(()) + } + + /// [`Draw`] the terminal interface by [`rendering`] the widgets. + /// + /// [`Draw`]: tui::Terminal::draw + /// [`rendering`]: crate::ui:render + pub fn draw(&mut self, app: &mut App) -> Result<()> { + self.terminal.draw(|frame| ui::render(app, frame))?; + Ok(()) + } + + /// Resets the terminal interface. + /// + /// This function is also used for the panic hook to revert + /// the terminal properties if unexpected errors occur. + fn reset() -> Result<()> { + terminal::disable_raw_mode()?; + crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; + Ok(()) + } + + /// Exits the terminal interface. + /// + /// It disables the raw mode and reverts back the terminal properties. + pub fn exit(&mut self) -> Result<()> { + Self::reset()?; + self.terminal.show_cursor()?; + Ok(()) + } +} diff --git a/src/frontend/ui.rs b/src/frontend/ui.rs new file mode 100644 index 0000000..ee7ed73 --- /dev/null +++ b/src/frontend/ui.rs @@ -0,0 +1,30 @@ +use ratatui::{ + layout::Alignment, + style::{Color, Style}, + widgets::{Block, BorderType, Borders, Paragraph}, +}; + +use crate::{frontend::app::App, frontend::tui::Frame}; + +pub fn render(app: &mut App, f: &mut Frame) { + f.render_widget( + Paragraph::new(format!( + " + Press `Esc`, `Ctrl-C` or `q` to stop running.\n\ + Press `j` and `k` to increment and decrement the counter respectively.\n\ + Counter: {} + ", + app.counter + )) + .block( + Block::default() + .title("Counter App") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ) + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center), + f.size(), + ) +} diff --git a/src/frontend/update.rs b/src/frontend/update.rs new file mode 100644 index 0000000..5e20f49 --- /dev/null +++ b/src/frontend/update.rs @@ -0,0 +1,17 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::frontend::app::App; + +pub fn update(app: &mut App, key_event: KeyEvent) { + match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => app.quit(), + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit() + } + } + KeyCode::Right | KeyCode::Char('j') => app.increment_counter(), + KeyCode::Left | KeyCode::Char('k') => app.decrement_counter(), + _ => {} + }; +} diff --git a/src/main.rs b/src/main.rs index 55ad42b..5e4a229 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ pub mod config; pub mod directb2s; pub mod fixprint; -mod frontend; mod indexer; pub mod jsonmodel; pub mod pov; +mod simplefrontend; pub mod vpx; +mod frontend; + use clap::{arg, Arg, ArgMatches, Command}; use colored::Colorize; use console::Emoji; @@ -146,16 +148,14 @@ fn handle_command(matches: ArgMatches) -> io::Result { let (config_path, config) = config::load_or_setup_config().unwrap(); println!("Using config file {}", config_path.display())?; let roms = indexer::find_roms(&config.rom_folder()).unwrap(); - match frontend::frontend_index(&config, true) { - Ok(tables) if tables.is_empty() => { - let warning = - format!("No tables found in {}", config.tables_folder.display()).red(); - eprintln!("{}", warning)?; - Ok(ExitCode::FAILURE) - } - Ok(vpx_files_with_tableinfo) => { - let vpinball_executable = config.vpx_executable; - frontend::frontend(&vpx_files_with_tableinfo, &roms, &vpinball_executable); + match simplefrontend::frontend_index(&config, true) { + Ok(tables) => { + // I guess we can handle empty tables here + frontend::main().map_err(|err| { + // convert to io error + let msg = format!("Error running frontend {}", err); + io::Error::new(io::ErrorKind::Other, msg) + })?; Ok(ExitCode::SUCCESS) } Err(IndexError::FolderDoesNotExist(path)) => { @@ -178,7 +178,7 @@ fn handle_command(matches: ArgMatches) -> io::Result { let (config_path, config) = config::load_or_setup_config().unwrap(); println!("Using config file {}", config_path.display())?; let roms = indexer::find_roms(&config.rom_folder()).unwrap(); - match frontend::frontend_index(&config, true) { + match simplefrontend::frontend_index(&config, true) { Ok(tables) if tables.is_empty() => { let warning = format!("No tables found in {}", config.tables_folder.display()).red(); @@ -187,7 +187,11 @@ fn handle_command(matches: ArgMatches) -> io::Result { } Ok(vpx_files_with_tableinfo) => { let vpinball_executable = config.vpx_executable; - frontend::frontend(&vpx_files_with_tableinfo, &roms, &vpinball_executable); + simplefrontend::frontend( + &vpx_files_with_tableinfo, + &roms, + &vpinball_executable, + ); Ok(ExitCode::SUCCESS) } Err(IndexError::FolderDoesNotExist(path)) => { diff --git a/src/frontend.rs b/src/simplefrontend.rs similarity index 100% rename from src/frontend.rs rename to src/simplefrontend.rs