3 Commits

Author SHA1 Message Date
jk
4e700691ba update justfile 2025-02-26 11:42:30 +01:00
jk
c29f8c5d1b update README 2025-02-26 11:42:03 +01:00
jk
ad11ff2c48 prepare v1.2.0 2025-02-26 11:41:38 +01:00
42 changed files with 1079 additions and 3870 deletions

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- "release/**"
tags:
- "v*"
jobs:
get-version:

4
.gitignore vendored
View File

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

View File

@@ -1,82 +1,5 @@
# 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
### 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
@@ -92,6 +15,7 @@
- (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

View File

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

1029
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,39 @@
[package]
name = "timr-tui"
version = "1.5.0"
version = "1.2.0"
description = "TUI to organize your time: Pomodoro, Countdown, Timer."
edition = "2024"
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`.
rust-version = "1.90.0"
rust-version = "1.85.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.5"
color-eyre = "0.6.2"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0"
strum = { version = "0.26.3", features = ["derive"] }
tokio = { version = "1.47.1", features = ["full"] }
tokio-stream = "0.1.17"
tokio-util = "0.7.16"
tokio = { version = "1.41.1", features = ["full"] }
tokio-stream = "0.1.16"
tokio-util = "0.7.12"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
directories = "5.0.1"
clap = { version = "4.5.48", features = ["derive"] }
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros"] }
notify-rust = "4.11.7"
clap = { version = "4.5.23", features = ["derive"] }
time = { version = "0.3.37", features = ["formatting", "local-offset"] }
notify-rust = "4.11.4"
rodio = { version = "0.20.1", features = [
"symphonia-mp3",
"symphonia-wav",
], default-features = false, optional = true }
thiserror = { version = "2.0.17", optional = true }
thiserror = { version = "2.0.11", optional = true }
[features]
sound = ["dep:rodio", "dep:thiserror"]

View File

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

221
README.md
View File

@@ -8,21 +8,9 @@ TUI to organize your time: Pomodoro, Countdown, Timer.
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
# Features
# Table of Contents
- [Preview](./#preview)
- [CLI](./#cli)
- [Keybindings](./#keybindings)
- [Installation](./#installation)
- [Development](./#development)
- [Misc](./#misc)
- [Contributing](./#contributing)
- [License](./#license)
# Preview
_(theme depends on your terminal preferences)_
_Side note:_ Theme colors depend on your terminal preferences.
## Pomodoro
@@ -42,23 +30,10 @@ _(theme depends on your terminal preferences)_
<img alt="countdown" src="demo/countdown.gif" />
</a>
## Countdown: Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
## Change style
<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 href="demo/style.gif">
<img alt="style" src="demo/style.gif" />
</a>
## Toggle deciseconds
@@ -67,18 +42,24 @@ _(theme depends 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">
<img alt="menu" src="demo/menu.gif" />
</a>
## Local time
<a href="demo/local-time.gif">
<img alt="menu" src="demo/local-time.gif" />
</a>
## Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
<a href="demo/countdown-met.gif">
<img alt="menu" src="demo/countdown-met.gif" />
</a>
# CLI
```sh
@@ -87,102 +68,26 @@ timr-tui --help
Usage: timr-tui [OPTIONS]
Options:
-c, --countdown <COUNTDOWN>
Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'.
--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
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'
-d, --decis Show deciseconds.
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro]
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
--menu Open the menu.
-r, --reset Reset stored values to default values.
-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 to store log file. If not set, standard application log directory is used (check README for details).
-h, --help Print help
```
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.
--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
@@ -208,10 +113,12 @@ Install [from the AUR](https://aur.archlinux.org/packages/timr/):
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
@@ -222,6 +129,7 @@ Pre-built artifacts are available to download from [latest GitHub release](https
If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to install dependencies. In other case run `nix develop`.
### Non Nix users
- [`Rust`](https://www.rust-lang.org/learn/get-started)
@@ -235,42 +143,28 @@ If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to
just
Available recipes:
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 # build app [alias: b]
default # list commands
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-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]
format # format files [alias: f]
lint # lint [alias: l]
run # run app [alias: r]
run-sound path # run app while sound feature is enabled. It expects a path to a sound file. [alias: rs]
test # run tests [alias: t]
```
### Build
- Linux
```sh
nix build
# or for bulding w/ statically linked binaries
@@ -278,17 +172,10 @@ nix build .#linuxStatic
```
- Windows (cross-compilation)
```sh
nix build .#windows
```
### Run tests
```sh
cargo test
```
# Misc.
## Persistant app state
@@ -320,11 +207,3 @@ C:/Users/{user}/AppData/Local/timr-tui/logs/app.log
```
Optional: You can use a custom directory by passing it via `--log` arg.
# Contributing
[CONTRIBUTING.md](./CONTRIBUTING.md)
# License
[MIT License](./LICENSE)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

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

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,20 +0,0 @@
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,20 +0,0 @@
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: 15 KiB

View File

@@ -1,20 +0,0 @@
Output demo/local-time-footer.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "AtomOneLight"
Set FontSize 14
Set Width 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 ":::"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,41 +0,0 @@
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: 130 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -12,15 +12,19 @@ Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -m p --menu"
Type "cargo run -- -r -m p"
Enter
Type@200ms "m"
Sleep 0.2
Show
# --- STYLES ---
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
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 166 KiB

View File

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

24
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1758758545,
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
"lastModified": 1739936662,
"narHash": "sha256-x4syUjNUuRblR07nDPeLDP7DpphaBVbUaSoeZkFbGSk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
"rev": "19de14aaeb869287647d9461cbd389187d8ecdb7",
"type": "github"
},
"original": {
@@ -23,11 +23,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1758782550,
"narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
"lastModified": 1740378829,
"narHash": "sha256-cwmm7F73aQFJY6YN1roNibNKwxT6FlfXkG3MEbpSp7Q=",
"owner": "nix-community",
"repo": "fenix",
"rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
"rev": "92823f1b0c919d7e2d806956aaf98e90f3761ab7",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1758690382,
"narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
"lastModified": 1740367490,
"narHash": "sha256-WGaHVAjcrv+Cun7zPlI41SerRtfknGQap281+AakSAw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e643668fd71b949c53f8626614b21ff71a07379d",
"rev": "0196c0175e9191c474c26ab5548db27ef5d34b05",
"type": "github"
},
"original": {
@@ -81,11 +81,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1758620797,
"narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
"lastModified": 1740329432,
"narHash": "sha256-eKQ7aBkNvF5AhUpyJ1cW450jxomZ4gTIaYir5qsNl7Y=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
"rev": "6d68c475c7aaf7534251182662456a4bf4216dfe",
"type": "github"
},
"original": {

View File

@@ -24,7 +24,7 @@
{
file = ./rust-toolchain.toml;
# sha256 = nixpkgs.lib.fakeSha256;
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
sha256 = "sha256-AJ6LX/Q/Er9kS15bn9iflkUwcgYqRQxiOIL2ToVAXaU=";
};
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
@@ -85,18 +85,19 @@
pkgs.nixd
pkgs.alejandra
]
# pkgs needed to play sound on Linux
# some extra pkgs needed to play sound on Linux
++ lib.optionals stdenv.isLinux [
pkgs.pkg-config
pkgs.alsa-lib.dev
pkgs.pipewire
pkgs.alsa-lib
];
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";
ALSA_PLUGIN_DIR =
if stdenv.isLinux
then "${pkgs.pipewire}/lib/alsa-lib/"
else "";
};
});
}

View File

@@ -9,21 +9,18 @@ default:
alias b := build
# 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
@@ -31,113 +28,79 @@ format:
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

View File

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

View File

@@ -2,7 +2,6 @@ use crate::{
args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
duration::DirectedDuration,
events::{self, TuiEventHandler},
storage::AppStorage,
terminal::Terminal,
@@ -11,7 +10,6 @@ use crate::{
countdown::{Countdown, CountdownState, CountdownStateArgs},
footer::{Footer, FooterState},
header::Header,
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
timer::{Timer, TimerState},
},
@@ -29,6 +27,7 @@ use ratatui::{
};
use std::path::PathBuf;
use std::time::Duration;
use time::OffsetDateTime;
use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -45,11 +44,9 @@ pub struct App {
#[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,
@@ -64,7 +61,6 @@ pub struct AppArgs {
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,
@@ -75,7 +71,6 @@ pub struct AppArgs {
pub current_value_timer: Duration,
pub app_tx: events::AppEventTx,
pub sound_path: Option<PathBuf>,
pub footer_toggle_app_time: Toggle,
}
pub struct FromAppArgs {
@@ -96,65 +91,36 @@ impl From<FromAppArgs> for App {
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
}
}
},
content: args.mode.unwrap_or(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: 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,
},
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
// invalidate `current_value_countdown` if an initial value is set via args
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_countdown: args.countdown.unwrap_or(stg.current_value_countdown),
elapsed_value_countdown: 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,
})
}
}
fn get_app_time() -> AppTime {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
impl App {
pub fn new(args: AppArgs) -> Self {
let AppArgs {
@@ -172,14 +138,12 @@ impl App {
content,
with_decis,
pomodoro_mode,
pomodoro_round,
notification,
blink,
sound_path,
app_tx,
footer_toggle_app_time,
} = args;
let app_time = AppTime::new();
let app_time = get_app_time();
Self {
mode: Mode::Running,
@@ -188,7 +152,6 @@ impl App {
sound_path,
content,
app_time,
app_time_format,
style,
with_decis,
countdown: CountdownState::new(CountdownStateArgs {
@@ -216,21 +179,9 @@ impl App {
initial_value_pause,
current_value_pause,
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
},
),
footer: FooterState::new(show_menu, app_time_format),
}
}
@@ -243,43 +194,12 @@ impl App {
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('q') | KeyCode::Esc => 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);
}
}
KeyCode::Char(':') => app.footer.toggle_app_time_format(),
// toogle menu
KeyCode::Char('m') => app.footer.set_show_menu(!app.footer.get_show_menu()),
KeyCode::Char(',') => {
@@ -300,9 +220,8 @@ impl App {
// 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.app_time = get_app_time();
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
@@ -310,7 +229,6 @@ impl App {
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 {
events::TuiEvent::Render | events::TuiEvent::Resize => {
@@ -335,7 +253,7 @@ impl App {
ClockTypeId::Timer => {
format!("{name} stopped by reaching its maximum value.")
}
_ => format!("{type_id:?} {name} done!"),
_ => format!("{:?} {name} done!", type_id),
};
// notification
let result = notify_rust::Notification::new()
@@ -401,7 +319,6 @@ impl App {
AppEditMode::None
}
}
Content::LocalTime => AppEditMode::None,
}
}
@@ -410,8 +327,6 @@ impl App {
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,
}
}
@@ -420,7 +335,6 @@ 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,
}
}
@@ -437,11 +351,10 @@ impl App {
show_menu: self.footer.get_show_menu(),
notification: self.notification,
blink: self.blink,
app_time_format: self.app_time_format,
app_time_format: *self.footer.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(
@@ -456,7 +369,6 @@ impl App {
),
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(),
}
}
}
@@ -483,9 +395,6 @@ impl AppWidget {
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.pomodoro),
Content::LocalTime => {
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
}
};
}
}
@@ -496,7 +405,7 @@ impl StatefulWidget for AppWidget {
let [v0, v1, v2] = Layout::vertical([
Constraint::Length(1),
Constraint::Percentage(100),
Constraint::Length(if state.footer.get_show_menu() { 5 } else { 1 }),
Constraint::Length(if state.footer.get_show_menu() { 4 } else { 1 }),
])
.areas(area);

View File

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

View File

@@ -1,8 +1,8 @@
use clap::ValueEnum;
use ratatui::symbols::shade;
use serde::{Deserialize, Serialize};
use strum::EnumString;
use time::{OffsetDateTime, format_description};
use time::OffsetDateTime;
use time::format_description;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize,
@@ -15,8 +15,6 @@ pub enum Content {
Timer,
#[value(name = "pomodoro", alias = "p")]
Pomodoro,
#[value(name = "localtime", alias = "l")]
LocalTime,
}
#[derive(Clone, Debug)]
@@ -73,7 +71,7 @@ impl Style {
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, EnumString, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum AppTimeFormat {
/// `hh:mm:ss`
#[default]
@@ -82,22 +80,17 @@ pub enum AppTimeFormat {
HhMm,
/// `hh:mm AM` (or PM)
Hh12Mm,
/// `` (empty)
Hidden,
}
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,
AppTimeFormat::Hh12Mm => AppTimeFormat::Hidden,
AppTimeFormat::Hidden => AppTimeFormat::HhMmSs,
}
}
}
@@ -118,64 +111,26 @@ impl From<AppTime> for OffsetDateTime {
}
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]",
AppTimeFormat::HhMmSs => Some("[hour]:[minute]:[second]"),
AppTimeFormat::HhMm => Some("[hour]:[minute]"),
AppTimeFormat::Hh12Mm => Some("[hour repr:12 padding:none]:[minute] [period]"),
AppTimeFormat::Hidden => None,
};
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)
if let Some(str) = parse_str {
format_description::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())
} else {
"".to_owned()
}
}
}
@@ -195,15 +150,6 @@ pub enum Toggle {
Off,
}
impl From<bool> for Toggle {
fn from(value: bool) -> Self {
match value {
true => Toggle::On,
false => Toggle::Off,
}
}
}
#[cfg(test)]
mod tests {
@@ -250,5 +196,12 @@ mod tests {
"6:06 PM",
"local"
);
// hidden
assert_eq!(AppTime::Utc(dt).format(&AppTimeFormat::Hidden), "", "utc");
assert_eq!(
AppTime::Local(dt).format(&AppTimeFormat::Hidden),
"",
"local"
);
}
}

View File

@@ -2,11 +2,13 @@ use color_eyre::{
Report,
eyre::{ensure, eyre},
};
use std::cmp::min;
use std::fmt;
use std::time::Duration;
use crate::common::AppTime;
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
pub const ONE_SECOND: Duration = Duration::from_secs(1);
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
pub const ONE_HOUR: Duration = Duration::from_secs(MINS_PER_HOUR * SECS_PER_MINUTE);
// unstable
// https://doc.rust-lang.org/src/core/time.rs.html#32
@@ -18,33 +20,9 @@ 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),
}
// max. 99:59:59
pub const MAX_DURATION: Duration =
Duration::from_secs(100 * MINS_PER_HOUR * SECS_PER_MINUTE).saturating_sub(ONE_SECOND);
#[derive(Debug, Clone, Copy, PartialOrd)]
pub struct DurationEx {
@@ -70,36 +48,22 @@ impl From<DurationEx> for Duration {
}
impl DurationEx {
pub fn years(&self) -> u64 {
self.days() / DAYS_PER_YEAR
pub fn seconds(&self) -> u64 {
self.inner.as_secs()
}
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 seconds_mod(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE
}
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
}
@@ -108,14 +72,6 @@ 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
@@ -142,26 +98,7 @@ impl DurationEx {
impl fmt::Display for DurationEx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
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 {
if self.hours() >= 10 {
write!(
f,
"{:02}:{:02}:{:02}",
@@ -189,183 +126,45 @@ impl fmt::Display for DurationEx {
}
}
/// Parse seconds (must be < 60)
fn parse_seconds(s: &str) -> Result<u8, Report> {
let secs = s.parse::<u8>().map_err(|_| eyre!("Invalid seconds"))?;
ensure!(secs < 60, "Seconds must be less than 60.");
Ok(secs)
}
/// Parse minutes (must be < 60)
fn parse_minutes(m: &str) -> Result<u8, Report> {
let mins = m.parse::<u8>().map_err(|_| eyre!("Invalid minutes"))?;
ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins)
}
/// Parse hours
fn parse_hours(h: &str) -> Result<u8, Report> {
let hours = h.parse::<u8>().map_err(|_| eyre!("Invalid hours"))?;
Ok(hours)
}
/// Parses `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)?;
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'"
));
}
};
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 parts: Vec<&str> = arg.split(':').rev().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 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.");
Ok(secs)
};
let total_seconds = hours * 3600 + minutes * 60 + seconds;
Ok(Duration::from_secs(total_seconds))
}
let parse_minutes = |m: &str| -> Result<u64, Report> {
let mins = m.parse::<u64>().map_err(|_| eyre!("Invalid minutes"))?;
ensure!(mins < 60, "Minutes must be less than 60.");
Ok(mins)
};
/// Similar to `parse_duration`, but it parses `years` and `days` in addition
/// 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();
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.");
Ok(hours)
};
// 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));
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
}
// 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));
[ss, mm, hh] => {
let s = parse_seconds(ss)?;
let m = parse_minutes(mm)?;
let h = parse_hours(hh)?;
h * 60 * 60 + m * 60 + s
}
// possible time format
else {
time_part = Some(part);
}
}
_ => return Err(eyre!("Invalid time format. Use 'ss', mm:ss, or hh:mm:ss")),
};
// 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)
Ok(Duration::from_secs(seconds))
}
#[cfg(test)]
@@ -374,58 +173,26 @@ 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(10 * HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "10:00:01");
let ex: DurationEx = Duration::from_secs(36001).into();
assert_eq!(format!("{}", ex), "10:00:01");
// h:mm:ss
let ex: DurationEx = Duration::from_secs(HOUR_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1:00:01");
let ex: DurationEx = Duration::from_secs(3601).into();
assert_eq!(format!("{}", ex), "1:00:01");
// mm:ss
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 11).into();
assert_eq!(format!("{ex}"), "1:11");
let ex: DurationEx = Duration::from_secs(71).into();
assert_eq!(format!("{}", ex), "1:11");
// m:ss
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 1).into();
assert_eq!(format!("{ex}"), "1:01");
let ex: DurationEx = Duration::from_secs(61).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]
@@ -433,7 +200,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]
@@ -441,35 +208,7 @@ 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");
}
#[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);
assert_eq!(format!("{}", ex3), "11");
}
#[test]
@@ -489,142 +228,8 @@ 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

