Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

66 changed files with 1287 additions and 7933 deletions

View File

@ -12,6 +12,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Check formatting - name: Check formatting
run: nix develop --command cargo fmt --all -- --check run: nix develop --command cargo fmt --all -- --check
- name: Run clippy - name: Run clippy
@ -24,6 +25,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Run tests - name: Run tests
run: nix develop --command cargo test run: nix develop --command cargo test
@ -32,5 +34,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build project - name: Build project
run: nix build .#timr run: nix build .#timr

View File

@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- "release/**" - "release/**"
tags:
- "v*"
jobs: jobs:
get-version: get-version:
@ -29,7 +31,7 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
os_target: linux os_target: linux
binary_name: timr-tui binary_name: timr-tui
arch: x86_64 # based on target 'x86_64-unknown-linux-musl' defined by `CARGO_BUILD_TARGET` in flake.nix arch: x86_64 # `x86_64` by default
- os: ubuntu-latest - os: ubuntu-latest
os_target: windows os_target: windows
binary_name: timr-tui.exe binary_name: timr-tui.exe
@ -41,17 +43,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build (windows) - name: Build (windows)
if: matrix.os_target == 'windows' if: matrix.os_target == 'windows'
run: nix build .#windows run: nix build .#windows
- name: Build (linux) - name: Build (linux/macos)
if: matrix.os_target == 'linux' if: matrix.os_target != 'windows'
run: nix build .#linuxStatic
- name: Build (macos)
if: matrix.os_target == 'macos'
run: nix build run: nix build
- name: Copy artifact - name: Copy artifact

7
.gitignore vendored
View File

