tui client wip
This commit is contained in:
parent
3f5fbf4302
commit
a73528bfb6
9 changed files with 471 additions and 34 deletions
53
client_tui/src/app.rs
Normal file
53
client_tui/src/app.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
87
client_tui/src/event.rs
Normal file
87
client_tui/src/event.rs
Normal file
|
|
@ -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>,
|
||||
// Event receiver channel.
|
||||
receiver: mpsc::Receiver<Event>,
|
||||
// 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<Event> {
|
||||
Ok(self.receiver.recv()?)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
77
client_tui/src/tui.rs
Normal file
77
client_tui/src/tui.rs
Normal file
|
|
@ -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<ratatui::backend::CrosstermBackend<std::io::Stderr>>;
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
30
client_tui/src/ui.rs
Normal file
30
client_tui/src/ui.rs
Normal file
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
17
client_tui/src/update.rs
Normal file
17
client_tui/src/update.rs
Normal file
|
|
@ -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(),
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue