diff --git a/tui/Cargo.toml b/tui/Cargo.toml index a3ee092..ca09e05 100644 --- a/tui/Cargo.toml +++ b/tui/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" aes = { workspace = true, optional = true } cipher-core.workspace = true color-eyre.workspace = true +crossterm = "0.28" des = { workspace = true, optional = true } ratatui = "0.29" thiserror.workspace = true diff --git a/tui/src/app.rs b/tui/src/app.rs new file mode 100644 index 0000000..0add324 --- /dev/null +++ b/tui/src/app.rs @@ -0,0 +1,96 @@ +use crate::event::{AppEvent, Event, EventHandler}; +use color_eyre::Result; +use ratatui::{ + DefaultTerminal, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, +}; + +/// Application. +#[derive(Debug)] +pub struct App { + /// Is the application running? + pub running: bool, + /// Counter. + pub counter: u8, + /// Event handler. + pub events: EventHandler, +} + +impl Default for App { + fn default() -> Self { + Self { + running: true, + counter: 0, + events: EventHandler::new(), + } + } +} + +impl App { + /// Constructs a new instance of [`App`]. + pub fn new() -> Self { + Self::default() + } + + /// Run the application's main loop. + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + while self.running { + terminal.draw(|frame| frame.render_widget(&self, frame.area()))?; + self.handle_events()?; + } + Ok(()) + } + + pub fn handle_events(&mut self) -> Result<()> { + match self.events.next()? { + Event::Tick => self.tick(), + Event::Crossterm(event) => match event { + crossterm::event::Event::Key(key_event) + if key_event.kind == crossterm::event::KeyEventKind::Press => + { + self.handle_key_event(key_event); + } + _ => {} + }, + Event::App(app_event) => match app_event { + AppEvent::Increment => self.increment_counter(), + AppEvent::Decrement => self.decrement_counter(), + AppEvent::Quit => self.quit(), + }, + } + Ok(()) + } + + /// Handles the key events and updates the state of [`App`]. + pub fn handle_key_event(&self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit), + KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => { + self.events.send(AppEvent::Quit); + } + KeyCode::Right => self.events.send(AppEvent::Increment), + KeyCode::Left => self.events.send(AppEvent::Decrement), + // Other handlers you could add here. + _ => {} + } + } + + /// Handles the tick event of the terminal. + /// + /// The tick event is where you can update the state of your application with any logic that + /// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation. + pub const fn tick(&self) {} + + /// Set running to false to quit the application. + pub const fn quit(&mut self) { + self.running = false; + } + + pub const fn increment_counter(&mut self) { + self.counter = self.counter.saturating_add(1); + } + + pub const fn decrement_counter(&mut self) { + self.counter = self.counter.saturating_sub(1); + } +} diff --git a/tui/src/event.rs b/tui/src/event.rs new file mode 100644 index 0000000..44f57ee --- /dev/null +++ b/tui/src/event.rs @@ -0,0 +1,131 @@ +use color_eyre::eyre::WrapErr; +use ratatui::crossterm::event::{self, Event as CrosstermEvent}; +use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +/// The frequency at which tick events are emitted. +const TICK_FPS: f64 = 30.0; + +/// Representation of all possible events. +#[derive(Clone, Debug)] +pub enum Event { + /// An event that is emitted on a regular schedule. + /// + /// Use this event to run any code which has to run outside of being a direct response to a user + /// event. e.g. polling exernal systems, updating animations, or rendering the UI based on a + /// fixed frame rate. + Tick, + /// Crossterm events. + /// + /// These events are emitted by the terminal. + Crossterm(CrosstermEvent), + /// Application events. + /// + /// Use this event to emit custom events that are specific to your application. + App(AppEvent), +} + +/// Application events. +/// +/// You can extend this enum with your own custom events. +#[derive(Clone, Debug)] +pub enum AppEvent { + /// Increment the counter. + Increment, + /// Decrement the counter. + Decrement, + /// Quit the application. + Quit, +} + +/// Terminal event handler. +#[derive(Debug)] +pub struct EventHandler { + /// Event sender channel. + sender: mpsc::Sender, + /// Event receiver channel. + receiver: mpsc::Receiver, +} + +impl EventHandler { + /// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events. + pub fn new() -> Self { + let (sender, receiver) = mpsc::channel(); + let actor = EventThread::new(sender.clone()); + thread::spawn(|| actor.run()); + Self { sender, receiver } + } + + /// Receives an event from the sender. + /// + /// This function blocks until an event is received. + /// + /// # Errors + /// + /// This function returns an error if the sender channel is disconnected. This can happen if an + /// error occurs in the event thread. In practice, this should not happen unless there is a + /// problem with the underlying terminal. + pub fn next(&self) -> color_eyre::Result { + Ok(self.receiver.recv()?) + } + + /// Queue an app event to be sent to the event receiver. + /// + /// This is useful for sending events to the event handler which will be processed by the next + /// iteration of the application's event loop. + pub fn send(&self, app_event: AppEvent) { + // Ignore the result as the reciever cannot be dropped while this struct still has a + // reference to it + let _ = self.sender.send(Event::App(app_event)); + } +} + +/// A thread that handles reading crossterm events and emitting tick events on a regular schedule. +struct EventThread { + /// Event sender channel. + sender: mpsc::Sender, +} + +impl EventThread { + /// Constructs a new instance of [`EventThread`]. + const fn new(sender: mpsc::Sender) -> Self { + Self { sender } + } + + /// Runs the event thread. + /// + /// This function emits tick events at a fixed rate and polls for crossterm events in between. + fn run(self) -> color_eyre::Result<()> { + let tick_interval = Duration::from_secs_f64(1.0 / TICK_FPS); + let mut last_tick = Instant::now(); + loop { + // emit tick events at a fixed rate + let timeout = tick_interval.saturating_sub(last_tick.elapsed()); + if timeout == Duration::ZERO { + last_tick = Instant::now(); + self.send(Event::Tick); + } + // poll for crossterm events, ensuring that we don't block the tick interval + if event::poll(timeout).wrap_err("failed to poll for crossterm events")? { + let event = event::read().wrap_err("failed to read crossterm event")?; + self.send(Event::Crossterm(event)); + } + } + } + + /// Sends an event to the receiver. + fn send(&self, event: Event) { + // Ignores the result because shutting down the app drops the receiver, which causes the send + // operation to fail. This is expected behavior and should not panic. + let _ = self.sender.send(event); + } +} + +impl Default for EventHandler { + fn default() -> Self { + Self::new() + } +} diff --git a/tui/src/main.rs b/tui/src/main.rs index e7a11a9..d4f1695 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -1,3 +1,13 @@ -fn main() { - println!("Hello, world!"); +use crate::app::App; + +mod app; +mod event; +mod ui; + +fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + let terminal = ratatui::init(); + let result = App::new().run(terminal); + ratatui::restore(); + result } diff --git a/tui/src/ui.rs b/tui/src/ui.rs new file mode 100644 index 0000000..b111ff4 --- /dev/null +++ b/tui/src/ui.rs @@ -0,0 +1,35 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Color, Stylize}, + widgets::{Block, BorderType, Paragraph, Widget}, +}; + +use crate::app::App; + +impl Widget for &App { + /// Renders the user interface widgets. + /// + // This is where you add new widgets. + // See the following resources: + // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html + // - https://github.com/ratatui/ratatui/tree/master/examples + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::bordered() + .title("event-driven-generated") + .title_alignment(Alignment::Center) + .border_type(BorderType::Rounded); + + let text = format!( + "This is a tui template.\n\ + Press `Esc`, `Ctrl-C` or `q` to stop running.\n\ + Press left and right to increment and decrement the counter respectively.\n\ + Counter: {}", + self.counter + ); + + let paragraph = Paragraph::new(text).block(block).fg(Color::Cyan).centered(); + + paragraph.render(area, buf); + } +}