66 Commits

Author SHA1 Message Date
jk
b39cac83d5 demos for v1.5.0 2025-10-02 18:54:11 +02:00
Jens Krause
eb376e4015 feat(args): accept years and days for --countdown (#114) 2025-10-01 15:26:02 +02:00
Jens Krause
ac2863cebc upgrade deps (#113)
* run `cargo upgrade`

```sh
❯ cargo upgrade
    Checking timr-tui's dependencies
name               old req compatible latest new req
====               ======= ========== ====== =======
tokio              1.45.1  1.47.1     1.47.1 1.47.1
tokio-util         0.7.15  0.7.16     0.7.16 0.7.16
tracing-subscriber 0.3.19  0.3.20     0.3.20 0.3.20
clap               4.5.40  4.5.48     4.5.48 4.5.48
time               0.3.41  0.3.44     0.3.44 0.3.44
thiserror          2.0.12  2.0.17     2.0.17 2.0.17
   Upgrading recursive dependencies
     Locking 0 packages to latest Rust 1.90.0 compatible versions
note: pass `--verbose` to see 2 unchanged dependencies behind latest
note: Re-run with `--incompatible` to upgrade incompatible version requirements
note: Re-run with `--verbose` to show more dependencies
  incompatible: 4 packages
  latest: 8 packages
```

* fix(duration) `test_parse_duration_by_time` panics
2025-10-01 13:34:20 +02:00
Jens Krause
3f4acec9f5 feat(args): parse countdown by given time (past or future) (#112)
* feat(args): parse `countdown` by time

* fix lint

No `Default` for `AppTime` needed...

* app: pass `countdown_until` down

* fix `parse_duration_by_time` and `parse_duration`

to handle different formats they support

* fix(countdown): percentage panics

`Duration::ZERO` needs to be considered

* `DirectedDuration`

* fix comment

* rename arg: `countdown-target`

* `ss`->`mm`, fix formats, update README

* alias `--ct`
2025-10-01 12:40:27 +02:00
Jens Krause
2277eeb033 feat(localtime): date (#111)
* feat(localtime): date

* update demo
2025-09-30 13:24:45 +02:00
Jens Krause
cb6c2d5142 feat(edit): 10x up/down (#110)
* feat(edit): 10x up/down

* fix `MAX_DURATION` (decisecond)

* footer: add `edit 10x` keybindings

* README: update keybindings
2025-09-30 12:38:25 +02:00
Jens Krause
6dc7eb81c2 fix(editable): auto jump to next possible editable while decreasing, but ignoring zero values (#109)
* extract `format_by_duration`

* fix(editable): switch to next ignoring zero values
2025-09-29 20:48:19 +02:00
Jens Krause
816741f842 improve format handling + fix days rendering (#107)
* render_(format) functions, compact `YyyDddHhMmSs`

* compact rendering of other formats

* add `YDHhMmSs`+`YDdHhMmSs` formats

* tests for `YDHhMmSs`+`YDdHhMmSs`

* `YyDHhMmSs` + `YyDdHhMmSs`

* `YyyDHhMmSs` + `YyyDdHhMmSs`

* fix `edit_up` to compare `years` properly

and add `test_edit_up_overflow_protection`

* fix rendering `Format::YyyDdHhMmSs`
2025-09-29 16:08:34 +02:00
jk
40eb602953 tests 2025-09-28 10:40:34 +02:00
jk
d5bf7f32a6 feat(timer|c-dwn): show/edit years + days 2025-09-28 10:40:34 +02:00
jk
9ff65e5c8e feat(duration): days and years 2025-09-28 10:40:34 +02:00
Jens Krause
24eb471df8 Contributing guidelines (initial version) (#94) 2025-09-26 17:07:34 +02:00
Jens Krause
0521c57695 Rust 1.90.0 (#95)
* Rust 1.90.0

* Make sound work again on Linux

Reverts changes of #77. It works before, but errors after updating to
Rust 1.90.0 and to latest Nix packages.
2025-09-26 17:05:28 +02:00
jk
f9a2e18179 ignore claude 2025-09-02 11:26:03 +02:00
Jens Krause
bac2e356e1 Prepare v1.4.0 (#92)
* Prepare v1.4.0

* cargo build
2025-09-02 11:19:07 +02:00
Jens Krause
60392b40ed demo: local time (#91) 2025-08-30 22:06:51 +02:00
Jens Krause
901cf69472 feat(screen): LocalTime (#90) 2025-08-30 21:48:56 +02:00
Jens Krause
c494f0e829 refactor(footer): AppTimeFormat data handling (#89)
- Extract local state of `app_time_format` from `footer` to have it globally available
- Add a deserialization fallback for deprecated `AppTimeFormat::Hidden`
- Persist `footer_app_time` toggle state
2025-08-27 19:44:02 +02:00
Jens Krause
637c1da21b Rust 1.89.0 (#87) 2025-08-15 11:43:17 +02:00
Jens Krause
3439e4aa8d Prepare v1.3.1 (#86)
* bump v1.3.1

* update CHANGELOG

* start workflow by `release/*` branches only but not by tags to avoid overriding releases.
2025-07-03 11:01:51 +02:00
jk
5bc37f005f lint again
in addition to #88, especially 78f5269
2025-06-28 11:49:21 +02:00
Jens Krause
bfa40fd8f1 Rust 1.88.0 (#85)
* Rust 1.88.0

* nix flake update

* cargo upgrade

* lint

all `uninlined_format_args` related
@see https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args
2025-06-28 11:39:22 +02:00
Jens Krause
93a3cde396 Rust 1.87.0 (#84)
* Rust 1.87.0

* nix flake update

* update CHANGELOG

* cargo upgrade

name         old req compatible latest new req
====         ======= ========== ====== =======
color-eyre   0.6.2   0.6.5      0.6.5  0.6.5
tokio        1.41.1  1.45.1     1.45.1 1.45.1
tokio-stream 0.1.16  0.1.17     0.1.17 0.1.17
tokio-util   0.7.12  0.7.15     0.7.15 0.7.15
clap         4.5.23  4.5.39     4.5.39 4.5.39
time         0.3.37  0.3.41     0.3.41 0.3.41
notify-rust  4.11.4  4.11.7     4.11.7 4.11.7
thiserror    2.0.11  2.0.12     2.0.12 2.0.12
2025-06-09 17:00:31 +02:00
Jens Krause
d27587a44a fix(pomodoro): ctrl+r resets rounds + both clocks (#83) 2025-05-20 10:01:29 +02:00
Jens Krause
5f4c5bb8ed (pomodoro) reset active clock only (#82)
It reverts changes of cc35f20f3e
2025-05-13 11:00:56 +02:00
Jens Krause
44af71c01c (args) set content by given duration (#81) 2025-05-12 12:14:27 +02:00
Jens Krause
c370d3096b Prepare v1.3.0 (#80)
* Prepare v1.3.0
* readme: fix keybindings
* update changelog
2025-05-06 11:46:29 +02:00
jk
aae5c38cd6 fix(footer): hide option to save initial value
for timer. Available 4 pomodoro + countdown only.
2025-05-06 11:25:29 +02:00
jk
5ad09b9848 (demo) update menu 2025-05-02 12:52:35 +02:00
Jens Krause
52ed8267be (pomodoro/countdown) Change initial value (#79)
- While editing, an user can apply changes as a new initial value (pomodoro/countdown only).
- New keybinding: `[^s]save initial value` 
- Update keybinding: `[s]ave changes` (instead of `[Enter]`)
- Refactor event handling to re-structure `edit` / `non-edit` modes.
- Refactor footer to reflect latest keybindings
2025-05-02 12:39:26 +02:00
jk
6b068bbd09 fix(pomodoro) add empty line to keep content
centered vertically comparing to other views (which have one label below the clock only)

In addition to #75.
2025-04-30 14:27:30 +02:00
Jens Krause
c96432779a (readme) add keybindings + toc (#78)
* (readme) add keybindings + toc
* update LICENSE
2025-04-30 14:19:13 +02:00
Jens Krause
509cf73cdd (flake) use alsa-lib-with-plugins (#77)
by following an idea from @camuward mentioned here https://github.com/143mailliw/muzak/pull/28#issuecomment-2833601152

Incl. another `nix flake update`.
2025-04-30 12:12:03 +02:00
Jens Krause
e6291a3131 Update keybindings (#76)
* (pomodoro) reset both clocks at once

* quit app by pressing `q` only

* (countdown) enter/esc keybindings

* (timer) enter/esc keybindings

* (pomodoro) enter/esc keybindings

* update footer label

* fix(coundown): don't reset elapsed clock

while skipping editing changes

* fix(clock): order of actions matters for ESC

handling. Set `pause` instead of `initial` mode while toggeling back.

* fix(timer): order of actions matters (ESC key)

* (footer) update order, lowercase standard keys
2025-04-30 11:11:33 +02:00
Jens Krause
90d9988e7a (pomodoro) count WORK rounds (#75) 2025-04-29 16:20:02 +02:00
Jens Krause
028a067419 Prepare v1.2.1 (#74) 2025-04-17 12:26:13 +02:00
jk
675428cfb0 Update README 2025-04-17 08:31:40 +02:00
Jens Krause
28c5d7194c Rust 1.86.0 (#73) 2025-04-13 21:37:47 +02:00
Jens Krause
5b445afe25 fix(countdown): reset MET while editing by local time (#72) 2025-04-13 21:17:56 +02:00
jk
beb12d5ec2 just: run with args 2025-04-13 19:19:46 +02:00
Jens Krause
d9399eafc9 fix(countdown): Reset MET if countdown is set by cli arguments (#71)
* fix: Reset MET if countdown is set by args

* update CHANGELOG
2025-03-03 12:21:17 +01:00
Jens Krause
ffad78e093 fix(just): group commands (#70) 2025-02-26 14:58:54 +01:00
jk
e7a5a1b2da exclude files for packaging 2025-02-26 12:31:18 +01:00
Jens Krause
6f0df4d488 Prepare v1.2.0 (#69)
* prepare v1.2.0

* update README

* update justfile
2025-02-26 12:11:54 +01:00
Jens Krause
3d9b235f12 Rust 1.85.0 + Rust 2024 Edition (#68)
- Add `rust-toolchain.toml`
- Refactor `flake` to consider `rust-toolchain.toml`, especially Rust version
- `just`: Add run-sound command
- Ignore sound files
- Format
2025-02-25 20:30:20 +01:00
jk
e094d7d81b feat(logging): add --log arg to enable logs
and/or to pass a custom log directory.
2025-02-06 20:33:52 +01:00
Jens Krause
843c4d019d set_panic_hook (#67) 2025-02-06 11:16:04 +01:00
Jens Krause
e95ecb9e9c feat(notification): Animate (blink) clock entering done mode (#65)
Optional.
2025-02-05 19:29:56 +01:00
Jens Krause
886deb3311 fix(notification): remove callbacks in favour of mpsc messaging (#64) 2025-02-05 13:35:24 +01:00
Jens Krause
7ff167368d --features sound (#63)
to enable `sound` notification for local builds only. Needed to avoid
endless issues by building the app for different platforms. Sound
support can be hard.
2025-02-04 17:39:50 +01:00
Jens Krause
a54b1b409a feat(clock): sound notification (experimental) (#62) 2025-02-04 17:28:41 +01:00
Jens Krause
8f50bc5fc6 AppEvent (#61)
Extend `events` to provide a `mpsc` channel to send `AppEvent`'s from
anywhere in the app straight to the `App`.
2025-02-04 15:05:02 +01:00
Jens Krause
d3c436da0b feat: native desktop notifications (experimental) (#59)
* desktop notification by entering `Mode::DONE` for `countdown` and `pomodoro`

* remove redundant `on_done_called` check

* remove build warning (release only)

* log notification errors

* cli arg to enable desktop notifications

* persistant notification settings

* ctrl shortcut

* update changelog

* max timer notification
2025-01-28 19:28:34 +01:00
jk
97787f718d remove editor settings (zed) 2025-01-26 18:49:14 +01:00
Jens Krause
557fcf95f0 Prepare v1.1.0 (#58) 2025-01-22 10:55:20 +01:00
Jens Krause
ec18da0664 fix(ci): remove magic nix cache action (#57) 2025-01-22 09:56:28 +01:00
Jens Krause
59c99f4f5c fix(build): statically linked binaries for linux (#55) 2025-01-22 09:43:51 +01:00
Jens Krause
6d2bf5ac09 Edit countdown by local time (#49) 2025-01-13 18:44:56 +01:00
Jens Krause
b1efb1eb62 rust 1.84, update deps (#48)
- `nix flake update`
- `cargo update`
2025-01-11 16:48:37 +01:00
Jens Krause
4ee5d7b4e9 Prepare v1.0.0 (#47)
* update README
* update CHANGELOG
* update demo
* bump v1.0.0
2025-01-10 16:36:57 +01:00
Jens Krause
9ea9f88266 feat(countdown): persist elapsed time (#46) 2025-01-10 16:01:03 +01:00
Jens Krause
c8af76c9e5 feat(countdown): rocket countdown (#45) 2025-01-08 18:52:18 +01:00
Jens Krause
468b4a5abf simplify style settings, improve naming (#44)
* simplify style settings by passing `style` directly to Widgets. No need to store it in `state` of widgets.
* remove unneeded things
* naming (state vs. widgets)
2025-01-07 19:02:57 +01:00
Jens Krause
8603a823e4 fix(footer): 12-hour format incl. tests (#43) 2025-01-06 19:22:02 +01:00
Jens Krause
94bdeeab11 feat(footer): show local time (#42) 2025-01-06 18:31:22 +01:00
Orhun Parmaksız
66c6d7fc46 docs: Add instructions for installing from the AUR (#41) 2025-01-06 09:06:15 +01:00
57 changed files with 6723 additions and 1142 deletions

View File

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

View File

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

7
.gitignore vendored
View File

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

View File

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

View File

@@ -1,30 +0,0 @@
// 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,5 +1,105 @@
# Changelog
## [unreleased]
### Misc.
- (deps) Rust 1.90.0 [#95](https://github.com/sectore/timr-tui/pull/95)
## 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
Happy `v1.0.0` 🎉
### Features
- (countdown) Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time)). [#45](https://github.com/sectore/timr-tui/pull/45), [#46](https://github.com/sectore/timr-tui/pull/46)
- (footer) Local time. Optional and with custom formats. [#42](https://github.com/sectore/timr-tui/pull/42), [#43](https://github.com/sectore/timr-tui/pull/43)
- (docs) More installation instructions: Cargo, AUR (Arch Linux) [#41](https://github.com/sectore/timr-tui/pull/41), pre-built release binaries (Linux, macOS, Windows) [#47](https://github.com/sectore/timr-tui/pull/47)
## v0.9.0 - 2025-01-03
Initial version.
@@ -8,5 +108,6 @@ Initial version.
- Add `Pomodoro`, `Timer`, `Countdown`
- Persist application state
- Change styles
- Custom styles for digits
- Toggle deciseconds
- CLI

18
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,18 @@
# 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.

2098
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

236
README.md
View File

@@ -6,11 +6,23 @@ TUI to organize your time: Pomodoro, Countdown, Timer.
- `[c]ountdown` Use it for your workout, yoga session, meditation, handstand or whatever.
- `[p]omodoro` Organize your working time to be focused all the time by following the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique).
It's built with [`Ratatui`](https://ratatui.rs/) written in [Rust 🦀](https://www.rust-lang.org/).
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
# Table of Contents
- [Preview](./#preview)
- [CLI](./#cli)
- [Keybindings](./#keybindings)
- [Installation](./#installation)
- [Development](./#development)
- [Misc](./#misc)
- [Contributing](./#contributing)
- [License](./#license)
# Preview
_Side note:_ Theme colors depend on your terminal preferences.
_(theme depends on your terminal preferences)_
## Pomodoro
@@ -30,10 +42,23 @@ _Side note:_ Theme colors depend on your terminal preferences.
<img alt="countdown" src="demo/countdown.gif" />
</a>
## Change style
## Countdown: Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
<a href="demo/style.gif">
<img alt="style" src="demo/style.gif" />
<a href="demo/countdown-met.gif">
<img alt="menu" src="demo/countdown-met.gif" />
</a>
## Local time
<a href="demo/local-time.gif">
<img alt="menu" src="demo/local-time.gif" />
</a>
## Local time (footer)
<a href="demo/local-time-footer.gif">
<img alt="menu" src="demo/local-time-footer.gif" />
</a>
## Toggle deciseconds
@@ -42,6 +67,12 @@ _Side note:_ Theme colors depend on your terminal preferences.
<img alt="deciseconds" src="demo/decis.gif" />
</a>
## Change style
<a href="demo/style.gif">
<img alt="style" src="demo/style.gif" />
</a>
## Menu
<a href="demo/menu.gif">
@@ -56,33 +87,132 @@ timr-tui --help
Usage: timr-tui [OPTIONS]
Options:
-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', or 'hh:mm:ss' [default: 25:00]
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 5:00]
-d, --decis Wether to show deciseconds or not. [default: false]
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro] [default: timer]
--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] [default: full]
-r, --reset Reset stored values to default.
-h, --help Print help
-V, --version Print version
-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'.
--countdown-target <COUNTDOWN_TARGET>
Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm' [aliases: --ct]
-w, --work <WORK>
Work 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', 'hh:mm:ss'
-d, --decis
Show deciseconds.
-m, --mode <MODE>
Mode to start with. [possible values: countdown, timer, pomodoro, localtime]
-s, --style <STYLE>
Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
--menu
Open menu.
-r, --reset
Reset stored values to defaults.
-n, --notification <NOTIFICATION>
Toggle desktop notifications. Experimental. [possible values: on, off]
--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>p</kbd> | Pomodoro |
| <kbd>c</kbd> | Countdown |
| <kbd>t</kbd> | Timer |
| <kbd>l</kbd> | Local Time |
## 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 `Pomodoro` screen only:**
| Key | Description |
| --- | --- |
| <kbd>←</kbd> or <kbd>→</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
From [crates.io](https://crates.io/crates/timr-tui) run:
## Cargo
### From [crates.io](https://crates.io/crates/timr-tui)
```sh
cargo install timr-tui
```
Latest version from git repository:
### From GitHub repository
```sh
cargo install --git https://github.com/sectore/timr-tui
```
# Build from source 🔧
## Arch Linux
Install [from the AUR](https://aur.archlinux.org/packages/timr/):
```sh
paru -S timr
```
## Release binaries
Pre-built artifacts are available to download from [latest GitHub release](https://github.com/sectore/timr-tui/releases).
# Development
## Requirements
@@ -92,7 +222,6 @@ cargo install --git https://github.com/sectore/timr-tui
If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to install dependencies. In other case run `nix develop`.
### Non Nix users
- [`Rust`](https://www.rust-lang.org/learn/get-started)
@@ -103,34 +232,63 @@ If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to
### Commands
```sh
just --list
just
Available recipes:
build # build app
b # alias for `build`
default
format # format files
f # alias for `format`
lint # lint
l # alias for `lint`
run # run app
r # alias for `run`
test # run tests
t # alias for `test`
default # list commands
[build]
build # build app [alias: b]
[demo]
demo-blink # build demo: blink animation [alias: db]
demo-countdown # build demo: countdown [alias: dc]
demo-countdown-met # build demo: countdown + met [alias: dcm]
demo-decis # build demo: deciseconds [alias: dd]
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
- Linux
```sh
nix build
# or for bulding w/ statically linked binaries
nix build .#linuxStatic
```
- Windows (cross-compilation)
```sh
nix build .#windows
```
### Run tests
```sh
cargo test
```
# Misc.
## Persistant app state
@@ -148,13 +306,25 @@ C:/Users/{user}/AppData/Local/timr-tui/data/app.data
## Logs
In `debug` mode only. Locations:
To get log output, start the app by passing `--log` to `timr-tui`. See [CLI](./#cli) for details.
Logs will be stored in an `app.log` file at following locations:
```sh
# Linux
~/.local/state/timr/logs/app.log
~/.local/state/timr-tui/logs/app.log
# macOS
/Users/{user}/Library/Application Support/timr-tui/logs/app.log
# `Windows`
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)

BIN
demo/blink.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

23
demo/blink.tape Normal file
View File

@@ -0,0 +1,23 @@
Output demo/blink.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "nord-light"
Set FontSize 14
Set Width 800
Set Height 400
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

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.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,20 @@
Output demo/countdown-target-future.gif
# 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 ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -m c --ct '2030-01-10 18:00'"
Enter
Type "m"
Sleep 0.2
Show
Sleep 0.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,20 @@
Output demo/countdown-target-past.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "seoulbones_light"
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 --ct '2024-01-10 18:00'"
Enter
Type "m"
Sleep 0.2
Show
Sleep 0.1

BIN
demo/local-time-date.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

20
demo/local-time-date.tape Normal file
View File

@@ -0,0 +1,20 @@
Output demo/local-time-date.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "WarmNeon"
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 l"
Enter
Type "m"
Sleep 0.2
Show
Sleep 0.1

BIN
demo/local-time-footer.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,20 @@
Output demo/local-time-footer.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "AtomOneLight"
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"
Enter
Sleep 0.2
Show
# --- toggle local time ---
Type@1.5s ":::"

BIN
demo/local-time.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

20
demo/local-time.tape Normal file
View File

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

BIN
demo/maximum.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

41
demo/maximum.tape Normal file
View File

@@ -0,0 +1,41 @@
Output demo/maximum.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "C64"
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 timer"
Enter
Sleep 0.2
Type "m"
Type "e"
# secs
Up@1ms 60
Left
# mins
Up@1ms 59
Left
# hours
Up@1ms 23
Left
# days
Up@1ms 364
Right@1ms 3
Down@1ms 1
Left@1ms 4
# years
Up@1ms 998
Right
# days
Up@1ms 365
Type@1ms "s"
Show
Sleep 0.1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -12,19 +12,15 @@ Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -m p"
Type "cargo run -- -r -m p --menu"
Enter
Sleep 0.2
Type@200ms "m"
Show
# --- STYLES ---
Sleep 0.5
Type "m"
Sleep 0.5
Type@0.5s "t"
Type@0.5s "c"
Type@0.5s "p"
Type@0.5s "e"
Right@0.5s
Left@0.5s
Type@0.5s "e"
Sleep 0.5
Sleep 0.3s
Type@0.3s "m"
Type@0.3s "t"
Type@0.3s "c"
Type@0.3s "p"
Type@0.3s "e"
Escape@0.3s

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -10,9 +10,8 @@ Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -d -m p"
Type "cargo run -- -d -m p --blink on"
Enter
Sleep 0.2
Show
@@ -25,7 +24,7 @@ Sleep 0.2
Down@30ms 80
Sleep 100ms
Type "e"
Sleep 3
Sleep 4
# --- POMODORO PAUSE ---
Right
Sleep 0.5
@@ -36,4 +35,4 @@ Sleep 0.2
Down@30ms 60
Sleep 100ms
Type "e"
Sleep 3
Sleep 4

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

24
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1733286231,
"narHash": "sha256-mlIDSv1/jqWnH8JTiOV7GMUNPCXL25+6jmD+7hdxx5o=",
"lastModified": 1758758545,
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
"owner": "ipetkov",
"repo": "crane",
"rev": "af1556ecda8bcf305820f68ec2f9d77b41d9cc80",
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
"type": "github"
},
"original": {
@@ -23,11 +23,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1732689334,
"narHash": "sha256-yKI1KiZ0+bvDvfPTQ1ZT3oP/nIu3jPYm4dnbRd6hYg4=",
"lastModified": 1758782550,
"narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a8a983027ca02b363dfc82fbe3f7d9548a8d3dce",
"rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1733212471,
"narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=",
"lastModified": 1758690382,
"narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "55d15ad12a74eb7d4646254e13638ad0c4128776",
"rev": "e643668fd71b949c53f8626614b21ff71a07379d",
"type": "github"
},
"original": {
@@ -81,11 +81,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1732633904,
"narHash": "sha256-7VKcoLug9nbAN2txqVksWHHJplqK9Ou8dXjIZAIYSGc=",
"lastModified": 1758620797,
"narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "8d5e91c94f80c257ce6dbdfba7bd63a5e8a03fa6",
"rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
"type": "github"
},
"original": {

View File

@@ -1,7 +1,5 @@
{
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";
flake-utils.url = "github:numtide/flake-utils";
crane.url = "github:ipetkov/crane";
@@ -20,34 +18,40 @@
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
# Using stable toolchain as base
toolchain = with fenix.packages.${system};
combine [
minimal.rustc
minimal.cargo
targets.x86_64-pc-windows-gnu.latest.rust-std
];
toolchain =
fenix.packages.${system}.fromToolchainFile
{
file = ./rust-toolchain.toml;
# sha256 = nixpkgs.lib.fakeSha256;
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
};
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
# Common build inputs for both native and cross compilation
commonArgs = {
src = craneLib.cleanCargoSource ./.;
cargoArtifacts = craneLib.buildDepsOnly {
src = craneLib.cleanCargoSource ./.;
};
strictDeps = true;
doCheck = false; # skip tests during nix build
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Native build
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
# @see https://crane.dev/examples/cross-windows.html
crossBuild = craneLib.buildPackage {
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
doCheck = false;
windowsBuild = craneLib.buildPackage {
inherit (commonArgs) src strictDeps doCheck;
CARGO_BUILD_TARGET = "x86_64-pc-windows-gnu";
@@ -68,24 +72,31 @@
packages = {
inherit timr;
default = timr;
windows = crossBuild;
linuxStatic = staticLinuxBuild;
windows = windowsBuild;
};
# Development shell with all necessary tools
devShell = with nixpkgs.legacyPackages.${system};
mkShell {
buildInputs = with fenix.packages.${system}.stable; [
rust-analyzer
clippy
rustfmt
devShells.default = with nixpkgs.legacyPackages.${system};
craneLib.devShell {
packages =
[
toolchain
pkgs.just
pkgs.nixd
pkgs.alejandra
]
# pkgs needed to play sound on Linux
++ lib.optionals stdenv.isLinux [
pkgs.pkg-config
pkgs.pipewire
pkgs.alsa-lib
];
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";
};
});
}

View File

@@ -2,64 +2,142 @@
set unstable := true
# list commands
default:
@just --list
alias b := build
alias f := format
alias l := lint
alias t := test
alias r := run
# build app
[group('build')]
build:
cargo build
alias t := test
# run tests
[group('test')]
test:
cargo test
alias f := format
# format files
[group('misc')]
format:
just --fmt
cargo fmt
alias l := lint
# lint
[group('misc')]
lint:
cargo clippy --no-deps
alias r := run
# run app
[group('dev')]
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
alias dp := demo-pomodoro
# build demo: pomodoro
[group('demo')]
demo-pomodoro:
vhs demo/pomodoro.tape
alias dt := demo-timer
# build demo: timer
[group('demo')]
demo-timer:
vhs demo/timer.tape
alias dc := demo-countdown
# build demo: countdown
[group('demo')]
demo-countdown:
vhs demo/countdown.tape
alias dcm := demo-countdown-met
# build demo: countdown + met
[group('demo')]
demo-countdown-met:
vhs demo/countdown-met.tape
alias ds := demo-style
# build demo: styles
[group('demo')]
demo-style:
vhs demo/style.tape
alias dd := demo-decis
# build demo: deciseconds
[group('demo')]
demo-decis:
vhs demo/decis.tape
alias dm := demo-menu
# build demo: menu
[group('demo')]
demo-menu:
vhs demo/menu.tape
alias dlt := demo-local-time
# build demo: local time
[group('demo')]
demo-local-time:
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
# build demo: rocket countdown
[group('demo')]
demo-rocket-countdown:
vhs demo/met.tape
alias db := demo-blink
# build demo: blink animation
[group('demo')]
demo-blink:
vhs demo/blink.tape

6
rust-toolchain.toml Normal file
View File

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

View File

@@ -1,19 +1,25 @@
use crate::{
args::Args,
common::{Content, Style},
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
events::{Event, EventHandler, Events},
duration::DirectedDuration,
events::{self, TuiEventHandler},
storage::AppStorage,
terminal::Terminal,
widgets::{
clock::{self, Clock, ClockArgs},
countdown::{Countdown, CountdownWidget},
footer::Footer,
clock::{self, ClockState, ClockStateArgs},
countdown::{Countdown, CountdownState, CountdownStateArgs},
footer::{Footer, FooterState},
header::Header,
pomodoro::{Mode as PomodoroMode, Pomodoro, PomodoroArgs, PomodoroWidget},
timer::{Timer, TimerWidget},
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
timer::{Timer, TimerState},
},
};
#[cfg(feature = "sound")]
use crate::sound::Sound;
use color_eyre::Result;
use ratatui::{
buffer::Buffer,
@@ -21,8 +27,9 @@ use ratatui::{
layout::{Constraint, Layout, Rect},
widgets::{StatefulWidget, Widget},
};
use std::path::PathBuf;
use std::time::Duration;
use tracing::debug;
use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
@@ -30,54 +37,121 @@ enum Mode {
Quit,
}
#[derive(Debug)]
pub struct App {
content: Content,
mode: Mode,
show_menu: bool,
countdown: Countdown,
timer: Timer,
pomodoro: Pomodoro,
notification: Toggle,
blink: Toggle,
#[allow(dead_code)] // w/ `--features sound` available only
sound_path: Option<PathBuf>,
app_time: AppTime,
app_time_format: AppTimeFormat,
countdown: CountdownState,
timer: TimerState,
pomodoro: PomodoroState,
local_time: LocalTimeState,
style: Style,
with_decis: bool,
footer: FooterState,
}
pub struct AppArgs {
pub style: Style,
pub with_decis: bool,
pub notification: Toggle,
pub blink: Toggle,
pub show_menu: bool,
pub app_time_format: AppTimeFormat,
pub content: Content,
pub pomodoro_mode: PomodoroMode,
pub pomodoro_round: u64,
pub initial_value_work: Duration,
pub current_value_work: Duration,
pub initial_value_pause: Duration,
pub current_value_pause: Duration,
pub initial_value_countdown: Duration,
pub current_value_countdown: Duration,
pub elapsed_value_countdown: Duration,
pub current_value_timer: Duration,
pub app_tx: events::AppEventTx,
pub sound_path: Option<PathBuf>,
pub footer_toggle_app_time: Toggle,
}
/// Getting `AppArgs` by merging `Args` and `AppStorage`.
/// `Args` wins btw.
impl From<(Args, AppStorage)> for AppArgs {
fn from((args, stg): (Args, AppStorage)) -> Self {
AppArgs {
pub struct FromAppArgs {
pub args: Args,
pub stg: AppStorage,
pub app_tx: events::AppEventTx,
}
/// 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,
show_menu: args.menu || stg.show_menu,
content: args.mode.unwrap_or(stg.content),
notification: args.notification.unwrap_or(stg.notification),
blink: args.blink.unwrap_or(stg.blink),
app_time_format: stg.app_time_format,
// Check args to set a possible mode to start with.
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() || args.countdown_target.is_some() {
Content::Countdown
}
// in other case just use latest stored state
else {
stg.content
}
}
},
style: args.style.unwrap_or(stg.style),
pomodoro_mode: stg.pomodoro_mode,
pomodoro_round: stg.pomodoro_count,
initial_value_work: args.work.unwrap_or(stg.inital_value_work),
// invalidate `current_value_work` if an initial value is set via args
current_value_work: args.work.unwrap_or(stg.current_value_work),
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
// invalidate `current_value_pause` if an initial value is set via args
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: match (&args.countdown, &args.countdown_target) {
(Some(d), _) => *d,
(None, Some(DirectedDuration::Until(d))) => *d,
// reset for values from "past"
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
(None, None) => stg.inital_value_countdown,
},
// invalidate `current_value_countdown` if an initial value is set via args
current_value_countdown: args.countdown.unwrap_or(stg.current_value_countdown),
current_value_countdown: match (&args.countdown, &args.countdown_target) {
(Some(d), _) => *d,
(None, Some(DirectedDuration::Until(d))) => *d,
// `zero` makes values from `past` marked as `DONE`
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
(None, None) => stg.inital_value_countdown,
},
elapsed_value_countdown: match (args.countdown, args.countdown_target) {
// use `Since` duration
(_, Some(DirectedDuration::Since(d))) => d,
// reset values
(_, Some(_)) => Duration::ZERO,
(Some(_), _) => Duration::ZERO,
(_, _) => stg.elapsed_value_countdown,
},
current_value_timer: stg.current_value_timer,
}
app_tx,
#[cfg(feature = "sound")]
sound_path: args.sound,
#[cfg(not(feature = "sound"))]
sound_path: None,
footer_toggle_app_time: stg.footer_app_time,
})
}
}
@@ -86,66 +160,212 @@ impl App {
let AppArgs {
style,
show_menu,
app_time_format,
initial_value_work,
initial_value_pause,
initial_value_countdown,
current_value_work,
current_value_pause,
current_value_countdown,
elapsed_value_countdown,
current_value_timer,
content,
with_decis,
pomodoro_mode,
pomodoro_round,
notification,
blink,
sound_path,
app_tx,
footer_toggle_app_time,
} = args;
let app_time = AppTime::new();
Self {
mode: Mode::Running,
notification,
blink,
sound_path,
content,
show_menu,
app_time,
app_time_format,
style,
with_decis,
countdown: Countdown::new(Clock::<clock::Countdown>::new(ClockArgs {
countdown: CountdownState::new(CountdownStateArgs {
initial_value: initial_value_countdown,
current_value: current_value_countdown,
tick_value: Duration::from_millis(TICK_VALUE_MS),
style,
elapsed_value: elapsed_value_countdown,
app_time,
with_decis,
})),
timer: Timer::new(Clock::<clock::Timer>::new(ClockArgs {
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),
style,
with_decis,
})),
pomodoro: Pomodoro::new(PomodoroArgs {
app_tx: Some(app_tx.clone()),
})
.with_name("Timer".to_owned()),
),
pomodoro: PomodoroState::new(PomodoroStateArgs {
mode: pomodoro_mode,
initial_value_work,
current_value_work,
initial_value_pause,
current_value_pause,
style,
with_decis,
round: pomodoro_round,
app_tx: app_tx.clone(),
}),
local_time: LocalTimeState::new(LocalTimeStateArgs {
app_time,
app_time_format,
}),
footer: FooterState::new(
show_menu,
if footer_toggle_app_time == Toggle::On {
Some(app_time_format)
} else {
None
},
),
}
}
pub async fn run(mut self, mut terminal: Terminal, mut events: Events) -> Result<Self> {
while self.is_running() {
if let Some(event) = events.next().await {
pub async fn run(
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('c') => app.content = Content::Countdown,
KeyCode::Char('t') => app.content = Content::Timer,
KeyCode::Char('p') => app.content = Content::Pomodoro,
KeyCode::Char('l') => app.content = Content::LocalTime,
// 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);
}
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);
}
// 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()),
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::LocalTime => app.local_time.update(event.clone()),
} {
match unhandled {
Event::Render | Event::Resize => {
self.draw(&mut terminal)?;
events::TuiEvent::Render | events::TuiEvent::Resize => {
app.draw(terminal)?;
}
Event::Key(key) => self.handle_key_event(key),
events::TuiEvent::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(())
},
);
}
}
}
Ok(())
};
while self.is_running() {
if let Some(event) = events.next().await {
let _ = match event {
events::Event::Terminal(e) => handle_tui_events(&mut self, e),
events::Event::App(e) => handle_app_events(&mut self, e),
};
}
}
Ok(self)
@@ -155,19 +375,43 @@ impl App {
self.mode != Mode::Quit
}
fn is_edit_mode(&self) -> bool {
fn get_edit_mode(&self) -> AppEditMode {
match self.content {
Content::Countdown => self.countdown.get_clock().is_edit_mode(),
Content::Timer => self.timer.get_clock().is_edit_mode(),
Content::Pomodoro => self.pomodoro.get_clock().is_edit_mode(),
Content::Countdown => {
if self.countdown.is_clock_edit_mode() {
AppEditMode::Clock
} 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::LocalTime => AppEditMode::None,
}
}
fn clock_is_running(&self) -> bool {
match self.content {
Content::Countdown => self.countdown.get_clock().is_running(),
Content::Countdown => self.countdown.is_running(),
Content::Timer => self.timer.get_clock().is_running(),
Content::Pomodoro => self.pomodoro.get_clock().is_running(),
// `LocalTime` does not use a `Clock`
Content::LocalTime => false,
}
}
@@ -176,37 +420,10 @@ impl App {
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
Content::Timer => None,
Content::Pomodoro => Some(self.pomodoro.get_clock().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,
KeyCode::Char('m') => self.show_menu = !self.show_menu,
KeyCode::Char(',') => {
self.style = self.style.next();
// update clocks
self.timer.set_style(self.style);
self.countdown.set_style(self.style);
self.pomodoro.set_style(self.style);
}
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.show_menu = true,
KeyCode::Down => self.show_menu = false,
_ => {}
};
}
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
terminal.draw(|frame| {
frame.render_stateful_widget(AppWidget, frame.area(), self);
@@ -217,10 +434,14 @@ impl App {
pub fn to_storage(&self) -> AppStorage {
AppStorage {
content: self.content,
show_menu: self.show_menu,
show_menu: self.footer.get_show_menu(),
notification: self.notification,
blink: self.blink,
app_time_format: self.app_time_format,
style: self.style,
with_decis: self.with_decis,
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()),
current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()),
inital_value_pause: Duration::from(
@@ -233,7 +454,9 @@ impl App {
current_value_countdown: Duration::from(
*self.countdown.get_clock().get_current_value(),
),
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
footer_app_time: self.footer.app_time_format().is_some().into(),
}
}
}
@@ -243,9 +466,26 @@ struct AppWidget;
impl AppWidget {
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) {
match state.content {
Content::Timer => TimerWidget.render(area, buf, &mut state.timer.clone()),
Content::Countdown => CountdownWidget.render(area, buf, &mut state.countdown.clone()),
Content::Pomodoro => PomodoroWidget.render(area, buf, &mut state.pomodoro.clone()),
Content::Timer => {
Timer {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.timer);
}
Content::Countdown => Countdown {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.countdown),
Content::Pomodoro => PomodoroWidget {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.pomodoro),
Content::LocalTime => {
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
}
};
}
}
@@ -256,7 +496,7 @@ impl StatefulWidget for AppWidget {
let [v0, v1, v2] = Layout::vertical([
Constraint::Length(1),
Constraint::Percentage(100),
Constraint::Length(if state.show_menu { 4 } else { 1 }),
Constraint::Length(if state.footer.get_show_menu() { 5 } else { 1 }),
])
.areas(area);
@@ -269,11 +509,11 @@ impl StatefulWidget for AppWidget {
self.render_content(v1, buf, state);
// footer
Footer {
show_menu: state.show_menu,
running_clock: state.clock_is_running(),
selected_content: state.content,
edit_mode: state.is_edit_mode(),
app_edit_mode: state.get_edit_mode(),
app_time: state.app_time,
}
.render(v2, buf);
.render(v2, buf, &mut state.footer);
}
}

View File

@@ -1,29 +1,39 @@
use crate::{
common::{Content, Style},
common::{Content, Style, Toggle},
duration,
};
#[cfg(feature = "sound")]
use crate::{sound, sound::SoundError};
use clap::Parser;
use std::path::PathBuf;
use std::time::Duration;
pub const LOG_DIRECTORY_DEFAULT_MISSING_VALUE: &str = " "; // empty string
#[derive(Parser)]
#[command(version)]
pub struct Args {
#[arg(long, short, value_parser = duration::parse_duration,
help = "Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
#[arg(long, short, value_parser = duration::parse_long_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'."
)]
pub countdown: Option<Duration>,
#[arg(long, visible_alias = "ct", value_parser = duration::parse_duration_by_time,
help = "Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm'"
)]
pub countdown_target: Option<duration::DirectedDuration>,
#[arg(long, short, value_parser = duration::parse_duration,
help = "Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)]
pub work: Option<Duration>,
#[arg(long, short, value_parser = duration::parse_duration,
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)]
pub pause: Option<Duration>,
#[arg(long, short = 'd', help = "Whether to show deciseconds or not.")]
#[arg(long, short = 'd', help = "Show deciseconds.")]
pub decis: bool,
#[arg(long, short = 'm', value_enum, help = "Mode to start with.")]
@@ -32,9 +42,55 @@ pub struct Args {
#[arg(long, short = 's', value_enum, help = "Style to display time with.")]
pub style: Option<Style>,
#[arg(long, value_enum, help = "Whether to open the menu or not.")]
#[arg(long, value_enum, help = "Open menu.")]
pub menu: bool,
#[arg(long, short = 'r', help = "Reset stored values to default.")]
#[arg(long, short = 'r', help = "Reset stored values to defaults.")]
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,6 +1,8 @@
use clap::ValueEnum;
use ratatui::symbols::shade;
use serde::{Deserialize, Serialize};
use strum::EnumString;
use time::{OffsetDateTime, format_description};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize,
@@ -13,6 +15,14 @@ pub enum Content {
Timer,
#[value(name = "pomodoro", alias = "p")]
Pomodoro,
#[value(name = "localtime", alias = "l")]
LocalTime,
}
#[derive(Clone, Debug)]
pub enum ClockTypeId {
Countdown,
Timer,
}
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
@@ -62,3 +72,183 @@ impl Style {
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, EnumString, Serialize, Deserialize)]
pub enum AppTimeFormat {
/// `hh:mm:ss`
#[default]
HhMmSs,
/// `hh:mm`
HhMm,
/// `hh:mm AM` (or PM)
Hh12Mm,
}
impl AppTimeFormat {
pub const fn first() -> Self {
Self::HhMmSs
}
pub const fn last() -> Self {
Self::Hh12Mm
}
pub fn next(&self) -> Self {
match self {
AppTimeFormat::HhMmSs => AppTimeFormat::HhMm,
AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm,
AppTimeFormat::Hh12Mm => AppTimeFormat::HhMmSs,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum AppTime {
Local(OffsetDateTime),
Utc(OffsetDateTime),
}
impl From<AppTime> for OffsetDateTime {
fn from(app_time: AppTime) -> Self {
match app_time {
AppTime::Local(t) => t,
AppTime::Utc(t) => t,
}
}
}
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 {
let parse_str = match app_format {
AppTimeFormat::HhMmSs => "[hour]:[minute]:[second]",
AppTimeFormat::HhMm => "[hour]:[minute]",
AppTimeFormat::Hh12Mm => "[hour repr:12 padding:none]:[minute] [period]",
};
format_description::parse(parse_str)
.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 format_date(&self) -> String {
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,
}
#[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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::{Date, Month, PrimitiveDateTime, Time};
#[test]
fn test_format_app_time() {
let dt = PrimitiveDateTime::new(
Date::from_calendar_date(2025, Month::January, 6).unwrap(),
Time::from_hms(18, 6, 10).unwrap(),
)
.assume_utc();
// hh:mm:ss
assert_eq!(
AppTime::Utc(dt).format(&AppTimeFormat::HhMmSs),
"18:06:10",
"utc"
);
assert_eq!(
AppTime::Local(dt).format(&AppTimeFormat::HhMmSs),
"18:06:10",
"local"
);
// hh:mm
assert_eq!(
AppTime::Utc(dt).format(&AppTimeFormat::HhMm),
"18:06",
"utc"
);
assert_eq!(
AppTime::Local(dt).format(&AppTimeFormat::HhMm),
"18:06",
"local"
);
// hh:mm period
assert_eq!(
AppTime::Utc(dt).format(&AppTimeFormat::Hh12Mm),
"6:06 PM",
"utc"
);
assert_eq!(
AppTime::Local(dt).format(&AppTimeFormat::Hh12Mm),
"6:06 PM",
"local"
);
}
}

View File

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

View File

@@ -1,14 +1,12 @@
use color_eyre::{
eyre::{ensure, eyre},
Report,
eyre::{ensure, eyre},
};
use std::cmp::min;
use std::fmt;
use std::time::Duration;
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);
use crate::common::AppTime;
// unstable
// https://doc.rust-lang.org/src/core/time.rs.html#32
@@ -20,6 +18,34 @@ pub const MINS_PER_HOUR: u64 = 60;
// https://doc.rust-lang.org/src/core/time.rs.html#36
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. 999y 364d 23:59:59.9 (1000 years - 1 decisecond)
pub const MAX_DURATION: Duration = ONE_YEAR
.saturating_mul(1000)
.saturating_sub(ONE_DECI_SECOND);
/// `Duration` with direction in time (past or future)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectedDuration {
/// Time `until` a future moment (positive `Duration`)
Until(Duration),
/// Time `since` a past moment (negative duration, but still represented as positive `Duration`)
Since(Duration),
}
#[derive(Debug, Clone, Copy, PartialOrd)]
pub struct DurationEx {
inner: Duration,
@@ -44,22 +70,36 @@ impl From<DurationEx> for Duration {
}
impl DurationEx {
pub fn seconds(&self) -> u64 {
self.inner.as_secs()
pub fn years(&self) -> u64 {
self.days() / DAYS_PER_YEAR
}
pub fn seconds_mod(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE
pub fn days(&self) -> u64 {
self.hours() / HOURS_PER_DAY
}
/// Days in a year
pub fn days_mod(&self) -> u64 {
self.days() % DAYS_PER_YEAR
}
pub fn hours(&self) -> u64 {
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
}
/// Hours as 24-hour clock
pub fn hours_mod(&self) -> u64 {
self.hours() % HOURS_PER_DAY
}
/// Hours as 12-hour clock
pub fn hours_mod_12(&self) -> u64 {
// 0 => 12,
// 1..=12 => hours,
// 13..=23 => hours - 12,
(self.hours_mod() + 11) % 12 + 1
}
pub fn minutes(&self) -> u64 {
self.seconds() / MINS_PER_HOUR
}
@@ -68,6 +108,14 @@ impl DurationEx {
self.minutes() % SECS_PER_MINUTE
}
pub fn seconds(&self) -> u64 {
self.inner.as_secs()
}
pub fn seconds_mod(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE
}
// deciseconds
pub fn decis(&self) -> u64 {
(self.inner.subsec_millis() / 100) as u64
@@ -86,11 +134,34 @@ impl DurationEx {
let inner = self.inner.saturating_sub(ex.inner);
Self { inner }
}
pub fn to_string_with_decis(self) -> String {
format!("{}.{}", self, self.decis())
}
}
impl fmt::Display for DurationEx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
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!(
f,
"{:02}:{:02}:{:02}",
@@ -118,45 +189,183 @@ impl fmt::Display for DurationEx {
}
}
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
let parts: Vec<&str> = arg.split(':').rev().collect();
let parse_seconds = |s: &str| -> Result<u64, Report> {
let secs = s.parse::<u64>().map_err(|_| eyre!("Invalid seconds"))?;
/// 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)
};
}
let parse_minutes = |m: &str| -> Result<u64, Report> {
let mins = m.parse::<u64>().map_err(|_| eyre!("Invalid minutes"))?;
/// 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)
};
}
let parse_hours = |h: &str| -> Result<u64, Report> {
let hours = h.parse::<u64>().map_err(|_| eyre!("Invalid hours"))?;
ensure!(hours < 100, "Hours must be less than 100.");
/// Parse hours
fn parse_hours(h: &str) -> Result<u8, Report> {
let hours = h.parse::<u8>().map_err(|_| eyre!("Invalid hours"))?;
Ok(hours)
};
let seconds = match parts.as_slice() {
[ss] => parse_seconds(ss)?,
[ss, mm] => {
let s = parse_seconds(ss)?;
let m = parse_minutes(mm)?;
m * 60 + s
}
[ss, mm, hh] => {
let s = parse_seconds(ss)?;
/// Parses `DirectedDuration` from following formats:
/// - `yyyy-mm-dd hh:mm:ss`
/// - `yyyy-mm-dd hh:mm`
/// - `hh:mm:ss`
/// - `hh:mm`
/// - `mm`
///
/// Returns `DirectedDuration::Until` for future times, `DirectedDuration::Since` for past times
pub fn parse_duration_by_time(arg: &str) -> Result<DirectedDuration, Report> {
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
let now: OffsetDateTime = AppTime::new().into();
let target_time = if arg.contains('-') {
// First: `YYYY-MM-DD HH:MM:SS`
// Then: `YYYY-MM-DD HH:MM`
let format_with_seconds =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
let format_without_seconds = format_description!("[year]-[month]-[day] [hour]:[minute]");
let pdt = PrimitiveDateTime::parse(arg, format_with_seconds)
.or_else(|_| PrimitiveDateTime::parse(arg, format_without_seconds))
.map_err(|e| {
eyre!("Invalid datetime '{}'. Use format 'yyyy-mm-dd hh:mm:ss' or 'yyyy-mm-dd hh:mm'. Error: {}", arg, e)
})?;
pdt.assume_offset(now.offset())
} else {
// Parse time parts: interpret as HH:MM:SS, HH:MM, or SS
let parts: Vec<&str> = arg.split(':').collect();
let (hour, minute, second) = match parts.as_slice() {
[mm] => {
// Single part: treat as minutes in current hour
let m = parse_minutes(mm)?;
(now.hour(), m, 0)
}
[hh, mm] => {
// Two parts: treat as HH:MM (time of day)
let h = parse_hours(hh)?;
h * 60 * 60 + m * 60 + s
let m = parse_minutes(mm)?;
(h, m, 0)
}
[hh, mm, ss] => {
// Three parts: HH:MM:SS
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(h, m, s)
}
_ => {
return Err(eyre!(
"Invalid time format. Use 'hh:mm:ss', 'hh:mm', or 'mm'"
));
}
_ => return Err(eyre!("Invalid time format. Use 'ss', mm:ss, or hh:mm:ss")),
};
Ok(Duration::from_secs(seconds))
now.replace_time(
time::Time::from_hms(hour, minute, second).map_err(|_| eyre!("Invalid time"))?,
)
};
let mut duration_secs = (target_time - now).whole_seconds();
// `Since` for past times
if duration_secs < 0 {
duration_secs *= -1;
Ok(DirectedDuration::Since(Duration::from_secs(
duration_secs as u64,
)))
} else
// `Until` for future times,
{
Ok(DirectedDuration::Until(Duration::from_secs(
duration_secs as u64,
)))
}
}
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
let parts: Vec<&str> = arg.split(':').collect();
let (hours, minutes, seconds) = match parts.as_slice() {
[ss] => {
// Single part: seconds only
let s = parse_seconds(ss)?;
(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;
Ok(Duration::from_secs(total_seconds))
}
/// Similar to `parse_duration`, but it parses `years` and `days` in addition
/// Formats: `Yy Dd`, `Yy` or `Dd` in any combination to other time formats
/// Examples: `10y 3d 12:10:03`, `2d 10:00`, `101y 33`, `5:30`
pub fn parse_long_duration(arg: &str) -> Result<Duration, Report> {
let arg = arg.trim();
// parts are separated by whitespaces:
// 3 parts: years, days, time
let parts: Vec<&str> = arg.split_whitespace().collect();
ensure!(parts.len() <= 3, "Invalid format. Too many parts.");
let mut total_duration = Duration::ZERO;
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
else if let Some(days_str) = part.strip_suffix('d') {
let days = days_str
.parse::<u64>()
.map_err(|_| eyre!("Invalid days value: '{}'", days_str))?;
total_duration = total_duration.saturating_add(ONE_DAY.saturating_mul(days as u32));
}
// possible time format
else {
time_part = Some(part);
}
}
// time format
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)]
@@ -165,26 +374,58 @@ mod tests {
use super::*;
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]
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
let ex: DurationEx = Duration::from_secs(36001).into();
assert_eq!(format!("{}", ex), "10:00:01");
let ex: DurationEx = Duration::from_secs(10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "10:00:01");
// h:mm:ss
let ex: DurationEx = Duration::from_secs(3601).into();
assert_eq!(format!("{}", ex), "1:00:01");
let ex: DurationEx = Duration::from_secs(HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1:00:01");
// mm:ss
let ex: DurationEx = Duration::from_secs(71).into();
assert_eq!(format!("{}", ex), "1:11");
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 11).into();
assert_eq!(format!("{ex}"), "1:11");
// m:ss
let ex: DurationEx = Duration::from_secs(61).into();
assert_eq!(format!("{}", ex), "1:01");
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1:01");
// ss
let ex: DurationEx = Duration::from_secs(11).into();
assert_eq!(format!("{}", ex), "11");
assert_eq!(format!("{ex}"), "11");
// s
let ex: DurationEx = Duration::from_secs(1).into();
assert_eq!(format!("{}", ex), "1");
assert_eq!(format!("{ex}"), "1");
}
#[test]
@@ -192,7 +433,7 @@ mod tests {
let ex: DurationEx = Duration::from_secs(10).into();
let ex2: DurationEx = Duration::from_secs(1).into();
let ex3 = ex.saturating_sub(ex2);
assert_eq!(format!("{}", ex3), "9");
assert_eq!(format!("{ex3}"), "9");
}
#[test]
@@ -200,7 +441,35 @@ mod tests {
let ex: DurationEx = Duration::from_secs(10).into();
let ex2: DurationEx = Duration::from_secs(1).into();
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]
@@ -220,8 +489,142 @@ mod tests {
// errors
assert!(parse_duration("1:60").is_err()); // invalid seconds
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("01:02:03:04").is_err()); // too many parts
}
#[test]
fn test_parse_duration_by_time() {
// YYYY-MM-DD HH:MM:SS - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30:45"),
Ok(DirectedDuration::Until(_))
));
// YYYY-MM-DD HH:MM - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30"),
Ok(DirectedDuration::Until(_))
));
// HH:MM:SS - past
assert!(matches!(
parse_duration_by_time("2000-01-01 23:59:59"),
Ok(DirectedDuration::Since(_))
));
// HH:MM - Until or Since depending on current time
assert!(parse_duration_by_time("18:00").is_ok());
// MM - Until or Since depending on current time
assert!(parse_duration_by_time("45").is_ok());
// errors
assert!(parse_duration_by_time("60").is_err()); // invalid minutes
assert!(parse_duration_by_time("24:00").is_err()); // invalid hours
assert!(parse_duration_by_time("24:00:00").is_err()); // invalid hours
assert!(parse_duration_by_time("2030-13-01 12:00:00").is_err()); // invalid month
assert!(parse_duration_by_time("2030-06-32 12:00:00").is_err()); // invalid day
assert!(parse_duration_by_time("abc").is_err()); // invalid input
assert!(parse_duration_by_time("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("1000y").unwrap(), MAX_DURATION);
assert_eq!(
parse_long_duration("999y 364d 23:59:59").unwrap(),
Duration::from_secs(
999 * 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)
}
}

View File

@@ -1,9 +1,11 @@
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
use futures::{Stream, StreamExt};
use std::{pin::Pin, time::Duration};
use tokio::sync::mpsc;
use tokio::time::interval;
use tokio_stream::{wrappers::IntervalStream, StreamMap};
use tokio_stream::{StreamMap, wrappers::IntervalStream};
use crate::common::ClockTypeId;
use crate::constants::{FPS_VALUE_MS, TICK_VALUE_MS};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
@@ -12,8 +14,9 @@ enum StreamKey {
Render,
Crossterm,
}
#[derive(Clone, Debug)]
pub enum Event {
pub enum TuiEvent {
Error,
Tick,
Render,
@@ -21,8 +24,17 @@ pub enum Event {
Resize,
}
#[derive(Clone, Debug)]
pub enum AppEvent {
ClockDone(ClockTypeId, String),
}
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
pub type AppEventRx = mpsc::UnboundedReceiver<AppEvent>;
pub struct Events {
streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = Event>>>>,
streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = TuiEvent>>>>,
app_channel: (AppEventTx, AppEventRx),
}
impl Default for Events {
@@ -33,31 +45,46 @@ impl Default for Events {
(StreamKey::Render, render_stream()),
(StreamKey::Crossterm, crossterm_stream()),
]),
app_channel: mpsc::unbounded_channel(),
}
}
}
pub enum Event {
Terminal(TuiEvent),
App(AppEvent),
}
impl Events {
pub fn new() -> Self {
Self::default()
}
pub async fn next(&mut self) -> Option<Event> {
self.streams.next().await.map(|(_, event)| event)
let streams = &mut self.streams;
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)),
}
}
fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
pub fn get_app_event_tx(&self) -> AppEventTx {
self.app_channel.0.clone()
}
}
fn tick_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
let tick_interval = interval(Duration::from_millis(TICK_VALUE_MS));
Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick))
Box::pin(IntervalStream::new(tick_interval).map(|_| TuiEvent::Tick))
}
fn render_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
fn render_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
let render_interval = interval(Duration::from_millis(FPS_VALUE_MS));
Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render))
Box::pin(IntervalStream::new(render_interval).map(|_| TuiEvent::Render))
}
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
Box::pin(
EventStream::new()
.fuse()
@@ -65,16 +92,16 @@ fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
.filter_map(|event| async move {
match event {
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
Some(Event::Key(key))
Some(TuiEvent::Key(key))
}
Ok(CrosstermEvent::Resize(_, _)) => Some(Event::Resize),
Err(_) => Some(Event::Error),
Ok(CrosstermEvent::Resize(_, _)) => Some(TuiEvent::Resize),
Err(_) => Some(TuiEvent::Error),
_ => None,
}
}),
)
}
pub trait EventHandler {
fn update(&mut self, _: Event) -> Option<Event>;
pub trait TuiEventHandler {
fn update(&mut self, _: TuiEvent) -> Option<TuiEvent>;
}

View File

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

View File

@@ -3,7 +3,6 @@ mod common;
mod config;
mod constants;
mod events;
#[cfg(debug_assertions)]
mod logging;
mod args;
@@ -13,24 +12,50 @@ mod terminal;
mod utils;
mod widgets;
use app::{App, AppArgs};
use args::Args;
#[cfg(feature = "sound")]
mod sound;
use app::{App, FromAppArgs};
use args::{Args, LOG_DIRECTORY_DEFAULT_MISSING_VALUE};
use clap::Parser;
use color_eyre::Result;
use config::Config;
use std::path::PathBuf;
use storage::{AppStorage, Storage};
#[tokio::main]
async fn main() -> Result<()> {
// init `Config`
let cfg = Config::init()?;
#[cfg(debug_assertions)]
logging::Logger::new(cfg.log_dir).init()?;
color_eyre::install()?;
// get args given by CLI
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
};
let terminal = terminal::setup()?;
if let Some(log_dir) = custom_log_dir {
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();
// check persistant storage
@@ -42,9 +67,14 @@ async fn main() -> Result<()> {
storage.load().unwrap_or_default()
};
// merge `Args` and `AppStorage`.
let app_args = AppArgs::from((args, stg));
let app_storage = App::new(app_args).run(terminal, events).await?.to_storage();
let app_storage = App::from(FromAppArgs {
args,
stg,
app_tx: events.get_app_event_tx(),
})
.run(&mut terminal, events)
.await?
.to_storage();
// store app state persistantly
storage.save(app_storage)?;

73
src/sound.rs Normal file
View File

@@ -0,0 +1,73 @@
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,20 +1,37 @@
use crate::{
common::{Content, Style},
common::{AppTimeFormat, Content, Style, Toggle},
widgets::pomodoro::Mode as PomodoroMode,
};
use color_eyre::eyre::Result;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use std::fs;
use std::path::PathBuf;
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)]
pub struct AppStorage {
pub content: Content,
pub show_menu: bool,
pub notification: Toggle,
pub blink: Toggle,
#[serde(deserialize_with = "deserialize_app_time_format")]
pub app_time_format: AppTimeFormat,
pub style: Style,
pub with_decis: bool,
pub pomodoro_mode: PomodoroMode,
pub pomodoro_count: u64,
// pomodoro -> work
pub inital_value_work: Duration,
pub current_value_work: Duration,
@@ -24,8 +41,11 @@ pub struct AppStorage {
// countdown
pub inital_value_countdown: Duration,
pub current_value_countdown: Duration,
pub elapsed_value_countdown: Duration,
// timer
pub current_value_timer: Duration,
// footer
pub footer_app_time: Toggle,
}
impl Default for AppStorage {
@@ -36,9 +56,13 @@ impl Default for AppStorage {
AppStorage {
content: Content::default(),
show_menu: true,
notification: Toggle::Off,
blink: Toggle::Off,
app_time_format: AppTimeFormat::default(),
style: Style::default(),
with_decis: false,
pomodoro_mode: PomodoroMode::Work,
pomodoro_count: 1,
// pomodoro -> work
inital_value_work: DEFAULT_WORK,
current_value_work: DEFAULT_WORK,
@@ -48,8 +72,11 @@ impl Default for AppStorage {
// countdown
inital_value_countdown: DEFAULT_COUNTDOWN,
current_value_countdown: DEFAULT_COUNTDOWN,
elapsed_value_countdown: Duration::ZERO,
// timer
current_value_timer: Duration::ZERO,
// footer
footer_app_time: Toggle::Off,
}
}
}

View File

@@ -5,13 +5,14 @@ use crossterm::{
cursor, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal as RatatuiTerminal};
use ratatui::{Terminal as RatatuiTerminal, backend::CrosstermBackend};
pub type Terminal = RatatuiTerminal<CrosstermBackend<io::Stdout>>;
pub fn setup() -> Result<Terminal> {
let mut stdout = std::io::stdout();
crossterm::terminal::enable_raw_mode()?;
set_panic_hook();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout))?;
terminal.clear()?;
@@ -24,3 +25,13 @@ pub fn teardown() -> Result<()> {
crossterm::terminal::disable_raw_mode()?;
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,8 +5,10 @@ pub mod clock_elements_test;
#[cfg(test)]
pub mod clock_test;
pub mod countdown;
pub mod edit_time;
pub mod footer;
pub mod header;
pub mod local_time;
pub mod pomodoro;
pub mod progressbar;
pub mod timer;

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,13 @@ use ratatui::{
pub const DIGIT_SIZE: usize = 5;
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
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 =
DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit
pub const COLON_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]
const DIGIT_0: [u8; DIGIT_SIZE * DIGIT_SIZE] = [

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,15 @@
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::KeyModifiers;
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
@@ -5,98 +17,381 @@ use ratatui::{
text::Line,
widgets::{StatefulWidget, Widget},
};
use std::cmp::max;
use std::ops::Sub;
use std::{cmp::max, time::Duration};
use time::OffsetDateTime;
use crate::{
common::Style,
events::{Event, EventHandler},
utils::center,
widgets::clock::{self, Clock, ClockWidget},
};
#[derive(Debug, Clone)]
pub struct Countdown {
clock: Clock<clock::Countdown>,
pub struct CountdownStateArgs {
pub initial_value: Duration,
pub current_value: Duration,
pub elapsed_value: Duration,
pub app_time: AppTime,
pub with_decis: bool,
pub app_tx: AppEventTx,
}
impl Countdown {
pub const fn new(clock: Clock<clock::Countdown>) -> Self {
Self { clock }
/// State for Countdown Widget
pub struct CountdownState {
/// clock to count down
clock: ClockState<clock::Countdown>,
/// clock to count time after `DONE` - similar to Mission Elapsed Time (MET)
elapsed_clock: ClockState<clock::Timer>,
app_time: AppTime,
/// Edit by local time
edit_time: Option<EditTimeState>,
}
pub fn set_style(&mut self, style: Style) {
self.clock.style = style;
impl CountdownState {
pub fn new(args: CountdownStateArgs) -> Self {
let CountdownStateArgs {
initial_value,
current_value,
elapsed_value,
with_decis,
app_time,
app_tx,
} = args;
Self {
clock: ClockState::<clock::Countdown>::new(ClockStateArgs {
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 {
initial_value: Duration::ZERO,
current_value: elapsed_value,
tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis: false,
app_tx: None,
})
.with_name("MET".to_owned())
// A previous `elapsed_value > 0` means the `Clock` was running before,
// but not in `Initial` state anymore. Updating `Mode` here
// is needed to handle `Event::Tick` in `EventHandler::update` properly
.with_mode(if elapsed_value.gt(&Duration::ZERO) {
ClockMode::Pause
} else {
ClockMode::Initial
}),
app_time,
edit_time: None,
}
}
pub fn set_with_decis(&mut self, with_decis: bool) {
self.clock.with_decis = with_decis;
self.elapsed_clock.with_decis = with_decis;
}
pub fn get_clock(&self) -> &Clock<clock::Countdown> {
pub fn get_clock(&self) -> &ClockState<clock::Countdown> {
&self.clock
}
pub fn is_running(&self) -> bool {
self.clock.is_running() || self.elapsed_clock.is_running()
}
impl EventHandler for Countdown {
fn update(&mut self, event: Event) -> Option<Event> {
let edit_mode = self.clock.is_edit_mode();
pub fn get_elapsed_value(&self) -> &DurationEx {
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 {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
match event {
Event::Tick => {
TuiEvent::Tick => {
if !self.clock.is_done() {
self.clock.tick();
} else {
self.clock.update_done_count();
self.elapsed_clock.tick();
if self.elapsed_clock.is_initial() {
self.elapsed_clock.run();
}
Event::Key(key) if key.code == KeyCode::Char('r') => {
self.clock.reset();
}
Event::Key(key) => match key.code {
KeyCode::Char('r') => {
self.clock.reset();
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);
}
KeyCode::Char('s') => {
self.clock.toggle_pause();
}
KeyCode::Char('e') => {
// EDIT CLOCK mode
TuiEvent::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();
}
KeyCode::Left if edit_mode => {
self.clock.edit_next();
// 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();
}
KeyCode::Right if edit_mode => {
// 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::Up if edit_mode => {
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 edit_mode => {
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::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::Key(key) => match key.code {
KeyCode::Char('r') => {
// reset both clocks to use intial values
self.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') => {
// toggle pause status depending on which clock is running
if !self.clock.is_done() {
self.clock.toggle_pause();
} else {
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') => {
// toggle edit mode
self.clock.toggle_edit();
// pause `elapsed_clock`
if self.elapsed_clock.is_running() {
self.elapsed_clock.toggle_pause();
}
}
_ => return Some(event),
},
_ => return Some(event),
}
None
}
}
pub struct CountdownWidget;
pub struct Countdown {
pub style: Style,
pub blink: bool,
}
impl StatefulWidget for CountdownWidget {
type State = Countdown;
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 {
type State = CountdownState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock = ClockWidget::new();
let label = Line::raw((format!("Countdown {}", state.clock.get_mode())).to_uppercase());
// render `edit_time` OR `clock`
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);
label.centered().render(v2, buf);
} else {
let label = Line::raw(
if state.clock.is_done() {
if state.clock.with_decis {
format!(
"Countdown {} +{}",
state.clock.get_mode(),
state
.elapsed_clock
.get_current_value()
.to_string_with_decis()
)
} else {
format!(
"Countdown {} +{}",
state.clock.get_mode(),
state.elapsed_clock.get_current_value()
)
}
} else {
format!("Countdown {}", state.clock.get_mode())
}
.to_uppercase(),
);
let widget = ClockWidget::new(self.style, self.blink);
let area = center(
area,
Constraint::Length(max(
clock.get_width(&state.clock.get_format(), state.clock.with_decis),
widget.get_width(state.clock.get_format(), state.clock.with_decis),
label.width() as u16,
)),
Constraint::Length(clock.get_height() + 1 /* height of label */),
Constraint::Length(widget.get_height() + 1 /* height of label */),
);
let [v1, v2] =
Layout::vertical(Constraint::from_lengths([clock.get_height(), 1])).areas(area);
Layout::vertical(Constraint::from_lengths([widget.get_height(), 1])).areas(area);
clock.render(v1, buf, &mut state.clock);
widget.render(v1, buf, &mut state.clock);
label.centered().render(v2, buf);
}
}
}

231
src/widgets/edit_time.rs Normal file
View File

@@ -0,0 +1,231 @@
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,29 +1,62 @@
use std::collections::BTreeMap;
use crate::common::Content;
use crate::common::{AppEditMode, AppTime, AppTimeFormat, Content};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
symbols::{border, scrollbar},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, Widget},
widgets::{Block, Borders, Cell, Row, StatefulWidget, Table, Widget},
};
#[derive(Debug, Clone)]
pub struct Footer {
pub show_menu: bool,
pub running_clock: bool,
pub selected_content: Content,
pub edit_mode: bool,
pub struct FooterState {
show_menu: bool,
app_time_format: Option<AppTimeFormat>,
}
impl Widget for Footer {
fn render(self, area: Rect, buf: &mut Buffer) {
impl FooterState {
pub const fn new(show_menu: bool, app_time_format: Option<AppTimeFormat>) -> Self {
Self {
show_menu,
app_time_format,
}
}
pub fn set_show_menu(&mut self, value: bool) {
self.show_menu = value;
}
pub const fn get_show_menu(&self) -> bool {
self.show_menu
}
pub const fn app_time_format(&self) -> &Option<AppTimeFormat> {
&self.app_time_format
}
pub const fn set_app_time_format(&mut self, value: Option<AppTimeFormat>) {
self.app_time_format = value;
}
}
#[derive(Debug)]
pub struct Footer {
pub running_clock: bool,
pub selected_content: Content,
pub app_edit_mode: AppEditMode,
pub app_time: AppTime,
}
impl StatefulWidget for Footer {
type State = FooterState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let content_labels: BTreeMap<Content, &str> = BTreeMap::from([
(Content::Countdown, "[c]ountdown"),
(Content::Timer, "[t]imer"),
(Content::Pomodoro, "[p]omodoro"),
(Content::LocalTime, "[l]ocal time"),
]);
let [_, area] =
@@ -31,15 +64,26 @@ impl Widget for Footer {
let [border_area, menu_area] =
Layout::vertical([Constraint::Length(1), Constraint::Percentage(100)]).areas(area);
Block::new()
.borders(Borders::TOP)
.title(
format! {"[m]enu {:} ", if self.show_menu {scrollbar::VERTICAL.end} else {scrollbar::VERTICAL.begin}},
format! {"[m]enu {:} ", if state.show_menu {scrollbar::VERTICAL.end} else {scrollbar::VERTICAL.begin}},
)
.title(
Line::from(
match (state.app_time_format, self.selected_content) {
// Show time
(Some(v), content) if content != Content::LocalTime => format!(" {} " // add some space around
, self.app_time.format(&v)),
// Hide time -> empty
_ => "".into(),
}
).right_aligned())
.border_set(border::PLAIN)
.render(border_area, buf);
// show menu
if self.show_menu {
if state.show_menu {
let content_labels: Vec<Span> = content_labels
.iter()
.enumerate()
@@ -47,7 +91,7 @@ impl Widget for Footer {
let mut style = Style::default();
// Add space for all except last
let label = if index < content_labels.len() - 1 {
format!("{} ", label)
format!("{label} ")
} else {
label.to_string()
};
@@ -60,9 +104,8 @@ impl Widget for Footer {
const SPACE: &str = " "; // 2 empty spaces
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
Table::new(
[
// content
let mut table_rows = vec![
// screens
Row::new(vec![
Cell::from(Span::styled(
"screens",
@@ -70,7 +113,7 @@ impl Widget for Footer {
)),
Cell::from(Line::from(content_labels)),
]),
// format
// appearance
Row::new(vec![
Cell::from(Span::styled(
"appearance",
@@ -80,56 +123,130 @@ impl Widget for Footer {
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
];
if self.selected_content != Content::LocalTime {
table_rows.extend_from_slice(&[
// controls - 1. row
Row::new(vec![
Cell::from(Span::styled(
"controls",
Style::default().add_modifier(Modifier::BOLD),
)),
Cell::from(Line::from({
if self.edit_mode {
vec![
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 {
match self.app_edit_mode {
AppEditMode::None => {
let mut spans = vec![Span::from(if self.running_clock {
"[s]top"
} else {
"[s]tart"
}),
Span::from(SPACE),
Span::from("[r]eset"),
})];
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[e]dit"),
];
]);
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("[← →]switch work/pause"),
Span::from("[^r]eset clocks+rounds"),
]);
}
spans
}
_ => {
let mut spans = vec![Span::from("[s]ave 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"),
]);
spans
}
}
})),
]),
// controls - 2. row
Row::new(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(
"[← →]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
)),
],
widths,
)
.column_spacing(1)
.render(menu_area, buf);
}
})),
]),
])
}
let table = Table::new(table_rows, widths).column_spacing(1);
Widget::render(table, menu_area, buf);
}
}
}

195
src/widgets/local_time.rs Normal file
View File

@@ -0,0 +1,195 @@
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::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,24 +1,20 @@
use crate::{
common::Style,
constants::TICK_VALUE_MS,
events::{Event, EventHandler},
events::{AppEventTx, TuiEvent, TuiEventHandler},
utils::center,
widgets::clock::{Clock, ClockWidget, Countdown},
widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown},
};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
layout::{Constraint, Layout, Rect},
text::Line,
widgets::{StatefulWidget, Widget},
};
use std::{cmp::max, time::Duration};
use strum::Display;
use serde::{Deserialize, Serialize};
use super::clock::ClockArgs;
use std::{cmp::max, time::Duration};
use strum::Display;
#[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)]
pub enum Mode {
@@ -26,20 +22,19 @@ pub enum Mode {
Pause,
}
#[derive(Debug, Clone)]
pub struct ClockMap {
work: Clock<Countdown>,
pause: Clock<Countdown>,
work: ClockState<Countdown>,
pause: ClockState<Countdown>,
}
impl ClockMap {
fn get_mut(&mut self, mode: &Mode) -> &mut Clock<Countdown> {
fn get_mut(&mut self, mode: &Mode) -> &mut ClockState<Countdown> {
match mode {
Mode::Work => &mut self.work,
Mode::Pause => &mut self.pause,
}
}
fn get(&self, mode: &Mode) -> &Clock<Countdown> {
fn get(&self, mode: &Mode) -> &ClockState<Countdown> {
match mode {
Mode::Work => &self.work,
Mode::Pause => &self.pause,
@@ -47,77 +42,89 @@ impl ClockMap {
}
}
#[derive(Debug, Clone)]
pub struct Pomodoro {
pub struct PomodoroState {
mode: Mode,
clock_map: ClockMap,
round: u64,
}
pub struct PomodoroArgs {
pub struct PomodoroStateArgs {
pub mode: Mode,
pub initial_value_work: Duration,
pub current_value_work: Duration,
pub initial_value_pause: Duration,
pub current_value_pause: Duration,
pub style: Style,
pub with_decis: bool,
pub app_tx: AppEventTx,
pub round: u64,
}
impl Pomodoro {
pub fn new(args: PomodoroArgs) -> Self {
let PomodoroArgs {
impl PomodoroState {
pub fn new(args: PomodoroStateArgs) -> Self {
let PomodoroStateArgs {
mode,
initial_value_work,
current_value_work,
initial_value_pause,
current_value_pause,
style,
with_decis,
app_tx,
round,
} = args;
Self {
mode,
clock_map: ClockMap {
work: Clock::<Countdown>::new(ClockArgs {
work: ClockState::<Countdown>::new(ClockStateArgs {
initial_value: initial_value_work,
current_value: current_value_work,
tick_value: Duration::from_millis(TICK_VALUE_MS),
style,
with_decis,
}),
pause: Clock::<Countdown>::new(ClockArgs {
app_tx: Some(app_tx.clone()),
})
.with_name("Work".to_owned()),
pause: ClockState::<Countdown>::new(ClockStateArgs {
initial_value: initial_value_pause,
current_value: current_value_pause,
tick_value: Duration::from_millis(TICK_VALUE_MS),
style,
with_decis,
}),
app_tx: Some(app_tx),
})
.with_name("Pause".to_owned()),
},
round,
}
}
fn get_clock_mut(&mut self) -> &mut Clock<Countdown> {
fn get_clock_mut(&mut self) -> &mut ClockState<Countdown> {
self.clock_map.get_mut(&self.mode)
}
pub fn get_clock(&self) -> &Clock<Countdown> {
pub fn get_clock(&self) -> &ClockState<Countdown> {
self.clock_map.get(&self.mode)
}
pub fn get_clock_work(&self) -> &Clock<Countdown> {
pub fn get_clock_work(&self) -> &ClockState<Countdown> {
&self.clock_map.work
}
pub fn get_clock_pause(&self) -> &Clock<Countdown> {
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> {
&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 {
&self.mode
}
pub fn set_style(&mut self, style: Style) {
self.clock_map.work.style = style;
self.clock_map.pause.style = style;
pub fn get_round(&self) -> u64 {
self.round
}
pub fn set_with_decis(&mut self, with_decis: bool) {
@@ -133,40 +140,84 @@ impl Pomodoro {
}
}
impl EventHandler for Pomodoro {
fn update(&mut self, event: Event) -> Option<Event> {
impl TuiEventHandler for PomodoroState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
let edit_mode = self.get_clock().is_edit_mode();
match event {
Event::Tick => {
TuiEvent::Tick => {
self.get_clock_mut().tick();
self.get_clock_mut().update_done_count();
}
Event::Key(key) => match key.code {
// EDIT mode
TuiEvent::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::Key(key) => match key.code {
// Toggle run/pause
KeyCode::Char('s') => {
self.get_clock_mut().toggle_pause();
}
// Enter edit mode
KeyCode::Char('e') => {
self.get_clock_mut().toggle_edit();
}
KeyCode::Left if edit_mode => {
self.get_clock_mut().edit_next();
}
// toggle WORK/PAUSE
KeyCode::Left => {
// `next` is acting as same as a `prev` function, we don't have
// `next` is acting as same as a "prev" function we don't have
self.next();
}
KeyCode::Right if edit_mode => {
self.get_clock_mut().edit_prev();
}
// toggle WORK/PAUSE
KeyCode::Right => {
self.next();
}
KeyCode::Up if edit_mode => {
self.get_clock_mut().edit_up();
}
KeyCode::Down if edit_mode => {
self.get_clock_mut().edit_down();
// reset rounds AND clocks
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.round = 1;
self.get_clock_work_mut().reset();
self.get_clock_pause_mut().reset();
}
// reset current clock
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();
}
_ => return Some(event),
@@ -177,12 +228,15 @@ impl EventHandler for Pomodoro {
}
}
pub struct PomodoroWidget;
pub struct PomodoroWidget {
pub style: Style,
pub blink: bool,
}
impl StatefulWidget for PomodoroWidget {
type State = Pomodoro;
type State = PomodoroState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock_widget = ClockWidget::new();
let clock_widget = ClockWidget::new(self.style, self.blink);
let label = Line::raw(
(format!(
"Pomodoro {} {}",
@@ -191,23 +245,34 @@ impl StatefulWidget for PomodoroWidget {
))
.to_uppercase(),
);
let label_round = Line::raw((format!("round {}", state.get_round(),)).to_uppercase());
let area = center(
area,
Constraint::Length(max(
clock_widget.get_width(
&state.get_clock().get_format(),
state.get_clock().with_decis,
),
clock_widget
.get_width(state.get_clock().get_format(), state.get_clock().with_decis),
label.width() as u16,
)),
Constraint::Length(clock_widget.get_height() + 1 /* height of mode_str */),
Constraint::Length(
// empty label + height of `label` + `label_round`
clock_widget.get_height() + 3,
),
);
let [v1, v2] =
Layout::vertical(Constraint::from_lengths([clock_widget.get_height(), 1])).areas(area);
let [v1, v2, v3, v4] = Layout::vertical(Constraint::from_lengths([
1,
clock_widget.get_height(),
1,
1,
]))
.areas(area);
clock_widget.render(v1, buf, state.get_clock_mut());
label.centered().render(v2, buf);
// empty line keep everything in center vertically comparing to other
// views (which have one label below the clock only)
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,9 +1,10 @@
use crate::{
common::Style,
events::{Event, EventHandler},
events::{TuiEvent, TuiEventHandler},
utils::center,
widgets::clock::{self, Clock, ClockWidget},
widgets::clock::{self, ClockState, ClockWidget},
};
use crossterm::event::KeyModifiers;
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
@@ -13,58 +14,83 @@ use ratatui::{
};
use std::cmp::max;
#[derive(Debug, Clone)]
pub struct Timer {
clock: Clock<clock::Timer>,
pub struct TimerState {
clock: ClockState<clock::Timer>,
}
impl Timer {
pub const fn new(clock: Clock<clock::Timer>) -> Self {
impl TimerState {
pub const fn new(clock: ClockState<clock::Timer>) -> Self {
Self { clock }
}
pub fn set_style(&mut self, style: Style) {
self.clock.style = style;
}
pub fn set_with_decis(&mut self, with_decis: bool) {
self.clock.with_decis = with_decis;
}
pub fn get_clock(&self) -> &Clock<clock::Timer> {
pub fn get_clock(&self) -> &ClockState<clock::Timer> {
&self.clock
}
}
impl EventHandler for Timer {
fn update(&mut self, event: Event) -> Option<Event> {
impl TuiEventHandler for TimerState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
let edit_mode = self.clock.is_edit_mode();
match event {
Event::Tick => {
TuiEvent::Tick => {
self.clock.tick();
self.clock.update_done_count();
}
Event::Key(key) => match key.code {
// EDIT mode
TuiEvent::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::Key(key) => match key.code {
// Toggle run/pause
KeyCode::Char('s') => {
self.clock.toggle_pause();
}
// reset clock
KeyCode::Char('r') => {
self.clock.reset();
}
// enter edit mode
KeyCode::Char('e') => {
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),
@@ -73,19 +99,22 @@ impl EventHandler for Timer {
}
}
pub struct TimerWidget;
pub struct Timer {
pub style: Style,
pub blink: bool,
}
impl StatefulWidget for &TimerWidget {
type State = Timer;
impl StatefulWidget for Timer {
type State = TimerState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let clock = &mut state.clock;
let clock_widget = ClockWidget::new();
let clock_widget = ClockWidget::new(self.style, self.blink);
let label = Line::raw((format!("Timer {}", clock.get_mode())).to_uppercase());
let area = center(
area,
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,
)),
Constraint::Length(clock_widget.get_height() + 1 /* height of label */),