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 088ba6b..22d3516 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + + +# Added by cargo + +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f4c3269 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sousa_tui" +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" +tui = "0.19.0" diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..680af1b --- /dev/null +++ b/shell.nix @@ -0,0 +1,28 @@ +{ 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.latest.rustChannels.stable.default.override { + extensions = [ + "rust-src" + ]; + }); +in +pkgs.mkShell { + buildInputs = with pkgs; [ + ruststable + rust-analyzer + sqlite + sqliteman + pkg-config + alsa-lib + ]; + + RUST_BACKTRACE = 1; + + shellHook = '' + cargo install --locked bacon + export PATH=$HOME/.cargo/bin:$PATH +''; +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3854413 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,116 @@ +use std::error; +use tui::backend::Backend; +use tui::layout::Alignment; +use tui::style::{Color, Style}; +use tui::terminal::Frame; +use tui::widgets::{Block, BorderType, Borders, Paragraph}; +use tui::layout::{Constraint, Direction, Layout}; + +/// Application result type. +pub type AppResult = std::result::Result>; + +/// Application. +#[derive(Debug)] +pub struct App { + /// Is the application running? + pub running: bool, +} + +impl Default for App { + fn default() -> Self { + Self { running: true } + } +} + +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) {} + + /// Renders the user interface widgets. + pub fn render(&mut self, frame: &mut Frame<'_, B>) { + // This is where you add new widgets. + // See the following resources: + // - https://docs.rs/tui/latest/tui/widgets/index.html + // - https://github.com/fdehau/tui-rs/tree/master/examples + let root_layout = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Percentage(80), + Constraint::Percentage(20) + ].as_ref() + ) + .split(frame.size()); + + let top_split = Layout::default() + .direction(Direction::Horizontal) + .margin(1) + .constraints( + [ + Constraint::Percentage(60), + Constraint::Percentage(40) + ].as_ref() + ) + .split(root_layout[0]); + + let right_split = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Percentage(50), + Constraint::Percentage(50) + ].as_ref() + ) + .split(top_split[1]); + + let bottom_block = Block::default() + .title("bottom block") + .borders(Borders::ALL); + frame.render_widget(bottom_block, root_layout[1]); + + frame.render_widget( + Paragraph::new( + "This is a tui-rs template.\nPress `Esc`, `Ctrl-C` or `q` to stop running.", + ).block( + Block::default() + .title("left top block") + .borders(Borders::ALL), + ), + top_split[0]); + + let top_right_block = Block::default() + .title("top right block") + .borders(Borders::ALL); + frame.render_widget(top_right_block, right_split[0]); + + let right_bottom_block = Block::default() + .title("right bottom block") + .borders(Borders::ALL); + frame.render_widget(right_bottom_block, right_split[1]); + + /* + frame.render_widget( + Paragraph::new( + "This is a tui-rs template.\nPress `Esc`, `Ctrl-C` or `q` to stop running.", + ) + .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(), + ) + */ + } +} 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..d31fe0f --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,22 @@ +use crate::app::App; +use crate::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.running = false; + } + // Exit application on `Ctrl-C` + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.running = false; + } + } + // Other handlers you could add here. + _ => {} + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2ee22e6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +/// Application. +pub mod app; + +/// Terminal events handler. +pub mod event; + +/// 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..716a904 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,35 @@ +use std::io; +use tui::backend::CrosstermBackend; +use tui::Terminal; +use sousa_tui::app::{App, AppResult}; +use sousa_tui::event::{Event, EventHandler}; +use sousa_tui::handler::handle_key_events; +use sousa_tui::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..60131c6 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,56 @@ +use crate::app::{App, AppResult}; +use crate::event::EventHandler; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use std::io; +use tui::backend::Backend; +use tui::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::app::App::render + pub fn draw(&mut self, app: &mut App) -> AppResult<()> { + self.terminal.draw(|frame| app.render(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(()) + } +}