diff --git a/.gitignore b/.gitignore index ca86dda..860aa41 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ # will have compiled files and executables debug/ target/ -result/** +result/**/* # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index e846ec0..eb7d9db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -642,6 +642,7 @@ dependencies = [ "color-eyre", "crossterm", "ratatui", + "strum", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9909dda..f8fb0c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" ratatui = "0.29.0" crossterm = "0.28.1" color-eyre = "0.6.2" +strum = { version = "0.26.3", features = ["derive"] } diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..2a6842a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,114 @@ +use color_eyre::{eyre::Context, Result}; +use crossterm::event; +use ratatui::{ + buffer::Buffer, + crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}, + layout::{Constraint, Layout, Rect}, + widgets::{Block, Paragraph, Widget}, + DefaultTerminal, Frame, +}; +use std::time::Duration; +use strum::{Display, EnumIter, FromRepr}; + +use crate::pomodoro::Pomodoro; +use crate::timer::Timer; +use crate::{countdown::Countdown, utils::center}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum Mode { + #[default] + Running, + Quit, +} + +#[derive(Debug, Clone, Copy, Default, Display, EnumIter, FromRepr, PartialEq, Eq)] +enum Content { + #[default] + Countdown, + Timer, + Pomodoro, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct App { + content: Content, + mode: Mode, +} + +impl App { + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + while self.is_running() { + terminal + .draw(|frame| self.draw(frame)) + .wrap_err("terminal.draw")?; + self.handle_events()?; + } + Ok(()) + } + + fn is_running(&self) -> bool { + 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_events(&mut self) -> Result<()> { + let timeout = Duration::from_secs_f64(1.0 / 50.0); + if !event::poll(timeout)? { + return Ok(()); + } + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key_press(key), + _ => {} + } + Ok(()) + } + + fn handle_key_press(&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, + _ => {} + }; + } +} + +impl Widget for &App { + fn render(self, area: Rect, buf: &mut Buffer) { + let vertical = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]); + let [header_area, content_area, footer_area] = vertical.areas(area); + + Block::new().render(area, buf); + self.render_header(header_area, buf); + self.render_content(content_area, buf); + self.render_footer(footer_area, buf); + } +} + +impl App { + fn render_header(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new("tim:r").render(area, buf); + } + fn render_footer(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new("footer").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), + }; + } +} diff --git a/src/countdown.rs b/src/countdown.rs new file mode 100644 index 0000000..5a9c752 --- /dev/null +++ b/src/countdown.rs @@ -0,0 +1,24 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + text::Text, + widgets::{Paragraph, Widget}, +}; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Countdown<'a> { + headline: Text<'a>, +} + +impl<'a> Countdown<'a> { + pub const fn new(headline: Text<'a>) -> Self { + Self { headline } + } +} + +impl Widget for Countdown<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let h = Paragraph::new(self.headline).centered(); + h.render(area, buf); + } +} diff --git a/src/main.rs b/src/main.rs index 9e81462..4ce6f4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,72 +1,16 @@ -//! # [Ratatui] Hello World example -//! -//! The latest version of this example is available in the [examples] folder in the repository. -//! -//! Please note that the examples are designed to be run against the `main` branch of the Github -//! repository. This means that you may not be able to compile with the latest release version on -//! crates.io, or the one that you have installed locally. -//! -//! See the [examples readme] for more information on finding examples that match the version of the -//! library you are using. -//! -//! [Ratatui]: https://github.com/ratatui/ratatui -//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples -//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md - -use std::time::Duration; +mod app; +mod countdown; +mod pomodoro; +mod timer; +mod utils; +use app::App; use color_eyre::{eyre::Context, Result}; -use ratatui::{ - crossterm::event::{self, Event, KeyCode}, - widgets::Paragraph, - DefaultTerminal, Frame, -}; -/// This is a bare minimum example. There are many approaches to running an application loop, so -/// this is not meant to be prescriptive. It is only meant to demonstrate the basic setup and -/// teardown of a terminal application. -/// -/// This example does not handle events or update the application state. It just draws a greeting -/// and exits when the user presses 'q'. fn main() -> Result<()> { - color_eyre::install()?; // augment errors / panics with easy to read messages + color_eyre::install()?; let terminal = ratatui::init(); - let app_result = run(terminal).context("app loop failed"); + let app_result = App::default().run(terminal).context("app loop failed"); ratatui::restore(); app_result } - -/// Run the application loop. This is where you would handle events and update the application -/// state. This example exits when the user presses 'q'. Other styles of application loops are -/// possible, for example, you could have multiple application states and switch between them based -/// on events, or you could have a single application state and update it based on events. -fn run(mut terminal: DefaultTerminal) -> Result<()> { - loop { - terminal.draw(draw)?; - if should_quit()? { - break; - } - } - Ok(()) -} - -/// Render the application. This is where you would draw the application UI. This example draws a -/// greeting. -fn draw(frame: &mut Frame) { - let greeting = Paragraph::new("Hello World! (press 'q' to quit)"); - frame.render_widget(greeting, frame.area()); -} - -/// Check if the user has pressed 'q'. This is where you would handle events. This example just -/// checks if the user has pressed 'q' and returns true if they have. It does not handle any other -/// events. There is a 250ms timeout on the event poll to ensure that the terminal is rendered at -/// least once every 250ms. This allows you to do other work in the application loop, such as -/// updating the application state, without blocking the event loop for too long. -fn should_quit() -> Result { - if event::poll(Duration::from_millis(250)).context("event poll failed")? { - if let Event::Key(key) = event::read().context("event read failed")? { - return Ok(KeyCode::Char('q') == key.code); - } - } - Ok(false) -} diff --git a/src/pomodoro.rs b/src/pomodoro.rs new file mode 100644 index 0000000..d6c5dfa --- /dev/null +++ b/src/pomodoro.rs @@ -0,0 +1,24 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + text::Text, + widgets::{Paragraph, Widget}, +}; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Pomodoro<'a> { + headline: Text<'a>, +} + +impl<'a> Pomodoro<'a> { + pub const fn new(headline: Text<'a>) -> Self { + Self { headline } + } +} + +impl Widget for Pomodoro<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let h = Paragraph::new(self.headline).centered(); + h.render(area, buf); + } +} diff --git a/src/timer.rs b/src/timer.rs new file mode 100644 index 0000000..12a9962 --- /dev/null +++ b/src/timer.rs @@ -0,0 +1,25 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + text::Text, + widgets::{Paragraph, Widget}, +}; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Timer<'a> { + value: u64, + headline: Text<'a>, +} + +impl<'a> Timer<'a> { + pub const fn new(value: u64, headline: Text<'a>) -> Self { + Self { value, headline } + } +} + +impl Widget for Timer<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let h = Paragraph::new(self.headline).centered(); + h.render(area, buf); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..8f6b8db --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,11 @@ +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +/// Helper to center an area by given `Constraint`'s +/// based on [Center a Rect](https://ratatui.rs/recipes/layout/center-a-rect) +pub fn center(base_area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { + let [area] = Layout::horizontal([horizontal]) + .flex(Flex::Center) + .areas(base_area); + let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); + area +}