Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9d17f1ee | ||
|
|
1bebfb897a | ||
|
|
90c0e50f1c | ||
|
|
27cb8c7e33 | ||
|
|
513f1fec11 | ||
|
|
1742d68c61 | ||
|
|
4ec52545e5 | ||
|
|
dfe3cfcc80 | ||
|
|
c637a82deb | ||
|
|
361a82ee08 | ||
|
|
95d914c757 | ||
|
|
e11dcaa913 | ||
|
|
d2f41e04e2 | ||
|
|
51f83e5b06 | ||
|
|
b5f3c709bf | ||
|
|
56e6ce66fa | ||
|
|
758a72fbf6 | ||
|
|
6b6221803c | ||
|
|
4594bc722e | ||
|
|
e2cd536079 | ||
|
|
99032834be | ||
|
|
f79813ac6b | ||
|
|
6437795c9f | ||
|
|
0c4f507ebf |
24
AGENTS.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# About
|
||||||
|
|
||||||
|
`timr-tui` is a TUI to maintain productivity and focus by providing different timers: Pomodoro, Countdown, Timer, Events.
|
||||||
|
|
||||||
|
Built with Rust using `Ratatui` as the main library.
|
||||||
|
|
||||||
|
# Development, Build, Tests
|
||||||
|
|
||||||
|
Check [README](./README.md) chapter `Development` to get all information about how to run, build, test the app.
|
||||||
|
|
||||||
|
# Code Guidelines
|
||||||
|
|
||||||
|
- Idiomatic Rust everywhere
|
||||||
|
- DRY whenever it makes sense
|
||||||
|
- Rare or no comments are preferred instead of commenting everything which the code already describes
|
||||||
|
- Keep tests compact and simple
|
||||||
|
|
||||||
|
# Agent Guidelines
|
||||||
|
|
||||||
|
- Keep your answers compact, but explicit. An user will ask if something is missing.
|
||||||
|
- For complex tasks provide a plan.
|
||||||
|
- Structure plans as small as possible.
|
||||||
|
- Solve complex problems step by step, never all at once.
|
||||||
|
- Act as a pair programmer, not as a vibe-coding provider. That's an user should guide you, not the opposite. Always ask if something is not clear to you.
|
||||||
51
CHANGELOG.md
@ -4,13 +4,62 @@
|
|||||||
|
|
||||||
### Misc.
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Rust 1.91.0 [#140](https://github.com/sectore/timr-tui/pull/140)
|
||||||
|
|
||||||
|
## v1.6.1 - 2025-10-29
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- (event) Ignore all key events except `KeyEventKind::Press` [#137](https://github.com/sectore/timr-tui/issues/137)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (docs) Update all demos [#135](https://github.com/sectore/timr-tui/pull/135), [513f1fe](https://github.com/sectore/timr-tui/commit/513f1fec11ab8bdad46ca565b0c3f08ed37d6219)
|
||||||
|
|
||||||
|
## v1.6.0 - 2025-10-16
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- (event) New `event` screen to count custom date times in the future or past. [#117](https://github.com/sectore/timr-tui/pull/117), [#120](https://github.com/sectore/timr-tui/pull/120), [#122](https://github.com/sectore/timr-tui/pull/122), [#123](https://github.com/sectore/timr-tui/pull/123), [#124](https://github.com/sectore/timr-tui/pull/124), [#125](https://github.com/sectore/timr-tui/pull/125), [#129](https://github.com/sectore/timr-tui/pull/129), [#130](https://github.com/sectore/timr-tui/pull/130), [#131](https://github.com/sectore/timr-tui/pull/131), [#132](https://github.com/sectore/timr-tui/pull/132)
|
||||||
|
- (keybindings) Switch screens by `←` or `→` keys [#127](https://github.com/sectore/timr-tui/pull/127)
|
||||||
|
- (duration) Inrease `MAX_DURATION` to `9999y 364d 23:59:59.9` [#128](https://github.com/sectore/timr-tui/pull/128)
|
||||||
|
|
||||||
|
### Breaking change
|
||||||
|
|
||||||
|
- (pomodoro)! New keybindings `ctrl+←` or `ctrl+→` to switch `work`/`pause` [#127](https://github.com/sectore/timr-tui/pull/127)
|
||||||
|
- (keybindings)! Change keys for `screens` [#126](https://github.com/sectore/timr-tui/pull/126)
|
||||||
|
- (cli)! Remove `--countdown-target` argument [#121](https://github.com/sectore/timr-tui/pull/121)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- Add `AGENTS.md` [#133](https://github.com/sectore/timr-tui/pull/133)
|
||||||
|
|
||||||
|
## v1.5.0 - 2025-10-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- (cli) Accept `years` and `days` for `--countdown` argument [#114](https://github.com/sectore/timr-tui/pull/114)
|
||||||
|
- (cli) New `--countdown-target` argument to parse `countdown` values by given time in the future or past [#112](https://github.com/sectore/timr-tui/pull/112)
|
||||||
|
- (localtime) Show `date` [#111](https://github.com/sectore/timr-tui/pull/111)
|
||||||
|
- (edit) Change any value by `10x` up or down [#110](https://github.com/sectore/timr-tui/pull/110)
|
||||||
|
- (timer/countdown): Support `days` and `years` up to `999y 364d 23:59:59` [#96](https://github.com/sectore/timr-tui/pull/96)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- (edit) Auto jump to next possible value while decreasing, but ignoring `zero` values [#109](https://github.com/sectore/timr-tui/pull/109)
|
||||||
|
- (format) Improve format handling + fix `days` (no zero-padding) [#107](https://github.com/sectore/timr-tui/pull/107)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Upgrade dependencies [#113](https://github.com/sectore/timr-tui/pull/113)
|
||||||
- (deps) Rust 1.90.0 [#95](https://github.com/sectore/timr-tui/pull/95)
|
- (deps) Rust 1.90.0 [#95](https://github.com/sectore/timr-tui/pull/95)
|
||||||
|
- (guide) Add contributing guidelines [#94](https://github.com/sectore/timr-tui/pull/94)
|
||||||
|
|
||||||
## v1.4.0 - 2025-09-02
|
## v1.4.0 - 2025-09-02
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- (screen): Local Time [#89](https://github.com/sectore/timr-tui/pull/89), [#90](https://github.com/sectore/timr-tui/pull/90), [#91](https://github.com/sectore/timr-tui/pull/91)
|
- (screen) Local Time [#89](https://github.com/sectore/timr-tui/pull/89), [#90](https://github.com/sectore/timr-tui/pull/90), [#91](https://github.com/sectore/timr-tui/pull/91)
|
||||||
|
|
||||||
### Misc.
|
### Misc.
|
||||||
|
|
||||||
|
|||||||
14
Cargo.lock
generated
@ -642,6 +642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
|
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2089,7 +2090,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "timr-tui"
|
name = "timr-tui"
|
||||||
version = "1.4.0"
|
version = "1.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
@ -2109,6 +2110,7 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"tui-input",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2267,6 +2269,16 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tui-input"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19"
|
||||||
|
dependencies = [
|
||||||
|
"ratatui",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "timr-tui"
|
name = "timr-tui"
|
||||||
version = "1.4.0"
|
version = "1.6.1"
|
||||||
description = "TUI to organize your time: Pomodoro, Countdown, Timer."
|
description = "TUI to organize your time: Pomodoro, Countdown, Timer."
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`.
|
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`.
|
||||||
rust-version = "1.90.0"
|
rust-version = "1.91.0"
|
||||||
homepage = "https://github.com/sectore/timr-tui"
|
homepage = "https://github.com/sectore/timr-tui"
|
||||||
repository = "https://github.com/sectore/timr-tui"
|
repository = "https://github.com/sectore/timr-tui"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@ -35,13 +35,14 @@ tracing = "0.1.41"
|
|||||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||||
directories = "5.0.1"
|
directories = "5.0.1"
|
||||||
clap = { version = "4.5.48", features = ["derive"] }
|
clap = { version = "4.5.48", features = ["derive"] }
|
||||||
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros"] }
|
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros", "serde"] }
|
||||||
notify-rust = "4.11.7"
|
notify-rust = "4.11.7"
|
||||||
rodio = { version = "0.20.1", features = [
|
rodio = { version = "0.20.1", features = [
|
||||||
"symphonia-mp3",
|
"symphonia-mp3",
|
||||||
"symphonia-wav",
|
"symphonia-wav",
|
||||||
], default-features = false, optional = true }
|
], default-features = false, optional = true }
|
||||||
thiserror = { version = "2.0.17", optional = true }
|
thiserror = { version = "2.0.17", optional = true }
|
||||||
|
tui-input = "0.14.0"
|
||||||
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
95
README.md
@ -1,10 +1,11 @@
|
|||||||
# timr-tui
|
# timr-tui
|
||||||
|
|
||||||
TUI to organize your time: Pomodoro, Countdown, Timer.
|
TUI to organize your time: Pomodoro, Countdown, Timer, Event.
|
||||||
|
|
||||||
- `[t]imer` Check the time on anything you are you doing.
|
- `[1] countdown` Use it for your workout, yoga/breathing sessions, meditation, handstand or whatever.
|
||||||
- `[c]ountdown` Use it for your workout, yoga session, meditation, handstand or whatever.
|
- `[2] timer` Check the time on anything you are you doing.
|
||||||
- `[p]omodoro` Organize your working time to be focused all the time by following the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique).
|
- `[3] pomodoro` Organize your working time to be focused all the time by following the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique).
|
||||||
|
- `[4] event` Count the time for any event in the future or past.
|
||||||
|
|
||||||
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
|
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
|
||||||
|
|
||||||
@ -36,29 +37,28 @@ _(theme depends on your terminal preferences)_
|
|||||||
<img alt="pomodoro" src="demo/timer.gif" />
|
<img alt="pomodoro" src="demo/timer.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Countdown
|
## Countdown (*incl. [Mission Elapsed Time](https://en.wikipedia.org/wiki/Mission_Elapsed_Time)*)
|
||||||
|
|
||||||
<a href="demo/countdown.gif">
|
<a href="demo/countdown.gif">
|
||||||
<img alt="countdown" src="demo/countdown.gif" />
|
<img alt="countdown" src="demo/countdown.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Countdown: Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
|
## Event (*past/future*)
|
||||||
|
|
||||||
<a href="demo/countdown-met.gif">
|
<a href="demo/event.gif">
|
||||||
<img alt="menu" src="demo/countdown-met.gif" />
|
<img alt="event" src="demo/event.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
## Local time
|
## Local time
|
||||||
|
|
||||||
<a href="demo/local-time.gif">
|
<a href="demo/local-time.gif">
|
||||||
<img alt="menu" src="demo/local-time.gif" />
|
<img alt="local time" src="demo/local-time.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Local time (footer)
|
## Local time (*footer*)
|
||||||
|
|
||||||
<a href="demo/local-time-footer.gif">
|
<a href="demo/local-time-footer.gif">
|
||||||
<img alt="menu" src="demo/local-time-footer.gif" />
|
<img alt="local time at footer" src="demo/local-time-footer.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Toggle deciseconds
|
## Toggle deciseconds
|
||||||
@ -67,6 +67,13 @@ _(theme depends on your terminal preferences)_
|
|||||||
<img alt="deciseconds" src="demo/decis.gif" />
|
<img alt="deciseconds" src="demo/decis.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## Maximum (*`9999y`* *`364d`* *`23:59:59.9`*)
|
||||||
|
|
||||||
|
<a href="demo/timer-max.png">
|
||||||
|
<img alt="maximum" src="demo/timer-max.png" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
## Change style
|
## Change style
|
||||||
|
|
||||||
<a href="demo/style.gif">
|
<a href="demo/style.gif">
|
||||||
@ -87,34 +94,20 @@ timr-tui --help
|
|||||||
Usage: timr-tui [OPTIONS]
|
Usage: timr-tui [OPTIONS]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c, --countdown <COUNTDOWN>
|
-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 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'.
|
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
|
||||||
--countdown-target <COUNTDOWN_TARGET>
|
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
|
||||||
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]
|
-e, --event <EVENT> Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'.
|
||||||
-w, --work <WORK>
|
-d, --decis Show deciseconds.
|
||||||
Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
|
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro, event, localtime]
|
||||||
-p, --pause <PAUSE>
|
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
|
||||||
Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
|
--menu Open menu.
|
||||||
-d, --decis
|
-r, --reset Reset stored values to defaults.
|
||||||
Show deciseconds.
|
-n, --notification <NOTIFICATION> Toggle desktop notifications. Experimental. [possible values: on, off]
|
||||||
-m, --mode <MODE>
|
--blink <BLINK> Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
|
||||||
Mode to start with. [possible values: countdown, timer, pomodoro, localtime]
|
--log [<LOG>] Directory for log file. If not set, standard application log directory is used (check README for details).
|
||||||
-s, --style <STYLE>
|
-h, --help Print help
|
||||||
Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
|
-V, --version Print version
|
||||||
--menu
|
|
||||||
Open menu.
|
|
||||||
-r, --reset
|
|
||||||
Reset stored values to defaults.
|
|
||||||
-n, --notification <NOTIFICATION>
|
|
||||||
Toggle desktop notifications. Experimental. [possible values: on, off]
|
|
||||||
--blink <BLINK>
|
|
||||||
Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
|
|
||||||
--log [<LOG>]
|
|
||||||
Directory for log file. If not set, standard application log directory is used (check README for details).
|
|
||||||
-h, --help
|
|
||||||
Print help
|
|
||||||
-V, --version
|
|
||||||
Print version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Extra option (if `--features sound` is enabled by local build only):
|
Extra option (if `--features sound` is enabled by local build only):
|
||||||
@ -135,10 +128,13 @@ Extra option (if `--features sound` is enabled by local build only):
|
|||||||
|
|
||||||
| Key | Description |
|
| Key | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| <kbd>p</kbd> | Pomodoro |
|
| <kbd>1</kbd> | Pomodoro |
|
||||||
| <kbd>c</kbd> | Countdown |
|
| <kbd>2</kbd> | Countdown |
|
||||||
| <kbd>t</kbd> | Timer |
|
| <kbd>3</kbd> | Timer |
|
||||||
| <kbd>l</kbd> | Local Time |
|
| <kbd>4</kbd> | Event |
|
||||||
|
| <kbd>0</kbd> | Local Time |
|
||||||
|
| <kbd>→</kbd> | next screen |
|
||||||
|
| <kbd>←</kbd> | previous screen |
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
@ -161,11 +157,19 @@ Extra option (if `--features sound` is enabled by local build only):
|
|||||||
| <kbd>↓</kbd> | edit to go down |
|
| <kbd>↓</kbd> | edit to go down |
|
||||||
| <kbd>ctrl+↓</kbd> | edit to go down 10x |
|
| <kbd>ctrl+↓</kbd> | edit to go down 10x |
|
||||||
|
|
||||||
|
**In `Event` `edit` mode only:**
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>Enter</kbd> | save changes |
|
||||||
|
| <kbd>Esc</kbd> | skip changes |
|
||||||
|
| <kbd>Tab</kbd> | switch input |
|
||||||
|
|
||||||
**In `Pomodoro` screen only:**
|
**In `Pomodoro` screen only:**
|
||||||
|
|
||||||
| Key | Description |
|
| Key | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| <kbd>←</kbd> or <kbd>→</kbd> | switch work/pause |
|
| <kbd>ctrl+←</kbd> or <kbd>ctrl+→</kbd> | switch work/pause |
|
||||||
| <kbd>ctrl+r</kbd> | reset round |
|
| <kbd>ctrl+r</kbd> | reset round |
|
||||||
| <kbd>ctrl+s</kbd> | save initial value |
|
| <kbd>ctrl+s</kbd> | save initial value |
|
||||||
|
|
||||||
@ -245,6 +249,7 @@ Available recipes:
|
|||||||
demo-countdown # build demo: countdown [alias: dc]
|
demo-countdown # build demo: countdown [alias: dc]
|
||||||
demo-countdown-met # build demo: countdown + met [alias: dcm]
|
demo-countdown-met # build demo: countdown + met [alias: dcm]
|
||||||
demo-decis # build demo: deciseconds [alias: dd]
|
demo-decis # build demo: deciseconds [alias: dd]
|
||||||
|
demo-event # build demo: event [alias: de]
|
||||||
demo-local-time # build demo: local time [alias: dlt]
|
demo-local-time # build demo: local time [alias: dlt]
|
||||||
demo-local-time-footer # build demo: local time (footer) [alias: dltf]
|
demo-local-time-footer # build demo: local time (footer) [alias: dltf]
|
||||||
demo-menu # build demo: menu [alias: dm]
|
demo-menu # build demo: menu [alias: dm]
|
||||||
|
|||||||
BIN
demo/blink.gif
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 46 KiB |
@ -4,8 +4,8 @@ Output demo/blink.gif
|
|||||||
Set Theme "nord-light"
|
Set Theme "nord-light"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
|
|||||||
BIN
demo/countdown-max.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
19
demo/countdown-max.tape
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "Retro"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Type 'cargo run -- -r -d -c "10000y"'
|
||||||
|
Enter
|
||||||
|
Sleep .2
|
||||||
|
Type "m"
|
||||||
|
# --- SCREENSHOT ---
|
||||||
|
Sleep 1s
|
||||||
|
Screenshot demo/countdown-max.png
|
||||||
|
Sleep 1s
|
||||||
|
Before Width: | Height: | Size: 43 KiB |
@ -1,22 +0,0 @@
|
|||||||
Output demo/countdown-met.gif
|
|
||||||
|
|
||||||
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
|
||||||
Set Theme "iceberg-light"
|
|
||||||
|
|
||||||
Set FontSize 14
|
|
||||||
Set Width 800
|
|
||||||
Set Height 400
|
|
||||||
Set Padding 0
|
|
||||||
Set Margin 1
|
|
||||||
|
|
||||||
# --- START ---
|
|
||||||
Set LoopOffset 4
|
|
||||||
Hide
|
|
||||||
Type "cargo run -- -m c -c 3"
|
|
||||||
Enter
|
|
||||||
Sleep 0.2
|
|
||||||
Show
|
|
||||||
Type "s"
|
|
||||||
Sleep 6
|
|
||||||
Type "r"
|
|
||||||
Sleep 1
|
|
||||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 81 KiB |
@ -1,23 +1,24 @@
|
|||||||
Output demo/countdown.gif
|
Output demo/countdown.gif
|
||||||
|
|
||||||
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
Set Theme "Builtin Solarized Light"
|
Set Theme "iceberg-light"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
# --- START ---
|
# --- START ---
|
||||||
Set LoopOffset 4
|
Set LoopOffset 4
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -r -d -m c"
|
Type "cargo run -- -r -d -c 10:00"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep .2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- COUNTDOWN ---
|
# --- COUNTDOWN ---
|
||||||
Sleep 1
|
Sleep .5
|
||||||
Type "s"
|
Type "s"
|
||||||
Sleep 1.4
|
Sleep 1.4
|
||||||
Type "s"
|
Type "s"
|
||||||
@ -28,6 +29,5 @@ Type "e"
|
|||||||
Sleep 0.1
|
Sleep 0.1
|
||||||
Down@10ms 65
|
Down@10ms 65
|
||||||
Sleep 0.1
|
Sleep 0.1
|
||||||
Type "e"
|
Type "s"
|
||||||
Sleep 0.1
|
|
||||||
Sleep 3
|
Sleep 3
|
||||||
|
|||||||
BIN
demo/decis.gif
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 35 KiB |
@ -4,8 +4,8 @@ Output demo/decis.gif
|
|||||||
Set Theme "nord-light"
|
Set Theme "nord-light"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
@ -14,7 +14,8 @@ Set LoopOffset 4
|
|||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -r -m t"
|
Type "cargo run -- -r -m t"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep .2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- STYLES ---
|
# --- STYLES ---
|
||||||
Type "s"
|
Type "s"
|
||||||
|
|||||||
BIN
demo/event.gif
Normal file
|
After Width: | Height: | Size: 87 KiB |
31
demo/event.tape
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
Output demo/event.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "Builtin Solarized Dark"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
Type "cargo run -- -r -e 'time=2010-01-10 10:00:00,title=hello world'"
|
||||||
|
Enter
|
||||||
|
Type "m"
|
||||||
|
Sleep 0.2
|
||||||
|
Show
|
||||||
|
# --- EVENT ---
|
||||||
|
Sleep 1
|
||||||
|
Type "e"
|
||||||
|
Backspace@1ms 17
|
||||||
|
Type@20ms "50-01-01 01:00:01"
|
||||||
|
Enter
|
||||||
|
Type "e"
|
||||||
|
Tab
|
||||||
|
Backspace@10ms 11
|
||||||
|
Type@20ms "hello future"
|
||||||
|
Enter
|
||||||
|
Sleep 1
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
@ -4,17 +4,18 @@ Output demo/local-time-footer.gif
|
|||||||
Set Theme "AtomOneLight"
|
Set Theme "AtomOneLight"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
# --- START ---
|
# --- START ---
|
||||||
Set LoopOffset 4
|
Set LoopOffset 4
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -m c"
|
Type "cargo run -- -r -m c"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- toggle local time ---
|
# --- toggle local time ---
|
||||||
Type@1.5s ":::"
|
Type@1s ":::"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
@ -4,8 +4,8 @@ Output demo/local-time.gif
|
|||||||
Set Theme "Atom"
|
Set Theme "Atom"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
@ -14,7 +14,8 @@ Set LoopOffset 4
|
|||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -m l"
|
Type "cargo run -- -m l"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep .2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- toggle local time ---
|
# --- toggle local time ---
|
||||||
Type@1.5s ":::"
|
Type@1s ":::"
|
||||||
|
|||||||
BIN
demo/menu.gif
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 179 KiB |
@ -4,23 +4,24 @@ Output demo/menu.gif
|
|||||||
Set Theme "Apple Classic"
|
Set Theme "Apple Classic"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
# --- START ---
|
# --- START ---
|
||||||
Set LoopOffset 4
|
Set LoopOffset 4
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -r -m p --menu"
|
Type "cargo run -- -r -m c"
|
||||||
Enter
|
Enter
|
||||||
Type@200ms "m"
|
Type@200ms "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- STYLES ---
|
# --- STYLES ---
|
||||||
Sleep 0.3s
|
Sleep 0.3s
|
||||||
Type@0.3s "m"
|
Type@0.3s "m" # show menu
|
||||||
Type@0.3s "t"
|
Type@0.3s "2"
|
||||||
Type@0.3s "c"
|
Type@0.3s "3"
|
||||||
Type@0.3s "p"
|
|
||||||
Type@0.3s "e"
|
Type@0.3s "e"
|
||||||
Escape@0.3s
|
Escape@0.3s
|
||||||
|
Type@0.3s "4"
|
||||||
|
Type@0.3s "0"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 185 KiB |
@ -1,32 +1,35 @@
|
|||||||
|
# Note: PR "support ctrl + arrow keys" https://github.com/charmbracelet/vhs/pull/673 needs to be merged to run this `tape`.
|
||||||
|
|
||||||
Output demo/pomodoro.gif
|
Output demo/pomodoro.gif
|
||||||
|
|
||||||
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
Set Theme "Catppuccin Frappe"
|
Set Theme "Catppuccin Frappe"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
# --- START ---
|
# --- START ---
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -d -m p --blink on"
|
Type "cargo run -- -r -d -m p --blink on"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep .2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- POMODORO WORK ---
|
# --- POMODORO WORK ---
|
||||||
Sleep 1
|
Sleep .5
|
||||||
Type "s"
|
Type "s" # start
|
||||||
Sleep 2.3
|
Sleep 2.3
|
||||||
Type "e"
|
Type "e"
|
||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
Down@30ms 80
|
Down@30ms 80
|
||||||
Sleep 100ms
|
Sleep 100ms
|
||||||
Type "e"
|
Type "s" # save
|
||||||
Sleep 4
|
Sleep 4
|
||||||
# --- POMODORO PAUSE ---
|
# --- POMODORO PAUSE ---
|
||||||
Right
|
Ctrl+Right
|
||||||
Sleep 0.5
|
Sleep 0.5
|
||||||
Type "s"
|
Type "s"
|
||||||
Sleep 2.3
|
Sleep 2.3
|
||||||
@ -34,5 +37,5 @@ Type "e"
|
|||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
Down@30ms 60
|
Down@30ms 60
|
||||||
Sleep 100ms
|
Sleep 100ms
|
||||||
Type "e"
|
Type "s" # save
|
||||||
Sleep 4
|
Sleep 4
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 24 KiB |
BIN
demo/style.gif
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 142 KiB |
@ -4,8 +4,8 @@ Output demo/style.gif
|
|||||||
Set Theme "OneDark"
|
Set Theme "OneDark"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ Hide
|
|||||||
Type "cargo run -- -r -d -m c"
|
Type "cargo run -- -r -d -m c"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- STYLES ---
|
# --- STYLES ---
|
||||||
Sleep 0.5
|
Sleep 0.5
|
||||||
|
|||||||
BIN
demo/timer-max.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
37
demo/timer-max.tape
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "SeaShells"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Type 'cargo run -- -r -m t'
|
||||||
|
Enter
|
||||||
|
Type "m"
|
||||||
|
Type "e"
|
||||||
|
Up@1ms 60 # ss
|
||||||
|
Left
|
||||||
|
Up@1ms 60 # mm
|
||||||
|
Left
|
||||||
|
Up@1ms 23 # hh
|
||||||
|
Left
|
||||||
|
Up@1ms 363 # ddd
|
||||||
|
Left
|
||||||
|
Up@1ms 9999 # yyyy
|
||||||
|
Right 4
|
||||||
|
Down # ss
|
||||||
|
Left
|
||||||
|
Down ## mm
|
||||||
|
Left 2
|
||||||
|
Down ## ddd
|
||||||
|
Up 2
|
||||||
|
Type "."
|
||||||
|
Type "s" # save
|
||||||
|
Type "s" # start to reach DONE
|
||||||
|
Sleep 2s
|
||||||
|
# --- SCREENSHOT ---
|
||||||
|
Screenshot demo/timer-max.png
|
||||||
|
Sleep 1s
|
||||||
BIN
demo/timer.gif
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 66 KiB |
@ -4,8 +4,8 @@ Output demo/timer.gif
|
|||||||
Set Theme "Belafonte Day"
|
Set Theme "Belafonte Day"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 1000
|
||||||
Set Height 400
|
Set Height 500
|
||||||
Set Padding 0
|
Set Padding 0
|
||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
@ -15,6 +15,7 @@ Hide
|
|||||||
Type "cargo run -- -r -d -m t"
|
Type "cargo run -- -r -d -m t"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
|
Type "m" # hide menu
|
||||||
Show
|
Show
|
||||||
# --- TIMER ---
|
# --- TIMER ---
|
||||||
Type "s"
|
Type "s"
|
||||||
@ -27,5 +28,5 @@ Type "e"
|
|||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
Up@30ms 57
|
Up@30ms 57
|
||||||
Sleep 0.7
|
Sleep 0.7
|
||||||
Type "e"
|
Type "s"
|
||||||
Sleep 4
|
Sleep 4
|
||||||
|
|||||||
24
flake.lock
generated
@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1758758545,
|
"lastModified": 1760924934,
|
||||||
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
|
"narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
|
"rev": "c6b4d5308293d0d04fcfeee92705017537cad02f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -23,11 +23,11 @@
|
|||||||
"rust-analyzer-src": "rust-analyzer-src"
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1758782550,
|
"lastModified": 1762065744,
|
||||||
"narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
|
"narHash": "sha256-c04mxJoCb8f6BBrdaREWmdQq+pfp395olXhC+B0G7DI=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "fenix",
|
"repo": "fenix",
|
||||||
"rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
|
"rev": "e0f24085a4a0da1c32adc308ec4c518ae886ff35",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -56,11 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1758690382,
|
"lastModified": 1761907660,
|
||||||
"narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
|
"narHash": "sha256-kJ8lIZsiPOmbkJypG+B5sReDXSD1KGu2VEPNqhRa/ew=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e643668fd71b949c53f8626614b21ff71a07379d",
|
"rev": "2fb006b87f04c4d3bdf08cfdbc7fab9c13d94a15",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -81,11 +81,11 @@
|
|||||||
"rust-analyzer-src": {
|
"rust-analyzer-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1758620797,
|
"lastModified": 1762016333,
|
||||||
"narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
|
"narHash": "sha256-PT8hXDYyeRjh9BGyLF/nZWm9TqRwP2EzeKuqUFH0M3w=",
|
||||||
"owner": "rust-lang",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-analyzer",
|
"repo": "rust-analyzer",
|
||||||
"rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
|
"rev": "fca718c0f2074bdccf9a996bb37b0fcaff80dc97",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
{
|
{
|
||||||
file = ./rust-toolchain.toml;
|
file = ./rust-toolchain.toml;
|
||||||
# sha256 = nixpkgs.lib.fakeSha256;
|
# sha256 = nixpkgs.lib.fakeSha256;
|
||||||
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
|
sha256 = "sha256-2eWc3xVTKqg5wKSHGwt1XoM/kUBC6y3MWfKg74Zn+fY=";
|
||||||
};
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
|
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
|
||||||
|
|||||||
32
justfile
@ -65,6 +65,17 @@ run-sound-args path args:
|
|||||||
|
|
||||||
# demos
|
# demos
|
||||||
|
|
||||||
|
alias da := demo-all
|
||||||
|
|
||||||
|
# build all demos
|
||||||
|
[group('demo')]
|
||||||
|
demo-all:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
for tape in demo/*.tape; do
|
||||||
|
echo "Building demo: $tape"
|
||||||
|
vhs "$tape"
|
||||||
|
done
|
||||||
|
|
||||||
alias dp := demo-pomodoro
|
alias dp := demo-pomodoro
|
||||||
|
|
||||||
# build demo: pomodoro
|
# build demo: pomodoro
|
||||||
@ -141,3 +152,24 @@ alias db := demo-blink
|
|||||||
[group('demo')]
|
[group('demo')]
|
||||||
demo-blink:
|
demo-blink:
|
||||||
vhs demo/blink.tape
|
vhs demo/blink.tape
|
||||||
|
|
||||||
|
alias de := demo-event
|
||||||
|
|
||||||
|
# build demo: event
|
||||||
|
[group('demo')]
|
||||||
|
demo-event:
|
||||||
|
vhs demo/event.tape
|
||||||
|
|
||||||
|
alias dcmx := demo-countdown-max
|
||||||
|
|
||||||
|
# build demo: timer-max
|
||||||
|
[group('demo')]
|
||||||
|
demo-countdown-max:
|
||||||
|
vhs demo/countdown-max.tape
|
||||||
|
|
||||||
|
alias dtm := demo-timer-max
|
||||||
|
|
||||||
|
# build demo: timer-max
|
||||||
|
[group('demo')]
|
||||||
|
demo-timer-max:
|
||||||
|
vhs demo/timer-max.tape
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
# Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`.
|
# Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`.
|
||||||
channel = "1.90.0"
|
channel = "1.91.0"
|
||||||
components = ["clippy", "rustfmt", "rust-src", "rust-analyzer"]
|
components = ["clippy", "rustfmt", "rust-src", "rust-analyzer"]
|
||||||
targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl"]
|
targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl"]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|||||||
101
src/app.rs
@ -2,13 +2,14 @@ use crate::{
|
|||||||
args::Args,
|
args::Args,
|
||||||
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
|
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
|
||||||
constants::TICK_VALUE_MS,
|
constants::TICK_VALUE_MS,
|
||||||
duration::DirectedDuration,
|
event::Event,
|
||||||
events::{self, TuiEventHandler},
|
events::{self, TuiEventHandler},
|
||||||
storage::AppStorage,
|
storage::AppStorage,
|
||||||
terminal::Terminal,
|
terminal::Terminal,
|
||||||
widgets::{
|
widgets::{
|
||||||
clock::{self, ClockState, ClockStateArgs},
|
clock::{self, ClockState, ClockStateArgs},
|
||||||
countdown::{Countdown, CountdownState, CountdownStateArgs},
|
countdown::{Countdown, CountdownState, CountdownStateArgs},
|
||||||
|
event::{EventState, EventStateArgs, EventWidget},
|
||||||
footer::{Footer, FooterState},
|
footer::{Footer, FooterState},
|
||||||
header::Header,
|
header::Header,
|
||||||
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
|
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
|
||||||
@ -17,6 +18,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crossterm::event::Event as CrosstermEvent;
|
||||||
|
|
||||||
#[cfg(feature = "sound")]
|
#[cfg(feature = "sound")]
|
||||||
use crate::sound::Sound;
|
use crate::sound::Sound;
|
||||||
|
|
||||||
@ -24,7 +27,7 @@ use color_eyre::Result;
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
crossterm::event::{KeyCode, KeyEvent},
|
crossterm::event::{KeyCode, KeyEvent},
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Position, Rect},
|
||||||
widgets::{StatefulWidget, Widget},
|
widgets::{StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -49,10 +52,12 @@ pub struct App {
|
|||||||
countdown: CountdownState,
|
countdown: CountdownState,
|
||||||
timer: TimerState,
|
timer: TimerState,
|
||||||
pomodoro: PomodoroState,
|
pomodoro: PomodoroState,
|
||||||
|
event: EventState,
|
||||||
local_time: LocalTimeState,
|
local_time: LocalTimeState,
|
||||||
style: Style,
|
style: Style,
|
||||||
with_decis: bool,
|
with_decis: bool,
|
||||||
footer: FooterState,
|
footer: FooterState,
|
||||||
|
cursor_position: Option<Position>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppArgs {
|
pub struct AppArgs {
|
||||||
@ -73,6 +78,7 @@ pub struct AppArgs {
|
|||||||
pub current_value_countdown: Duration,
|
pub current_value_countdown: Duration,
|
||||||
pub elapsed_value_countdown: Duration,
|
pub elapsed_value_countdown: Duration,
|
||||||
pub current_value_timer: Duration,
|
pub current_value_timer: Duration,
|
||||||
|
pub event: Event,
|
||||||
pub app_tx: events::AppEventTx,
|
pub app_tx: events::AppEventTx,
|
||||||
pub sound_path: Option<PathBuf>,
|
pub sound_path: Option<PathBuf>,
|
||||||
pub footer_toggle_app_time: Toggle,
|
pub footer_toggle_app_time: Toggle,
|
||||||
@ -103,8 +109,10 @@ impl From<FromAppArgs> for App {
|
|||||||
None => {
|
None => {
|
||||||
if args.work.is_some() || args.pause.is_some() {
|
if args.work.is_some() || args.pause.is_some() {
|
||||||
Content::Pomodoro
|
Content::Pomodoro
|
||||||
} else if args.countdown.is_some() || args.countdown_target.is_some() {
|
} else if args.countdown.is_some() {
|
||||||
Content::Countdown
|
Content::Countdown
|
||||||
|
} else if args.event.is_some() {
|
||||||
|
Content::Event
|
||||||
}
|
}
|
||||||
// in other case just use latest stored state
|
// in other case just use latest stored state
|
||||||
else {
|
else {
|
||||||
@ -121,30 +129,16 @@ impl From<FromAppArgs> for App {
|
|||||||
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
|
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
|
||||||
// invalidate `current_value_pause` if an initial value is set via args
|
// invalidate `current_value_pause` if an initial value is set via args
|
||||||
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
|
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
|
||||||
initial_value_countdown: match (&args.countdown, &args.countdown_target) {
|
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
|
||||||
(Some(d), _) => *d,
|
|
||||||
(None, Some(DirectedDuration::Until(d))) => *d,
|
|
||||||
// reset for values from "past"
|
|
||||||
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
|
|
||||||
(None, None) => stg.inital_value_countdown,
|
|
||||||
},
|
|
||||||
// invalidate `current_value_countdown` if an initial value is set via args
|
// invalidate `current_value_countdown` if an initial value is set via args
|
||||||
current_value_countdown: match (&args.countdown, &args.countdown_target) {
|
current_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
|
||||||
(Some(d), _) => *d,
|
elapsed_value_countdown: match args.countdown {
|
||||||
(None, Some(DirectedDuration::Until(d))) => *d,
|
// reset value if countdown is set by arguments
|
||||||
// `zero` makes values from `past` marked as `DONE`
|
Some(_) => Duration::ZERO,
|
||||||
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
|
None => stg.elapsed_value_countdown,
|
||||||
(None, None) => stg.inital_value_countdown,
|
|
||||||
},
|
|
||||||
elapsed_value_countdown: match (args.countdown, args.countdown_target) {
|
|
||||||
// use `Since` duration
|
|
||||||
(_, Some(DirectedDuration::Since(d))) => d,
|
|
||||||
// reset values
|
|
||||||
(_, Some(_)) => Duration::ZERO,
|
|
||||||
(Some(_), _) => Duration::ZERO,
|
|
||||||
(_, _) => stg.elapsed_value_countdown,
|
|
||||||
},
|
},
|
||||||
current_value_timer: stg.current_value_timer,
|
current_value_timer: stg.current_value_timer,
|
||||||
|
event: args.event.unwrap_or(stg.event),
|
||||||
app_tx,
|
app_tx,
|
||||||
#[cfg(feature = "sound")]
|
#[cfg(feature = "sound")]
|
||||||
sound_path: args.sound,
|
sound_path: args.sound,
|
||||||
@ -173,6 +167,7 @@ impl App {
|
|||||||
with_decis,
|
with_decis,
|
||||||
pomodoro_mode,
|
pomodoro_mode,
|
||||||
pomodoro_round,
|
pomodoro_round,
|
||||||
|
event,
|
||||||
notification,
|
notification,
|
||||||
blink,
|
blink,
|
||||||
sound_path,
|
sound_path,
|
||||||
@ -223,6 +218,12 @@ impl App {
|
|||||||
app_time,
|
app_time,
|
||||||
app_time_format,
|
app_time_format,
|
||||||
}),
|
}),
|
||||||
|
event: EventState::new(EventStateArgs {
|
||||||
|
app_time,
|
||||||
|
event,
|
||||||
|
with_decis,
|
||||||
|
app_tx: app_tx.clone(),
|
||||||
|
}),
|
||||||
footer: FooterState::new(
|
footer: FooterState::new(
|
||||||
show_menu,
|
show_menu,
|
||||||
if footer_toggle_app_time == Toggle::On {
|
if footer_toggle_app_time == Toggle::On {
|
||||||
@ -231,6 +232,7 @@ impl App {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
cursor_position: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,10 +246,19 @@ impl App {
|
|||||||
debug!("Received key {:?}", key.code);
|
debug!("Received key {:?}", key.code);
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => app.mode = Mode::Quit,
|
KeyCode::Char('q') => app.mode = Mode::Quit,
|
||||||
KeyCode::Char('c') => app.content = Content::Countdown,
|
KeyCode::Char('1') | KeyCode::Char('c') /* TODO: deprecated, remove it in next version */ => app.content = Content::Countdown,
|
||||||
KeyCode::Char('t') => app.content = Content::Timer,
|
KeyCode::Char('2') | KeyCode::Char('t') /* TODO: deprecated, remove it in next version */ => app.content = Content::Timer,
|
||||||
KeyCode::Char('p') => app.content = Content::Pomodoro,
|
KeyCode::Char('3') | KeyCode::Char('p') /* TODO: deprecated, remove it in next version */ => app.content = Content::Pomodoro,
|
||||||
KeyCode::Char('l') => app.content = Content::LocalTime,
|
KeyCode::Char('4') => app.content = Content::Event,
|
||||||
|
// toogle app time format
|
||||||
|
KeyCode::Char('0') | KeyCode::Char('l') /* TODO: deprecated, remove it in next version */ => app.content = Content::LocalTime,
|
||||||
|
// switch `screens`
|
||||||
|
KeyCode::Right => {
|
||||||
|
app.content = app.content.next();
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
app.content = app.content.prev();
|
||||||
|
}
|
||||||
// toogle app time format
|
// toogle app time format
|
||||||
KeyCode::Char(':') => {
|
KeyCode::Char(':') => {
|
||||||
if app.content == Content::LocalTime {
|
if app.content == Content::LocalTime {
|
||||||
@ -291,6 +302,7 @@ impl App {
|
|||||||
app.timer.set_with_decis(app.with_decis);
|
app.timer.set_with_decis(app.with_decis);
|
||||||
app.countdown.set_with_decis(app.with_decis);
|
app.countdown.set_with_decis(app.with_decis);
|
||||||
app.pomodoro.set_with_decis(app.with_decis);
|
app.pomodoro.set_with_decis(app.with_decis);
|
||||||
|
app.event.set_with_decis(app.with_decis);
|
||||||
}
|
}
|
||||||
KeyCode::Up => app.footer.set_show_menu(true),
|
KeyCode::Up => app.footer.set_show_menu(true),
|
||||||
KeyCode::Down => app.footer.set_show_menu(false),
|
KeyCode::Down => app.footer.set_show_menu(false),
|
||||||
@ -303,6 +315,7 @@ impl App {
|
|||||||
app.app_time = AppTime::new();
|
app.app_time = AppTime::new();
|
||||||
app.countdown.set_app_time(app.app_time);
|
app.countdown.set_app_time(app.app_time);
|
||||||
app.local_time.set_app_time(app.app_time);
|
app.local_time.set_app_time(app.app_time);
|
||||||
|
app.event.set_app_time(app.app_time);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipe events into subviews and handle only 'unhandled' events afterwards
|
// Pipe events into subviews and handle only 'unhandled' events afterwards
|
||||||
@ -310,13 +323,17 @@ impl App {
|
|||||||
Content::Countdown => app.countdown.update(event.clone()),
|
Content::Countdown => app.countdown.update(event.clone()),
|
||||||
Content::Timer => app.timer.update(event.clone()),
|
Content::Timer => app.timer.update(event.clone()),
|
||||||
Content::Pomodoro => app.pomodoro.update(event.clone()),
|
Content::Pomodoro => app.pomodoro.update(event.clone()),
|
||||||
|
Content::Event => app.event.update(event.clone()),
|
||||||
Content::LocalTime => app.local_time.update(event.clone()),
|
Content::LocalTime => app.local_time.update(event.clone()),
|
||||||
} {
|
} {
|
||||||
match unhandled {
|
match unhandled {
|
||||||
events::TuiEvent::Render | events::TuiEvent::Resize => {
|
events::TuiEvent::Render
|
||||||
|
| events::TuiEvent::Crossterm(crossterm::event::Event::Resize(_, _)) => {
|
||||||
app.draw(terminal)?;
|
app.draw(terminal)?;
|
||||||
}
|
}
|
||||||
events::TuiEvent::Key(key) => handle_key_event(app, key),
|
events::TuiEvent::Crossterm(CrosstermEvent::Key(key)) => {
|
||||||
|
handle_key_event(app, key)
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -356,6 +373,9 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
events::AppEvent::SetCursor(position) => {
|
||||||
|
app.cursor_position = position;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
@ -401,6 +421,13 @@ impl App {
|
|||||||
AppEditMode::None
|
AppEditMode::None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Content::Event => {
|
||||||
|
if self.event.is_edit_mode() {
|
||||||
|
AppEditMode::Event
|
||||||
|
} else {
|
||||||
|
AppEditMode::None
|
||||||
|
}
|
||||||
|
}
|
||||||
Content::LocalTime => AppEditMode::None,
|
Content::LocalTime => AppEditMode::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -410,6 +437,8 @@ impl App {
|
|||||||
Content::Countdown => self.countdown.is_running(),
|
Content::Countdown => self.countdown.is_running(),
|
||||||
Content::Timer => self.timer.get_clock().is_running(),
|
Content::Timer => self.timer.get_clock().is_running(),
|
||||||
Content::Pomodoro => self.pomodoro.get_clock().is_running(),
|
Content::Pomodoro => self.pomodoro.get_clock().is_running(),
|
||||||
|
// Event clock runs forever
|
||||||
|
Content::Event => true,
|
||||||
// `LocalTime` does not use a `Clock`
|
// `LocalTime` does not use a `Clock`
|
||||||
Content::LocalTime => false,
|
Content::LocalTime => false,
|
||||||
}
|
}
|
||||||
@ -420,6 +449,7 @@ impl App {
|
|||||||
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
|
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
|
||||||
Content::Timer => None,
|
Content::Timer => None,
|
||||||
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
|
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
|
||||||
|
Content::Event => Some(self.event.get_percentage_done()),
|
||||||
Content::LocalTime => None,
|
Content::LocalTime => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -427,6 +457,11 @@ impl App {
|
|||||||
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
|
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
frame.render_stateful_widget(AppWidget, frame.area(), self);
|
frame.render_stateful_widget(AppWidget, frame.area(), self);
|
||||||
|
|
||||||
|
// Set cursor position if requested
|
||||||
|
if let Some(position) = self.cursor_position {
|
||||||
|
frame.set_cursor_position(position);
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -456,6 +491,7 @@ impl App {
|
|||||||
),
|
),
|
||||||
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
|
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
|
||||||
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
|
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
|
||||||
|
event: self.event.get_event(),
|
||||||
footer_app_time: self.footer.app_time_format().is_some().into(),
|
footer_app_time: self.footer.app_time_format().is_some().into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -483,6 +519,11 @@ impl AppWidget {
|
|||||||
blink: state.blink == Toggle::On,
|
blink: state.blink == Toggle::On,
|
||||||
}
|
}
|
||||||
.render(area, buf, &mut state.pomodoro),
|
.render(area, buf, &mut state.pomodoro),
|
||||||
|
Content::Event => EventWidget {
|
||||||
|
style: state.style,
|
||||||
|
blink: state.blink == Toggle::On,
|
||||||
|
}
|
||||||
|
.render(area, buf, &mut state.event),
|
||||||
Content::LocalTime => {
|
Content::LocalTime => {
|
||||||
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
|
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/args.rs
@ -1,6 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::{Content, Style, Toggle},
|
common::{Content, Style, Toggle},
|
||||||
duration,
|
duration,
|
||||||
|
event::{Event, parse_event},
|
||||||
};
|
};
|
||||||
#[cfg(feature = "sound")]
|
#[cfg(feature = "sound")]
|
||||||
use crate::{sound, sound::SoundError};
|
use crate::{sound, sound::SoundError};
|
||||||
@ -18,11 +19,6 @@ pub struct Args {
|
|||||||
)]
|
)]
|
||||||
pub countdown: Option<Duration>,
|
pub countdown: Option<Duration>,
|
||||||
|
|
||||||
#[arg(long, visible_alias = "ct", value_parser = duration::parse_duration_by_time,
|
|
||||||
help = "Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm'"
|
|
||||||
)]
|
|
||||||
pub countdown_target: Option<duration::DirectedDuration>,
|
|
||||||
|
|
||||||
#[arg(long, short, value_parser = duration::parse_duration,
|
#[arg(long, short, value_parser = duration::parse_duration,
|
||||||
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
|
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
|
||||||
)]
|
)]
|
||||||
@ -33,6 +29,14 @@ pub struct Args {
|
|||||||
)]
|
)]
|
||||||
pub pause: Option<Duration>,
|
pub pause: Option<Duration>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
short = 'e',
|
||||||
|
value_parser = parse_event,
|
||||||
|
help = "Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'."
|
||||||
|
)]
|
||||||
|
pub event: Option<Event>,
|
||||||
|
|
||||||
#[arg(long, short = 'd', help = "Show deciseconds.")]
|
#[arg(long, short = 'd', help = "Show deciseconds.")]
|
||||||
pub decis: bool,
|
pub decis: bool,
|
||||||
|
|
||||||
|
|||||||
@ -15,14 +15,39 @@ pub enum Content {
|
|||||||
Timer,
|
Timer,
|
||||||
#[value(name = "pomodoro", alias = "p")]
|
#[value(name = "pomodoro", alias = "p")]
|
||||||
Pomodoro,
|
Pomodoro,
|
||||||
|
#[value(name = "event", alias = "e")]
|
||||||
|
Event,
|
||||||
#[value(name = "localtime", alias = "l")]
|
#[value(name = "localtime", alias = "l")]
|
||||||
LocalTime,
|
LocalTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Content {
|
||||||
|
pub fn next(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Content::Countdown => Content::Timer,
|
||||||
|
Content::Timer => Content::Pomodoro,
|
||||||
|
Content::Pomodoro => Content::Event,
|
||||||
|
Content::Event => Content::LocalTime,
|
||||||
|
Content::LocalTime => Content::Countdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Content::Countdown => Content::LocalTime,
|
||||||
|
Content::Timer => Content::Countdown,
|
||||||
|
Content::Pomodoro => Content::Timer,
|
||||||
|
Content::Event => Content::Pomodoro,
|
||||||
|
Content::LocalTime => Content::Event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum ClockTypeId {
|
pub enum ClockTypeId {
|
||||||
Countdown,
|
Countdown,
|
||||||
Timer,
|
Timer,
|
||||||
|
Event,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
|
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
|
||||||
@ -184,6 +209,7 @@ pub enum AppEditMode {
|
|||||||
None,
|
None,
|
||||||
Clock,
|
Clock,
|
||||||
Time,
|
Time,
|
||||||
|
Event,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
@ -251,4 +277,48 @@ mod tests {
|
|||||||
"local"
|
"local"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_next() {
|
||||||
|
let start = Content::Countdown;
|
||||||
|
let mut current = start;
|
||||||
|
|
||||||
|
// Cycle through: Countdown -> Timer -> Pomodoro -> Event -> LocalTime -> Countdown
|
||||||
|
current = current.next();
|
||||||
|
assert_eq!(current, Content::Timer);
|
||||||
|
|
||||||
|
current = current.next();
|
||||||
|
assert_eq!(current, Content::Pomodoro);
|
||||||
|
|
||||||
|
current = current.next();
|
||||||
|
assert_eq!(current, Content::Event);
|
||||||
|
|
||||||
|
current = current.next();
|
||||||
|
assert_eq!(current, Content::LocalTime);
|
||||||
|
|
||||||
|
current = current.next();
|
||||||
|
assert_eq!(current, start, "Should cycle back to start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_prev() {
|
||||||
|
let start = Content::Countdown;
|
||||||
|
let mut current = start;
|
||||||
|
|
||||||
|
// Cycle backwards: Countdown -> LocalTime -> Event -> Pomodoro -> Timer -> Countdown
|
||||||
|
current = current.prev();
|
||||||
|
assert_eq!(current, Content::LocalTime);
|
||||||
|
|
||||||
|
current = current.prev();
|
||||||
|
assert_eq!(current, Content::Event);
|
||||||
|
|
||||||
|
current = current.prev();
|
||||||
|
assert_eq!(current, Content::Pomodoro);
|
||||||
|
|
||||||
|
current = current.prev();
|
||||||
|
assert_eq!(current, Content::Timer);
|
||||||
|
|
||||||
|
current = current.prev();
|
||||||
|
assert_eq!(current, start, "Should cycle back to start");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
514
src/duration.rs
@ -5,8 +5,7 @@ use color_eyre::{
|
|||||||
use std::cmp::min;
|
use std::cmp::min;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use time::OffsetDateTime;
|
||||||
use crate::common::AppTime;
|
|
||||||
|
|
||||||
// unstable
|
// unstable
|
||||||
// https://doc.rust-lang.org/src/core/time.rs.html#32
|
// https://doc.rust-lang.org/src/core/time.rs.html#32
|
||||||
@ -32,18 +31,204 @@ pub const ONE_YEAR: Duration =
|
|||||||
// ^ https://www.math.net/days-in-a-year
|
// ^ https://www.math.net/days-in-a-year
|
||||||
const DAYS_PER_YEAR: u64 = 365; // ignore leap year of 366 days
|
const DAYS_PER_YEAR: u64 = 365; // ignore leap year of 366 days
|
||||||
|
|
||||||
// max. 999y 364d 23:59:59.9 (1000 years - 1 decisecond)
|
// max. 9999y 364d 23:59:59.9 (10k years - 1 decisecond)
|
||||||
pub const MAX_DURATION: Duration = ONE_YEAR
|
pub const MAX_DURATION: Duration = ONE_YEAR
|
||||||
.saturating_mul(1000)
|
.saturating_mul(10000)
|
||||||
.saturating_sub(ONE_DECI_SECOND);
|
.saturating_sub(ONE_DECI_SECOND);
|
||||||
|
|
||||||
/// `Duration` with direction in time (past or future)
|
/// Trait for duration types that can be displayed in clock widgets.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
///
|
||||||
pub enum DirectedDuration {
|
/// This trait abstracts over different duration calculation strategies:
|
||||||
/// Time `until` a future moment (positive `Duration`)
|
/// - `DurationEx`: Uses fixed 365-day years (fast, simple)
|
||||||
Until(Duration),
|
/// - `CalendarDuration`: Uses actual calendar dates (accounts for leap years)
|
||||||
/// Time `since` a past moment (negative duration, but still represented as positive `Duration`)
|
pub trait ClockDuration {
|
||||||
Since(Duration),
|
/// Total years
|
||||||
|
fn years(&self) -> u64;
|
||||||
|
|
||||||
|
/// Total days
|
||||||
|
fn days(&self) -> u64;
|
||||||
|
|
||||||
|
/// Days within the current year (0-364 or 0-365 for leap years)
|
||||||
|
fn days_mod(&self) -> u64;
|
||||||
|
|
||||||
|
/// Total hours
|
||||||
|
fn hours(&self) -> u64;
|
||||||
|
|
||||||
|
/// Hours within the current day (0-23)
|
||||||
|
fn hours_mod(&self) -> u64;
|
||||||
|
|
||||||
|
/// Hours as 12-hour clock (1-12)
|
||||||
|
fn hours_mod_12(&self) -> u64;
|
||||||
|
|
||||||
|
/// Total minutes
|
||||||
|
fn minutes(&self) -> u64;
|
||||||
|
|
||||||
|
/// Minutes within the current hour (0-59)
|
||||||
|
fn minutes_mod(&self) -> u64;
|
||||||
|
|
||||||
|
/// Total seconds
|
||||||
|
fn seconds(&self) -> u64;
|
||||||
|
|
||||||
|
/// Seconds within the current minute (0-59)
|
||||||
|
fn seconds_mod(&self) -> u64;
|
||||||
|
|
||||||
|
/// Deciseconds (tenths of a second, 0-9)
|
||||||
|
fn decis(&self) -> u64;
|
||||||
|
|
||||||
|
/// Total milliseconds
|
||||||
|
fn millis(&self) -> u128;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar-aware duration that accounts for leap years.
|
||||||
|
///
|
||||||
|
/// Unlike `DurationEx` which uses fixed 365-day years, this calculates
|
||||||
|
/// years and days based on actual calendar dates, properly handling leap years.
|
||||||
|
///
|
||||||
|
/// All calculations are performed on-demand from the stored dates.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CalendarDuration {
|
||||||
|
earlier: OffsetDateTime,
|
||||||
|
later: OffsetDateTime,
|
||||||
|
direction: CalendarDurationDirection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Debug, Clone, Copy)]
|
||||||
|
pub enum CalendarDurationDirection {
|
||||||
|
Since,
|
||||||
|
Until,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarDuration {
|
||||||
|
/// Create a new CalendarDuration by given two `OffsetDateTime`.
|
||||||
|
///
|
||||||
|
/// The order of arguments matters:
|
||||||
|
/// First: `start_time` - `OffsetDateTime` to start from
|
||||||
|
/// Second: `end_time` - `OffsetDateTime` for expected end
|
||||||
|
pub fn from_start_end_times(start_time: OffsetDateTime, end_time: OffsetDateTime) -> Self {
|
||||||
|
// To avoid negative values by calculating differences of `start` and `end` times,
|
||||||
|
// we might switch those values internally by storing it as `earlier` and `later` values
|
||||||
|
// It simplifies all calculations in `ClockDuration` trait later.
|
||||||
|
// And `direction` will still help to still get original `start` and `end` times later.
|
||||||
|
if start_time <= end_time {
|
||||||
|
Self {
|
||||||
|
earlier: start_time,
|
||||||
|
later: end_time,
|
||||||
|
direction: CalendarDurationDirection::Since,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self {
|
||||||
|
earlier: end_time,
|
||||||
|
later: start_time,
|
||||||
|
direction: CalendarDurationDirection::Until,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn direction(&self) -> CalendarDurationDirection {
|
||||||
|
self.direction
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_since(&self) -> bool {
|
||||||
|
self.direction == CalendarDurationDirection::Since
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_time(&self) -> &OffsetDateTime {
|
||||||
|
match self.direction {
|
||||||
|
CalendarDurationDirection::Since => &self.earlier,
|
||||||
|
CalendarDurationDirection::Until => &self.later,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_time(&self) -> &OffsetDateTime {
|
||||||
|
match self.direction {
|
||||||
|
CalendarDurationDirection::Since => &self.later,
|
||||||
|
CalendarDurationDirection::Until => &self.earlier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CalendarDuration> for Duration {
|
||||||
|
fn from(cal_duration: CalendarDuration) -> Self {
|
||||||
|
let diff = cal_duration.later - cal_duration.earlier;
|
||||||
|
Duration::from_millis(diff.whole_milliseconds().max(0) as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClockDuration for CalendarDuration {
|
||||||
|
fn years(&self) -> u64 {
|
||||||
|
let mut years = (self.later.year() - self.earlier.year()) as i64;
|
||||||
|
|
||||||
|
// Check if we've completed a full year by comparing month/day/time
|
||||||
|
let intermediate = self
|
||||||
|
.earlier
|
||||||
|
.replace_year(self.later.year())
|
||||||
|
.unwrap_or(self.earlier);
|
||||||
|
|
||||||
|
if intermediate > self.later {
|
||||||
|
years -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
years.max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days_mod(&self) -> u64 {
|
||||||
|
let year_count = self.years();
|
||||||
|
|
||||||
|
// Calculate intermediate date after adding complete years
|
||||||
|
let target_year = self.earlier.year() + year_count as i32;
|
||||||
|
let intermediate = self
|
||||||
|
.earlier
|
||||||
|
.replace_year(target_year)
|
||||||
|
.unwrap_or(self.earlier);
|
||||||
|
|
||||||
|
let remaining = self.later - intermediate;
|
||||||
|
remaining.whole_days().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days(&self) -> u64 {
|
||||||
|
(self.later - self.earlier).whole_days().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hours_mod(&self) -> u64 {
|
||||||
|
let total_hours = (self.later - self.earlier).whole_hours();
|
||||||
|
(total_hours % 24).max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hours(&self) -> u64 {
|
||||||
|
(self.later - self.earlier).whole_hours().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hours_mod_12(&self) -> u64 {
|
||||||
|
let hours = self.hours_mod();
|
||||||
|
(hours + 11) % 12 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minutes_mod(&self) -> u64 {
|
||||||
|
let total_minutes = (self.later - self.earlier).whole_minutes();
|
||||||
|
(total_minutes % 60).max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minutes(&self) -> u64 {
|
||||||
|
(self.later - self.earlier).whole_minutes().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seconds_mod(&self) -> u64 {
|
||||||
|
let total_seconds = (self.later - self.earlier).whole_seconds();
|
||||||
|
(total_seconds % 60).max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seconds(&self) -> u64 {
|
||||||
|
(self.later - self.earlier).whole_seconds().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decis(&self) -> u64 {
|
||||||
|
let total_millis = (self.later - self.earlier).whole_milliseconds();
|
||||||
|
((total_millis % 1000) / 100).max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn millis(&self) -> u128 {
|
||||||
|
(self.later - self.earlier).whole_milliseconds().max(0) as u128
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialOrd)]
|
#[derive(Debug, Clone, Copy, PartialOrd)]
|
||||||
@ -69,62 +254,60 @@ impl From<DurationEx> for Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DurationEx {
|
impl ClockDuration for DurationEx {
|
||||||
pub fn years(&self) -> u64 {
|
fn years(&self) -> u64 {
|
||||||
self.days() / DAYS_PER_YEAR
|
self.days() / DAYS_PER_YEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn days(&self) -> u64 {
|
fn days(&self) -> u64 {
|
||||||
self.hours() / HOURS_PER_DAY
|
self.hours() / HOURS_PER_DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Days in a year
|
fn days_mod(&self) -> u64 {
|
||||||
pub fn days_mod(&self) -> u64 {
|
|
||||||
self.days() % DAYS_PER_YEAR
|
self.days() % DAYS_PER_YEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hours(&self) -> u64 {
|
fn hours(&self) -> u64 {
|
||||||
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
|
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hours as 24-hour clock
|
fn hours_mod(&self) -> u64 {
|
||||||
pub fn hours_mod(&self) -> u64 {
|
|
||||||
self.hours() % HOURS_PER_DAY
|
self.hours() % HOURS_PER_DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hours as 12-hour clock
|
fn hours_mod_12(&self) -> u64 {
|
||||||
pub fn hours_mod_12(&self) -> u64 {
|
|
||||||
// 0 => 12,
|
// 0 => 12,
|
||||||
// 1..=12 => hours,
|
// 1..=12 => hours,
|
||||||
// 13..=23 => hours - 12,
|
// 13..=23 => hours - 12,
|
||||||
(self.hours_mod() + 11) % 12 + 1
|
(self.hours_mod() + 11) % 12 + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn minutes(&self) -> u64 {
|
fn minutes(&self) -> u64 {
|
||||||
self.seconds() / MINS_PER_HOUR
|
self.seconds() / MINS_PER_HOUR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn minutes_mod(&self) -> u64 {
|
fn minutes_mod(&self) -> u64 {
|
||||||
self.minutes() % SECS_PER_MINUTE
|
self.minutes() % SECS_PER_MINUTE
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seconds(&self) -> u64 {
|
fn seconds(&self) -> u64 {
|
||||||
self.inner.as_secs()
|
self.inner.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seconds_mod(&self) -> u64 {
|
fn seconds_mod(&self) -> u64 {
|
||||||
self.seconds() % SECS_PER_MINUTE
|
self.seconds() % SECS_PER_MINUTE
|
||||||
}
|
}
|
||||||
|
|
||||||
// deciseconds
|
fn decis(&self) -> u64 {
|
||||||
pub fn decis(&self) -> u64 {
|
|
||||||
(self.inner.subsec_millis() / 100) as u64
|
(self.inner.subsec_millis() / 100) as u64
|
||||||
}
|
}
|
||||||
// milliseconds
|
|
||||||
pub fn millis(&self) -> u128 {
|
fn millis(&self) -> u128 {
|
||||||
self.inner.as_millis()
|
self.inner.as_millis()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DurationEx {
|
||||||
pub fn saturating_add(&self, ex: DurationEx) -> Self {
|
pub fn saturating_add(&self, ex: DurationEx) -> Self {
|
||||||
let inner = self.inner.saturating_add(ex.inner);
|
let inner = self.inner.saturating_add(ex.inner);
|
||||||
Self { inner }
|
Self { inner }
|
||||||
@ -142,6 +325,7 @@ impl DurationEx {
|
|||||||
|
|
||||||
impl fmt::Display for DurationEx {
|
impl fmt::Display for DurationEx {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
use ClockDuration as _; // Import trait methods
|
||||||
if self.years() >= 1 {
|
if self.years() >= 1 {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
@ -209,84 +393,6 @@ fn parse_hours(h: &str) -> Result<u8, Report> {
|
|||||||
Ok(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`
|
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
|
||||||
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
|
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
|
||||||
let parts: Vec<&str> = arg.split(':').collect();
|
let parts: Vec<&str> = arg.split(':').collect();
|
||||||
@ -371,6 +477,7 @@ pub fn parse_long_duration(arg: &str) -> Result<Duration, Report> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
use super::ClockDuration;
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@ -493,42 +600,6 @@ mod tests {
|
|||||||
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
|
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_duration_by_time() {
|
|
||||||
// YYYY-MM-DD HH:MM:SS - future
|
|
||||||
assert!(matches!(
|
|
||||||
parse_duration_by_time("2050-06-15 14:30:45"),
|
|
||||||
Ok(DirectedDuration::Until(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
// YYYY-MM-DD HH:MM - future
|
|
||||||
assert!(matches!(
|
|
||||||
parse_duration_by_time("2050-06-15 14:30"),
|
|
||||||
Ok(DirectedDuration::Until(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
// HH:MM:SS - past
|
|
||||||
assert!(matches!(
|
|
||||||
parse_duration_by_time("2000-01-01 23:59:59"),
|
|
||||||
Ok(DirectedDuration::Since(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
// HH:MM - Until or Since depending on current time
|
|
||||||
assert!(parse_duration_by_time("18:00").is_ok());
|
|
||||||
|
|
||||||
// MM - Until or Since depending on current time
|
|
||||||
assert!(parse_duration_by_time("45").is_ok());
|
|
||||||
|
|
||||||
// errors
|
|
||||||
assert!(parse_duration_by_time("60").is_err()); // invalid minutes
|
|
||||||
assert!(parse_duration_by_time("24:00").is_err()); // invalid hours
|
|
||||||
assert!(parse_duration_by_time("24:00:00").is_err()); // invalid hours
|
|
||||||
assert!(parse_duration_by_time("2030-13-01 12:00:00").is_err()); // invalid month
|
|
||||||
assert!(parse_duration_by_time("2030-06-32 12:00:00").is_err()); // invalid day
|
|
||||||
assert!(parse_duration_by_time("abc").is_err()); // invalid input
|
|
||||||
assert!(parse_duration_by_time("01:02:03:04").is_err()); // too many parts
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_long_duration() {
|
fn test_parse_long_duration() {
|
||||||
// `Yy`
|
// `Yy`
|
||||||
@ -607,11 +678,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// MAX_DURATION clamping
|
// MAX_DURATION clamping
|
||||||
assert_eq!(parse_long_duration("1000y").unwrap(), MAX_DURATION);
|
assert_eq!(parse_long_duration("10000y").unwrap(), MAX_DURATION);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_long_duration("999y 364d 23:59:59").unwrap(),
|
parse_long_duration("9999y 364d 23:59:59").unwrap(),
|
||||||
Duration::from_secs(
|
Duration::from_secs(
|
||||||
999 * YEAR_IN_SECONDS
|
9999 * YEAR_IN_SECONDS
|
||||||
+ 364 * DAY_IN_SECONDS
|
+ 364 * DAY_IN_SECONDS
|
||||||
+ 23 * HOUR_IN_SECONDS
|
+ 23 * HOUR_IN_SECONDS
|
||||||
+ 59 * MINUTE_IN_SECONDS
|
+ 59 * MINUTE_IN_SECONDS
|
||||||
@ -627,4 +698,155 @@ mod tests {
|
|||||||
assert!(parse_long_duration("1y 2d 3d 4:00").is_err()); // too many parts (4 parts)
|
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)
|
assert!(parse_long_duration("1y 2d 3h 4m 5s").is_err()); // too many parts (5 parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_leap_year() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// 2024 is a leap year (366 days)
|
||||||
|
let start = datetime!(2024-01-01 00:00:00 UTC);
|
||||||
|
let end = datetime!(2025-01-01 00:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 1, "Should be exactly 1 year");
|
||||||
|
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
|
||||||
|
assert_eq!(cal_dur.days(), 366, "2024 has 366 days (leap year)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_non_leap_year() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// 2023 is not a leap year (365 days)
|
||||||
|
let start = datetime!(2023-01-01 00:00:00 UTC);
|
||||||
|
let end = datetime!(2024-01-01 00:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 1, "Should be exactly 1 year");
|
||||||
|
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
|
||||||
|
assert_eq!(cal_dur.days(), 365, "2023 has 365 days (non-leap year)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_partial_year_with_leap_day() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// Span including Feb 29, 2024
|
||||||
|
let start = datetime!(2024-02-01 00:00:00 UTC);
|
||||||
|
let end = datetime!(2024-03-15 00:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 0, "Should be 0 years");
|
||||||
|
// Feb 2024 has 29 days, so: 29 days (rest of Feb) + 15 days (March) = 44 days
|
||||||
|
assert_eq!(
|
||||||
|
cal_dur.days(),
|
||||||
|
43,
|
||||||
|
"Should be 43 days (29 in Feb + 14 partial March)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_partial_year_without_leap_day() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// Same dates but in 2023 (non-leap year)
|
||||||
|
let start = datetime!(2023-02-01 00:00:00 UTC);
|
||||||
|
let end = datetime!(2023-03-15 00:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 0, "Should be 0 years");
|
||||||
|
// Feb 2023 has 28 days, so: 28 days (rest of Feb) + 15 days (March) = 43 days
|
||||||
|
assert_eq!(
|
||||||
|
cal_dur.days(),
|
||||||
|
42,
|
||||||
|
"Should be 42 days (28 in Feb + 14 partial March)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_multiple_years_spanning_leap_years() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// From 2023 (non-leap) through 2024 (leap) to 2025
|
||||||
|
let start = datetime!(2023-03-01 10:00:00 UTC);
|
||||||
|
let end = datetime!(2025-03-01 10:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 2, "Should be exactly 2 years");
|
||||||
|
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
|
||||||
|
// Total days: 365 (2023 partial + 2024 partial) + 366 (full 2024 year conceptually included)
|
||||||
|
// Actually: From 2023-03-01 to 2025-03-01 = 365 + 366 = 731 days
|
||||||
|
assert_eq!(cal_dur.days(), 731, "Should be 731 total days");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_year_boundary() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// Test incomplete year - just before year boundary
|
||||||
|
let start = datetime!(2024-01-01 00:00:00 UTC);
|
||||||
|
let end = datetime!(2024-12-31 23:59:59 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 0, "Should be 0 years (not complete)");
|
||||||
|
assert_eq!(cal_dur.days(), 365, "Should be 365 days");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_hours_minutes_seconds() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
let start = datetime!(2024-01-01 10:30:45 UTC);
|
||||||
|
let end = datetime!(2024-01-02 14:25:50 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 0);
|
||||||
|
assert_eq!(cal_dur.days(), 1);
|
||||||
|
assert_eq!(cal_dur.hours_mod(), 3, "Should be 3 hours past midnight");
|
||||||
|
assert_eq!(cal_dur.minutes_mod(), 55, "Should be 55 minutes");
|
||||||
|
assert_eq!(cal_dur.seconds_mod(), 5, "Should be 5 seconds");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_reversed_dates() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
// CalendarDuration::between should handle reversed order
|
||||||
|
let later = datetime!(2025-01-01 00:00:00 UTC);
|
||||||
|
let earlier = datetime!(2024-01-01 00:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(later, earlier);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 1, "Should still calculate 1 year");
|
||||||
|
assert_eq!(cal_dur.days(), 366, "Should still be 366 days");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_same_date() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
let date = datetime!(2024-06-15 12:00:00 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(date, date);
|
||||||
|
|
||||||
|
assert_eq!(cal_dur.years(), 0);
|
||||||
|
assert_eq!(cal_dur.days(), 0);
|
||||||
|
assert_eq!(cal_dur.hours(), 0);
|
||||||
|
assert_eq!(cal_dur.minutes(), 0);
|
||||||
|
assert_eq!(cal_dur.seconds(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_duration_deciseconds() {
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
let start = datetime!(2024-01-01 00:00:00.000 UTC);
|
||||||
|
let end = datetime!(2024-01-01 00:00:00.750 UTC);
|
||||||
|
let cal_dur = CalendarDuration::from_start_end_times(start, end);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cal_dur.decis(),
|
||||||
|
7,
|
||||||
|
"Should be 7 deciseconds (750ms = 7.5 decis, truncated to 7)"
|
||||||
|
);
|
||||||
|
assert_eq!(cal_dur.millis(), 750, "Should be 750 milliseconds");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
src/event.rs
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::macros::{datetime, format_description};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Event {
|
||||||
|
pub date_time: time::PrimitiveDateTime,
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Event {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// Mario Bros. "...entered mass production in Japan on June 21" 1983
|
||||||
|
// https://en.wikipedia.org/wiki/Mario_Bros.#Release
|
||||||
|
date_time: datetime!(1983-06-21 00:00),
|
||||||
|
title: Some("Release date of Mario Bros. in Japan".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `Event`
|
||||||
|
/// Supports two formats:
|
||||||
|
/// (1) "YYYY-MM-DD HH:MM:SS"
|
||||||
|
/// (2) "time=YYYY-MM-DD HH:MM:SS,title=my event"
|
||||||
|
pub fn parse_event(s: &str) -> Result<Event, String> {
|
||||||
|
let s = s.trim();
|
||||||
|
|
||||||
|
// check + parse (2)
|
||||||
|
if s.contains('=') {
|
||||||
|
parse_event_key_value(s)
|
||||||
|
} else {
|
||||||
|
// parse (1)
|
||||||
|
parse_event_date_time(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `Event` based on "YYYY-MM-DD HH:MM:SS" format
|
||||||
|
fn parse_event_date_time(s: &str) -> Result<Event, String> {
|
||||||
|
let time = time::PrimitiveDateTime::parse(
|
||||||
|
s,
|
||||||
|
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to parse event date_time '{}': {}. Expected format: 'YYYY-MM-DD HH:MM:SS'",
|
||||||
|
s, e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Event {
|
||||||
|
date_time: time,
|
||||||
|
title: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an `Event` defined by a `key=value` pair.
|
||||||
|
/// Valid keys: `time` and `title`.
|
||||||
|
/// Format: "time=YYYY-MM-DD HH:MM:SS,title=my event"
|
||||||
|
fn parse_event_key_value(s: &str) -> Result<Event, String> {
|
||||||
|
let mut time_str = None;
|
||||||
|
let mut title_str = None;
|
||||||
|
|
||||||
|
// k/v pairs are splitted by commas
|
||||||
|
for part in s.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some((key, value)) = part.split_once('=') {
|
||||||
|
match key.trim() {
|
||||||
|
"time" => time_str = Some(value.trim()),
|
||||||
|
"title" => title_str = Some(value.trim()),
|
||||||
|
unknown => {
|
||||||
|
return Err(format!(
|
||||||
|
"Unknown key '{}'. Valid keys: 'time', 'title'",
|
||||||
|
unknown
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid key=value pair: '{}'. Expected format: 'key=value'",
|
||||||
|
part
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_str = time_str.ok_or(
|
||||||
|
"Missing required 'time' field. Expected format: 'time=YYYY-MM-DD HH:MM:SS[,title=...]'",
|
||||||
|
)?;
|
||||||
|
let time = time::PrimitiveDateTime::parse(
|
||||||
|
time_str,
|
||||||
|
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to parse event time '{}': {}. Expected format: 'YYYY-MM-DD HH:MM:SS'",
|
||||||
|
time_str, e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let title = title_str.filter(|t| !t.is_empty()).map(|t| t.to_string());
|
||||||
|
|
||||||
|
Ok(Event {
|
||||||
|
date_time: time,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_event() {
|
||||||
|
// Simple format: time only
|
||||||
|
let result = parse_event("2024-01-01 14:30:00").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2024-01-01 14:30:00));
|
||||||
|
assert_eq!(result.title, None);
|
||||||
|
|
||||||
|
// Simple format: with leading/trailing whitespace (outer trim works)
|
||||||
|
let result = parse_event(" 2025-12-25 12:30:00 ").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-12-25 12:30:00));
|
||||||
|
assert_eq!(result.title, None);
|
||||||
|
|
||||||
|
// Key=value format: time only
|
||||||
|
let result = parse_event("time=2025-10-10 14:30:00").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
|
||||||
|
assert_eq!(result.title, None);
|
||||||
|
|
||||||
|
// Key=value format: time and title
|
||||||
|
let result = parse_event("time=2025-10-10 14:30:00,title=Team Meeting").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
|
||||||
|
assert_eq!(result.title, Some("Team Meeting".to_string()));
|
||||||
|
|
||||||
|
// Key=value format: order independent
|
||||||
|
let result = parse_event("title=Stand-up,time=2025-10-10 09:00:00").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-10-10 09:00:00));
|
||||||
|
assert_eq!(result.title, Some("Stand-up".to_string()));
|
||||||
|
|
||||||
|
// Key=value format: title with spaces and special chars
|
||||||
|
let result =
|
||||||
|
parse_event("time=2025-10-10 14:30:00,title=Sprint Planning: Q1 Review").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
|
||||||
|
assert_eq!(result.title, Some("Sprint Planning: Q1 Review".to_string()));
|
||||||
|
|
||||||
|
// Key=value format: empty title treated as None
|
||||||
|
let result = parse_event("time=2025-10-10 14:30:00,title=").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
|
||||||
|
assert_eq!(result.title, None);
|
||||||
|
|
||||||
|
// Key=value format: whitespace handling
|
||||||
|
let result = parse_event(" time = 2025-10-10 14:30:00 , title = My Event ").unwrap();
|
||||||
|
assert_eq!(result.date_time, datetime!(2025-10-10 14:30:00));
|
||||||
|
assert_eq!(result.title, Some("My Event".to_string()));
|
||||||
|
|
||||||
|
// Error cases: invalid time format
|
||||||
|
assert!(parse_event("2025-13-01 00:00:00").is_err());
|
||||||
|
assert!(parse_event("invalid").is_err());
|
||||||
|
assert!(parse_event("2025/10/10 14:30:00").is_err());
|
||||||
|
|
||||||
|
// Error cases: missing time in key=value format
|
||||||
|
assert!(parse_event("title=My Event").is_err());
|
||||||
|
|
||||||
|
// Error cases: unknown key
|
||||||
|
assert!(parse_event("time=2025-10-10 14:30:00,foo=bar").is_err());
|
||||||
|
|
||||||
|
// Error cases: malformed key=value pair
|
||||||
|
assert!(parse_event("time=2025-10-10 14:30:00,notapair").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
|
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind};
|
||||||
use futures::{Stream, StreamExt};
|
use futures::{Stream, StreamExt};
|
||||||
|
use ratatui::layout::Position;
|
||||||
use std::{pin::Pin, time::Duration};
|
use std::{pin::Pin, time::Duration};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
@ -20,13 +21,13 @@ pub enum TuiEvent {
|
|||||||
Error,
|
Error,
|
||||||
Tick,
|
Tick,
|
||||||
Render,
|
Render,
|
||||||
Key(KeyEvent),
|
Crossterm(CrosstermEvent),
|
||||||
Resize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
ClockDone(ClockTypeId, String),
|
ClockDone(ClockTypeId, String),
|
||||||
|
SetCursor(Option<Position>),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
|
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
|
||||||
@ -89,14 +90,13 @@ fn crossterm_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
|
|||||||
EventStream::new()
|
EventStream::new()
|
||||||
.fuse()
|
.fuse()
|
||||||
// we are not interested in all events
|
// we are not interested in all events
|
||||||
.filter_map(|event| async move {
|
.filter_map(|result| async move {
|
||||||
match event {
|
match result {
|
||||||
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
|
// filter `KeyEventKind::Press` out to ignore all the other `CrosstermEvent::Key` events
|
||||||
Some(TuiEvent::Key(key))
|
Ok(CrosstermEvent::Key(key)) => (key.kind == KeyEventKind::Press)
|
||||||
}
|
.then_some(TuiEvent::Crossterm(CrosstermEvent::Key(key))),
|
||||||
Ok(CrosstermEvent::Resize(_, _)) => Some(TuiEvent::Resize),
|
Ok(other) => Some(TuiEvent::Crossterm(other)),
|
||||||
Err(_) => Some(TuiEvent::Error),
|
Err(_) => Some(TuiEvent::Error),
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ mod app;
|
|||||||
mod common;
|
mod common;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
mod event;
|
||||||
mod events;
|
mod events;
|
||||||
mod logging;
|
mod logging;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::{AppTimeFormat, Content, Style, Toggle},
|
common::{AppTimeFormat, Content, Style, Toggle},
|
||||||
|
event::Event,
|
||||||
widgets::pomodoro::Mode as PomodoroMode,
|
widgets::pomodoro::Mode as PomodoroMode,
|
||||||
};
|
};
|
||||||
use color_eyre::eyre::Result;
|
use color_eyre::eyre::Result;
|
||||||
@ -44,6 +45,8 @@ pub struct AppStorage {
|
|||||||
pub elapsed_value_countdown: Duration,
|
pub elapsed_value_countdown: Duration,
|
||||||
// timer
|
// timer
|
||||||
pub current_value_timer: Duration,
|
pub current_value_timer: Duration,
|
||||||
|
// event
|
||||||
|
pub event: Event,
|
||||||
// footer
|
// footer
|
||||||
pub footer_app_time: Toggle,
|
pub footer_app_time: Toggle,
|
||||||
}
|
}
|
||||||
@ -75,6 +78,8 @@ impl Default for AppStorage {
|
|||||||
elapsed_value_countdown: Duration::ZERO,
|
elapsed_value_countdown: Duration::ZERO,
|
||||||
// timer
|
// timer
|
||||||
current_value_timer: Duration::ZERO,
|
current_value_timer: Duration::ZERO,
|
||||||
|
// event
|
||||||
|
event: Event::default(),
|
||||||
// footer
|
// footer
|
||||||
footer_app_time: Toggle::Off,
|
footer_app_time: Toggle::Off,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ pub mod clock_elements_test;
|
|||||||
pub mod clock_test;
|
pub mod clock_test;
|
||||||
pub mod countdown;
|
pub mod countdown;
|
||||||
pub mod edit_time;
|
pub mod edit_time;
|
||||||
|
pub mod event;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod local_time;
|
pub mod local_time;
|
||||||
|
|||||||
@ -11,11 +11,12 @@ use ratatui::{
|
|||||||
widgets::{StatefulWidget, Widget},
|
widgets::{StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::widgets::clock_elements::FOUR_DIGITS_WIDTH;
|
||||||
use crate::{
|
use crate::{
|
||||||
common::{ClockTypeId, Style as DigitStyle},
|
common::{ClockTypeId, Style as DigitStyle},
|
||||||
duration::{
|
duration::{
|
||||||
DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND,
|
ClockDuration, DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE,
|
||||||
ONE_YEAR,
|
ONE_SECOND, ONE_YEAR,
|
||||||
},
|
},
|
||||||
events::{AppEvent, AppEventTx},
|
events::{AppEvent, AppEventTx},
|
||||||
utils::center_horizontal,
|
utils::center_horizontal,
|
||||||
@ -88,10 +89,19 @@ pub enum Format {
|
|||||||
YyyDHhMmSs,
|
YyyDHhMmSs,
|
||||||
YyyDdHhMmSs,
|
YyyDdHhMmSs,
|
||||||
YyyDddHhMmSs,
|
YyyDddHhMmSs,
|
||||||
|
YyyyDHhMmSs,
|
||||||
|
YyyyDdHhMmSs,
|
||||||
|
YyyyDddHhMmSs,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_by_duration(d: &DurationEx) -> Format {
|
pub fn format_by_duration<D: ClockDuration>(d: &D) -> Format {
|
||||||
if d.years() >= 100 && d.days_mod() >= 100 {
|
if d.years() >= 1000 && d.days_mod() >= 100 {
|
||||||
|
Format::YyyyDddHhMmSs
|
||||||
|
} else if d.years() >= 1000 && d.days_mod() >= 10 {
|
||||||
|
Format::YyyyDdHhMmSs
|
||||||
|
} else if d.years() >= 1000 && d.days() >= 1 {
|
||||||
|
Format::YyyyDHhMmSs
|
||||||
|
} else if d.years() >= 100 && d.days_mod() >= 100 {
|
||||||
Format::YyyDddHhMmSs
|
Format::YyyDddHhMmSs
|
||||||
} else if d.years() >= 100 && d.days_mod() >= 10 {
|
} else if d.years() >= 100 && d.days_mod() >= 10 {
|
||||||
Format::YyyDdHhMmSs
|
Format::YyyDdHhMmSs
|
||||||
@ -140,7 +150,10 @@ pub fn time_by_format(format: &Format) -> Time {
|
|||||||
| Format::YyDHhMmSs
|
| Format::YyDHhMmSs
|
||||||
| Format::YyyDddHhMmSs
|
| Format::YyyDddHhMmSs
|
||||||
| Format::YyyDdHhMmSs
|
| Format::YyyDdHhMmSs
|
||||||
| Format::YyyDHhMmSs => Time::Years,
|
| Format::YyyDHhMmSs
|
||||||
|
| Format::YyyyDddHhMmSs
|
||||||
|
| Format::YyyyDdHhMmSs
|
||||||
|
| Format::YyyyDHhMmSs => Time::Years,
|
||||||
Format::DddHhMmSs | Format::DdHhMmSs | Format::DHhMmSs => Time::Days,
|
Format::DddHhMmSs | Format::DdHhMmSs | Format::DHhMmSs => Time::Days,
|
||||||
Format::HhMmSs | Format::HMmSs => Time::Hours,
|
Format::HhMmSs | Format::HMmSs => Time::Hours,
|
||||||
Format::MmSs | Format::MSs => Time::Minutes,
|
Format::MmSs | Format::MSs => Time::Minutes,
|
||||||
@ -161,7 +174,7 @@ pub fn count_by_mode(times: u32, mode: &Mode) -> Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RANGE_OF_DONE_COUNT: u64 = 4;
|
const RANGE_OF_DONE_COUNT: u64 = 4;
|
||||||
const MAX_DONE_COUNT: u64 = RANGE_OF_DONE_COUNT * 5;
|
pub const MAX_DONE_COUNT: u64 = RANGE_OF_DONE_COUNT * 5;
|
||||||
|
|
||||||
pub struct ClockState<T> {
|
pub struct ClockState<T> {
|
||||||
type_id: ClockTypeId,
|
type_id: ClockTypeId,
|
||||||
@ -437,7 +450,7 @@ impl<T> ClockState<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_format(&mut self) {
|
fn update_format(&mut self) {
|
||||||
let d = self.get_current_value();
|
let d: &DurationEx = self.get_current_value();
|
||||||
self.format = format_by_duration(d);
|
self.format = format_by_duration(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,18 +460,17 @@ impl<T> ClockState<T> {
|
|||||||
/// `tick` won't be called again after `Mode::Done` event (e.g. in `widget::Countdown`).
|
/// `tick` won't be called again after `Mode::Done` event (e.g. in `widget::Countdown`).
|
||||||
/// That's why `update_done_count` is called from "outside".
|
/// That's why `update_done_count` is called from "outside".
|
||||||
pub fn update_done_count(&mut self) {
|
pub fn update_done_count(&mut self) {
|
||||||
if let Some(count) = self.done_count {
|
self.done_count = count_clock_done(self.done_count);
|
||||||
if count > 0 {
|
|
||||||
let value = count - 1;
|
|
||||||
self.done_count = Some(value)
|
|
||||||
} else {
|
|
||||||
// None means we are done and no counting anymore.
|
|
||||||
self.done_count = None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Safe way to count a possible `done` value
|
||||||
|
pub fn count_clock_done(value: Option<u64>) -> Option<u64> {
|
||||||
|
// Safe substraction for `Some(value > 1)`
|
||||||
|
// `None` means `done` == no counting anymore.
|
||||||
|
value.and_then(|count| count.checked_sub(1))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Countdown {}
|
pub struct Countdown {}
|
||||||
|
|
||||||
@ -642,7 +654,29 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_horizontal_lengths(&self, format: &Format, with_decis: bool) -> Vec<u16> {
|
pub fn get_width(&self, format: &Format, with_decis: bool) -> u16 {
|
||||||
|
clock_horizontal_lengths(format, with_decis).iter().sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_height(&self) -> u16 {
|
||||||
|
DIGIT_HEIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to check whether to blink the clock while rendering.
|
||||||
|
/// Its logic is based on a given `count` value.
|
||||||
|
pub fn should_blink(count_value: Option<u64>) -> bool {
|
||||||
|
// Example:
|
||||||
|
// if `RANGE_OF_DONE_COUNT` is 4
|
||||||
|
// then for ranges `0..4`, `8..12` etc. it will return `true`
|
||||||
|
count_value
|
||||||
|
.map(|b| (b % (RANGE_OF_DONE_COUNT * 2)) < RANGE_OF_DONE_COUNT)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get horizontal lengths of a clock
|
||||||
|
// depending on given `Format` and `with_decis` params
|
||||||
|
pub fn clock_horizontal_lengths(format: &Format, with_decis: bool) -> Vec<u16> {
|
||||||
let add_decis = |mut lengths: Vec<u16>, with_decis: bool| -> Vec<u16> {
|
let add_decis = |mut lengths: Vec<u16>, with_decis: bool| -> Vec<u16> {
|
||||||
if with_decis {
|
if with_decis {
|
||||||
lengths.extend_from_slice(&[
|
lengths.extend_from_slice(&[
|
||||||
@ -656,6 +690,48 @@ where
|
|||||||
const LABEL_WIDTH: u16 = DIGIT_LABEL_WIDTH + DIGIT_SPACE_WIDTH;
|
const LABEL_WIDTH: u16 = DIGIT_LABEL_WIDTH + DIGIT_SPACE_WIDTH;
|
||||||
|
|
||||||
match format {
|
match format {
|
||||||
|
Format::YyyyDddHhMmSs => add_decis(
|
||||||
|
vec![
|
||||||
|
FOUR_DIGITS_WIDTH, // y_y_y_y
|
||||||
|
LABEL_WIDTH, // _l__
|
||||||
|
THREE_DIGITS_WIDTH, // d_d_d
|
||||||
|
LABEL_WIDTH, // _l__
|
||||||
|
TWO_DIGITS_WIDTH, // h_h
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
TWO_DIGITS_WIDTH, // m_m
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
TWO_DIGITS_WIDTH, // s_s
|
||||||
|
],
|
||||||
|
with_decis,
|
||||||
|
),
|
||||||
|
Format::YyyyDdHhMmSs => add_decis(
|
||||||
|
vec![
|
||||||
|
FOUR_DIGITS_WIDTH, // y_y_y_y
|
||||||
|
LABEL_WIDTH, // _l__
|
||||||
|
TWO_DIGITS_WIDTH, // d_d
|
||||||
|
LABEL_WIDTH, // _l__
|
||||||
|
TWO_DIGITS_WIDTH, // h_h
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
TWO_DIGITS_WIDTH, // m_m
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
TWO_DIGITS_WIDTH, // s_s
|
||||||
|
],
|
||||||
|
with_decis,
|
||||||
|
),
|
||||||
|
Format::YyyyDHhMmSs => add_decis(
|
||||||
|
vec![
|
||||||
|
FOUR_DIGITS_WIDTH, // y_y_y_y
|
||||||
|
LABEL_WIDTH, // _l__
|
||||||
|
DIGIT_WIDTH, // d
|
||||||
|
LABEL_WIDTH, // _l__
|
||||||
|
TWO_DIGITS_WIDTH, // h_h
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
TWO_DIGITS_WIDTH, // m_m
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
TWO_DIGITS_WIDTH, // s_s
|
||||||
|
],
|
||||||
|
with_decis,
|
||||||
|
),
|
||||||
Format::YyyDddHhMmSs => add_decis(
|
Format::YyyDddHhMmSs => add_decis(
|
||||||
vec![
|
vec![
|
||||||
THREE_DIGITS_WIDTH, // y_y_y
|
THREE_DIGITS_WIDTH, // y_y_y
|
||||||
@ -868,55 +944,52 @@ where
|
|||||||
with_decis,
|
with_decis,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_width(&self, format: &Format, with_decis: bool) -> u16 {
|
|
||||||
self.get_horizontal_lengths(format, with_decis).iter().sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_height(&self) -> u16 {
|
|
||||||
DIGIT_HEIGHT
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks whether to blink the clock while rendering.
|
|
||||||
/// Its logic is based on a given `count` value.
|
|
||||||
fn should_blink(&self, count_value: &Option<u64>) -> bool {
|
|
||||||
// Example:
|
|
||||||
// if `RANGE_OF_DONE_COUNT` is 4
|
|
||||||
// then for ranges `0..4`, `8..12` etc. it will return `true`
|
|
||||||
count_value
|
|
||||||
.map(|b| (b % (RANGE_OF_DONE_COUNT * 2)) < RANGE_OF_DONE_COUNT)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> StatefulWidget for ClockWidget<T>
|
// State to render a clock
|
||||||
where
|
pub struct RenderClockState<'a, D: ClockDuration> {
|
||||||
T: std::fmt::Debug,
|
pub format: Format,
|
||||||
{
|
pub editable_time: Option<Time>,
|
||||||
type State = ClockState<T>;
|
pub with_decis: bool,
|
||||||
|
pub symbol: &'a str,
|
||||||
|
pub widths: Vec<u16>,
|
||||||
|
pub duration: D,
|
||||||
|
}
|
||||||
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
// Helper to render a clock
|
||||||
let with_decis = state.with_decis;
|
pub fn render_clock<D: ClockDuration>(area: Rect, buf: &mut Buffer, state: RenderClockState<D>) {
|
||||||
let format = state.format;
|
let RenderClockState {
|
||||||
// to simulate a blink effect, just use an "empty" symbol (string)
|
format,
|
||||||
// to "empty" all digits and to have an "empty" render area
|
with_decis,
|
||||||
let symbol = if self.blink && self.should_blink(&state.done_count) {
|
symbol,
|
||||||
" "
|
widths,
|
||||||
} else {
|
editable_time,
|
||||||
self.style.get_digit_symbol()
|
duration,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
let width = widths.iter().sum();
|
||||||
|
let area = center_horizontal(area, Constraint::Length(width));
|
||||||
|
|
||||||
|
let edit_years = matches!(editable_time, Some(Time::Years));
|
||||||
|
let edit_days = matches!(editable_time, Some(Time::Days));
|
||||||
|
let edit_hours = matches!(editable_time, Some(Time::Hours));
|
||||||
|
let edit_minutes = matches!(editable_time, Some(Time::Minutes));
|
||||||
|
let edit_secs = matches!(editable_time, Some(Time::Seconds));
|
||||||
|
let edit_decis = matches!(editable_time, Some(Time::Decis));
|
||||||
|
|
||||||
|
let render_four_digits = |d1, d2, d3, d4, editable, area, buf: &mut Buffer| {
|
||||||
|
let [a1, a2, a3, a4] = Layout::horizontal(Constraint::from_lengths([
|
||||||
|
DIGIT_WIDTH + DIGIT_SPACE_WIDTH,
|
||||||
|
DIGIT_WIDTH + DIGIT_SPACE_WIDTH,
|
||||||
|
DIGIT_WIDTH + DIGIT_SPACE_WIDTH,
|
||||||
|
DIGIT_WIDTH,
|
||||||
|
]))
|
||||||
|
.areas(area);
|
||||||
|
Digit::new(d1, editable, symbol).render(a1, buf);
|
||||||
|
Digit::new(d2, editable, symbol).render(a2, buf);
|
||||||
|
Digit::new(d3, editable, symbol).render(a3, buf);
|
||||||
|
Digit::new(d4, editable, symbol).render(a4, buf);
|
||||||
};
|
};
|
||||||
let widths = self.get_horizontal_lengths(&format, with_decis);
|
|
||||||
let area = center_horizontal(
|
|
||||||
area,
|
|
||||||
Constraint::Length(self.get_width(&format, with_decis)),
|
|
||||||
);
|
|
||||||
let edit_years = matches!(state.mode, Mode::Editable(Time::Years, _));
|
|
||||||
let edit_days = matches!(state.mode, Mode::Editable(Time::Days, _));
|
|
||||||
let edit_hours = matches!(state.mode, Mode::Editable(Time::Hours, _));
|
|
||||||
let edit_minutes = matches!(state.mode, Mode::Editable(Time::Minutes, _));
|
|
||||||
let edit_secs = matches!(state.mode, Mode::Editable(Time::Seconds, _));
|
|
||||||
let edit_decis = matches!(state.mode, Mode::Editable(Time::Decis, _));
|
|
||||||
|
|
||||||
let render_three_digits = |d1, d2, d3, editable, area, buf: &mut Buffer| {
|
let render_three_digits = |d1, d2, d3, editable, area, buf: &mut Buffer| {
|
||||||
let [a1, a2, a3] = Layout::horizontal(Constraint::from_lengths([
|
let [a1, a2, a3] = Layout::horizontal(Constraint::from_lengths([
|
||||||
@ -948,11 +1021,23 @@ where
|
|||||||
Dot::new(symbol).render(area, buf);
|
Dot::new(symbol).render(area, buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let render_yyyy = |area, buf| {
|
||||||
|
render_four_digits(
|
||||||
|
(duration.years() / 1000) % 10,
|
||||||
|
(duration.years() / 100) % 10,
|
||||||
|
(duration.years() / 10) % 10,
|
||||||
|
duration.years() % 10,
|
||||||
|
edit_years,
|
||||||
|
area,
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
let render_yyy = |area, buf| {
|
let render_yyy = |area, buf| {
|
||||||
render_three_digits(
|
render_three_digits(
|
||||||
(state.current_value.years() / 100) % 10,
|
(duration.years() / 100) % 10,
|
||||||
(state.current_value.years() / 10) % 10,
|
(duration.years() / 10) % 10,
|
||||||
state.current_value.years() % 10,
|
duration.years() % 10,
|
||||||
edit_years,
|
edit_years,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -961,8 +1046,8 @@ where
|
|||||||
|
|
||||||
let render_yy = |area, buf| {
|
let render_yy = |area, buf| {
|
||||||
render_two_digits(
|
render_two_digits(
|
||||||
(state.current_value.years() / 10) % 10,
|
(duration.years() / 10) % 10,
|
||||||
state.current_value.years() % 10,
|
duration.years() % 10,
|
||||||
edit_years,
|
edit_years,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -970,14 +1055,14 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let render_y = |area, buf| {
|
let render_y = |area, buf| {
|
||||||
Digit::new(state.current_value.years() % 10, edit_years, symbol).render(area, buf);
|
Digit::new(duration.years() % 10, edit_years, symbol).render(area, buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
let render_ddd = |area, buf| {
|
let render_ddd = |area, buf| {
|
||||||
render_three_digits(
|
render_three_digits(
|
||||||
(state.current_value.days_mod() / 100) % 10,
|
(duration.days_mod() / 100) % 10,
|
||||||
(state.current_value.days_mod() / 10) % 10,
|
(duration.days_mod() / 10) % 10,
|
||||||
state.current_value.days_mod() % 10,
|
duration.days_mod() % 10,
|
||||||
edit_days,
|
edit_days,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -986,8 +1071,8 @@ where
|
|||||||
|
|
||||||
let render_dd = |area, buf| {
|
let render_dd = |area, buf| {
|
||||||
render_two_digits(
|
render_two_digits(
|
||||||
(state.current_value.days_mod() / 10) % 10,
|
(duration.days_mod() / 10) % 10,
|
||||||
state.current_value.days_mod() % 10,
|
duration.days_mod() % 10,
|
||||||
edit_days,
|
edit_days,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -995,13 +1080,13 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let render_d = |area, buf| {
|
let render_d = |area, buf| {
|
||||||
Digit::new(state.current_value.days_mod() % 10, edit_days, symbol).render(area, buf);
|
Digit::new(duration.days_mod() % 10, edit_days, symbol).render(area, buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
let render_hh = |area, buf| {
|
let render_hh = |area, buf| {
|
||||||
render_two_digits(
|
render_two_digits(
|
||||||
state.current_value.hours_mod() / 10,
|
duration.hours_mod() / 10,
|
||||||
state.current_value.hours_mod() % 10,
|
duration.hours_mod() % 10,
|
||||||
edit_hours,
|
edit_hours,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -1009,13 +1094,13 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let render_h = |area, buf| {
|
let render_h = |area, buf| {
|
||||||
Digit::new(state.current_value.hours_mod() % 10, edit_hours, symbol).render(area, buf);
|
Digit::new(duration.hours_mod() % 10, edit_hours, symbol).render(area, buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
let render_mm = |area, buf| {
|
let render_mm = |area, buf| {
|
||||||
render_two_digits(
|
render_two_digits(
|
||||||
state.current_value.minutes_mod() / 10,
|
duration.minutes_mod() / 10,
|
||||||
state.current_value.minutes_mod() % 10,
|
duration.minutes_mod() % 10,
|
||||||
edit_minutes,
|
edit_minutes,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -1023,14 +1108,13 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let render_m = |area, buf| {
|
let render_m = |area, buf| {
|
||||||
Digit::new(state.current_value.minutes_mod() % 10, edit_minutes, symbol)
|
Digit::new(duration.minutes_mod() % 10, edit_minutes, symbol).render(area, buf);
|
||||||
.render(area, buf);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let render_ss = |area, buf| {
|
let render_ss = |area, buf| {
|
||||||
render_two_digits(
|
render_two_digits(
|
||||||
state.current_value.seconds_mod() / 10,
|
duration.seconds_mod() / 10,
|
||||||
state.current_value.seconds_mod() % 10,
|
duration.seconds_mod() % 10,
|
||||||
edit_secs,
|
edit_secs,
|
||||||
area,
|
area,
|
||||||
buf,
|
buf,
|
||||||
@ -1038,11 +1122,11 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
let render_s = |area, buf| {
|
let render_s = |area, buf| {
|
||||||
Digit::new(state.current_value.seconds_mod() % 10, edit_secs, symbol).render(area, buf);
|
Digit::new(duration.seconds_mod() % 10, edit_secs, symbol).render(area, buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
let render_ds = |area, buf| {
|
let render_ds = |area, buf| {
|
||||||
Digit::new(state.current_value.decis(), edit_decis, symbol).render(area, buf);
|
Digit::new(duration.decis(), edit_decis, symbol).render(area, buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
let render_label = |l: &str, area, buf: &mut Buffer| {
|
let render_label = |l: &str, area, buf: &mut Buffer| {
|
||||||
@ -1062,6 +1146,90 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
match format {
|
match format {
|
||||||
|
Format::YyyyDddHhMmSs if with_decis => {
|
||||||
|
let [y_y_y_y, ly, d_d_d, ld, h_h, c_hm, m_m, c_ms, s_s, dot, ds] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
|
render_yyyy(y_y_y_y, buf);
|
||||||
|
render_label_y(ly, buf);
|
||||||
|
render_ddd(d_d_d, buf);
|
||||||
|
render_label_d(ld, buf);
|
||||||
|
render_hh(h_h, buf);
|
||||||
|
render_colon(c_hm, buf);
|
||||||
|
render_mm(m_m, buf);
|
||||||
|
render_colon(c_ms, buf);
|
||||||
|
render_ss(s_s, buf);
|
||||||
|
render_dot(dot, buf);
|
||||||
|
render_ds(ds, buf);
|
||||||
|
}
|
||||||
|
Format::YyyyDddHhMmSs => {
|
||||||
|
let [y_y_y_y, ly, d_d_d, ld, h_h, c_hm, m_m, c_ms, s_s] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
|
render_yyyy(y_y_y_y, buf);
|
||||||
|
render_label_y(ly, buf);
|
||||||
|
render_ddd(d_d_d, buf);
|
||||||
|
render_label_d(ld, buf);
|
||||||
|
render_hh(h_h, buf);
|
||||||
|
render_colon(c_hm, buf);
|
||||||
|
render_mm(m_m, buf);
|
||||||
|
render_colon(c_ms, buf);
|
||||||
|
render_ss(s_s, buf);
|
||||||
|
}
|
||||||
|
Format::YyyyDdHhMmSs if with_decis => {
|
||||||
|
let [y_y_y_y, ly, d_d, ld, h_h, c_hm, m_m, c_ms, s_s, dot, ds] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
|
render_yyyy(y_y_y_y, buf);
|
||||||
|
render_label_y(ly, buf);
|
||||||
|
render_dd(d_d, buf);
|
||||||
|
render_label_d(ld, buf);
|
||||||
|
render_hh(h_h, buf);
|
||||||
|
render_colon(c_hm, buf);
|
||||||
|
render_mm(m_m, buf);
|
||||||
|
render_colon(c_ms, buf);
|
||||||
|
render_ss(s_s, buf);
|
||||||
|
render_dot(dot, buf);
|
||||||
|
render_ds(ds, buf);
|
||||||
|
}
|
||||||
|
Format::YyyyDdHhMmSs => {
|
||||||
|
let [y_y_y_y, ly, d_d, ld, h_h, c_hm, m_m, c_ms, s_s] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
|
render_yyyy(y_y_y_y, buf);
|
||||||
|
render_label_y(ly, buf);
|
||||||
|
render_dd(d_d, buf);
|
||||||
|
render_label_d(ld, buf);
|
||||||
|
render_hh(h_h, buf);
|
||||||
|
render_colon(c_hm, buf);
|
||||||
|
render_mm(m_m, buf);
|
||||||
|
render_colon(c_ms, buf);
|
||||||
|
render_ss(s_s, buf);
|
||||||
|
}
|
||||||
|
Format::YyyyDHhMmSs if with_decis => {
|
||||||
|
let [y_y_y_y, ly, d, ld, h_h, c_hm, m_m, c_ms, s_s, dot, ds] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
|
render_yyyy(y_y_y_y, buf);
|
||||||
|
render_label_y(ly, buf);
|
||||||
|
render_d(d, buf);
|
||||||
|
render_label_d(ld, buf);
|
||||||
|
render_hh(h_h, buf);
|
||||||
|
render_colon(c_hm, buf);
|
||||||
|
render_mm(m_m, buf);
|
||||||
|
render_colon(c_ms, buf);
|
||||||
|
render_ss(s_s, buf);
|
||||||
|
render_dot(dot, buf);
|
||||||
|
render_ds(ds, buf);
|
||||||
|
}
|
||||||
|
Format::YyyyDHhMmSs => {
|
||||||
|
let [y_y_y_y, ly, d, ld, h_h, c_hm, m_m, c_ms, s_s] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
|
render_yyyy(y_y_y_y, buf);
|
||||||
|
render_label_y(ly, buf);
|
||||||
|
render_d(d, buf);
|
||||||
|
render_label_d(ld, buf);
|
||||||
|
render_hh(h_h, buf);
|
||||||
|
render_colon(c_hm, buf);
|
||||||
|
render_mm(m_m, buf);
|
||||||
|
render_colon(c_ms, buf);
|
||||||
|
render_ss(s_s, buf);
|
||||||
|
}
|
||||||
Format::YyyDddHhMmSs if with_decis => {
|
Format::YyyDddHhMmSs if with_decis => {
|
||||||
let [y_y_y, ly, d_d_d, ld, h_h, c_hm, m_m, c_ms, s_s, dot, ds] =
|
let [y_y_y, ly, d_d_d, ld, h_h, c_hm, m_m, c_ms, s_s, dot, ds] =
|
||||||
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
@ -1436,8 +1604,7 @@ where
|
|||||||
render_ds(ds, buf);
|
render_ds(ds, buf);
|
||||||
}
|
}
|
||||||
Format::MmSs => {
|
Format::MmSs => {
|
||||||
let [m_m, c_ms, s_s] =
|
let [m_m, c_ms, s_s] = Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
|
||||||
render_mm(m_m, buf);
|
render_mm(m_m, buf);
|
||||||
render_colon(c_ms, buf);
|
render_colon(c_ms, buf);
|
||||||
render_ss(s_s, buf);
|
render_ss(s_s, buf);
|
||||||
@ -1452,15 +1619,13 @@ where
|
|||||||
render_ds(ds, buf);
|
render_ds(ds, buf);
|
||||||
}
|
}
|
||||||
Format::MSs => {
|
Format::MSs => {
|
||||||
let [m, c_ms, s_s] =
|
let [m, c_ms, s_s] = Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
|
||||||
render_m(m, buf);
|
render_m(m, buf);
|
||||||
render_colon(c_ms, buf);
|
render_colon(c_ms, buf);
|
||||||
render_ss(s_s, buf);
|
render_ss(s_s, buf);
|
||||||
}
|
}
|
||||||
Format::Ss if state.with_decis => {
|
Format::Ss if state.with_decis => {
|
||||||
let [s_s, dot, ds] =
|
let [s_s, dot, ds] = Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
||||||
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
|
|
||||||
render_ss(s_s, buf);
|
render_ss(s_s, buf);
|
||||||
render_dot(dot, buf);
|
render_dot(dot, buf);
|
||||||
render_ds(ds, buf);
|
render_ds(ds, buf);
|
||||||
@ -1480,5 +1645,38 @@ where
|
|||||||
render_s(s, buf);
|
render_s(s, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> StatefulWidget for ClockWidget<T>
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
type State = ClockState<T>;
|
||||||
|
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
let with_decis = state.with_decis;
|
||||||
|
let format = state.format;
|
||||||
|
let widths = clock_horizontal_lengths(&format, with_decis);
|
||||||
|
|
||||||
|
// To simulate a blink effect, just use an "empty" symbol (string)
|
||||||
|
// It's "empty" all digits and creates an "empty" render area
|
||||||
|
let symbol = if self.blink && should_blink(state.done_count) {
|
||||||
|
" "
|
||||||
|
} else {
|
||||||
|
self.style.get_digit_symbol()
|
||||||
|
};
|
||||||
|
|
||||||
|
let render_state = RenderClockState {
|
||||||
|
with_decis,
|
||||||
|
duration: state.current_value,
|
||||||
|
editable_time: match state.get_mode() {
|
||||||
|
Mode::Editable(time, _) => Some(*time),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
format,
|
||||||
|
symbol,
|
||||||
|
widths,
|
||||||
|
};
|
||||||
|
render_clock(area, buf, render_state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@ pub const DIGIT_SIZE: usize = 5;
|
|||||||
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
|
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
|
||||||
pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */;
|
pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */;
|
||||||
pub const TWO_DIGITS_WIDTH: u16 = DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit
|
pub const TWO_DIGITS_WIDTH: u16 = DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit
|
||||||
pub const THREE_DIGITS_WIDTH: u16 =
|
pub const THREE_DIGITS_WIDTH: u16 = TWO_DIGITS_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit
|
||||||
DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit
|
pub const FOUR_DIGITS_WIDTH: u16 = THREE_DIGITS_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit-space-digit
|
||||||
pub const COLON_WIDTH: u16 = 4; // incl. padding left + padding right
|
pub const COLON_WIDTH: u16 = 4; // incl. padding left + padding right
|
||||||
pub const DOT_WIDTH: u16 = 4; // incl. padding left + padding right
|
pub const DOT_WIDTH: u16 = 4; // incl. padding left + padding right
|
||||||
pub const DIGIT_SPACE_WIDTH: u16 = 1; // space between digits
|
pub const DIGIT_SPACE_WIDTH: u16 = 1; // space between digits
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::ClockTypeId,
|
common::ClockTypeId,
|
||||||
duration::{
|
duration::{
|
||||||
MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND, ONE_YEAR,
|
DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND,
|
||||||
|
ONE_YEAR,
|
||||||
},
|
},
|
||||||
widgets::clock::*,
|
widgets::clock::*,
|
||||||
};
|
};
|
||||||
@ -76,115 +77,166 @@ fn test_get_format_hours() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_format_by_duration_boundaries() {
|
fn test_format_by_duration_boundaries() {
|
||||||
// S
|
// S
|
||||||
assert_eq!(format_by_duration(&(ONE_SECOND * 9).into()), Format::S);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(ONE_SECOND * 9).into()),
|
||||||
|
Format::S
|
||||||
|
);
|
||||||
// Ss
|
// Ss
|
||||||
assert_eq!(format_by_duration(&(10 * ONE_SECOND).into()), Format::Ss);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(10 * ONE_SECOND).into()),
|
||||||
|
Format::Ss
|
||||||
|
);
|
||||||
// Ss
|
// Ss
|
||||||
assert_eq!(format_by_duration(&(59 * ONE_SECOND).into()), Format::Ss);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(59 * ONE_SECOND).into()),
|
||||||
|
Format::Ss
|
||||||
|
);
|
||||||
// MSs
|
// MSs
|
||||||
assert_eq!(format_by_duration(&ONE_MINUTE.into()), Format::MSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&ONE_MINUTE.into()),
|
||||||
|
Format::MSs
|
||||||
|
);
|
||||||
// HhMmSs
|
// HhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
|
||||||
Format::HhMmSs
|
Format::HhMmSs
|
||||||
);
|
);
|
||||||
// DHhMmSs
|
// DHhMmSs
|
||||||
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&ONE_DAY.into()),
|
||||||
|
Format::DHhMmSs
|
||||||
|
);
|
||||||
// DHhMmSs
|
// DHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
Format::DHhMmSs
|
Format::DHhMmSs
|
||||||
);
|
);
|
||||||
// DdHhMmSs
|
// DdHhMmSs
|
||||||
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(10 * ONE_DAY).into()),
|
||||||
|
Format::DdHhMmSs
|
||||||
|
);
|
||||||
// DdHhMmSs
|
// DdHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
Format::DdHhMmSs
|
Format::DdHhMmSs
|
||||||
);
|
);
|
||||||
// DddHhMmSs
|
// DddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_DAY).into()),
|
||||||
Format::DddHhMmSs
|
Format::DddHhMmSs
|
||||||
);
|
);
|
||||||
// DddHhMmSs
|
// DddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
|
format_by_duration::<DurationEx>(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
|
||||||
Format::DddHhMmSs
|
Format::DddHhMmSs
|
||||||
);
|
);
|
||||||
// YDHhMmSs
|
// YDHhMmSs
|
||||||
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
|
||||||
|
Format::YDHhMmSs
|
||||||
|
);
|
||||||
// YDdHhMmSs
|
// YDdHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(
|
||||||
|
&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
|
||||||
|
),
|
||||||
Format::YDdHhMmSs
|
Format::YDdHhMmSs
|
||||||
);
|
);
|
||||||
// YDddHhMmSs
|
// YDddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
Format::YDddHhMmSs
|
Format::YDddHhMmSs
|
||||||
);
|
);
|
||||||
// YDddHhMmSs
|
// YDddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
|
||||||
Format::YDddHhMmSs
|
Format::YDddHhMmSs
|
||||||
);
|
);
|
||||||
// YyDHhMmSs
|
// YyDHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR).into()),
|
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
|
||||||
Format::YyDHhMmSs
|
Format::YyDHhMmSs
|
||||||
);
|
);
|
||||||
// YyDdHhMmSs
|
// YyDdHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
Format::YyDdHhMmSs
|
Format::YyDdHhMmSs
|
||||||
);
|
);
|
||||||
// YyDdHhMmSs
|
// YyDdHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(
|
||||||
|
&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
|
||||||
|
),
|
||||||
Format::YyDdHhMmSs
|
Format::YyDdHhMmSs
|
||||||
);
|
);
|
||||||
// YyDddHhMmSs
|
// YyDddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
Format::YyDddHhMmSs
|
Format::YyDddHhMmSs
|
||||||
);
|
);
|
||||||
// YyDddHhMmSs
|
// YyDddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
|
||||||
Format::YyDddHhMmSs
|
Format::YyDddHhMmSs
|
||||||
);
|
);
|
||||||
// YyyDHhMmSs
|
// YyyDHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
|
||||||
Format::YyyDHhMmSs
|
Format::YyyDHhMmSs
|
||||||
);
|
);
|
||||||
// YyyDdHhMmSs
|
// YyyDdHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
Format::YyyDdHhMmSs
|
Format::YyyDdHhMmSs
|
||||||
);
|
);
|
||||||
// YyyDdHhMmSs
|
// YyyDdHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
format_by_duration::<DurationEx>(
|
||||||
|
&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
|
||||||
|
),
|
||||||
Format::YyyDdHhMmSs
|
Format::YyyDdHhMmSs
|
||||||
);
|
);
|
||||||
// YyyDddHhMmSs
|
// YyyDddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
Format::YyyDddHhMmSs
|
Format::YyyDddHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// YyyyDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(1000 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
|
Format::YyyyDdHhMmSs
|
||||||
|
);
|
||||||
|
// YyyyDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(
|
||||||
|
&(1000 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
|
||||||
|
),
|
||||||
|
Format::YyyyDdHhMmSs
|
||||||
|
);
|
||||||
|
// YyyyDddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(1000 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YyyyDddHhMmSs
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_format_by_duration_days() {
|
fn test_format_by_duration_days() {
|
||||||
// DHhMmSs
|
// DHhMmSs
|
||||||
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&ONE_DAY.into()),
|
||||||
|
Format::DHhMmSs
|
||||||
|
);
|
||||||
// DdHhMmSs
|
// DdHhMmSs
|
||||||
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&(10 * ONE_DAY).into()),
|
||||||
|
Format::DdHhMmSs
|
||||||
|
);
|
||||||
// DddHhMmSs
|
// DddHhMmSs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(101 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(101 * ONE_DAY).into()),
|
||||||
Format::DddHhMmSs
|
Format::DddHhMmSs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -192,59 +244,62 @@ fn test_format_by_duration_days() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_format_by_duration_years() {
|
fn test_format_by_duration_years() {
|
||||||
// YDHhMmSs (1 year, 0 days)
|
// YDHhMmSs (1 year, 0 days)
|
||||||
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
|
assert_eq!(
|
||||||
|
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
|
||||||
|
Format::YDHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
// YDHhMmSs (1 year, 1 day)
|
// YDHhMmSs (1 year, 1 day)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_YEAR + ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(ONE_YEAR + ONE_DAY).into()),
|
||||||
Format::YDHhMmSs
|
Format::YDHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YDdHhMmSs (1 year, 10 days)
|
// YDdHhMmSs (1 year, 10 days)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_YEAR + 10 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
Format::YDdHhMmSs
|
Format::YDdHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YDddHhMmSs (1 year, 100 days)
|
// YDddHhMmSs (1 year, 100 days)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
Format::YDddHhMmSs
|
Format::YDddHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YyDHhMmSs (10 years)
|
// YyDHhMmSs (10 years)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR).into()),
|
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
|
||||||
Format::YyDHhMmSs
|
Format::YyDHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YyDdHhMmSs (10 years, 10 days)
|
// YyDdHhMmSs (10 years, 10 days)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
Format::YyDdHhMmSs
|
Format::YyDdHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YyDddHhMmSs (10 years, 100 days)
|
// YyDddHhMmSs (10 years, 100 days)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
Format::YyDddHhMmSs
|
Format::YyDddHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YyyDHhMmSs (100 years)
|
// YyyDHhMmSs (100 years)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
|
||||||
Format::YyyDHhMmSs
|
Format::YyyDHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YyyDdHhMmSs (100 years, 10 days)
|
// YyyDdHhMmSs (100 years, 10 days)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
Format::YyyDdHhMmSs
|
Format::YyyDdHhMmSs
|
||||||
);
|
);
|
||||||
|
|
||||||
// YyyDddHhMmSs (100 years, 100 days)
|
// YyyDddHhMmSs (100 years, 100 days)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
|
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
Format::YyyDddHhMmSs
|
Format::YyyDddHhMmSs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use crate::{
|
|||||||
edit_time::{EditTimeState, EditTimeStateArgs, EditTimeWidget},
|
edit_time::{EditTimeState, EditTimeStateArgs, EditTimeWidget},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::{Event as CrosstermEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
crossterm::event::KeyCode,
|
crossterm::event::KeyCode,
|
||||||
@ -163,7 +163,8 @@ impl TuiEventHandler for CountdownState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// EDIT CLOCK mode
|
// EDIT CLOCK mode
|
||||||
TuiEvent::Key(key) if self.is_clock_edit_mode() => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_clock_edit_mode() => {
|
||||||
|
match key.code {
|
||||||
// skip editing
|
// skip editing
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
// Important: set current value first
|
// Important: set current value first
|
||||||
@ -207,9 +208,11 @@ impl TuiEventHandler for CountdownState {
|
|||||||
self.clock.edit_down();
|
self.clock.edit_down();
|
||||||
}
|
}
|
||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
// EDIT LOCAL TIME mode
|
// EDIT LOCAL TIME mode
|
||||||
TuiEvent::Key(key) if self.is_time_edit_mode() => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_time_edit_mode() => {
|
||||||
|
match key.code {
|
||||||
// skip editing
|
// skip editing
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.edit_time = None;
|
self.edit_time = None;
|
||||||
@ -256,9 +259,10 @@ impl TuiEventHandler for CountdownState {
|
|||||||
self.edit_time.as_mut().unwrap().down();
|
self.edit_time.as_mut().unwrap().down();
|
||||||
}
|
}
|
||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
// default mode
|
// default mode
|
||||||
TuiEvent::Key(key) => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
// reset both clocks to use intial values
|
// reset both clocks to use intial values
|
||||||
self.clock.reset();
|
self.clock.reset();
|
||||||
|
|||||||
589
src/widgets/event.rs
Normal file
@ -0,0 +1,589 @@
|
|||||||
|
use color_eyre::{Report, eyre::eyre};
|
||||||
|
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Layout, Position, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Paragraph, StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
|
||||||
|
use tui_input::Input;
|
||||||
|
use tui_input::backend::crossterm::EventHandler;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
common::{AppTime, ClockTypeId, Style as DigitStyle},
|
||||||
|
duration::CalendarDuration,
|
||||||
|
event::Event,
|
||||||
|
events::{AppEvent, AppEventTx, TuiEvent, TuiEventHandler},
|
||||||
|
utils::center,
|
||||||
|
widgets::{clock, clock_elements::DIGIT_HEIGHT},
|
||||||
|
};
|
||||||
|
use std::{cmp::max, time::Duration};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
enum Editable {
|
||||||
|
#[default]
|
||||||
|
DateTime,
|
||||||
|
Title,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editable {
|
||||||
|
pub fn next(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Editable::DateTime => Editable::Title,
|
||||||
|
Editable::Title => Editable::DateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Editable::DateTime => Editable::Title,
|
||||||
|
Editable::Title => Editable::DateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum EditMode {
|
||||||
|
None,
|
||||||
|
Editing(Editable),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditMode {
|
||||||
|
fn is_editable(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
EditMode::None => false,
|
||||||
|
EditMode::Editing(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for `EventWidget`
|
||||||
|
pub struct EventState {
|
||||||
|
title: Option<String>,
|
||||||
|
event_time: OffsetDateTime,
|
||||||
|
app_time: OffsetDateTime,
|
||||||
|
start_time: OffsetDateTime,
|
||||||
|
with_decis: bool,
|
||||||
|
/// counter to simulate `DONE` state
|
||||||
|
/// Default value: `None`
|
||||||
|
done_count: Option<u64>,
|
||||||
|
app_tx: AppEventTx,
|
||||||
|
// inputs
|
||||||
|
input_datetime: Input,
|
||||||
|
input_datetime_error: Option<Report>,
|
||||||
|
input_title: Input,
|
||||||
|
input_title_error: Option<Report>,
|
||||||
|
edit_mode: EditMode,
|
||||||
|
last_editable: Editable,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventStateArgs {
|
||||||
|
pub app_time: AppTime,
|
||||||
|
pub event: Event,
|
||||||
|
pub with_decis: bool,
|
||||||
|
pub app_tx: AppEventTx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventState {
|
||||||
|
pub fn new(args: EventStateArgs) -> Self {
|
||||||
|
let EventStateArgs {
|
||||||
|
app_time,
|
||||||
|
event,
|
||||||
|
with_decis,
|
||||||
|
app_tx,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
let app_datetime = OffsetDateTime::from(app_time);
|
||||||
|
// assume event has as same `offset` as `app_time`
|
||||||
|
let event_offset = event.date_time.assume_offset(app_datetime.offset());
|
||||||
|
let input_datetime_value = format_offsetdatetime(&event_offset);
|
||||||
|
let input_title_value = event.title.clone().unwrap_or("".into());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
title: event.title.clone(),
|
||||||
|
event_time: event_offset,
|
||||||
|
app_time: app_datetime,
|
||||||
|
start_time: app_datetime,
|
||||||
|
with_decis,
|
||||||
|
done_count: None,
|
||||||
|
app_tx,
|
||||||
|
input_datetime: Input::default().with_value(input_datetime_value),
|
||||||
|
input_datetime_error: None,
|
||||||
|
input_title: Input::default().with_value(input_title_value),
|
||||||
|
input_title_error: None,
|
||||||
|
edit_mode: EditMode::None,
|
||||||
|
last_editable: Editable::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets `app_time`
|
||||||
|
pub fn set_app_time(&mut self, app_time: AppTime) {
|
||||||
|
let app_datetime = OffsetDateTime::from(app_time);
|
||||||
|
self.app_time = app_datetime;
|
||||||
|
|
||||||
|
// Since updating `app_time` is like a `Tick`, we check `done` state here
|
||||||
|
self.check_done();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_with_decis(&mut self, with_decis: bool) {
|
||||||
|
self.with_decis = with_decis;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_event(&self) -> Event {
|
||||||
|
Event {
|
||||||
|
title: self.title.clone(),
|
||||||
|
date_time: time::PrimitiveDateTime::new(self.event_time.date(), self.event_time.time()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_percentage_done(&self) -> u16 {
|
||||||
|
get_percentage(self.start_time, self.event_time, self.app_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_duration(&mut self) -> CalendarDuration {
|
||||||
|
CalendarDuration::from_start_end_times(self.event_time, self.app_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_done(&mut self) {
|
||||||
|
let clock_duration = self.get_duration();
|
||||||
|
if clock_duration.is_since() {
|
||||||
|
let duration: Duration = clock_duration.into();
|
||||||
|
// give some offset to make sure we are around `Duration::ZERO`
|
||||||
|
// Without that we might miss it, because the app runs on its own FPS
|
||||||
|
if duration < Duration::from_millis(100) {
|
||||||
|
// reset `done_count`
|
||||||
|
self.done_count = Some(clock::MAX_DONE_COUNT);
|
||||||
|
// send notification
|
||||||
|
_ = self.app_tx.send(AppEvent::ClockDone(
|
||||||
|
ClockTypeId::Event,
|
||||||
|
self.title.clone().unwrap_or("".into()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// count (possible) `done`
|
||||||
|
self.done_count = clock::count_clock_done(self.done_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_cursor(&mut self) {
|
||||||
|
_ = self.app_tx.send(AppEvent::SetCursor(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_edit_mode(&self) -> bool {
|
||||||
|
self.edit_mode.is_editable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_edit_mode(&mut self) {
|
||||||
|
self.edit_mode = EditMode::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_input_datetime(&mut self) {
|
||||||
|
self.input_datetime = Input::default().with_value(format_offsetdatetime(&self.event_time));
|
||||||
|
self.input_datetime_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_input_title(&mut self) {
|
||||||
|
self.input_title = Input::default().with_value(self.title.clone().unwrap_or_default());
|
||||||
|
self.input_title_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_event_time(&mut self, date_time: PrimitiveDateTime) {
|
||||||
|
self.event_time =
|
||||||
|
// apply offset to be in sync with `AppTime`
|
||||||
|
date_time.assume_offset(self.app_time.offset());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_title(&mut self, value: &str) {
|
||||||
|
self.title = if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value.into())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_switch_input(&mut self, editable: Editable) {
|
||||||
|
// before switching store valid values or reset inputs in case of errors
|
||||||
|
match editable {
|
||||||
|
Editable::DateTime => {
|
||||||
|
// accept valid values only
|
||||||
|
match validate_datetime(self.input_datetime.value()) {
|
||||||
|
Ok(dt) => self.save_event_time(dt),
|
||||||
|
Err(_) => self.reset_input_datetime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Editable::Title => match validate_title(self.input_title.clone().value()) {
|
||||||
|
Ok(title) => self.save_title(title),
|
||||||
|
Err(_) => self.reset_input_title(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch_input(&mut self, editable: Editable) {
|
||||||
|
self.edit_mode = EditMode::Editing(editable);
|
||||||
|
self.last_editable = editable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_datetime(value: &str) -> Result<time::PrimitiveDateTime, Report> {
|
||||||
|
time::PrimitiveDateTime::parse(
|
||||||
|
value,
|
||||||
|
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
|
||||||
|
)
|
||||||
|
.map_err(|_| eyre!("Expected format 'YYYY-MM-DD HH:MM:SS'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LABEL_WIDTH: usize = 60;
|
||||||
|
|
||||||
|
fn validate_title(value: &str) -> Result<&str, Report> {
|
||||||
|
if value.len() > MAX_LABEL_WIDTH {
|
||||||
|
return Err(eyre!("Max. {} chars", MAX_LABEL_WIDTH));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiEventHandler for EventState {
|
||||||
|
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
|
||||||
|
let editable = self.edit_mode.is_editable();
|
||||||
|
match event {
|
||||||
|
// EDIT mode
|
||||||
|
TuiEvent::Crossterm(crossterm_event @ CrosstermEvent::Key(key)) if editable => {
|
||||||
|
match key.code {
|
||||||
|
// Skip changes
|
||||||
|
KeyCode::Esc => {
|
||||||
|
// reset inputs
|
||||||
|
self.reset_input_datetime();
|
||||||
|
self.reset_input_title();
|
||||||
|
|
||||||
|
self.reset_edit_mode();
|
||||||
|
self.reset_cursor();
|
||||||
|
}
|
||||||
|
// switch to prev. input
|
||||||
|
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
if let EditMode::Editing(editable) = self.edit_mode {
|
||||||
|
self.prepare_switch_input(editable);
|
||||||
|
self.switch_input(editable.prev());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// switch to next input
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if let EditMode::Editing(editable) = self.edit_mode {
|
||||||
|
self.prepare_switch_input(editable);
|
||||||
|
self.switch_input(editable.next());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => match self.edit_mode {
|
||||||
|
EditMode::Editing(Editable::DateTime) => {
|
||||||
|
// accept valid values only
|
||||||
|
match validate_datetime(self.input_datetime.value()) {
|
||||||
|
Ok(dt) => {
|
||||||
|
self.save_event_time(dt);
|
||||||
|
self.reset_edit_mode();
|
||||||
|
self.reset_cursor();
|
||||||
|
}
|
||||||
|
Err(e) => self.input_datetime_error = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditMode::Editing(Editable::Title) => {
|
||||||
|
// accept valid values only
|
||||||
|
match validate_title(self.input_title.clone().value()) {
|
||||||
|
Ok(title) => {
|
||||||
|
self.save_title(title);
|
||||||
|
self.reset_edit_mode();
|
||||||
|
self.reset_cursor();
|
||||||
|
}
|
||||||
|
Err(e) => self.input_title_error = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditMode::None => {}
|
||||||
|
},
|
||||||
|
_ => match self.edit_mode {
|
||||||
|
EditMode::Editing(Editable::DateTime) => {
|
||||||
|
// push `CrosstermEvent` down to input
|
||||||
|
self.input_datetime.handle_event(&crossterm_event);
|
||||||
|
|
||||||
|
let value = self.input_datetime.value();
|
||||||
|
|
||||||
|
match self.input_datetime_error {
|
||||||
|
// To relax errors while typing:
|
||||||
|
// (A) Do a "full" validation of `datetime` in case of a previous error only
|
||||||
|
Some(_) => {
|
||||||
|
if let Err(e) = validate_datetime(value) {
|
||||||
|
self.input_datetime_error = Some(e);
|
||||||
|
} else {
|
||||||
|
self.input_datetime_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (B) do a "light" validation of `datetime` in case of no previous error
|
||||||
|
None => {
|
||||||
|
// check length of expected format
|
||||||
|
if value.len() > 19 {
|
||||||
|
self.input_datetime_error =
|
||||||
|
Some(eyre!("Expected format 'YYYY-MM-DD HH:MM:SS'"))
|
||||||
|
} else {
|
||||||
|
self.input_datetime_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditMode::Editing(Editable::Title) => {
|
||||||
|
// push `CrosstermEvent` down to input
|
||||||
|
self.input_title.handle_event(&crossterm_event);
|
||||||
|
// do always a validation while typing
|
||||||
|
if let Err(e) = validate_title(self.input_title.value()) {
|
||||||
|
self.input_title_error = Some(e);
|
||||||
|
} else {
|
||||||
|
self.input_title_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditMode::None => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NORMAL mode
|
||||||
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
|
||||||
|
// Enter edit mode
|
||||||
|
KeyCode::Char('e') => {
|
||||||
|
self.edit_mode = EditMode::Editing(self.last_editable);
|
||||||
|
}
|
||||||
|
_ => return Some(event),
|
||||||
|
},
|
||||||
|
_ => return Some(event),
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_percentage(start: OffsetDateTime, end: OffsetDateTime, current: OffsetDateTime) -> u16 {
|
||||||
|
let total_millis = (end - start).whole_milliseconds();
|
||||||
|
|
||||||
|
if total_millis <= 0 {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed_millis = (current - start).whole_milliseconds();
|
||||||
|
|
||||||
|
if elapsed_millis <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentage = (elapsed_millis * 100 / total_millis).min(100);
|
||||||
|
percentage as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_offsetdatetime(dt: &OffsetDateTime) -> String {
|
||||||
|
dt.format(&format_description!(
|
||||||
|
"[year]-[month]-[day] [hour]:[minute]:[second]"
|
||||||
|
))
|
||||||
|
.unwrap_or_else(|e| format!("time format error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EventWidget {
|
||||||
|
pub style: DigitStyle,
|
||||||
|
pub blink: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatefulWidget for EventWidget {
|
||||||
|
type State = EventState;
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
let with_decis = state.with_decis;
|
||||||
|
let clock_duration = state.get_duration();
|
||||||
|
let clock_format = clock::format_by_duration(&clock_duration);
|
||||||
|
let clock_widths = clock::clock_horizontal_lengths(&clock_format, with_decis);
|
||||||
|
let clock_width = clock_widths.iter().sum();
|
||||||
|
|
||||||
|
let area = center(
|
||||||
|
area,
|
||||||
|
Constraint::Length(max(clock_width, MAX_LABEL_WIDTH as u16)),
|
||||||
|
Constraint::Length(
|
||||||
|
DIGIT_HEIGHT + 7, /* height of all labels + empty lines */
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let [_, v1, v2, v3, _, v4] = Layout::vertical(Constraint::from_lengths([
|
||||||
|
3, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only
|
||||||
|
DIGIT_HEIGHT,
|
||||||
|
1, // label: event date
|
||||||
|
1, // label: event title
|
||||||
|
1, // empty
|
||||||
|
1, // label: error
|
||||||
|
]))
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
// To simulate a blink effect, just use an "empty" symbol (string)
|
||||||
|
// It's "empty" all digits and creates an "empty" render area
|
||||||
|
let symbol = if self.blink && clock::should_blink(state.done_count) {
|
||||||
|
" "
|
||||||
|
} else {
|
||||||
|
self.style.get_digit_symbol()
|
||||||
|
};
|
||||||
|
|
||||||
|
let render_clock_state = clock::RenderClockState {
|
||||||
|
with_decis,
|
||||||
|
duration: clock_duration.clone(),
|
||||||
|
editable_time: None,
|
||||||
|
format: clock_format,
|
||||||
|
symbol,
|
||||||
|
widths: clock_widths,
|
||||||
|
};
|
||||||
|
|
||||||
|
clock::render_clock(v1, buf, render_clock_state);
|
||||||
|
|
||||||
|
// Helper to calculate centered area, cursor x position, and scroll
|
||||||
|
let calc_editable_input_positions = |input: &Input, area: Rect| -> (Rect, u16, usize) {
|
||||||
|
// Calculate scroll position to keep cursor visible
|
||||||
|
let input_scroll = input.visual_scroll(area.width as usize);
|
||||||
|
|
||||||
|
// Get correct visual width (handles unicode properly)
|
||||||
|
let text_width = Line::raw(input.value()).width() as u16;
|
||||||
|
|
||||||
|
// Calculate visible text width after scrolling
|
||||||
|
let visible_text_width = text_width
|
||||||
|
.saturating_sub(input_scroll as u16)
|
||||||
|
.min(area.width);
|
||||||
|
|
||||||
|
// Center the visible portion
|
||||||
|
let offset_x = (area.width.saturating_sub(visible_text_width)) / 2;
|
||||||
|
|
||||||
|
let centered_area = Rect {
|
||||||
|
x: area.x + offset_x,
|
||||||
|
y: area.y,
|
||||||
|
width: visible_text_width,
|
||||||
|
height: area.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cursor position relative to the visible scrolled text
|
||||||
|
let cursor_offset = input.visual_cursor().saturating_sub(input_scroll);
|
||||||
|
let cursor_x = area.x + offset_x + cursor_offset as u16;
|
||||||
|
|
||||||
|
(centered_area, cursor_x, input_scroll)
|
||||||
|
};
|
||||||
|
|
||||||
|
fn input_edit_style(with_error: bool) -> Style {
|
||||||
|
if with_error {
|
||||||
|
Style::default()
|
||||||
|
.add_modifier(Modifier::UNDERLINED)
|
||||||
|
.fg(Color::Red)
|
||||||
|
} else {
|
||||||
|
Style::default().add_modifier(Modifier::UNDERLINED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render date time input
|
||||||
|
match state.edit_mode {
|
||||||
|
// EDIT
|
||||||
|
EditMode::Editing(Editable::DateTime) => {
|
||||||
|
let (datetime_area, datetime_cursor_x, datetime_scroll) =
|
||||||
|
calc_editable_input_positions(&state.input_datetime, v2);
|
||||||
|
|
||||||
|
Paragraph::new(state.input_datetime.value())
|
||||||
|
.style(input_edit_style(state.input_datetime_error.is_some()))
|
||||||
|
.scroll((0, datetime_scroll as u16))
|
||||||
|
.render(datetime_area, buf);
|
||||||
|
|
||||||
|
// Update cursor
|
||||||
|
let cp = Position::new(datetime_cursor_x, v2.y);
|
||||||
|
let _ = state.app_tx.send(AppEvent::SetCursor(Some(cp)));
|
||||||
|
}
|
||||||
|
// NORMAL
|
||||||
|
_ => {
|
||||||
|
let mut prefix = "Until";
|
||||||
|
|
||||||
|
if clock_duration.is_since() {
|
||||||
|
let duration: Duration = clock_duration.clone().into();
|
||||||
|
// Show `done` for a short of time (1 sec)
|
||||||
|
prefix = if duration < Duration::from_secs(1) {
|
||||||
|
"Done"
|
||||||
|
} else {
|
||||||
|
"Since"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"{} {}",
|
||||||
|
prefix.to_uppercase(),
|
||||||
|
state.input_datetime.value()
|
||||||
|
))
|
||||||
|
.centered()
|
||||||
|
.render(v2, buf);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render title input
|
||||||
|
match state.edit_mode {
|
||||||
|
// EDIT
|
||||||
|
EditMode::Editing(Editable::Title) => {
|
||||||
|
let (title_area, title_cursor_x, title_scroll) =
|
||||||
|
calc_editable_input_positions(&state.input_title, v3);
|
||||||
|
|
||||||
|
Paragraph::new(state.input_title.value().to_uppercase())
|
||||||
|
.style(input_edit_style(state.input_title_error.is_some()))
|
||||||
|
.scroll((0, title_scroll as u16))
|
||||||
|
.render(title_area, buf);
|
||||||
|
// Update cursor
|
||||||
|
let cp = Position::new(title_cursor_x, v3.y);
|
||||||
|
let _ = state.app_tx.send(AppEvent::SetCursor(Some(cp)));
|
||||||
|
}
|
||||||
|
// NORMAL
|
||||||
|
_ => {
|
||||||
|
Paragraph::new(state.input_title.value().to_uppercase())
|
||||||
|
.centered()
|
||||||
|
.render(v3, buf);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render error
|
||||||
|
let error_txt: String = match (&state.input_datetime_error, &state.input_title_error) {
|
||||||
|
(Some(e), _) => e.to_string(),
|
||||||
|
(_, Some(e)) => e.to_string(),
|
||||||
|
_ => "".into(),
|
||||||
|
};
|
||||||
|
Paragraph::new(error_txt.to_lowercase())
|
||||||
|
.style(Style::default().add_modifier(Modifier::ITALIC))
|
||||||
|
.centered()
|
||||||
|
.render(v4, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_percentage() {
|
||||||
|
let start = datetime!(2024-01-01 10:00:00 UTC);
|
||||||
|
let end = datetime!(2024-01-01 20:00:00 UTC);
|
||||||
|
|
||||||
|
// current == start: 0%
|
||||||
|
assert_eq!(get_percentage(start, end, start), 0);
|
||||||
|
|
||||||
|
// current == end: 100%
|
||||||
|
assert_eq!(get_percentage(start, end, end), 100);
|
||||||
|
|
||||||
|
// current halfway: 50%
|
||||||
|
let halfway = datetime!(2024-01-01 15:00:00 UTC);
|
||||||
|
assert_eq!(get_percentage(start, end, halfway), 50);
|
||||||
|
|
||||||
|
// current 25%
|
||||||
|
let quarter = datetime!(2024-01-01 12:30:00 UTC);
|
||||||
|
assert_eq!(get_percentage(start, end, quarter), 25);
|
||||||
|
|
||||||
|
// current 75%
|
||||||
|
let three_quarters = datetime!(2024-01-01 17:30:00 UTC);
|
||||||
|
assert_eq!(get_percentage(start, end, three_quarters), 75);
|
||||||
|
|
||||||
|
// current > end: clamped to 100%
|
||||||
|
let after = datetime!(2024-01-01 22:00:00 UTC);
|
||||||
|
assert_eq!(get_percentage(start, end, after), 100);
|
||||||
|
|
||||||
|
// current < start: 0%
|
||||||
|
let before = datetime!(2024-01-01 08:00:00 UTC);
|
||||||
|
assert_eq!(get_percentage(start, end, before), 0);
|
||||||
|
|
||||||
|
// end <= start: 100%
|
||||||
|
assert_eq!(get_percentage(end, start, halfway), 100);
|
||||||
|
assert_eq!(get_percentage(start, start, start), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,10 +53,11 @@ impl StatefulWidget for Footer {
|
|||||||
type State = FooterState;
|
type State = FooterState;
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
let content_labels: BTreeMap<Content, &str> = BTreeMap::from([
|
let content_labels: BTreeMap<Content, &str> = BTreeMap::from([
|
||||||
(Content::Countdown, "[c]ountdown"),
|
(Content::Countdown, "[1]countdown"),
|
||||||
(Content::Timer, "[t]imer"),
|
(Content::Timer, "[2]timer"),
|
||||||
(Content::Pomodoro, "[p]omodoro"),
|
(Content::Pomodoro, "[3]pomodoro"),
|
||||||
(Content::LocalTime, "[l]ocal time"),
|
(Content::Event, "[4]event"),
|
||||||
|
(Content::LocalTime, "[0]local time"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let [_, area] =
|
let [_, area] =
|
||||||
@ -84,7 +85,7 @@ impl StatefulWidget for Footer {
|
|||||||
.render(border_area, buf);
|
.render(border_area, buf);
|
||||||
// show menu
|
// show menu
|
||||||
if state.show_menu {
|
if state.show_menu {
|
||||||
let content_labels: Vec<Span> = content_labels
|
let mut content_labels: Vec<Span> = content_labels
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, (content, label))| {
|
.map(|(index, (content, label))| {
|
||||||
@ -102,6 +103,13 @@ impl StatefulWidget for Footer {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
content_labels.extend_from_slice(&[
|
||||||
|
Span::from(SPACE),
|
||||||
|
Span::from("[→]next"),
|
||||||
|
Span::from(SPACE),
|
||||||
|
Span::from("[←]prev."),
|
||||||
|
]);
|
||||||
|
|
||||||
const SPACE: &str = " "; // 2 empty spaces
|
const SPACE: &str = " "; // 2 empty spaces
|
||||||
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
|
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
|
||||||
let mut table_rows = vec![
|
let mut table_rows = vec![
|
||||||
@ -135,6 +143,7 @@ impl StatefulWidget for Footer {
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Controls (except for `localtime`)
|
||||||
if self.selected_content != Content::LocalTime {
|
if self.selected_content != Content::LocalTime {
|
||||||
table_rows.extend_from_slice(&[
|
table_rows.extend_from_slice(&[
|
||||||
// controls - 1. row
|
// controls - 1. row
|
||||||
@ -145,7 +154,7 @@ impl StatefulWidget for Footer {
|
|||||||
)),
|
)),
|
||||||
Cell::from(Line::from({
|
Cell::from(Line::from({
|
||||||
match self.app_edit_mode {
|
match self.app_edit_mode {
|
||||||
AppEditMode::None => {
|
AppEditMode::None if self.selected_content != Content::Event => {
|
||||||
let mut spans = vec![Span::from(if self.running_clock {
|
let mut spans = vec![Span::from(if self.running_clock {
|
||||||
"[s]top"
|
"[s]top"
|
||||||
} else {
|
} else {
|
||||||
@ -173,8 +182,16 @@ impl StatefulWidget for Footer {
|
|||||||
}
|
}
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
_ => {
|
AppEditMode::None if self.selected_content == Content::Event => {
|
||||||
|
vec![Span::from("[e]dit")]
|
||||||
|
}
|
||||||
|
AppEditMode::Clock | AppEditMode::Time | AppEditMode::Event => {
|
||||||
let mut spans = vec![Span::from("[s]ave changes")];
|
let mut spans = vec![Span::from("[s]ave changes")];
|
||||||
|
|
||||||
|
if self.selected_content == Content::Event {
|
||||||
|
spans[0] = Span::from("[enter]save changes")
|
||||||
|
};
|
||||||
|
|
||||||
if self.selected_content == Content::Countdown
|
if self.selected_content == Content::Countdown
|
||||||
|| self.selected_content == Content::Pomodoro
|
|| self.selected_content == Content::Pomodoro
|
||||||
{
|
{
|
||||||
@ -187,13 +204,24 @@ impl StatefulWidget for Footer {
|
|||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
Span::from("[esc]skip changes"),
|
Span::from("[esc]skip changes"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if self.selected_content == Content::Event {
|
||||||
|
spans.extend_from_slice(&[
|
||||||
|
Span::from(SPACE),
|
||||||
|
Span::from("[tab]switch input"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
|
_ => vec![],
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
]),
|
]),
|
||||||
// controls - 2. row
|
// controls - 2. row
|
||||||
Row::new(vec![
|
Row::new(if self.selected_content == Content::Event {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
Cell::from(Line::from("")),
|
Cell::from(Line::from("")),
|
||||||
Cell::from(Line::from({
|
Cell::from(Line::from({
|
||||||
match self.app_edit_mode {
|
match self.app_edit_mode {
|
||||||
@ -201,7 +229,7 @@ impl StatefulWidget for Footer {
|
|||||||
let mut spans = vec![];
|
let mut spans = vec![];
|
||||||
if self.selected_content == Content::Pomodoro {
|
if self.selected_content == Content::Pomodoro {
|
||||||
spans.extend_from_slice(&[Span::from(
|
spans.extend_from_slice(&[Span::from(
|
||||||
"[← →]switch work/pause",
|
"[^←] or [^→] switch work/pause",
|
||||||
)]);
|
)]);
|
||||||
}
|
}
|
||||||
spans
|
spans
|
||||||
@ -240,7 +268,8 @@ impl StatefulWidget for Footer {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
]),
|
]
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
common::{AppTime, AppTimeFormat, Style as DigitStyle},
|
common::{AppTime, AppTimeFormat, Style as DigitStyle},
|
||||||
duration::DurationEx,
|
duration::{ClockDuration, DurationEx},
|
||||||
events::{TuiEvent, TuiEventHandler},
|
events::{TuiEvent, TuiEventHandler},
|
||||||
utils::center,
|
utils::center,
|
||||||
widgets::clock_elements::{
|
widgets::clock_elements::{
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use crate::{
|
|||||||
utils::center,
|
utils::center,
|
||||||
widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown},
|
widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown},
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
@ -149,7 +149,7 @@ impl TuiEventHandler for PomodoroState {
|
|||||||
self.get_clock_mut().update_done_count();
|
self.get_clock_mut().update_done_count();
|
||||||
}
|
}
|
||||||
// EDIT mode
|
// EDIT mode
|
||||||
TuiEvent::Key(key) if edit_mode => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code {
|
||||||
// Skip changes
|
// Skip changes
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
let clock = self.get_clock_mut();
|
let clock = self.get_clock_mut();
|
||||||
@ -188,7 +188,7 @@ impl TuiEventHandler for PomodoroState {
|
|||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
},
|
},
|
||||||
// default mode
|
// default mode
|
||||||
TuiEvent::Key(key) => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
|
||||||
// Toggle run/pause
|
// Toggle run/pause
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.get_clock_mut().toggle_pause();
|
self.get_clock_mut().toggle_pause();
|
||||||
@ -198,12 +198,12 @@ impl TuiEventHandler for PomodoroState {
|
|||||||
self.get_clock_mut().toggle_edit();
|
self.get_clock_mut().toggle_edit();
|
||||||
}
|
}
|
||||||
// toggle WORK/PAUSE
|
// toggle WORK/PAUSE
|
||||||
KeyCode::Left => {
|
KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
// `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();
|
self.next();
|
||||||
}
|
}
|
||||||
// toggle WORK/PAUSE
|
// toggle WORK/PAUSE
|
||||||
KeyCode::Right => {
|
KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
self.next();
|
self.next();
|
||||||
}
|
}
|
||||||
// reset rounds AND clocks
|
// reset rounds AND clocks
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use crate::{
|
|||||||
utils::center,
|
utils::center,
|
||||||
widgets::clock::{self, ClockState, ClockWidget},
|
widgets::clock::{self, ClockState, ClockWidget},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::{Event as CrosstermEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
crossterm::event::KeyCode,
|
crossterm::event::KeyCode,
|
||||||
@ -41,7 +41,7 @@ impl TuiEventHandler for TimerState {
|
|||||||
self.clock.update_done_count();
|
self.clock.update_done_count();
|
||||||
}
|
}
|
||||||
// EDIT mode
|
// EDIT mode
|
||||||
TuiEvent::Key(key) if edit_mode => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code {
|
||||||
// Skip changes
|
// Skip changes
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
// Important: set current value first
|
// Important: set current value first
|
||||||
@ -78,7 +78,7 @@ impl TuiEventHandler for TimerState {
|
|||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
},
|
},
|
||||||
// default mode
|
// default mode
|
||||||
TuiEvent::Key(key) => match key.code {
|
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
|
||||||
// Toggle run/pause
|
// Toggle run/pause
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.clock.toggle_pause();
|
self.clock.toggle_pause();
|
||||||
|
|||||||