Stopwatch App
In this section, we are going to combine what we learnt in the previous tutorials and build a stopwatch application. We are also going to take advantage of a widget from an external dependency.
Here’s the dependencies you’ll need in your Cargo.toml
:
[package]
name = "ratatui-stopwatch-app"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
publish.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
color-eyre = "0.6.2"
crossterm = { version = "0.27.0", features = ["event-stream"] }
directories = "5.0.1"
futures = "0.3.28"
human-panic = "1.2.0"
itertools = "0.11.0"
libc = "0.2.147"
log = "0.4.20"
ratatui = "0.24.0"
strip-ansi-escapes = "0.2.0"
strum = "0.25.0"
tokio = { version = "1.32.0", features = ["full"] }
tokio-util = "0.7.8"
tui-big-text = "0.2.1"
Here’s a gif of what it will look like if you run this:
This application uses an external dependency called
tui-big-text
.
This application also combines the AppState
(or Mode) pattern from
the JSON Editor with the Message
(or Command
or Action
) pattern
from the Async Counter App. This Message
pattern is common in
The Elm Architecture pattern.
This application uses a Tui
struct that
combines the Terminal
and Event Handler
that we discussed in the previous section.
The full code is available on GitHub.
Here’s the relevant application part of the code:
use std::time::{Duration, Instant};
use color_eyre::eyre::{eyre, Result};
use futures::{FutureExt, StreamExt};
use itertools::Itertools;
use ratatui::{backend::CrosstermBackend as Backend, prelude::*, widgets::*};
use strum::EnumIs;
use tui_big_text::BigText;
#[tokio::main]
async fn main() -> Result<()> {
let mut app = StopwatchApp::default();
app.run().await
}
#[derive(Clone, Debug)]
pub enum Event {
Error,
Tick,
Key(crossterm::event::KeyEvent),
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, EnumIs)]
enum AppState {
#[default]
Stopped,
Running,
Quitting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Message {
StartOrSplit,
Stop,
Tick,
Quit,
}
#[derive(Debug, Clone, PartialEq)]
struct StopwatchApp {
state: AppState,
splits: Vec<Instant>,
start_time: Instant,
frames: u32,
fps: f64,
}
impl Default for StopwatchApp {
fn default() -> Self {
Self::new()
}
}
impl StopwatchApp {
fn new() -> Self {
Self {
start_time: Instant::now(),
frames: Default::default(),
fps: Default::default(),
splits: Default::default(),
state: Default::default(),
}
}
async fn run(&mut self) -> Result<()> {
let mut tui = Tui::new()?;
tui.enter()?;
while !self.state.is_quitting() {
tui.draw(|f| self.ui(f).expect("Unexpected error during drawing"))?;
let event = tui.next().await.ok_or(eyre!("Unable to get event"))?; // blocks until next event
let message = self.handle_event(event)?;
self.update(message)?;
}
tui.exit()?;
Ok(())
}
fn handle_event(&self, event: Event) -> Result<Message> {
let msg = match event {
Event::Key(key) => {
match key.code {
crossterm::event::KeyCode::Char('q') => Message::Quit,
crossterm::event::KeyCode::Char(' ') => Message::StartOrSplit,
crossterm::event::KeyCode::Char('s') | crossterm::event::KeyCode::Enter => Message::Stop,
_ => Message::Tick,
}
},
_ => Message::Tick,
};
Ok(msg)
}
fn update(&mut self, message: Message) -> Result<()> {
match message {
Message::StartOrSplit => self.start_or_split(),
Message::Stop => self.stop(),
Message::Tick => self.tick(),
Message::Quit => self.quit(),
}
Ok(())
}
fn start_or_split(&mut self) {
if self.state.is_stopped() {
self.start();
} else {
self.record_split();
}
}
fn stop(&mut self) {
self.record_split();
self.state = AppState::Stopped;
}
fn tick(&mut self) {
self.frames += 1;
let now = Instant::now();
let elapsed = (now - self.start_time).as_secs_f64();
if elapsed >= 1.0 {
self.fps = self.frames as f64 / elapsed;
self.start_time = now;
self.frames = 0;
}
}
fn quit(&mut self) {
self.state = AppState::Quitting
}
fn start(&mut self) {
self.splits.clear();
self.state = AppState::Running;
self.record_split();
}
fn record_split(&mut self) {
if !self.state.is_running() {
return;
}
self.splits.push(Instant::now());
}
fn elapsed(&mut self) -> Duration {
if self.state.is_running() {
self.splits.first().map_or(Duration::ZERO, Instant::elapsed)
} else {
// last - first or 0 if there are no splits
let now = Instant::now();
let first = *self.splits.first().unwrap_or(&now);
let last = *self.splits.last().unwrap_or(&now);
last - first
}
}
fn ui(&mut self, f: &mut Frame) -> Result<()> {
let layout = self.layout(f.size());
f.render_widget(Paragraph::new("Stopwatch Example"), layout[0]);
f.render_widget(self.fps_paragraph(), layout[1]);
f.render_widget(self.timer_paragraph(), layout[2]);
f.render_widget(Paragraph::new("Splits:"), layout[3]);
f.render_widget(self.splits_paragraph(), layout[4]);
f.render_widget(self.help_paragraph(), layout[5]);
Ok(())
}
fn fps_paragraph(&mut self) -> Paragraph<'_> {
let fps = format!("{:.2} fps", self.fps);
Paragraph::new(fps).style(Style::new().dim()).alignment(Alignment::Right)
}
fn timer_paragraph(&mut self) -> BigText<'_> {
let style = if self.state.is_running() { Style::new().green() } else { Style::new().red() };
let elapsed = self.elapsed();
let duration = self.format_duration(elapsed);
let lines = vec![duration.into()];
tui_big_text::BigTextBuilder::default().lines(lines).style(style).build().unwrap()
}
/// Renders the splits as a list of lines.
///
/// ```text
/// #01 -- 00:00.693 -- 00:00.693
/// #02 -- 00:00.719 -- 00:01.413
/// ```
fn splits_paragraph(&mut self) -> Paragraph<'_> {
let start = *self.splits.first().unwrap_or(&Instant::now());
let mut splits = self
.splits
.iter()
.copied()
.tuple_windows()
.enumerate()
.map(|(index, (prev, current))| self.format_split(index, start, prev, current))
.collect::<Vec<_>>();
splits.reverse();
Paragraph::new(splits)
}
fn help_paragraph(&mut self) -> Paragraph<'_> {
let space_action = if self.state.is_stopped() { "start" } else { "split" };
let help_text =
Line::from(vec!["space ".into(), space_action.dim(), " enter ".into(), "stop".dim(), " q ".into(), "quit".dim()]);
Paragraph::new(help_text).gray()
}
fn layout(&self, area: Rect) -> Vec<Rect> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Length(2), // top bar
Constraint::Length(8), // timer
Constraint::Length(1), // splits header
Constraint::Min(0), // splits
Constraint::Length(1), // help
])
.split(area);
let top_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![
Constraint::Length(20), // title
Constraint::Min(0), // fps counter
])
.split(layout[0]);
// return a new vec with the top_layout rects and then rest of layout
top_layout[..].iter().chain(layout[1..].iter()).copied().collect()
}
fn format_split<'a>(&self, index: usize, start: Instant, previous: Instant, current: Instant) -> Line<'a> {
let split = self.format_duration(current - previous);
let elapsed = self.format_duration(current - start);
Line::from(vec![
format!("#{:02} -- ", index + 1).into(),
Span::styled(split, Style::new().yellow()),
" -- ".into(),
Span::styled(elapsed, Style::new()),
])
}
fn format_duration(&self, duration: Duration) -> String {
format!("{:02}:{:02}.{:03}", duration.as_secs() / 60, duration.as_secs() % 60, duration.subsec_millis())
}
}
It is worth thinking about what it takes to build your own custom widget by looking at
the source for the BigText
widget:
#[derive(Debug, Builder, Clone, PartialEq, Eq, Hash)]
pub struct BigText<'a> {
#[builder(setter(into))]
lines: Vec<Line<'a>>,
#[builder(default)]
style: Style,
}
impl Widget for BigText<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let layout = layout(area);
for (line, line_layout) in self.lines.iter().zip(layout) {
for (g, cell) in line.styled_graphemes(self.style).zip(line_layout) {
render_symbol(g, cell, buf);
}
}
}
}
To build a custom widget, you have to implement the Widget
trait. We cover how to implement the
Widget
trait for your own structs in a separate section.