PaperPaw is a beta: for testing, and for developers, who can build their own plugins before the release. Expect rough edges.

lib.rs

648 lines, 22 KB

//! A Pomodoro timer: focus rounds and breaks.
//!
//! - The state (phase, end time, rounds) is shared by every part.
//! - A timer (`Work::at`) ends the phase exactly on time, whatever screen
//!   is open, and says so with a notice.
//! - The screen redraws once a minute (every ten seconds in the last
//!   minute), never holds the device awake, and works by touch and by
//!   buttons.
//! - A status item shows the minutes left anywhere status lines show.

#![no_std]

extern crate alloc;

use alloc::borrow::Cow;
use alloc::boxed::Box;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::String;
use core::cell::RefCell;

use paperpaw_sdk::builtin::{actions, events, menus};
use paperpaw_sdk::power::WakeHold;
use paperpaw_sdk::time::unix_seconds;
use paperpaw_sdk::event::Event;
use paperpaw_sdk::setting::Setting;
use paperpaw_sdk::ui::{self, Ink, Label, Stack, TextRole, UiStyle};
use paperpaw_sdk::widgets::Align;
use paperpaw_sdk::{
    ActionSpec, Canvas, Command, Cx, Env, Gesture, GestureKind, Handled, Id, Instant, Manifest,
    MenuEntry, Params, Plugin, Rect, RegisterError, Registrar, Screen, ScreenSpec, SettingSpec,
    StatusItemSpec, StatusValue, Version,
};

pub const PLUGIN: Id = Id::new_static("pomodoro");
pub const OPEN: Id = Id::new_static("pomodoro.open");
pub const SCREEN: Id = Id::new_static("pomodoro.screen");
const DONE: Event<()> = Event::new("pomodoro.done");
const GROUP: Id = Id::new_static("pomodoro.group");

const FOCUS_MIN: Setting<i64> = Setting::new("pomodoro.focus");
const SHORT_MIN: Setting<i64> = Setting::new("pomodoro.short");
const LONG_MIN: Setting<i64> = Setting::new("pomodoro.long");
const ROUNDS: Setting<i64> = Setting::new("pomodoro.rounds");

static SETTINGS: [SettingSpec; 4] = [
    SettingSpec::number(FOCUS_MIN.static_str(), "pomodoro.setting.focus", 1, 90, 25, GROUP),
    SettingSpec::number(SHORT_MIN.static_str(), "pomodoro.setting.short", 1, 30, 5, GROUP),
    SettingSpec::number(LONG_MIN.static_str(), "pomodoro.setting.long", 1, 60, 15, GROUP),
    SettingSpec::number(ROUNDS.static_str(), "pomodoro.setting.rounds", 2, 8, 4, GROUP),
];

#[derive(Clone, Copy, PartialEq, Eq)]
enum Phase {
    Focus,
    Short,
    Long,
}

/// What every part shares.
struct State {
    phase: Phase,
    running: bool,
    /// When the phase ends while running.
    end: Option<Instant>,
    /// Milliseconds left while paused (or not started).
    left_ms: u64,
    /// Focus rounds finished in the current set.
    done: i64,
    /// Focus rounds finished since PaperPaw started.
    total: u32,
    /// Seconds since 1970 when the phase ends, while running: kept so a
    /// restart can go on where it was.
    end_unix: Option<i64>,
}

type Shared = Rc<RefCell<State>>;

/// Where the state is kept, in the plugin's own folder.
fn save_path() -> String {
    format!("{}/state.txt", Env::plugin_dir("pomodoro"))
}

fn phase_code(phase: Phase) -> u8 {
    match phase {
        Phase::Focus => 0,
        Phase::Short => 1,
        Phase::Long => 2,
    }
}

fn phase_from(code: i64) -> Phase {
    match code {
        1 => Phase::Short,
        2 => Phase::Long,
        _ => Phase::Focus,
    }
}

fn minutes(env: &Env<'_>, phase: Phase) -> u64 {
    let m = match phase {
        Phase::Focus => env.settings().read(&FOCUS_MIN),
        Phase::Short => env.settings().read(&SHORT_MIN),
        Phase::Long => env.settings().read(&LONG_MIN),
    };
    m.max(1) as u64
}

fn phase_key(phase: Phase) -> &'static str {
    match phase {
        Phase::Focus => "pomodoro.phase.focus",
        Phase::Short => "pomodoro.phase.short",
        Phase::Long => "pomodoro.phase.long",
    }
}

