diff --git a/Cargo.lock b/Cargo.lock index 47bcc7c..feaa2a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,6 +1147,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +[[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.83" @@ -1230,6 +1236,18 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "client_tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "bincode", + "crossterm", + "ratatui", + "renet", + "store", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1424,6 +1442,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.1", + "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" @@ -1470,6 +1513,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encase" version = "0.6.1" @@ -1900,6 +1949,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.3.3" @@ -1962,6 +2017,12 @@ dependencies = [ "hashbrown 0.14.2", ] +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + [[package]] name = "inflections" version = "1.1.1" @@ -2030,6 +2091,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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.9" @@ -2209,6 +2279,15 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efa59af2ddfad1854ae27d75009d538d0998b4b2fd47083e743ac1a10e46c60" +dependencies = [ + "hashbrown 0.14.2", +] + [[package]] name = "mach2" version = "0.4.1" @@ -2825,6 +2904,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8a99fddc9f0ba0a85884b8d14e3592853e787d581ca1816c91349b10e4eeab" +[[package]] +name = "ratatui" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" +dependencies = [ + "bitflags 2.4.1", + "cassowary", + "crossterm", + "indoc", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -2975,6 +3072,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 = "ruzstd" version = "0.4.0" @@ -3053,6 +3156,36 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" +[[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 = "simd-adler32" version = "0.3.7" @@ -3120,6 +3253,28 @@ dependencies = [ "serde", ] +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.38", +] + [[package]] name = "subtle" version = "2.5.0" @@ -3395,6 +3550,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[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.11" diff --git a/Cargo.toml b/Cargo.toml index 7bc67b4..4bc64ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver="2" members = [ "client", + "client_tui", "server", "store" ] diff --git a/Makefile b/Makefile index 684f370..457653a 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,6 @@ startserver: startclient1: RUST_LOG=trictrac_client cargo run --bin=trictrac-client Titi startclient2: - RUST_LOG=trictrac_client cargo run --bin=trictrac-client Tutu + RUST_LOG=trictrac_client cargo run --bin=trictrac-client Titu +startclienttui: + RUST_LOG=trictrac_client cargo run --bin=client_tui Tutu diff --git a/client_tui/src/app.rs b/client_tui/src/app.rs new file mode 100644 index 0000000..c598fe0 --- /dev/null +++ b/client_tui/src/app.rs @@ -0,0 +1,53 @@ +// 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; + } + } +} + +#[cfg(test)] +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/client_tui/src/event.rs b/client_tui/src/event.rs new file mode 100644 index 0000000..6dae20f --- /dev/null +++ b/client_tui/src/event.rs @@ -0,0 +1,87 @@ +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. + #[allow(dead_code)] + sender: mpsc::Sender, + // Event receiver channel. + receiver: mpsc::Receiver, + // Event handler thread. + #[allow(dead_code)] + 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) => { + if e.kind == event::KeyEventKind::Press { + sender.send(Event::Key(e)) + } else { + Ok(()) // ignore KeyEventKind::Release on windows + } + } + 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/client_tui/src/main.rs b/client_tui/src/main.rs index 51748ad..2658579 100644 --- a/client_tui/src/main.rs +++ b/client_tui/src/main.rs @@ -1,41 +1,50 @@ -use crossterm::{ - event::{self, KeyCode, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; -use ratatui::{ - prelude::{CrosstermBackend, Stylize, Terminal}, - widgets::Paragraph, -}; -use std::io::{stdout, Result}; +// 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; fn main() -> Result<()> { - stdout().execute(EnterAlternateScreen)?; - enable_raw_mode()?; - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - terminal.clear()?; + // Create an application. + let mut app = App::new(); - loop { - terminal.draw(|frame| { - let area = frame.size(); - frame.render_widget( - Paragraph::new("Hello Ratatui! (press 'q' to quit)") - .white() - .on_blue(), - area, - ); - })?; + // 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.enter()?; - if event::poll(std::time::Duration::from_millis(16))? { - if let event::Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { - break; - } - } - } + // 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(_, _) => {} + }; } - stdout().execute(LeaveAlternateScreen)?; - disable_raw_mode()?; + // Exit the user interface. + tui.exit()?; Ok(()) } diff --git a/client_tui/src/tui.rs b/client_tui/src/tui.rs new file mode 100644 index 0000000..177751e --- /dev/null +++ b/client_tui/src/tui.rs @@ -0,0 +1,77 @@ +use std::{io, panic}; + +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; + +pub type CrosstermTerminal = ratatui::Terminal>; + +use crate::{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 enter(&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/client_tui/src/ui.rs b/client_tui/src/ui.rs new file mode 100644 index 0000000..8995a46 --- /dev/null +++ b/client_tui/src/ui.rs @@ -0,0 +1,30 @@ +use ratatui::{ + prelude::{Alignment, Frame}, + style::{Color, Style}, + widgets::{Block, BorderType, Borders, Paragraph}, +}; + +use crate::app::App; + +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/client_tui/src/update.rs b/client_tui/src/update.rs new file mode 100644 index 0000000..36c859e --- /dev/null +++ b/client_tui/src/update.rs @@ -0,0 +1,17 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::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(), + _ => {} + }; +}