16 Commits

Author SHA1 Message Date
jk
b128a8d7b4 Prepare v1.5.0 2025-10-03 12:44:00 +02:00
Jens Krause
0c4f507ebf demos for v1.5.0 (#115) 2025-10-03 12:39:23 +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
30 changed files with 2656 additions and 697 deletions

4
.gitignore vendored
View File

@@ -21,3 +21,7 @@ result/**/*
# ignore (possible) sound files # ignore (possible) sound files
**/*.{mp3,wav} **/*.{mp3,wav}
CLAUDE.md
.claude

View File

@@ -1,10 +1,33 @@
# Changelog # Changelog
## v1.5.0 - 2025-10-03
### Features
- (cli) Accept `years` and `days` for `--countdown` argument [#114](https://github.com/sectore/timr-tui/pull/114)
- (cli) New `--countdown-target` argument to parse `countdown` values by given time in the future or past [#112](https://github.com/sectore/timr-tui/pull/112)
- (localtime) Show `date` [#111](https://github.com/sectore/timr-tui/pull/111)
- (edit) Change any value by `10x` up or down [#110](https://github.com/sectore/timr-tui/pull/110)
- (timer/countdown): Support `days` and `years` up to `999y 364d 23:59:59` [#96](https://github.com/sectore/timr-tui/pull/96)
### Fix
- (edit) Auto jump to next possible value while decreasing, but ignoring `zero` values [#109](https://github.com/sectore/timr-tui/pull/109)
- (format) Improve format handling + fix `days` (no zero-padding) [#107](https://github.com/sectore/timr-tui/pull/107)
### Misc.
- (deps) Upgrade dependencies [#113](https://github.com/sectore/timr-tui/pull/113)
- (deps) Rust 1.90.0 [#95](https://github.com/sectore/timr-tui/pull/95)
- (guide) Add contributing guidelines [#94](https://github.com/sectore/timr-tui/pull/94)
## v1.4.0 - 2025-09-02 ## v1.4.0 - 2025-09-02
### Features ### 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) - (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. ### Misc.

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.

697
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,24 @@
[package] [package]
name = "timr-tui" name = "timr-tui"
version = "1.4.0" version = "1.5.0"
description = "TUI to organize your time: Pomodoro, Countdown, Timer." description = "TUI to organize your time: Pomodoro, Countdown, Timer."
edition = "2024" edition = "2024"
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`. # Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`.
rust-version = "1.89.0" rust-version = "1.90.0"
homepage = "https://github.com/sectore/timr-tui" homepage = "https://github.com/sectore/timr-tui"
repository = "https://github.com/sectore/timr-tui" repository = "https://github.com/sectore/timr-tui"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
keywords = ["tui", "timer", "countdown", "pomodoro"] keywords = ["tui", "timer", "countdown", "pomodoro"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
exclude = [".github/*", "demo/*.tape", "result/*", "*.mp3"] exclude = [
".github/*",
"demo/*.tape",
"result/*",
"*.mp3",
".claude",
"CLAUDE.md",
]
[dependencies] [dependencies]
ratatui = "0.29.0" ratatui = "0.29.0"
@@ -21,20 +28,20 @@ futures = "0.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
strum = { version = "0.26.3", features = ["derive"] } strum = { version = "0.26.3", features = ["derive"] }
tokio = { version = "1.45.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
tokio-util = "0.7.15" tokio-util = "0.7.16"
tracing = "0.1.41" 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" directories = "5.0.1"
clap = { version = "4.5.40", features = ["derive"] } clap = { version = "4.5.48", features = ["derive"] }
time = { version = "0.3.41", features = ["formatting", "local-offset"] } time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros"] }
notify-rust = "4.11.7" notify-rust = "4.11.7"
rodio = { version = "0.20.1", features = [ rodio = { version = "0.20.1", features = [
"symphonia-mp3", "symphonia-mp3",
"symphonia-wav", "symphonia-wav",
], default-features = false, optional = true } ], default-features = false, optional = true }
thiserror = { version = "2.0.12", optional = true } thiserror = { version = "2.0.17", optional = true }
[features] [features]

View File

@@ -17,6 +17,7 @@ Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.or
- [Installation](./#installation) - [Installation](./#installation)
- [Development](./#development) - [Development](./#development)
- [Misc](./#misc) - [Misc](./#misc)
- [Contributing](./#contributing)
- [License](./#license) - [License](./#license)
# Preview # Preview
@@ -86,19 +87,34 @@ timr-tui --help
Usage: timr-tui [OPTIONS] Usage: timr-tui [OPTIONS]
Options: Options:
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' -c, --countdown <COUNTDOWN>
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' 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'.
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' --countdown-target <COUNTDOWN_TARGET>
-d, --decis Show deciseconds. 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]
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro, localtime] -w, --work <WORK>
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille] Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
--menu Open the menu. -p, --pause <PAUSE>
-r, --reset Reset stored values to default values. Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-n, --notification <NOTIFICATION> Toggle desktop notifications. Experimental. [possible values: on, off] -d, --decis
--blink <BLINK> Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off] Show deciseconds.
--log [<LOG>] Directory to store log file. If not set, standard application log directory is used (check README for details). -m, --mode <MODE>
-h, --help Print help Mode to start with. [possible values: countdown, timer, pomodoro, localtime]
-V, --version Print version -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): Extra option (if `--features sound` is enabled by local build only):
@@ -141,22 +157,24 @@ Extra option (if `--features sound` is enabled by local build only):
| <kbd>Esc</kbd> | skip changes | | <kbd>Esc</kbd> | skip changes |
| <kbd>←</kbd> or <kbd>→</kbd> | change selection | | <kbd>←</kbd> or <kbd>→</kbd> | change selection |
| <kbd>↑</kbd> | edit to go up | | <kbd>↑</kbd> | edit to go up |
| <kbd>ctrl+↑</kbd> | edit to go up 10x |
| <kbd>↓</kbd> | edit to go down | | <kbd>↓</kbd> | edit to go down |
| <kbd>ctrl+↓</kbd> | edit to go down 10x |
**In `Pomodoro` screen only:** **In `Pomodoro` screen only:**
| Key | Description | | Key | Description |
| --- | --- | | --- | --- |
| <kbd>←</kbd> or <kbd>→</kbd> | switch work/pause | | <kbd>←</kbd> or <kbd>→</kbd> | switch work/pause |
| <kbd>^r</kbd> | reset round | | <kbd>ctrl+r</kbd> | reset round |
| <kbd>^s</kbd> | save initial value | | <kbd>ctrl+s</kbd> | save initial value |
**In `Countdown` screen only:** **In `Countdown` screen only:**
| Key | Description | | Key | Description |
| --- | --- | | --- | --- |
| <kbd>^e</kbd> | edit by local time | | <kbd>ctrl+e</kbd> | edit by local time |
| <kbd>^s</kbd> | save initial value | | <kbd>ctrl+s</kbd> | save initial value |
## Appearance ## Appearance
@@ -303,6 +321,10 @@ C:/Users/{user}/AppData/Local/timr-tui/logs/app.log
Optional: You can use a custom directory by passing it via `--log` arg. Optional: You can use a custom directory by passing it via `--log` arg.
# Contributing
[CONTRIBUTING.md](./CONTRIBUTING.md)
# License # License
[MIT License](./LICENSE) [MIT License](./LICENSE)

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

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

24
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1754269165, "lastModified": 1758758545,
"narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "444e81206df3f7d92780680e45858e31d2f07a08", "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -23,11 +23,11 @@
"rust-analyzer-src": "rust-analyzer-src" "rust-analyzer-src": "rust-analyzer-src"
}, },
"locked": { "locked": {
"lastModified": 1755240331, "lastModified": 1758782550,
"narHash": "sha256-wEtw76+R/TOHEIjYOnxADC91G6s422HGruAngbjzsDw=", "narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "3f076d4502001c64877099093318b2dbd8b062a1", "rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -56,11 +56,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1755027561, "lastModified": 1758690382,
"narHash": "sha256-IVft239Bc8p8Dtvf7UAACMG5P3ZV+3/aO28gXpGtMXI=", "narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "005433b926e16227259a1843015b5b2b7f7d1fc3", "rev": "e643668fd71b949c53f8626614b21ff71a07379d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -81,11 +81,11 @@
"rust-analyzer-src": { "rust-analyzer-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1755004716, "lastModified": 1758620797,
"narHash": "sha256-TbhPR5Fqw5LjAeI3/FOPhNNFQCF3cieKCJWWupeZmiA=", "narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
"owner": "rust-lang", "owner": "rust-lang",
"repo": "rust-analyzer", "repo": "rust-analyzer",
"rev": "b2a58b8c6eff3c3a2c8b5c70dbf69ead78284194", "rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -24,7 +24,7 @@
{ {
file = ./rust-toolchain.toml; file = ./rust-toolchain.toml;
# sha256 = nixpkgs.lib.fakeSha256; # sha256 = nixpkgs.lib.fakeSha256;
sha256 = "sha256-+9FmLhAOezBZCOziO0Qct1NOrfpjNsXxc/8I0c7BdKE="; sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
}; };
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
@@ -85,15 +85,18 @@
pkgs.nixd pkgs.nixd
pkgs.alejandra pkgs.alejandra
] ]
# some extra pkgs needed to play sound on Linux # pkgs needed to play sound on Linux
++ lib.optionals stdenv.isLinux [ ++ lib.optionals stdenv.isLinux [
pkgs.pkg-config pkgs.pkg-config
(pkgs.alsa-lib-with-plugins.override { pkgs.pipewire
plugins = [pkgs.alsa-plugins pkgs.pipewire]; pkgs.alsa-lib
})
]; ];
inherit (commonArgs) src; inherit (commonArgs) src;
# 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

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

View File

@@ -2,6 +2,7 @@ use crate::{
args::Args, args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle}, common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS, constants::TICK_VALUE_MS,
duration::DirectedDuration,
events::{self, TuiEventHandler}, events::{self, TuiEventHandler},
storage::AppStorage, storage::AppStorage,
terminal::Terminal, terminal::Terminal,
@@ -28,7 +29,6 @@ use ratatui::{
}; };
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use time::OffsetDateTime;
use tracing::{debug, error}; use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -103,7 +103,7 @@ impl From<FromAppArgs> for App {
None => { None => {
if args.work.is_some() || args.pause.is_some() { if args.work.is_some() || args.pause.is_some() {
Content::Pomodoro Content::Pomodoro
} else if args.countdown.is_some() { } else if args.countdown.is_some() || args.countdown_target.is_some() {
Content::Countdown Content::Countdown
} }
// in other case just use latest stored state // in other case just use latest stored state
@@ -121,13 +121,28 @@ impl From<FromAppArgs> for App {
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause), initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
// invalidate `current_value_pause` if an initial value is set via args // invalidate `current_value_pause` if an initial value is set via args
current_value_pause: args.pause.unwrap_or(stg.current_value_pause), current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown), initial_value_countdown: 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 // 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) {
elapsed_value_countdown: match args.countdown { (Some(d), _) => *d,
// reset value if countdown is set by arguments (None, Some(DirectedDuration::Until(d))) => *d,
Some(_) => Duration::ZERO, // `zero` makes values from `past` marked as `DONE`
None => stg.elapsed_value_countdown, (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, current_value_timer: stg.current_value_timer,
app_tx, app_tx,
@@ -140,13 +155,6 @@ impl From<FromAppArgs> for App {
} }
} }
fn get_app_time() -> AppTime {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
impl App { impl App {
pub fn new(args: AppArgs) -> Self { pub fn new(args: AppArgs) -> Self {
let AppArgs { let AppArgs {
@@ -171,7 +179,7 @@ impl App {
app_tx, app_tx,
footer_toggle_app_time, footer_toggle_app_time,
} = args; } = args;
let app_time = get_app_time(); let app_time = AppTime::new();
Self { Self {
mode: Mode::Running, mode: Mode::Running,
@@ -292,7 +300,7 @@ impl App {
// Closure to handle `TuiEvent`'s // Closure to handle `TuiEvent`'s
let mut handle_tui_events = |app: &mut Self, event: events::TuiEvent| -> Result<()> { let mut handle_tui_events = |app: &mut Self, event: events::TuiEvent| -> Result<()> {
if matches!(event, events::TuiEvent::Tick) { if matches!(event, events::TuiEvent::Tick) {
app.app_time = get_app_time(); app.app_time = AppTime::new();
app.countdown.set_app_time(app.app_time); app.countdown.set_app_time(app.app_time);
app.local_time.set_app_time(app.app_time); app.local_time.set_app_time(app.app_time);
} }

View File

@@ -13,18 +13,23 @@ pub const LOG_DIRECTORY_DEFAULT_MISSING_VALUE: &str = " "; // empty string
#[derive(Parser)] #[derive(Parser)]
#[command(version)] #[command(version)]
pub struct Args { pub struct Args {
#[arg(long, short, value_parser = duration::parse_duration, #[arg(long, short, value_parser = duration::parse_long_duration,
help = "Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'" 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>, 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, #[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>, pub work: Option<Duration>,
#[arg(long, short, value_parser = duration::parse_duration, #[arg(long, short, value_parser = duration::parse_duration,
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'" help = "Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)] )]
pub pause: Option<Duration>, pub pause: Option<Duration>,
@@ -37,10 +42,10 @@ pub struct Args {
#[arg(long, short = 's', value_enum, help = "Style to display time with.")] #[arg(long, short = 's', value_enum, help = "Style to display time with.")]
pub style: Option<Style>, pub style: Option<Style>,
#[arg(long, value_enum, help = "Open the menu.")] #[arg(long, value_enum, help = "Open menu.")]
pub menu: bool, pub menu: bool,
#[arg(long, short = 'r', help = "Reset stored values to default values.")] #[arg(long, short = 'r', help = "Reset stored values to defaults.")]
pub reset: bool, pub reset: bool,
#[arg( #[arg(
@@ -76,7 +81,7 @@ pub struct Args {
// this value will be checked later in `main` // this value will be checked later in `main`
// to use another (default) log directory instead // to use another (default) log directory instead
default_missing_value=LOG_DIRECTORY_DEFAULT_MISSING_VALUE, default_missing_value=LOG_DIRECTORY_DEFAULT_MISSING_VALUE,
help = "Directory to store log file. If not set, standard application log directory is used (check README for details).", help = "Directory for log file. If not set, standard application log directory is used (check README for details).",
value_hint = clap::ValueHint::DirPath, value_hint = clap::ValueHint::DirPath,
)] )]
pub log: Option<PathBuf>, pub log: Option<PathBuf>,

View File

@@ -118,6 +118,14 @@ impl From<AppTime> for OffsetDateTime {
} }
impl AppTime { impl AppTime {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
pub fn format(&self, app_format: &AppTimeFormat) -> String { pub fn format(&self, app_format: &AppTimeFormat) -> String {
let parse_str = match app_format { let parse_str = match app_format {
AppTimeFormat::HhMmSs => "[hour]:[minute]:[second]", AppTimeFormat::HhMmSs => "[hour]:[minute]:[second]",
@@ -135,6 +143,17 @@ impl AppTime {
.unwrap_or_else(|e| e.to_string()) .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 { pub fn get_period(&self) -> String {
format_description::parse("[period]") format_description::parse("[period]")
.map_err(|_| "parse error") .map_err(|_| "parse error")

View File

@@ -2,13 +2,11 @@ use color_eyre::{
Report, Report,
eyre::{ensure, eyre}, eyre::{ensure, eyre},
}; };
use std::cmp::min;
use std::fmt; use std::fmt;
use std::time::Duration; use std::time::Duration;
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100); use crate::common::AppTime;
pub const ONE_SECOND: Duration = Duration::from_secs(1);
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
pub const ONE_HOUR: Duration = Duration::from_secs(MINS_PER_HOUR * SECS_PER_MINUTE);
// unstable // unstable
// https://doc.rust-lang.org/src/core/time.rs.html#32 // https://doc.rust-lang.org/src/core/time.rs.html#32
@@ -20,9 +18,33 @@ pub const MINS_PER_HOUR: u64 = 60;
// https://doc.rust-lang.org/src/core/time.rs.html#36 // https://doc.rust-lang.org/src/core/time.rs.html#36
const HOURS_PER_DAY: u64 = 24; const HOURS_PER_DAY: u64 = 24;
// max. 99:59:59 pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
pub const MAX_DURATION: Duration = pub const ONE_SECOND: Duration = Duration::from_secs(1);
Duration::from_secs(100 * MINS_PER_HOUR * SECS_PER_MINUTE).saturating_sub(ONE_SECOND); 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)] #[derive(Debug, Clone, Copy, PartialOrd)]
pub struct DurationEx { pub struct DurationEx {
@@ -48,12 +70,17 @@ impl From<DurationEx> for Duration {
} }
impl DurationEx { impl DurationEx {
pub fn seconds(&self) -> u64 { pub fn years(&self) -> u64 {
self.inner.as_secs() self.days() / DAYS_PER_YEAR
} }
pub fn seconds_mod(&self) -> u64 { pub fn days(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE 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 { pub fn hours(&self) -> u64 {
@@ -81,6 +108,14 @@ impl DurationEx {
self.minutes() % SECS_PER_MINUTE 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 // deciseconds
pub fn decis(&self) -> u64 { pub fn decis(&self) -> u64 {
(self.inner.subsec_millis() / 100) as u64 (self.inner.subsec_millis() / 100) as u64
@@ -107,7 +142,26 @@ impl DurationEx {
impl fmt::Display for DurationEx { impl fmt::Display for DurationEx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.hours() >= 10 { if self.years() >= 1 {
write!(
f,
"{}y {}d {:02}:{:02}:{:02}",
self.years(),
self.days_mod(),
self.hours_mod(),
self.minutes_mod(),
self.seconds_mod(),
)
} else if self.hours() >= HOURS_PER_DAY {
write!(
f,
"{}d {:02}:{:02}:{:02}",
self.days_mod(),
self.hours_mod(),
self.minutes_mod(),
self.seconds_mod(),
)
} else if self.hours() >= 10 {
write!( write!(
f, f,
"{:02}:{:02}:{:02}", "{:02}:{:02}:{:02}",
@@ -135,45 +189,183 @@ impl fmt::Display for DurationEx {
} }
} }
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss` /// Parse seconds (must be < 60)
pub fn parse_duration(arg: &str) -> Result<Duration, Report> { fn parse_seconds(s: &str) -> Result<u8, Report> {
let parts: Vec<&str> = arg.split(':').rev().collect(); let secs = s.parse::<u8>().map_err(|_| eyre!("Invalid seconds"))?;
let parse_seconds = |s: &str| -> Result<u64, Report> {
let secs = s.parse::<u64>().map_err(|_| eyre!("Invalid seconds"))?;
ensure!(secs < 60, "Seconds must be less than 60."); ensure!(secs < 60, "Seconds must be less than 60.");
Ok(secs) Ok(secs)
}; }
let parse_minutes = |m: &str| -> Result<u64, Report> { /// Parse minutes (must be < 60)
let mins = m.parse::<u64>().map_err(|_| eyre!("Invalid minutes"))?; 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."); ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins) Ok(mins)
}; }
let parse_hours = |h: &str| -> Result<u64, Report> { /// Parse hours
let hours = h.parse::<u64>().map_err(|_| eyre!("Invalid hours"))?; fn parse_hours(h: &str) -> Result<u8, Report> {
ensure!(hours < 100, "Hours must be less than 100."); let hours = h.parse::<u8>().map_err(|_| eyre!("Invalid hours"))?;
Ok(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)?; 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)?; 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)] #[cfg(test)]
@@ -182,19 +374,51 @@ mod tests {
use super::*; use super::*;
use std::time::Duration; use std::time::Duration;
const MINUTE_IN_SECONDS: u64 = ONE_MINUTE.as_secs();
const HOUR_IN_SECONDS: u64 = ONE_HOUR.as_secs();
const DAY_IN_SECONDS: u64 = ONE_DAY.as_secs();
const YEAR_IN_SECONDS: u64 = ONE_YEAR.as_secs();
#[test] #[test]
fn test_fmt() { fn test_fmt() {
// 1y Dd hh:mm:ss (single year)
let ex: DurationEx =
Duration::from_secs(YEAR_IN_SECONDS + 10 * DAY_IN_SECONDS + 36001).into();
assert_eq!(format!("{ex}"), "1y 10d 10:00:01");
// 5y Dd hh:mm:ss (multiple years)
let ex: DurationEx = Duration::from_secs(
5 * YEAR_IN_SECONDS + 100 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1,
)
.into();
assert_eq!(format!("{ex}"), "5y 100d 10:00:01");
// 150y Dd hh:mm:ss (more than 100 years)
let ex: DurationEx = Duration::from_secs(
150 * YEAR_IN_SECONDS + 200 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1,
)
.into();
assert_eq!(format!("{ex}"), "150y 200d 10:00:01");
// 366d hh:mm:ss (days more than a year)
let ex: DurationEx =
Duration::from_secs(366 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1y 1d 10:00:01");
// 1d hh:mm:ss (single day)
let ex: DurationEx = Duration::from_secs(DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1d 10:00:01");
// 2d hh:mm:ss (multiple days)
let ex: DurationEx =
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "2d 10:00:01");
// hh:mm:ss // hh:mm:ss
let ex: DurationEx = Duration::from_secs(36001).into(); let ex: DurationEx = Duration::from_secs(10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "10:00:01"); assert_eq!(format!("{ex}"), "10:00:01");
// h:mm:ss // h:mm:ss
let ex: DurationEx = Duration::from_secs(3601).into(); let ex: DurationEx = Duration::from_secs(HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1:00:01"); assert_eq!(format!("{ex}"), "1:00:01");
// mm:ss // mm:ss
let ex: DurationEx = Duration::from_secs(71).into(); let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 11).into();
assert_eq!(format!("{ex}"), "1:11"); assert_eq!(format!("{ex}"), "1:11");
// m:ss // m:ss
let ex: DurationEx = Duration::from_secs(61).into(); let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1:01"); assert_eq!(format!("{ex}"), "1:01");
// ss // ss
let ex: DurationEx = Duration::from_secs(11).into(); let ex: DurationEx = Duration::from_secs(11).into();
@@ -265,8 +489,142 @@ mod tests {
// errors // errors
assert!(parse_duration("1:60").is_err()); // invalid seconds assert!(parse_duration("1:60").is_err()); // invalid seconds
assert!(parse_duration("60:00").is_err()); // invalid minutes assert!(parse_duration("60:00").is_err()); // invalid minutes
assert!(parse_duration("100:00:00").is_err()); // invalid hours
assert!(parse_duration("abc").is_err()); // invalid input assert!(parse_duration("abc").is_err()); // invalid input
assert!(parse_duration("01:02:03:04").is_err()); // too many parts assert!(parse_duration("01:02:03:04").is_err()); // too many parts
} }
#[test]
fn test_parse_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)
}
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,8 @@
use crate::{ use crate::{
common::ClockTypeId, common::ClockTypeId,
duration::{ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND}, duration::{
MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND, ONE_YEAR,
},
widgets::clock::*, widgets::clock::*,
}; };
use std::time::Duration; use std::time::Duration;
@@ -23,6 +25,230 @@ fn test_type_id() {
assert!(matches!(c.get_type_id(), ClockTypeId::Countdown)); assert!(matches!(c.get_type_id(), ClockTypeId::Countdown));
} }
#[test]
fn test_get_format_seconds() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND * 5,
current_value: ONE_SECOND * 5,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// S
assert_eq!(c.get_format(), &Format::S);
// Ss
c.set_current_value(Duration::from_secs(15).into());
assert_eq!(c.get_format(), &Format::Ss);
}
#[test]
fn test_get_format_minutes() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_MINUTE,
current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// MSs
assert_eq!(c.get_format(), &Format::MSs);
// MmSs
c.set_current_value((ONE_MINUTE * 11).into()); // 10+ minutes
assert_eq!(c.get_format(), &Format::MmSs);
}
#[test]
fn test_get_format_hours() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR,
current_value: ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// HMmSS
assert_eq!(c.get_format(), &Format::HMmSs);
// HhMmSs
c.set_current_value((10 * ONE_HOUR).into());
assert_eq!(c.get_format(), &Format::HhMmSs);
}
#[test]
fn test_format_by_duration_boundaries() {
// S
assert_eq!(format_by_duration(&(ONE_SECOND * 9).into()), Format::S);
// Ss
assert_eq!(format_by_duration(&(10 * ONE_SECOND).into()), Format::Ss);
// Ss
assert_eq!(format_by_duration(&(59 * ONE_SECOND).into()), Format::Ss);
// MSs
assert_eq!(format_by_duration(&ONE_MINUTE.into()), Format::MSs);
// HhMmSs
assert_eq!(
format_by_duration(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
Format::HhMmSs
);
// DHhMmSs
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
// DHhMmSs
assert_eq!(
format_by_duration(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::DHhMmSs
);
// DdHhMmSs
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
// DdHhMmSs
assert_eq!(
format_by_duration(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::DdHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_DAY).into()),
Format::DddHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
Format::DddHhMmSs
);
// YDHhMmSs
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
// YDdHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::YDdHhMmSs
);
// YDddHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YDddHhMmSs
assert_eq!(
format_by_duration(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyDdHhMmSs
);
// YyDdHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::YyDdHhMmSs
);
// YyDddHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyDddHhMmSs
);
// YyDddHhMmSs
assert_eq!(
format_by_duration(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
Format::YyDddHhMmSs
);
// YyyDHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyDdHhMmSs
);
// YyyDdHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::YyyDdHhMmSs
);
// YyyDddHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyDddHhMmSs
);
}
#[test]
fn test_format_by_duration_days() {
// DHhMmSs
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
// DdHhMmSs
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
// DddHhMmSs
assert_eq!(
format_by_duration(&(101 * ONE_DAY).into()),
Format::DddHhMmSs
);
}
#[test]
fn test_format_by_duration_years() {
// YDHhMmSs (1 year, 0 days)
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
// YDHhMmSs (1 year, 1 day)
assert_eq!(
format_by_duration(&(ONE_YEAR + ONE_DAY).into()),
Format::YDHhMmSs
);
// YDdHhMmSs (1 year, 10 days)
assert_eq!(
format_by_duration(&(ONE_YEAR + 10 * ONE_DAY).into()),
Format::YDdHhMmSs
);
// YDddHhMmSs (1 year, 100 days)
assert_eq!(
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs (10 years)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs (10 years, 10 days)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyDdHhMmSs
);
// YyDddHhMmSs (10 years, 100 days)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyDddHhMmSs
);
// YyyDHhMmSs (100 years)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs (100 years, 10 days)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyDdHhMmSs
);
// YyyDddHhMmSs (100 years, 100 days)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyDddHhMmSs
);
}
#[test] #[test]
fn test_default_edit_mode_hhmmss() { fn test_default_edit_mode_hhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -63,6 +289,278 @@ fn test_default_edit_mode_ss() {
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
} }
#[test]
fn test_edit_up_stays_in_seconds() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_MINUTE - ONE_SECOND,
current_value: ONE_MINUTE - ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_up();
// Edit mode should stay on seconds
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_up_stays_in_minutes() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR - ONE_SECOND,
current_value: ONE_HOUR - ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_up();
// Edit mode should stay on minutes
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_up_stays_in_hours() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY - ONE_SECOND,
current_value: ONE_DAY - ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_up();
// Edit mode should stay on hours
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
}
#[test]
fn test_edit_up_stays_in_days() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR - ONE_DAY,
current_value: ONE_YEAR - ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_up();
// Edit mode should stay on days
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
}
#[test]
fn test_edit_up_overflow_protection() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: MAX_DURATION.saturating_sub(ONE_SECOND),
current_value: MAX_DURATION.saturating_sub(ONE_SECOND),
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
c.edit_next(); // Years
c.edit_up(); // +1y
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Days
c.edit_up(); // +1d
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Hours
c.edit_up(); // +1h
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Minutes
c.edit_up(); // +1m
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
c.edit_prev(); // Sec.
c.edit_up(); // +1s
c.edit_up(); // +1s
c.edit_up(); // +1s
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
}
#[test]
fn test_edit_down_years_to_days() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR + ONE_DAY,
current_value: ONE_YEAR + ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
c.edit_next(); // Years
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
}
#[test]
fn test_edit_down_days_to_hours() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY + ONE_HOUR,
current_value: ONE_DAY + ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
c.edit_next(); // Days
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
}
#[test]
fn test_edit_down_hours_to_minutes() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_HOUR + ONE_MINUTE,
current_value: ONE_HOUR + ONE_MINUTE,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Hours
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_down_minutes_to_seconds() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_MINUTE,
current_value: ONE_MINUTE,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_down();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_next_ydddhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_hours_in_dhhmmss_format() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY + ONE_HOUR,
current_value: ONE_DAY + ONE_HOUR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
c.toggle_edit();
c.edit_next(); // Move to Hours
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
// Increment hours - should stay in Hours edit mode
c.edit_up();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
assert_eq!(
Duration::from(*c.get_current_value()),
ONE_DAY + 2 * ONE_HOUR
);
}
#[test]
fn test_edit_next_ydddhhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_next_dhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY,
current_value: ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes (following existing pattern)
c.toggle_edit();
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test] #[test]
fn test_edit_next_hhmmssd() { fn test_edit_next_hhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -78,6 +576,10 @@ fn test_edit_next_hhmmssd() {
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
@@ -100,6 +602,10 @@ fn test_edit_next_hhmmss() {
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next(); c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
@@ -159,6 +665,25 @@ fn test_edit_next_ssd() {
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
} }
#[test]
fn test_edit_next_sd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test] #[test]
fn test_edit_next_ss() { fn test_edit_next_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -172,10 +697,103 @@ fn test_edit_next_ss() {
// toggle on // toggle on
c.toggle_edit(); c.toggle_edit();
c.edit_next(); c.edit_next();
println!("mode -> {:?}", c.get_mode());
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
} }
#[test]
fn test_edit_next_s() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_next();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test]
fn test_edit_prev_ydddhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_prev_ydddhhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_YEAR,
current_value: ONE_YEAR,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test]
fn test_edit_prev_dhhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_DAY,
current_value: ONE_DAY,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on - should start at Minutes
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
}
#[test] #[test]
fn test_edit_prev_hhmmssd() { fn test_edit_prev_hhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -275,6 +893,25 @@ fn test_edit_prev_ssd() {
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
} }
#[test]
fn test_edit_prev_sd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: true,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test] #[test]
fn test_edit_prev_ss() { fn test_edit_prev_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -292,6 +929,23 @@ fn test_edit_prev_ss() {
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _))); assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
} }
#[test]
fn test_edit_prev_s() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
initial_value: ONE_SECOND,
current_value: ONE_SECOND,
tick_value: ONE_DECI_SECOND,
with_decis: false,
app_tx: None,
});
// toggle on
c.toggle_edit();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
c.edit_prev();
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
}
#[test] #[test]
fn test_edit_up_ss() { fn test_edit_up_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs { let mut c = ClockState::<Timer>::new(ClockStateArgs {

View File

@@ -194,9 +194,15 @@ impl TuiEventHandler for CountdownState {
KeyCode::Left => { KeyCode::Left => {
self.clock.edit_next(); self.clock.edit_next();
} }
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
KeyCode::Up => { KeyCode::Up => {
self.clock.edit_up(); self.clock.edit_up();
} }
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => { KeyCode::Down => {
self.clock.edit_down(); self.clock.edit_down();
} }
@@ -372,10 +378,11 @@ impl StatefulWidget for Countdown {
.to_uppercase(), .to_uppercase(),
); );
let widget = ClockWidget::new(self.style, self.blink); let widget = ClockWidget::new(self.style, self.blink);
let area = center( let area = center(
area, area,
Constraint::Length(max( Constraint::Length(max(
widget.get_width(&state.clock.get_format(), state.clock.with_decis), widget.get_width(state.clock.get_format(), state.clock.with_decis),
label.width() as u16, label.width() as u16,
)), )),
Constraint::Length(widget.get_height() + 1 /* height of label */), Constraint::Length(widget.get_height() + 1 /* height of label */),

View File

@@ -220,11 +220,23 @@ impl StatefulWidget for Footer {
scrollbar::VERTICAL.begin scrollbar::VERTICAL.begin
)), )),
Span::from(SPACE), Span::from(SPACE),
Span::from(format!(
// ctrl + ↑
"[^{}]edit up 10x",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!( Span::from(format!(
// ↓ // ↓
"[{}]edit up", "[{}]edit up",
scrollbar::VERTICAL.end scrollbar::VERTICAL.end
)), )),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↓
"[^{}]edit up 10x",
scrollbar::VERTICAL.end
)),
], ],
} }
})), })),

View File

@@ -115,6 +115,8 @@ impl StatefulWidget for LocalTimeWidget {
let symbol = self.style.get_digit_symbol(); let symbol = self.style.get_digit_symbol();
let label = Line::raw("Local Time".to_uppercase()); 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 format = state.format;
let widths = self.get_horizontal_lengths(&format); let widths = self.get_horizontal_lengths(&format);
@@ -127,14 +129,21 @@ impl StatefulWidget for LocalTimeWidget {
widths[2] = 0; // `space` widths[2] = 0; // `space`
} }
let width = widths.iter().sum(); 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( let area = center(
area, area,
Constraint::Length(max(width, label.width() as u16)), Constraint::Length(content_width),
Constraint::Length(DIGIT_HEIGHT + 1 /* height of label */), Constraint::Length(v_heights.iter().sum()),
); );
let [v1, v2] = Layout::vertical(Constraint::from_lengths([DIGIT_HEIGHT, 1])).areas(area); let [_, v1, v2, v3] = Layout::vertical(Constraint::from_lengths(v_heights)).areas(area);
match state.format { match state.format {
AppTimeFormat::HhMmSs => { AppTimeFormat::HhMmSs => {
@@ -181,5 +190,6 @@ impl StatefulWidget for LocalTimeWidget {
} }
} }
label.centered().render(v2, buf); label.centered().render(v2, buf);
label_date.centered().render(v3, buf);
} }
} }

View File

@@ -250,10 +250,8 @@ impl StatefulWidget for PomodoroWidget {
let area = center( let area = center(
area, area,
Constraint::Length(max( Constraint::Length(max(
clock_widget.get_width( clock_widget
&state.get_clock().get_format(), .get_width(state.get_clock().get_format(), state.get_clock().with_decis),
state.get_clock().with_decis,
),
label.width() as u16, label.width() as u16,
)), )),
Constraint::Length( Constraint::Length(

View File

@@ -4,6 +4,7 @@ use crate::{
utils::center, utils::center,
widgets::clock::{self, ClockState, ClockWidget}, widgets::clock::{self, ClockState, ClockWidget},
}; };
use crossterm::event::KeyModifiers;
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
crossterm::event::KeyCode, crossterm::event::KeyCode,
@@ -60,11 +61,17 @@ impl TuiEventHandler for TimerState {
KeyCode::Right => { KeyCode::Right => {
self.clock.edit_prev(); self.clock.edit_prev();
} }
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
// change value up // change value up
KeyCode::Up => { KeyCode::Up => {
self.clock.edit_up(); self.clock.edit_up();
} }
// change value down // change value down
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => { KeyCode::Down => {
self.clock.edit_down(); self.clock.edit_down();
} }
@@ -107,7 +114,7 @@ impl StatefulWidget for Timer {
let area = center( let area = center(
area, area,
Constraint::Length(max( Constraint::Length(max(
clock_widget.get_width(&clock.get_format(), clock.with_decis), clock_widget.get_width(clock.get_format(), clock.with_decis),
label.width() as u16, label.width() as u16,
)), )),
Constraint::Length(clock_widget.get_height() + 1 /* height of label */), Constraint::Length(clock_widget.get_height() + 1 /* height of label */),