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.
- (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)
- (guide) Add contributing guidelines [#94](https://github.com/sectore/timr-tui/pull/94)
## v1.4.0 - 2025-09-02
### Features
- (screen): Local Time [#89](https://github.com/sectore/timr-tui/pull/89), [#90](https://github.com/sectore/timr-tui/pull/90), [#91](https://github.com/sectore/timr-tui/pull/91)
- (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.

14
Cargo.lock generated
View File

@ -642,6 +642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
dependencies = [
"powerfmt",
"serde_core",
]
[[package]]
@ -2089,7 +2090,7 @@ dependencies = [
[[package]]
name = "timr-tui"
version = "1.4.0"
version = "1.6.1"
dependencies = [
"clap",
"color-eyre",
@ -2109,6 +2110,7 @@ dependencies = [
"tokio-util",
"tracing",
"tracing-subscriber",
"tui-input",
]
[[package]]
@ -2267,6 +2269,16 @@ dependencies = [
"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]]
name = "uds_windows"
version = "1.1.0"

View File

@ -1,10 +1,10 @@
[package]
name = "timr-tui"
version = "1.4.0"
version = "1.6.1"
description = "TUI to organize your time: Pomodoro, Countdown, Timer."
edition = "2024"
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`.
rust-version = "1.90.0"
rust-version = "1.91.0"
homepage = "https://github.com/sectore/timr-tui"
repository = "https://github.com/sectore/timr-tui"
readme = "README.md"
@ -35,13 +35,14 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
directories = "5.0.1"
clap = { version = "4.5.48", features = ["derive"] }
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros"] }
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros", "serde"] }
notify-rust = "4.11.7"
rodio = { version = "0.20.1", features = [
"symphonia-mp3",
"symphonia-wav",
], default-features = false, optional = true }
thiserror = { version = "2.0.17", optional = true }
tui-input = "0.14.0"
[features]

View File

@ -1,10 +1,11 @@
# 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.
- `[c]ountdown` Use it for your workout, yoga session, meditation, handstand or whatever.
- `[p]omodoro` Organize your working time to be focused all the time by following the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique).
- `[1] countdown` Use it for your workout, yoga/breathing sessions, meditation, handstand or whatever.
- `[2] timer` Check the time on anything you are you doing.
- `[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/).
@ -36,29 +37,28 @@ _(theme depends on your terminal preferences)_
<img alt="pomodoro" src="demo/timer.gif" />
</a>
## Countdown
## Countdown (*incl. [Mission Elapsed Time](https://en.wikipedia.org/wiki/Mission_Elapsed_Time)*)
<a href="demo/countdown.gif">
<img alt="countdown" src="demo/countdown.gif" />
</a>
## Countdown: Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
## Event (*past/future*)
<a href="demo/countdown-met.gif">
<img alt="menu" src="demo/countdown-met.gif" />
<a href="demo/event.gif">
<img alt="event" src="demo/event.gif" />
</a>
## Local time
<a href="demo/local-time.gif">
<img alt="menu" src="demo/local-time.gif" />
<img alt="local time" src="demo/local-time.gif" />
</a>
## Local time (footer)
## Local time (*footer*)
<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>
## Toggle deciseconds
@ -67,6 +67,13 @@ _(theme depends on your terminal preferences)_
<img alt="deciseconds" src="demo/decis.gif" />
</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
<a href="demo/style.gif">
@ -87,34 +94,20 @@ timr-tui --help
Usage: timr-tui [OPTIONS]
Options:
-c, --countdown <COUNTDOWN>
Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'.
--countdown-target <COUNTDOWN_TARGET>
Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm' [aliases: --ct]
-w, --work <WORK>
Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-p, --pause <PAUSE>
Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-d, --decis
Show deciseconds.
-m, --mode <MODE>
Mode to start with. [possible values: countdown, timer, pomodoro, localtime]
-s, --style <STYLE>
Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
--menu
Open menu.
-r, --reset
Reset stored values to defaults.
-n, --notification <NOTIFICATION>
Toggle desktop notifications. Experimental. [possible values: on, off]
--blink <BLINK>
Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
--log [<LOG>]
Directory for log file. If not set, standard application log directory is used (check README for details).
-h, --help
Print help
-V, --version
Print version
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: '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'
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
-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'.
-d, --decis Show deciseconds.
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro, event, localtime]
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille]
--menu Open menu.
-r, --reset Reset stored values to defaults.
-n, --notification <NOTIFICATION> Toggle desktop notifications. Experimental. [possible values: on, off]
--blink <BLINK> Toggle blink mode to animate a clock when it reaches its finished mode. [possible values: on, off]
--log [<LOG>] Directory for log file. If not set, standard application log directory is used (check README for details).
-h, --help Print help
-V, --version Print version
```
Extra option (if `--features sound` is enabled by local build only):
@ -135,10 +128,13 @@ Extra option (if `--features sound` is enabled by local build only):
| Key | Description |
| --- | --- |
| <kbd>p</kbd> | Pomodoro |
| <kbd>c</kbd> | Countdown |
| <kbd>t</kbd> | Timer |
| <kbd>l</kbd> | Local Time |
| <kbd>1</kbd> | Pomodoro |
| <kbd>2</kbd> | Countdown |
| <kbd>3</kbd> | Timer |
| <kbd>4</kbd> | Event |
| <kbd>0</kbd> | Local Time |
| <kbd></kbd> | next screen |
| <kbd></kbd> | previous screen |
## Controls
@ -161,11 +157,19 @@ Extra option (if `--features sound` is enabled by local build only):
| <kbd></kbd> | edit to go down |
| <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:**
| 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+s</kbd> | save initial value |
@ -245,6 +249,7 @@ Available recipes:
demo-countdown # build demo: countdown [alias: dc]
demo-countdown-met # build demo: countdown + met [alias: dcm]
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-footer # build demo: local time (footer) [alias: dltf]
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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
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
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "Builtin Solarized Light"
Set Theme "iceberg-light"
Set FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -d -m c"
Type "cargo run -- -r -d -c 10:00"
Enter
Sleep 0.2
Sleep .2
Type "m" # hide menu
Show
# --- COUNTDOWN ---
Sleep 1
Sleep .5
Type "s"
Sleep 1.4
Type "s"
@ -28,6 +29,5 @@ Type "e"
Sleep 0.1
Down@10ms 65
Sleep 0.1
Type "e"
Sleep 0.1
Type "s"
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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
@ -14,7 +14,8 @@ Set LoopOffset 4
Hide
Type "cargo run -- -r -m t"
Enter
Sleep 0.2
Sleep .2
Type "m" # hide menu
Show
# --- STYLES ---
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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -m c"
Type "cargo run -- -r -m c"
Enter
Sleep 0.2
Type "m" # hide menu
Show
# --- 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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
@ -14,7 +14,8 @@ Set LoopOffset 4
Hide
Type "cargo run -- -m l"
Enter
Sleep 0.2
Sleep .2
Type "m" # hide menu
Show
# --- 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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -r -m p --menu"
Type "cargo run -- -r -m c"
Enter
Type@200ms "m"
Type@200ms "m" # hide menu
Show
# --- STYLES ---
Sleep 0.3s
Type@0.3s "m"
Type@0.3s "t"
Type@0.3s "c"
Type@0.3s "p"
Type@0.3s "m" # show menu
Type@0.3s "2"
Type@0.3s "3"
Type@0.3s "e"
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
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "Catppuccin Frappe"
Set FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
# --- START ---
Hide
Type "cargo run -- -d -m p --blink on"
Type "cargo run -- -r -d -m p --blink on"
Enter
Sleep 0.2
Sleep .2
Type "m" # hide menu
Show
# --- POMODORO WORK ---
Sleep 1
Type "s"
Sleep .5
Type "s" # start
Sleep 2.3
Type "e"
Sleep 0.2
Down@30ms 80
Sleep 100ms
Type "e"
Type "s" # save
Sleep 4
# --- POMODORO PAUSE ---
Right
Ctrl+Right
Sleep 0.5
Type "s"
Sleep 2.3
@ -34,5 +37,5 @@ Type "e"
Sleep 0.2
Down@30ms 60
Sleep 100ms
Type "e"
Type "s" # save
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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
@ -15,6 +15,7 @@ Hide
Type "cargo run -- -r -d -m c"
Enter
Sleep 0.2
Type "m" # hide menu
Show
# --- STYLES ---
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 FontSize 14
Set Width 800
Set Height 400
Set Width 1000
Set Height 500
Set Padding 0
Set Margin 1
@ -15,6 +15,7 @@ Hide
Type "cargo run -- -r -d -m t"
Enter
Sleep 0.2
Type "m" # hide menu
Show
# --- TIMER ---
Type "s"
@ -27,5 +28,5 @@ Type "e"
Sleep 0.2
Up@30ms 57
Sleep 0.7
Type "e"
Type "s"
Sleep 4

24
flake.lock generated
View File

@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1758758545,
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
"lastModified": 1760924934,
"narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=",
"owner": "ipetkov",
"repo": "crane",
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
"rev": "c6b4d5308293d0d04fcfeee92705017537cad02f",
"type": "github"
},
"original": {
@ -23,11 +23,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1758782550,
"narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
"lastModified": 1762065744,
"narHash": "sha256-c04mxJoCb8f6BBrdaREWmdQq+pfp395olXhC+B0G7DI=",
"owner": "nix-community",
"repo": "fenix",
"rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
"rev": "e0f24085a4a0da1c32adc308ec4c518ae886ff35",
"type": "github"
},
"original": {
@ -56,11 +56,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1758690382,
"narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
"lastModified": 1761907660,
"narHash": "sha256-kJ8lIZsiPOmbkJypG+B5sReDXSD1KGu2VEPNqhRa/ew=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e643668fd71b949c53f8626614b21ff71a07379d",
"rev": "2fb006b87f04c4d3bdf08cfdbc7fab9c13d94a15",
"type": "github"
},
"original": {
@ -81,11 +81,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1758620797,
"narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
"lastModified": 1762016333,
"narHash": "sha256-PT8hXDYyeRjh9BGyLF/nZWm9TqRwP2EzeKuqUFH0M3w=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
"rev": "fca718c0f2074bdccf9a996bb37b0fcaff80dc97",
"type": "github"
},
"original": {

View File

@ -24,7 +24,7 @@
{
file = ./rust-toolchain.toml;
# sha256 = nixpkgs.lib.fakeSha256;
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
sha256 = "sha256-2eWc3xVTKqg5wKSHGwt1XoM/kUBC6y3MWfKg74Zn+fY=";
};
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;

View File

@ -65,6 +65,17 @@ run-sound-args path args:
# 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
# build demo: pomodoro
@ -141,3 +152,24 @@ alias db := demo-blink
[group('demo')]
demo-blink:
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]
# 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"]
targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl"]
profile = "minimal"

View File

@ -2,13 +2,14 @@ use crate::{
args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
duration::DirectedDuration,
event::Event,
events::{self, TuiEventHandler},
storage::AppStorage,
terminal::Terminal,
widgets::{
clock::{self, ClockState, ClockStateArgs},
countdown::{Countdown, CountdownState, CountdownStateArgs},
event::{EventState, EventStateArgs, EventWidget},
footer::{Footer, FooterState},
header::Header,
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
@ -17,6 +18,8 @@ use crate::{
},
};
use crossterm::event::Event as CrosstermEvent;
#[cfg(feature = "sound")]
use crate::sound::Sound;
@ -24,7 +27,7 @@ use color_eyre::Result;
use ratatui::{
buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent},
layout::{Constraint, Layout, Rect},
layout::{Constraint, Layout, Position, Rect},
widgets::{StatefulWidget, Widget},
};
use std::path::PathBuf;
@ -49,10 +52,12 @@ pub struct App {
countdown: CountdownState,
timer: TimerState,
pomodoro: PomodoroState,
event: EventState,
local_time: LocalTimeState,
style: Style,
with_decis: bool,
footer: FooterState,
cursor_position: Option<Position>,
}
pub struct AppArgs {
@ -73,6 +78,7 @@ pub struct AppArgs {
pub current_value_countdown: Duration,
pub elapsed_value_countdown: Duration,
pub current_value_timer: Duration,
pub event: Event,
pub app_tx: events::AppEventTx,
pub sound_path: Option<PathBuf>,
pub footer_toggle_app_time: Toggle,
@ -103,8 +109,10 @@ impl From<FromAppArgs> for App {
None => {
if args.work.is_some() || args.pause.is_some() {
Content::Pomodoro
} else if args.countdown.is_some() || args.countdown_target.is_some() {
} else if args.countdown.is_some() {
Content::Countdown
} else if args.event.is_some() {
Content::Event
}
// in other case just use latest stored state
else {
@ -121,30 +129,16 @@ impl From<FromAppArgs> for App {
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
// invalidate `current_value_pause` if an initial value is set via args
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
initial_value_countdown: match (&args.countdown, &args.countdown_target) {
(Some(d), _) => *d,
(None, Some(DirectedDuration::Until(d))) => *d,
// reset for values from "past"
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
(None, None) => stg.inital_value_countdown,
},
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
// invalidate `current_value_countdown` if an initial value is set via args
current_value_countdown: match (&args.countdown, &args.countdown_target) {
(Some(d), _) => *d,
(None, Some(DirectedDuration::Until(d))) => *d,
// `zero` makes values from `past` marked as `DONE`
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
(None, None) => stg.inital_value_countdown,
},
elapsed_value_countdown: match (args.countdown, args.countdown_target) {
// use `Since` duration
(_, Some(DirectedDuration::Since(d))) => d,
// reset values
(_, Some(_)) => Duration::ZERO,
(Some(_), _) => Duration::ZERO,
(_, _) => stg.elapsed_value_countdown,
current_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
elapsed_value_countdown: match args.countdown {
// reset value if countdown is set by arguments
Some(_) => Duration::ZERO,
None => stg.elapsed_value_countdown,
},
current_value_timer: stg.current_value_timer,
event: args.event.unwrap_or(stg.event),
app_tx,
#[cfg(feature = "sound")]
sound_path: args.sound,
@ -173,6 +167,7 @@ impl App {
with_decis,
pomodoro_mode,
pomodoro_round,
event,
notification,
blink,
sound_path,
@ -223,6 +218,12 @@ impl App {
app_time,
app_time_format,
}),
event: EventState::new(EventStateArgs {
app_time,
event,
with_decis,
app_tx: app_tx.clone(),
}),
footer: FooterState::new(
show_menu,
if footer_toggle_app_time == Toggle::On {
@ -231,6 +232,7 @@ impl App {
None
},
),
cursor_position: None,
}
}
@ -244,10 +246,19 @@ impl App {
debug!("Received key {:?}", key.code);
match key.code {
KeyCode::Char('q') => app.mode = Mode::Quit,
KeyCode::Char('c') => app.content = Content::Countdown,
KeyCode::Char('t') => app.content = Content::Timer,
KeyCode::Char('p') => app.content = Content::Pomodoro,
KeyCode::Char('l') => app.content = Content::LocalTime,
KeyCode::Char('1') | KeyCode::Char('c') /* TODO: deprecated, remove it in next version */ => app.content = Content::Countdown,
KeyCode::Char('2') | KeyCode::Char('t') /* TODO: deprecated, remove it in next version */ => app.content = Content::Timer,
KeyCode::Char('3') | KeyCode::Char('p') /* TODO: deprecated, remove it in next version */ => app.content = Content::Pomodoro,
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
KeyCode::Char(':') => {
if app.content == Content::LocalTime {
@ -291,6 +302,7 @@ impl App {
app.timer.set_with_decis(app.with_decis);
app.countdown.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::Down => app.footer.set_show_menu(false),
@ -303,6 +315,7 @@ impl App {
app.app_time = AppTime::new();
app.countdown.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
@ -310,13 +323,17 @@ impl App {
Content::Countdown => app.countdown.update(event.clone()),
Content::Timer => app.timer.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()),
} {
match unhandled {
events::TuiEvent::Render | events::TuiEvent::Resize => {
events::TuiEvent::Render
| events::TuiEvent::Crossterm(crossterm::event::Event::Resize(_, _)) => {
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(())
};
@ -401,6 +421,13 @@ impl App {
AppEditMode::None
}
}
Content::Event => {
if self.event.is_edit_mode() {
AppEditMode::Event
} else {
AppEditMode::None
}
}
Content::LocalTime => AppEditMode::None,
}
}
@ -410,6 +437,8 @@ impl App {
Content::Countdown => self.countdown.is_running(),
Content::Timer => self.timer.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`
Content::LocalTime => false,
}
@ -420,6 +449,7 @@ impl App {
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
Content::Timer => None,
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
Content::Event => Some(self.event.get_percentage_done()),
Content::LocalTime => None,
}
}
@ -427,6 +457,11 @@ impl App {
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
terminal.draw(|frame| {
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(())
}
@ -456,6 +491,7 @@ impl App {
),
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_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(),
}
}
@ -483,6 +519,11 @@ impl AppWidget {
blink: state.blink == Toggle::On,
}
.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 => {
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
}

View File

@ -1,6 +1,7 @@
use crate::{
common::{Content, Style, Toggle},
duration,
event::{Event, parse_event},
};
#[cfg(feature = "sound")]
use crate::{sound, sound::SoundError};
@ -18,11 +19,6 @@ pub struct Args {
)]
pub countdown: Option<Duration>,
#[arg(long, visible_alias = "ct", value_parser = duration::parse_duration_by_time,
help = "Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm'"
)]
pub countdown_target: Option<duration::DirectedDuration>,
#[arg(long, short, value_parser = duration::parse_duration,
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
)]
@ -33,6 +29,14 @@ pub struct Args {
)]
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.")]
pub decis: bool,

