Skip to content

Commit

Permalink
docs: add stopwatch example
Browse files Browse the repository at this point in the history
  • Loading branch information
joshka committed Sep 5, 2023
1 parent 686a58f commit 030981a
Show file tree
Hide file tree
Showing 4 changed files with 376 additions and 1 deletion.
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ ratatui = "0.23.0"
[dev-dependencies]
anyhow = "1.0.44"
indoc = "2.0.3"
crossterm = "0.27.0"
crossterm = { version = "0.27.0", features = ["event-stream"] }
futures = "0.3"
strum = "0.25.0"
tokio = { version = "1.16", features = ["full"] }
9 changes: 9 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Examples

## Hello World

![Hello World](https://vhs.charm.sh/vhs-1dIs1zoxqGwkP60aMcfpR8.gif)

## Stopwatch

![Stopwatch](https://vhs.charm.sh/vhs-3dTTtrLkyU54hNah22PAR9.gif)
340 changes: 340 additions & 0 deletions examples/stopwatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
use std::{
io::{self, Stdout},
time::{Duration, Instant},
};

use anyhow::{bail, Context, Result};
use crossterm::{
event::{self, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::{FutureExt, StreamExt};
// use futures::{select, FutureExt, StreamExt};
use itertools::Itertools;
use ratatui::{prelude::*, widgets::Paragraph};
use strum::EnumIs;
use tokio::select;
use tui_big_text::BigText;

#[tokio::main]
async fn main() -> Result<()> {
let mut app = StopwatchApp::default();
app.run().await
}

#[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, Default, Clone, PartialEq)]
struct StopwatchApp {
state: AppState,
splits: Vec<Instant>,
fps_counter: FpsCounter,
}

impl StopwatchApp {
async fn run(&mut self) -> Result<()> {
let mut tui = Tui::init()?;
let mut events = EventHandler::new(60.0);
while !self.state.is_quitting() {
self.draw(&mut tui)?;
let message = events.next().await?;
self.handle_message(message)?;
}
Ok(())
}

fn handle_message(&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.fps_counter.tick()
}

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 draw(&mut self, tui: &mut Tui) -> Result<()> {
tui.draw(|frame| {
let layout = layout(frame.size());
frame.render_widget(Paragraph::new("Stopwatch Example"), layout[0]);
frame.render_widget(self.fps_paragraph(), layout[1]);
frame.render_widget(self.timer_paragraph(), layout[2]);
frame.render_widget(Paragraph::new("Splits:"), layout[3]);
frame.render_widget(self.splits_paragraph(), layout[4]);
frame.render_widget(self.help_paragraph(), layout[5]);
})
}

fn fps_paragraph(&mut self) -> Paragraph<'_> {
let fps = format!("{:.2} fps", self.fps_counter.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 duration = format_duration(self.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))| 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(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>(index: usize, start: Instant, previous: Instant, current: Instant) -> Line<'a> {
let split = format_duration(current - previous);
let elapsed = 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(duration: Duration) -> String {
format!(
"{:02}:{:02}.{:03}",
duration.as_secs() / 60,
duration.as_secs() % 60,
duration.subsec_millis()
)
}

#[derive(Debug, Clone, PartialEq)]
struct FpsCounter {
start_time: Instant,
frames: u32,
pub fps: f64,
}

impl Default for FpsCounter {
fn default() -> Self {
Self::new()
}
}

impl FpsCounter {
fn new() -> Self {
Self {
start_time: Instant::now(),
frames: 0,
fps: 0.0,
}
}

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;
}
}
}

/// Handles events from crossterm and emits `Message`s.
struct EventHandler {
crossterm_events: event::EventStream,
interval: tokio::time::Interval,
}

impl EventHandler {
/// Creates a new event handler that emits a `Message::Tick` every `1.0 / max_fps` seconds.
fn new(max_fps: f32) -> Self {
let period = Duration::from_secs_f32(1.0 / max_fps);
Self {
crossterm_events: event::EventStream::new(),
interval: tokio::time::interval(period),
}
}

async fn next(&mut self) -> Result<Message> {
select! {
event = self.crossterm_events.next().fuse() => Self::handle_crossterm_event(event),
_ = self.interval.tick().fuse() => Ok(Message::Tick),
}
}

fn handle_crossterm_event(
event: Option<core::result::Result<event::Event, std::io::Error>>,
) -> Result<Message> {
match event {
Some(Ok(event::Event::Key(key))) => Ok(match key.code {
KeyCode::Char('q') => Message::Quit,
KeyCode::Char(' ') => Message::StartOrSplit,
KeyCode::Char('s') | KeyCode::Enter => Message::Stop,
_ => Message::Tick,
}),
Some(Err(err)) => bail!(err),
None => bail!("event stream ended unexpectedly"),
_ => Ok(Message::Tick),
}
}
}

struct Tui {
terminal: Terminal<CrosstermBackend<Stdout>>,
}

impl Tui {
fn init() -> Result<Tui> {
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("failed to create terminal")?;
enable_raw_mode().context("failed to enable raw mode")?;
terminal.hide_cursor().context("failed to hide cursor")?;
terminal.clear().context("failed to clear console")?;
Ok(Self { terminal })
}

fn draw(&mut self, frame: impl FnOnce(&mut Frame<CrosstermBackend<Stdout>>)) -> Result<()> {
self.terminal.draw(frame).context("failed to draw frame")?;
Ok(())
}
}

impl Drop for Tui {
fn drop(&mut self) {
disable_raw_mode().expect("failed to disable raw mode");
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)
.expect("failed to switch to main screen");
self.terminal.show_cursor().expect("failed to show cursor");
self.terminal.clear().expect("failed to clear console");
}
}
23 changes: 23 additions & 0 deletions examples/stopwatch.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# VHS Tape (see https://github.com/charmbracelet/vhs)
Output "target/stopwatch.gif"
Set Theme "OceanicMaterial"
Set Width 1200
Set Height 600
Hide
Type@0 "cargo run --example stopwatch --quiet"
Enter
Sleep 2s
Show
Sleep 2s
Space
Sleep 1s
Space
Sleep 2s
Space
Sleep 3s
Space
Sleep 4s
Space
Sleep 5s
Enter
Sleep 2s

0 comments on commit 030981a

Please sign in to comment.