Event handling (#5)

- Refactor `event` handling (heavily inspired by [crates-tui](https://github.com/ratatui/crates-tui/) via [Tui with Terminal and EventHandler](https://ratatui.rs/recipes/apps/terminal-and-event-handler/))
- Refactor widget structure
- Disable `nixos-unstable` temporarily
- Add `.rustfmt.toml`
This commit is contained in:
Jens K.
2024-12-02 15:43:04 +01:00
committed by GitHub
parent db5909f3d9
commit 2f587c97b5
17 changed files with 469 additions and 95 deletions

View File

@@ -1,47 +1,70 @@
use color_eyre::{eyre::Context, Result};
use crossterm::event;
use crate::{
events::{Event, Events},
terminal::Terminal,
utils::center,
widgets::{
countdown::Countdown, footer::Footer, header::Header, pomodoro::Pomodoro, timer::Timer,
},
};
use color_eyre::Result;
use ratatui::{
buffer::Buffer,
crossterm::event::{Event, KeyCode, KeyEventKind},
crossterm::event::{KeyCode, KeyEvent},
layout::{Constraint, Layout, Rect},
widgets::{Block, Paragraph, Widget},
DefaultTerminal, Frame,
widgets::{Block, Widget},
};
use crate::footer::Footer;
use crate::pomodoro::Pomodoro;
use crate::timer::Timer;
use crate::{countdown::Countdown, utils::center};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
#[default]
Running,
Quit,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Content {
#[default]
Countdown,
Timer,
Pomodoro,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[derive(Debug)]
pub struct App {
content: Content,
mode: Mode,
show_menu: bool,
tick: u128,
}
impl Default for App {
fn default() -> Self {
Self {
mode: Mode::Running,
content: Content::Countdown,
show_menu: false,
tick: 0,
}
}
}
impl App {
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
pub fn new() -> Self {
Self::default()
}
pub async fn run(&mut self, mut terminal: Terminal, mut events: Events) -> Result<()> {
while self.is_running() {
terminal
.draw(|frame| self.draw(frame))
.wrap_err("terminal.draw")?;
self.handle_events()?;
if let Some(event) = events.next().await {
match event {
Event::Render | Event::Resize(_, _) => {
self.draw(&mut terminal)?;
}
Event::Tick => {
self.tick = self.tick.saturating_add(1);
}
Event::Key(key) => self.handle_key_event(key),
_ => {}
}
}
}
Ok(())
}
@@ -50,27 +73,33 @@ impl App {
self.mode != Mode::Quit
}
/// Draw a single frame of the app.
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
fn handle_key_event(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.mode = Mode::Quit,
KeyCode::Char('c') => self.content = Content::Countdown,
KeyCode::Char('t') => self.content = Content::Timer,
KeyCode::Char('p') => self.content = Content::Pomodoro,
KeyCode::Char('m') => self.show_menu = !self.show_menu,
_ => {}
};
}
fn handle_events(&mut self) -> Result<()> {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
return Ok(());
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.mode = Mode::Quit,
KeyCode::Char('c') => self.content = Content::Countdown,
KeyCode::Char('t') => self.content = Content::Timer,
KeyCode::Char('p') => self.content = Content::Pomodoro,
KeyCode::Char('m') => self.show_menu = !self.show_menu,
_ => {}
};
}
fn draw(&self, terminal: &mut Terminal) -> Result<()> {
terminal.draw(|frame| {
frame.render_widget(self, frame.area());
})?;
Ok(())
}
fn render_content(&self, area: Rect, buf: &mut Buffer) {
// center content
let area = center(area, Constraint::Length(50), Constraint::Length(1));
match self.content {
Content::Timer => Timer::new(200, "Timer".into()).render(area, buf),
Content::Countdown => Countdown::new("Countdown".into()).render(area, buf),
Content::Pomodoro => Pomodoro::new("Pomodoro".into()).render(area, buf),
};
}
}
impl Widget for &App {
@@ -83,24 +112,8 @@ impl Widget for &App {
let [header_area, content_area, footer_area] = vertical.areas(area);
Block::new().render(area, buf);
self.render_header(header_area, buf);
Header::new(self.tick).render(header_area, buf);
self.render_content(content_area, buf);
Footer::new(self.show_menu, self.content).render(footer_area, buf);
}
}
impl App {
fn render_header(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new("tim:r").render(area, buf);
}
fn render_content(&self, area: Rect, buf: &mut Buffer) {
// center content
let area = center(area, Constraint::Length(50), Constraint::Length(1));
match self.content {
Content::Timer => Timer::new(200, "Timer".into()).render(area, buf),
Content::Countdown => Countdown::new("Countdown".into()).render(area, buf),
Content::Pomodoro => Pomodoro::new("Pomodoro".into()).render(area, buf),
};
}
}

76
src/events.rs Normal file
View File

@@ -0,0 +1,76 @@
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
use futures::{Stream, StreamExt};
use std::{pin::Pin, time::Duration};
use tokio::time::interval;
use tokio_stream::{wrappers::IntervalStream, StreamMap};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
enum StreamKey {
Ticks,
Render,
Crossterm,
}
#[derive(Clone, Debug)]
pub enum Event {
Init,
Quit,
Error,
Tick,
Render,
Key(KeyEvent),
Resize(u16, u16),
}
pub struct Events {
streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = Event>>>>,
}
impl Default for Events {
fn default() -> Self {
Self {
streams: StreamMap::from_iter([
(StreamKey::Ticks, tick_stream()),
(StreamKey::Render, render_stream()),
(StreamKey::Crossterm, crossterm_stream()),
]),
}
}
}
impl Events {
pub fn new() -> Self {
Self::default()
}
pub async fn next(&mut self) -> Option<Event> {
self.streams.next().await.map(|(_, event)| event)
}
}
fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
let tick_interval = interval(Duration::from_secs_f64(1.0 / 10.0));
Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick))
}
fn render_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
let render_interval = interval(Duration::from_secs_f64(1.0 / 60.0)); // 60 FPS
Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render))
}
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
Box::pin(
EventStream::new()
.fuse()
// we are not interested in all events
.filter_map(|event| async move {
match event {
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
Some(Event::Key(key))
}
Ok(CrosstermEvent::Resize(x, y)) => Some(Event::Resize(x, y)),
Err(_) => Some(Event::Error),
_ => None,
}
}),
)
}