View File

@ -15,14 +15,39 @@ pub enum Content {
Timer,
#[value(name = "pomodoro", alias = "p")]
Pomodoro,
#[value(name = "event", alias = "e")]
Event,
#[value(name = "localtime", alias = "l")]
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)]
pub enum ClockTypeId {
Countdown,
Timer,
Event,
}
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
@ -184,6 +209,7 @@ pub enum AppEditMode {
None,
Clock,
Time,
Event,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
@ -251,4 +277,48 @@ mod tests {
"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::fmt;
use std::time::Duration;
use crate::common::AppTime;
use time::OffsetDateTime;
// unstable
// 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
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
.saturating_mul(1000)
.saturating_mul(10000)
.saturating_sub(ONE_DECI_SECOND);
/// `Duration` with direction in time (past or future)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectedDuration {
/// Time `until` a future moment (positive `Duration`)
Until(Duration),
/// Time `since` a past moment (negative duration, but still represented as positive `Duration`)
Since(Duration),
/// Trait for duration types that can be displayed in clock widgets.
///
/// This trait abstracts over different duration calculation strategies:
/// - `DurationEx`: Uses fixed 365-day years (fast, simple)
/// - `CalendarDuration`: Uses actual calendar dates (accounts for leap years)
pub trait ClockDuration {
/// 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)]
@ -69,62 +254,60 @@ impl From<DurationEx> for Duration {
}
}
impl DurationEx {
pub fn years(&self) -> u64 {
impl ClockDuration for DurationEx {
fn years(&self) -> u64 {
self.days() / DAYS_PER_YEAR
}
pub fn days(&self) -> u64 {
fn days(&self) -> u64 {
self.hours() / HOURS_PER_DAY
}
/// Days in a year
pub fn days_mod(&self) -> u64 {
fn days_mod(&self) -> u64 {
self.days() % DAYS_PER_YEAR
}
pub fn hours(&self) -> u64 {
fn hours(&self) -> u64 {
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
}
/// Hours as 24-hour clock
pub fn hours_mod(&self) -> u64 {
fn hours_mod(&self) -> u64 {
self.hours() % HOURS_PER_DAY
}
/// Hours as 12-hour clock
pub fn hours_mod_12(&self) -> u64 {
fn hours_mod_12(&self) -> u64 {
// 0 => 12,
// 1..=12 => hours,
// 13..=23 => hours - 12,
(self.hours_mod() + 11) % 12 + 1
}
pub fn minutes(&self) -> u64 {
fn minutes(&self) -> u64 {
self.seconds() / MINS_PER_HOUR
}
pub fn minutes_mod(&self) -> u64 {
fn minutes_mod(&self) -> u64 {
self.minutes() % SECS_PER_MINUTE
}
pub fn seconds(&self) -> u64 {
fn seconds(&self) -> u64 {
self.inner.as_secs()
}
pub fn seconds_mod(&self) -> u64 {
fn seconds_mod(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE
}
// deciseconds
pub fn decis(&self) -> u64 {
fn decis(&self) -> u64 {
(self.inner.subsec_millis() / 100) as u64
}
// milliseconds
pub fn millis(&self) -> u128 {
fn millis(&self) -> u128 {
self.inner.as_millis()
}
}
impl DurationEx {
pub fn saturating_add(&self, ex: DurationEx) -> Self {
let inner = self.inner.saturating_add(ex.inner);
Self { inner }
@ -142,6 +325,7 @@ impl DurationEx {
impl fmt::Display for DurationEx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use ClockDuration as _; // Import trait methods
if self.years() >= 1 {
write!(
f,
@ -209,84 +393,6 @@ fn parse_hours(h: &str) -> Result<u8, Report> {
Ok(hours)
}
/// Parses `DirectedDuration` from following formats:
/// - `yyyy-mm-dd hh:mm:ss`
/// - `yyyy-mm-dd hh:mm`
/// - `hh:mm:ss`
/// - `hh:mm`
/// - `mm`
///
/// Returns `DirectedDuration::Until` for future times, `DirectedDuration::Since` for past times
pub fn parse_duration_by_time(arg: &str) -> Result<DirectedDuration, Report> {
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
let now: OffsetDateTime = AppTime::new().into();
let target_time = if arg.contains('-') {
// First: `YYYY-MM-DD HH:MM:SS`
// Then: `YYYY-MM-DD HH:MM`
let format_with_seconds =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
let format_without_seconds = format_description!("[year]-[month]-[day] [hour]:[minute]");
let pdt = PrimitiveDateTime::parse(arg, format_with_seconds)
.or_else(|_| PrimitiveDateTime::parse(arg, format_without_seconds))
.map_err(|e| {
eyre!("Invalid datetime '{}'. Use format 'yyyy-mm-dd hh:mm:ss' or 'yyyy-mm-dd hh:mm'. Error: {}", arg, e)
})?;
pdt.assume_offset(now.offset())
} else {
// Parse time parts: interpret as HH:MM:SS, HH:MM, or SS
let parts: Vec<&str> = arg.split(':').collect();
let (hour, minute, second) = match parts.as_slice() {
[mm] => {
// Single part: treat as minutes in current hour
let m = parse_minutes(mm)?;
(now.hour(), m, 0)
}
[hh, mm] => {
// Two parts: treat as HH:MM (time of day)
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
(h, m, 0)
}
[hh, mm, ss] => {
// Three parts: HH:MM:SS
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(h, m, s)
}
_ => {
return Err(eyre!(
"Invalid time format. Use 'hh:mm:ss', 'hh:mm', or 'mm'"
));
}
};
now.replace_time(
time::Time::from_hms(hour, minute, second).map_err(|_| eyre!("Invalid time"))?,
)
};
let mut duration_secs = (target_time - now).whole_seconds();
// `Since` for past times
if duration_secs < 0 {
duration_secs *= -1;
Ok(DirectedDuration::Since(Duration::from_secs(
duration_secs as u64,
)))
} else
// `Until` for future times,
{
Ok(DirectedDuration::Until(Duration::from_secs(
duration_secs as u64,
)))
}
}
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
let parts: Vec<&str> = arg.split(':').collect();
@ -371,6 +477,7 @@ pub fn parse_long_duration(arg: &str) -> Result<Duration, Report> {
#[cfg(test)]
mod tests {
use super::ClockDuration;
use super::*;
use std::time::Duration;
@ -493,42 +600,6 @@ mod tests {
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
}
#[test]
fn test_parse_duration_by_time() {
// YYYY-MM-DD HH:MM:SS - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30:45"),
Ok(DirectedDuration::Until(_))
));
// YYYY-MM-DD HH:MM - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30"),
Ok(DirectedDuration::Until(_))
));
// HH:MM:SS - past
assert!(matches!(
parse_duration_by_time("2000-01-01 23:59:59"),
Ok(DirectedDuration::Since(_))
));
// HH:MM - Until or Since depending on current time
assert!(parse_duration_by_time("18:00").is_ok());
// MM - Until or Since depending on current time
assert!(parse_duration_by_time("45").is_ok());
// errors
assert!(parse_duration_by_time("60").is_err()); // invalid minutes
assert!(parse_duration_by_time("24:00").is_err()); // invalid hours
assert!(parse_duration_by_time("24:00:00").is_err()); // invalid hours
assert!(parse_duration_by_time("2030-13-01 12:00:00").is_err()); // invalid month
assert!(parse_duration_by_time("2030-06-32 12:00:00").is_err()); // invalid day
assert!(parse_duration_by_time("abc").is_err()); // invalid input
assert!(parse_duration_by_time("01:02:03:04").is_err()); // too many parts
}
#[test]
fn test_parse_long_duration() {
// `Yy`
@ -607,11 +678,11 @@ mod tests {
);
// MAX_DURATION clamping
assert_eq!(parse_long_duration("1000y").unwrap(), MAX_DURATION);
assert_eq!(parse_long_duration("10000y").unwrap(), MAX_DURATION);
assert_eq!(
parse_long_duration("999y 364d 23:59:59").unwrap(),
parse_long_duration("9999y 364d 23:59:59").unwrap(),
Duration::from_secs(
999 * YEAR_IN_SECONDS
9999 * YEAR_IN_SECONDS
+ 364 * DAY_IN_SECONDS
+ 23 * HOUR_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 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 ratatui::layout::Position;
use std::{pin::Pin, time::Duration};
use tokio::sync::mpsc;
use tokio::time::interval;
@ -20,13 +21,13 @@ pub enum TuiEvent {
Error,
Tick,
Render,
Key(KeyEvent),
Resize,
Crossterm(CrosstermEvent),
}
#[derive(Clone, Debug)]
pub enum AppEvent {
ClockDone(ClockTypeId, String),
SetCursor(Option<Position>),
}
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
@ -89,14 +90,13 @@ fn crossterm_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
EventStream::new()
.fuse()
// we are not interested in all events
.filter_map(|event| async move {
match event {
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
Some(TuiEvent::Key(key))
}
Ok(CrosstermEvent::Resize(_, _)) => Some(TuiEvent::Resize),
.filter_map(|result| async move {
match result {
// filter `KeyEventKind::Press` out to ignore all the other `CrosstermEvent::Key` events
Ok(CrosstermEvent::Key(key)) => (key.kind == KeyEventKind::Press)
.then_some(TuiEvent::Crossterm(CrosstermEvent::Key(key))),
Ok(other) => Some(TuiEvent::Crossterm(other)),
Err(_) => Some(TuiEvent::Error),
_ => None,
}
}),
)

View File

@ -2,6 +2,7 @@ mod app;
mod common;
mod config;
mod constants;
mod event;
mod events;
mod logging;

View File

@ -1,5 +1,6 @@
use crate::{
common::{AppTimeFormat, Content, Style, Toggle},
event::Event,
widgets::pomodoro::Mode as PomodoroMode,
};
use color_eyre::eyre::Result;
@ -44,6 +45,8 @@ pub struct AppStorage {
pub elapsed_value_countdown: Duration,
// timer
pub current_value_timer: Duration,
// event
pub event: Event,
// footer
pub footer_app_time: Toggle,
}
@ -75,6 +78,8 @@ impl Default for AppStorage {
elapsed_value_countdown: Duration::ZERO,
// timer
current_value_timer: Duration::ZERO,
// event
event: Event::default(),
// footer
footer_app_time: Toggle::Off,
}

View File

@ -6,6 +6,7 @@ pub mod clock_elements_test;
pub mod clock_test;
pub mod countdown;
pub mod edit_time;
pub mod event;
pub mod footer;
pub mod header;
pub mod local_time;

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,8 @@ pub const DIGIT_SIZE: usize = 5;
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */;
pub const TWO_DIGITS_WIDTH: u16 = DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit
pub const THREE_DIGITS_WIDTH: u16 =
DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit
pub const THREE_DIGITS_WIDTH: u16 = TWO_DIGITS_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 DOT_WIDTH: u16 = 4; // incl. padding left + padding right
pub const DIGIT_SPACE_WIDTH: u16 = 1; // space between digits

View File

@ -1,7 +1,8 @@
use crate::{
common::ClockTypeId,
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::*,
};
@ -76,115 +77,166 @@ fn test_get_format_hours() {
#[test]
fn test_format_by_duration_boundaries() {
// 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
assert_eq!(format_by_duration(&(10 * ONE_SECOND).into()), Format::Ss);
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_SECOND).into()),
Format::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
assert_eq!(format_by_duration(&ONE_MINUTE.into()), Format::MSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_MINUTE.into()),
Format::MSs
);
// HhMmSs
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
);
// DHhMmSs
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_DAY.into()),
Format::DHhMmSs
);
// DHhMmSs
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
);
// 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
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
);
// DddHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(100 * ONE_DAY).into()),
Format::DddHhMmSs
);
// DddHhMmSs
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
);
// YDHhMmSs
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
Format::YDHhMmSs
);
// YDdHhMmSs
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
);
// YDddHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YDddHhMmSs
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
);
// YyDHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs
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
);
// YyDdHhMmSs
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
);
// YyDddHhMmSs
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
);
// YyDddHhMmSs
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
);
// YyyDHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs
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
);
// YyyDdHhMmSs
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
);
// YyyDddHhMmSs
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
);
// 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]
fn test_format_by_duration_days() {
// DHhMmSs
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_DAY.into()),
Format::DHhMmSs
);
// 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
assert_eq!(
format_by_duration(&(101 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(101 * ONE_DAY).into()),
Format::DddHhMmSs
);
}
@ -192,59 +244,62 @@ fn test_format_by_duration_days() {
#[test]
fn test_format_by_duration_years() {
// 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)
assert_eq!(
format_by_duration(&(ONE_YEAR + ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + ONE_DAY).into()),
Format::YDHhMmSs
);
// YDdHhMmSs (1 year, 10 days)
assert_eq!(
format_by_duration(&(ONE_YEAR + 10 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + 10 * ONE_DAY).into()),
Format::YDdHhMmSs
);
// YDddHhMmSs (1 year, 100 days)
assert_eq!(
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs (10 years)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs (10 years, 10 days)
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
);
// YyDddHhMmSs (10 years, 100 days)
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
);
// YyyDHhMmSs (100 years)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs (100 years, 10 days)
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
);
// YyyDddHhMmSs (100 years, 100 days)
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
);
}

View File

@ -9,7 +9,7 @@ use crate::{
edit_time::{EditTimeState, EditTimeStateArgs, EditTimeWidget},
},
};
use crossterm::event::KeyModifiers;
use crossterm::event::{Event as CrosstermEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
@ -163,102 +163,106 @@ impl TuiEventHandler for CountdownState {
}
}
// EDIT CLOCK mode
TuiEvent::Key(key) if self.is_clock_edit_mode() => match key.code {
// skip editing
KeyCode::Esc => {
// Important: set current value first
self.clock.set_current_value(*self.clock.get_prev_value());
// before toggling back to non-edit mode
self.clock.toggle_edit();
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// toggle edit mode
self.clock.toggle_edit();
// set initial value
self.clock
.set_initial_value(*self.clock.get_current_value());
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// Apply changes
KeyCode::Char('s') => {
// toggle edit mode
self.clock.toggle_edit();
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
KeyCode::Right => {
self.clock.edit_prev();
}
KeyCode::Left => {
self.clock.edit_next();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
KeyCode::Up => {
self.clock.edit_up();
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => {
self.clock.edit_down();
}
_ => return Some(event),
},
// EDIT LOCAL TIME mode
TuiEvent::Key(key) if self.is_time_edit_mode() => match key.code {
// skip editing
KeyCode::Esc => {
self.edit_time = None;
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(edit_time) = &mut self.edit_time.clone() {
// Order matters:
// 1. update current value
self.edit_time_done(edit_time);
// 2. set initial value
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_clock_edit_mode() => {
match key.code {
// skip editing
KeyCode::Esc => {
// Important: set current value first
self.clock.set_current_value(*self.clock.get_prev_value());
// before toggling back to non-edit mode
self.clock.toggle_edit();
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// toggle edit mode
self.clock.toggle_edit();
// set initial value
self.clock
.set_initial_value(*self.clock.get_current_value());
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// Apply changes of editing by local time
KeyCode::Char('s') => {
if let Some(edit_time) = &mut self.edit_time.clone() {
self.edit_time_done(edit_time)
// Apply changes
KeyCode::Char('s') => {
// toggle edit mode
self.clock.toggle_edit();
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// always reset `elapsed_clock`
self.elapsed_clock.reset();
KeyCode::Right => {
self.clock.edit_prev();
}
KeyCode::Left => {
self.clock.edit_next();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_up();
}
KeyCode::Up => {
self.clock.edit_up();
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clock.edit_jump_down();
}
KeyCode::Down => {
self.clock.edit_down();
}
_ => return Some(event),
}
// move edit position to the left
KeyCode::Left => {
// safe unwrap because we are in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().next();
}
// EDIT LOCAL TIME mode
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_time_edit_mode() => {
match key.code {
// skip editing
KeyCode::Esc => {
self.edit_time = None;
}
// Apply changes and set new initial value
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(edit_time) = &mut self.edit_time.clone() {
// Order matters:
// 1. update current value
self.edit_time_done(edit_time);
// 2. set initial value
self.clock
.set_initial_value(*self.clock.get_current_value());
}
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// Apply changes of editing by local time
KeyCode::Char('s') => {
if let Some(edit_time) = &mut self.edit_time.clone() {
self.edit_time_done(edit_time)
}
// always reset `elapsed_clock`
self.elapsed_clock.reset();
}
// move edit position to the left
KeyCode::Left => {
// safe unwrap because we are in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().next();
}
// move edit position to the right
KeyCode::Right => {
// safe unwrap because we are in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().prev();
}
// Value up
KeyCode::Up => {
// safe unwrap because of previous check in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().up();
}
// Value down
KeyCode::Down => {
// safe unwrap because of previous check in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().down();
}
_ => return Some(event),
}
// move edit position to the right
KeyCode::Right => {
// safe unwrap because we are in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().prev();
}
// Value up
KeyCode::Up => {
// safe unwrap because of previous check in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().up();
}
// Value down
KeyCode::Down => {
// safe unwrap because of previous check in `is_time_edit_mode`
self.edit_time.as_mut().unwrap().down();
}
_ => return Some(event),
},
}
// default mode
TuiEvent::Key(key) => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
KeyCode::Char('r') => {
// reset both clocks to use intial values
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;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let content_labels: BTreeMap<Content, &str> = BTreeMap::from([
(Content::Countdown, "[c]ountdown"),
(Content::Timer, "[t]imer"),
(Content::Pomodoro, "[p]omodoro"),
(Content::LocalTime, "[l]ocal time"),
(Content::Countdown, "[1]countdown"),
(Content::Timer, "[2]timer"),
(Content::Pomodoro, "[3]pomodoro"),
(Content::Event, "[4]event"),
(Content::LocalTime, "[0]local time"),
]);
let [_, area] =
@ -84,7 +85,7 @@ impl StatefulWidget for Footer {
.render(border_area, buf);
// show menu
if state.show_menu {
let content_labels: Vec<Span> = content_labels
let mut content_labels: Vec<Span> = content_labels
.iter()
.enumerate()
.map(|(index, (content, label))| {
@ -102,6 +103,13 @@ impl StatefulWidget for Footer {
})
.collect();
content_labels.extend_from_slice(&[
Span::from(SPACE),
Span::from("[→]next"),
Span::from(SPACE),
Span::from("[←]prev."),
]);
const SPACE: &str = " "; // 2 empty spaces
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
let mut table_rows = vec![
@ -135,6 +143,7 @@ impl StatefulWidget for Footer {
]),
];
// Controls (except for `localtime`)
if self.selected_content != Content::LocalTime {
table_rows.extend_from_slice(&[
// controls - 1. row
@ -145,7 +154,7 @@ impl StatefulWidget for Footer {
)),
Cell::from(Line::from({
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 {
"[s]top"
} else {
@ -173,8 +182,16 @@ impl StatefulWidget for Footer {
}
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")];
if self.selected_content == Content::Event {
spans[0] = Span::from("[enter]save changes")
};
if self.selected_content == Content::Countdown
|| self.selected_content == Content::Pomodoro
{
@ -187,60 +204,72 @@ impl StatefulWidget for Footer {
Span::from(SPACE),
Span::from("[esc]skip changes"),
]);
if self.selected_content == Content::Event {
spans.extend_from_slice(&[
Span::from(SPACE),
Span::from("[tab]switch input"),
]);
}
spans
}
_ => vec![],
}
})),
]),
// controls - 2. row
Row::new(vec![
Cell::from(Line::from("")),
Cell::from(Line::from({
match self.app_edit_mode {
AppEditMode::None => {
let mut spans = vec![];
if self.selected_content == Content::Pomodoro {
spans.extend_from_slice(&[Span::from(
"[← →]switch work/pause",
)]);
Row::new(if self.selected_content == Content::Event {
vec![]
} else {
vec![
Cell::from(Line::from("")),
Cell::from(Line::from({
match self.app_edit_mode {
AppEditMode::None => {
let mut spans = vec![];
if self.selected_content == Content::Pomodoro {
spans.extend_from_slice(&[Span::from(
"[^←] or [^→] switch work/pause",
)]);
}
spans
}
spans
_ => vec![
Span::from(format!(
// ← →,
"[{} {}]change selection",
scrollbar::HORIZONTAL.begin,
scrollbar::HORIZONTAL.end
)),
Span::from(SPACE),
Span::from(format!(
// ↑
"[{}]edit up",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↑
"[^{}]edit up 10x",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!(
// ↓
"[{}]edit up",
scrollbar::VERTICAL.end
)),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↓
"[^{}]edit up 10x",
scrollbar::VERTICAL.end
)),
],
}
_ => vec![
Span::from(format!(
// ← →,
"[{} {}]change selection",
scrollbar::HORIZONTAL.begin,
scrollbar::HORIZONTAL.end
)),
Span::from(SPACE),
Span::from(format!(
// ↑
"[{}]edit up",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↑
"[^{}]edit up 10x",
scrollbar::VERTICAL.begin
)),
Span::from(SPACE),
Span::from(format!(
// ↓
"[{}]edit up",
scrollbar::VERTICAL.end
)),
Span::from(SPACE),
Span::from(format!(
// ctrl + ↓
"[^{}]edit up 10x",
scrollbar::VERTICAL.end
)),
],
}
})),
]),
})),
]
}),
])
}

View File

@ -8,7 +8,7 @@ use ratatui::{
use crate::{
common::{AppTime, AppTimeFormat, Style as DigitStyle},
duration::DurationEx,
duration::{ClockDuration, DurationEx},
events::{TuiEvent, TuiEventHandler},
utils::center,
widgets::clock_elements::{

View File

@ -5,7 +5,7 @@ use crate::{
utils::center,
widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown},
};
use crossterm::event::{KeyCode, KeyModifiers};
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
@ -149,7 +149,7 @@ impl TuiEventHandler for PomodoroState {
self.get_clock_mut().update_done_count();
}
// EDIT mode
TuiEvent::Key(key) if edit_mode => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code {
// Skip changes
KeyCode::Esc => {
let clock = self.get_clock_mut();
@ -188,7 +188,7 @@ impl TuiEventHandler for PomodoroState {
_ => return Some(event),
},
// default mode
TuiEvent::Key(key) => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
// Toggle run/pause
KeyCode::Char('s') => {
self.get_clock_mut().toggle_pause();
@ -198,12 +198,12 @@ impl TuiEventHandler for PomodoroState {
self.get_clock_mut().toggle_edit();
}
// 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
self.next();
}
// toggle WORK/PAUSE
KeyCode::Right => {
KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.next();
}
// reset rounds AND clocks

View File

@ -4,7 +4,7 @@ use crate::{
utils::center,
widgets::clock::{self, ClockState, ClockWidget},
};
use crossterm::event::KeyModifiers;
use crossterm::event::{Event as CrosstermEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
crossterm::event::KeyCode,
@ -41,7 +41,7 @@ impl TuiEventHandler for TimerState {
self.clock.update_done_count();
}
// EDIT mode
TuiEvent::Key(key) if edit_mode => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code {
// Skip changes
KeyCode::Esc => {
// Important: set current value first
@ -78,7 +78,7 @@ impl TuiEventHandler for TimerState {
_ => return Some(event),
},
// default mode
TuiEvent::Key(key) => match key.code {
TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code {
// Toggle run/pause
KeyCode::Char('s') => {
self.clock.toggle_pause();