impl State {
    fn remaining(&self, now: Instant) -> u64 {
        if let (true, Some(end)) = (self.running, self.end) {
            end.since(now)
        } else {
            self.left_ms
        }
    }

    /// Keeps the state in a small file, so that switching the device off
    /// does not lose the round.
    fn save(&self, env: &Env<'_>) {
        let end = if self.running { self.end_unix.unwrap_or(0) } else { 0 };
        let text = format!(
            "{}|{}|{}|{}|{}|{}",
            phase_code(self.phase),
            u8::from(self.running),
            end,
            self.left_ms,
            self.done,
            self.total
        );
        let _ = env.storage().write(&save_path(), text.as_bytes());
    }

    /// Reads the kept state back at start-up. A round that was running
    /// goes on by the wall clock; one that ended meanwhile moves on.
    fn load(&mut self, env: &Env<'_>, out: &mut alloc::vec::Vec<Command>) {
        let mut buf = [0u8; 96];
        let Ok(n) = env.storage().read_at(&save_path(), 0, &mut buf) else {
            return;
        };
        let Ok(text) = core::str::from_utf8(&buf[..n]) else {
            return;
        };
        let mut parts = text.split('|').map(|p| p.trim().parse::<i64>().ok());
        let (Some(Some(phase)), Some(Some(running)), Some(Some(end)), Some(Some(left)), Some(Some(done)), Some(Some(total))) = (
            parts.next(),
            parts.next(),
            parts.next(),
            parts.next(),
            parts.next(),
            parts.next(),
        ) else {
            return;
        };
        self.phase = phase_from(phase);
        self.done = done.clamp(0, 99);
        self.total = total.clamp(0, 100_000) as u32;
        self.running = false;
        self.left_ms = (left.max(0) as u64).min(minutes(env, self.phase) * 60_000);
        if running == 1 && end > 0 {
            if let Some(now) = unix_seconds(env) {
                if end > now {
                    self.left_ms = ((end - now) as u64 * 1000)
                        .min(minutes(env, self.phase) * 60_000);
                    self.start(env, out);
                } else {
                    // Time ran out while the device was off.
                    self.running = true;
                    self.advance(env, true);
                }
            }
        }
    }

    fn start(&mut self, env: &Env<'_>, out: &mut alloc::vec::Vec<Command>) {
        if self.running {
            return;
        }
        self.running = true;
        let end = env.now().add_millis(self.left_ms);
        self.end = Some(end);
        self.end_unix = unix_seconds(env).map(|s| s + (self.left_ms / 1000) as i64);
        out.push(DONE.schedule((), end));
        self.save(env);
    }

    fn pause(&mut self, env: &Env<'_>, out: &mut alloc::vec::Vec<Command>) {
        if !self.running {
            return;
        }
        self.left_ms = self.remaining(env.now());
        self.running = false;
        out.push(Command::CancelWork(DONE.id().clone(), None));
        self.save(env);
    }

    fn enter(&mut self, env: &Env<'_>, phase: Phase) {
        self.phase = phase;
        self.running = false;
        self.left_ms = minutes(env, phase) * 60_000;
        self.save(env);
    }

    /// The phase after this one.
    fn next_phase(&self, env: &Env<'_>) -> Phase {
        match self.phase {
            Phase::Focus => {
                let every = env.settings().read(&ROUNDS).max(2);
                if self.done >= every {
                    Phase::Long
                } else {
                    Phase::Short
                }
            }
            _ => Phase::Focus,
        }
    }

    /// Finishes the phase (time is up, or skipped) and goes to the next.
    fn advance(&mut self, env: &Env<'_>, finished: bool) {
        if self.phase == Phase::Focus && finished {
            self.done += 1;
            self.total += 1;
        }
        let next = self.next_phase(env);
        if self.phase == Phase::Long {
            self.done = 0;
        }
        self.enter(env, next);
    }
}

#[derive(Debug, Default)]
pub struct Pomodoro;

static HOW: [(&str, &str); 2] = [
    ("en", include_str!("../how/en.txt")),
    ("de", include_str!("../how/de.txt")),
];

impl Plugin for Pomodoro {
    fn manifest(&self) -> Manifest {
        Manifest::new(PLUGIN, "Pomodoro", Version::new(0, 1, 0))
            .description("A Pomodoro timer for focused reading.")
            .how(&HOW)
            .uses(paperpaw_sdk::Uses::NOTHING)
    }