View File

@@ -1,17 +1,19 @@
mod app;
mod countdown;
mod footer;
mod pomodoro;
mod timer;
mod events;
mod terminal;
mod utils;
mod widgets;
use app::App;
use color_eyre::{eyre::Context, Result};
use color_eyre::Result;
fn main() -> Result<()> {
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let terminal = ratatui::init();
let app_result = App::default().run(terminal).context("app loop failed");
ratatui::restore();
app_result
let terminal = terminal::init()?;
let events = events::Events::new();
App::new().run(terminal, events).await?;
terminal::restore()?;
Ok(())
}

22
src/terminal.rs Normal file
View File

@@ -0,0 +1,22 @@
use std::io::{stdout, Stdout};
use color_eyre::eyre::Result;
use crossterm::{execute, terminal::*};
use ratatui::{backend::CrosstermBackend, Terminal as RatatuiTerminal};
pub type Terminal = RatatuiTerminal<CrosstermBackend<Stdout>>;
pub fn init() -> Result<Terminal> {
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout()))?;
terminal.clear()?;
terminal.hide_cursor()?;
Ok(terminal)
}
pub fn restore() -> Result<()> {
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}

View File

@@ -9,3 +9,16 @@ pub fn center(base_area: Rect, horizontal: Constraint, vertical: Constraint) ->
let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
area
}
pub fn format_ms(ms: u128, show_tenths: bool) -> String {
// let hours = ms / 3600000;
let minutes = (ms % 3600000) / 60000;
let seconds = (ms % 60000) / 1000;
let tenths = (ms % 1000) / 100;
if show_tenths {
format!("{:02}:{:02}.{}", minutes, seconds, tenths)
} else {
format!("{:02}:{:02}", minutes, seconds)
}
}

5
src/widgets.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod countdown;
pub mod footer;
pub mod header;
pub mod pomodoro;
pub mod timer;

View File

@@ -1,22 +1,21 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
text::Text,
widgets::{Paragraph, Widget},
};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Countdown<'a> {
headline: Text<'a>,
pub struct Countdown {
headline: String,
}
impl<'a> Countdown<'a> {
pub const fn new(headline: Text<'a>) -> Self {
impl Countdown {
pub const fn new(headline: String) -> Self {
Self { headline }
}
}
impl Widget for Countdown<'_> {
impl Widget for Countdown {
fn render(self, area: Rect, buf: &mut Buffer) {
let h = Paragraph::new(self.headline).centered();
h.render(area, buf);

32
src/widgets/header.rs Normal file
View File

@@ -0,0 +1,32 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
text::Span,
widgets::Widget,
};
use crate::utils::format_ms;
#[derive(Debug, Clone)]
pub struct Header {
tick: u128,
}
impl Header {
pub fn new(tick: u128) -> Self {
Self { tick }
}
}
impl Widget for Header {
fn render(self, area: Rect, buf: &mut Buffer) {
let time_string = format_ms(self.tick * 100, true);
let tick_span = Span::raw(time_string);
let tick_width = tick_span.width().try_into().unwrap_or(0);
let [h1, h2] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(tick_width)]).areas(area);
Span::raw("tim:r").render(h1, buf);
tick_span.render(h2, buf);
}
}

View File

@@ -1,22 +1,21 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
text::Text,
widgets::{Paragraph, Widget},
};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Pomodoro<'a> {
headline: Text<'a>,
pub struct Pomodoro {
headline: String,
}
impl<'a> Pomodoro<'a> {
pub const fn new(headline: Text<'a>) -> Self {
impl Pomodoro {
pub const fn new(headline: String) -> Self {
Self { headline }
}
}
impl Widget for Pomodoro<'_> {
impl Widget for Pomodoro {
fn render(self, area: Rect, buf: &mut Buffer) {
let h = Paragraph::new(self.headline).centered();
h.render(area, buf);

View File

@@ -1,23 +1,22 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
text::Text,
widgets::{Paragraph, Widget},
};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Timer<'a> {
pub struct Timer {
value: u64,
headline: Text<'a>,
headline: String,
}
impl<'a> Timer<'a> {
pub const fn new(value: u64, headline: Text<'a>) -> Self {
impl Timer {
pub const fn new(value: u64, headline: String) -> Self {
Self { value, headline }
}
}
impl Widget for Timer<'_> {
impl Widget for Timer {
fn render(self, area: Rect, buf: &mut Buffer) {
let h = Paragraph::new(self.headline).centered();
h.render(area, buf);