@ -18,10 +18,3 @@ result/**/*
#.idea/ #.idea/
# #
.direnv .direnv
# ignore (possible) sound files
**/*.{mp3,wav}
CLAUDE.md
.claude

View File

@ -1,2 +1 @@
style_edition = "2024"
reorder_imports = true reorder_imports = true

30
.zed/settings.json Normal file
View File

@ -0,0 +1,30 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"format_on_save": "on",
"formatter": "language_server",
"lsp": {
"rust-analyzer": {
"initialization_options": {
"check": {
"command": "clippy" // rust-analyzer.check.command (default: "check")
}
}
}
},
"languages": {
"Nix": {
"formatter": {
"external": {
"command": "alejandra",
"arguments": [
"-q"
]
}
},
"format_on_save": "on"
}
}
}

View File

@ -1,24 +0,0 @@
# About
`timr-tui` is a TUI to maintain productivity and focus by providing different timers: Pomodoro, Countdown, Timer, Events.
Built with Rust using `Ratatui` as the main library.
# Development, Build, Tests
Check [README](./README.md) chapter `Development` to get all information about how to run, build, test the app.
# Code Guidelines
- Idiomatic Rust everywhere
- DRY whenever it makes sense
- Rare or no comments are preferred instead of commenting everything which the code already describes
- Keep tests compact and simple
# Agent Guidelines
- Keep your answers compact, but explicit. An user will ask if something is missing.
- For complex tasks provide a plan.
- Structure plans as small as possible.
- Solve complex problems step by step, never all at once.
- Act as a pair programmer, not as a vibe-coding provider. That's an user should guide you, not the opposite. Always ask if something is not clear to you.

View File

@ -1,144 +1,5 @@
# Changelog # Changelog
## [unreleased]
### Misc.
- (deps) Rust 1.91.0 [#140](https://github.com/sectore/timr-tui/pull/140)
## v1.6.1 - 2025-10-29
### Fix
- (event) Ignore all key events except `KeyEventKind::Press` [#137](https://github.com/sectore/timr-tui/issues/137)
### Misc.
- (docs) Update all demos [#135](https://github.com/sectore/timr-tui/pull/135), [513f1fe](https://github.com/sectore/timr-tui/commit/513f1fec11ab8bdad46ca565b0c3f08ed37d6219)
## v1.6.0 - 2025-10-16
### Features
- (event) New `event` screen to count custom date times in the future or past. [#117](https://github.com/sectore/timr-tui/pull/117), [#120](https://github.com/sectore/timr-tui/pull/120), [#122](https://github.com/sectore/timr-tui/pull/122), [#123](https://github.com/sectore/timr-tui/pull/123), [#124](https://github.com/sectore/timr-tui/pull/124), [#125](https://github.com/sectore/timr-tui/pull/125), [#129](https://github.com/sectore/timr-tui/pull/129), [#130](https://github.com/sectore/timr-tui/pull/130), [#131](https://github.com/sectore/timr-tui/pull/131), [#132](https://github.com/sectore/timr-tui/pull/132)
- (keybindings) Switch screens by `←` or `→` keys [#127](https://github.com/sectore/timr-tui/pull/127)
- (duration) Inrease `MAX_DURATION` to `9999y 364d 23:59:59.9` [#128](https://github.com/sectore/timr-tui/pull/128)
### Breaking change
- (pomodoro)! New keybindings `ctrl+←` or `ctrl+→` to switch `work`/`pause` [#127](https://github.com/sectore/timr-tui/pull/127)
- (keybindings)! Change keys for `screens` [#126](https://github.com/sectore/timr-tui/pull/126)
- (cli)! Remove `--countdown-target` argument [#121](https://github.com/sectore/timr-tui/pull/121)
### Misc.
- Add `AGENTS.md` [#133](https://github.com/sectore/timr-tui/pull/133)
## v1.5.0 - 2025-10-03
### Features
- (cli) Accept `years` and `days` for `--countdown` argument [#114](https://github.com/sectore/timr-tui/pull/114)
- (cli) New `--countdown-target` argument to parse `countdown` values by given time in the future or past [#112](https://github.com/sectore/timr-tui/pull/112)
- (localtime) Show `date` [#111](https://github.com/sectore/timr-tui/pull/111)
- (edit) Change any value by `10x` up or down [#110](https://github.com/sectore/timr-tui/pull/110)
- (timer/countdown): Support `days` and `years` up to `999y 364d 23:59:59` [#96](https://github.com/sectore/timr-tui/pull/96)
### Fix
- (edit) Auto jump to next possible value while decreasing, but ignoring `zero` values [#109](https://github.com/sectore/timr-tui/pull/109)
- (format) Improve format handling + fix `days` (no zero-padding) [#107](https://github.com/sectore/timr-tui/pull/107)
### Misc.
- (deps) Upgrade dependencies [#113](https://github.com/sectore/timr-tui/pull/113)
- (deps) Rust 1.90.0 [#95](https://github.com/sectore/timr-tui/pull/95)
- (guide) Add contributing guidelines [#94](https://github.com/sectore/timr-tui/pull/94)
## v1.4.0 - 2025-09-02
### Features
- (screen) Local Time [#89](https://github.com/sectore/timr-tui/pull/89), [#90](https://github.com/sectore/timr-tui/pull/90), [#91](https://github.com/sectore/timr-tui/pull/91)
### Misc.
- (deps) Rust 1.89.0 [#87](https://github.com/sectore/timr-tui/pull/87)
## v1.3.1 - 2025-07-03
### Features
- (args) set `content` by given duration [#81](https://github.com/sectore/timr-tui/pull/81)
### Fixes
- (pomodoro) `ctrl+r` resets rounds AND both clocks [#83](https://github.com/sectore/timr-tui/pull/83)
- (pomodoro) reset active clock only [#82](https://github.com/sectore/timr-tui/pull/82)
### Misc.
- (deps) Rust 1.88.0 [#85](https://github.com/sectore/timr-tui/pull/85)
## v1.3.0 - 2025-05-06
###
- (pomodoro) Count WORK rounds [#75](https://github.com/sectore/timr-tui/pull/75), [6b068bb](https://github.com/sectore/timr-tui/commit/6b068bbd094d9ec1a36b47598fadfc71296d9590)
- (pomodoro/countdown) Change initial value [#79](https://github.com/sectore/timr-tui/pull/79), [aae5c38](https://github.com/sectore/timr-tui/commit/aae5c38cd6a666d5ba418b12fb67879a2146b9a2)
### Changes
- Update keybindings [#76](https://github.com/sectore/timr-tui/pull/76)
### Misc.
- (flake) use alsa-lib-with-plugins [#77](https://github.com/sectore/timr-tui/pull/77)
- (readme) add keybindings + toc [#78](https://github.com/sectore/timr-tui/pull/78)
## v1.2.1 - 2025-04-17
### Fixes
- (countdown) Reset `Mission Elapsed Time (MET)` if `countdown` is set by _cli arguments_ [#71](https://github.com/sectore/timr-tui/pull/71)
- (countdown) Reset `Mission Elapsed Time (MET)` while setting `countdown` by _local time_ [#72](https://github.com/sectore/timr-tui/pull/72)
### Misc.
- (deps) Use latest `Rust 1.86` [#73](https://github.com/sectore/timr-tui/pull/73)
- (cargo) Exclude files for packaging [e7a5a1b](https://github.com/sectore/timr-tui/commit/e7a5a1b2da7a7967f2602a0b92f391ac768ca638)
- (just) `group` commands [#70](https://github.com/sectore/timr-tui/pull/70)
## v1.2.0 - 2025-02-26
### Features
- (notification) Clock animation (blink) by reaching `done` mode (optional) [#65](https://github.com/sectore/timr-tui/pull/65)
- (notification) Native desktop notification (optional, experimental) [#59](https://github.com/sectore/timr-tui/pull/59)
- (notification) Sound notification (optional, experimental, available in local build only) [#62](https://github.com/sectore/timr-tui/pull/62)
- (logging) Add `--log` arg to enable logs [e094d7d](https://github.com/sectore/timr-tui/commit/e094d7d81b99f58f0d3bc50124859a4e1f6dbe4f)
### Misc.
- (refactor) Extend event handling for using a `mpsc` channel to send `AppEvent`'s from anywhere. [#61](https://github.com/sectore/timr-tui/pull/61)
- (extension) Use `set_panic_hook` for better error handling [#67](https://github.com/sectore/timr-tui/pull/67)
- (deps) Use latest `Rust 1.85` and `Rust 2024 Edition`. Refactor `flake` to consider `rust-toolchain.toml` etc. [#68](https://github.com/sectore/timr-tui/pull/68)
## v1.1.0 - 2025-01-22
### Features
- (countdown) Edit countdown by local time [#49](https://github.com/sectore/timr-tui/pull/49)
### Fixes
- (ci) Build statically linked binaries for Linux [#55](https://github.com/sectore/timr-tui/pull/55)
- (ci) Remove magic nix cache action (#57) [#56](https://github.com/sectore/timr-tui/issues/56)
### Misc.
- (deps) Latest Rust 1.84, update deps [#48](https://github.com/sectore/timr-tui/pull/48)
## v1.0.0 - 2025-01-10 ## v1.0.0 - 2025-01-10
Happy `v1.0.0` 🎉 Happy `v1.0.0` 🎉

View File

@ -1,18 +0,0 @@
# Contributing
Any feedback / contribution are welcome. Just open an `issue`, a `PR` or start a `discussion`.
## Code style / conventions
- Try to write [clean, idiomatic Rust code](https://github.com/mre/idiomatic-rust).
- Keep code [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) whenever it makes sense.
- Before pushing any code make sure to run `clippy` and `fmt`. Check provided [`just`](./jusfile) file to run such commands, [CI](https://github.com/sectore/timr-tui/blob/main/.github/workflows/ci.yml) will do the same.
- Have fun to write code.
## Design files
Use [Figma Design file](https://www.figma.com/community/file/1553076532392275586/timr-tui) to suggest design changes
## AI
Always understand what AI provides to you. Never push any code based on [`vibe coding`](https://en.wikipedia.org/wiki/Vibe_coding) you or anybody else can't follow. Make sure your agent still follows all code styles and conventions suggested above. Use AI for better, not for worse code.

2050
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,29 @@
[package] [package]
name = "timr-tui" name = "timr-tui"
version = "1.6.1" version = "1.0.0"
description = "TUI to organize your time: Pomodoro, Countdown, Timer." description = "TUI to organize your time: Pomodoro, Countdown, Timer."
edition = "2024" edition = "2021"
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`. rust-version = "1.82.0"
rust-version = "1.91.0"
homepage = "https://github.com/sectore/timr-tui" homepage = "https://github.com/sectore/timr-tui"
repository = "https://github.com/sectore/timr-tui" repository = "https://github.com/sectore/timr-tui"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
keywords = ["tui", "timer", "countdown", "pomodoro"] keywords = ["tui", "timer", "countdown", "pomodoro"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
exclude = [
".github/*",
"demo/*.tape",
"result/*",
"*.mp3",
".claude",
"CLAUDE.md",
]
[dependencies] [dependencies]
ratatui = "0.29.0" ratatui = "0.29.0"
crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } crossterm = {version = "0.28.1", features = ["event-stream", "serde"] }
color-eyre = "0.6.5" color-eyre = "0.6.2"
futures = "0.3" futures = "0.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
strum = { version = "0.26.3", features = ["derive"] } strum = { version = "0.26.3", features = ["derive"] }
tokio = { version = "1.47.1", features = ["full"] } tokio = { version = "1.41.1", features = ["full"] }
tokio-stream = "0.1.17" tokio-stream = "0.1.16"
tokio-util = "0.7.16" tokio-util = "0.7.12"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
directories = "5.0.1" directories = "5.0.1"
clap = { version = "4.5.48", features = ["derive"] } clap = { version = "4.5.23", features = ["derive"] }
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros", "serde"] } time = { version = "0.3.37", features = ["formatting", "local-offset"] }
notify-rust = "4.11.7"
rodio = { version = "0.20.1", features = [
"symphonia-mp3",
"symphonia-wav",
], default-features = false, optional = true }
thiserror = { version = "2.0.17", optional = true }
tui-input = "0.14.0"
[features]
sound = ["dep:rodio", "dep:thiserror"]

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024-2025 Jens Krause Copyright (c) 2024 Jens K.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

243
README.md
View File

@ -1,29 +1,16 @@
# timr-tui # timr-tui
TUI to organize your time: Pomodoro, Countdown, Timer, Event. TUI to organize your time: Pomodoro, Countdown, Timer.
- `[1] countdown` Use it for your workout, yoga/breathing sessions, meditation, handstand or whatever. - `[t]imer` Check the time on anything you are you doing.
- `[2] timer` Check the time on anything you are you doing. - `[c]ountdown` Use it for your workout, yoga session, meditation, handstand or whatever.
- `[3] pomodoro` Organize your working time to be focused all the time by following the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique). - `[p]omodoro` Organize your working time to be focused all the time by following the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique).
- `[4] event` Count the time for any event in the future or past.
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/). Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
# Features
# Table of Contents _Side note:_ Theme colors depend on your terminal preferences.
- [Preview](./#preview)
- [CLI](./#cli)
- [Keybindings](./#keybindings)
- [Installation](./#installation)
- [Development](./#development)
- [Misc](./#misc)
- [Contributing](./#contributing)
- [License](./#license)
# Preview
_(theme depends on your terminal preferences)_
## Pomodoro ## Pomodoro
@ -37,28 +24,16 @@ _(theme depends on your terminal preferences)_
<img alt="pomodoro" src="demo/timer.gif" /> <img alt="pomodoro" src="demo/timer.gif" />
</a> </a>
## Countdown (*incl. [Mission Elapsed Time](https://en.wikipedia.org/wiki/Mission_Elapsed_Time)*) ## Countdown
<a href="demo/countdown.gif"> <a href="demo/countdown.gif">
<img alt="countdown" src="demo/countdown.gif" /> <img alt="countdown" src="demo/countdown.gif" />
</a> </a>
## Event (*past/future*) ## Change style
<a href="demo/event.gif"> <a href="demo/style.gif">
<img alt="event" src="demo/event.gif" /> <img alt="style" src="demo/style.gif" />
</a>
## Local time
<a href="demo/local-time.gif">
<img alt="local time" src="demo/local-time.gif" />
</a>
## Local time (*footer*)
<a href="demo/local-time-footer.gif">
<img alt="local time at footer" src="demo/local-time-footer.gif" />
</a> </a>
## Toggle deciseconds ## Toggle deciseconds
@ -67,25 +42,24 @@ _(theme depends on your terminal preferences)_
<img alt="deciseconds" src="demo/decis.gif" /> <img alt="deciseconds" src="demo/decis.gif" />
</a> </a>
## Maximum (*`9999y`* *`364d`* *`23:59:59.9`*)
<a href="demo/timer-max.png">
<img alt="maximum" src="demo/timer-max.png" />
</a>
## Change style
<a href="demo/style.gif">
<img alt="style" src="demo/style.gif" />
</a>
## Menu ## Menu
<a href="demo/menu.gif"> <a href="demo/menu.gif">
<img alt="menu" src="demo/menu.gif" /> <img alt="menu" src="demo/menu.gif" />
</a> </a>
## Local time
<a href="demo/local-time.gif">
<img alt="menu" src="demo/local-time.gif" />
</a>
## Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
<a href="demo/countdown-met.gif">
<img alt="menu" src="demo/countdown-met.gif" />
</a>
# CLI # CLI
```sh ```sh
@ -94,100 +68,18 @@ timr-tui --help
Usage: timr-tui [OPTIONS] Usage: timr-tui [OPTIONS]
Options: Options:
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'. -c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 10:00]
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss' -w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 25:00]
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss' -p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 5:00]
-e, --event <EVENT> Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'. -d, --decis Wether to show deciseconds or not. [default: false]
-d, --decis Show deciseconds. -m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro] [default: timer]
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro, event, localtime] --menu Whether to open the menu or not.
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille] -s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille] [default: full]
--menu Open menu. -r, --reset Reset stored values to default.
-r, --reset Reset stored values to defaults. -h, --help Print help
-n, --notification <NOTIFICATION> Toggle desktop notifications. Experimental. [possible values: on, off] -V, --version Print version
--blink <BLINK> Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
--log [<LOG>] Directory for log file. If not set, standard application log directory is used (check README for details).
-h, --help Print help
-V, --version Print version
``` ```
Extra option (if `--features sound` is enabled by local build only):
```sh
--sound <SOUND> Path to sound file (.mp3 or .wav) to play as notification. Experimental.
```
# Keybindings
## Menu
| Key | Description |
| --- | --- |
| <kbd></kbd> / <kbd></kbd> or <kbd>m</kbd> | Toggle menu |
## Screens
| Key | Description |
| --- | --- |
| <kbd>1</kbd> | Pomodoro |
| <kbd>2</kbd> | Countdown |
| <kbd>3</kbd> | Timer |
| <kbd>4</kbd> | Event |
| <kbd>0</kbd> | Local Time |
| <kbd></kbd> | next screen |
| <kbd></kbd> | previous screen |
## Controls
| Key | Description |
| --- | --- |
| <kbd>s</kbd> | start |
| <kbd>r</kbd> | reset |
| <kbd>e</kbd> | enter edit mode |
| <kbd>q</kbd> | quit |
**In `edit` mode only:**
| Key | Description |
| --- | --- |
| <kbd>s</kbd> | save changes |
| <kbd>Esc</kbd> | skip changes |
| <kbd></kbd> or <kbd></kbd> | change selection |
| <kbd></kbd> | edit to go up |
| <kbd>ctrl+↑</kbd> | edit to go up 10x |
| <kbd></kbd> | edit to go down |
| <kbd>ctrl+↓</kbd> | edit to go down 10x |
**In `Event` `edit` mode only:**
| Key | Description |
| --- | --- |
| <kbd>Enter</kbd> | save changes |
| <kbd>Esc</kbd> | skip changes |
| <kbd>Tab</kbd> | switch input |
**In `Pomodoro` screen only:**
| Key | Description |
| --- | --- |
| <kbd>ctrl+←</kbd> or <kbd>ctrl+→</kbd> | switch work/pause |
| <kbd>ctrl+r</kbd> | reset round |
| <kbd>ctrl+s</kbd> | save initial value |
**In `Countdown` screen only:**
| Key | Description |
| --- | --- |
| <kbd>ctrl+e</kbd> | edit by local time |
| <kbd>ctrl+s</kbd> | save initial value |
## Appearance
| Key | Description |
| --- | --- |
| <kbd>,</kbd> | toggle styles |
| <kbd>.</kbd> | toggle deciseconds |
| <kbd>:</kbd> | toggle local time |
# Installation # Installation
## Cargo ## Cargo
@ -212,10 +104,12 @@ Install [from the AUR](https://aur.archlinux.org/packages/timr/):
paru -S timr paru -S timr
``` ```
## Release binaries ## Release binaries
Pre-built artifacts are available to download from [latest GitHub release](https://github.com/sectore/timr-tui/releases). Pre-built artifacts are available to download from [latest GitHub release](https://github.com/sectore/timr-tui/releases).
# Development # Development
## Requirements ## Requirements
@ -226,6 +120,7 @@ Pre-built artifacts are available to download from [latest GitHub release](https
If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to install dependencies. In other case run `nix develop`. If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to install dependencies. In other case run `nix develop`.
### Non Nix users ### Non Nix users
- [`Rust`](https://www.rust-lang.org/learn/get-started) - [`Rust`](https://www.rust-lang.org/learn/get-started)
@ -236,64 +131,34 @@ If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to
### Commands ### Commands
```sh ```sh
just just --list
Available recipes: Available recipes:
default # list commands build # build app
b # alias for `build`
[build] default
build # build app [alias: b] format # format files
f # alias for `format`
[demo] lint # lint
demo-blink # build demo: blink animation [alias: db] l # alias for `lint`
demo-countdown # build demo: countdown [alias: dc] run # run app
demo-countdown-met # build demo: countdown + met [alias: dcm] r # alias for `run`
demo-decis # build demo: deciseconds [alias: dd] test # run tests
demo-event # build demo: event [alias: de] t # alias for `test`
demo-local-time # build demo: local time [alias: dlt]
demo-local-time-footer # build demo: local time (footer) [alias: dltf]
demo-menu # build demo: menu [alias: dm]
demo-pomodoro # build demo: pomodoro [alias: dp]
demo-rocket-countdown # build demo: rocket countdown [alias: drc]
demo-style # build demo: styles [alias: ds]
demo-timer # build demo: timer [alias: dt]
[dev]
run # run app [alias: r]
run-args args # run app with arguments. It expects arguments as a string (e.g. "-c 5:00"). [alias: ra]
run-sound path # run app while sound feature is enabled. It expects a path to a sound file. [alias: rs]
run-sound-args path args # run app while sound feature is enabled by adding a path to a sound file and other arguments as string (e.g. "-c 5:00"). [alias: rsa]
[misc]
format # format files [alias: f]
lint # lint [alias: l]
[test]
test # run tests [alias: t]
``` ```
### Build ### Build
- Linux - Linux
```sh ```sh
nix build nix build
# or for bulding w/ statically linked binaries
nix build .#linuxStatic
``` ```
- Windows (cross-compilation) - Windows (cross-compilation)
```sh ```sh
nix build .#windows nix build .#windows
``` ```
### Run tests
```sh
cargo test
```
# Misc. # Misc.
## Persistant app state ## Persistant app state
@ -311,9 +176,7 @@ C:/Users/{user}/AppData/Local/timr-tui/data/app.data
## Logs ## Logs
To get log output, start the app by passing `--log` to `timr-tui`. See [CLI](./#cli) for details. In `debug` mode only. Locations:
Logs will be stored in an `app.log` file at following locations:
```sh ```sh
# Linux # Linux
@ -323,13 +186,3 @@ Logs will be stored in an `app.log` file at following locations:
# `Windows` # `Windows`
C:/Users/{user}/AppData/Local/timr-tui/logs/app.log C:/Users/{user}/AppData/Local/timr-tui/logs/app.log
``` ```
Optional: You can use a custom directory by passing it via `--log` arg.
# Contributing
[CONTRIBUTING.md](./CONTRIBUTING.md)
# License
[MIT License](./LICENSE)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1,23 +0,0 @@
Output demo/blink.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "nord-light"
Set FontSize 14
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
# countdown 1.0s
Type "cargo run -- -r -m countdown -d -c 1 --blink=on"
Enter
Sleep 0.2
Type "m"
Type ":::"
Show
Type "s"
Sleep 4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,19 +0,0 @@
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "Retro"
Set FontSize 14
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Type 'cargo run -- -r -d -c "10000y"'
Enter
Sleep .2
Type "m"
# --- SCREENSHOT ---
Sleep 1s
Screenshot demo/countdown-max.png
Sleep 1s

BIN
demo/countdown-met.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

22
demo/countdown-met.tape Normal file
View File

@ -0,0 +1,22 @@
Output demo/countdown-met.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "iceberg-light"
Set FontSize 14
Set Width 800
Set Height 400
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -m c -c 3"
Enter
Sleep 0.2
Show
Type "s"
Sleep 6
Type "r"
Sleep 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -1,24 +1,23 @@
Output demo/countdown.gif Output demo/countdown.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md # https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "iceberg-light" Set Theme "Builtin Solarized Light"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
# --- START --- # --- START ---
Set LoopOffset 4 Set LoopOffset 4
Hide Hide
Type "cargo run -- -r -d -c 10:00" Type "cargo run -- -r -d -m c"
Enter Enter
Sleep .2 Sleep 0.2
Type "m" # hide menu
Show Show
# --- COUNTDOWN --- # --- COUNTDOWN ---
Sleep .5 Sleep 1
Type "s" Type "s"
Sleep 1.4 Sleep 1.4
Type "s" Type "s"
@ -29,5 +28,6 @@ Type "e"
Sleep 0.1 Sleep 0.1
Down@10ms 65 Down@10ms 65
Sleep 0.1 Sleep 0.1
Type "s" Type "e"
Sleep 0.1
Sleep 3 Sleep 3

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -4,8 +4,8 @@ Output demo/decis.gif
Set Theme "nord-light" Set Theme "nord-light"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
@ -14,8 +14,7 @@ Set LoopOffset 4
Hide Hide
Type "cargo run -- -r -m t" Type "cargo run -- -r -m t"
Enter Enter
Sleep .2 Sleep 0.2
Type "m" # hide menu
Show Show
# --- STYLES --- # --- STYLES ---
Type "s" Type "s"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

View File

@ -1,31 +0,0 @@
Output demo/event.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "Builtin Solarized Dark"
Set FontSize 14
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -e 'time=2010-01-10 10:00:00,title=hello world'"
Enter
Type "m"
Sleep 0.2
Show
# --- EVENT ---
Sleep 1
Type "e"
Backspace@1ms 17
Type@20ms "50-01-01 01:00:01"
Enter
Type "e"
Tab
Backspace@10ms 11
Type@20ms "hello future"
Enter
Sleep 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,21 +0,0 @@
Output demo/local-time-footer.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "AtomOneLight"
Set FontSize 14
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -m c"
Enter
Sleep 0.2
Type "m" # hide menu
Show
# --- toggle local time ---
Type@1s ":::"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,21 +1,22 @@
Output demo/local-time.gif Output demo/local-time.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md # https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "Atom" Set Theme "AtomOneLight"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
# --- START --- # --- START ---
Set LoopOffset 4 Set LoopOffset 4
Hide Hide
Type "cargo run -- -m l" Type "cargo run -- -m c"
Enter Enter
Sleep .2 Sleep 0.2
Type "m" # hide menu
Show Show
Sleep 1
# --- toggle local time --- # --- toggle local time ---
Type@1s ":::" Type@1.5s ":::"
Sleep 1.5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -4,24 +4,27 @@ Output demo/menu.gif
Set Theme "Apple Classic" Set Theme "Apple Classic"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
# --- START --- # --- START ---
Set LoopOffset 4 Set LoopOffset 4
Hide Hide
Type "cargo run -- -r -m c" Type "cargo run -- -r -m p"
Enter Enter
Type@200ms "m" # hide menu Sleep 0.2
Show Show
# --- STYLES --- # --- STYLES ---
Sleep 0.3s Sleep 0.5
Type@0.3s "m" # show menu Type "m"
Type@0.3s "2" Sleep 0.5
Type@0.3s "3" Type@0.5s "t"
Type@0.3s "e" Type@0.5s "c"
Escape@0.3s Type@0.5s "p"
Type@0.3s "4" Type@0.5s "e"
Type@0.3s "0" Right@0.5s
Left@0.5s
Type@0.5s "e"
Sleep 0.5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 166 KiB

View File

@ -1,35 +1,33 @@
# Note: PR "support ctrl + arrow keys" https://github.com/charmbracelet/vhs/pull/673 needs to be merged to run this `tape`.
Output demo/pomodoro.gif Output demo/pomodoro.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md # https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "Catppuccin Frappe" Set Theme "Catppuccin Frappe"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
# --- START --- # --- START ---
Set LoopOffset 4
Hide Hide
Type "cargo run -- -r -d -m p --blink on" Type "cargo run -- -r -d -m p"
Enter Enter
Sleep .2 Sleep 0.2
Type "m" # hide menu
Show Show
# --- POMODORO WORK --- # --- POMODORO WORK ---
Sleep .5 Sleep 1
Type "s" # start Type "s"
Sleep 2.3 Sleep 2.3
Type "e" Type "e"
Sleep 0.2 Sleep 0.2
Down@30ms 80 Down@30ms 80
Sleep 100ms Sleep 100ms
Type "s" # save Type "e"
Sleep 4 Sleep 3
# --- POMODORO PAUSE --- # --- POMODORO PAUSE ---
Ctrl+Right Right
Sleep 0.5 Sleep 0.5
Type "s" Type "s"
Sleep 2.3 Sleep 2.3
@ -37,5 +35,5 @@ Type "e"
Sleep 0.2 Sleep 0.2
Down@30ms 60 Down@30ms 60
Sleep 100ms Sleep 100ms
Type "s" # save Type "e"
Sleep 4 Sleep 3

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -4,8 +4,8 @@ Output demo/style.gif
Set Theme "OneDark" Set Theme "OneDark"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
@ -15,7 +15,6 @@ Hide
Type "cargo run -- -r -d -m c" Type "cargo run -- -r -d -m c"
Enter Enter
Sleep 0.2 Sleep 0.2
Type "m" # hide menu
Show Show
# --- STYLES --- # --- STYLES ---
Sleep 0.5 Sleep 0.5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,37 +0,0 @@
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "SeaShells"
Set FontSize 14
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Type 'cargo run -- -r -m t'
Enter
Type "m"
Type "e"
Up@1ms 60 # ss
Left
Up@1ms 60 # mm
Left
Up@1ms 23 # hh
Left
Up@1ms 363 # ddd
Left
Up@1ms 9999 # yyyy
Right 4
Down # ss
Left
Down ## mm
Left 2
Down ## ddd
Up 2
Type "."
Type "s" # save
Type "s" # start to reach DONE
Sleep 2s
# --- SCREENSHOT ---
Screenshot demo/timer-max.png
Sleep 1s

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -4,8 +4,8 @@ Output demo/timer.gif
Set Theme "Belafonte Day" Set Theme "Belafonte Day"
Set FontSize 14 Set FontSize 14
Set Width 1000 Set Width 800
Set Height 500 Set Height 400
Set Padding 0 Set Padding 0
Set Margin 1 Set Margin 1
@ -15,7 +15,6 @@ Hide
Type "cargo run -- -r -d -m t" Type "cargo run -- -r -d -m t"
Enter Enter
Sleep 0.2 Sleep 0.2
Type "m" # hide menu
Show Show
# --- TIMER --- # --- TIMER ---
Type "s" Type "s"
@ -28,5 +27,5 @@ Type "e"
Sleep 0.2 Sleep 0.2
Up@30ms 57 Up@30ms 57
Sleep 0.7 Sleep 0.7
Type "s" Type "e"
Sleep 4 Sleep 4

24
flake.lock generated
View File

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1760924934, "lastModified": 1733286231,
"narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=", "narHash": "sha256-mlIDSv1/jqWnH8JTiOV7GMUNPCXL25+6jmD+7hdxx5o=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "c6b4d5308293d0d04fcfeee92705017537cad02f", "rev": "af1556ecda8bcf305820f68ec2f9d77b41d9cc80",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -23,11 +23,11 @@
"rust-analyzer-src": "rust-analyzer-src" "rust-analyzer-src": "rust-analyzer-src"
}, },
"locked": { "locked": {
"lastModified": 1762065744, "lastModified": 1732689334,
"narHash": "sha256-c04mxJoCb8f6BBrdaREWmdQq+pfp395olXhC+B0G7DI=", "narHash": "sha256-yKI1KiZ0+bvDvfPTQ1ZT3oP/nIu3jPYm4dnbRd6hYg4=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "e0f24085a4a0da1c32adc308ec4c518ae886ff35", "rev": "a8a983027ca02b363dfc82fbe3f7d9548a8d3dce",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -56,11 +56,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1761907660, "lastModified": 1733212471,
"narHash": "sha256-kJ8lIZsiPOmbkJypG+B5sReDXSD1KGu2VEPNqhRa/ew=", "narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2fb006b87f04c4d3bdf08cfdbc7fab9c13d94a15", "rev": "55d15ad12a74eb7d4646254e13638ad0c4128776",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -81,11 +81,11 @@
"rust-analyzer-src": { "rust-analyzer-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1762016333, "lastModified": 1732633904,
"narHash": "sha256-PT8hXDYyeRjh9BGyLF/nZWm9TqRwP2EzeKuqUFH0M3w=", "narHash": "sha256-7VKcoLug9nbAN2txqVksWHHJplqK9Ou8dXjIZAIYSGc=",
"owner": "rust-lang", "owner": "rust-lang",
"repo": "rust-analyzer", "repo": "rust-analyzer",
"rev": "fca718c0f2074bdccf9a996bb37b0fcaff80dc97", "rev": "8d5e91c94f80c257ce6dbdfba7bd63a5e8a03fa6",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -1,5 +1,7 @@
{ {
inputs = { inputs = {
# Disable `nixos-unstable` for now, it introduced some `VScode` related errors:
# error: function 'buildVscodeExtension' called without required argument 'pname'
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
crane.url = "github:ipetkov/crane"; crane.url = "github:ipetkov/crane";
@ -18,40 +20,34 @@
}: }:
flake-utils.lib.eachDefaultSystem (system: let flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
# Using stable toolchain as base
toolchain = toolchain = with fenix.packages.${system};
fenix.packages.${system}.fromToolchainFile combine [
{ minimal.rustc
file = ./rust-toolchain.toml; minimal.cargo
# sha256 = nixpkgs.lib.fakeSha256; targets.x86_64-pc-windows-gnu.latest.rust-std
sha256 = "sha256-2eWc3xVTKqg5wKSHGwt1XoM/kUBC6y3MWfKg74Zn+fY="; ];
};
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
# Common build inputs for both native and cross compilation
commonArgs = { commonArgs = {
src = craneLib.cleanCargoSource ./.; src = craneLib.cleanCargoSource ./.;
strictDeps = true; cargoArtifacts = craneLib.buildDepsOnly {
src = craneLib.cleanCargoSource ./.;
};
doCheck = false; # skip tests during nix build doCheck = false; # skip tests during nix build
}; };
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Native build # Native build
timr = craneLib.buildPackage commonArgs; timr = craneLib.buildPackage commonArgs;
# Linux build w/ statically linked binaries
staticLinuxBuild = craneLib.buildPackage (commonArgs
// {
inherit cargoArtifacts;
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static";
});
# Windows cross-compilation build # Windows cross-compilation build
# @see https://crane.dev/examples/cross-windows.html # @see https://crane.dev/examples/cross-windows.html
windowsBuild = craneLib.buildPackage { crossBuild = craneLib.buildPackage {
inherit (commonArgs) src strictDeps doCheck; src = craneLib.cleanCargoSource ./.;
strictDeps = true;
doCheck = false;
CARGO_BUILD_TARGET = "x86_64-pc-windows-gnu"; CARGO_BUILD_TARGET = "x86_64-pc-windows-gnu";
@ -72,31 +68,24 @@
packages = { packages = {
inherit timr; inherit timr;
default = timr; default = timr;
linuxStatic = staticLinuxBuild; windows = crossBuild;
windows = windowsBuild;
}; };
devShells.default = with nixpkgs.legacyPackages.${system}; # Development shell with all necessary tools
craneLib.devShell { devShell = with nixpkgs.legacyPackages.${system};
packages = mkShell {
[ buildInputs = with fenix.packages.${system}.stable; [
toolchain rust-analyzer
pkgs.just clippy
pkgs.nixd rustfmt
pkgs.alejandra toolchain
] pkgs.just
# pkgs needed to play sound on Linux pkgs.nixd
++ lib.optionals stdenv.isLinux [ pkgs.alejandra
pkgs.pkg-config ];
pkgs.pipewire
pkgs.alsa-lib
];
inherit (commonArgs) src; inherit (commonArgs) src;
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
# Environment variables needed discover ALSA/PipeWire properly on Linux
LD_LIBRARY_PATH = lib.optionalString stdenv.isLinux "${pkgs.alsa-lib}/lib:${pkgs.pipewire}/lib";
ALSA_PLUGIN_DIR = lib.optionalString stdenv.isLinux "${pkgs.pipewire}/lib/alsa-lib";
}; };
}); });
} }

103
justfile
View File

@ -2,174 +2,79 @@
set unstable := true set unstable := true
# list commands
default: default:
@just --list @just --list
alias b := build alias b := build
alias f := format
alias l := lint
alias t := test
alias r := run
# build app # build app
[group('build')]
build: build:
cargo build cargo build
alias t := test
# run tests # run tests
[group('test')]
test: test:
cargo test cargo test
alias f := format
# format files # format files
[group('misc')]
format: format:
just --fmt just --fmt
cargo fmt cargo fmt
alias l := lint
# lint # lint
[group('misc')]
lint: lint:
cargo clippy --no-deps cargo clippy --no-deps
alias r := run
# run app # run app
[group('dev')]
run: run:
cargo run cargo run
alias ra := run-args
# run app with arguments. It expects arguments as a string (e.g. "-c 5:00").
[group('dev')]
run-args args:
cargo run -- {{ args }}
alias rs := run-sound
# run app while sound feature is enabled. It expects a path to a sound file.
[group('dev')]
run-sound path:
cargo run --features sound -- --sound={{ path }}
alias rsa := run-sound-args
# run app while sound feature is enabled by adding a path to a sound file and other arguments as string (e.g. "-c 5:00").
[group('dev')]
run-sound-args path args:
cargo run --features sound -- --sound={{ path }} {{ args }}
# demos # demos
alias da := demo-all
# build all demos
[group('demo')]
demo-all:
#!/usr/bin/env bash
for tape in demo/*.tape; do
echo "Building demo: $tape"
vhs "$tape"
done
alias dp := demo-pomodoro alias dp := demo-pomodoro
# build demo: pomodoro
[group('demo')]
demo-pomodoro: demo-pomodoro:
vhs demo/pomodoro.tape vhs demo/pomodoro.tape
alias dt := demo-timer alias dt := demo-timer
# build demo: timer
[group('demo')]
demo-timer: demo-timer:
vhs demo/timer.tape vhs demo/timer.tape
alias dc := demo-countdown alias dc := demo-countdown
# build demo: countdown
[group('demo')]
demo-countdown: demo-countdown:
vhs demo/countdown.tape vhs demo/countdown.tape
alias dcm := demo-countdown-met alias dcm := demo-countdown-met
# build demo: countdown + met
[group('demo')]
demo-countdown-met: demo-countdown-met:
vhs demo/countdown-met.tape vhs demo/countdown-met.tape
alias ds := demo-style alias ds := demo-style
# build demo: styles
[group('demo')]
demo-style: demo-style:
vhs demo/style.tape vhs demo/style.tape
alias dd := demo-decis alias dd := demo-decis
# build demo: deciseconds
[group('demo')]
demo-decis: demo-decis:
vhs demo/decis.tape vhs demo/decis.tape
alias dm := demo-menu alias dm := demo-menu
# build demo: menu
[group('demo')]
demo-menu: demo-menu:
vhs demo/menu.tape vhs demo/menu.tape
alias dlt := demo-local-time alias dlt := demo-local-time
# build demo: local time
[group('demo')]
demo-local-time: demo-local-time:
vhs demo/local-time.tape vhs demo/local-time.tape
alias dltf := demo-local-time-footer
# build demo: local time (footer)
[group('demo')]
demo-local-time-footer:
vhs demo/local-time-footer.tape
alias drc := demo-rocket-countdown alias drc := demo-rocket-countdown
# build demo: rocket countdown
[group('demo')]
demo-rocket-countdown: demo-rocket-countdown:
vhs demo/met.tape vhs demo/met.tape
alias db := demo-blink
# build demo: blink animation
[group('demo')]
demo-blink:
vhs demo/blink.tape
alias de := demo-event
# build demo: event
[group('demo')]
demo-event:
vhs demo/event.tape
alias dcmx := demo-countdown-max
# build demo: timer-max
[group('demo')]
demo-countdown-max:
vhs demo/countdown-max.tape
alias dtm := demo-timer-max
# build demo: timer-max
[group('demo')]
demo-timer-max:
vhs demo/timer-max.tape

View File

@ -1,6 +0,0 @@
[toolchain]
# Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`.
channel = "1.91.0"
components = ["clippy", "rustfmt", "rust-src", "rust-analyzer"]
targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl"]
profile = "minimal"

View File

@ -1,38 +1,29 @@
use crate::{ use crate::{
args::Args, args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle}, common::{AppTime, AppTimeFormat, Content, Style},
constants::TICK_VALUE_MS, constants::TICK_VALUE_MS,
event::Event, events::{Event, EventHandler, Events},
events::{self, TuiEventHandler},
storage::AppStorage, storage::AppStorage,
terminal::Terminal, terminal::Terminal,
widgets::{ widgets::{
clock::{self, ClockState, ClockStateArgs}, clock::{self, ClockState, ClockStateArgs},
countdown::{Countdown, CountdownState, CountdownStateArgs}, countdown::{Countdown, CountdownState},
event::{EventState, EventStateArgs, EventWidget},
footer::{Footer, FooterState}, footer::{Footer, FooterState},
header::Header, header::Header,
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget}, pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
timer::{Timer, TimerState}, timer::{Timer, TimerState},
}, },
}; };
use crossterm::event::Event as CrosstermEvent;
#[cfg(feature = "sound")]
use crate::sound::Sound;
use color_eyre::Result; use color_eyre::Result;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent}, crossterm::event::{KeyCode, KeyEvent},
layout::{Constraint, Layout, Position, Rect}, layout::{Constraint, Layout, Rect},
widgets::{StatefulWidget, Widget}, widgets::{StatefulWidget, Widget},
}; };
use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, error}; use time::OffsetDateTime;
use tracing::debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode { enum Mode {
@ -40,36 +31,26 @@ enum Mode {
Quit, Quit,
} }
#[derive(Debug)]
pub struct App { pub struct App {
content: Content, content: Content,
mode: Mode, mode: Mode,
notification: Toggle,
blink: Toggle,
#[allow(dead_code)] // w/ `--features sound` available only
sound_path: Option<PathBuf>,
app_time: AppTime, app_time: AppTime,
app_time_format: AppTimeFormat,
countdown: CountdownState, countdown: CountdownState,
timer: TimerState, timer: TimerState,
pomodoro: PomodoroState, pomodoro: PomodoroState,
event: EventState,
local_time: LocalTimeState,
style: Style, style: Style,
with_decis: bool, with_decis: bool,
footer: FooterState, footer: FooterState,
cursor_position: Option<Position>,
} }
pub struct AppArgs { pub struct AppArgs {
pub style: Style, pub style: Style,
pub with_decis: bool, pub with_decis: bool,
pub notification: Toggle,
pub blink: Toggle,
pub show_menu: bool, pub show_menu: bool,
pub app_time_format: AppTimeFormat, pub app_time_format: AppTimeFormat,
pub content: Content, pub content: Content,
pub pomodoro_mode: PomodoroMode, pub pomodoro_mode: PomodoroMode,
pub pomodoro_round: u64,
pub initial_value_work: Duration, pub initial_value_work: Duration,
pub current_value_work: Duration, pub current_value_work: Duration,
pub initial_value_pause: Duration, pub initial_value_pause: Duration,
@ -78,51 +59,19 @@ pub struct AppArgs {
pub current_value_countdown: Duration, pub current_value_countdown: Duration,
pub elapsed_value_countdown: Duration, pub elapsed_value_countdown: Duration,
pub current_value_timer: Duration, pub current_value_timer: Duration,
pub event: Event,
pub app_tx: events::AppEventTx,
pub sound_path: Option<PathBuf>,
pub footer_toggle_app_time: Toggle,
} }
pub struct FromAppArgs { /// Getting `AppArgs` by merging `Args` and `AppStorage`.
pub args: Args, /// `Args` wins btw.
pub stg: AppStorage, impl From<(Args, AppStorage)> for AppArgs {
pub app_tx: events::AppEventTx, fn from((args, stg): (Args, AppStorage)) -> Self {
} AppArgs {
/// Creates an `App` by merging `Args` and `AppStorage` (`Args` wins)
/// and adding `AppEventTx`
impl From<FromAppArgs> for App {
fn from(args: FromAppArgs) -> Self {
let FromAppArgs { args, stg, app_tx } = args;
App::new(AppArgs {
with_decis: args.decis || stg.with_decis, with_decis: args.decis || stg.with_decis,
show_menu: args.menu || stg.show_menu, show_menu: args.menu || stg.show_menu,
notification: args.notification.unwrap_or(stg.notification),
blink: args.blink.unwrap_or(stg.blink),
app_time_format: stg.app_time_format, app_time_format: stg.app_time_format,
// Check args to set a possible mode to start with. content: args.mode.unwrap_or(stg.content),
content: match args.mode {
Some(mode) => mode,
// check other args (especially durations)
None => {
if args.work.is_some() || args.pause.is_some() {
Content::Pomodoro
} else if args.countdown.is_some() {
Content::Countdown
} else if args.event.is_some() {
Content::Event
}
// in other case just use latest stored state
else {
stg.content
}
}
},
style: args.style.unwrap_or(stg.style), style: args.style.unwrap_or(stg.style),
pomodoro_mode: stg.pomodoro_mode, pomodoro_mode: stg.pomodoro_mode,
pomodoro_round: stg.pomodoro_count,
initial_value_work: args.work.unwrap_or(stg.inital_value_work), initial_value_work: args.work.unwrap_or(stg.inital_value_work),
// invalidate `current_value_work` if an initial value is set via args // invalidate `current_value_work` if an initial value is set via args
current_value_work: args.work.unwrap_or(stg.current_value_work), current_value_work: args.work.unwrap_or(stg.current_value_work),
@ -131,21 +80,17 @@ impl From<FromAppArgs> for App {
current_value_pause: args.pause.unwrap_or(stg.current_value_pause), current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown), initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
// invalidate `current_value_countdown` if an initial value is set via args // invalidate `current_value_countdown` if an initial value is set via args
current_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown), current_value_countdown: args.countdown.unwrap_or(stg.current_value_countdown),
elapsed_value_countdown: match args.countdown { elapsed_value_countdown: stg.elapsed_value_countdown,
// reset value if countdown is set by arguments
Some(_) => Duration::ZERO,
None => stg.elapsed_value_countdown,
},
current_value_timer: stg.current_value_timer, current_value_timer: stg.current_value_timer,
event: args.event.unwrap_or(stg.event), }
app_tx, }
#[cfg(feature = "sound")] }
sound_path: args.sound,
#[cfg(not(feature = "sound"))] fn get_app_time() -> AppTime {
sound_path: None, match OffsetDateTime::now_local() {
footer_toggle_app_time: stg.footer_app_time, Ok(t) => AppTime::Local(t),
}) Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
} }
} }
@ -166,44 +111,28 @@ impl App {
content, content,
with_decis, with_decis,
pomodoro_mode, pomodoro_mode,
pomodoro_round,
event,
notification,
blink,
sound_path,
app_tx,
footer_toggle_app_time,
} = args; } = args;
let app_time = AppTime::new();
Self { Self {
mode: Mode::Running, mode: Mode::Running,
notification,
blink,
sound_path,
content, content,
app_time, app_time: get_app_time(),
app_time_format,
style, style,
with_decis, with_decis,
countdown: CountdownState::new(CountdownStateArgs { countdown: CountdownState::new(
initial_value: initial_value_countdown, ClockState::<clock::Countdown>::new(ClockStateArgs {
current_value: current_value_countdown, initial_value: initial_value_countdown,
elapsed_value: elapsed_value_countdown, current_value: current_value_countdown,
app_time,
with_decis,
app_tx: app_tx.clone(),
}),
timer: TimerState::new(
ClockState::<clock::Timer>::new(ClockStateArgs {
initial_value: Duration::ZERO,
current_value: current_value_timer,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis, with_decis,
app_tx: Some(app_tx.clone()), }),
}) elapsed_value_countdown,
.with_name("Timer".to_owned()),
), ),
timer: TimerState::new(ClockState::<clock::Timer>::new(ClockStateArgs {
initial_value: Duration::ZERO,
current_value: current_value_timer,
tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis,
})),
pomodoro: PomodoroState::new(PomodoroStateArgs { pomodoro: PomodoroState::new(PomodoroStateArgs {
mode: pomodoro_mode, mode: pomodoro_mode,
initial_value_work, initial_value_work,
@ -211,181 +140,32 @@ impl App {
initial_value_pause, initial_value_pause,
current_value_pause, current_value_pause,
with_decis, with_decis,
round: pomodoro_round,
app_tx: app_tx.clone(),
}), }),
local_time: LocalTimeState::new(LocalTimeStateArgs { footer: FooterState::new(show_menu, app_time_format),
app_time,
app_time_format,
}),
event: EventState::new(EventStateArgs {
app_time,
event,
with_decis,
app_tx: app_tx.clone(),
}),
footer: FooterState::new(
show_menu,
if footer_toggle_app_time == Toggle::On {
Some(app_time_format)
} else {
None
},
),
cursor_position: None,
} }
} }
pub async fn run( pub async fn run(mut self, mut terminal: Terminal, mut events: Events) -> Result<Self> {
mut self,
terminal: &mut Terminal,
mut events: events::Events,
) -> Result<Self> {
// Closure to handle `KeyEvent`'s
let handle_key_event = |app: &mut Self, key: KeyEvent| {
debug!("Received key {:?}", key.code);
match key.code {
KeyCode::Char('q') => app.mode = Mode::Quit,
KeyCode::Char('1') | KeyCode::Char('c') /* TODO: deprecated, remove it in next version */ => app.content = Content::Countdown,
KeyCode::Char('2') | KeyCode::Char('t') /* TODO: deprecated, remove it in next version */ => app.content = Content::Timer,
KeyCode::Char('3') | KeyCode::Char('p') /* TODO: deprecated, remove it in next version */ => app.content = Content::Pomodoro,
KeyCode::Char('4') => app.content = Content::Event,
// toogle app time format
KeyCode::Char('0') | KeyCode::Char('l') /* TODO: deprecated, remove it in next version */ => app.content = Content::LocalTime,
// switch `screens`
KeyCode::Right => {
app.content = app.content.next();
}
KeyCode::Left => {
app.content = app.content.prev();
}
// toogle app time format
KeyCode::Char(':') => {
if app.content == Content::LocalTime {
// For LocalTime content: just cycle through formats
app.app_time_format = app.app_time_format.next();
app.local_time.set_app_time_format(app.app_time_format);
// Only update footer if it's currently showing time
if app.footer.app_time_format().is_some() {
app.footer.set_app_time_format(Some(app.app_time_format));
}
} else {
// For other content: allow footer to toggle between formats and None
let new_format = match app.footer.app_time_format() {
// footer is hidden -> show first format
None => Some(AppTimeFormat::first()),
Some(v) => {
if v != &AppTimeFormat::last() {
Some(v.next())
} else {
// reached last format -> hide footer time
None
}
}
};
if let Some(format) = new_format {
app.app_time_format = format;
app.local_time.set_app_time_format(format);
}
app.footer.set_app_time_format(new_format);
}
}
// toogle menu
KeyCode::Char('m') => app.footer.set_show_menu(!app.footer.get_show_menu()),
KeyCode::Char(',') => {
app.style = app.style.next();
}
KeyCode::Char('.') => {
app.with_decis = !app.with_decis;
// update clocks
app.timer.set_with_decis(app.with_decis);
app.countdown.set_with_decis(app.with_decis);
app.pomodoro.set_with_decis(app.with_decis);
app.event.set_with_decis(app.with_decis);
}
KeyCode::Up => app.footer.set_show_menu(true),
KeyCode::Down => app.footer.set_show_menu(false),
_ => {}
};
};
// Closure to handle `TuiEvent`'s
let mut handle_tui_events = |app: &mut Self, event: events::TuiEvent| -> Result<()> {
if matches!(event, events::TuiEvent::Tick) {
app.app_time = AppTime::new();
app.countdown.set_app_time(app.app_time);
app.local_time.set_app_time(app.app_time);
app.event.set_app_time(app.app_time);
}
// Pipe events into subviews and handle only 'unhandled' events afterwards
if let Some(unhandled) = match app.content {
Content::Countdown => app.countdown.update(event.clone()),
Content::Timer => app.timer.update(event.clone()),
Content::Pomodoro => app.pomodoro.update(event.clone()),
Content::Event => app.event.update(event.clone()),
Content::LocalTime => app.local_time.update(event.clone()),
} {
match unhandled {
events::TuiEvent::Render
| events::TuiEvent::Crossterm(crossterm::event::Event::Resize(_, _)) => {
app.draw(terminal)?;
}
events::TuiEvent::Crossterm(CrosstermEvent::Key(key)) => {
handle_key_event(app, key)
}
_ => {}
}
}
Ok(())
};
#[allow(unused_variables)] // `app` is used by `--features sound` only
// Closure to handle `AppEvent`'s
let handle_app_events = |app: &mut Self, event: events::AppEvent| -> Result<()> {
match event {
events::AppEvent::ClockDone(type_id, name) => {
debug!("AppEvent::ClockDone");
if app.notification == Toggle::On {
let msg = match type_id {
ClockTypeId::Timer => {
format!("{name} stopped by reaching its maximum value.")
}
_ => format!("{type_id:?} {name} done!"),
};
// notification
let result = notify_rust::Notification::new()
.summary(&msg.to_uppercase())
.show();
if let Err(err) = result {
error!("on_done {name} error: {err}");
}
};
#[cfg(feature = "sound")]
if let Some(path) = app.sound_path.clone() {
_ = Sound::new(path).and_then(|sound| sound.play()).or_else(
|err| -> Result<()> {
error!("Sound error: {:?}", err);
Ok(())
},
);
}
}
events::AppEvent::SetCursor(position) => {
app.cursor_position = position;
}
}
Ok(())
};
while self.is_running() { while self.is_running() {
if let Some(event) = events.next().await { if let Some(event) = events.next().await {
let _ = match event { if matches!(event, Event::Tick) {
events::Event::Terminal(e) => handle_tui_events(&mut self, e), self.app_time = get_app_time();
events::Event::App(e) => handle_app_events(&mut self, e), }
};
// Pipe events into subviews and handle only 'unhandled' events afterwards
if let Some(unhandled) = match self.content {
Content::Countdown => self.countdown.update(event.clone()),
Content::Timer => self.timer.update(event.clone()),
Content::Pomodoro => self.pomodoro.update(event.clone()),
} {
match unhandled {
Event::Render | Event::Resize => {
self.draw(&mut terminal)?;
}
Event::Key(key) => self.handle_key_event(key),
_ => {}
}
}
} }
} }
Ok(self) Ok(self)
@ -395,40 +175,11 @@ impl App {
self.mode != Mode::Quit self.mode != Mode::Quit
} }
fn get_edit_mode(&self) -> AppEditMode { fn is_edit_mode(&self) -> bool {
match self.content { match self.content {
Content::Countdown => { Content::Countdown => self.countdown.get_clock().is_edit_mode(),
if self.countdown.is_clock_edit_mode() { Content::Timer => self.timer.get_clock().is_edit_mode(),
AppEditMode::Clock Content::Pomodoro => self.pomodoro.get_clock().is_edit_mode(),
} else if self.countdown.is_time_edit_mode() {
AppEditMode::Time
} else {
AppEditMode::None
}
}
Content::Timer => {
if self.timer.get_clock().is_edit_mode() {
AppEditMode::Clock
} else {
AppEditMode::None
}
}
Content::Pomodoro => {
if self.pomodoro.get_clock().is_edit_mode() {
AppEditMode::Clock
} else {
AppEditMode::None
}
}
Content::Event => {
if self.event.is_edit_mode() {
AppEditMode::Event
} else {
AppEditMode::None
}
}
Content::LocalTime => AppEditMode::None,
} }
} }
@ -437,10 +188,6 @@ impl App {
Content::Countdown => self.countdown.is_running(), Content::Countdown => self.countdown.is_running(),
Content::Timer => self.timer.get_clock().is_running(), Content::Timer => self.timer.get_clock().is_running(),
Content::Pomodoro => self.pomodoro.get_clock().is_running(), Content::Pomodoro => self.pomodoro.get_clock().is_running(),
// Event clock runs forever
Content::Event => true,
// `LocalTime` does not use a `Clock`
Content::LocalTime => false,
} }
} }
@ -449,19 +196,39 @@ impl App {
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()), Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
Content::Timer => None, Content::Timer => None,
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()), Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
Content::Event => Some(self.event.get_percentage_done()),
Content::LocalTime => None,
} }
} }
fn handle_key_event(&mut self, key: KeyEvent) {
debug!("Received key {:?}", key.code);
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,
// toogle app time format
KeyCode::Char(':') => self.footer.toggle_app_time_format(),
// toogle menu
KeyCode::Char('m') => self.footer.set_show_menu(!self.footer.get_show_menu()),
KeyCode::Char(',') => {
self.style = self.style.next();
}
KeyCode::Char('.') => {
self.with_decis = !self.with_decis;
// update clocks
self.timer.set_with_decis(self.with_decis);
self.countdown.set_with_decis(self.with_decis);
self.pomodoro.set_with_decis(self.with_decis);
}
KeyCode::Up => self.footer.set_show_menu(true),
KeyCode::Down => self.footer.set_show_menu(false),
_ => {}
};
}
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> { fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
terminal.draw(|frame| { terminal.draw(|frame| {
frame.render_stateful_widget(AppWidget, frame.area(), self); frame.render_stateful_widget(AppWidget, frame.area(), self);
// Set cursor position if requested
if let Some(position) = self.cursor_position {
frame.set_cursor_position(position);
}
})?; })?;
Ok(()) Ok(())
} }
@ -470,13 +237,10 @@ impl App {
AppStorage { AppStorage {
content: self.content, content: self.content,
show_menu: self.footer.get_show_menu(), show_menu: self.footer.get_show_menu(),
notification: self.notification, app_time_format: *self.footer.app_time_format(),
blink: self.blink,
app_time_format: self.app_time_format,
style: self.style, style: self.style,
with_decis: self.with_decis, with_decis: self.with_decis,
pomodoro_mode: self.pomodoro.get_mode().clone(), pomodoro_mode: self.pomodoro.get_mode().clone(),
pomodoro_count: self.pomodoro.get_round(),
inital_value_work: Duration::from(*self.pomodoro.get_clock_work().get_initial_value()), inital_value_work: Duration::from(*self.pomodoro.get_clock_work().get_initial_value()),
current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()), current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()),
inital_value_pause: Duration::from( inital_value_pause: Duration::from(
@ -491,8 +255,6 @@ impl App {
), ),
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()), elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()), current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
event: self.event.get_event(),
footer_app_time: self.footer.app_time_format().is_some().into(),
} }
} }
} }
@ -503,29 +265,13 @@ impl AppWidget {
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) { fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) {
match state.content { match state.content {
Content::Timer => { Content::Timer => {
Timer { Timer { style: state.style }.render(area, buf, &mut state.timer);
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.timer);
} }
Content::Countdown => Countdown { Content::Countdown => {
style: state.style, Countdown { style: state.style }.render(area, buf, &mut state.countdown)
blink: state.blink == Toggle::On,
} }
.render(area, buf, &mut state.countdown), Content::Pomodoro => {
Content::Pomodoro => PomodoroWidget { PomodoroWidget { style: state.style }.render(area, buf, &mut state.pomodoro)
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.pomodoro),
Content::Event => EventWidget {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.event),
Content::LocalTime => {
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
} }
}; };
} }
@ -537,7 +283,7 @@ impl StatefulWidget for AppWidget {
let [v0, v1, v2] = Layout::vertical([ let [v0, v1, v2] = Layout::vertical([
Constraint::Length(1), Constraint::Length(1),
Constraint::Percentage(100), Constraint::Percentage(100),
Constraint::Length(if state.footer.get_show_menu() { 5 } else { 1 }), Constraint::Length(if state.footer.get_show_menu() { 4 } else { 1 }),
]) ])
.areas(area); .areas(area);
@ -552,7 +298,7 @@ impl StatefulWidget for AppWidget {
Footer { Footer {
running_clock: state.clock_is_running(), running_clock: state.clock_is_running(),
selected_content: state.content, selected_content: state.content,
app_edit_mode: state.get_edit_mode(), edit_mode: state.is_edit_mode(),
app_time: state.app_time, app_time: state.app_time,
} }
.render(v2, buf, &mut state.footer); .render(v2, buf, &mut state.footer);

View File

@ -1,43 +1,29 @@
use crate::{ use crate::{
common::{Content, Style, Toggle}, common::{Content, Style},
duration, duration,
event::{Event, parse_event},
}; };
#[cfg(feature = "sound")]
use crate::{sound, sound::SoundError};
use clap::Parser; use clap::Parser;
use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
pub const LOG_DIRECTORY_DEFAULT_MISSING_VALUE: &str = " "; // empty string
#[derive(Parser)] #[derive(Parser)]
#[command(version)] #[command(version)]
pub struct Args { pub struct Args {
#[arg(long, short, value_parser = duration::parse_long_duration, #[arg(long, short, value_parser = duration::parse_duration,
help = "Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'." help = "Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
)] )]
pub countdown: Option<Duration>, pub countdown: Option<Duration>,
#[arg(long, short, value_parser = duration::parse_duration, #[arg(long, short, value_parser = duration::parse_duration,
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'" help = "Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
)] )]
pub work: Option<Duration>, pub work: Option<Duration>,
#[arg(long, short, value_parser = duration::parse_duration, #[arg(long, short, value_parser = duration::parse_duration,
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'" help = "Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
)] )]
pub pause: Option<Duration>, pub pause: Option<Duration>,
#[arg( #[arg(long, short = 'd', help = "Whether to show deciseconds or not.")]
long,
short = 'e',
value_parser = parse_event,
help = "Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'."
)]
pub event: Option<Event>,
#[arg(long, short = 'd', help = "Show deciseconds.")]
pub decis: bool, pub decis: bool,
#[arg(long, short = 'm', value_enum, help = "Mode to start with.")] #[arg(long, short = 'm', value_enum, help = "Mode to start with.")]
@ -46,55 +32,9 @@ pub struct Args {
#[arg(long, short = 's', value_enum, help = "Style to display time with.")] #[arg(long, short = 's', value_enum, help = "Style to display time with.")]
pub style: Option<Style>, pub style: Option<Style>,
#[arg(long, value_enum, help = "Open menu.")] #[arg(long, value_enum, help = "Whether to open the menu or not.")]
pub menu: bool, pub menu: bool,
#[arg(long, short = 'r', help = "Reset stored values to defaults.")] #[arg(long, short = 'r', help = "Reset stored values to default.")]
pub reset: bool, pub reset: bool,
#[arg(
long,
short,
value_enum,
help = "Toggle desktop notifications. Experimental."
)]
pub notification: Option<Toggle>,
#[arg(
long,
value_enum,
help = "Toggle blink mode to animate a clock when it reaches its finished mode."
)]
pub blink: Option<Toggle>,
#[cfg(feature = "sound")]
#[arg(
long,
value_enum,
help = "Path to sound file (.mp3 or .wav) to play as notification. Experimental.",
value_hint = clap::ValueHint::FilePath,
value_parser = sound_file_parser,
)]
pub sound: Option<PathBuf>,
#[arg(
long,
// allows both --log=path and --log path syntax
num_args = 0..=1,
// Note: If no value is passed, use a " " by default,
// this value will be checked later in `main`
// to use another (default) log directory instead
default_missing_value=LOG_DIRECTORY_DEFAULT_MISSING_VALUE,
help = "Directory for log file. If not set, standard application log directory is used (check README for details).",
value_hint = clap::ValueHint::DirPath,
)]
pub log: Option<PathBuf>,
}
#[cfg(feature = "sound")]
/// Custom parser for sound file
fn sound_file_parser(s: &str) -> Result<PathBuf, SoundError> {
let path = PathBuf::from(s);
sound::validate_sound_file(&path)?;
Ok(path)
} }