    fn register(&self, r: &mut Registrar) -> Result<(), RegisterError> {
        let shared: Shared = Rc::new(RefCell::new(State {
            phase: Phase::Focus,
            running: false,
            end: None,
            left_ms: 25 * 60_000,
            done: 0,
            total: 0,
            end_unix: None,
        }));

        // At start-up and on waking: go on where the saved state left off.
        let s = shared.clone();
        r.on(events::STARTED, move |env, _, out| {
            let mut cmds = alloc::vec::Vec::new();
            s.borrow_mut().load(env, &mut cmds);
            for c in cmds {
                out.push(c);
            }
        })?;
        let s = shared.clone();
        r.on(events::SUSPEND, move |env, _, _| {
            s.borrow().save(env);
        })?;

        r.add_action(ActionSpec::new(OPEN, "pomodoro.title").handler(|_, _, out| {
            out.push(Command::OpenScreen(SCREEN, Params::new()));
        }))?;
        r.add_menu_entry(MenuEntry::new(menus::TOOLS, OPEN).order(80))?;
        r.add_icon(OPEN, Cow::Borrowed("clock"))?;

        let made = shared.clone();
        r.add_screen(ScreenSpec::new(
            SCREEN,
            "pomodoro.title",
            Box::new(move |env, _| Box::new(PomodoroScreen::new(env, made.clone()))),
        ))?;

        // The time is up: tell the reader, go on to the next phase.
        let s = shared.clone();
        r.on(DONE, move |env, _, out| {
            let mut st = s.borrow_mut();
            if !st.running {
                return;
            }
            let was = st.phase;
            st.advance(env, true);
            let key = match was {
                Phase::Focus => "pomodoro.notice.break",
                _ => "pomodoro.notice.focus",
            };
            out.push(Command::Notify(String::from(env.i18n().tr(key))));
            // Breaks and the next round start by themselves only when asked.
            if env.settings().read(&AUTO) {
                let mut cmds = alloc::vec::Vec::new();
                st.start(env, &mut cmds);
                for c in cmds {
                    out.push(c);
                }
            }
        })?;

        r.add_setting_group(paperpaw_sdk::SettingGroupSpec::new(GROUP, "pomodoro.group"))?;
        r.add_icon(GROUP, Cow::Borrowed("clock"))?;
        r.add_settings(&SETTINGS)?;
        r.add_setting(SettingSpec::switch(
            AUTO.static_str(),
            "pomodoro.setting.auto",
            false,
            GROUP,
        ))?;
        r.add_setting(
            SettingSpec::switch(
                AWAKE.static_str(),
                "pomodoro.setting.awake",
                false,
                GROUP,
            )
            .description("pomodoro.setting.awake.help"),
        )?;

        // Minutes left, in any status line.
        let s = shared.clone();
        r.add_status_item(
            StatusItemSpec::new(
                Id::new_static("pomodoro.status"),
                "pomodoro.status",
                Box::new(move |env, _| {
                    let st = s.borrow();
                    if !st.running {
                        return None;
                    }
                    let mins = st.remaining(env.now()).div_ceil(60_000);
                    Some(StatusValue::Text(format!("{mins} min")))
                }),
            )
            .tap(OPEN),
        )?;

        r.add_translations(Cow::Borrowed(include_str!("../i18n/en.ini")))?;
        r.add_translations(Cow::Borrowed(include_str!("../i18n/de.ini")))?;
        Ok(())
    }
}

const AUTO: Setting<bool> = Setting::new("pomodoro.auto");
/// Keeps the screen on while a round runs and this screen is open.
const AWAKE: Setting<bool> = Setting::new("pomodoro.awake");

/// Milliseconds until the screen is drawn again: to the next full minute
/// of what is left (at least a second), every ten seconds in the last
/// minute.
fn next_tick(left_ms: u64) -> u64 {
    if left_ms <= 60_000 {
        10_000.min(left_ms.max(1000))
    } else {
        let into = left_ms % 60_000;
        if into == 0 { 60_000 } else { into }.max(1000)
    }
}