@@ -3,35 +3,21 @@ use crate::{
widgets::pomodoro::Mode as PomodoroMode,
};
use color_eyre::eyre::Result;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, 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,
@@ -44,8 +30,6 @@ pub struct AppStorage {
pub elapsed_value_countdown: Duration,
// timer
pub current_value_timer: Duration,
// footer
pub footer_app_time: Toggle,
}
impl Default for AppStorage {
@@ -62,7 +46,6 @@ impl Default for AppStorage {
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,
@@ -75,8 +58,6 @@ impl Default for AppStorage {
elapsed_value_countdown: Duration::ZERO,
// timer
current_value_timer: Duration::ZERO,
// footer
footer_app_time: Toggle::Off,
}
}
}

View File

@@ -8,7 +8,6 @@ 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,13 +7,9 @@ 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] = [

View File

@@ -1,8 +1,6 @@
use crate::{
common::ClockTypeId,
duration::{
MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND, ONE_YEAR,
},
duration::{ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND},
widgets::clock::*,
};
use std::time::Duration;
@@ -25,230 +23,6 @@ fn test_type_id() {
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]
fn test_default_edit_mode_hhmmss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -289,278 +63,6 @@ fn test_default_edit_mode_ss() {
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]
fn test_edit_next_hhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -576,10 +78,6 @@ fn test_edit_next_hhmmssd() {
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, _)));
@@ -602,10 +100,6 @@ fn test_edit_next_hhmmss() {
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, _)));
@@ -665,25 +159,6 @@ fn test_edit_next_ssd() {
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]
fn test_edit_next_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -697,103 +172,10 @@ fn test_edit_next_ss() {
// toggle on
c.toggle_edit();
c.edit_next();
println!("mode -> {:?}", c.get_mode());
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]
fn test_edit_prev_hhmmssd() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -893,25 +275,6 @@ fn test_edit_prev_ssd() {
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]
fn test_edit_prev_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {
@@ -929,23 +292,6 @@ fn test_edit_prev_ss() {
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]
fn test_edit_up_ss() {
let mut c = ClockState::<Timer>::new(ClockStateArgs {

View File

@@ -144,6 +144,8 @@ impl CountdownState {
impl TuiEventHandler for CountdownState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
let is_edit_clock = self.clock.is_edit_mode();
let is_edit_time = self.edit_time.is_some();
match event {
TuiEvent::Tick => {
if !self.clock.is_done() {
@@ -162,102 +164,6 @@ impl TuiEventHandler for CountdownState {
edit_time.set_max_time(max_time);
}
}
// 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();
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// toggle edit mode
self.clock.toggle_edit();
// set initial value
self.clock
.set_initial_value(*self.clock.get_current_value());
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// Apply changes
KeyCode::Char('s') => {
// toggle edit mode
self.clock.toggle_edit();
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
KeyCode::Right => {
self.clock.edit_prev();
}
KeyCode::Left => {
self.clock.edit_next();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
KeyCode::Up => {
self.clock.edit_up();
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => {
self.clock.edit_down();
}
_ => return Some(event),
},
// EDIT LOCAL TIME mode
TuiEvent::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
@@ -283,29 +189,85 @@ impl TuiEventHandler for CountdownState {
self.edit_time_done(edit_time);
}
}
// Enter edit by local time mode
// STRG + e => toggle edit time
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(),
}));
// stop editing clock
if self.clock.is_edit_mode() {
// toggle edit mode
self.clock.toggle_edit();
}
// pause `elapsed_clock`
if let Some(edit_time) = &mut self.edit_time.clone() {
self.edit_time_done(edit_time)
} else {
// update `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(),
}));
}
// stop `clock`
if self.clock.is_running() {
self.clock.toggle_pause();
}
// stop `elapsed_clock`
if self.elapsed_clock.is_running() {
self.elapsed_clock.toggle_pause();
}
}
// Enter edit clock mode
// STRG + e => toggle edit clock
KeyCode::Char('e') => {
// toggle edit mode
self.clock.toggle_edit();
// pause `elapsed_clock`
// stop `elapsed_clock`
if self.elapsed_clock.is_running() {
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);
}
}
KeyCode::Left if is_edit_clock => {
self.clock.edit_next();
}
KeyCode::Left if is_edit_time => {
// safe unwrap because of previous check in `is_edit_time`
self.edit_time.as_mut().unwrap().next();
}
KeyCode::Right if is_edit_clock => {
self.clock.edit_prev();
}
KeyCode::Right if is_edit_time => {
// safe unwrap because of previous check in `is_edit_time`
self.edit_time.as_mut().unwrap().prev();
}
KeyCode::Up if is_edit_clock => {
self.clock.edit_up();
// whenever `clock`'s value is changed, reset `elapsed_clock`
self.elapsed_clock.reset();
}
KeyCode::Up if is_edit_time => {
// safe unwrap because of previous check in `is_edit_time`
self.edit_time.as_mut().unwrap().up();
// whenever `clock`'s value is changed, reset `elapsed_clock`
self.elapsed_clock.reset();
}
KeyCode::Down if is_edit_clock => {
self.clock.edit_down();
// whenever clock value is changed, reset timer
self.elapsed_clock.reset();
}
KeyCode::Down if is_edit_time => {
// safe unwrap because of previous check in `is_edit_time`
self.edit_time.as_mut().unwrap().down();
// whenever clock value is changed, reset timer
self.elapsed_clock.reset();
}
_ => return Some(event),
},
@@ -325,7 +287,7 @@ fn human_days_diff(a: &OffsetDateTime, b: &OffsetDateTime) -> String {
match days_diff {
0 => "today".to_owned(),
1 => "tomorrow".to_owned(),
n => format!("+{n}days"),
n => format!("+{}days", n),
}
}
@@ -378,11 +340,10 @@ impl StatefulWidget for Countdown {
.to_uppercase(),
);
let widget = ClockWidget::new(self.style, self.blink);
let area = center(
area,
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,
)),
Constraint::Length(widget.get_height() + 1 /* height of label */),

