Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ratatui based frontend #131

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
450 changes: 381 additions & 69 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,17 @@ shellexpand = "3.1.0"
walkdir = "2.5.0"
wild = "2.2.1"
figment = { version = "0.10", features = ["toml", "env"] }

toml = "0.8.19"
is_executable = "1.0.3"
regex = { version = "1.10.6", features = [] }
vpin = { version = "0.15.6" }
rust-ini = "0.21.1"
edit = "0.1.5"
ratatui = "0.28.1"
crossterm = "0.28.1"
anyhow = "1.0.75"
timeago = "0.4.2"

[dev-dependencies]
pretty_assertions = "1.4.0"
Expand Down
79 changes: 79 additions & 0 deletions src/frontend/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use std::{
sync::mpsc,
thread,
time::{Duration, Instant},
};

use anyhow::Result;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};

/// 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>,
/// Event receiver channel.
receiver: mpsc::Receiver<Event>,
/// 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) => Ok(()), //sender.send(Event::Mouse(e)),
CrosstermEvent::Resize(_w, _h) => Ok(()), //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: sender,
receiver,
_handler: 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<Event> {
Ok(self.receiver.recv()?)
}
}
121 changes: 121 additions & 0 deletions src/frontend/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/// Application.
pub mod state;

/// Terminal events handler.
pub mod event;

/// Widget renderer.
pub mod ui;

/// Terminal user interface.
pub mod tui;

/// Application updater.
pub mod update;

use crate::config::ResolvedConfig;
use crate::indexer::IndexedTable;
use crate::simplefrontend::{launch, TableOption};
use anyhow::Result;
use event::{Event, EventHandler};
use ratatui::backend::CrosstermBackend;
use state::State;
use std::collections::HashSet;
use std::io::stdin;
use tui::Tui;
use update::update;

type Terminal = ratatui::Terminal<CrosstermBackend<std::io::Stderr>>;

pub enum Action {
External(TableOption),
Quit,
None,
}

pub fn main(config: ResolvedConfig, items: Vec<IndexedTable>, roms: HashSet<String>) -> Result<()> {
// Create an application.
let mut state = State::new(config, roms, items);

// 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.
run(&mut state, &mut tui)?;

// Exit the user interface.
tui.exit()?;
// TODO is this the same or better?
//ratatui::restore();
Ok(())
}

fn run(state: &mut State, tui: &mut Tui) -> Result<()> {
loop {
// Render the user interface.
tui.draw(state)?;
// Handle events.
let action = match tui.events.next()? {
Event::Tick => Action::None,
Event::Key(key_event) => update(state, key_event),
// Event::Mouse(_) => {}
// Event::Resize(_, _) => {}
};
let done = run_action(state, tui, action)?;
if done {
break;
}
}
Ok(())
}

fn run_action(state: &mut State, tui: &mut Tui, action: Action) -> Result<bool> {
match action {
Action::External(table_action) => {
if let Some(selected) = state.tables.selected() {
let selected_path = &selected.path;
let vpinball_executable = &state.config.vpx_executable;
match table_action {
TableOption::Launch => run_external(tui, || {
launch(selected_path, vpinball_executable, None);
Ok(())
}),
TableOption::LaunchFullscreen => run_external(tui, || {
launch(selected_path, vpinball_executable, Some(true));
Ok(())
}),
TableOption::LaunchWindowed => run_external(tui, || {
launch(selected_path, vpinball_executable, Some(false));
Ok(())
}),
not_implemented => run_external(tui, || {
eprintln!(
"Action not implemented: {:?}. Press enter to continue.",
not_implemented.display()
);
// read line
let _ = stdin().read_line(&mut String::new())?;
Ok(())
}),
}?;
} else {
unreachable!("At this point, a table should be selected.");
}
Ok(false)
}
Action::Quit => Ok(true),
Action::None => Ok(false),
}
}

fn run_external<T>(tui: &mut Tui, run: impl Fn() -> Result<T>) -> Result<T> {
// TODO most of this stuff is duplicated in Tui
tui.disable()?;
let result = run();
tui.enable()?;
result
}
Loading