Components/home.rs
Here’s an example of the Home
component with additional state:
show_help
is abool
that tracks whether or not help should be rendered or notticker
is a counter that increments everyAppTick
.
This Home
component also adds fields for input: Input
, and stores a reference to
action_tx: mpsc::UnboundedSender<Action>
use std::{collections::HashMap, time::Duration};
use color_eyre::eyre::Result;use log::error;use ratatui::{ crossterm::event::{KeyCode, KeyEvent}, layout::{Alignment, Constraint, Layout, Margin, Position, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, Paragraph, Row, Table}, Frame,};use tokio::sync::mpsc::UnboundedSender;use tracing::trace;use tui_input::{backend::crossterm::EventHandler, Input};
use super::Component;use crate::{action::Action, config::key_event_to_string};
#[derive(Default, Copy, Clone, PartialEq, Eq)]pub enum Mode { #[default] Normal, Insert, Processing,}
#[derive(Default)]pub struct Home { pub show_help: bool, pub counter: usize, pub app_ticker: usize, pub render_ticker: usize, pub mode: Mode, pub input: Input, pub action_tx: Option<UnboundedSender<Action>>, pub keymap: HashMap<KeyEvent, Action>, pub text: Vec<String>, pub last_events: Vec<KeyEvent>,}
impl Home { pub fn new() -> Self { Self::default() }
pub fn keymap(mut self, keymap: HashMap<KeyEvent, Action>) -> Self { self.keymap = keymap; self }
pub fn tick(&mut self) { log::info!("Tick"); self.app_ticker = self.app_ticker.saturating_add(1); self.last_events.drain(..); }
pub fn render_tick(&mut self) { log::debug!("Render Tick"); self.render_ticker = self.render_ticker.saturating_add(1); }
pub fn add(&mut self, s: String) { self.text.push(s) }
pub fn schedule_increment(&mut self, i: usize) { let tx = self.action_tx.clone().unwrap(); tokio::spawn(async move { tx.send(Action::EnterProcessing).unwrap(); tokio::time::sleep(Duration::from_secs(1)).await; tx.send(Action::Increment(i)).unwrap(); tx.send(Action::ExitProcessing).unwrap(); }); }
pub fn schedule_decrement(&mut self, i: usize) { let tx = self.action_tx.clone().unwrap(); tokio::spawn(async move { tx.send(Action::EnterProcessing).unwrap(); tokio::time::sleep(Duration::from_secs(1)).await; tx.send(Action::Decrement(i)).unwrap(); tx.send(Action::ExitProcessing).unwrap(); }); }
pub fn increment(&mut self, i: usize) { self.counter = self.counter.saturating_add(i); }
pub fn decrement(&mut self, i: usize) { self.counter = self.counter.saturating_sub(i); }}
impl Component for Home { fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { self.action_tx = Some(tx); Ok(()) }
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> { self.last_events.push(key); let action = match self.mode { Mode::Normal | Mode::Processing => return Ok(None), Mode::Insert => match key.code { KeyCode::Esc => Action::EnterNormal, KeyCode::Enter => { if let Some(sender) = &self.action_tx { if let Err(e) = sender.send(Action::CompleteInput(self.input.value().to_string())) { error!("Failed to send action: {:?}", e); } } Action::EnterNormal }, _ => { self.input.handle_event(&ratatui::crossterm::event::Event::Key(key)); Action::Update }, }, }; Ok(Some(action)) }
fn update(&mut self, action: Action) -> Result<Option<Action>> { match action { Action::Tick => self.tick(), Action::Render => self.render_tick(), Action::ToggleShowHelp => self.show_help = !self.show_help, Action::ScheduleIncrement => self.schedule_increment(1), Action::ScheduleDecrement => self.schedule_decrement(1), Action::Increment(i) => self.increment(i), Action::Decrement(i) => self.decrement(i), Action::CompleteInput(s) => self.add(s), Action::EnterNormal => { self.mode = Mode::Normal; }, Action::EnterInsert => { self.mode = Mode::Insert; }, Action::EnterProcessing => { self.mode = Mode::Processing; }, Action::ExitProcessing => { // TODO: Make this go to previous mode instead self.mode = Mode::Normal; }, _ => (), } Ok(None) }
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { let rects = Layout::default().constraints([Constraint::Percentage(100), Constraint::Min(3)].as_ref()).split(rect);
let mut text: Vec<Line> = self.text.clone().iter().map(|l| Line::from(l.clone())).collect(); text.insert(0, "".into()); text.insert(0, "Type into input and hit enter to display here".dim().into()); text.insert(0, "".into()); text.insert(0, format!("Render Ticker: {}", self.render_ticker).into()); text.insert(0, format!("App Ticker: {}", self.app_ticker).into()); text.insert(0, format!("Counter: {}", self.counter).into()); text.insert(0, "".into()); text.insert( 0, Line::from(vec![ "Press ".into(), Span::styled("j", Style::default().fg(Color::Red)), " or ".into(), Span::styled("k", Style::default().fg(Color::Red)), " to ".into(), Span::styled("increment", Style::default().fg(Color::Yellow)), " or ".into(), Span::styled("decrement", Style::default().fg(Color::Yellow)), ".".into(), ]), ); text.insert(0, "".into());
f.render_widget( Paragraph::new(text) .block( Block::default() .title("ratatui async template") .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_style(match self.mode { Mode::Processing => Style::default().fg(Color::Yellow), _ => Style::default(), }) .border_type(BorderType::Rounded), ) .style(Style::default().fg(Color::Cyan)) .alignment(Alignment::Center), rects[0], ); let width = rects[1].width.max(3) - 3; // keep 2 for borders and 1 for cursor let scroll = self.input.visual_scroll(width as usize); let input = Paragraph::new(self.input.value()) .style(match self.mode { Mode::Insert => Style::default().fg(Color::Yellow), _ => Style::default(), }) .scroll((0, scroll as u16)) .block(Block::default().borders(Borders::ALL).title(Line::from(vec![ Span::raw("Enter Input Mode "), Span::styled("(Press ", Style::default().fg(Color::DarkGray)), Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)), Span::styled(" to start, ", Style::default().fg(Color::DarkGray)), Span::styled("ESC", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)), Span::styled(" to finish)", Style::default().fg(Color::DarkGray)), ]))); f.render_widget(input, rects[1]); if self.mode == Mode::Insert { let position = Position { x: (rects[1].x + 1 + self.input.cursor() as u16).min(rects[1].x + rects[1].width - 2), y: rects[1].y + 1, }; f.set_cursor_position(position) }
if self.show_help { let rect = rect.inner(Margin { horizontal: 4, vertical: 2 }); f.render_widget(Clear, rect); let block = Block::default() .title(Line::from(vec![Span::styled("Key Bindings", Style::default().add_modifier(Modifier::BOLD))])) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)); f.render_widget(block, rect); let rows = vec![ Row::new(vec!["j", "Increment"]), Row::new(vec!["k", "Decrement"]), Row::new(vec!["/", "Enter Input"]), Row::new(vec!["ESC", "Exit Input"]), Row::new(vec!["Enter", "Submit Input"]), Row::new(vec!["q", "Quit"]), Row::new(vec!["?", "Open Help"]), ]; let table = Table::new(rows, [Constraint::Percentage(10), Constraint::Percentage(90)]) .header(Row::new(vec!["Key", "Action"]).bottom_margin(1).style(Style::default().add_modifier(Modifier::BOLD))) .column_spacing(1); f.render_widget(table, rect.inner(Margin { vertical: 4, horizontal: 2 })); };
f.render_widget( Block::default() .title( Line::from(format!( "{:?}", &self.last_events.iter().map(key_event_to_string).collect::<Vec<_>>() )) .alignment(Alignment::Right), ) .title_style(Style::default().add_modifier(Modifier::BOLD)), Rect { x: rect.x + 1, y: rect.height.saturating_sub(1), width: rect.width.saturating_sub(2), height: 1 }, );
Ok(()) }}
The render
function takes a Frame
and draws a paragraph to display a counter as well as a text
box input:
The Home
component has a couple of methods increment
and decrement
that we saw earlier, but
this time additional Action
s are sent on the action_tx
channel to track the start and end of the
increment.
pub fn schedule_increment(&mut self, i: usize) { let tx = self.action_tx.clone().unwrap(); tokio::task::spawn(async move { tx.send(Action::EnterProcessing).unwrap(); tokio::time::sleep(Duration::from_secs(5)).await; tx.send(Action::Increment(i)).unwrap(); tx.send(Action::ExitProcessing).unwrap(); }); }
pub fn schedule_decrement(&mut self, i: usize) { let tx = self.action_tx.clone().unwrap(); tokio::task::spawn(async move { tx.send(Action::EnterProcessing).unwrap(); tokio::time::sleep(Duration::from_secs(5)).await; tx.send(Action::Decrement(i)).unwrap(); tx.send(Action::ExitProcessing).unwrap(); }); }
When a Action
is sent on the action channel, it is received in the main
thread in the
app.run()
loop which then calls the dispatch
method with the appropriate action:
fn dispatch(&mut self, action: Action) -> Option<Action> { match action { Action::Tick => self.tick(), Action::ToggleShowHelp => self.show_help = !self.show_help, Action::ScheduleIncrement=> self.schedule_increment(1), Action::ScheduleDecrement=> self.schedule_decrement(1), Action::Increment(i) => self.increment(i), Action::Decrement(i) => self.decrement(i), Action::EnterNormal => { self.mode = Mode::Normal; }, Action::EnterInsert => { self.mode = Mode::Insert; }, Action::EnterProcessing => { self.mode = Mode::Processing; }, Action::ExitProcessing => { // TODO: Make this go to previous mode instead self.mode = Mode::Normal; }, _ => (), } None }
This way, you can have Action
affect multiple components by propagating the actions down all of
them.
When the Mode
is switched to Insert
, all events are handled off the Input
widget from the
excellent tui-input
crate.