View File

@ -1,8 +1,8 @@
use clap::ValueEnum; use clap::ValueEnum;
use ratatui::symbols::shade; use ratatui::symbols::shade;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use strum::EnumString; use time::format_description;
use time::{OffsetDateTime, format_description}; use time::OffsetDateTime;
#[derive( #[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize,
@ -15,39 +15,6 @@ pub enum Content {
Timer, Timer,
#[value(name = "pomodoro", alias = "p")] #[value(name = "pomodoro", alias = "p")]
Pomodoro, Pomodoro,
#[value(name = "event", alias = "e")]
Event,
#[value(name = "localtime", alias = "l")]
LocalTime,
}
impl Content {
pub fn next(&self) -> Self {
match self {
Content::Countdown => Content::Timer,
Content::Timer => Content::Pomodoro,
Content::Pomodoro => Content::Event,
Content::Event => Content::LocalTime,
Content::LocalTime => Content::Countdown,
}
}
pub fn prev(&self) -> Self {
match self {
Content::Countdown => Content::LocalTime,
Content::Timer => Content::Countdown,
Content::Pomodoro => Content::Timer,
Content::Event => Content::Pomodoro,
Content::LocalTime => Content::Event,
}
}
}
#[derive(Clone, Debug)]
pub enum ClockTypeId {
Countdown,
Timer,
Event,
} }
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
@ -98,7 +65,7 @@ impl Style {
} }
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, EnumString, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum AppTimeFormat { pub enum AppTimeFormat {
/// `hh:mm:ss` /// `hh:mm:ss`
#[default] #[default]
@ -107,22 +74,17 @@ pub enum AppTimeFormat {
HhMm, HhMm,
/// `hh:mm AM` (or PM) /// `hh:mm AM` (or PM)
Hh12Mm, Hh12Mm,
/// `` (empty)
Hidden,
} }
impl AppTimeFormat { impl AppTimeFormat {
pub const fn first() -> Self {
Self::HhMmSs
}
pub const fn last() -> Self {
Self::Hh12Mm
}
pub fn next(&self) -> Self { pub fn next(&self) -> Self {
match self { match self {
AppTimeFormat::HhMmSs => AppTimeFormat::HhMm, AppTimeFormat::HhMmSs => AppTimeFormat::HhMm,
AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm, AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm,
AppTimeFormat::Hh12Mm => AppTimeFormat::HhMmSs, AppTimeFormat::Hh12Mm => AppTimeFormat::Hidden,
AppTimeFormat::Hidden => AppTimeFormat::HhMmSs,
} }
} }
} }
@ -143,89 +105,25 @@ impl From<AppTime> for OffsetDateTime {
} }
impl AppTime { impl AppTime {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
pub fn format(&self, app_format: &AppTimeFormat) -> String { pub fn format(&self, app_format: &AppTimeFormat) -> String {
let parse_str = match app_format { let parse_str = match app_format {
AppTimeFormat::HhMmSs => "[hour]:[minute]:[second]", AppTimeFormat::HhMmSs => Some("[hour]:[minute]:[second]"),
AppTimeFormat::HhMm => "[hour]:[minute]", AppTimeFormat::HhMm => Some("[hour]:[minute]"),
AppTimeFormat::Hh12Mm => "[hour repr:12 padding:none]:[minute] [period]", AppTimeFormat::Hh12Mm => Some("[hour repr:12 padding:none]:[minute] [period]"),
AppTimeFormat::Hidden => None,
}; };
format_description::parse(parse_str) if let Some(str) = parse_str {
.map_err(|_| "parse error") format_description::parse(str)
.and_then(|fd| { .map_err(|_| "parse error")
OffsetDateTime::from(*self) .and_then(|fd| {
.format(&fd) OffsetDateTime::from(*self)
.map_err(|_| "format error") .format(&fd)
}) .map_err(|_| "format error")
.unwrap_or_else(|e| e.to_string()) })
} .unwrap_or_else(|e| e.to_string())
} else {
pub fn format_date(&self) -> String { "".to_owned()
format_description::parse("[year]-[month]-[day]")
.map_err(|_| "parse error")
.and_then(|fd| {
OffsetDateTime::from(*self)
.format(&fd)
.map_err(|_| "format error")
})
.unwrap_or_else(|e| e.to_string())
}
pub fn get_period(&self) -> String {
format_description::parse("[period]")
.map_err(|_| "parse error")
.and_then(|fd| {
OffsetDateTime::from(*self)
.format(&fd)
.map_err(|_| "format error")
})
.unwrap_or_else(|e| e.to_string())
}
/// Converts `AppTime` into a `Duration` representing elapsed time since midnight (today).
pub fn as_duration_of_today(&self) -> std::time::Duration {
let dt = OffsetDateTime::from(*self);
let time = dt.time();
let total_nanos = u64::from(time.hour()) * 3_600_000_000_000
+ u64::from(time.minute()) * 60_000_000_000
+ u64::from(time.second()) * 1_000_000_000
+ u64::from(time.nanosecond());
std::time::Duration::from_nanos(total_nanos)
}
}
#[derive(Debug)]
pub enum AppEditMode {
None,
Clock,
Time,
Event,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Toggle {
#[value(name = "on")]
On,
#[default]
#[value(name = "off")]
Off,
}
impl From<bool> for Toggle {
fn from(value: bool) -> Self {
match value {
true => Toggle::On,
false => Toggle::Off,
} }
} }
} }
@ -276,49 +174,12 @@ mod tests {
"6:06 PM", "6:06 PM",
"local" "local"
); );
} // hidden
assert_eq!(AppTime::Utc(dt).format(&AppTimeFormat::Hidden), "", "utc");
#[test] assert_eq!(
fn test_content_next() { AppTime::Local(dt).format(&AppTimeFormat::Hidden),
let start = Content::Countdown; "",
let mut current = start; "local"
);
// Cycle through: Countdown -> Timer -> Pomodoro -> Event -> LocalTime -> Countdown
current = current.next();
assert_eq!(current, Content::Timer);
current = current.next();
assert_eq!(current, Content::Pomodoro);
current = current.next();
assert_eq!(current, Content::Event);
current = current.next();
assert_eq!(current, Content::LocalTime);
current = current.next();
assert_eq!(current, start, "Should cycle back to start");
}
#[test]
fn test_content_prev() {
let start = Content::Countdown;
let mut current = start;
// Cycle backwards: Countdown -> LocalTime -> Event -> Pomodoro -> Timer -> Countdown
current = current.prev();
assert_eq!(current, Content::LocalTime);
current = current.prev();
assert_eq!(current, Content::Event);
current = current.prev();
assert_eq!(current, Content::Pomodoro);
current = current.prev();
assert_eq!(current, Content::Timer);
current = current.prev();
assert_eq!(current, start, "Should cycle back to start");
} }
} }

View File