View File

@@ -13,11 +13,11 @@ use ratatui::{
#[derive(Debug, Clone)]
pub struct FooterState {
show_menu: bool,
app_time_format: Option<AppTimeFormat>,
app_time_format: AppTimeFormat,
}
impl FooterState {
pub const fn new(show_menu: bool, app_time_format: Option<AppTimeFormat>) -> Self {
pub const fn new(show_menu: bool, app_time_format: AppTimeFormat) -> Self {
Self {
show_menu,
app_time_format,
@@ -32,12 +32,12 @@ impl FooterState {
self.show_menu
}
pub const fn app_time_format(&self) -> &Option<AppTimeFormat> {
pub const fn app_time_format(&self) -> &AppTimeFormat {
&self.app_time_format
}
pub const fn set_app_time_format(&mut self, value: Option<AppTimeFormat>) {
self.app_time_format = value;
pub fn toggle_app_time_format(&mut self) {
self.app_time_format = self.app_time_format.next();
}
}
@@ -56,7 +56,6 @@ impl StatefulWidget for Footer {
(Content::Countdown, "[c]ountdown"),
(Content::Timer, "[t]imer"),
(Content::Pomodoro, "[p]omodoro"),
(Content::LocalTime, "[l]ocal time"),
]);
let [_, area] =
@@ -72,12 +71,11 @@ impl StatefulWidget for Footer {
)
.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(),
match state.app_time_format {
// `Hidden` -> no (empty) title
AppTimeFormat::Hidden => "".into(),
// others -> add some space around
_ => format!(" {} ", self.app_time.format(&state.app_time_format))
}
).right_aligned())
.border_set(border::PLAIN)
@@ -91,7 +89,7 @@ impl StatefulWidget 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()
};
@@ -104,40 +102,37 @@ impl StatefulWidget for Footer {
const SPACE: &str = " "; // 2 empty spaces
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
let mut table_rows = vec![
// screens
Row::new(vec![
Cell::from(Span::styled(
"screens",
Style::default().add_modifier(Modifier::BOLD),
)),
Cell::from(Line::from(content_labels)),
]),
// appearance
Row::new(vec![
Cell::from(Span::styled(
"appearance",
Style::default().add_modifier(Modifier::BOLD),
)),
Cell::from(Line::from(vec![
Span::from("[,]change style"),
Span::from(SPACE),
Span::from("[.]toggle deciseconds"),
Span::from(SPACE),
Span::from(format!(
"[:]toggle {} time",
match self.app_time {
AppTime::Local(_) => "local",
AppTime::Utc(_) => "utc",
}
let table = Table::new(
[
// content
Row::new(vec![
Cell::from(Span::styled(
"screens",
Style::default().add_modifier(Modifier::BOLD),
)),
])),
]),
];
if self.selected_content != Content::LocalTime {
table_rows.extend_from_slice(&[
// controls - 1. row
Cell::from(Line::from(content_labels)),
]),
// format
Row::new(vec![
Cell::from(Span::styled(
"appearance",
Style::default().add_modifier(Modifier::BOLD),
)),
Cell::from(Line::from(vec![
Span::from("[,]change style"),
Span::from(SPACE),
Span::from("[.]toggle deciseconds"),
Span::from(SPACE),
Span::from(format!(
"[:]toggle {} time",
match self.app_time {
AppTime::Local(_) => "local",
AppTime::Utc(_) => "utc",
}
)),
])),
]),
// edit
Row::new(vec![
Cell::from(Span::styled(
"controls",
@@ -146,105 +141,55 @@ impl StatefulWidget for Footer {
Cell::from(Line::from({
match self.app_edit_mode {
AppEditMode::None => {
let mut spans = vec![Span::from(if self.running_clock {
"[s]top"
} else {
"[s]tart"
})];
spans.extend_from_slice(&[
let mut spans = vec![
Span::from(if self.running_clock {
"[s]top"
} else {
"[s]tart"
}),
Span::from(SPACE),
Span::from("[r]eset"),
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("[^r]eset clocks+rounds"),
Span::from("[← →]switch work/pause"),
]);
}
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![
others => vec![
Span::from(match others {
AppEditMode::Clock => "[e]dit done",
AppEditMode::Time => "[^e]dit done",
_ => "",
}),
Span::from(SPACE),
Span::from(format!(
// ← →,
"[{} {}]change selection",
"[{} {}]edit selection",
scrollbar::HORIZONTAL.begin,
scrollbar::HORIZONTAL.end
)),
)), // ← →,
Span::from(SPACE),
Span::from(format!(
// ↑
"[{}]edit up",
scrollbar::VERTICAL.begin
)),
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
)),
Span::from(format!("[{}]edit up", scrollbar::VERTICAL.end)), // ↓,
],
}
})),
]),
])
}
let table = Table::new(table_rows, widths).column_spacing(1);
],
widths,
)
.column_spacing(1);
Widget::render(table, menu_area, buf);
}

View File

@@ -1,195 +0,0 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{StatefulWidget, Widget},
};
use crate::{
common::{AppTime, AppTimeFormat, Style as DigitStyle},
duration::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

@@ -5,9 +5,9 @@ use crate::{
utils::center,
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},
@@ -45,7 +45,6 @@ impl ClockMap {
pub struct PomodoroState {
mode: Mode,
clock_map: ClockMap,
round: u64,
}
pub struct PomodoroStateArgs {
@@ -56,7 +55,6 @@ pub struct PomodoroStateArgs {
pub current_value_pause: Duration,
pub with_decis: bool,
pub app_tx: AppEventTx,
pub round: u64,
}
impl PomodoroState {
@@ -69,7 +67,6 @@ impl PomodoroState {
current_value_pause,
with_decis,
app_tx,
round,
} = args;
Self {
mode,
@@ -91,7 +88,6 @@ impl PomodoroState {
})
.with_name("Pause".to_owned()),
},
round,
}
}
@@ -107,26 +103,14 @@ impl PomodoroState {
&self.clock_map.work
}
pub fn get_clock_work_mut(&mut self) -> &mut ClockState<Countdown> {
self.clock_map.get_mut(&Mode::Work)
}
pub fn get_clock_pause(&self) -> &ClockState<Countdown> {
&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 get_round(&self) -> u64 {
self.round
}
pub fn set_with_decis(&mut self, with_decis: bool) {
self.clock_map.work.with_decis = with_decis;
self.clock_map.pause.with_decis = with_decis;
@@ -148,76 +132,33 @@ impl TuiEventHandler for PomodoroState {
self.get_clock_mut().tick();
self.get_clock_mut().update_done_count();
}
// 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();
}
// toggle WORK/PAUSE
KeyCode::Left if edit_mode => {
self.get_clock_mut().edit_next();
}
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();
}
// toggle WORK/PAUSE
KeyCode::Right if edit_mode => {
self.get_clock_mut().edit_prev();
}
KeyCode::Right => {
self.next();
}
// 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();
KeyCode::Up if edit_mode => {
self.get_clock_mut().edit_up();
}
KeyCode::Down if edit_mode => {
self.get_clock_mut().edit_down();
}
// 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),
@@ -245,34 +186,23 @@ 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(
// empty label + height of `label` + `label_round`
clock_widget.get_height() + 3,
),
Constraint::Length(clock_widget.get_height() + 1 /* height of mode_str */),
);
let [v1, v2, v3, v4] = Layout::vertical(Constraint::from_lengths([
1,
clock_widget.get_height(),
1,
1,
]))
.areas(area);
let [v1, v2] =
Layout::vertical(Constraint::from_lengths([clock_widget.get_height(), 1])).areas(area);
// 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);
clock_widget.render(v1, buf, state.get_clock_mut());
label.centered().render(v2, buf);
}
}

View File

@@ -4,7 +4,6 @@ use crate::{
utils::center,
widgets::clock::{self, ClockState, ClockWidget},
};
use crossterm::event::KeyModifiers;
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
@@ -40,57 +39,28 @@ impl TuiEventHandler for TimerState {
self.clock.tick();
self.clock.update_done_count();
}
// 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),
@@ -114,7 +84,7 @@ impl StatefulWidget for Timer {
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 */),