Compare commits

...

24 Commits

Author SHA1 Message Date
Jens Krause
ca9d17f1ee
Rust 1.91.0 (#140) 2025-11-03 17:23:06 +01:00
jk
1bebfb897a
fix(demo): hide menu from local-time-footer demo 2025-10-29 12:04:36 +01:00
Jens Krause
90c0e50f1c
Prepare v1.6.1 (#138) 2025-10-29 11:31:42 +01:00
Jens Krause
27cb8c7e33
fix: filter KeyEventKind::Press out (#137)
to ignore all the other `CrosstermEvent::Key` events
2025-10-29 11:03:49 +01:00
jk
513f1fec11
fix(tape) typo 2025-10-19 19:03:21 +02:00
Jens Krause
1742d68c61
update all demos (#135)
- new size: `1000x500`
- remove deprecated demos
- `just da`: build all demos
- update README
2025-10-19 19:02:04 +02:00
Jens Krause
4ec52545e5
Prepare v1.6.0 (#134) 2025-10-16 14:44:19 +02:00
Jens Krause
dfe3cfcc80
add AGENTS.md (#133)
* add `AGENTS.md`
* update README
2025-10-16 12:01:50 +02:00
Jens Krause
c637a82deb
fix(event) relax validation (#132)
* fix(event) relax validation

to show errors if needed only, but not all the time

* update demo

* update CL

* fix demo

* `prepare|switch_input`
2025-10-16 11:54:53 +02:00
Jens Krause
361a82ee08
demo: event (#131)
* demo: event

* update README

* update CL
2025-10-15 17:00:57 +02:00
Jens Krause
95d914c757
feat(event) make date_time + title editable (#130)
* wip: editable events

* make it work

* fix: scroll position, title validation, underline

inputs to visualize edit mode

* show error

* prefix datetime

* refactor rendering inputs

* compact `EditMode`

* update footer to include `event` keybindings

* update README
2025-10-15 16:49:17 +02:00
Jens Krause
e11dcaa913
feat(event) persist state (#129)
* feat(event) persist state

* `Event::default()`
2025-10-14 10:18:53 +02:00
Jens Krause
d2f41e04e2
inrease MAX_DURATION to 9999y 364d 23:59:59.9 (#128) 2025-10-13 14:09:16 +02:00
Jens Krause
51f83e5b06
feat(screens)! switch by using or keys (#127)
* feat(screens) switch by `←` or `→` keys

* test cycling `Content` using `next`/`prev`

* update CL
2025-10-13 13:28:51 +02:00
Jens Krause
b5f3c709bf
(keybindings)! change keys for screens (#126) 2025-10-13 12:46:32 +02:00
Jens Krause
56e6ce66fa
feat(cli) parse event (#125)
* feat(cli) parse `event`

* check possible `Event` for `mode`

* m.bros
2025-10-13 11:54:06 +02:00
Jens Krause
758a72fbf6
feat(event) desktop notification at event time (#124) 2025-10-10 10:50:50 +02:00
Jens Krause
6b6221803c
feat(event) blink effect at event time (#123)
Similar to `Countdown` and `Pomodoro` DONE effects.
2025-10-10 10:18:37 +02:00
Jens Krause
4594bc722e
feat(event): Show percentage until event (#122)
using `get_percentage_done`
2025-10-09 20:04:29 +02:00
Jens Krause
e2cd536079
Introduce CalendarDuration (#120)
* trait ClockDuration, CalendarDuration, tests

* make clock rendering more generic

* remove `should_blink` from `RenderClockState`

* pass less down: `mode` -> `editable_time`

* simplify `event` duration states

* remove deprecated `DirectedDuration`

* fix comments
2025-10-09 19:51:34 +02:00
Jens Krause
99032834be
(cli)! Remove --countdown-target argument (#121)
Reverts #112.

For targeting a date (past/future) a new `event` feature will be
introduced (soon).
2025-10-08 18:03:06 +02:00
Jens Krause
f79813ac6b
feat(event) Add widget (#117)
* skeleton
* make `Event` widget work
2025-10-05 21:05:14 +02:00
Jens Krause
6437795c9f
Prepare v1.5.0 (#116) 2025-10-03 12:55:43 +02:00
Jens Krause
0c4f507ebf
demos for v1.5.0 (#115) 2025-10-03 12:39:23 +02:00
54 changed files with 2926 additions and 1341 deletions

24
AGENTS.md Normal file
View 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.

View File

@ -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
View File

@ -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"

View File

@ -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]

View File

@ -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]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

19
demo/countdown-max.tape Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -4,8 +4,8 @@ Output demo/decis.gif
Set Theme "nord-light" Set Theme "nord-light"
Set FontSize 14 Set FontSize 14
Set Width 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

31
demo/event.tape Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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 ":::"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -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 ":::"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 179 KiB

View File

@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -4,8 +4,8 @@ Output demo/style.gif
Set Theme "OneDark" Set Theme "OneDark"
Set FontSize 14 Set FontSize 14
Set Width 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

37
demo/timer-max.tape Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -4,8 +4,8 @@ Output demo/timer.gif
Set Theme "Belafonte Day" Set Theme "Belafonte Day"
Set FontSize 14 Set FontSize 14
Set Width 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
View File

@ -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": {

View File

@ -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;

View File

@ -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

View File

@ -1,6 +1,6 @@
[toolchain] [toolchain]
# Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`. # Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`.
channel = "1.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"

View File

@ -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);
} }

View File

@ -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,

View File

@ -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");
}
} }

View File

@ -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
View 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());
}
}

View File

@ -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,
} }
}), }),
) )

View File

@ -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;

View File

@ -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,
} }

View File

@ -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;

View File

@ -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);
} }
} }

View File

@ -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

View File

@ -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
); );
} }

View File

@ -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
View 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);
}
}

View File

@ -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 {
], ],
} }
})), })),
]), ]
}),
]) ])
} }

View File

@ -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::{

View File

@ -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

View File

@ -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();