pomodoro-tui 0.2.0

A simple Pomodoro timer with a terminal user interface.
Documentation
use crate::ascii_images;
use crossterm::event;
use ratatui::{layout, style::Stylize, symbols, text, widgets, DefaultTerminal, Frame};
use std::io;
use std::sync::mpsc;
use std::time;
use tui_big_text;

enum Event {
    Key(event::KeyEvent),
    Tick,
}

pub struct App {
    pomo: pomodoro_tui::Pomodoro,
    exit: bool,
    tx: mpsc::Sender<Event>,
    rx: mpsc::Receiver<Event>,
    hide_image: bool,
}

impl App {
    pub fn new(work_min: u64, break_min: u64, hide_image: bool) -> Self {
        let (tx, rx) = mpsc::channel();
        App {
            pomo: pomodoro_tui::Pomodoro::new((work_min, 0), (break_min, 0)),
            exit: false,
            tx,
            rx,
            hide_image,
        }
    }

    pub fn run(&mut self, mut terminal: DefaultTerminal) -> io::Result<()> {
        while !self.exit {
            terminal.draw(|frame| self.draw(frame))?;
            match self.rx.recv() {
                Ok(Event::Key(key_event)) => self.handle_key_event(key_event),
                Ok(Event::Tick) => self.pomo.check_and_switch(),
                _ => (),
            }
        }
        Ok(())
    }

    pub fn handle_inputs(&self) {
        let tx = self.tx.clone();
        let tick_rate = time::Duration::from_millis(200);
        std::thread::spawn(move || {
            let mut last_tick = time::Instant::now();
            loop {
                let timeout = tick_rate.saturating_sub(last_tick.elapsed());
                if event::poll(timeout).unwrap() {
                    match event::read().unwrap() {
                        event::Event::Key(key_event) => tx.send(Event::Key(key_event)).unwrap(),
                        _ => (),
                    }
                }
                if last_tick.elapsed() >= tick_rate {
                    tx.send(Event::Tick).unwrap();
                    last_tick = time::Instant::now();
                }
            }
        });
    }

    pub fn start_or_pause(&mut self) {
        self.pomo.start_or_pause();
    }

    fn draw(&self, frame: &mut Frame) {
        let (work_size, work_pixel, break_size, break_pixel) = match self.pomo.state() {
            pomodoro_tui::PomodoroState::Work => (
                8,
                tui_big_text::PixelSize::Full,
                4,
                tui_big_text::PixelSize::Quadrant,
            ),
            pomodoro_tui::PomodoroState::Break => (
                4,
                tui_big_text::PixelSize::Quadrant,
                8,
                tui_big_text::PixelSize::Full,
            ),
        };

        let area = frame.area();

        let block = self.get_block_widget();
        frame.render_widget(block, area);

        let (lcenter, rtop, rbottom) = self.get_layout(area, work_size, break_size);

        if !self.hide_image {
            let ascii_img = self.get_ascii_image_widget();
            frame.render_widget(ascii_img, lcenter);
        }

        let (work_timer, break_timer) = self.get_timer_widgets(work_pixel, break_pixel);
        frame.render_widget(work_timer, rtop);
        frame.render_widget(break_timer, rbottom);
    }

    fn get_layout(
        &self,
        area: layout::Rect,
        work_size: u16,
        break_size: u16,
    ) -> (layout::Rect, layout::Rect, layout::Rect) {
        let (ascii_width, timer_width) = if !self.hide_image { (50, 50) } else { (0, 100) };
        let horizontal = layout::Layout::horizontal([
            layout::Constraint::Percentage(ascii_width),
            layout::Constraint::Percentage(timer_width),
        ]);
        let [left, right] = horizontal.areas(area);

        let left_layout = layout::Layout::vertical([
            layout::Constraint::Fill(1),
            layout::Constraint::Length(10),
            layout::Constraint::Fill(1),
        ]);
        let [_, lcenter, _] = left_layout.areas(left);

        let right_layout = layout::Layout::vertical([
            layout::Constraint::Fill(1),
            layout::Constraint::Length(work_size),
            layout::Constraint::Length(break_size),
            layout::Constraint::Fill(1),
        ]);
        let [_, rtop, rbottom, _] = right_layout.areas(right);

        (lcenter, rtop, rbottom)
    }

    fn get_block_widget(&self) -> widgets::Block {
        let start_pause = match self.pomo.is_running() {
            true => "Pause ",
            false => "Start ",
        };

        let title = text::Line::from(" Pomodoro ".bold());
        let instructions = text::Line::from(vec![
            start_pause.into(),
            "<S>".blue().bold(),
            " Reset ".into(),
            "<R>".blue().bold(),
            " Quit ".into(),
            "<Q/Esc> ".blue().bold(),
        ]);
        widgets::Block::bordered()
            .title(title.centered())
            .title_bottom(instructions.centered())
            .border_set(symbols::border::THICK)
    }

    fn get_ascii_image_widget(&self) -> widgets::Paragraph {
        let ascii_image: Vec<text::Line> = match self.pomo.state() {
            pomodoro_tui::PomodoroState::Work => ascii_images::computer(),
            pomodoro_tui::PomodoroState::Break => ascii_images::sleeping_cat(),
        }
        .into_iter()
        .map(text::Line::from)
        .collect();

        widgets::Paragraph::new(ascii_image).alignment(layout::Alignment::Center)
    }

    fn get_timer_widgets(
        &self,
        work_pixel: tui_big_text::PixelSize,
        break_pixel: tui_big_text::PixelSize,
    ) -> (tui_big_text::BigText, tui_big_text::BigText) {
        let work_timer = tui_big_text::BigText::builder()
            .pixel_size(work_pixel)
            .lines(vec![self.pomo.work_time().blue().into()])
            .centered()
            .build();
        let break_timer = tui_big_text::BigText::builder()
            .pixel_size(break_pixel)
            .lines(vec![self.pomo.break_time().green().into()])
            .centered()
            .build();
        (work_timer, break_timer)
    }

    fn handle_key_event(&mut self, key_event: event::KeyEvent) {
        match key_event.code {
            event::KeyCode::Char('s') => {
                self.pomo.start_or_pause();
            }
            event::KeyCode::Char('r') => {
                self.pomo.reset();
            }
            event::KeyCode::Esc => self.exit = true,
            event::KeyCode::Char('q') => self.exit = true,
            _ => (),
        }
    }
}