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 eventsmaybe_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 updatesSome(state) = state_rx.recv() => {app_router = app_router.move_with_state(&state);},// Catch and handle interrupt signalOk(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,// ..pagesaction_tx: UnboundedSender<Action>,}impl Component for AppRouter {fn new(state: &State, action_tx: UnboundedSender<Action>) -> SelfwhereSelf: Sized,{Self {props: Props::from(state),action_tx,}.move_with_state(state)}fn move_with_state(self, state: &State) -> SelfwhereSelf: 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>) -> SelfwhereSelf: Sized;fn move_with_state(self, state: &State) -> SelfwhereSelf: 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 signalpub 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"