/// The seconds shown: whole minutes while more than a minute is left, in
/// steps of ten in the last one, so the clock never claims more than it
/// redraws.
fn shown_secs(left_ms: u64) -> u64 {
    let secs = left_ms.div_ceil(1000);
    if left_ms > 60_000 {
        secs.div_ceil(60) * 60
    } else {
        secs.div_ceil(10) * 10
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
    Main,
    Skip,
    Reset,
}

struct PomodoroScreen {
    style: UiStyle,
    header: ui::Header,
    shared: Shared,
    focus: Focus,
    buttons: [Rect; 3],
}

impl PomodoroScreen {
    fn new(env: &Env<'_>, shared: Shared) -> Self {
        let style = UiStyle::new(env);
        Self {
            header: ui::Header::new(env, &style, true),
            style,
            shared,
            focus: Focus::Main,
            buttons: [Rect::new(0, 0, 0, 0); 3],
        }
    }

    /// Asks for the next redraw as rarely as the display needs: once a
    /// minute, every ten seconds in the last minute. The device is never
    /// held awake; the phase's own timer ends it on time.
    fn tick(&self, cx: &mut Cx<'_, '_>) {
        let (running, left) = {
            let st = self.shared.borrow();
            (st.running, st.remaining(cx.env().now()))
        };
        if running {
            let now = cx.env().now();
            cx.wake_at(now.add_millis(next_tick(left)));
            // Only if the reader asked for it, and only while this screen
            // is open and the clock runs; the hold ends with the screen.
            if cx.env().settings().read(&AWAKE) {
                cx.keep_awake(WakeHold::new(
                    Id::new_static("pomodoro.awake"),
                    "pomodoro.setting.awake",
                ));
            }
        }
    }

    fn toggle(&mut self, cx: &mut Cx<'_, '_>) {
        let mut cmds = alloc::vec::Vec::new();
        {
            let mut st = self.shared.borrow_mut();
            if st.running {
                st.pause(cx.env(), &mut cmds);
            } else {
                st.start(cx.env(), &mut cmds);
                let note = cx.env().i18n().tr("pomodoro.notice.slow");
                cmds.push(Command::Notify(String::from(note)));
            }
        }
        for c in cmds {
            cx.command(c);
        }
        self.tick(cx);
        cx.request_redraw();
        cx.request_refresh(paperpaw_sdk::RefreshMode::Partial);
    }

    fn skip(&mut self, cx: &mut Cx<'_, '_>) {
        {
            let mut st = self.shared.borrow_mut();
            let was_running = st.running;
            st.advance(cx.env(), false);
            let _ = was_running;
        }
        cx.command(Command::CancelWork(DONE.id().clone(), None));
        self.shared.borrow().save(cx.env());
        cx.request_redraw();
        cx.request_refresh(paperpaw_sdk::RefreshMode::Partial);
    }

    fn reset(&mut self, cx: &mut Cx<'_, '_>) {
        {
            let mut st = self.shared.borrow_mut();
            st.done = 0;
            let env = cx.env();
            st.enter(env, Phase::Focus);
        }
        cx.command(Command::CancelWork(DONE.id().clone(), None));
        cx.request_redraw();
        cx.request_refresh(paperpaw_sdk::RefreshMode::Partial);
    }

    fn press(&mut self, which: Focus, cx: &mut Cx<'_, '_>) {
        match which {
            Focus::Main => self.toggle(cx),
            Focus::Skip => self.skip(cx),
            Focus::Reset => self.reset(cx),
        }
    }
}

impl Screen for PomodoroScreen {
    fn on_show(&mut self, cx: &mut Cx<'_, '_>) {
        self.tick(cx);
    }

    fn on_timer(&mut self, cx: &mut Cx<'_, '_>) {
        self.tick(cx);
        cx.request_redraw();
        cx.request_refresh(paperpaw_sdk::RefreshMode::Partial);
    }

    fn on_event(&mut self, _event: &Id, _params: &Params, cx: &mut Cx<'_, '_>) {
        // The phase may have ended while this screen is open.
        self.tick(cx);
        cx.request_redraw();
        cx.request_refresh(paperpaw_sdk::RefreshMode::Partial);
    }

    fn draw(&mut self, canvas: &mut Canvas<'_>, env: &Env<'_>) {
        let style = &self.style;
        let mut ui = ui::Ui::new(canvas, env, style);
        let i18n = env.i18n();
        self.header.draw(&mut ui, i18n.tr("pomodoro.title"), None, None);

        let display = env.device().display;
        let margin = style.margin();
        let top = self.header.area().height;
        let body = Rect::new(
            margin,
            top,
            display.width.saturating_sub(2 * margin),
            display.height.saturating_sub(top + margin),
        );

        let st = self.shared.borrow();
        let total_ms = minutes(env, st.phase) * 60_000;
        let left = st.remaining(env.now()).min(total_ms);
        let secs = shown_secs(left);
        let clock = format!("{:02}:{:02}", secs / 60, secs % 60);

        let mut stack = Stack::new(body, style.gap());
        let phase_area = stack.take(style.row());
        Label::new(i18n.tr(phase_key(st.phase)))
            .role(TextRole::Body)
            .align(Align::Center)
            .draw(&mut ui, phase_area);

        // The big clock.
        let clock_h = (body.height / 3).max(style.row());
        let clock_area = stack.take(clock_h);
        Label::new(&clock)
            .role(TextRole::Numeral)
            .min(12.0)
            .align(Align::Center)
            .draw(&mut ui, clock_area);

        // Progress of the phase.
        let bar_h = style.row() / 2;
        let bar = stack.take(bar_h.max(8));
        let permille = if total_ms == 0 {
            0
        } else {
            (1000 - (left * 1000 / total_ms).min(1000)) as u16
        };
        ui.progress(bar, permille, style.ink(Ink::Text));

        // Rounds of this set, as "● ● ○ ○", and the day's count.
        let every = env.settings().read(&ROUNDS).max(2);
        let dots_area = stack.take(style.row());
        let radius = (dots_area.height / 4).max(6) as i32;
        let step = radius * 3;
        let width = step * (every as i32 - 1);
        let cx0 = i32::from(dots_area.x) + i32::from(dots_area.width) / 2 - width / 2;
        let cy = i32::from(dots_area.y) + i32::from(dots_area.height) / 2;
        let ink = style.ink(Ink::Text);
        for n in 0..every {
            let c = paperpaw_sdk::Point::new(cx0 + step * n as i32, cy);
            if n < st.done {
                ui.canvas().fill_circle(c, radius as u32, ink);
            } else {
                ui.canvas().stroke_circle(c, radius as u32, 2, ink);
            }
        }
        let total_text = i18n.plural(
            "pomodoro.total",
            i64::from(st.total),
            &[("count", paperpaw_sdk::Arg::Int(i64::from(st.total)))],
        );
        let total_area = stack.take(style.row());
        Label::new(&total_text)
            .role(TextRole::Small)
            .align(Align::Center)
            .draw(&mut ui, total_area);

        // Buttons.
        let main_label = if st.running {
            i18n.tr("pomodoro.pause")
        } else {
            i18n.tr("pomodoro.start")
        };
        let labels = [main_label, i18n.tr("pomodoro.skip"), i18n.tr("pomodoro.reset")];
        let cells_h = ui::button_cells_height(env, style, body.width, &labels, style.gap());
        let area = stack.take_bottom(cells_h);
        let cells = ui::button_cells(env, style, area, &labels, style.gap());
        let shown = ui::focus_shown(env);
        let order = [Focus::Main, Focus::Skip, Focus::Reset];
        for (i, cell) in cells.iter().enumerate().take(3) {
            self.buttons[i] = *cell;
            ui.button(*cell, labels[i], i == 0, shown && self.focus == order[i]);
        }
    }

    fn on_action(&mut self, action: &Id, cx: &mut Cx<'_, '_>) -> Handled {
        let order = [Focus::Main, Focus::Skip, Focus::Reset];
        let at = order.iter().position(|f| *f == self.focus).unwrap_or(0);
        if *action == actions::CONFIRM {
            let f = self.focus;
            self.press(f, cx);
            return Handled::Yes;
        }
        if *action == actions::LEFT || *action == actions::UP {
            self.focus = order[(at + 2) % 3];
            cx.request_redraw();
            return Handled::Yes;
        }
        if *action == actions::RIGHT || *action == actions::DOWN {
            self.focus = order[(at + 1) % 3];
            cx.request_redraw();
            return Handled::Yes;
        }
        if *action == actions::BACK {
            cx.command(Command::Back);
            return Handled::Yes;
        }
        Handled::No
    }

    fn on_gesture(&mut self, gesture: Gesture, cx: &mut Cx<'_, '_>) -> Handled {
        if gesture.kind != GestureKind::Tap {
            return Handled::No;
        }
        let order = [Focus::Main, Focus::Skip, Focus::Reset];
        for (i, rect) in self.buttons.iter().enumerate() {
            if rect.contains(gesture.start) {
                self.focus = order[i];
                self.press(order[i], cx);
                return Handled::Yes;
            }
        }
        if let Some(action) = self.header.tap(cx.env(), &self.style, gesture.start, None) {
            cx.command(Command::run(action));
            return Handled::Yes;
        }
        Handled::No
    }
}