@ -1,9 +1,8 @@
use crate::constants::APP_NAME; use crate::constants::APP_NAME;
use color_eyre::eyre::{Result, eyre}; use color_eyre::eyre::{eyre, Result};
use directories::ProjectDirs; use directories::ProjectDirs;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
pub struct Config { pub struct Config {
pub log_dir: PathBuf, pub log_dir: PathBuf,
pub data_dir: PathBuf, pub data_dir: PathBuf,
@ -11,11 +10,8 @@ pub struct Config {
impl Config { impl Config {
pub fn init() -> Result<Self> { pub fn init() -> Result<Self> {
// default logs dir
let log_dir = get_default_state_dir()?.join("logs"); let log_dir = get_default_state_dir()?.join("logs");
fs::create_dir_all(&log_dir)?; fs::create_dir_all(&log_dir)?;
// default data dir
let data_dir = get_default_state_dir()?.join("data"); let data_dir = get_default_state_dir()?.join("data");
fs::create_dir_all(&data_dir)?; fs::create_dir_all(&data_dir)?;

View File

@ -1,11 +1,14 @@
use color_eyre::{ use color_eyre::{
Report,
eyre::{ensure, eyre}, eyre::{ensure, eyre},
Report,
}; };
use std::cmp::min;
use std::fmt; use std::fmt;
use std::time::Duration; use std::time::Duration;
use time::OffsetDateTime;
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
pub const ONE_SECOND: Duration = Duration::from_secs(1);
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
pub const ONE_HOUR: Duration = Duration::from_secs(MINS_PER_HOUR * SECS_PER_MINUTE);
// unstable // unstable
// https://doc.rust-lang.org/src/core/time.rs.html#32 // https://doc.rust-lang.org/src/core/time.rs.html#32
@ -17,220 +20,6 @@ pub const MINS_PER_HOUR: u64 = 60;
// https://doc.rust-lang.org/src/core/time.rs.html#36 // https://doc.rust-lang.org/src/core/time.rs.html#36
const HOURS_PER_DAY: u64 = 24; const HOURS_PER_DAY: u64 = 24;
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
pub const ONE_SECOND: Duration = Duration::from_secs(1);
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
pub const ONE_HOUR: Duration = Duration::from_secs(MINS_PER_HOUR * SECS_PER_MINUTE);
pub const ONE_DAY: Duration = Duration::from_secs(HOURS_PER_DAY * MINS_PER_HOUR * SECS_PER_MINUTE);
pub const ONE_YEAR: Duration =
Duration::from_secs(DAYS_PER_YEAR * HOURS_PER_DAY * MINS_PER_HOUR * SECS_PER_MINUTE);
// Days per year
// "There are 365 days in a year in a common year of the Gregorian calendar and 366 days in a leap year.
// Leap years occur every four years. The average number of days in a year is 365.2425 days."
// ^ https://www.math.net/days-in-a-year
const DAYS_PER_YEAR: u64 = 365; // ignore leap year of 366 days
// max. 9999y 364d 23:59:59.9 (10k years - 1 decisecond)
pub const MAX_DURATION: Duration = ONE_YEAR
.saturating_mul(10000)
.saturating_sub(ONE_DECI_SECOND);
/// Trait for duration types that can be displayed in clock widgets.
///
/// This trait abstracts over different duration calculation strategies:
/// - `DurationEx`: Uses fixed 365-day years (fast, simple)
/// - `CalendarDuration`: Uses actual calendar dates (accounts for leap years)
pub trait ClockDuration {
/// Total years
fn years(&self) -> u64;
/// Total days
fn days(&self) -> u64;
/// Days within the current year (0-364 or 0-365 for leap years)
fn days_mod(&self) -> u64;
/// Total hours
fn hours(&self) -> u64;
/// Hours within the current day (0-23)
fn hours_mod(&self) -> u64;
/// Hours as 12-hour clock (1-12)
fn hours_mod_12(&self) -> u64;
/// Total minutes
fn minutes(&self) -> u64;
/// Minutes within the current hour (0-59)
fn minutes_mod(&self) -> u64;
/// Total seconds
fn seconds(&self) -> u64;
/// Seconds within the current minute (0-59)
fn seconds_mod(&self) -> u64;
/// Deciseconds (tenths of a second, 0-9)
fn decis(&self) -> u64;
/// Total milliseconds
fn millis(&self) -> u128;
}
/// Calendar-aware duration that accounts for leap years.
///
/// Unlike `DurationEx` which uses fixed 365-day years, this calculates
/// years and days based on actual calendar dates, properly handling leap years.
///
/// All calculations are performed on-demand from the stored dates.
#[derive(Debug, Clone)]
pub struct CalendarDuration {
earlier: OffsetDateTime,
later: OffsetDateTime,
direction: CalendarDurationDirection,
}
#[derive(PartialEq, Debug, Clone, Copy)]
pub enum CalendarDurationDirection {
Since,
Until,
}
impl CalendarDuration {
/// Create a new CalendarDuration by given two `OffsetDateTime`.
///
/// The order of arguments matters:
/// First: `start_time` - `OffsetDateTime` to start from
/// Second: `end_time` - `OffsetDateTime` for expected end
pub fn from_start_end_times(start_time: OffsetDateTime, end_time: OffsetDateTime) -> Self {
// To avoid negative values by calculating differences of `start` and `end` times,
// we might switch those values internally by storing it as `earlier` and `later` values
// It simplifies all calculations in `ClockDuration` trait later.
// And `direction` will still help to still get original `start` and `end` times later.
if start_time <= end_time {
Self {
earlier: start_time,
later: end_time,
direction: CalendarDurationDirection::Since,
}
} else {
Self {
earlier: end_time,
later: start_time,
direction: CalendarDurationDirection::Until,
}
}
}
pub fn direction(&self) -> CalendarDurationDirection {
self.direction
}
pub fn is_since(&self) -> bool {
self.direction == CalendarDurationDirection::Since
}
pub fn start_time(&self) -> &OffsetDateTime {
match self.direction {
CalendarDurationDirection::Since => &self.earlier,
CalendarDurationDirection::Until => &self.later,
}
}
pub fn end_time(&self) -> &OffsetDateTime {
match self.direction {
CalendarDurationDirection::Since => &self.later,
CalendarDurationDirection::Until => &self.earlier,
}
}
}
impl From<CalendarDuration> for Duration {
fn from(cal_duration: CalendarDuration) -> Self {
let diff = cal_duration.later - cal_duration.earlier;
Duration::from_millis(diff.whole_milliseconds().max(0) as u64)
}
}
impl ClockDuration for CalendarDuration {
fn years(&self) -> u64 {
let mut years = (self.later.year() - self.earlier.year()) as i64;
// Check if we've completed a full year by comparing month/day/time
let intermediate = self
.earlier
.replace_year(self.later.year())
.unwrap_or(self.earlier);
if intermediate > self.later {
years -= 1;
}
years.max(0) as u64
}
fn days_mod(&self) -> u64 {
let year_count = self.years();
// Calculate intermediate date after adding complete years
let target_year = self.earlier.year() + year_count as i32;
let intermediate = self
.earlier
.replace_year(target_year)
.unwrap_or(self.earlier);
let remaining = self.later - intermediate;
remaining.whole_days().max(0) as u64
}
fn days(&self) -> u64 {
(self.later - self.earlier).whole_days().max(0) as u64
}
fn hours_mod(&self) -> u64 {
let total_hours = (self.later - self.earlier).whole_hours();
(total_hours % 24).max(0) as u64
}
fn hours(&self) -> u64 {
(self.later - self.earlier).whole_hours().max(0) as u64
}
fn hours_mod_12(&self) -> u64 {
let hours = self.hours_mod();
(hours + 11) % 12 + 1
}
fn minutes_mod(&self) -> u64 {
let total_minutes = (self.later - self.earlier).whole_minutes();
(total_minutes % 60).max(0) as u64
}
fn minutes(&self) -> u64 {
(self.later - self.earlier).whole_minutes().max(0) as u64
}
fn seconds_mod(&self) -> u64 {
let total_seconds = (self.later - self.earlier).whole_seconds();
(total_seconds % 60).max(0) as u64
}
fn seconds(&self) -> u64 {
(self.later - self.earlier).whole_seconds().max(0) as u64
}
fn decis(&self) -> u64 {
let total_millis = (self.later - self.earlier).whole_milliseconds();
((total_millis % 1000) / 100).max(0) as u64
}
fn millis(&self) -> u128 {
(self.later - self.earlier).whole_milliseconds().max(0) as u128
}
}
#[derive(Debug, Clone, Copy, PartialOrd)] #[derive(Debug, Clone, Copy, PartialOrd)]
pub struct DurationEx { pub struct DurationEx {
inner: Duration, inner: Duration,
@ -254,60 +43,40 @@ impl From<DurationEx> for Duration {
} }
} }
impl ClockDuration for DurationEx { impl DurationEx {
fn years(&self) -> u64 { pub fn seconds(&self) -> u64 {
self.days() / DAYS_PER_YEAR
}
fn days(&self) -> u64 {
self.hours() / HOURS_PER_DAY
}
fn days_mod(&self) -> u64 {
self.days() % DAYS_PER_YEAR
}
fn hours(&self) -> u64 {
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
}
fn hours_mod(&self) -> u64 {
self.hours() % HOURS_PER_DAY
}
fn hours_mod_12(&self) -> u64 {
// 0 => 12,
// 1..=12 => hours,
// 13..=23 => hours - 12,
(self.hours_mod() + 11) % 12 + 1
}
fn minutes(&self) -> u64 {
self.seconds() / MINS_PER_HOUR
}
fn minutes_mod(&self) -> u64 {
self.minutes() % SECS_PER_MINUTE
}
fn seconds(&self) -> u64 {
self.inner.as_secs() self.inner.as_secs()
} }
fn seconds_mod(&self) -> u64 { pub fn seconds_mod(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE self.seconds() % SECS_PER_MINUTE
} }
fn decis(&self) -> u64 { pub fn hours(&self) -> u64 {
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
}
pub fn hours_mod(&self) -> u64 {
self.hours() % HOURS_PER_DAY
}
pub fn minutes(&self) -> u64 {
self.seconds() / MINS_PER_HOUR
}
pub fn minutes_mod(&self) -> u64 {
self.minutes() % SECS_PER_MINUTE
}
// deciseconds
pub fn decis(&self) -> u64 {
(self.inner.subsec_millis() / 100) as u64 (self.inner.subsec_millis() / 100) as u64
} }
// milliseconds
fn millis(&self) -> u128 { pub fn millis(&self) -> u128 {
self.inner.as_millis() self.inner.as_millis()
} }
}
impl DurationEx {
pub fn saturating_add(&self, ex: DurationEx) -> Self { pub fn saturating_add(&self, ex: DurationEx) -> Self {
let inner = self.inner.saturating_add(ex.inner); let inner = self.inner.saturating_add(ex.inner);
Self { inner } Self { inner }
@ -325,27 +94,7 @@ impl DurationEx {
impl fmt::Display for DurationEx { impl fmt::Display for DurationEx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use ClockDuration as _; // Import trait methods if self.hours() >= 10 {
if self.years() >= 1 {
write!(
f,
"{}y {}d {:02}:{:02}:{:02}",
self.years(),
self.days_mod(),
self.hours_mod(),
self.minutes_mod(),
self.seconds_mod(),
)
} else if self.hours() >= HOURS_PER_DAY {
write!(
f,
"{}d {:02}:{:02}:{:02}",
self.days_mod(),
self.hours_mod(),
self.minutes_mod(),
self.seconds_mod(),
)
} else if self.hours() >= 10 {
write!( write!(
f, f,
"{:02}:{:02}:{:02}", "{:02}:{:02}:{:02}",
@ -373,166 +122,73 @@ impl fmt::Display for DurationEx {
} }
} }
/// Parse seconds (must be < 60)
fn parse_seconds(s: &str) -> Result<u8, Report> {
let secs = s.parse::<u8>().map_err(|_| eyre!("Invalid seconds"))?;
ensure!(secs < 60, "Seconds must be less than 60.");
Ok(secs)
}
/// Parse minutes (must be < 60)
fn parse_minutes(m: &str) -> Result<u8, Report> {
let mins = m.parse::<u8>().map_err(|_| eyre!("Invalid minutes"))?;
ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins)
}
/// Parse hours
fn parse_hours(h: &str) -> Result<u8, Report> {
let hours = h.parse::<u8>().map_err(|_| eyre!("Invalid hours"))?;
Ok(hours)
}
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss` /// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
pub fn parse_duration(arg: &str) -> Result<Duration, Report> { pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
let parts: Vec<&str> = arg.split(':').collect(); let parts: Vec<&str> = arg.split(':').rev().collect();
let (hours, minutes, seconds) = match parts.as_slice() { let parse_seconds = |s: &str| -> Result<u64, Report> {
[ss] => { let secs = s.parse::<u64>().map_err(|_| eyre!("Invalid seconds"))?;
// Single part: seconds only ensure!(secs < 60, "Seconds must be less than 60.");
let s = parse_seconds(ss)?; Ok(secs)
(0u64, 0u64, s as u64)
}
[mm, ss] => {
// Two parts: MM:SS
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(0u64, m as u64, s as u64)
}
[hh, mm, ss] => {
// Three parts: HH:MM:SS
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(h as u64, m as u64, s as u64)
}
_ => {
return Err(eyre!(
"Invalid time format. Use 'ss', 'mm:ss', or 'hh:mm:ss'"
));
}
}; };
let total_seconds = hours * 3600 + minutes * 60 + seconds; let parse_minutes = |m: &str| -> Result<u64, Report> {
Ok(Duration::from_secs(total_seconds)) let mins = m.parse::<u64>().map_err(|_| eyre!("Invalid minutes"))?;
} ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins)
};
/// Similar to `parse_duration`, but it parses `years` and `days` in addition let parse_hours = |h: &str| -> Result<u64, Report> {
/// Formats: `Yy Dd`, `Yy` or `Dd` in any combination to other time formats let hours = h.parse::<u64>().map_err(|_| eyre!("Invalid hours"))?;
/// Examples: `10y 3d 12:10:03`, `2d 10:00`, `101y 33`, `5:30` ensure!(hours < 100, "Hours must be less than 100.");
pub fn parse_long_duration(arg: &str) -> Result<Duration, Report> { Ok(hours)
let arg = arg.trim(); };
// parts are separated by whitespaces: let seconds = match parts.as_slice() {
// 3 parts: years, days, time [ss] => parse_seconds(ss)?,
let parts: Vec<&str> = arg.split_whitespace().collect(); [ss, mm] => {
ensure!(parts.len() <= 3, "Invalid format. Too many parts."); let s = parse_seconds(ss)?;
let m = parse_minutes(mm)?;
let mut total_duration = Duration::ZERO; m * 60 + s
let mut time_part: Option<&str> = None;
for part in parts {
// years
if let Some(years_str) = part.strip_suffix('y') {
let years = years_str
.parse::<u64>()
.map_err(|_| eyre!("Invalid years value: '{}'", years_str))?;
total_duration = total_duration.saturating_add(ONE_YEAR.saturating_mul(years as u32));
} }
// days [ss, mm, hh] => {
else if let Some(days_str) = part.strip_suffix('d') { let s = parse_seconds(ss)?;
let days = days_str let m = parse_minutes(mm)?;
.parse::<u64>() let h = parse_hours(hh)?;
.map_err(|_| eyre!("Invalid days value: '{}'", days_str))?; h * 60 * 60 + m * 60 + s
total_duration = total_duration.saturating_add(ONE_DAY.saturating_mul(days as u32));
} }
// possible time format _ => return Err(eyre!("Invalid time format. Use 'ss', mm:ss, or hh:mm:ss")),
else { };
time_part = Some(part);
}
}
// time format Ok(Duration::from_secs(seconds))
if let Some(time) = time_part {
let time_duration = parse_duration(time)?;
total_duration = total_duration.saturating_add(time_duration);
}
// avoid overflow
total_duration = min(MAX_DURATION, total_duration);
Ok(total_duration)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ClockDuration;
use super::*; use super::*;
use std::time::Duration; use std::time::Duration;
const MINUTE_IN_SECONDS: u64 = ONE_MINUTE.as_secs();
const HOUR_IN_SECONDS: u64 = ONE_HOUR.as_secs();
const DAY_IN_SECONDS: u64 = ONE_DAY.as_secs();
const YEAR_IN_SECONDS: u64 = ONE_YEAR.as_secs();
#[test] #[test]
fn test_fmt() { fn test_fmt() {
// 1y Dd hh:mm:ss (single year)
let ex: DurationEx =
Duration::from_secs(YEAR_IN_SECONDS + 10 * DAY_IN_SECONDS + 36001).into();
assert_eq!(format!("{ex}"), "1y 10d 10:00:01");
// 5y Dd hh:mm:ss (multiple years)
let ex: DurationEx = Duration::from_secs(
5 * YEAR_IN_SECONDS + 100 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1,
)
.into();
assert_eq!(format!("{ex}"), "5y 100d 10:00:01");
// 150y Dd hh:mm:ss (more than 100 years)
let ex: DurationEx = Duration::from_secs(
150 * YEAR_IN_SECONDS + 200 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1,
)
.into();
assert_eq!(format!("{ex}"), "150y 200d 10:00:01");
// 366d hh:mm:ss (days more than a year)
let ex: DurationEx =
Duration::from_secs(366 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1y 1d 10:00:01");
// 1d hh:mm:ss (single day)
let ex: DurationEx = Duration::from_secs(DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1d 10:00:01");
// 2d hh:mm:ss (multiple days)
let ex: DurationEx =
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "2d 10:00:01");
// hh:mm:ss // hh:mm:ss
let ex: DurationEx = Duration::from_secs(10 * HOUR_IN_SECONDS + 1).into(); let ex: DurationEx = Duration::from_secs(36001).into();
assert_eq!(format!("{ex}"), "10:00:01"); assert_eq!(format!("{}", ex), "10:00:01");
// h:mm:ss // h:mm:ss
let ex: DurationEx = Duration::from_secs(HOUR_IN_SECONDS + 1).into(); let ex: DurationEx = Duration::from_secs(3601).into();
assert_eq!(format!("{ex}"), "1:00:01"); assert_eq!(format!("{}", ex), "1:00:01");
// mm:ss // mm:ss
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 11).into(); let ex: DurationEx = Duration::from_secs(71).into();
assert_eq!(format!("{ex}"), "1:11"); assert_eq!(format!("{}", ex), "1:11");
// m:ss // m:ss
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 1).into(); let ex: DurationEx = Duration::from_secs(61).into();
assert_eq!(format!("{ex}"), "1:01"); assert_eq!(format!("{}", ex), "1:01");
// ss // ss
let ex: DurationEx = Duration::from_secs(11).into(); let ex: DurationEx = Duration::from_secs(11).into();
assert_eq!(format!("{ex}"), "11"); assert_eq!(format!("{}", ex), "11");
// s // s
let ex: DurationEx = Duration::from_secs(1).into(); let ex: DurationEx = Duration::from_secs(1).into();
assert_eq!(format!("{ex}"), "1"); assert_eq!(format!("{}", ex), "1");
} }
#[test] #[test]
@ -540,7 +196,7 @@ mod tests {
let ex: DurationEx = Duration::from_secs(10).into(); let ex: DurationEx = Duration::from_secs(10).into();
let ex2: DurationEx = Duration::from_secs(1).into(); let ex2: DurationEx = Duration::from_secs(1).into();
let ex3 = ex.saturating_sub(ex2); let ex3 = ex.saturating_sub(ex2);
assert_eq!(format!("{ex3}"), "9"); assert_eq!(format!("{}", ex3), "9");
} }
#[test] #[test]
@ -548,35 +204,7 @@ mod tests {
let ex: DurationEx = Duration::from_secs(10).into(); let ex: DurationEx = Duration::from_secs(10).into();
let ex2: DurationEx = Duration::from_secs(1).into(); let ex2: DurationEx = Duration::from_secs(1).into();
let ex3 = ex.saturating_add(ex2); let ex3 = ex.saturating_add(ex2);
assert_eq!(format!("{ex3}"), "11"); assert_eq!(format!("{}", ex3), "11");
}
#[test]
fn test_hours_mod_12() {
// 24 -> 12
let ex: DurationEx = ONE_HOUR.saturating_mul(24).into();
let result = ex.hours_mod_12();
assert_eq!(result, 12);
// 12 -> 12
let ex: DurationEx = ONE_HOUR.saturating_mul(12).into();
let result = ex.hours_mod_12();
assert_eq!(result, 12);
// 0 -> 12
let ex: DurationEx = ONE_SECOND.into();
let result = ex.hours_mod_12();
assert_eq!(result, 12);
// 13 -> 1
let ex: DurationEx = ONE_HOUR.saturating_mul(13).into();
let result = ex.hours_mod_12();
assert_eq!(result, 1);
// 1 -> 1
let ex: DurationEx = ONE_HOUR.saturating_mul(1).into();
let result = ex.hours_mod_12();
assert_eq!(result, 1);
} }
#[test] #[test]
@ -596,257 +224,8 @@ mod tests {
// errors // errors
assert!(parse_duration("1:60").is_err()); // invalid seconds assert!(parse_duration("1:60").is_err()); // invalid seconds
assert!(parse_duration("60:00").is_err()); // invalid minutes assert!(parse_duration("60:00").is_err()); // invalid minutes
assert!(parse_duration("100:00:00").is_err()); // invalid hours
assert!(parse_duration("abc").is_err()); // invalid input assert!(parse_duration("abc").is_err()); // invalid input
assert!(parse_duration("01:02:03:04").is_err()); // too many parts assert!(parse_duration("01:02:03:04").is_err()); // too many parts
} }
#[test]
fn test_parse_long_duration() {
// `Yy`
assert_eq!(
parse_long_duration("10y").unwrap(),
Duration::from_secs(10 * YEAR_IN_SECONDS)
);
assert_eq!(
parse_long_duration("101y").unwrap(),
Duration::from_secs(101 * YEAR_IN_SECONDS)
);
// `Dd`
assert_eq!(
parse_long_duration("2d").unwrap(),
Duration::from_secs(2 * DAY_IN_SECONDS)
);
// `Yy Dd`
assert_eq!(
parse_long_duration("10y 3d").unwrap(),
Duration::from_secs(10 * YEAR_IN_SECONDS + 3 * DAY_IN_SECONDS)
);
// `Yy Dd hh:mm:ss`
assert_eq!(
parse_long_duration("10y 3d 12:10:03").unwrap(),
Duration::from_secs(
10 * YEAR_IN_SECONDS
+ 3 * DAY_IN_SECONDS
+ 12 * HOUR_IN_SECONDS
+ 10 * MINUTE_IN_SECONDS
+ 3
)
);
// `Dd hh:mm`
assert_eq!(
parse_long_duration("2d 10:00").unwrap(),
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * 60)
);
// `Yy ss`
assert_eq!(
parse_long_duration("101y 33").unwrap(),
Duration::from_secs(101 * YEAR_IN_SECONDS + 33)
);
// time formats (backward compatibility with `parse_duration`)
assert_eq!(
parse_long_duration("5:30").unwrap(),
Duration::from_secs(5 * MINUTE_IN_SECONDS + 30)
);
assert_eq!(
parse_long_duration("01:30:45").unwrap(),
Duration::from_secs(HOUR_IN_SECONDS + 30 * MINUTE_IN_SECONDS + 45)
);
assert_eq!(parse_long_duration("42").unwrap(), Duration::from_secs(42));
// `Dd ss`
assert_eq!(
parse_long_duration("5d 30").unwrap(),
Duration::from_secs(5 * DAY_IN_SECONDS + 30)
);
// `Yy hh:mm:ss`
assert_eq!(
parse_long_duration("1y 01:30:00").unwrap(),
Duration::from_secs(YEAR_IN_SECONDS + HOUR_IN_SECONDS + 30 * MINUTE_IN_SECONDS)
);
// Whitespace handling
assert_eq!(
parse_long_duration(" 2d 10:00 ").unwrap(),
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * MINUTE_IN_SECONDS)
);
// MAX_DURATION clamping
assert_eq!(parse_long_duration("10000y").unwrap(), MAX_DURATION);
assert_eq!(
parse_long_duration("9999y 364d 23:59:59").unwrap(),
Duration::from_secs(
9999 * YEAR_IN_SECONDS
+ 364 * DAY_IN_SECONDS
+ 23 * HOUR_IN_SECONDS
+ 59 * MINUTE_IN_SECONDS
+ 59
)
);
// errors
assert!(parse_long_duration("10x").is_err()); // invalid unit
assert!(parse_long_duration("abc").is_err()); // invalid input
assert!(parse_long_duration("10y 60:00").is_err()); // invalid minutes in time part
assert!(parse_long_duration("5d 1:60").is_err()); // invalid seconds in time part
assert!(parse_long_duration("1y 2d 3d 4:00").is_err()); // too many parts (4 parts)
assert!(parse_long_duration("1y 2d 3h 4m 5s").is_err()); // too many parts (5 parts)
}
#[test]
fn test_calendar_duration_leap_year() {
use time::macros::datetime;
// 2024 is a leap year (366 days)
let start = datetime!(2024-01-01 00:00:00 UTC);
let end = datetime!(2025-01-01 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 1, "Should be exactly 1 year");
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
assert_eq!(cal_dur.days(), 366, "2024 has 366 days (leap year)");
}
#[test]
fn test_calendar_duration_non_leap_year() {
use time::macros::datetime;
// 2023 is not a leap year (365 days)
let start = datetime!(2023-01-01 00:00:00 UTC);
let end = datetime!(2024-01-01 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 1, "Should be exactly 1 year");
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
assert_eq!(cal_dur.days(), 365, "2023 has 365 days (non-leap year)");
}
#[test]
fn test_calendar_duration_partial_year_with_leap_day() {
use time::macros::datetime;
// Span including Feb 29, 2024
let start = datetime!(2024-02-01 00:00:00 UTC);
let end = datetime!(2024-03-15 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0, "Should be 0 years");
// Feb 2024 has 29 days, so: 29 days (rest of Feb) + 15 days (March) = 44 days
assert_eq!(
cal_dur.days(),
43,
"Should be 43 days (29 in Feb + 14 partial March)"
);
}
#[test]
fn test_calendar_duration_partial_year_without_leap_day() {
use time::macros::datetime;
// Same dates but in 2023 (non-leap year)
let start = datetime!(2023-02-01 00:00:00 UTC);
let end = datetime!(2023-03-15 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0, "Should be 0 years");
// Feb 2023 has 28 days, so: 28 days (rest of Feb) + 15 days (March) = 43 days
assert_eq!(
cal_dur.days(),
42,
"Should be 42 days (28 in Feb + 14 partial March)"
);
}
#[test]
fn test_calendar_duration_multiple_years_spanning_leap_years() {
use time::macros::datetime;
// From 2023 (non-leap) through 2024 (leap) to 2025
let start = datetime!(2023-03-01 10:00:00 UTC);
let end = datetime!(2025-03-01 10:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 2, "Should be exactly 2 years");
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
// Total days: 365 (2023 partial + 2024 partial) + 366 (full 2024 year conceptually included)
// Actually: From 2023-03-01 to 2025-03-01 = 365 + 366 = 731 days
assert_eq!(cal_dur.days(), 731, "Should be 731 total days");
}
#[test]
fn test_calendar_duration_year_boundary() {
use time::macros::datetime;
// Test incomplete year - just before year boundary
let start = datetime!(2024-01-01 00:00:00 UTC);
let end = datetime!(2024-12-31 23:59:59 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0, "Should be 0 years (not complete)");
assert_eq!(cal_dur.days(), 365, "Should be 365 days");
}
#[test]
fn test_calendar_duration_hours_minutes_seconds() {
use time::macros::datetime;
let start = datetime!(2024-01-01 10:30:45 UTC);
let end = datetime!(2024-01-02 14:25:50 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0);
assert_eq!(cal_dur.days(), 1);
assert_eq!(cal_dur.hours_mod(), 3, "Should be 3 hours past midnight");
assert_eq!(cal_dur.minutes_mod(), 55, "Should be 55 minutes");
assert_eq!(cal_dur.seconds_mod(), 5, "Should be 5 seconds");
}
#[test]
fn test_calendar_duration_reversed_dates() {
use time::macros::datetime;
// CalendarDuration::between should handle reversed order
let later = datetime!(2025-01-01 00:00:00 UTC);
let earlier = datetime!(2024-01-01 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(later, earlier);
assert_eq!(cal_dur.years(), 1, "Should still calculate 1 year");
assert_eq!(cal_dur.days(), 366, "Should still be 366 days");
}
#[test]
fn test_calendar_duration_same_date() {
use time::macros::datetime;
let date = datetime!(2024-06-15 12:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(date, date);
assert_eq!(cal_dur.years(), 0);
assert_eq!(cal_dur.days(), 0);
assert_eq!(cal_dur.hours(), 0);
assert_eq!(cal_dur.minutes(), 0);
assert_eq!(cal_dur.seconds(), 0);
}
#[test]
fn test_calendar_duration_deciseconds() {
use time::macros::datetime;
let start = datetime!(2024-01-01 00:00:00.000 UTC);
let end = datetime!(2024-01-01 00:00:00.750 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(
cal_dur.decis(),
7,
"Should be 7 deciseconds (750ms = 7.5 decis, truncated to 7)"
);
assert_eq!(cal_dur.millis(), 750, "Should be 750 milliseconds");
}
} }

View File

@ -1,169 +0,0 @@
use serde::{Deserialize, Serialize};
use time::macros::{datetime, format_description};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Event {
pub date_time: time::PrimitiveDateTime,
pub title: Option<String>,
}
impl Default for Event {
fn default() -> Self {
Self {
// Mario Bros. "...entered mass production in Japan on June 21" 1983
// https://en.wikipedia.org/wiki/Mario_Bros.#Release
date_time: datetime!(1983-06-21 00:00),
title: Some("Release date of Mario Bros. in Japan".into()),
}
}
}
/// Parses an `Event`
/// Supports two formats:
/// (1) "YYYY-MM-DD HH:MM:SS"
/// (2) "time=YYYY-MM-DD HH:MM:SS,title=my event"
pub fn parse_event(s: &str) -> Result<Event, String> {
let s = s.trim();
// check + parse (2)
if s.contains('=') {
parse_event_key_value(s)
} else {
// parse (1)
parse_event_date_time(s)
}
}
/// Parses an `Event` based on "YYYY-MM-DD HH:MM:SS" format
fn parse_event_date_time(s: &str) -> Result<Event, String> {
let time = time::PrimitiveDateTime::parse(
s,
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.map_err(|e| {
format!(
"Failed to parse event date_time '{}': {}. Expected format: 'YYYY-MM-DD HH:MM:SS'",
s, e
)
})?;
Ok(Event {
date_time: time,
title: None,
})
}
/// Parses an `Event` defined by a `key=value` pair.
/// Valid keys: `time` and `title`.
/// Format: "time=YYYY-MM-DD HH:MM:SS,title=my event"
fn parse_event_key_value(s: &str) -> Result<Event, String> {
let mut time_str = None;
let mut title_str = None;
// k/v pairs are splitted by commas
for part in s.split(',') {
let part = part.trim();
if let Some((key, value)) = part.split_once('=') {
match key.trim() {
"time" => time_str = Some(value.trim()),
"title" => title_str = Some(value.trim()),
unknown => {
return Err(format!(
"Unknown key '{}'. Valid keys: 'time', 'title'",
unknown
));
}
}
} else {
return Err(format!(
"Invalid key=value pair: '{}'. Expected format: 'key=value'",
part
));
}
}
let time_str = time_str.ok_or(
"Missing required 'time' field. Expected format: 'time=YYYY-MM-DD HH:MM:SS[,title=...]'",
)?;
let time = time::PrimitiveDateTime::parse(
time_str,
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.map_err(|e| {
format!(
"Failed to parse event time '{}': {}. Expected format: 'YYYY-MM-DD HH:MM:SS'",
time_str, e
)
})?;
let title = title_str.filter(|t| !t.is_empty()).map(|t| t.to_string());
Ok(Event {
date_time: time,
title,
})
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn test_parse_event() {
// Simple format: time only
let result = parse_event("2024-01-01 14:30:00").unwrap();
assert_eq!(result.date_time, datetime!(2024-01-01 14:30:00));
assert_eq!(result.title, None);
// Simple format: with leading/trailing whitespace (outer trim works)
let result = parse_event(" 2025-12-25 12:30:00 ").unwrap();
assert_eq!(result.date_time, datetime!(2025-12-25 12:30:00));
assert_eq!(result.title, None);
// Key=value format: time only
let result = parse_event("time=2025-10-10 14:30:00").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, None);
// Key=value format: time and title
let result = parse_event("time=2025-10-10 14:30:00,title=Team Meeting").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, Some("Team Meeting".to_string()));
// Key=value format: order independent
let result = parse_event("title=Stand-up,time=2025-10-10 09:00:00").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 09:00:00));
assert_eq!(result.title, Some("Stand-up".to_string()));
// Key=value format: title with spaces and special chars
let result =
parse_event("time=2025-10-10 14:30:00,title=Sprint Planning: Q1 Review").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, Some("Sprint Planning: Q1 Review".to_string()));
// Key=value format: empty title treated as None
let result = parse_event("time=2025-10-10 14:30:00,title=").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, None);
// Key=value format: whitespace handling
let result = parse_event(" time = 2025-10-10 14:30:00 , title = My Event ").unwrap();
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
assert_eq!(result.title, Some("My Event".to_string()));
// Error cases: invalid time format
assert!(parse_event("2025-13-01 00:00:00").is_err());
assert!(parse_event("invalid").is_err());
assert!(parse_event("2025/10/10 14:30:00").is_err());
// Error cases: missing time in key=value format
assert!(parse_event("title=My Event").is_err());
// Error cases: unknown key
assert!(parse_event("time=2025-10-10 14:30:00,foo=bar").is_err());
// Error cases: malformed key=value pair
assert!(parse_event("time=2025-10-10 14:30:00,notapair").is_err());
}
}

View File

@ -1,12 +1,9 @@
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
use ratatui::layout::Position;
use std::{pin::Pin, time::Duration}; use std::{pin::Pin, time::Duration};
use tokio::sync::mpsc;
use tokio::time::interval; use tokio::time::interval;
use tokio_stream::{StreamMap, wrappers::IntervalStream}; use tokio_stream::{wrappers::IntervalStream, StreamMap};
use crate::common::ClockTypeId;
use crate::constants::{FPS_VALUE_MS, TICK_VALUE_MS}; use crate::constants::{FPS_VALUE_MS, TICK_VALUE_MS};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
@ -15,27 +12,17 @@ enum StreamKey {
Render, Render,
Crossterm, Crossterm,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum TuiEvent { pub enum Event {
Error, Error,
Tick, Tick,
Render, Render,
Crossterm(CrosstermEvent), Key(KeyEvent),
Resize,
} }
#[derive(Clone, Debug)]
pub enum AppEvent {
ClockDone(ClockTypeId, String),
SetCursor(Option<Position>),
}
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
pub type AppEventRx = mpsc::UnboundedReceiver<AppEvent>;
pub struct Events { pub struct Events {
streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = TuiEvent>>>>, streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = Event>>>>,
app_channel: (AppEventTx, AppEventRx),
} }
impl Default for Events { impl Default for Events {
@ -46,62 +33,48 @@ impl Default for Events {
(StreamKey::Render, render_stream()), (StreamKey::Render, render_stream()),
(StreamKey::Crossterm, crossterm_stream()), (StreamKey::Crossterm, crossterm_stream()),
]), ]),
app_channel: mpsc::unbounded_channel(),
} }
} }
} }
pub enum Event {
Terminal(TuiEvent),
App(AppEvent),
}
impl Events { impl Events {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
pub async fn next(&mut self) -> Option<Event> { pub async fn next(&mut self) -> Option<Event> {
let streams = &mut self.streams; self.streams.next().await.map(|(_, event)| event)
let app_rx = &mut self.app_channel.1;
tokio::select! {
Some((_, event)) = streams.next() => Some(Event::Terminal(event)),
Some(app_event) = app_rx.recv() => Some(Event::App(app_event)),
}
}
pub fn get_app_event_tx(&self) -> AppEventTx {
self.app_channel.0.clone()
} }
} }
fn tick_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> { fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
let tick_interval = interval(Duration::from_millis(TICK_VALUE_MS)); let tick_interval = interval(Duration::from_millis(TICK_VALUE_MS));
Box::pin(IntervalStream::new(tick_interval).map(|_| TuiEvent::Tick)) Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick))
} }
fn render_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> { fn render_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
let render_interval = interval(Duration::from_millis(FPS_VALUE_MS)); let render_interval = interval(Duration::from_millis(FPS_VALUE_MS));
Box::pin(IntervalStream::new(render_interval).map(|_| TuiEvent::Render)) Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render))
} }
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> { fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
Box::pin( Box::pin(
EventStream::new() EventStream::new()
.fuse() .fuse()
// we are not interested in all events // we are not interested in all events
.filter_map(|result| async move { .filter_map(|event| async move {
match result { match event {
// filter `KeyEventKind::Press` out to ignore all the other `CrosstermEvent::Key` events Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
Ok(CrosstermEvent::Key(key)) => (key.kind == KeyEventKind::Press) Some(Event::Key(key))
.then_some(TuiEvent::Crossterm(CrosstermEvent::Key(key))), }
Ok(other) => Some(TuiEvent::Crossterm(other)), Ok(CrosstermEvent::Resize(_, _)) => Some(Event::Resize),
Err(_) => Some(TuiEvent::Error), Err(_) => Some(Event::Error),
_ => None,
} }
}), }),
) )
} }
pub trait TuiEventHandler { pub trait EventHandler {
fn update(&mut self, _: TuiEvent) -> Option<TuiEvent>; fn update(&mut self, _: Event) -> Option<Event>;
} }

View File

@ -1,4 +1,4 @@
use color_eyre::eyre::{Result, eyre}; use color_eyre::eyre::Result;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
@ -17,13 +17,7 @@ impl Logger {
pub fn init(&self) -> Result<()> { pub fn init(&self) -> Result<()> {
let log_path = self.log_dir.join("app.log"); let log_path = self.log_dir.join("app.log");
let log_file = fs::File::create(log_path).map_err(|err| { let log_file = fs::File::create(log_path)?;
eyre!(
"Could not create a log file in {:?} : {}",
self.log_dir,
err
)
})?;
let fmt_layer = tracing_subscriber::fmt::layer() let fmt_layer = tracing_subscriber::fmt::layer()
.with_file(true) .with_file(true)
.with_line_number(true) .with_line_number(true)

View File

@ -2,8 +2,8 @@ mod app;
mod common; mod common;
mod config; mod config;
mod constants; mod constants;
mod event;
mod events; mod events;
#[cfg(debug_assertions)]
mod logging; mod logging;
mod args; mod args;
@ -13,50 +13,24 @@ mod terminal;
mod utils; mod utils;
mod widgets; mod widgets;
#[cfg(feature = "sound")] use app::{App, AppArgs};
mod sound; use args::Args;
use app::{App, FromAppArgs};
use args::{Args, LOG_DIRECTORY_DEFAULT_MISSING_VALUE};
use clap::Parser; use clap::Parser;
use color_eyre::Result; use color_eyre::Result;
use config::Config; use config::Config;
use std::path::PathBuf;
use storage::{AppStorage, Storage}; use storage::{AppStorage, Storage};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// init `Config`
let cfg = Config::init()?; let cfg = Config::init()?;
#[cfg(debug_assertions)]
logging::Logger::new(cfg.log_dir).init()?;
color_eyre::install()?; color_eyre::install()?;
// get args given by CLI // get args given by CLI
let args = Args::parse(); let args = Args::parse();
// Note:
// `log` arg can have three different values:
// (1) not set => None
// (2) set with path => Some(Some(path))
// (3) set without path => Some(None)
let custom_log_dir: Option<Option<&PathBuf>> = if let Some(path) = &args.log {
if path.ne(PathBuf::from(LOG_DIRECTORY_DEFAULT_MISSING_VALUE).as_os_str()) {
// (2)
Some(Some(path))
} else {
// (3)
Some(None)
}
} else {
// (1)
None
};
if let Some(log_dir) = custom_log_dir { let terminal = terminal::setup()?;
let dir: PathBuf = log_dir.unwrap_or(&cfg.log_dir).to_path_buf();
logging::Logger::new(dir).init()?;
}
let mut terminal = terminal::setup()?;
let events = events::Events::new(); let events = events::Events::new();
// check persistant storage // check persistant storage
@ -68,14 +42,9 @@ async fn main() -> Result<()> {
storage.load().unwrap_or_default() storage.load().unwrap_or_default()
}; };
let app_storage = App::from(FromAppArgs { // merge `Args` and `AppStorage`.
args, let app_args = AppArgs::from((args, stg));
stg, let app_storage = App::new(app_args).run(terminal, events).await?.to_storage();
app_tx: events.get_app_event_tx(),
})
.run(&mut terminal, events)
.await?
.to_storage();
// store app state persistantly // store app state persistantly
storage.save(app_storage)?; storage.save(app_storage)?;

View File

@ -1,73 +0,0 @@
use rodio::{Decoder, OutputStream, Sink};
use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SoundError {
#[error("Sound output stream error: {0}")]
OutputStream(String),
#[error("Sound file error: {0}")]
File(String),
#[error("Sound sink error: {0}")]
Sink(String),
#[error("Sound decoder error: {0}")]
Decoder(String),
}
pub fn validate_sound_file(path: &PathBuf) -> Result<&PathBuf, SoundError> {
// validate path
if !path.exists() {
let err = SoundError::File(format!("File not found: {:?}", path));
return Err(err);
};
// Validate file extension
path.extension()
.and_then(|ext| ext.to_str())
.filter(|ext| ["mp3", "wav"].contains(&ext.to_lowercase().as_str()))
.ok_or_else(|| {
SoundError::File(
"Unsupported file extension. Only .mp3 and .wav are supported".to_owned(),
)
})?;
Ok(path)
}
// #[derive(Clone)]
pub struct Sound {
path: PathBuf,
}
impl Sound {
pub fn new(path: PathBuf) -> Result<Self, SoundError> {
Ok(Self { path })
}
pub fn play(&self) -> Result<(), SoundError> {
// validate file again
validate_sound_file(&self.path)?;
// before playing the sound
let path = self.path.clone();
std::thread::spawn(move || -> Result<(), SoundError> {
// Important note: Never (ever) use a single `_` as a placeholder here. `_stream` or something is fine!
// The value will dropped and the sound will fail without any errors
// see https://github.com/RustAudio/rodio/issues/330
let (_stream, handle) =
OutputStream::try_default().map_err(|e| SoundError::OutputStream(e.to_string()))?;
let file = File::open(&path).map_err(|e| SoundError::File(e.to_string()))?;
let sink = Sink::try_new(&handle).map_err(|e| SoundError::Sink(e.to_string()))?;
let decoder = Decoder::new(BufReader::new(file))
.map_err(|e| SoundError::Decoder(e.to_string()))?;
sink.append(decoder);
sink.sleep_until_end();
Ok(())
});
Ok(())
}
}

View File

@ -1,38 +1,21 @@
use crate::{ use crate::{
common::{AppTimeFormat, Content, Style, Toggle}, common::{AppTimeFormat, Content, Style},
event::Event,
widgets::pomodoro::Mode as PomodoroMode, widgets::pomodoro::Mode as PomodoroMode,
}; };
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
fn deserialize_app_time_format<'de, D>(deserializer: D) -> Result<AppTimeFormat, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
// Hidden is deprecated - use `default` value instead
"Hidden" => Ok(AppTimeFormat::default()),
_ => s.parse().map_err(serde::de::Error::custom),
}
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct AppStorage { pub struct AppStorage {
pub content: Content, pub content: Content,
pub show_menu: bool, pub show_menu: bool,
pub notification: Toggle,
pub blink: Toggle,
#[serde(deserialize_with = "deserialize_app_time_format")]
pub app_time_format: AppTimeFormat, pub app_time_format: AppTimeFormat,
pub style: Style, pub style: Style,
pub with_decis: bool, pub with_decis: bool,
pub pomodoro_mode: PomodoroMode, pub pomodoro_mode: PomodoroMode,
pub pomodoro_count: u64,
// pomodoro -> work // pomodoro -> work
pub inital_value_work: Duration, pub inital_value_work: Duration,
pub current_value_work: Duration, pub current_value_work: Duration,
@ -45,10 +28,6 @@ pub struct AppStorage {
pub elapsed_value_countdown: Duration, pub elapsed_value_countdown: Duration,
// timer // timer
pub current_value_timer: Duration, pub current_value_timer: Duration,
// event
pub event: Event,
// footer
pub footer_app_time: Toggle,
} }
impl Default for AppStorage { impl Default for AppStorage {
@ -59,13 +38,10 @@ impl Default for AppStorage {
AppStorage { AppStorage {
content: Content::default(), content: Content::default(),
show_menu: true, show_menu: true,
notification: Toggle::Off,
blink: Toggle::Off,
app_time_format: AppTimeFormat::default(), app_time_format: AppTimeFormat::default(),
style: Style::default(), style: Style::default(),
with_decis: false, with_decis: false,
pomodoro_mode: PomodoroMode::Work, pomodoro_mode: PomodoroMode::Work,
pomodoro_count: 1,
// pomodoro -> work // pomodoro -> work
inital_value_work: DEFAULT_WORK, inital_value_work: DEFAULT_WORK,
current_value_work: DEFAULT_WORK, current_value_work: DEFAULT_WORK,
@ -78,10 +54,6 @@ impl Default for AppStorage {
elapsed_value_countdown: Duration::ZERO, elapsed_value_countdown: Duration::ZERO,
// timer // timer
current_value_timer: Duration::ZERO, current_value_timer: Duration::ZERO,
// event
event: Event::default(),
// footer
footer_app_time: Toggle::Off,
} }
} }
} }

View File

@ -5,14 +5,13 @@ use crossterm::{
cursor, execute, cursor, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen}, terminal::{EnterAlternateScreen, LeaveAlternateScreen},
}; };
use ratatui::{Terminal as RatatuiTerminal, backend::CrosstermBackend}; use ratatui::{backend::CrosstermBackend, Terminal as RatatuiTerminal};
pub type Terminal = RatatuiTerminal<CrosstermBackend<io::Stdout>>; pub type Terminal = RatatuiTerminal<CrosstermBackend<io::Stdout>>;
pub fn setup() -> Result<Terminal> { pub fn setup() -> Result<Terminal> {
let mut stdout = std::io::stdout(); let mut stdout = std::io::stdout();
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
set_panic_hook();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?; execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout))?; let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout))?;
terminal.clear()?; terminal.clear()?;
@ -25,13 +24,3 @@ pub fn teardown() -> Result<()> {
crossterm::terminal::disable_raw_mode()?; crossterm::terminal::disable_raw_mode()?;
Ok(()) Ok(())
} }
// Panic hook
// see https://ratatui.rs/tutorials/counter-app/error-handling/#setup-hooks
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = teardown(); // ignore any errors as we are already failing
hook(panic_info);
}));
}

View File

@ -5,11 +5,8 @@ pub mod clock_elements_test;
#[cfg(test)] #[cfg(test)]
pub mod clock_test; pub mod clock_test;
pub mod countdown; pub mod countdown;
pub mod edit_time;
pub mod event;
pub mod footer; pub mod footer;
pub mod header; pub mod header;
pub mod local_time;
pub mod pomodoro; pub mod pomodoro;
pub mod progressbar; pub mod progressbar;
pub mod timer; pub mod timer;

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,8 @@ use ratatui::{
pub const DIGIT_SIZE: usize = 5; pub const DIGIT_SIZE: usize = 5;
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16; pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */; pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */;
pub const TWO_DIGITS_WIDTH: u16 = DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit
pub const THREE_DIGITS_WIDTH: u16 = TWO_DIGITS_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit
pub const FOUR_DIGITS_WIDTH: u16 = THREE_DIGITS_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit-space-digit
pub const COLON_WIDTH: u16 = 4; // incl. padding left + padding right pub const COLON_WIDTH: u16 = 4; // incl. padding left + padding right
pub const DOT_WIDTH: u16 = 4; // incl. padding left + padding right pub const DOT_WIDTH: u16 = 4; // incl. padding left + padding right
pub const DIGIT_SPACE_WIDTH: u16 = 1; // space between digits
pub const DIGIT_LABEL_WIDTH: u16 = 3; // label (single char) incl. padding left + padding right
#[rustfmt::skip] #[rustfmt::skip]
const DIGIT_0: [u8; DIGIT_SIZE * DIGIT_SIZE] = [ const DIGIT_0: [u8; DIGIT_SIZE * DIGIT_SIZE] = [

View File

@ -1,314 +1,34 @@
use crate::{ use crate::{
common::ClockTypeId, duration::{ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND},
duration::{
DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND,
ONE_YEAR,
},
widgets::clock::*, widgets::clock::*,
}; };
use std::time::Duration; use std::time::Duration;
fn default_args() -> ClockStateArgs {
ClockStateArgs {
initial_value: ONE_HOUR,
current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
}
}
#[test] #[test]
fn test_type_id() { fn test_toggle_edit() {
let c = ClockState::<Timer>::new(default_args());
assert!(matches!(c.get_type_id(), ClockTypeId::Timer));
let c = ClockState::<Countdown>::new(default_args());
assert!(matches!(c.get_type_id(), ClockTypeId::Countdown));
}
#[test]
fn test_get_format_seconds() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND * 5,
current_value: ONE_SECOND * 5,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// S
assert_eq!(c.get_format(), &Format::S);
// Ss
c.set_current_value(Duration::from_secs(15).into());
assert_eq!(c.get_format(), &Format::Ss);
}
#[test]
fn test_get_format_minutes() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_MINUTE,
current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// MSs
assert_eq!(c.get_format(), &Format::MSs);
// MmSs
c.set_current_value((ONE_MINUTE * 11).into()); // 10+ minutes
assert_eq!(c.get_format(), &Format::MmSs);
}
#[test]
fn test_get_format_hours() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR, initial_value: ONE_HOUR,
current_value: ONE_HOUR, current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: true,
app_tx: None,
}); });
// HMmSS // off by default
assert_eq!(c.get_format(), &Format::HMmSs); assert!(!c.is_edit_mode());
// HhMmSs // toggle on
c.set_current_value((10 * ONE_HOUR).into()); c.toggle_edit();
assert_eq!(c.get_format(), &Format::HhMmSs); assert!(c.is_edit_mode());
} // toggle off
c.toggle_edit();
#[test] assert!(!c.is_edit_mode());
fn test_format_by_duration_boundaries() {
// S
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_SECOND * 9).into()),
Format::S
);
// Ss
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_SECOND).into()),
Format::Ss
);
// Ss
assert_eq!(
format_by_duration::<DurationEx>(&(59 * ONE_SECOND).into()),
Format::Ss
);
// MSs
assert_eq!(
format_by_duration::<DurationEx>(&ONE_MINUTE.into()),
Format::MSs
);
// HhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
Format::HhMmSs
);
// DHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&ONE_DAY.into()),
Format::DHhMmSs
);
// DHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::DHhMmSs
);
// DdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_DAY).into()),
Format::DdHhMmSs
);
// DdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::DdHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_DAY).into()),
Format::DddHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
Format::DddHhMmSs
);
// YDHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
Format::YDHhMmSs
);
// YDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(
&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YDdHhMmSs
);
// YDddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YDddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyDdHhMmSs
);
// YyDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(
&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YyDdHhMmSs
);
// YyDddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyDddHhMmSs
);
// YyDddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
Format::YyDddHhMmSs
);
// YyyDHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyDdHhMmSs
);
// YyyDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(
&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YyyDdHhMmSs
);
// YyyDddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyDddHhMmSs
);
// YyyyDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(1000 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyyDdHhMmSs
);
// YyyyDdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(
&(1000 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YyyyDdHhMmSs
);
// YyyyDddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(1000 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyyDddHhMmSs
);
}
#[test]
fn test_format_by_duration_days() {
// DHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&ONE_DAY.into()),
Format::DHhMmSs
);
// DdHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_DAY).into()),
Format::DdHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration::<DurationEx>(&(101 * ONE_DAY).into()),
Format::DddHhMmSs
);
}
#[test]
fn test_format_by_duration_years() {
// YDHhMmSs (1 year, 0 days)
assert_eq!(
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
Format::YDHhMmSs
);
// YDHhMmSs (1 year, 1 day)
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_YEAR + ONE_DAY).into()),
Format::YDHhMmSs
);
// YDdHhMmSs (1 year, 10 days)
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_YEAR + 10 * ONE_DAY).into()),
Format::YDdHhMmSs
);
// YDddHhMmSs (1 year, 100 days)
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs (10 years)
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs (10 years, 10 days)
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyDdHhMmSs
);
// YyDddHhMmSs (10 years, 100 days)
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyDddHhMmSs
);
// YyyDHhMmSs (100 years)
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs (100 years, 10 days)
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyDdHhMmSs
);
// YyyDddHhMmSs (100 years, 100 days)
assert_eq!(
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyDddHhMmSs
);
} }
#[test] #[test]
fn test_default_edit_mode_hhmmss() { fn test_default_edit_mode_hhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR,
current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
..default_args()
}); });
// toggle on // toggle on
@ -323,7 +43,6 @@ fn test_default_edit_mode_mmss() {
current_value: ONE_MINUTE, current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
c.toggle_edit(); c.toggle_edit();
@ -337,285 +56,12 @@ fn test_default_edit_mode_ss() {
current_value: ONE_SECOND, current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
c.toggle_edit(); c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
} }
#[test]
fn test_edit_up_stays_in_seconds() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_MINUTE - ONE_SECOND,
current_value: ONE_MINUTE - ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_up();
// Edit mode should stay on seconds
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_up_stays_in_minutes() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR - ONE_SECOND,
current_value: ONE_HOUR - ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_up();
// Edit mode should stay on minutes
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_up_stays_in_hours() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY - ONE_SECOND,
current_value: ONE_DAY - ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_up();
// Edit mode should stay on hours
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
}
#[test]
fn test_edit_up_stays_in_days() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR - ONE_DAY,
current_value: ONE_YEAR - ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_up();
// Edit mode should stay on days
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
}
#[test]
fn test_edit_up_overflow_protection() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: MAX_DURATION.saturating_sub(ONE_SECOND),
current_value: MAX_DURATION.saturating_sub(ONE_SECOND),
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
c.edit_next(); // Years
c.edit_up(); // +1y
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Days
c.edit_up(); // +1d
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Hours
c.edit_up(); // +1h
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Minutes
c.edit_up(); // +1m
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Sec.
c.edit_up(); // +1s
c.edit_up(); // +1s
c.edit_up(); // +1s
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
}
#[test]
fn test_edit_down_years_to_days() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR + ONE_DAY,
current_value: ONE_YEAR + ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
c.edit_next(); // Years
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
}
#[test]
fn test_edit_down_days_to_hours() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY + ONE_HOUR,
current_value: ONE_DAY + ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
}
#[test]
fn test_edit_down_hours_to_minutes() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR + ONE_MINUTE,
current_value: ONE_HOUR + ONE_MINUTE,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_down_minutes_to_seconds() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_MINUTE,
current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_next_ydddhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_hours_in_dhhmmss_format() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY + ONE_HOUR,
current_value: ONE_DAY + ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Move to Hours
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
// Increment hours - should stay in Hours edit mode
c.edit_up();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
assert_eq!(
Duration::from(*c.get_current_value()),
ONE_DAY + 2 * ONE_HOUR
);
}
#[test]
fn test_edit_next_ydddhhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_next_dhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY,
current_value: ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes (following existing pattern)
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test] #[test]
fn test_edit_next_hhmmssd() { fn test_edit_next_hhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@ -623,7 +69,6 @@ fn test_edit_next_hhmmssd() {
current_value: ONE_HOUR, current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
@ -631,10 +76,6 @@ fn test_edit_next_hhmmssd() {
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
@ -649,7 +90,6 @@ fn test_edit_next_hhmmss() {
current_value: ONE_HOUR, current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -657,10 +97,6 @@ fn test_edit_next_hhmmss() {
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
@ -673,7 +109,6 @@ fn test_edit_next_mmssd() {
current_value: ONE_MINUTE, current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
@ -693,7 +128,6 @@ fn test_edit_next_mmss() {
current_value: ONE_MINUTE, current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -711,7 +145,6 @@ fn test_edit_next_ssd() {
current_value: ONE_SECOND * 3, current_value: ONE_SECOND * 3,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
@ -720,25 +153,6 @@ fn test_edit_next_ssd() {
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
} }
#[test]
fn test_edit_next_sd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test] #[test]
fn test_edit_next_ss() { fn test_edit_next_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@ -746,109 +160,15 @@ fn test_edit_next_ss() {
current_value: ONE_SECOND * 3, current_value: ONE_SECOND * 3,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
c.toggle_edit(); c.toggle_edit();
c.edit_next(); c.edit_next();
println!("mode -> {:?}", c.get_mode());
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
} }
#[test]
fn test_edit_next_s() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_prev_ydddhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_prev_ydddhhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_prev_dhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY,
current_value: ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test] #[test]
fn test_edit_prev_hhmmssd() { fn test_edit_prev_hhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@ -856,7 +176,6 @@ fn test_edit_prev_hhmmssd() {
current_value: ONE_HOUR, current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
@ -877,7 +196,6 @@ fn test_edit_prev_hhmmss() {
current_value: ONE_HOUR, current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -896,7 +214,6 @@ fn test_edit_prev_mmssd() {
current_value: ONE_MINUTE, current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
@ -917,7 +234,6 @@ fn test_edit_prev_mmss() {
current_value: ONE_MINUTE, current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -936,26 +252,6 @@ fn test_edit_prev_ssd() {
current_value: ONE_SECOND, current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: true,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_prev_sd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
}); });
// toggle on // toggle on
@ -974,24 +270,6 @@ fn test_edit_prev_ss() {
current_value: ONE_SECOND, current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: false, with_decis: false,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_prev_s() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -1007,8 +285,7 @@ fn test_edit_up_ss() {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: Duration::ZERO, current_value: Duration::ZERO,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -1024,8 +301,7 @@ fn test_edit_up_mmss() {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: Duration::from_secs(60), current_value: Duration::from_secs(60),
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -1044,8 +320,7 @@ fn test_edit_up_hhmmss() {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: Duration::from_secs(3600), current_value: Duration::from_secs(3600),
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -1066,8 +341,7 @@ fn test_edit_down_ss() {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: ONE_SECOND, current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -1087,8 +361,7 @@ fn test_edit_down_mmss() {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: Duration::from_secs(120), current_value: Duration::from_secs(120),
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on
@ -1110,8 +383,7 @@ fn test_edit_down_hhmmss() {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: Duration::from_secs(3600), current_value: Duration::from_secs(3600),
tick_value: ONE_DECI_SECOND, tick_value: ONE_DECI_SECOND,
with_decis: true, with_decis: false,
app_tx: None,
}); });
// toggle on // toggle on

View File

@ -1,15 +1,3 @@
use crate::{
common::{AppTime, Style},
constants::TICK_VALUE_MS,
duration::{DurationEx, MAX_DURATION},
events::{AppEventTx, TuiEvent, TuiEventHandler},
utils::center,
widgets::{
clock::{self, ClockState, ClockStateArgs, ClockWidget, Mode as ClockMode},
edit_time::{EditTimeState, EditTimeStateArgs, EditTimeWidget},
},
};
use crossterm::event::{Event as CrosstermEvent, KeyModifiers};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::KeyCode, crossterm::event::KeyCode,
@ -17,57 +5,36 @@ use ratatui::{
text::Line, text::Line,
widgets::{StatefulWidget, Widget}, widgets::{StatefulWidget, Widget},
}; };
use std::ops::Sub;
use std::{cmp::max, time::Duration}; use std::{cmp::max, time::Duration};
use time::OffsetDateTime;
pub struct CountdownStateArgs { use crate::{
pub initial_value: Duration, common::Style,
pub current_value: Duration, constants::TICK_VALUE_MS,
pub elapsed_value: Duration, duration::DurationEx,
pub app_time: AppTime, events::{Event, EventHandler},
pub with_decis: bool, utils::center,
pub app_tx: AppEventTx, widgets::clock::{self, ClockState, ClockStateArgs, ClockWidget, Mode as ClockMode},
} };
/// State for Countdown Widget /// State for Countdown Widget
#[derive(Debug, Clone)]
pub struct CountdownState { pub struct CountdownState {
/// clock to count down /// clock to count down
clock: ClockState<clock::Countdown>, clock: ClockState<clock::Countdown>,
/// clock to count time after `DONE` - similar to Mission Elapsed Time (MET) /// clock to count time after `DONE` - similar to Mission Elapsed Time (MET)
elapsed_clock: ClockState<clock::Timer>, elapsed_clock: ClockState<clock::Timer>,
app_time: AppTime,
/// Edit by local time
edit_time: Option<EditTimeState>,
} }
impl CountdownState { impl CountdownState {
pub fn new(args: CountdownStateArgs) -> Self { pub fn new(clock: ClockState<clock::Countdown>, elapsed_value: Duration) -> Self {
let CountdownStateArgs {
initial_value,
current_value,
elapsed_value,
with_decis,
app_time,
app_tx,
} = args;
Self { Self {
clock: ClockState::<clock::Countdown>::new(ClockStateArgs { clock,
initial_value,
current_value,
tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis,
app_tx: Some(app_tx.clone()),
}),
elapsed_clock: ClockState::<clock::Timer>::new(ClockStateArgs { elapsed_clock: ClockState::<clock::Timer>::new(ClockStateArgs {
initial_value: Duration::ZERO, initial_value: Duration::ZERO,
current_value: elapsed_value, current_value: elapsed_value,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis: false, with_decis: false,
app_tx: None,
}) })
.with_name("MET".to_owned())
// A previous `elapsed_value > 0` means the `Clock` was running before, // A previous `elapsed_value > 0` means the `Clock` was running before,
// but not in `Initial` state anymore. Updating `Mode` here // but not in `Initial` state anymore. Updating `Mode` here
// is needed to handle `Event::Tick` in `EventHandler::update` properly // is needed to handle `Event::Tick` in `EventHandler::update` properly
@ -76,8 +43,6 @@ impl CountdownState {
} else { } else {
ClockMode::Initial ClockMode::Initial
}), }),
app_time,
edit_time: None,
} }
} }
@ -97,182 +62,27 @@ impl CountdownState {
pub fn get_elapsed_value(&self) -> &DurationEx { pub fn get_elapsed_value(&self) -> &DurationEx {
self.elapsed_clock.get_current_value() self.elapsed_clock.get_current_value()
} }
pub fn set_app_time(&mut self, app_time: AppTime) {
self.app_time = app_time;
}
fn time_to_edit(&self) -> OffsetDateTime {
// get current value
let d: Duration = (*self.clock.get_current_value()).into();
// transform
let dd = time::Duration::try_from(d).unwrap_or(time::Duration::ZERO);
// substract from `app_time`
OffsetDateTime::from(self.app_time).saturating_add(dd)
}
pub fn min_time_to_edit(&self) -> OffsetDateTime {
OffsetDateTime::from(self.app_time)
}
fn max_time_to_edit(&self) -> OffsetDateTime {
OffsetDateTime::from(self.app_time)
.saturating_add(time::Duration::try_from(MAX_DURATION).unwrap_or(time::Duration::ZERO))
}
fn edit_time_done(&mut self, edit_time: &mut EditTimeState) {
// get diff
let d: time::Duration = edit_time
.get_time()
.sub(OffsetDateTime::from(self.app_time));
// transfrom
let dx: DurationEx = Duration::try_from(d).unwrap_or(Duration::ZERO).into();
// update clock
self.clock.set_current_value(dx);
// remove `edit_time`
self.edit_time = None;
}
pub fn is_clock_edit_mode(&self) -> bool {
self.clock.is_edit_mode()
}
pub fn is_time_edit_mode(&self) -> bool {
self.edit_time.is_some()
}
} }
impl TuiEventHandler for CountdownState { impl EventHandler for CountdownState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> { fn update(&mut self, event: Event) -> Option<Event> {
let edit_mode = self.clock.is_edit_mode();
match event { match event {
TuiEvent::Tick => { Event::Tick => {
if !self.clock.is_done() { if !self.clock.is_done() {
self.clock.tick(); self.clock.tick();
} else { } else {
self.clock.update_done_count();
self.elapsed_clock.tick(); self.elapsed_clock.tick();
if self.elapsed_clock.is_initial() { if self.elapsed_clock.is_initial() {
self.elapsed_clock.run(); self.elapsed_clock.run();
} }
} }
let min_time = self.min_time_to_edit();
let max_time = self.max_time_to_edit();
if let Some(edit_time) = &mut self.edit_time {
edit_time.set_min_time(min_time);
edit_time.set_max_time(max_time);
}
} }
// EDIT CLOCK mode Event::Key(key) => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_clock_edit_mode() => {
match key.code {
// skip editing
KeyCode::Esc => {
// Important: set current value first
self.clock.set_current_value(*self.clock.get_prev_value());
// before toggling back to non-edit mode
self.clock.toggle_edit();
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// toggle edit mode
self.clock.toggle_edit();
// set initial value
self.clock
.set_initial_value(*self.clock.get_current_value());
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// Apply changes
KeyCode::Char('s') => {
// toggle edit mode
self.clock.toggle_edit();
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
KeyCode::Right => {
self.clock.edit_prev();
}
KeyCode::Left => {
self.clock.edit_next();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
KeyCode::Up => {
self.clock.edit_up();
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => {
self.clock.edit_down();
}
_ => return Some(event),
}
}
// EDIT LOCAL TIME mode
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_time_edit_mode() => {
match key.code {
// skip editing
KeyCode::Esc => {
self.edit_time = None;
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(edit_time) = &mut self.edit_time.clone() {
// Order matters:
// 1. update current value
self.edit_time_done(edit_time);
// 2. set initial value
self.clock
.set_initial_value(*self.clock.get_current_value());
}
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// Apply changes of editing by local time
KeyCode::Char('s') => {
if let Some(edit_time) = &mut self.edit_time.clone() {
self.edit_time_done(edit_time)
}
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// move edit position to the left
KeyCode::Left => {
// safe unwrap because we are in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().next();
}
// move edit position to the right
KeyCode::Right => {
// safe unwrap because we are in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().prev();
}
// Value up
KeyCode::Up => {
// safe unwrap because of previous check in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().up();
}
// Value down
KeyCode::Down => {
// safe unwrap because of previous check in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().down();
}
_ => return Some(event),
}
}
// default mode
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
KeyCode::Char('r') => { KeyCode::Char('r') => {
// reset both clocks to use intial values // reset both clocks
self.clock.reset(); self.clock.reset();
self.elapsed_clock.reset(); self.elapsed_clock.reset();
// reset `edit_time` back initial value
let time = self.time_to_edit();
if let Some(edit_time) = &mut self.edit_time {
edit_time.set_time(time);
}
} }
KeyCode::Char('s') => { KeyCode::Char('s') => {
// toggle pause status depending on which clock is running // toggle pause status depending on which clock is running
@ -281,36 +91,30 @@ impl TuiEventHandler for CountdownState {
} else { } else {
self.elapsed_clock.toggle_pause(); self.elapsed_clock.toggle_pause();
} }
// finish `edit_time` and continue for using `clock`
if let Some(edit_time) = &mut self.edit_time.clone() {
self.edit_time_done(edit_time);
}
} }
// Enter edit by local time mode
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// set `edit_time`
self.edit_time = Some(EditTimeState::new(EditTimeStateArgs {
time: self.time_to_edit(),
min: self.min_time_to_edit(),
max: self.max_time_to_edit(),
}));
// pause `elapsed_clock`
if self.elapsed_clock.is_running() {
self.elapsed_clock.toggle_pause();
}
}
// Enter edit clock mode
KeyCode::Char('e') => { KeyCode::Char('e') => {
// toggle edit mode
self.clock.toggle_edit(); self.clock.toggle_edit();
// stop + reset timer entering `edit` mode
// pause `elapsed_clock`
if self.elapsed_clock.is_running() { if self.elapsed_clock.is_running() {
self.elapsed_clock.toggle_pause(); self.elapsed_clock.toggle_pause();
} }
} }
KeyCode::Left if edit_mode => {
self.clock.edit_next();
}
KeyCode::Right if edit_mode => {
self.clock.edit_prev();
}
KeyCode::Up if edit_mode => {
self.clock.edit_up();
// whenever `clock`'s value is changed, reset `elapsed_clock`
self.elapsed_clock.reset();
}
KeyCode::Down if edit_mode => {
self.clock.edit_down();
// whenever clock value is changed, reset timer
self.elapsed_clock.reset();
}
_ => return Some(event), _ => return Some(event),
}, },
_ => return Some(event), _ => return Some(event),
@ -321,81 +125,49 @@ impl TuiEventHandler for CountdownState {
pub struct Countdown { pub struct Countdown {
pub style: Style, pub style: Style,
pub blink: bool,
}
fn human_days_diff(a: &OffsetDateTime, b: &OffsetDateTime) -> String {
let days_diff = (a.date() - b.date()).whole_days();
match days_diff {
0 => "today".to_owned(),
1 => "tomorrow".to_owned(),
n => format!("+{n}days"),
}
} }
impl StatefulWidget for Countdown { impl StatefulWidget for Countdown {
type State = CountdownState; type State = CountdownState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// render `edit_time` OR `clock` let clock = ClockWidget::new(self.style);
if let Some(edit_time) = &mut state.edit_time {
let label = Line::raw(
format!(
"Countdown {} {}",
edit_time.get_selected().clone(),
human_days_diff(edit_time.get_time(), &state.app_time.into())
)
.to_uppercase(),
);
let widget = EditTimeWidget::new(self.style);
let area = center(
area,
Constraint::Length(max(widget.get_width(), label.width() as u16)),
Constraint::Length(widget.get_height() + 1 /* height of label */),
);
let [v1, v2] =
Layout::vertical(Constraint::from_lengths([widget.get_height(), 1])).areas(area);
widget.render(v1, buf, edit_time); let label = Line::raw(
label.centered().render(v2, buf); if state.clock.is_done() {
} else { if state.clock.with_decis {
let label = Line::raw( format!(
if state.clock.is_done() { "Countdown {} +{}",
if state.clock.with_decis { state.clock.get_mode(),
format!( state
"Countdown {} +{}", .elapsed_clock
state.clock.get_mode(), .get_current_value()
state .to_string_with_decis()
.elapsed_clock )
.get_current_value()
.to_string_with_decis()
)
} else {
format!(
"Countdown {} +{}",
state.clock.get_mode(),
state.elapsed_clock.get_current_value()
)
}
} else { } else {
format!("Countdown {}", state.clock.get_mode()) format!(
"Countdown {} +{}",
state.clock.get_mode(),
state.elapsed_clock.get_current_value()
)
} }
.to_uppercase(), } else {
); format!("Countdown {}", state.clock.get_mode())
let widget = ClockWidget::new(self.style, self.blink); }
.to_uppercase(),
);
let area = center( let area = center(
area, area,
Constraint::Length(max( Constraint::Length(max(
widget.get_width(state.clock.get_format(), state.clock.with_decis), clock.get_width(&state.clock.get_format(), state.clock.with_decis),
label.width() as u16, label.width() as u16,
)), )),
Constraint::Length(widget.get_height() + 1 /* height of label */), Constraint::Length(clock.get_height() + 1 /* height of label */),
); );
let [v1, v2] = let [v1, v2] =
Layout::vertical(Constraint::from_lengths([widget.get_height(), 1])).areas(area); Layout::vertical(Constraint::from_lengths([clock.get_height(), 1])).areas(area);
widget.render(v1, buf, &mut state.clock); clock.render(v1, buf, &mut state.clock);
label.centered().render(v2, buf); label.centered().render(v2, buf);
}
} }
} }

View File

@ -1,231 +0,0 @@
use std::fmt;
use time::OffsetDateTime;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
widgets::{StatefulWidget, Widget},
};
use crate::{
common::Style,
widgets::clock_elements::{COLON_WIDTH, Colon, DIGIT_SPACE_WIDTH, DIGIT_WIDTH, Digit},
};
use super::clock_elements::DIGIT_HEIGHT;
#[derive(Debug, Clone)]
pub enum Selected {
Seconds,
Minutes,
Hours,
}
impl Selected {
pub fn next(&self) -> Self {
match self {
Selected::Seconds => Selected::Minutes,
Selected::Minutes => Selected::Hours,
Selected::Hours => Selected::Seconds,
}
}
pub fn prev(&self) -> Self {
match self {
Selected::Seconds => Selected::Hours,
Selected::Minutes => Selected::Seconds,
Selected::Hours => Selected::Minutes,
}
}
}
impl fmt::Display for Selected {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Selected::Seconds => write!(f, "[edit seconds]"),
Selected::Minutes => write!(f, "[edit minutes]"),
Selected::Hours => write!(f, "[edit hours]"),
}
}
}
#[derive(Debug, Clone)]
pub struct EditTimeState {
selected: Selected,
time: OffsetDateTime,
min: OffsetDateTime,
max: OffsetDateTime,
}
#[derive(Debug, Clone)]
pub struct EditTimeStateArgs {
pub time: OffsetDateTime,
pub min: OffsetDateTime,
pub max: OffsetDateTime,
}
impl EditTimeState {
pub fn new(args: EditTimeStateArgs) -> Self {
EditTimeState {
time: args.time,
min: args.min,
max: args.max,
selected: Selected::Minutes,
}
}
pub fn set_time(&mut self, time: OffsetDateTime) {
self.time = time;
}
pub fn set_min_time(&mut self, min: OffsetDateTime) {
self.min = min;
}
pub fn set_max_time(&mut self, min: OffsetDateTime) {
self.max = min;
}
pub fn get_time(&mut self) -> &OffsetDateTime {
&self.time
}
pub fn get_selected(&mut self) -> &Selected {
&self.selected
}
pub fn next(&mut self) {
self.selected = self.selected.next();
}
pub fn prev(&mut self) {
self.selected = self.selected.prev();
}
pub fn up(&mut self) {
self.time = match self.selected {
Selected::Seconds => {
if self
.time
.lt(&self.max.saturating_sub(time::Duration::new(1, 0)))
{
self.time.saturating_add(time::Duration::new(1, 0))
} else {
self.time
}
}
Selected::Minutes => {
if self
.time
.lt(&self.max.saturating_sub(time::Duration::new(60, 0)))
{
self.time.saturating_add(time::Duration::new(60, 0))
} else {
self.time
}
}
Selected::Hours => {
if self
.time
.lt(&self.max.saturating_sub(time::Duration::new(60 * 60, 0)))
{
self.time.saturating_add(time::Duration::new(60 * 60, 0))
} else {
self.time
}
}
}
}
pub fn down(&mut self) {
self.time = match self.selected {
Selected::Seconds => {
if self
.time
.ge(&self.min.saturating_add(time::Duration::new(1, 0)))
{
self.time.saturating_sub(time::Duration::new(1, 0))
} else {
self.time
}
}
Selected::Minutes => {
if self
.time
.ge(&self.min.saturating_add(time::Duration::new(60, 0)))
{
self.time.saturating_sub(time::Duration::new(60, 0))
} else {
self.time
}
}
Selected::Hours => {
if self
.time
.ge(&self.min.saturating_add(time::Duration::new(60 * 60, 0)))
{
self.time.saturating_sub(time::Duration::new(60 * 60, 0))
} else {
self.time
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct EditTimeWidget {
style: Style,
}
impl EditTimeWidget {
pub fn new(style: Style) -> Self {
Self { style }
}
fn get_horizontal_lengths(&self) -> Vec<u16> {
vec![
DIGIT_WIDTH, // h
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // h
COLON_WIDTH, // :
DIGIT_WIDTH, // m
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // m
COLON_WIDTH, // :
DIGIT_WIDTH, // s
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // s
]
}
pub fn get_width(&self) -> u16 {
self.get_horizontal_lengths().iter().sum()
}
pub fn get_height(&self) -> u16 {
DIGIT_HEIGHT
}
}
impl StatefulWidget for EditTimeWidget {
type State = EditTimeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let symbol = self.style.get_digit_symbol();
let edit_hours = matches!(state.selected, Selected::Hours);
let edit_minutes = matches!(state.selected, Selected::Minutes);
let edit_secs = matches!(state.selected, Selected::Seconds);
let [hh, _, h, c_hm, mm, _, m, c_ms, ss, _, s] =
Layout::horizontal(Constraint::from_lengths(self.get_horizontal_lengths())).areas(area);
Digit::new((state.time.hour() as u64) / 10, edit_hours, symbol).render(hh, buf);
Digit::new((state.time.hour() as u64) % 10, edit_hours, symbol).render(h, buf);
Colon::new(symbol).render(c_hm, buf);
Digit::new((state.time.minute() as u64) / 10, edit_minutes, symbol).render(mm, buf);
Digit::new((state.time.minute() as u64) % 10, edit_minutes, symbol).render(m, buf);
Colon::new(symbol).render(c_ms, buf);
Digit::new((state.time.second() as u64) / 10, edit_secs, symbol).render(ss, buf);
Digit::new((state.time.second() as u64) % 10, edit_secs, symbol).render(s, buf);
}
}

View File

@ -1,589 +0,0 @@
use color_eyre::{Report, eyre::eyre};
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Paragraph, StatefulWidget, Widget},
};
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
use tui_input::Input;
use tui_input::backend::crossterm::EventHandler;
use crate::{
common::{AppTime, ClockTypeId, Style as DigitStyle},
duration::CalendarDuration,
event::Event,
events::{AppEvent, AppEventTx, TuiEvent, TuiEventHandler},
utils::center,
widgets::{clock, clock_elements::DIGIT_HEIGHT},
};
use std::{cmp::max, time::Duration};
#[derive(Clone, Copy, Default)]
enum Editable {
#[default]
DateTime,
Title,
}
impl Editable {
pub fn next(&self) -> Self {
match self {
Editable::DateTime => Editable::Title,
Editable::Title => Editable::DateTime,
}
}
pub fn prev(&self) -> Self {
match self {
Editable::DateTime => Editable::Title,
Editable::Title => Editable::DateTime,
}
}
}
#[derive(Clone, Copy)]
enum EditMode {
None,
Editing(Editable),
}
impl EditMode {
fn is_editable(&self) -> bool {
match self {
EditMode::None => false,
EditMode::Editing(_) => true,
}
}
}
/// State for `EventWidget`
pub struct EventState {
title: Option<String>,
event_time: OffsetDateTime,
app_time: OffsetDateTime,
start_time: OffsetDateTime,
with_decis: bool,
/// counter to simulate `DONE` state
/// Default value: `None`
done_count: Option<u64>,
app_tx: AppEventTx,
// inputs
input_datetime: Input,
input_datetime_error: Option<Report>,
input_title: Input,
input_title_error: Option<Report>,
edit_mode: EditMode,
last_editable: Editable,
}
pub struct EventStateArgs {
pub app_time: AppTime,
pub event: Event,
pub with_decis: bool,
pub app_tx: AppEventTx,
}
impl EventState {
pub fn new(args: EventStateArgs) -> Self {
let EventStateArgs {
app_time,
event,
with_decis,
app_tx,
} = args;
let app_datetime = OffsetDateTime::from(app_time);
// assume event has as same `offset` as `app_time`
let event_offset = event.date_time.assume_offset(app_datetime.offset());
let input_datetime_value = format_offsetdatetime(&event_offset);
let input_title_value = event.title.clone().unwrap_or("".into());
Self {
title: event.title.clone(),
event_time: event_offset,
app_time: app_datetime,
start_time: app_datetime,
with_decis,
done_count: None,
app_tx,
input_datetime: Input::default().with_value(input_datetime_value),
input_datetime_error: None,
input_title: Input::default().with_value(input_title_value),
input_title_error: None,
edit_mode: EditMode::None,
last_editable: Editable::default(),
}
}
// Sets `app_time`
pub fn set_app_time(&mut self, app_time: AppTime) {
let app_datetime = OffsetDateTime::from(app_time);
self.app_time = app_datetime;
// Since updating `app_time` is like a `Tick`, we check `done` state here
self.check_done();
}
pub fn set_with_decis(&mut self, with_decis: bool) {
self.with_decis = with_decis;
}
pub fn get_event(&self) -> Event {
Event {
title: self.title.clone(),
date_time: time::PrimitiveDateTime::new(self.event_time.date(), self.event_time.time()),
}
}
pub fn get_percentage_done(&self) -> u16 {
get_percentage(self.start_time, self.event_time, self.app_time)
}
pub fn get_duration(&mut self) -> CalendarDuration {
CalendarDuration::from_start_end_times(self.event_time, self.app_time)
}
fn check_done(&mut self) {
let clock_duration = self.get_duration();
if clock_duration.is_since() {
let duration: Duration = clock_duration.into();
// give some offset to make sure we are around `Duration::ZERO`
// Without that we might miss it, because the app runs on its own FPS
if duration < Duration::from_millis(100) {
// reset `done_count`
self.done_count = Some(clock::MAX_DONE_COUNT);
// send notification
_ = self.app_tx.send(AppEvent::ClockDone(
ClockTypeId::Event,
self.title.clone().unwrap_or("".into()),
));
}
// count (possible) `done`
self.done_count = clock::count_clock_done(self.done_count);
}
}
fn reset_cursor(&mut self) {
_ = self.app_tx.send(AppEvent::SetCursor(None));
}
pub fn is_edit_mode(&self) -> bool {
self.edit_mode.is_editable()
}
fn reset_edit_mode(&mut self) {
self.edit_mode = EditMode::None;
}
fn reset_input_datetime(&mut self) {
self.input_datetime = Input::default().with_value(format_offsetdatetime(&self.event_time));
self.input_datetime_error = None;
}
fn reset_input_title(&mut self) {
self.input_title = Input::default().with_value(self.title.clone().unwrap_or_default());
self.input_title_error = None;
}
fn save_event_time(&mut self, date_time: PrimitiveDateTime) {
self.event_time =
// apply offset to be in sync with `AppTime`
date_time.assume_offset(self.app_time.offset());
}
fn save_title(&mut self, value: &str) {
self.title = if value.is_empty() {
None
} else {
Some(value.into())
};
}
fn prepare_switch_input(&mut self, editable: Editable) {
// before switching store valid values or reset inputs in case of errors
match editable {
Editable::DateTime => {
// accept valid values only
match validate_datetime(self.input_datetime.value()) {
Ok(dt) => self.save_event_time(dt),
Err(_) => self.reset_input_datetime(),
}
}
Editable::Title => match validate_title(self.input_title.clone().value()) {
Ok(title) => self.save_title(title),
Err(_) => self.reset_input_title(),
},
}
}
fn switch_input(&mut self, editable: Editable) {
self.edit_mode = EditMode::Editing(editable);
self.last_editable = editable;
}
}
fn validate_datetime(value: &str) -> Result<time::PrimitiveDateTime, Report> {
time::PrimitiveDateTime::parse(
value,
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.map_err(|_| eyre!("Expected format 'YYYY-MM-DD HH:MM:SS'"))
}
const MAX_LABEL_WIDTH: usize = 60;
fn validate_title(value: &str) -> Result<&str, Report> {
if value.len() > MAX_LABEL_WIDTH {
return Err(eyre!("Max. {} chars", MAX_LABEL_WIDTH));
}
Ok(value)
}
impl TuiEventHandler for EventState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
let editable = self.edit_mode.is_editable();
match event {
// EDIT mode
TuiEvent::Crossterm(crossterm_event @ CrosstermEvent::Key(key)) if editable => {
match key.code {
// Skip changes
KeyCode::Esc => {
// reset inputs
self.reset_input_datetime();
self.reset_input_title();
self.reset_edit_mode();
self.reset_cursor();
}
// switch to prev. input
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
if let EditMode::Editing(editable) = self.edit_mode {
self.prepare_switch_input(editable);
self.switch_input(editable.prev());
}
}
// switch to next input
KeyCode::Tab => {
if let EditMode::Editing(editable) = self.edit_mode {
self.prepare_switch_input(editable);
self.switch_input(editable.next());
}
}
KeyCode::Enter => match self.edit_mode {
EditMode::Editing(Editable::DateTime) => {
// accept valid values only
match validate_datetime(self.input_datetime.value()) {
Ok(dt) => {
self.save_event_time(dt);
self.reset_edit_mode();
self.reset_cursor();
}
Err(e) => self.input_datetime_error = Some(e),
}
}
EditMode::Editing(Editable::Title) => {
// accept valid values only
match validate_title(self.input_title.clone().value()) {
Ok(title) => {
self.save_title(title);
self.reset_edit_mode();
self.reset_cursor();
}
Err(e) => self.input_title_error = Some(e),
}
}
EditMode::None => {}
},
_ => match self.edit_mode {
EditMode::Editing(Editable::DateTime) => {
// push `CrosstermEvent` down to input
self.input_datetime.handle_event(&crossterm_event);
let value = self.input_datetime.value();
match self.input_datetime_error {
// To relax errors while typing:
// (A) Do a "full" validation of `datetime` in case of a previous error only
Some(_) => {
if let Err(e) = validate_datetime(value) {
self.input_datetime_error = Some(e);
} else {
self.input_datetime_error = None;
}
}
// (B) do a "light" validation of `datetime` in case of no previous error
None => {
// check length of expected format
if value.len() > 19 {
self.input_datetime_error =
Some(eyre!("Expected format 'YYYY-MM-DD HH:MM:SS'"))
} else {
self.input_datetime_error = None;
}
}
}
}
EditMode::Editing(Editable::Title) => {
// push `CrosstermEvent` down to input
self.input_title.handle_event(&crossterm_event);
// do always a validation while typing
if let Err(e) = validate_title(self.input_title.value()) {
self.input_title_error = Some(e);
} else {
self.input_title_error = None;
}
}
EditMode::None => {}
},
}
}
// NORMAL mode
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
// Enter edit mode
KeyCode::Char('e') => {
self.edit_mode = EditMode::Editing(self.last_editable);
}
_ => return Some(event),
},
_ => return Some(event),
}
None
}
}
fn get_percentage(start: OffsetDateTime, end: OffsetDateTime, current: OffsetDateTime) -> u16 {
let total_millis = (end - start).whole_milliseconds();
if total_millis <= 0 {
return 100;
}
let elapsed_millis = (current - start).whole_milliseconds();
if elapsed_millis <= 0 {
return 0;
}
let percentage = (elapsed_millis * 100 / total_millis).min(100);
percentage as u16
}
fn format_offsetdatetime(dt: &OffsetDateTime) -> String {
dt.format(&format_description!(
"[year]-[month]-[day] [hour]:[minute]:[second]"
))
.unwrap_or_else(|e| format!("time format error: {}", e))
}
#[derive(Debug)]
pub struct EventWidget {
pub style: DigitStyle,
pub blink: bool,
}
impl StatefulWidget for EventWidget {
type State = EventState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let with_decis = state.with_decis;
let clock_duration = state.get_duration();
let clock_format = clock::format_by_duration(&clock_duration);
let clock_widths = clock::clock_horizontal_lengths(&clock_format, with_decis);
let clock_width = clock_widths.iter().sum();
let area = center(
area,
Constraint::Length(max(clock_width, MAX_LABEL_WIDTH as u16)),
Constraint::Length(
DIGIT_HEIGHT + 7, /* height of all labels + empty lines */
),
);
let [_, v1, v2, v3, _, v4] = Layout::vertical(Constraint::from_lengths([
3, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only
DIGIT_HEIGHT,
1, // label: event date
1, // label: event title
1, // empty
1, // label: error
]))
.areas(area);
// To simulate a blink effect, just use an "empty" symbol (string)
// It's "empty" all digits and creates an "empty" render area
let symbol = if self.blink && clock::should_blink(state.done_count) {
" "
} else {
self.style.get_digit_symbol()
};
let render_clock_state = clock::RenderClockState {
with_decis,
duration: clock_duration.clone(),
editable_time: None,
format: clock_format,
symbol,
widths: clock_widths,
};
clock::render_clock(v1, buf, render_clock_state);
// Helper to calculate centered area, cursor x position, and scroll
let calc_editable_input_positions = |input: &Input, area: Rect| -> (Rect, u16, usize) {
// Calculate scroll position to keep cursor visible
let input_scroll = input.visual_scroll(area.width as usize);
// Get correct visual width (handles unicode properly)
let text_width = Line::raw(input.value()).width() as u16;
// Calculate visible text width after scrolling
let visible_text_width = text_width
.saturating_sub(input_scroll as u16)
.min(area.width);
// Center the visible portion
let offset_x = (area.width.saturating_sub(visible_text_width)) / 2;
let centered_area = Rect {
x: area.x + offset_x,
y: area.y,
width: visible_text_width,
height: area.height,
};
// Cursor position relative to the visible scrolled text
let cursor_offset = input.visual_cursor().saturating_sub(input_scroll);
let cursor_x = area.x + offset_x + cursor_offset as u16;
(centered_area, cursor_x, input_scroll)
};
fn input_edit_style(with_error: bool) -> Style {
if with_error {
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(Color::Red)
} else {
Style::default().add_modifier(Modifier::UNDERLINED)
}
}
// Render date time input
match state.edit_mode {
// EDIT
EditMode::Editing(Editable::DateTime) => {
let (datetime_area, datetime_cursor_x, datetime_scroll) =
calc_editable_input_positions(&state.input_datetime, v2);
Paragraph::new(state.input_datetime.value())
.style(input_edit_style(state.input_datetime_error.is_some()))
.scroll((0, datetime_scroll as u16))
.render(datetime_area, buf);
// Update cursor
let cp = Position::new(datetime_cursor_x, v2.y);
let _ = state.app_tx.send(AppEvent::SetCursor(Some(cp)));
}
// NORMAL
_ => {
let mut prefix = "Until";
if clock_duration.is_since() {
let duration: Duration = clock_duration.clone().into();
// Show `done` for a short of time (1 sec)
prefix = if duration < Duration::from_secs(1) {
"Done"
} else {
"Since"
};
};
Paragraph::new(format!(
"{} {}",
prefix.to_uppercase(),
state.input_datetime.value()
))
.centered()
.render(v2, buf);
}
};
// Render title input
match state.edit_mode {
// EDIT
EditMode::Editing(Editable::Title) => {
let (title_area, title_cursor_x, title_scroll) =
calc_editable_input_positions(&state.input_title, v3);
Paragraph::new(state.input_title.value().to_uppercase())
.style(input_edit_style(state.input_title_error.is_some()))
.scroll((0, title_scroll as u16))
.render(title_area, buf);
// Update cursor
let cp = Position::new(title_cursor_x, v3.y);
let _ = state.app_tx.send(AppEvent::SetCursor(Some(cp)));
}
// NORMAL
_ => {
Paragraph::new(state.input_title.value().to_uppercase())
.centered()
.render(v3, buf);
}
};
// Render error
let error_txt: String = match (&state.input_datetime_error, &state.input_title_error) {
(Some(e), _) => e.to_string(),
(_, Some(e)) => e.to_string(),
_ => "".into(),
};
Paragraph::new(error_txt.to_lowercase())
.style(Style::default().add_modifier(Modifier::ITALIC))
.centered()
.render(v4, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn test_get_percentage() {
let start = datetime!(2024-01-01 10:00:00 UTC);
let end = datetime!(2024-01-01 20:00:00 UTC);
// current == start: 0%
assert_eq!(get_percentage(start, end, start), 0);
// current == end: 100%
assert_eq!(get_percentage(start, end, end), 100);
// current halfway: 50%
let halfway = datetime!(2024-01-01 15:00:00 UTC);
assert_eq!(get_percentage(start, end, halfway), 50);
// current 25%
let quarter = datetime!(2024-01-01 12:30:00 UTC);
assert_eq!(get_percentage(start, end, quarter), 25);
// current 75%
let three_quarters = datetime!(2024-01-01 17:30:00 UTC);
assert_eq!(get_percentage(start, end, three_quarters), 75);
// current > end: clamped to 100%
let after = datetime!(2024-01-01 22:00:00 UTC);
assert_eq!(get_percentage(start, end, after), 100);
// current < start: 0%
let before = datetime!(2024-01-01 08:00:00 UTC);
assert_eq!(get_percentage(start, end, before), 0);
// end <= start: 100%
assert_eq!(get_percentage(end, start, halfway), 100);
assert_eq!(get_percentage(start, start, start), 100);
}
}

View File

@ -1,6 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::common::{AppEditMode, AppTime, AppTimeFormat, Content}; use crate::common::{AppTime, AppTimeFormat, Content};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
@ -13,11 +13,11 @@ use ratatui::{
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FooterState { pub struct FooterState {
show_menu: bool, show_menu: bool,
app_time_format: Option<AppTimeFormat>, app_time_format: AppTimeFormat,
} }
impl FooterState { impl FooterState {
pub const fn new(show_menu: bool, app_time_format: Option<AppTimeFormat>) -> Self { pub const fn new(show_menu: bool, app_time_format: AppTimeFormat) -> Self {
Self { Self {
show_menu, show_menu,
app_time_format, app_time_format,
@ -32,12 +32,12 @@ impl FooterState {
self.show_menu self.show_menu
} }
pub const fn app_time_format(&self) -> &Option<AppTimeFormat> { pub const fn app_time_format(&self) -> &AppTimeFormat {
&self.app_time_format &self.app_time_format
} }
pub const fn set_app_time_format(&mut self, value: Option<AppTimeFormat>) { pub fn toggle_app_time_format(&mut self) {
self.app_time_format = value; self.app_time_format = self.app_time_format.next();
} }
} }
@ -45,7 +45,7 @@ impl FooterState {
pub struct Footer { pub struct Footer {
pub running_clock: bool, pub running_clock: bool,
pub selected_content: Content, pub selected_content: Content,
pub app_edit_mode: AppEditMode, pub edit_mode: bool,
pub app_time: AppTime, pub app_time: AppTime,
} }
@ -53,11 +53,9 @@ impl StatefulWidget for Footer {
type State = FooterState; type State = FooterState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let content_labels: BTreeMap<Content, &str> = BTreeMap::from([ let content_labels: BTreeMap<Content, &str> = BTreeMap::from([
(Content::Countdown, "[1]countdown"), (Content::Countdown, "[c]ountdown"),
(Content::Timer, "[2]timer"), (Content::Timer, "[t]imer"),
(Content::Pomodoro, "[3]pomodoro"), (Content::Pomodoro, "[p]omodoro"),
(Content::Event, "[4]event"),
(Content::LocalTime, "[0]local time"),
]); ]);
let [_, area] = let [_, area] =
@ -73,26 +71,25 @@ impl StatefulWidget for Footer {
) )
.title( .title(
Line::from( Line::from(
match (state.app_time_format, self.selected_content) { match state.app_time_format {
// Show time // `Hidden` -> no (empty) title
(Some(v), content) if content != Content::LocalTime => format!(" {} " // add some space around AppTimeFormat::Hidden => "".into(),
, self.app_time.format(&v)), // others -> add some space around
// Hide time -> empty _ => format!(" {} ", self.app_time.format(&state.app_time_format))
_ => "".into(),
} }
).right_aligned()) ).right_aligned())
.border_set(border::PLAIN) .border_set(border::PLAIN)
.render(border_area, buf); .render(border_area, buf);
// show menu // show menu
if state.show_menu { if state.show_menu {
let mut content_labels: Vec<Span> = content_labels let content_labels: Vec<Span> = content_labels
.iter() .iter()
.enumerate() .enumerate()
.map(|(index, (content, label))| { .map(|(index, (content, label))| {
let mut style = Style::default(); let mut style = Style::default();
// Add space for all except last // Add space for all except last
let label = if index < content_labels.len() - 1 { let label = if index < content_labels.len() - 1 {
format!("{label} ") format!("{} ", label)
} else { } else {
label.to_string() label.to_string()
}; };
@ -103,177 +100,85 @@ impl StatefulWidget for Footer {
}) })
.collect(); .collect();
content_labels.extend_from_slice(&[
Span::from(SPACE),
Span::from("[→]next"),
Span::from(SPACE),
Span::from("[←]prev."),
]);
const SPACE: &str = " "; // 2 empty spaces const SPACE: &str = " "; // 2 empty spaces
let widths = [Constraint::Length(12), Constraint::Percentage(100)]; let widths = [Constraint::Length(12), Constraint::Percentage(100)];
let mut table_rows = vec![ let table = Table::new(
// screens [
Row::new(vec![ // content
Cell::from(Span::styled( Row::new(vec![
"screens", Cell::from(Span::styled(
Style::default().add_modifier(Modifier::BOLD), "screens",
)), Style::default().add_modifier(Modifier::BOLD),
Cell::from(Line::from(content_labels)),
]),
// appearance
Row::new(vec![
Cell::from(Span::styled(
"appearance",
Style::default().add_modifier(Modifier::BOLD),
)),
Cell::from(Line::from(vec![
Span::from("[,]change style"),
Span::from(SPACE),
Span::from("[.]toggle deciseconds"),
Span::from(SPACE),
Span::from(format!(
"[:]toggle {} time",
match self.app_time {
AppTime::Local(_) => "local",
AppTime::Utc(_) => "utc",
}
)), )),
])), Cell::from(Line::from(content_labels)),
]), ]),
]; // format
Row::new(vec![
// Controls (except for `localtime`) Cell::from(Span::styled(
if self.selected_content != Content::LocalTime { "appearance",
table_rows.extend_from_slice(&[ Style::default().add_modifier(Modifier::BOLD),
// controls - 1. row )),
Cell::from(Line::from(vec![
Span::from("[,]change style"),
Span::from(SPACE),
Span::from("[.]toggle deciseconds"),
Span::from(SPACE),
Span::from(format!(
"[:]toggle {} time",
match self.app_time {
AppTime::Local(_) => "local",
AppTime::Utc(_) => "utc",
}
)),
])),
]),
// edit
Row::new(vec![ Row::new(vec![
Cell::from(Span::styled( Cell::from(Span::styled(
"controls", "controls",
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
)), )),
Cell::from(Line::from({ Cell::from(Line::from({
match self.app_edit_mode { if self.edit_mode {
AppEditMode::None if self.selected_content != Content::Event => { vec![
let mut spans = vec![Span::from(if self.running_clock { Span::from("[e]dit done"),
Span::from(SPACE),
Span::from(format!(
"[{} {}]edit selection",
scrollbar::HORIZONTAL.begin,
scrollbar::HORIZONTAL.end
)), // ← →,
Span::from(SPACE),
Span::from(format!("[{}]edit up", scrollbar::VERTICAL.begin)), // ↑
Span::from(SPACE),
Span::from(format!("[{}]edit up", scrollbar::VERTICAL.end)), // ↓,
]
} else {
let mut spans = vec![
Span::from(if self.running_clock {
"[s]top" "[s]top"
} else { } else {
"[s]tart" "[s]tart"
})]; }),
Span::from(SPACE),
Span::from("[r]eset"),
Span::from(SPACE),
Span::from("[e]dit"),
];
if self.selected_content == Content::Pomodoro {
spans.extend_from_slice(&[ spans.extend_from_slice(&[
Span::from(SPACE), Span::from(SPACE),
Span::from("[e]dit"), Span::from("[← →]switch work/pause"),
]); ]);
if self.selected_content == Content::Countdown {
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[^e]dit by local time"),
]);
}
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[r]eset clock"),
]);
if self.selected_content == Content::Pomodoro {
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[^r]eset clocks+rounds"),
]);
}
spans
} }
AppEditMode::None if self.selected_content == Content::Event => { spans
vec![Span::from("[e]dit")]
}
AppEditMode::Clock | AppEditMode::Time | AppEditMode::Event => {
let mut spans = vec![Span::from("[s]ave changes")];
if self.selected_content == Content::Event {
spans[0] = Span::from("[enter]save changes")
};
if self.selected_content == Content::Countdown
|| self.selected_content == Content::Pomodoro
{
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[^s]ave initial value"),
]);
}
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[esc]skip changes"),
]);
if self.selected_content == Content::Event {
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[tab]switch input"),
]);
}
spans
}
_ => vec![],
} }
})), })),
]), ]),
// controls - 2. row ],
Row::new(if self.selected_content == Content::Event { widths,
vec![] )
} else { .column_spacing(1);
vec![
Cell::from(Line::from("")),
Cell::from(Line::from({
match self.app_edit_mode {
AppEditMode::None => {
let mut spans = vec![];
if self.selected_content == Content::Pomodoro {
spans.extend_from_slice(&[Span::from(
"[^←] or [^→] switch work/pause",
)]);
}
spans
}
_ => vec![
Span::from(format!(
// ← →,
"[{} {}]change selection",
scrollbar::HORIZONTAL.begin,
scrollbar::HORIZONTAL.end
)),
Span::from(SPACE),
Span::from(format!(
// ↑
"[{}]edit up",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↑
"[^{}]edit up 10x",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!(
// ↓
"[{}]edit up",
scrollbar::VERTICAL.end
)),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↓
"[^{}]edit up 10x",
scrollbar::VERTICAL.end
)),
],
}
})),
]
}),
])
}
let table = Table::new(table_rows, widths).column_spacing(1);
Widget::render(table, menu_area, buf); Widget::render(table, menu_area, buf);
} }

View File

@ -1,195 +0,0 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{StatefulWidget, Widget},
};
use crate::{
common::{AppTime, AppTimeFormat, Style as DigitStyle},
duration::{ClockDuration, DurationEx},
events::{TuiEvent, TuiEventHandler},
utils::center,
widgets::clock_elements::{
COLON_WIDTH, Colon, DIGIT_HEIGHT, DIGIT_SPACE_WIDTH, DIGIT_WIDTH, Digit,
},
};
use std::cmp::max;
/// State for `LocalTimeWidget`
pub struct LocalTimeState {
time: AppTime,
format: AppTimeFormat,
}
pub struct LocalTimeStateArgs {
pub app_time: AppTime,
pub app_time_format: AppTimeFormat,
}
impl LocalTimeState {
pub fn new(args: LocalTimeStateArgs) -> Self {
let LocalTimeStateArgs {
app_time,
app_time_format,
} = args;
Self {
time: app_time,
format: app_time_format,
}
}
pub fn set_app_time(&mut self, app_time: AppTime) {
self.time = app_time;
}
pub fn set_app_time_format(&mut self, format: AppTimeFormat) {
self.format = format;
}
}
impl TuiEventHandler for LocalTimeState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
Some(event)
}
}
#[derive(Debug)]
pub struct LocalTimeWidget {
pub style: DigitStyle,
}
impl LocalTimeWidget {
fn get_horizontal_lengths(&self, format: &AppTimeFormat) -> Vec<u16> {
const PERIOD_WIDTH: u16 = 2; // PM or AM
match format {
AppTimeFormat::HhMmSs => vec![
DIGIT_WIDTH, // H
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // h
COLON_WIDTH, // :
DIGIT_WIDTH, // M
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // m
COLON_WIDTH, // :
DIGIT_WIDTH, // S
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // s
],
AppTimeFormat::HhMm => vec![
DIGIT_WIDTH, // H
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // h
COLON_WIDTH, // :
DIGIT_WIDTH, // M
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // m
],
AppTimeFormat::Hh12Mm => vec![
DIGIT_SPACE_WIDTH + PERIOD_WIDTH, // (space) + (empty period) to center everything well horizontally
DIGIT_WIDTH, // H
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // h
COLON_WIDTH, // :
DIGIT_WIDTH, // M
DIGIT_SPACE_WIDTH, // (space)
DIGIT_WIDTH, // m
DIGIT_SPACE_WIDTH, // (space)
PERIOD_WIDTH, // period
],
}
}
}
impl StatefulWidget for LocalTimeWidget {
type State = LocalTimeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let current_value: DurationEx = state.time.as_duration_of_today().into();
let hours = current_value.hours_mod();
let hours12 = current_value.hours_mod_12();
let minutes = current_value.minutes_mod();
let seconds = current_value.seconds_mod();
let symbol = self.style.get_digit_symbol();
let label = Line::raw("Local Time".to_uppercase());
let label_date = Line::raw(state.time.format_date().to_uppercase());
let mut content_width = max(label.width(), label_date.width()) as u16;
let format = state.format;
let widths = self.get_horizontal_lengths(&format);
let mut widths = widths;
// Special case for `Hh12Mm`
// It might be `h:Mm` OR `Hh:Mm` depending on `hours12`
if state.format == AppTimeFormat::Hh12Mm && hours12 < 10 {
// single digit means, no (zero) width's for `H` and `space`
widths[1] = 0; // `H`
widths[2] = 0; // `space`
}
content_width = max(widths.iter().sum(), content_width);
let v_heights = [
1, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only
DIGIT_HEIGHT, // local time
1, // label
1, // date
];
let area = center(
area,
Constraint::Length(content_width),
Constraint::Length(v_heights.iter().sum()),
);
let [_, v1, v2, v3] = Layout::vertical(Constraint::from_lengths(v_heights)).areas(area);
match state.format {
AppTimeFormat::HhMmSs => {
let [hh, _, h, c_hm, mm, _, m, c_ms, ss, _, s] =
Layout::horizontal(Constraint::from_lengths(widths)).areas(v1);
Digit::new(hours / 10, false, symbol).render(hh, buf);
Digit::new(hours % 10, false, symbol).render(h, buf);
Colon::new(symbol).render(c_hm, buf);
Digit::new(minutes / 10, false, symbol).render(mm, buf);
Digit::new(minutes % 10, false, symbol).render(m, buf);
Colon::new(symbol).render(c_ms, buf);
Digit::new(seconds / 10, false, symbol).render(ss, buf);
Digit::new(seconds % 10, false, symbol).render(s, buf);
}
AppTimeFormat::HhMm => {
let [hh, _, h, c_hm, mm, _, m] =
Layout::horizontal(Constraint::from_lengths(widths)).areas(v1);
Digit::new(hours / 10, false, symbol).render(hh, buf);
Digit::new(hours % 10, false, symbol).render(h, buf);
Colon::new(symbol).render(c_hm, buf);
Digit::new(minutes / 10, false, symbol).render(mm, buf);
Digit::new(minutes % 10, false, symbol).render(m, buf);
}
AppTimeFormat::Hh12Mm => {
let [_, hh, _, h, c_hm, mm, _, m, _, p] =
Layout::horizontal(Constraint::from_lengths(widths)).areas(v1);
// Hh
if hours12 >= 10 {
Digit::new(hours12 / 10, false, symbol).render(hh, buf);
Digit::new(hours12 % 10, false, symbol).render(h, buf);
}
// h
else {
Digit::new(hours12, false, symbol).render(h, buf);
}
Colon::new(symbol).render(c_hm, buf);
Digit::new(minutes / 10, false, symbol).render(mm, buf);
Digit::new(minutes % 10, false, symbol).render(m, buf);
Span::styled(
state.time.get_period().to_uppercase(),
Style::default().add_modifier(Modifier::BOLD),
)
.render(p, buf);
}
}
label.centered().render(v2, buf);
label_date.centered().render(v3, buf);
}
}

View File

@ -1,27 +1,32 @@
use crate::{ use crate::{
common::Style, common::Style,
constants::TICK_VALUE_MS, constants::TICK_VALUE_MS,
events::{AppEventTx, TuiEvent, TuiEventHandler}, events::{Event, EventHandler},
utils::center, utils::center,
widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown}, widgets::clock::{ClockState, ClockWidget, Countdown},
}; };
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::KeyCode,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
text::Line, text::Line,
widgets::{StatefulWidget, Widget}, widgets::{StatefulWidget, Widget},
}; };
use serde::{Deserialize, Serialize};
use std::{cmp::max, time::Duration}; use std::{cmp::max, time::Duration};
use strum::Display; use strum::Display;
use serde::{Deserialize, Serialize};
use super::clock::ClockStateArgs;
#[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)]
pub enum Mode { pub enum Mode {
Work, Work,
Pause, Pause,
} }
#[derive(Debug, Clone)]
pub struct ClockMap { pub struct ClockMap {
work: ClockState<Countdown>, work: ClockState<Countdown>,
pause: ClockState<Countdown>, pause: ClockState<Countdown>,
@ -42,10 +47,10 @@ impl ClockMap {
} }
} }
#[derive(Debug, Clone)]
pub struct PomodoroState { pub struct PomodoroState {
mode: Mode, mode: Mode,
clock_map: ClockMap, clock_map: ClockMap,
round: u64,
} }
pub struct PomodoroStateArgs { pub struct PomodoroStateArgs {
@ -55,8 +60,6 @@ pub struct PomodoroStateArgs {
pub initial_value_pause: Duration, pub initial_value_pause: Duration,
pub current_value_pause: Duration, pub current_value_pause: Duration,
pub with_decis: bool, pub with_decis: bool,
pub app_tx: AppEventTx,
pub round: u64,
} }
impl PomodoroState { impl PomodoroState {
@ -68,8 +71,6 @@ impl PomodoroState {
initial_value_pause, initial_value_pause,
current_value_pause, current_value_pause,
with_decis, with_decis,
app_tx,
round,
} = args; } = args;
Self { Self {
mode, mode,
@ -79,19 +80,14 @@ impl PomodoroState {
current_value: current_value_work, current_value: current_value_work,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis, with_decis,
app_tx: Some(app_tx.clone()), }),
})
.with_name("Work".to_owned()),
pause: ClockState::<Countdown>::new(ClockStateArgs { pause: ClockState::<Countdown>::new(ClockStateArgs {
initial_value: initial_value_pause, initial_value: initial_value_pause,
current_value: current_value_pause, current_value: current_value_pause,
tick_value: Duration::from_millis(TICK_VALUE_MS), tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis, with_decis,
app_tx: Some(app_tx), }),
})
.with_name("Pause".to_owned()),
}, },
round,
} }
} }
@ -107,26 +103,14 @@ impl PomodoroState {
&self.clock_map.work &self.clock_map.work
} }
pub fn get_clock_work_mut(&mut self) -> &mut ClockState<Countdown> {
self.clock_map.get_mut(&Mode::Work)
}
pub fn get_clock_pause(&self) -> &ClockState<Countdown> { pub fn get_clock_pause(&self) -> &ClockState<Countdown> {
&self.clock_map.pause &self.clock_map.pause
} }
pub fn get_clock_pause_mut(&mut self) -> &mut ClockState<Countdown> {
self.clock_map.get_mut(&Mode::Pause)
}
pub fn get_mode(&self) -> &Mode { pub fn get_mode(&self) -> &Mode {
&self.mode &self.mode
} }
pub fn get_round(&self) -> u64 {
self.round
}
pub fn set_with_decis(&mut self, with_decis: bool) { pub fn set_with_decis(&mut self, with_decis: bool) {
self.clock_map.work.with_decis = with_decis; self.clock_map.work.with_decis = with_decis;
self.clock_map.pause.with_decis = with_decis; self.clock_map.pause.with_decis = with_decis;
@ -140,84 +124,40 @@ impl PomodoroState {
} }
} }
impl TuiEventHandler for PomodoroState { impl EventHandler for PomodoroState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> { fn update(&mut self, event: Event) -> Option<Event> {
let edit_mode = self.get_clock().is_edit_mode(); let edit_mode = self.get_clock().is_edit_mode();
match event { match event {
TuiEvent::Tick => { Event::Tick => {
self.get_clock_mut().tick(); self.get_clock_mut().tick();
self.get_clock_mut().update_done_count();
} }
// EDIT mode Event::Key(key) => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code {
// Skip changes
KeyCode::Esc => {
let clock = self.get_clock_mut();
// Important: set current value first
clock.set_current_value(*clock.get_prev_value());
// before toggling back to non-edit mode
clock.toggle_edit();
}
// Apply changes and update initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.get_clock_mut().toggle_edit();
// update initial value
let c = *self.get_clock().get_current_value();
self.get_clock_mut().set_initial_value(c);
}
// Apply changes
KeyCode::Char('s') => {
self.get_clock_mut().toggle_edit();
}
// Value up
KeyCode::Up => {
self.get_clock_mut().edit_up();
}
// Value down
KeyCode::Down => {
self.get_clock_mut().edit_down();
}
// move edit position to the left
KeyCode::Left => {
self.get_clock_mut().edit_next();
}
// move edit position to the right
KeyCode::Right => {
self.get_clock_mut().edit_prev();
}
_ => return Some(event),
},
// default mode
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
// Toggle run/pause
KeyCode::Char('s') => { KeyCode::Char('s') => {
self.get_clock_mut().toggle_pause(); self.get_clock_mut().toggle_pause();
} }
// Enter edit mode
KeyCode::Char('e') => { KeyCode::Char('e') => {
self.get_clock_mut().toggle_edit(); self.get_clock_mut().toggle_edit();
} }
// toggle WORK/PAUSE KeyCode::Left if edit_mode => {
KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => { self.get_clock_mut().edit_next();
// `next` is acting as same as a "prev" function we don't have }
KeyCode::Left => {
// `next` is acting as same as a `prev` function, we don't have
self.next(); self.next();
} }
// toggle WORK/PAUSE KeyCode::Right if edit_mode => {
KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => { self.get_clock_mut().edit_prev();
}
KeyCode::Right => {
self.next(); self.next();
} }
// reset rounds AND clocks KeyCode::Up if edit_mode => {
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.get_clock_mut().edit_up();
self.round = 1; }
self.get_clock_work_mut().reset(); KeyCode::Down if edit_mode => {
self.get_clock_pause_mut().reset(); self.get_clock_mut().edit_down();
} }
// reset current clock
KeyCode::Char('r') => { KeyCode::Char('r') => {
// increase round before (!!) resetting the clock
if self.get_mode() == &Mode::Work && self.get_clock().is_done() {
self.round += 1;
}
self.get_clock_mut().reset(); self.get_clock_mut().reset();
} }
_ => return Some(event), _ => return Some(event),
@ -230,13 +170,12 @@ impl TuiEventHandler for PomodoroState {
pub struct PomodoroWidget { pub struct PomodoroWidget {
pub style: Style, pub style: Style,
pub blink: bool,
} }
impl StatefulWidget for PomodoroWidget { impl StatefulWidget for PomodoroWidget {
type State = PomodoroState; type State = PomodoroState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock_widget = ClockWidget::new(self.style, self.blink); let clock_widget = ClockWidget::new(self.style);
let label = Line::raw( let label = Line::raw(
(format!( (format!(
"Pomodoro {} {}", "Pomodoro {} {}",
@ -245,34 +184,23 @@ impl StatefulWidget for PomodoroWidget {
)) ))
.to_uppercase(), .to_uppercase(),
); );
let label_round = Line::raw((format!("round {}", state.get_round(),)).to_uppercase());
let area = center( let area = center(
area, area,
Constraint::Length(max( Constraint::Length(max(
clock_widget clock_widget.get_width(
.get_width(state.get_clock().get_format(), state.get_clock().with_decis), &state.get_clock().get_format(),
state.get_clock().with_decis,
),
label.width() as u16, label.width() as u16,
)), )),
Constraint::Length( Constraint::Length(clock_widget.get_height() + 1 /* height of mode_str */),
// empty label + height of `label` + `label_round`
clock_widget.get_height() + 3,
),
); );
let [v1, v2, v3, v4] = Layout::vertical(Constraint::from_lengths([ let [v1, v2] =
1, Layout::vertical(Constraint::from_lengths([clock_widget.get_height(), 1])).areas(area);
clock_widget.get_height(),
1,
1,
]))
.areas(area);
// empty line keep everything in center vertically comparing to other clock_widget.render(v1, buf, state.get_clock_mut());
// views (which have one label below the clock only) label.centered().render(v2, buf);
Line::raw("").centered().render(v1, buf);
clock_widget.render(v2, buf, state.get_clock_mut());
label.centered().render(v3, buf);
label_round.centered().render(v4, buf);
} }
} }

View File

@ -1,10 +1,9 @@
use crate::{ use crate::{
common::Style, common::Style,
events::{TuiEvent, TuiEventHandler}, events::{Event, EventHandler},
utils::center, utils::center,
widgets::clock::{self, ClockState, ClockWidget}, widgets::clock::{self, ClockState, ClockWidget},
}; };
use crossterm::event::{Event as CrosstermEvent, KeyModifiers};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::KeyCode, crossterm::event::KeyCode,
@ -14,6 +13,7 @@ use ratatui::{
}; };
use std::cmp::max; use std::cmp::max;
#[derive(Debug, Clone)]
pub struct TimerState { pub struct TimerState {
clock: ClockState<clock::Timer>, clock: ClockState<clock::Timer>,
} }
@ -32,65 +32,35 @@ impl TimerState {
} }
} }
impl TuiEventHandler for TimerState { impl EventHandler for TimerState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> { fn update(&mut self, event: Event) -> Option<Event> {
let edit_mode = self.clock.is_edit_mode(); let edit_mode = self.clock.is_edit_mode();
match event { match event {
TuiEvent::Tick => { Event::Tick => {
self.clock.tick(); self.clock.tick();
self.clock.update_done_count();
} }
// EDIT mode Event::Key(key) => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code {
// Skip changes
KeyCode::Esc => {
// Important: set current value first
self.clock.set_current_value(*self.clock.get_prev_value());
// before toggling back to non-edit mode
self.clock.toggle_edit();
}
// Apply changes
KeyCode::Char('s') => {
self.clock.toggle_edit();
}
// move change position to the left
KeyCode::Left => {
self.clock.edit_next();
}
// move change position to the right
KeyCode::Right => {
self.clock.edit_prev();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
// change value up
KeyCode::Up => {
self.clock.edit_up();
}
// change value down
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => {
self.clock.edit_down();
}
_ => return Some(event),
},
// default mode
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
// Toggle run/pause
KeyCode::Char('s') => { KeyCode::Char('s') => {
self.clock.toggle_pause(); self.clock.toggle_pause();
} }
// reset clock
KeyCode::Char('r') => { KeyCode::Char('r') => {
self.clock.reset(); self.clock.reset();
} }
// enter edit mode
KeyCode::Char('e') => { KeyCode::Char('e') => {
self.clock.toggle_edit(); self.clock.toggle_edit();
} }
KeyCode::Left if edit_mode => {
self.clock.edit_next();
}
KeyCode::Right if edit_mode => {
self.clock.edit_prev();
}
KeyCode::Up if edit_mode => {
self.clock.edit_up();
}
KeyCode::Down if edit_mode => {
self.clock.edit_down();
}
_ => return Some(event), _ => return Some(event),
}, },
_ => return Some(event), _ => return Some(event),
@ -101,20 +71,19 @@ impl TuiEventHandler for TimerState {
pub struct Timer { pub struct Timer {
pub style: Style, pub style: Style,
pub blink: bool,
} }
impl StatefulWidget for Timer { impl StatefulWidget for Timer {
type State = TimerState; type State = TimerState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock = &mut state.clock; let clock = &mut state.clock;
let clock_widget = ClockWidget::new(self.style, self.blink); let clock_widget = ClockWidget::new(self.style);
let label = Line::raw((format!("Timer {}", clock.get_mode())).to_uppercase()); let label = Line::raw((format!("Timer {}", clock.get_mode())).to_uppercase());
let area = center( let area = center(
area, area,
Constraint::Length(max( Constraint::Length(max(
clock_widget.get_width(clock.get_format(), clock.with_decis), clock_widget.get_width(&clock.get_format(), clock.with_decis),
label.width() as u16, label.width() as u16,
)), )),
Constraint::Length(clock_widget.get_height() + 1 /* height of label */), Constraint::Length(clock_widget.get_height() + 1 /* height of label */),