E72A4G7AN4VCNI2PICW2I4AY3KR2EOF6YQBDI4OMYJU3LR2DPSEQC
use std::{
io::{self, Stdout},
time::Duration,
};
use anyhow::Context as _;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::{
broadcast,
mpsc::{self, UnboundedReceiver, UnboundedSender},
};
use tokio_stream::StreamExt;
use crate::{
state::{Action, State},
termination::Interrupted,
};
use self::components::{Component, ComponentRender};
mod components;
mod pages;
pub use pages::AppRouter;
const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
pub struct UiManager {
action_tx: mpsc::UnboundedSender<Action>,
}
impl UiManager {
pub fn new() -> (Self, UnboundedReceiver<Action>) {
let (action_tx, action_rx) = mpsc::unbounded_channel();
(Self { action_tx }, action_rx)
}
pub async fn main_loop<Router: ComponentRender<()> + Component>(
self,
mut state_rx: UnboundedReceiver<State>,
mut interrupt_rx: broadcast::Receiver<Interrupted>,
) -> anyhow::Result<Interrupted> {
let mut app_router = {
let state = state_rx.recv().await.unwrap();
Router::new(&state, self.action_tx.clone())
};
let mut terminal = setup_terminal()?;
let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
let mut crossterm_events = EventStream::new();
let result: anyhow::Result<Interrupted> = loop {
tokio::select! {
// Tick
_ = ticker.tick() => (),
// Catch and handle crossterm events
maybe_event = crossterm_events.next() => match maybe_event {
Some(Ok(Event::Key(key))) => {
app_router.handle_key_event(key);
}
Some(Ok(Event::Mouse(mouse))) => {
app_router.handle_mouse_event(mouse);
}
None => break Ok(Interrupted::UserInt),
_ => (),
},
// Handle state updates
Some(state) = state_rx.recv() => {
app_router = app_router.move_with_state(&state);
},
// Catch and handle interrupt signal
Ok(interrupted) = interrupt_rx.recv() => {
break Ok(interrupted);
}
}
if let Err(err) = terminal
.draw(|frame| app_router.render(frame, ()))
.context("could not render to the terminal")
{
break Err(err);
}
};
restore_terminal(&mut terminal);
result
}
}
fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> anyhow::Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
)?;
Ok(terminal.show_cursor()?)
}
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout},
widgets::Paragraph,
};
use tokio::sync::mpsc::UnboundedSender;
use crate::state::{Action, State};
use super::components::{Component, ComponentRender};
enum ActivePage {
MainPage,
}
struct Props {
active_page: ActivePage,
}
impl From<&State> for Props {
fn from(state: &State) -> Self {
Self {
active_page: ActivePage::MainPage,
}
}
}
pub struct AppRouter {
props: Props,
// ..pages
action_tx: UnboundedSender<Action>,
}
impl Component for AppRouter {
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized,
{
Self {
props: Props::from(state),
action_tx,
}
.move_with_state(state)
}
fn move_with_state(self, state: &State) -> Self
where
Self: Sized,
{
Self {
props: Props::from(state),
..self
}
}
fn name(&self) -> &str {
"Main Screen"
}
fn handle_key_event(&mut self, key: KeyEvent) {
#[allow(clippy::single_match)]
match key.code {
KeyCode::Char('q') => {
let _ = self.action_tx.send(Action::Exit);
}
_ => {}
}
}
}
impl ComponentRender<()> for AppRouter {
fn render(&self, frame: &mut ratatui::prelude::Frame, _props: ()) {
frame.render_widget(Paragraph::new("Press q to exit."), frame.size());
}
}
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::prelude::Frame;
use tokio::sync::mpsc::UnboundedSender;
use crate::state::{Action, State};
pub trait Component {
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized;
fn move_with_state(self, state: &State) -> Self
where
Self: Sized;
fn name(&self) -> &str;
fn handle_key_event(&mut self, _key: KeyEvent) {}
fn handle_mouse_event(&mut self, _mouse: MouseEvent) {}
}
pub trait ComponentRender<Props> {
fn render(&self, frame: &mut Frame, props: Props);
}
#[cfg(unix)]
use tokio::signal::unix::signal;
use tokio::sync::broadcast;
#[derive(Debug, Clone)]
pub enum Interrupted {
OsSigInt,
UserInt,
}
#[derive(Debug, Clone)]
pub struct Terminator {
interrupt_tx: broadcast::Sender<Interrupted>,
}
impl Terminator {
pub fn new(interrupt_tx: broadcast::Sender<Interrupted>) -> Self {
Self { interrupt_tx }
}
pub fn terminate(&mut self, interrupted: Interrupted) -> anyhow::Result<()> {
self.interrupt_tx.send(interrupted)?;
Ok(())
}
}
#[cfg(unix)]
async fn terminate_by_unix_signal(mut terminator: Terminator) {
let mut interrupt_signal = signal(tokio::signal::unix::SignalKind::interrupt())
.expect("failed to create interrupt signal stream");
interrupt_signal.recv().await;
terminator
.terminate(Interrupted::OsSigInt)
.expect("failed to send interrupt signal");
}
/// Create a broadcast channel for retrieving the application kill signal
pub fn create_termination() -> (Terminator, broadcast::Receiver<Interrupted>) {
let (tx, rx) = broadcast::channel(1);
let terminator = Terminator::new(tx);
#[cfg(unix)]
tokio::spawn(terminate_by_unix_signal(terminator.clone()));
(terminator, rx)
}
pub mod action;
pub mod store;
pub use self::{action::Action, store::StateStore};
#[derive(Debug, Clone)]
pub struct State {
pub counter: usize,
}
impl Default for State {
fn default() -> Self {
Self { counter: 0 }
}
}
impl State {
fn upcount(&mut self) {
self.counter += 1;
}
fn downcount(&mut self) {
self.counter -= 1;
}
}
use tokio::sync::{
broadcast,
mpsc::{self, UnboundedReceiver, UnboundedSender},
};
use crate::termination::{Interrupted, Terminator};
use super::{Action, State};
pub struct StateStore {
state_tx: UnboundedSender<State>,
}
impl StateStore {
pub fn new() -> (Self, UnboundedReceiver<State>) {
let (state_tx, state_rx) = mpsc::unbounded_channel::<State>();
(StateStore { state_tx }, state_rx)
}
}
impl StateStore {
pub async fn main_loop(
self,
mut terminator: Terminator,
mut action_rx: UnboundedReceiver<Action>,
mut interrupt_rx: broadcast::Receiver<Interrupted>,
) -> anyhow::Result<Interrupted> {
let mut state = State::default();
self.state_tx.send(state.clone())?;
let result = loop {
tokio::select! {
Some(action) = action_rx.recv() => match action {
Action::IncCounter => {
state.upcount();
},
Action::DecCounter => {
state.downcount();
},
Action::Exit => {
let _ = terminator.terminate(Interrupted::UserInt);
break Interrupted::UserInt;
}
},
Ok(interrupted) = interrupt_rx.recv() => {
break interrupted;
}
}
};
Ok(result)
}
}
#[derive(Debug, Clone)]
pub enum Action {
IncCounter,
DecCounter,
Exit,
}
use crate::{
state::StateStore,
termination::{create_termination, Interrupted},
ui::{AppRouter, UiManager},
};
mod state;
mod termination;
mod ui;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (terminator, mut interrupt_rx) = create_termination();
let (state_store, state_rx) = StateStore::new();
let (ui_manager, action_rx) = UiManager::new();
tokio::try_join!(
state_store.main_loop(terminator, action_rx, interrupt_rx.resubscribe()),
ui_manager.main_loop::<AppRouter>(state_rx, interrupt_rx.resubscribe()),
)?;
if let Ok(reason) = interrupt_rx.recv().await {
match reason {
Interrupted::UserInt => eprintln!("exited per user request"),
Interrupted::OsSigInt => eprintln!("exited because of OS sigint"),
}
} else {
println!("exited because of an unexpected error");
}
Ok(())
}
[package]
name = "testapp"
edition = "2021"
version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.82"
crossterm = { version = "0.27.0", features = ["event-stream"] }
rand = "0.8.5"
ratatui = "0.26.1"
tokio = { version = "1.37.0", features = ["rt-multi-thread", "time", "net", "macros", "signal", "sync"] }
tokio-stream = "0.1.15"