diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.gitignore b/.gitignore index 3ca43ae..9cd484e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb +.direnv/* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a07e62f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "recount" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +crossterm = "0.26.1" +ratatui = "0.20.1" diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..02aeb10 --- /dev/null +++ b/shell.nix @@ -0,0 +1,18 @@ +# +{ pkgs ? import {}}: + + let + rust_overlay = import (builtins.fetchTarball "https://github.com/oxalica/rust-overlay/archive/master.tar.gz"); + pkgs = import { overlays = [ rust_overlay ]; }; + ruststable = (pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + ]; + }); + in + pkgs.mkShell { + buildInputs = with pkgs; [ + ruststable + rust-analyzer + ]; + } diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..2ec8371 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,69 @@ +use std::error; + +/// Application result type. +pub type AppResult = std::result::Result>; + +/// Application. +#[derive(Debug)] +pub struct App<'a> { + /// Is the application running? + pub running: bool, + /// counter + pub counter: u8, + + pub tab_titles: Vec<&'a str>, + pub current_tab: usize, +} + +impl<'a> Default for App<'a> { + fn default() -> Self { + Self { + running: true, + counter: 0, + tab_titles: vec!["History", "New Entry", "Accounts"], + current_tab: 0, + } + } +} + +impl<'a> App<'a> { + /// 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.running = false; + } + + pub fn next_tab(&mut self) { + self.current_tab += 1; + if self.current_tab >= self.tab_titles.len() { + self.current_tab = 0; + } + } + + pub fn prev_tab(&mut self) { + if self.current_tab == 0 { + self.current_tab = self.tab_titles.len() - 1; + } else { + self.current_tab -= 1; + } + } + + 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; + } + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..99f5787 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,77 @@ +use crate::app::AppResult; +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +/// 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. +#[allow(dead_code)] +#[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) -> AppResult { + Ok(self.receiver.recv()?) + } +} diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..3cea937 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,28 @@ +use crate::app::{App, AppResult}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Handles the key events and updates the state of [`App`]. +pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { + match key_event.code { + // Exit application on `ESC` or `q` + KeyCode::Esc | KeyCode::Char('q') => { + app.quit(); + } + // Exit application on `Ctrl-C` + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit(); + } + } + // Counter handlers + KeyCode::Right => { + app.increment_counter(); + } + KeyCode::Left => { + app.decrement_counter(); + } + // Other handlers you could add here. + _ => {} + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..85ad8bf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +/// Application. +pub mod app; + +/// Terminal events handler. +pub mod event; + +/// Widget renderer. +pub mod ui; + +/// Terminal user interface. +pub mod tui; + +/// Event handler. +pub mod handler; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5934ce0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,36 @@ +use std::io; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use recount::app::{App, AppResult}; +use recount::event::{Event, EventHandler}; +use recount::handler::handle_key_events; +use recount::tui::Tui; + +fn main() -> AppResult<()> { + // Create an application. + let mut app = App::new(); + + // Initialize the terminal user interface. + let backend = CrosstermBackend::new(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.running { + // Render the user interface. + tui.draw(&mut app)?; + // Handle events. + match tui.events.next()? { + Event::Tick => app.tick(), + Event::Key(key_event) => handle_key_events(key_event, &mut app)?, + Event::Mouse(_) => {} + Event::Resize(_, _) => {} + } + } + + // Exit the user interface. + tui.exit()?; + Ok(()) +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..18f28f9 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,57 @@ +use crate::app::{App, AppResult}; +use crate::event::EventHandler; +use crate::ui; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use std::io; +use ratatui::backend::Backend; +use ratatui::Terminal; + +/// Representation of a terminal user interface. +/// +/// It is responsible for setting up the terminal, +/// initializing the interface and handling the draw events. +#[derive(Debug)] +pub struct Tui { + /// Interface to the Terminal. + terminal: Terminal, + /// Terminal event handler. + pub events: EventHandler, +} + +impl Tui { + /// Constructs a new instance of [`Tui`]. + pub fn new(terminal: Terminal, events: EventHandler) -> Self { + Self { terminal, events } + } + + /// Initializes the terminal interface. + /// + /// It enables the raw mode and sets terminal properties. + pub fn init(&mut self) -> AppResult<()> { + terminal::enable_raw_mode()?; + crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; + 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) -> AppResult<()> { + self.terminal.draw(|frame| ui::render(app, frame))?; + Ok(()) + } + + /// Exits the terminal interface. + /// + /// It disables the raw mode and reverts back the terminal properties. + pub fn exit(&mut self) -> AppResult<()> { + terminal::disable_raw_mode()?; + crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; + self.terminal.show_cursor()?; + Ok(()) + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..e717470 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,36 @@ +use ratatui::{ + backend::Backend, + layout::Alignment, + style::{Color, Style}, + widgets::{Block, BorderType, Borders, Paragraph}, + Frame, +}; + +use crate::app::App; + +/// Renders the user interface widgets. +pub fn render(app: &mut App, frame: &mut Frame<'_, B>) { + // This is where you add new widgets. + // See the following resources: + // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html + // - https://github.com/tui-rs-revival/ratatui/tree/master/examples + frame.render_widget( + Paragraph::new(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: {}", + app.counter + )) + .block( + Block::default() + .title("Template") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ) + .style(Style::default().fg(Color::Cyan).bg(Color::Black)) + .alignment(Alignment::Center), + frame.size(), + ) +}