Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39cac83d5 | ||
|
|
eb376e4015 | ||
|
|
ac2863cebc | ||
|
|
3f4acec9f5 | ||
|
|
2277eeb033 | ||
|
|
cb6c2d5142 | ||
|
|
6dc7eb81c2 | ||
|
|
816741f842 | ||
|
|
40eb602953 | ||
|
|
d5bf7f32a6 | ||
|
|
9ff65e5c8e | ||
|
|
24eb471df8 | ||
|
|
0521c57695 | ||
|
|
f9a2e18179 | ||
|
|
bac2e356e1 | ||
|
|
60392b40ed | ||
|
|
901cf69472 | ||
|
|
c494f0e829 | ||
|
|
637c1da21b | ||
|
|
3439e4aa8d | ||
|
|
5bc37f005f | ||
|
|
bfa40fd8f1 | ||
|
|
93a3cde396 | ||
|
|
d27587a44a | ||
|
|
5f4c5bb8ed | ||
|
|
44af71c01c | ||
|
|
c370d3096b | ||
|
|
aae5c38cd6 | ||
|
|
5ad09b9848 | ||
|
|
52ed8267be | ||
|
|
6b068bbd09 | ||
|
|
c96432779a | ||
|
|
509cf73cdd | ||
|
|
e6291a3131 | ||
|
|
90d9988e7a | ||
|
|
028a067419 | ||
|
|
675428cfb0 | ||
|
|
28c5d7194c | ||
|
|
5b445afe25 | ||
|
|
beb12d5ec2 | ||
|
|
d9399eafc9 | ||
|
|
ffad78e093 | ||
|
|
e7a5a1b2da | ||
|
|
6f0df4d488 | ||
|
|
3d9b235f12 | ||
|
|
e094d7d81b | ||
|
|
843c4d019d | ||
|
|
e95ecb9e9c | ||
|
|
886deb3311 | ||
|
|
7ff167368d | ||
|
|
a54b1b409a | ||
|
|
8f50bc5fc6 | ||
|
|
d3c436da0b | ||
|
|
97787f718d | ||
|
|
557fcf95f0 |
2
.github/workflows/release.yml
vendored
@@ -4,8 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "release/**"
|
- "release/**"
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
get-version:
|
get-version:
|
||||||
|
|||||||
7
.gitignore
vendored
@@ -18,3 +18,10 @@ result/**/*
|
|||||||
#.idea/
|
#.idea/
|
||||||
#
|
#
|
||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
|
# ignore (possible) sound files
|
||||||
|
**/*.{mp3,wav}
|
||||||
|
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
style_edition = "2024"
|
||||||
reorder_imports = true
|
reorder_imports = true
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
// Folder-specific settings
|
|
||||||
//
|
|
||||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
|
||||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
|
||||||
{
|
|
||||||
"format_on_save": "on",
|
|
||||||
"formatter": "language_server",
|
|
||||||
"lsp": {
|
|
||||||
"rust-analyzer": {
|
|
||||||
"initialization_options": {
|
|
||||||
"check": {
|
|
||||||
"command": "clippy" // rust-analyzer.check.command (default: "check")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"Nix": {
|
|
||||||
"formatter": {
|
|
||||||
"external": {
|
|
||||||
"command": "alejandra",
|
|
||||||
"arguments": [
|
|
||||||
"-q"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"format_on_save": "on"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
90
CHANGELOG.md
@@ -1,5 +1,95 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [unreleased]
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Rust 1.90.0 [#95](https://github.com/sectore/timr-tui/pull/95)
|
||||||
|
|
||||||
|
## v1.4.0 - 2025-09-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- (screen): Local Time [#89](https://github.com/sectore/timr-tui/pull/89), [#90](https://github.com/sectore/timr-tui/pull/90), [#91](https://github.com/sectore/timr-tui/pull/91)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Rust 1.89.0 [#87](https://github.com/sectore/timr-tui/pull/87)
|
||||||
|
|
||||||
|
## v1.3.1 - 2025-07-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- (args) set `content` by given duration [#81](https://github.com/sectore/timr-tui/pull/81)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- (pomodoro) `ctrl+r` resets rounds AND both clocks [#83](https://github.com/sectore/timr-tui/pull/83)
|
||||||
|
- (pomodoro) reset active clock only [#82](https://github.com/sectore/timr-tui/pull/82)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Rust 1.88.0 [#85](https://github.com/sectore/timr-tui/pull/85)
|
||||||
|
|
||||||
|
## v1.3.0 - 2025-05-06
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
- (pomodoro) Count WORK rounds [#75](https://github.com/sectore/timr-tui/pull/75), [6b068bb](https://github.com/sectore/timr-tui/commit/6b068bbd094d9ec1a36b47598fadfc71296d9590)
|
||||||
|
- (pomodoro/countdown) Change initial value [#79](https://github.com/sectore/timr-tui/pull/79), [aae5c38](https://github.com/sectore/timr-tui/commit/aae5c38cd6a666d5ba418b12fb67879a2146b9a2)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Update keybindings [#76](https://github.com/sectore/timr-tui/pull/76)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (flake) use alsa-lib-with-plugins [#77](https://github.com/sectore/timr-tui/pull/77)
|
||||||
|
- (readme) add keybindings + toc [#78](https://github.com/sectore/timr-tui/pull/78)
|
||||||
|
|
||||||
|
## v1.2.1 - 2025-04-17
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- (countdown) Reset `Mission Elapsed Time (MET)` if `countdown` is set by _cli arguments_ [#71](https://github.com/sectore/timr-tui/pull/71)
|
||||||
|
- (countdown) Reset `Mission Elapsed Time (MET)` while setting `countdown` by _local time_ [#72](https://github.com/sectore/timr-tui/pull/72)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Use latest `Rust 1.86` [#73](https://github.com/sectore/timr-tui/pull/73)
|
||||||
|
- (cargo) Exclude files for packaging [e7a5a1b](https://github.com/sectore/timr-tui/commit/e7a5a1b2da7a7967f2602a0b92f391ac768ca638)
|
||||||
|
- (just) `group` commands [#70](https://github.com/sectore/timr-tui/pull/70)
|
||||||
|
|
||||||
|
## v1.2.0 - 2025-02-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- (notification) Clock animation (blink) by reaching `done` mode (optional) [#65](https://github.com/sectore/timr-tui/pull/65)
|
||||||
|
- (notification) Native desktop notification (optional, experimental) [#59](https://github.com/sectore/timr-tui/pull/59)
|
||||||
|
- (notification) Sound notification (optional, experimental, available in local build only) [#62](https://github.com/sectore/timr-tui/pull/62)
|
||||||
|
- (logging) Add `--log` arg to enable logs [e094d7d](https://github.com/sectore/timr-tui/commit/e094d7d81b99f58f0d3bc50124859a4e1f6dbe4f)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (refactor) Extend event handling for using a `mpsc` channel to send `AppEvent`'s from anywhere. [#61](https://github.com/sectore/timr-tui/pull/61)
|
||||||
|
- (extension) Use `set_panic_hook` for better error handling [#67](https://github.com/sectore/timr-tui/pull/67)
|
||||||
|
- (deps) Use latest `Rust 1.85` and `Rust 2024 Edition`. Refactor `flake` to consider `rust-toolchain.toml` etc. [#68](https://github.com/sectore/timr-tui/pull/68)
|
||||||
|
|
||||||
|
## v1.1.0 - 2025-01-22
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- (countdown) Edit countdown by local time [#49](https://github.com/sectore/timr-tui/pull/49)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- (ci) Build statically linked binaries for Linux [#55](https://github.com/sectore/timr-tui/pull/55)
|
||||||
|
- (ci) Remove magic nix cache action (#57) [#56](https://github.com/sectore/timr-tui/issues/56)
|
||||||
|
|
||||||
|
### Misc.
|
||||||
|
|
||||||
|
- (deps) Latest Rust 1.84, update deps [#48](https://github.com/sectore/timr-tui/pull/48)
|
||||||
|
|
||||||
## v1.0.0 - 2025-01-10
|
## v1.0.0 - 2025-01-10
|
||||||
|
|
||||||
Happy `v1.0.0` 🎉
|
Happy `v1.0.0` 🎉
|
||||||
|
|||||||
18
CONTRIBUTING.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Any feedback / contribution are welcome. Just open an `issue`, a `PR` or start a `discussion`.
|
||||||
|
|
||||||
|
## Code style / conventions
|
||||||
|
|
||||||
|
- Try to write [clean, idiomatic Rust code](https://github.com/mre/idiomatic-rust).
|
||||||
|
- Keep code [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) whenever it makes sense.
|
||||||
|
- Before pushing any code make sure to run `clippy` and `fmt`. Check provided [`just`](./jusfile) file to run such commands, [CI](https://github.com/sectore/timr-tui/blob/main/.github/workflows/ci.yml) will do the same.
|
||||||
|
- Have fun to write code.
|
||||||
|
|
||||||
|
## Design files
|
||||||
|
|
||||||
|
Use [Figma Design file](https://www.figma.com/community/file/1553076532392275586/timr-tui) to suggest design changes
|
||||||
|
|
||||||
|
## AI
|
||||||
|
|
||||||
|
Always understand what AI provides to you. Never push any code based on [`vibe coding`](https://en.wikipedia.org/wiki/Vibe_coding) you or anybody else can't follow. Make sure your agent still follows all code styles and conventions suggested above. Use AI for better, not for worse code.
|
||||||
2088
Cargo.lock
generated
41
Cargo.toml
@@ -1,29 +1,48 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "timr-tui"
|
name = "timr-tui"
|
||||||
version = "1.1.0-alpha"
|
version = "1.4.0"
|
||||||
description = "TUI to organize your time: Pomodoro, Countdown, Timer."
|
description = "TUI to organize your time: Pomodoro, Countdown, Timer."
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.84.0"
|
# Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`.
|
||||||
|
rust-version = "1.90.0"
|
||||||
homepage = "https://github.com/sectore/timr-tui"
|
homepage = "https://github.com/sectore/timr-tui"
|
||||||
repository = "https://github.com/sectore/timr-tui"
|
repository = "https://github.com/sectore/timr-tui"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
keywords = ["tui", "timer", "countdown", "pomodoro"]
|
keywords = ["tui", "timer", "countdown", "pomodoro"]
|
||||||
categories = ["command-line-utilities"]
|
categories = ["command-line-utilities"]
|
||||||
|
exclude = [
|
||||||
|
".github/*",
|
||||||
|
"demo/*.tape",
|
||||||
|
"result/*",
|
||||||
|
"*.mp3",
|
||||||
|
".claude",
|
||||||
|
"CLAUDE.md",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29.0"
|
ratatui = "0.29.0"
|
||||||
crossterm = {version = "0.28.1", features = ["event-stream", "serde"] }
|
crossterm = { version = "0.28.1", features = ["event-stream", "serde"] }
|
||||||
color-eyre = "0.6.2"
|
color-eyre = "0.6.5"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
strum = { version = "0.26.3", features = ["derive"] }
|
strum = { version = "0.26.3", features = ["derive"] }
|
||||||
tokio = { version = "1.41.1", features = ["full"] }
|
tokio = { version = "1.47.1", features = ["full"] }
|
||||||
tokio-stream = "0.1.16"
|
tokio-stream = "0.1.17"
|
||||||
tokio-util = "0.7.12"
|
tokio-util = "0.7.16"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||||
directories = "5.0.1"
|
directories = "5.0.1"
|
||||||
clap = { version = "4.5.23", features = ["derive"] }
|
clap = { version = "4.5.48", features = ["derive"] }
|
||||||
time = { version = "0.3.37", features = ["formatting", "local-offset"] }
|
time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros"] }
|
||||||
|
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 }
|
||||||
|
|
||||||
|
|
||||||
|
[features]
|
||||||
|
sound = ["dep:rodio", "dep:thiserror"]
|
||||||
|
|||||||
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 Jens K.
|
Copyright (c) 2024-2025 Jens Krause
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
228
README.md
@@ -8,9 +8,21 @@ TUI to organize your time: Pomodoro, Countdown, Timer.
|
|||||||
|
|
||||||
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
|
Built with [Ratatui](https://ratatui.rs/) / [Rust 🦀](https://www.rust-lang.org/).
|
||||||
|
|
||||||
# Features
|
|
||||||
|
|
||||||
_Side note:_ Theme colors depend on your terminal preferences.
|
# Table of Contents
|
||||||
|
|
||||||
|
- [Preview](./#preview)
|
||||||
|
- [CLI](./#cli)
|
||||||
|
- [Keybindings](./#keybindings)
|
||||||
|
- [Installation](./#installation)
|
||||||
|
- [Development](./#development)
|
||||||
|
- [Misc](./#misc)
|
||||||
|
- [Contributing](./#contributing)
|
||||||
|
- [License](./#license)
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
|
||||||
|
_(theme depends on your terminal preferences)_
|
||||||
|
|
||||||
## Pomodoro
|
## Pomodoro
|
||||||
|
|
||||||
@@ -30,10 +42,23 @@ _Side note:_ Theme colors depend on your terminal preferences.
|
|||||||
<img alt="countdown" src="demo/countdown.gif" />
|
<img alt="countdown" src="demo/countdown.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Change style
|
## Countdown: Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
|
||||||
|
|
||||||
<a href="demo/style.gif">
|
<a href="demo/countdown-met.gif">
|
||||||
<img alt="style" src="demo/style.gif" />
|
<img alt="menu" src="demo/countdown-met.gif" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
## Local time
|
||||||
|
|
||||||
|
<a href="demo/local-time.gif">
|
||||||
|
<img alt="menu" src="demo/local-time.gif" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Local time (footer)
|
||||||
|
|
||||||
|
<a href="demo/local-time-footer.gif">
|
||||||
|
<img alt="menu" src="demo/local-time-footer.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Toggle deciseconds
|
## Toggle deciseconds
|
||||||
@@ -42,24 +67,18 @@ _Side note:_ Theme colors depend on your terminal preferences.
|
|||||||
<img alt="deciseconds" src="demo/decis.gif" />
|
<img alt="deciseconds" src="demo/decis.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## Change style
|
||||||
|
|
||||||
|
<a href="demo/style.gif">
|
||||||
|
<img alt="style" src="demo/style.gif" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Menu
|
## Menu
|
||||||
|
|
||||||
<a href="demo/menu.gif">
|
<a href="demo/menu.gif">
|
||||||
<img alt="menu" src="demo/menu.gif" />
|
<img alt="menu" src="demo/menu.gif" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Local time
|
|
||||||
|
|
||||||
<a href="demo/local-time.gif">
|
|
||||||
<img alt="menu" src="demo/local-time.gif" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Mission Elapsed Time ([MET](https://en.wikipedia.org/wiki/Mission_Elapsed_Time))
|
|
||||||
|
|
||||||
<a href="demo/countdown-met.gif">
|
|
||||||
<img alt="menu" src="demo/countdown-met.gif" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
# CLI
|
# CLI
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -68,18 +87,103 @@ timr-tui --help
|
|||||||
Usage: timr-tui [OPTIONS]
|
Usage: timr-tui [OPTIONS]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c, --countdown <COUNTDOWN> Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 10:00]
|
-c, --countdown <COUNTDOWN>
|
||||||
-w, --work <WORK> Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 25:00]
|
Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'.
|
||||||
-p, --pause <PAUSE> Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss' [default: 5:00]
|
--countdown-target <COUNTDOWN_TARGET>
|
||||||
-d, --decis Wether to show deciseconds or not. [default: false]
|
Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm' [aliases: --ct]
|
||||||
-m, --mode <MODE> Mode to start with. [possible values: countdown, timer, pomodoro] [default: timer]
|
-w, --work <WORK>
|
||||||
--menu Whether to open the menu or not.
|
Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
|
||||||
-s, --style <STYLE> Style to display time with. [possible values: full, light, medium, dark, thick, cross, braille] [default: full]
|
-p, --pause <PAUSE>
|
||||||
-r, --reset Reset stored values to default.
|
Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'
|
||||||
-h, --help Print help
|
-d, --decis
|
||||||
-V, --version Print version
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Extra option (if `--features sound` is enabled by local build only):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
--sound <SOUND> Path to sound file (.mp3 or .wav) to play as notification. Experimental.
|
||||||
|
```
|
||||||
|
|
||||||
|
# Keybindings
|
||||||
|
|
||||||
|
## Menu
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>↑</kbd> / <kbd>↓</kbd> or <kbd>m</kbd> | Toggle menu |
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>p</kbd> | Pomodoro |
|
||||||
|
| <kbd>c</kbd> | Countdown |
|
||||||
|
| <kbd>t</kbd> | Timer |
|
||||||
|
| <kbd>l</kbd> | Local Time |
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>s</kbd> | start |
|
||||||
|
| <kbd>r</kbd> | reset |
|
||||||
|
| <kbd>e</kbd> | enter edit mode |
|
||||||
|
| <kbd>q</kbd> | quit |
|
||||||
|
|
||||||
|
**In `edit` mode only:**
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>s</kbd> | save changes |
|
||||||
|
| <kbd>Esc</kbd> | skip changes |
|
||||||
|
| <kbd>←</kbd> or <kbd>→</kbd> | change selection |
|
||||||
|
| <kbd>↑</kbd> | edit to go up |
|
||||||
|
| <kbd>ctrl+↑</kbd> | edit to go up 10x |
|
||||||
|
| <kbd>↓</kbd> | edit to go down |
|
||||||
|
| <kbd>ctrl+↓</kbd> | edit to go down 10x |
|
||||||
|
|
||||||
|
**In `Pomodoro` screen only:**
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>←</kbd> or <kbd>→</kbd> | switch work/pause |
|
||||||
|
| <kbd>ctrl+r</kbd> | reset round |
|
||||||
|
| <kbd>ctrl+s</kbd> | save initial value |
|
||||||
|
|
||||||
|
**In `Countdown` screen only:**
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>ctrl+e</kbd> | edit by local time |
|
||||||
|
| <kbd>ctrl+s</kbd> | save initial value |
|
||||||
|
|
||||||
|
## Appearance
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| <kbd>,</kbd> | toggle styles |
|
||||||
|
| <kbd>.</kbd> | toggle deciseconds |
|
||||||
|
| <kbd>:</kbd> | toggle local time |
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
## Cargo
|
## Cargo
|
||||||
@@ -104,12 +208,10 @@ Install [from the AUR](https://aur.archlinux.org/packages/timr/):
|
|||||||
paru -S timr
|
paru -S timr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Release binaries
|
## Release binaries
|
||||||
|
|
||||||
Pre-built artifacts are available to download from [latest GitHub release](https://github.com/sectore/timr-tui/releases).
|
Pre-built artifacts are available to download from [latest GitHub release](https://github.com/sectore/timr-tui/releases).
|
||||||
|
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -120,7 +222,6 @@ Pre-built artifacts are available to download from [latest GitHub release](https
|
|||||||
|
|
||||||
If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to install dependencies. In other case run `nix develop`.
|
If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to install dependencies. In other case run `nix develop`.
|
||||||
|
|
||||||
|
|
||||||
### Non Nix users
|
### Non Nix users
|
||||||
|
|
||||||
- [`Rust`](https://www.rust-lang.org/learn/get-started)
|
- [`Rust`](https://www.rust-lang.org/learn/get-started)
|
||||||
@@ -131,34 +232,63 @@ If you have [`direnv`](https://direnv.net) installed, run `direnv allow` once to
|
|||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just --list
|
just
|
||||||
|
|
||||||
Available recipes:
|
Available recipes:
|
||||||
build # build app
|
default # list commands
|
||||||
b # alias for `build`
|
|
||||||
default
|
[build]
|
||||||
format # format files
|
build # build app [alias: b]
|
||||||
f # alias for `format`
|
|
||||||
lint # lint
|
[demo]
|
||||||
l # alias for `lint`
|
demo-blink # build demo: blink animation [alias: db]
|
||||||
run # run app
|
demo-countdown # build demo: countdown [alias: dc]
|
||||||
r # alias for `run`
|
demo-countdown-met # build demo: countdown + met [alias: dcm]
|
||||||
test # run tests
|
demo-decis # build demo: deciseconds [alias: dd]
|
||||||
t # alias for `test`
|
demo-local-time # build demo: local time [alias: dlt]
|
||||||
|
demo-local-time-footer # build demo: local time (footer) [alias: dltf]
|
||||||
|
demo-menu # build demo: menu [alias: dm]
|
||||||
|
demo-pomodoro # build demo: pomodoro [alias: dp]
|
||||||
|
demo-rocket-countdown # build demo: rocket countdown [alias: drc]
|
||||||
|
demo-style # build demo: styles [alias: ds]
|
||||||
|
demo-timer # build demo: timer [alias: dt]
|
||||||
|
|
||||||
|
[dev]
|
||||||
|
run # run app [alias: r]
|
||||||
|
run-args args # run app with arguments. It expects arguments as a string (e.g. "-c 5:00"). [alias: ra]
|
||||||
|
run-sound path # run app while sound feature is enabled. It expects a path to a sound file. [alias: rs]
|
||||||
|
run-sound-args path args # run app while sound feature is enabled by adding a path to a sound file and other arguments as string (e.g. "-c 5:00"). [alias: rsa]
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
format # format files [alias: f]
|
||||||
|
lint # lint [alias: l]
|
||||||
|
|
||||||
|
[test]
|
||||||
|
test # run tests [alias: t]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
- Linux
|
- Linux
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix build
|
nix build
|
||||||
|
# or for bulding w/ statically linked binaries
|
||||||
|
nix build .#linuxStatic
|
||||||
```
|
```
|
||||||
|
|
||||||
- Windows (cross-compilation)
|
- Windows (cross-compilation)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix build .#windows
|
nix build .#windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Run tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
# Misc.
|
# Misc.
|
||||||
|
|
||||||
## Persistant app state
|
## Persistant app state
|
||||||
@@ -176,7 +306,9 @@ C:/Users/{user}/AppData/Local/timr-tui/data/app.data
|
|||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
In `debug` mode only. Locations:
|
To get log output, start the app by passing `--log` to `timr-tui`. See [CLI](./#cli) for details.
|
||||||
|
|
||||||
|
Logs will be stored in an `app.log` file at following locations:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Linux
|
# Linux
|
||||||
@@ -186,3 +318,13 @@ In `debug` mode only. Locations:
|
|||||||
# `Windows`
|
# `Windows`
|
||||||
C:/Users/{user}/AppData/Local/timr-tui/logs/app.log
|
C:/Users/{user}/AppData/Local/timr-tui/logs/app.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional: You can use a custom directory by passing it via `--log` arg.
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
[CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
[MIT License](./LICENSE)
|
||||||
|
|||||||
BIN
demo/blink.gif
Normal file
|
After Width: | Height: | Size: 39 KiB |
23
demo/blink.tape
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Output demo/blink.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "nord-light"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 800
|
||||||
|
Set Height 400
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
# countdown 1.0s
|
||||||
|
Type "cargo run -- -r -m countdown -d -c 1 --blink=on"
|
||||||
|
Enter
|
||||||
|
Sleep 0.2
|
||||||
|
Type "m"
|
||||||
|
Type ":::"
|
||||||
|
Show
|
||||||
|
Type "s"
|
||||||
|
Sleep 4
|
||||||
BIN
demo/countdown-target-future.gif
Normal file
|
After Width: | Height: | Size: 20 KiB |
20
demo/countdown-target-future.tape
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Output demo/countdown-target-future.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "SeaShells"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
Type "cargo run -- -r -m c --ct '2030-01-10 18:00'"
|
||||||
|
Enter
|
||||||
|
Type "m"
|
||||||
|
Sleep 0.2
|
||||||
|
Show
|
||||||
|
Sleep 0.1
|
||||||
BIN
demo/countdown-target-past.gif
Normal file
|
After Width: | Height: | Size: 11 KiB |
20
demo/countdown-target-past.tape
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Output demo/countdown-target-past.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "seoulbones_light"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
Type "cargo run -- -r -m c --ct '2024-01-10 18:00'"
|
||||||
|
Enter
|
||||||
|
Type "m"
|
||||||
|
Sleep 0.2
|
||||||
|
Show
|
||||||
|
Sleep 0.1
|
||||||
BIN
demo/local-time-date.gif
Normal file
|
After Width: | Height: | Size: 15 KiB |
20
demo/local-time-date.tape
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Output demo/local-time-date.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "WarmNeon"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
Type "cargo run -- -r -m l"
|
||||||
|
Enter
|
||||||
|
Type "m"
|
||||||
|
Sleep 0.2
|
||||||
|
Show
|
||||||
|
Sleep 0.1
|
||||||
BIN
demo/local-time-footer.gif
Normal file
|
After Width: | Height: | Size: 15 KiB |
20
demo/local-time-footer.tape
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Output demo/local-time-footer.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "AtomOneLight"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 800
|
||||||
|
Set Height 400
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
Type "cargo run -- -m c"
|
||||||
|
Enter
|
||||||
|
Sleep 0.2
|
||||||
|
Show
|
||||||
|
# --- toggle local time ---
|
||||||
|
Type@1.5s ":::"
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 30 KiB |
@@ -1,7 +1,7 @@
|
|||||||
Output demo/local-time.gif
|
Output demo/local-time.gif
|
||||||
|
|
||||||
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
Set Theme "AtomOneLight"
|
Set Theme "Atom"
|
||||||
|
|
||||||
Set FontSize 14
|
Set FontSize 14
|
||||||
Set Width 800
|
Set Width 800
|
||||||
@@ -12,11 +12,9 @@ Set Margin 1
|
|||||||
# --- START ---
|
# --- START ---
|
||||||
Set LoopOffset 4
|
Set LoopOffset 4
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -m c"
|
Type "cargo run -- -m l"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
Show
|
Show
|
||||||
Sleep 1
|
|
||||||
# --- toggle local time ---
|
# --- toggle local time ---
|
||||||
Type@1.5s ":::"
|
Type@1.5s ":::"
|
||||||
Sleep 1.5
|
|
||||||
|
|||||||
BIN
demo/maximum.gif
Normal file
|
After Width: | Height: | Size: 14 KiB |
41
demo/maximum.tape
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
Output demo/maximum.gif
|
||||||
|
|
||||||
|
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
|
||||||
|
Set Theme "C64"
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 500
|
||||||
|
Set Padding 0
|
||||||
|
Set Margin 1
|
||||||
|
|
||||||
|
# --- START ---
|
||||||
|
Set LoopOffset 4
|
||||||
|
Hide
|
||||||
|
Type "cargo run -- -r -m timer"
|
||||||
|
Enter
|
||||||
|
Sleep 0.2
|
||||||
|
Type "m"
|
||||||
|
Type "e"
|
||||||
|
# secs
|
||||||
|
Up@1ms 60
|
||||||
|
Left
|
||||||
|
# mins
|
||||||
|
Up@1ms 59
|
||||||
|
Left
|
||||||
|
# hours
|
||||||
|
Up@1ms 23
|
||||||
|
Left
|
||||||
|
# days
|
||||||
|
Up@1ms 364
|
||||||
|
Right@1ms 3
|
||||||
|
Down@1ms 1
|
||||||
|
Left@1ms 4
|
||||||
|
# years
|
||||||
|
Up@1ms 998
|
||||||
|
Right
|
||||||
|
# days
|
||||||
|
Up@1ms 365
|
||||||
|
Type@1ms "s"
|
||||||
|
Show
|
||||||
|
Sleep 0.1
|
||||||
BIN
demo/menu.gif
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 130 KiB |
@@ -12,19 +12,15 @@ Set Margin 1
|
|||||||
# --- START ---
|
# --- START ---
|
||||||
Set LoopOffset 4
|
Set LoopOffset 4
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -r -m p"
|
Type "cargo run -- -r -m p --menu"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Type@200ms "m"
|
||||||
Show
|
Show
|
||||||
# --- STYLES ---
|
# --- STYLES ---
|
||||||
Sleep 0.5
|
Sleep 0.3s
|
||||||
Type "m"
|
Type@0.3s "m"
|
||||||
Sleep 0.5
|
Type@0.3s "t"
|
||||||
Type@0.5s "t"
|
Type@0.3s "c"
|
||||||
Type@0.5s "c"
|
Type@0.3s "p"
|
||||||
Type@0.5s "p"
|
Type@0.3s "e"
|
||||||
Type@0.5s "e"
|
Escape@0.3s
|
||||||
Right@0.5s
|
|
||||||
Left@0.5s
|
|
||||||
Type@0.5s "e"
|
|
||||||
Sleep 0.5
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 174 KiB |
@@ -10,9 +10,8 @@ Set Padding 0
|
|||||||
Set Margin 1
|
Set Margin 1
|
||||||
|
|
||||||
# --- START ---
|
# --- START ---
|
||||||
Set LoopOffset 4
|
|
||||||
Hide
|
Hide
|
||||||
Type "cargo run -- -r -d -m p"
|
Type "cargo run -- -d -m p --blink on"
|
||||||
Enter
|
Enter
|
||||||
Sleep 0.2
|
Sleep 0.2
|
||||||
Show
|
Show
|
||||||
@@ -25,7 +24,7 @@ Sleep 0.2
|
|||||||
Down@30ms 80
|
Down@30ms 80
|
||||||
Sleep 100ms
|
Sleep 100ms
|
||||||
Type "e"
|
Type "e"
|
||||||
Sleep 3
|
Sleep 4
|
||||||
# --- POMODORO PAUSE ---
|
# --- POMODORO PAUSE ---
|
||||||
Right
|
Right
|
||||||
Sleep 0.5
|
Sleep 0.5
|
||||||
@@ -36,4 +35,4 @@ Sleep 0.2
|
|||||||
Down@30ms 60
|
Down@30ms 60
|
||||||
Sleep 100ms
|
Sleep 100ms
|
||||||
Type "e"
|
Type "e"
|
||||||
Sleep 3
|
Sleep 4
|
||||||
|
|||||||
24
flake.lock
generated
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1736566337,
|
"lastModified": 1758758545,
|
||||||
"narHash": "sha256-SC0eDcZPqISVt6R0UfGPyQLrI0+BppjjtQ3wcSlk0oI=",
|
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "9172acc1ee6c7e1cbafc3044ff850c568c75a5a3",
|
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
"rust-analyzer-src": "rust-analyzer-src"
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1736577158,
|
"lastModified": 1758782550,
|
||||||
"narHash": "sha256-ngnAENZ+vmzOFgnj0EDtHj22nuH7MQB+EqzUmdbvaqA=",
|
"narHash": "sha256-olCvyP5r6+HQTl2EUudtjlA5UammsBpkzAl0l9+utZc=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "fenix",
|
"repo": "fenix",
|
||||||
"rev": "05dcdb02ea657f81b13d99bd0ca36b09d25f4c43",
|
"rev": "32f4e350c03cc5762be811e9c700e8696cd13c02",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1736344531,
|
"lastModified": 1758690382,
|
||||||
"narHash": "sha256-8YVQ9ZbSfuUk2bUf2KRj60NRraLPKPS0Q4QFTbc+c2c=",
|
"narHash": "sha256-NY3kSorgqE5LMm1LqNwGne3ZLMF2/ILgLpFr1fS4X3o=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "bffc22eb12172e6db3c5dde9e3e5628f8e3e7912",
|
"rev": "e643668fd71b949c53f8626614b21ff71a07379d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -81,11 +81,11 @@
|
|||||||
"rust-analyzer-src": {
|
"rust-analyzer-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1736517563,
|
"lastModified": 1758620797,
|
||||||
"narHash": "sha256-YJ5ajpMsyXITc91ZfnI0Mdocd+tmCFkZ3BLozUkB44M=",
|
"narHash": "sha256-Ly4rHgrixFMBnkbMursVt74mxnntnE6yVdF5QellJ+A=",
|
||||||
"owner": "rust-lang",
|
"owner": "rust-lang",
|
||||||
"repo": "rust-analyzer",
|
"repo": "rust-analyzer",
|
||||||
"rev": "4f35021ca9a8e7f9ed4344139b9eaf770a2e5725",
|
"rev": "905641f3520230ad6ef421bcf5da9c6b49f2479b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
45
flake.nix
@@ -18,31 +18,32 @@
|
|||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
toolchain = with fenix.packages.${system};
|
|
||||||
combine [
|
toolchain =
|
||||||
minimal.rustc
|
fenix.packages.${system}.fromToolchainFile
|
||||||
minimal.cargo
|
{
|
||||||
targets.x86_64-pc-windows-gnu.latest.rust-std
|
file = ./rust-toolchain.toml;
|
||||||
targets.x86_64-unknown-linux-musl.latest.rust-std
|
# sha256 = nixpkgs.lib.fakeSha256;
|
||||||
];
|
sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI=";
|
||||||
|
};
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
|
craneLib = (crane.mkLib pkgs).overrideToolchain toolchain;
|
||||||
|
|
||||||
# Common build inputs for both native and cross compilation
|
|
||||||
commonArgs = {
|
commonArgs = {
|
||||||
src = craneLib.cleanCargoSource ./.;
|
src = craneLib.cleanCargoSource ./.;
|
||||||
cargoArtifacts = craneLib.buildDepsOnly {
|
|
||||||
src = craneLib.cleanCargoSource ./.;
|
|
||||||
};
|
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
doCheck = false; # skip tests during nix build
|
doCheck = false; # skip tests during nix build
|
||||||
};
|
};
|
||||||
|
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
|
|
||||||
# Native build
|
# Native build
|
||||||
timr = craneLib.buildPackage commonArgs;
|
timr = craneLib.buildPackage commonArgs;
|
||||||
|
|
||||||
# Linux build w/ statically linked binaries
|
# Linux build w/ statically linked binaries
|
||||||
staticLinuxBuild = craneLib.buildPackage (commonArgs
|
staticLinuxBuild = craneLib.buildPackage (commonArgs
|
||||||
// {
|
// {
|
||||||
|
inherit cargoArtifacts;
|
||||||
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
|
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
|
||||||
CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static";
|
CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static";
|
||||||
});
|
});
|
||||||
@@ -75,21 +76,27 @@
|
|||||||
windows = windowsBuild;
|
windows = windowsBuild;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Development shell with all necessary tools
|
devShells.default = with nixpkgs.legacyPackages.${system};
|
||||||
devShell = with nixpkgs.legacyPackages.${system};
|
craneLib.devShell {
|
||||||
mkShell {
|
packages =
|
||||||
buildInputs = with fenix.packages.${system}.stable; [
|
[
|
||||||
rust-analyzer
|
|
||||||
clippy
|
|
||||||
rustfmt
|
|
||||||
toolchain
|
toolchain
|
||||||
pkgs.just
|
pkgs.just
|
||||||
pkgs.nixd
|
pkgs.nixd
|
||||||
pkgs.alejandra
|
pkgs.alejandra
|
||||||
|
]
|
||||||
|
# pkgs needed to play sound on Linux
|
||||||
|
++ lib.optionals stdenv.isLinux [
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.pipewire
|
||||||
|
pkgs.alsa-lib
|
||||||
];
|
];
|
||||||
|
|
||||||
inherit (commonArgs) src;
|
inherit (commonArgs) src;
|
||||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
|
||||||
|
# Environment variables needed discover ALSA/PipeWire properly on Linux
|
||||||
|
LD_LIBRARY_PATH = lib.optionalString stdenv.isLinux "${pkgs.alsa-lib}/lib:${pkgs.pipewire}/lib";
|
||||||
|
ALSA_PLUGIN_DIR = lib.optionalString stdenv.isLinux "${pkgs.pipewire}/lib/alsa-lib";
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
71
justfile
@@ -2,79 +2,142 @@
|
|||||||
|
|
||||||
set unstable := true
|
set unstable := true
|
||||||
|
|
||||||
|
# list commands
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|
||||||
alias b := build
|
alias b := build
|
||||||
alias f := format
|
|
||||||
alias l := lint
|
|
||||||
alias t := test
|
|
||||||
alias r := run
|
|
||||||
|
|
||||||
# build app
|
# build app
|
||||||
|
[group('build')]
|
||||||
build:
|
build:
|
||||||
cargo build
|
cargo build
|
||||||
|
|
||||||
|
alias t := test
|
||||||
|
|
||||||
# run tests
|
# run tests
|
||||||
|
[group('test')]
|
||||||
test:
|
test:
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
|
alias f := format
|
||||||
|
|
||||||
# format files
|
# format files
|
||||||
|
[group('misc')]
|
||||||
format:
|
format:
|
||||||
just --fmt
|
just --fmt
|
||||||
cargo fmt
|
cargo fmt
|
||||||
|
|
||||||
|
alias l := lint
|
||||||
|
|
||||||
# lint
|
# lint
|
||||||
|
[group('misc')]
|
||||||
lint:
|
lint:
|
||||||
cargo clippy --no-deps
|
cargo clippy --no-deps
|
||||||
|
|
||||||
|
alias r := run
|
||||||
|
|
||||||
# run app
|
# run app
|
||||||
|
[group('dev')]
|
||||||
run:
|
run:
|
||||||
cargo run
|
cargo run
|
||||||
|
|
||||||
|
alias ra := run-args
|
||||||
|
|
||||||
|
# run app with arguments. It expects arguments as a string (e.g. "-c 5:00").
|
||||||
|
[group('dev')]
|
||||||
|
run-args args:
|
||||||
|
cargo run -- {{ args }}
|
||||||
|
|
||||||
|
alias rs := run-sound
|
||||||
|
|
||||||
|
# run app while sound feature is enabled. It expects a path to a sound file.
|
||||||
|
[group('dev')]
|
||||||
|
run-sound path:
|
||||||
|
cargo run --features sound -- --sound={{ path }}
|
||||||
|
|
||||||
|
alias rsa := run-sound-args
|
||||||
|
|
||||||
|
# run app while sound feature is enabled by adding a path to a sound file and other arguments as string (e.g. "-c 5:00").
|
||||||
|
[group('dev')]
|
||||||
|
run-sound-args path args:
|
||||||
|
cargo run --features sound -- --sound={{ path }} {{ args }}
|
||||||
|
|
||||||
# demos
|
# demos
|
||||||
|
|
||||||
alias dp := demo-pomodoro
|
alias dp := demo-pomodoro
|
||||||
|
|
||||||
|
# build demo: pomodoro
|
||||||
|
[group('demo')]
|
||||||
demo-pomodoro:
|
demo-pomodoro:
|
||||||
vhs demo/pomodoro.tape
|
vhs demo/pomodoro.tape
|
||||||
|
|
||||||
alias dt := demo-timer
|
alias dt := demo-timer
|
||||||
|
|
||||||
|
# build demo: timer
|
||||||
|
[group('demo')]
|
||||||
demo-timer:
|
demo-timer:
|
||||||
vhs demo/timer.tape
|
vhs demo/timer.tape
|
||||||
|
|
||||||
alias dc := demo-countdown
|
alias dc := demo-countdown
|
||||||
|
|
||||||
|
# build demo: countdown
|
||||||
|
[group('demo')]
|
||||||
demo-countdown:
|
demo-countdown:
|
||||||
vhs demo/countdown.tape
|
vhs demo/countdown.tape
|
||||||
|
|
||||||
alias dcm := demo-countdown-met
|
alias dcm := demo-countdown-met
|
||||||
|
|
||||||
|
# build demo: countdown + met
|
||||||
|
[group('demo')]
|
||||||
demo-countdown-met:
|
demo-countdown-met:
|
||||||
vhs demo/countdown-met.tape
|
vhs demo/countdown-met.tape
|
||||||
|
|
||||||
alias ds := demo-style
|
alias ds := demo-style
|
||||||
|
|
||||||
|
# build demo: styles
|
||||||
|
[group('demo')]
|
||||||
demo-style:
|
demo-style:
|
||||||
vhs demo/style.tape
|
vhs demo/style.tape
|
||||||
|
|
||||||
alias dd := demo-decis
|
alias dd := demo-decis
|
||||||
|
|
||||||
|
# build demo: deciseconds
|
||||||
|
[group('demo')]
|
||||||
demo-decis:
|
demo-decis:
|
||||||
vhs demo/decis.tape
|
vhs demo/decis.tape
|
||||||
|
|
||||||
alias dm := demo-menu
|
alias dm := demo-menu
|
||||||
|
|
||||||
|
# build demo: menu
|
||||||
|
[group('demo')]
|
||||||
demo-menu:
|
demo-menu:
|
||||||
vhs demo/menu.tape
|
vhs demo/menu.tape
|
||||||
|
|
||||||
alias dlt := demo-local-time
|
alias dlt := demo-local-time
|
||||||
|
|
||||||
|
# build demo: local time
|
||||||
|
[group('demo')]
|
||||||
demo-local-time:
|
demo-local-time:
|
||||||
vhs demo/local-time.tape
|
vhs demo/local-time.tape
|
||||||
|
|
||||||
|
alias dltf := demo-local-time-footer
|
||||||
|
|
||||||
|
# build demo: local time (footer)
|
||||||
|
[group('demo')]
|
||||||
|
demo-local-time-footer:
|
||||||
|
vhs demo/local-time-footer.tape
|
||||||
|
|
||||||
alias drc := demo-rocket-countdown
|
alias drc := demo-rocket-countdown
|
||||||
|
|
||||||
|
# build demo: rocket countdown
|
||||||
|
[group('demo')]
|
||||||
demo-rocket-countdown:
|
demo-rocket-countdown:
|
||||||
vhs demo/met.tape
|
vhs demo/met.tape
|
||||||
|
|
||||||
|
alias db := demo-blink
|
||||||
|
|
||||||
|
# build demo: blink animation
|
||||||
|
[group('demo')]
|
||||||
|
demo-blink:
|
||||||
|
vhs demo/blink.tape
|
||||||
|
|||||||
6
rust-toolchain.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[toolchain]
|
||||||
|
# Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`.
|
||||||
|
channel = "1.90.0"
|
||||||
|
components = ["clippy", "rustfmt", "rust-src", "rust-analyzer"]
|
||||||
|
targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl"]
|
||||||
|
profile = "minimal"
|
||||||
351
src/app.rs
@@ -1,19 +1,25 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
args::Args,
|
args::Args,
|
||||||
common::{AppEditMode, AppTime, AppTimeFormat, Content, Style},
|
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
|
||||||
constants::TICK_VALUE_MS,
|
constants::TICK_VALUE_MS,
|
||||||
events::{Event, EventHandler, Events},
|
duration::DirectedDuration,
|
||||||
|
events::{self, TuiEventHandler},
|
||||||
storage::AppStorage,
|
storage::AppStorage,
|
||||||
terminal::Terminal,
|
terminal::Terminal,
|
||||||
widgets::{
|
widgets::{
|
||||||
clock::{self, ClockState, ClockStateArgs},
|
clock::{self, ClockState, ClockStateArgs},
|
||||||
countdown::{Countdown, CountdownState},
|
countdown::{Countdown, CountdownState, CountdownStateArgs},
|
||||||
footer::{Footer, FooterState},
|
footer::{Footer, FooterState},
|
||||||
header::Header,
|
header::Header,
|
||||||
|
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
|
||||||
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
|
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
|
||||||
timer::{Timer, TimerState},
|
timer::{Timer, TimerState},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "sound")]
|
||||||
|
use crate::sound::Sound;
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
@@ -21,9 +27,9 @@ use ratatui::{
|
|||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
widgets::{StatefulWidget, Widget},
|
widgets::{StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use time::OffsetDateTime;
|
use tracing::{debug, error};
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
@@ -31,14 +37,19 @@ enum Mode {
|
|||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
content: Content,
|
content: Content,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
|
notification: Toggle,
|
||||||
|
blink: Toggle,
|
||||||
|
#[allow(dead_code)] // w/ `--features sound` available only
|
||||||
|
sound_path: Option<PathBuf>,
|
||||||
app_time: AppTime,
|
app_time: AppTime,
|
||||||
|
app_time_format: AppTimeFormat,
|
||||||
countdown: CountdownState,
|
countdown: CountdownState,
|
||||||
timer: TimerState,
|
timer: TimerState,
|
||||||
pomodoro: PomodoroState,
|
pomodoro: PomodoroState,
|
||||||
|
local_time: LocalTimeState,
|
||||||
style: Style,
|
style: Style,
|
||||||
with_decis: bool,
|
with_decis: bool,
|
||||||
footer: FooterState,
|
footer: FooterState,
|
||||||
@@ -47,10 +58,13 @@ pub struct App {
|
|||||||
pub struct AppArgs {
|
pub struct AppArgs {
|
||||||
pub style: Style,
|
pub style: Style,
|
||||||
pub with_decis: bool,
|
pub with_decis: bool,
|
||||||
|
pub notification: Toggle,
|
||||||
|
pub blink: Toggle,
|
||||||
pub show_menu: bool,
|
pub show_menu: bool,
|
||||||
pub app_time_format: AppTimeFormat,
|
pub app_time_format: AppTimeFormat,
|
||||||
pub content: Content,
|
pub content: Content,
|
||||||
pub pomodoro_mode: PomodoroMode,
|
pub pomodoro_mode: PomodoroMode,
|
||||||
|
pub pomodoro_round: u64,
|
||||||
pub initial_value_work: Duration,
|
pub initial_value_work: Duration,
|
||||||
pub current_value_work: Duration,
|
pub current_value_work: Duration,
|
||||||
pub initial_value_pause: Duration,
|
pub initial_value_pause: Duration,
|
||||||
@@ -59,38 +73,85 @@ pub struct AppArgs {
|
|||||||
pub current_value_countdown: Duration,
|
pub current_value_countdown: Duration,
|
||||||
pub elapsed_value_countdown: Duration,
|
pub elapsed_value_countdown: Duration,
|
||||||
pub current_value_timer: Duration,
|
pub current_value_timer: Duration,
|
||||||
|
pub app_tx: events::AppEventTx,
|
||||||
|
pub sound_path: Option<PathBuf>,
|
||||||
|
pub footer_toggle_app_time: Toggle,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Getting `AppArgs` by merging `Args` and `AppStorage`.
|
pub struct FromAppArgs {
|
||||||
/// `Args` wins btw.
|
pub args: Args,
|
||||||
impl From<(Args, AppStorage)> for AppArgs {
|
pub stg: AppStorage,
|
||||||
fn from((args, stg): (Args, AppStorage)) -> Self {
|
pub app_tx: events::AppEventTx,
|
||||||
AppArgs {
|
}
|
||||||
|
|
||||||
|
/// Creates an `App` by merging `Args` and `AppStorage` (`Args` wins)
|
||||||
|
/// and adding `AppEventTx`
|
||||||
|
impl From<FromAppArgs> for App {
|
||||||
|
fn from(args: FromAppArgs) -> Self {
|
||||||
|
let FromAppArgs { args, stg, app_tx } = args;
|
||||||
|
|
||||||
|
App::new(AppArgs {
|
||||||
with_decis: args.decis || stg.with_decis,
|
with_decis: args.decis || stg.with_decis,
|
||||||
show_menu: args.menu || stg.show_menu,
|
show_menu: args.menu || stg.show_menu,
|
||||||
|
notification: args.notification.unwrap_or(stg.notification),
|
||||||
|
blink: args.blink.unwrap_or(stg.blink),
|
||||||
app_time_format: stg.app_time_format,
|
app_time_format: stg.app_time_format,
|
||||||
content: args.mode.unwrap_or(stg.content),
|
// Check args to set a possible mode to start with.
|
||||||
|
content: match args.mode {
|
||||||
|
Some(mode) => mode,
|
||||||
|
// check other args (especially durations)
|
||||||
|
None => {
|
||||||
|
if args.work.is_some() || args.pause.is_some() {
|
||||||
|
Content::Pomodoro
|
||||||
|
} else if args.countdown.is_some() || args.countdown_target.is_some() {
|
||||||
|
Content::Countdown
|
||||||
|
}
|
||||||
|
// in other case just use latest stored state
|
||||||
|
else {
|
||||||
|
stg.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
style: args.style.unwrap_or(stg.style),
|
style: args.style.unwrap_or(stg.style),
|
||||||
pomodoro_mode: stg.pomodoro_mode,
|
pomodoro_mode: stg.pomodoro_mode,
|
||||||
|
pomodoro_round: stg.pomodoro_count,
|
||||||
initial_value_work: args.work.unwrap_or(stg.inital_value_work),
|
initial_value_work: args.work.unwrap_or(stg.inital_value_work),
|
||||||
// invalidate `current_value_work` if an initial value is set via args
|
// invalidate `current_value_work` if an initial value is set via args
|
||||||
current_value_work: args.work.unwrap_or(stg.current_value_work),
|
current_value_work: args.work.unwrap_or(stg.current_value_work),
|
||||||
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
|
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
|
||||||
// invalidate `current_value_pause` if an initial value is set via args
|
// invalidate `current_value_pause` if an initial value is set via args
|
||||||
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
|
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
|
||||||
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
|
initial_value_countdown: match (&args.countdown, &args.countdown_target) {
|
||||||
|
(Some(d), _) => *d,
|
||||||
|
(None, Some(DirectedDuration::Until(d))) => *d,
|
||||||
|
// reset for values from "past"
|
||||||
|
(None, Some(DirectedDuration::Since(_))) => Duration::ZERO,
|
||||||
|
(None, None) => stg.inital_value_countdown,
|
||||||
|
},
|
||||||
// invalidate `current_value_countdown` if an initial value is set via args
|
// invalidate `current_value_countdown` if an initial value is set via args
|
||||||
current_value_countdown: args.countdown.unwrap_or(stg.current_value_countdown),
|
current_value_countdown: match (&args.countdown, &args.countdown_target) {
|
||||||
elapsed_value_countdown: stg.elapsed_value_countdown,
|
(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_timer: stg.current_value_timer,
|
current_value_timer: stg.current_value_timer,
|
||||||
}
|
app_tx,
|
||||||
}
|
#[cfg(feature = "sound")]
|
||||||
}
|
sound_path: args.sound,
|
||||||
|
#[cfg(not(feature = "sound"))]
|
||||||
fn get_app_time() -> AppTime {
|
sound_path: None,
|
||||||
match OffsetDateTime::now_local() {
|
footer_toggle_app_time: stg.footer_app_time,
|
||||||
Ok(t) => AppTime::Local(t),
|
})
|
||||||
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,30 +172,43 @@ impl App {
|
|||||||
content,
|
content,
|
||||||
with_decis,
|
with_decis,
|
||||||
pomodoro_mode,
|
pomodoro_mode,
|
||||||
|
pomodoro_round,
|
||||||
|
notification,
|
||||||
|
blink,
|
||||||
|
sound_path,
|
||||||
|
app_tx,
|
||||||
|
footer_toggle_app_time,
|
||||||
} = args;
|
} = args;
|
||||||
let app_time = get_app_time();
|
let app_time = AppTime::new();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
mode: Mode::Running,
|
mode: Mode::Running,
|
||||||
|
notification,
|
||||||
|
blink,
|
||||||
|
sound_path,
|
||||||
content,
|
content,
|
||||||
app_time,
|
app_time,
|
||||||
|
app_time_format,
|
||||||
style,
|
style,
|
||||||
with_decis,
|
with_decis,
|
||||||
countdown: CountdownState::new(
|
countdown: CountdownState::new(CountdownStateArgs {
|
||||||
ClockState::<clock::Countdown>::new(ClockStateArgs {
|
|
||||||
initial_value: initial_value_countdown,
|
initial_value: initial_value_countdown,
|
||||||
current_value: current_value_countdown,
|
current_value: current_value_countdown,
|
||||||
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
elapsed_value: elapsed_value_countdown,
|
||||||
with_decis,
|
|
||||||
}),
|
|
||||||
elapsed_value_countdown,
|
|
||||||
app_time,
|
app_time,
|
||||||
),
|
with_decis,
|
||||||
timer: TimerState::new(ClockState::<clock::Timer>::new(ClockStateArgs {
|
app_tx: app_tx.clone(),
|
||||||
|
}),
|
||||||
|
timer: TimerState::new(
|
||||||
|
ClockState::<clock::Timer>::new(ClockStateArgs {
|
||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: current_value_timer,
|
current_value: current_value_timer,
|
||||||
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
||||||
with_decis,
|
with_decis,
|
||||||
})),
|
app_tx: Some(app_tx.clone()),
|
||||||
|
})
|
||||||
|
.with_name("Timer".to_owned()),
|
||||||
|
),
|
||||||
pomodoro: PomodoroState::new(PomodoroStateArgs {
|
pomodoro: PomodoroState::new(PomodoroStateArgs {
|
||||||
mode: pomodoro_mode,
|
mode: pomodoro_mode,
|
||||||
initial_value_work,
|
initial_value_work,
|
||||||
@@ -142,33 +216,156 @@ impl App {
|
|||||||
initial_value_pause,
|
initial_value_pause,
|
||||||
current_value_pause,
|
current_value_pause,
|
||||||
with_decis,
|
with_decis,
|
||||||
|
round: pomodoro_round,
|
||||||
|
app_tx: app_tx.clone(),
|
||||||
}),
|
}),
|
||||||
footer: FooterState::new(show_menu, app_time_format),
|
local_time: LocalTimeState::new(LocalTimeStateArgs {
|
||||||
|
app_time,
|
||||||
|
app_time_format,
|
||||||
|
}),
|
||||||
|
footer: FooterState::new(
|
||||||
|
show_menu,
|
||||||
|
if footer_toggle_app_time == Toggle::On {
|
||||||
|
Some(app_time_format)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(mut self, mut terminal: Terminal, mut events: Events) -> Result<Self> {
|
pub async fn run(
|
||||||
while self.is_running() {
|
mut self,
|
||||||
if let Some(event) = events.next().await {
|
terminal: &mut Terminal,
|
||||||
if matches!(event, Event::Tick) {
|
mut events: events::Events,
|
||||||
self.app_time = get_app_time();
|
) -> Result<Self> {
|
||||||
self.countdown.set_app_time(self.app_time);
|
// Closure to handle `KeyEvent`'s
|
||||||
|
let handle_key_event = |app: &mut Self, key: KeyEvent| {
|
||||||
|
debug!("Received key {:?}", key.code);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => app.mode = Mode::Quit,
|
||||||
|
KeyCode::Char('c') => app.content = Content::Countdown,
|
||||||
|
KeyCode::Char('t') => app.content = Content::Timer,
|
||||||
|
KeyCode::Char('p') => app.content = Content::Pomodoro,
|
||||||
|
KeyCode::Char('l') => app.content = Content::LocalTime,
|
||||||
|
// toogle app time format
|
||||||
|
KeyCode::Char(':') => {
|
||||||
|
if app.content == Content::LocalTime {
|
||||||
|
// For LocalTime content: just cycle through formats
|
||||||
|
app.app_time_format = app.app_time_format.next();
|
||||||
|
app.local_time.set_app_time_format(app.app_time_format);
|
||||||
|
// Only update footer if it's currently showing time
|
||||||
|
if app.footer.app_time_format().is_some() {
|
||||||
|
app.footer.set_app_time_format(Some(app.app_time_format));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other content: allow footer to toggle between formats and None
|
||||||
|
let new_format = match app.footer.app_time_format() {
|
||||||
|
// footer is hidden -> show first format
|
||||||
|
None => Some(AppTimeFormat::first()),
|
||||||
|
Some(v) => {
|
||||||
|
if v != &AppTimeFormat::last() {
|
||||||
|
Some(v.next())
|
||||||
|
} else {
|
||||||
|
// reached last format -> hide footer time
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(format) = new_format {
|
||||||
|
app.app_time_format = format;
|
||||||
|
app.local_time.set_app_time_format(format);
|
||||||
|
}
|
||||||
|
app.footer.set_app_time_format(new_format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// toogle menu
|
||||||
|
KeyCode::Char('m') => app.footer.set_show_menu(!app.footer.get_show_menu()),
|
||||||
|
KeyCode::Char(',') => {
|
||||||
|
app.style = app.style.next();
|
||||||
|
}
|
||||||
|
KeyCode::Char('.') => {
|
||||||
|
app.with_decis = !app.with_decis;
|
||||||
|
// update clocks
|
||||||
|
app.timer.set_with_decis(app.with_decis);
|
||||||
|
app.countdown.set_with_decis(app.with_decis);
|
||||||
|
app.pomodoro.set_with_decis(app.with_decis);
|
||||||
|
}
|
||||||
|
KeyCode::Up => app.footer.set_show_menu(true),
|
||||||
|
KeyCode::Down => app.footer.set_show_menu(false),
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Closure to handle `TuiEvent`'s
|
||||||
|
let mut handle_tui_events = |app: &mut Self, event: events::TuiEvent| -> Result<()> {
|
||||||
|
if matches!(event, events::TuiEvent::Tick) {
|
||||||
|
app.app_time = AppTime::new();
|
||||||
|
app.countdown.set_app_time(app.app_time);
|
||||||
|
app.local_time.set_app_time(app.app_time);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipe events into subviews and handle only 'unhandled' events afterwards
|
// Pipe events into subviews and handle only 'unhandled' events afterwards
|
||||||
if let Some(unhandled) = match self.content {
|
if let Some(unhandled) = match app.content {
|
||||||
Content::Countdown => self.countdown.update(event.clone()),
|
Content::Countdown => app.countdown.update(event.clone()),
|
||||||
Content::Timer => self.timer.update(event.clone()),
|
Content::Timer => app.timer.update(event.clone()),
|
||||||
Content::Pomodoro => self.pomodoro.update(event.clone()),
|
Content::Pomodoro => app.pomodoro.update(event.clone()),
|
||||||
|
Content::LocalTime => app.local_time.update(event.clone()),
|
||||||
} {
|
} {
|
||||||
match unhandled {
|
match unhandled {
|
||||||
Event::Render | Event::Resize => {
|
events::TuiEvent::Render | events::TuiEvent::Resize => {
|
||||||
self.draw(&mut terminal)?;
|
app.draw(terminal)?;
|
||||||
}
|
}
|
||||||
Event::Key(key) => self.handle_key_event(key),
|
events::TuiEvent::Key(key) => handle_key_event(app, key),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
#[allow(unused_variables)] // `app` is used by `--features sound` only
|
||||||
|
// Closure to handle `AppEvent`'s
|
||||||
|
let handle_app_events = |app: &mut Self, event: events::AppEvent| -> Result<()> {
|
||||||
|
match event {
|
||||||
|
events::AppEvent::ClockDone(type_id, name) => {
|
||||||
|
debug!("AppEvent::ClockDone");
|
||||||
|
|
||||||
|
if app.notification == Toggle::On {
|
||||||
|
let msg = match type_id {
|
||||||
|
ClockTypeId::Timer => {
|
||||||
|
format!("{name} stopped by reaching its maximum value.")
|
||||||
|
}
|
||||||
|
_ => format!("{type_id:?} {name} done!"),
|
||||||
|
};
|
||||||
|
// notification
|
||||||
|
let result = notify_rust::Notification::new()
|
||||||
|
.summary(&msg.to_uppercase())
|
||||||
|
.show();
|
||||||
|
if let Err(err) = result {
|
||||||
|
error!("on_done {name} error: {err}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "sound")]
|
||||||
|
if let Some(path) = app.sound_path.clone() {
|
||||||
|
_ = Sound::new(path).and_then(|sound| sound.play()).or_else(
|
||||||
|
|err| -> Result<()> {
|
||||||
|
error!("Sound error: {:?}", err);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
while self.is_running() {
|
||||||
|
if let Some(event) = events.next().await {
|
||||||
|
let _ = match event {
|
||||||
|
events::Event::Terminal(e) => handle_tui_events(&mut self, e),
|
||||||
|
events::Event::App(e) => handle_app_events(&mut self, e),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(self)
|
Ok(self)
|
||||||
@@ -204,6 +401,7 @@ impl App {
|
|||||||
AppEditMode::None
|
AppEditMode::None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Content::LocalTime => AppEditMode::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +410,8 @@ impl App {
|
|||||||
Content::Countdown => self.countdown.is_running(),
|
Content::Countdown => self.countdown.is_running(),
|
||||||
Content::Timer => self.timer.get_clock().is_running(),
|
Content::Timer => self.timer.get_clock().is_running(),
|
||||||
Content::Pomodoro => self.pomodoro.get_clock().is_running(),
|
Content::Pomodoro => self.pomodoro.get_clock().is_running(),
|
||||||
|
// `LocalTime` does not use a `Clock`
|
||||||
|
Content::LocalTime => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,36 +420,10 @@ impl App {
|
|||||||
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
|
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
|
||||||
Content::Timer => None,
|
Content::Timer => None,
|
||||||
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
|
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
|
||||||
|
Content::LocalTime => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key_event(&mut self, key: KeyEvent) {
|
|
||||||
debug!("Received key {:?}", key.code);
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') | KeyCode::Esc => self.mode = Mode::Quit,
|
|
||||||
KeyCode::Char('c') => self.content = Content::Countdown,
|
|
||||||
KeyCode::Char('t') => self.content = Content::Timer,
|
|
||||||
KeyCode::Char('p') => self.content = Content::Pomodoro,
|
|
||||||
// toogle app time format
|
|
||||||
KeyCode::Char(':') => self.footer.toggle_app_time_format(),
|
|
||||||
// toogle menu
|
|
||||||
KeyCode::Char('m') => self.footer.set_show_menu(!self.footer.get_show_menu()),
|
|
||||||
KeyCode::Char(',') => {
|
|
||||||
self.style = self.style.next();
|
|
||||||
}
|
|
||||||
KeyCode::Char('.') => {
|
|
||||||
self.with_decis = !self.with_decis;
|
|
||||||
// update clocks
|
|
||||||
self.timer.set_with_decis(self.with_decis);
|
|
||||||
self.countdown.set_with_decis(self.with_decis);
|
|
||||||
self.pomodoro.set_with_decis(self.with_decis);
|
|
||||||
}
|
|
||||||
KeyCode::Up => self.footer.set_show_menu(true),
|
|
||||||
KeyCode::Down => self.footer.set_show_menu(false),
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
|
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
frame.render_stateful_widget(AppWidget, frame.area(), self);
|
frame.render_stateful_widget(AppWidget, frame.area(), self);
|
||||||
@@ -261,10 +435,13 @@ impl App {
|
|||||||
AppStorage {
|
AppStorage {
|
||||||
content: self.content,
|
content: self.content,
|
||||||
show_menu: self.footer.get_show_menu(),
|
show_menu: self.footer.get_show_menu(),
|
||||||
app_time_format: *self.footer.app_time_format(),
|
notification: self.notification,
|
||||||
|
blink: self.blink,
|
||||||
|
app_time_format: self.app_time_format,
|
||||||
style: self.style,
|
style: self.style,
|
||||||
with_decis: self.with_decis,
|
with_decis: self.with_decis,
|
||||||
pomodoro_mode: self.pomodoro.get_mode().clone(),
|
pomodoro_mode: self.pomodoro.get_mode().clone(),
|
||||||
|
pomodoro_count: self.pomodoro.get_round(),
|
||||||
inital_value_work: Duration::from(*self.pomodoro.get_clock_work().get_initial_value()),
|
inital_value_work: Duration::from(*self.pomodoro.get_clock_work().get_initial_value()),
|
||||||
current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()),
|
current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()),
|
||||||
inital_value_pause: Duration::from(
|
inital_value_pause: Duration::from(
|
||||||
@@ -279,6 +456,7 @@ impl App {
|
|||||||
),
|
),
|
||||||
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
|
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
|
||||||
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
|
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
|
||||||
|
footer_app_time: self.footer.app_time_format().is_some().into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,13 +467,24 @@ impl AppWidget {
|
|||||||
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) {
|
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) {
|
||||||
match state.content {
|
match state.content {
|
||||||
Content::Timer => {
|
Content::Timer => {
|
||||||
Timer { style: state.style }.render(area, buf, &mut state.timer);
|
Timer {
|
||||||
|
style: state.style,
|
||||||
|
blink: state.blink == Toggle::On,
|
||||||
}
|
}
|
||||||
Content::Countdown => {
|
.render(area, buf, &mut state.timer);
|
||||||
Countdown { style: state.style }.render(area, buf, &mut state.countdown)
|
|
||||||
}
|
}
|
||||||
Content::Pomodoro => {
|
Content::Countdown => Countdown {
|
||||||
PomodoroWidget { style: state.style }.render(area, buf, &mut state.pomodoro)
|
style: state.style,
|
||||||
|
blink: state.blink == Toggle::On,
|
||||||
|
}
|
||||||
|
.render(area, buf, &mut state.countdown),
|
||||||
|
Content::Pomodoro => PomodoroWidget {
|
||||||
|
style: state.style,
|
||||||
|
blink: state.blink == Toggle::On,
|
||||||
|
}
|
||||||
|
.render(area, buf, &mut state.pomodoro),
|
||||||
|
Content::LocalTime => {
|
||||||
|
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -307,7 +496,7 @@ impl StatefulWidget for AppWidget {
|
|||||||
let [v0, v1, v2] = Layout::vertical([
|
let [v0, v1, v2] = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Percentage(100),
|
Constraint::Percentage(100),
|
||||||
Constraint::Length(if state.footer.get_show_menu() { 4 } else { 1 }),
|
Constraint::Length(if state.footer.get_show_menu() { 5 } else { 1 }),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
|
|||||||
72
src/args.rs
@@ -1,29 +1,39 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::{Content, Style},
|
common::{Content, Style, Toggle},
|
||||||
duration,
|
duration,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "sound")]
|
||||||
|
use crate::{sound, sound::SoundError};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub const LOG_DIRECTORY_DEFAULT_MISSING_VALUE: &str = " "; // empty string
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
#[arg(long, short, value_parser = duration::parse_duration,
|
#[arg(long, short, value_parser = duration::parse_long_duration,
|
||||||
help = "Countdown time to start from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
|
help = "Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'."
|
||||||
)]
|
)]
|
||||||
pub countdown: Option<Duration>,
|
pub countdown: Option<Duration>,
|
||||||
|
|
||||||
|
#[arg(long, visible_alias = "ct", value_parser = duration::parse_duration_by_time,
|
||||||
|
help = "Countdown targeting a specific time in the future or past. Formats: 'yyyy-mm-dd hh:mm:ss', 'yyyy-mm-dd hh:mm', 'hh:mm:ss', 'hh:mm', 'mm'"
|
||||||
|
)]
|
||||||
|
pub countdown_target: Option<duration::DirectedDuration>,
|
||||||
|
|
||||||
#[arg(long, short, value_parser = duration::parse_duration,
|
#[arg(long, short, value_parser = duration::parse_duration,
|
||||||
help = "Work time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
|
help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
|
||||||
)]
|
)]
|
||||||
pub work: Option<Duration>,
|
pub work: Option<Duration>,
|
||||||
|
|
||||||
#[arg(long, short, value_parser = duration::parse_duration,
|
#[arg(long, short, value_parser = duration::parse_duration,
|
||||||
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', or 'hh:mm:ss'"
|
help = "Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'"
|
||||||
)]
|
)]
|
||||||
pub pause: Option<Duration>,
|
pub pause: Option<Duration>,
|
||||||
|
|
||||||
#[arg(long, short = 'd', help = "Whether to show deciseconds or not.")]
|
#[arg(long, short = 'd', help = "Show deciseconds.")]
|
||||||
pub decis: bool,
|
pub decis: bool,
|
||||||
|
|
||||||
#[arg(long, short = 'm', value_enum, help = "Mode to start with.")]
|
#[arg(long, short = 'm', value_enum, help = "Mode to start with.")]
|
||||||
@@ -32,9 +42,55 @@ pub struct Args {
|
|||||||
#[arg(long, short = 's', value_enum, help = "Style to display time with.")]
|
#[arg(long, short = 's', value_enum, help = "Style to display time with.")]
|
||||||
pub style: Option<Style>,
|
pub style: Option<Style>,
|
||||||
|
|
||||||
#[arg(long, value_enum, help = "Whether to open the menu or not.")]
|
#[arg(long, value_enum, help = "Open menu.")]
|
||||||
pub menu: bool,
|
pub menu: bool,
|
||||||
|
|
||||||
#[arg(long, short = 'r', help = "Reset stored values to default.")]
|
#[arg(long, short = 'r', help = "Reset stored values to defaults.")]
|
||||||
pub reset: bool,
|
pub reset: bool,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
short,
|
||||||
|
value_enum,
|
||||||
|
help = "Toggle desktop notifications. Experimental."
|
||||||
|
)]
|
||||||
|
pub notification: Option<Toggle>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
help = "Toggle blink mode to animate a clock when it reaches its finished mode."
|
||||||
|
)]
|
||||||
|
pub blink: Option<Toggle>,
|
||||||
|
|
||||||
|
#[cfg(feature = "sound")]
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_enum,
|
||||||
|
help = "Path to sound file (.mp3 or .wav) to play as notification. Experimental.",
|
||||||
|
value_hint = clap::ValueHint::FilePath,
|
||||||
|
value_parser = sound_file_parser,
|
||||||
|
)]
|
||||||
|
pub sound: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
// allows both --log=path and --log path syntax
|
||||||
|
num_args = 0..=1,
|
||||||
|
// Note: If no value is passed, use a " " by default,
|
||||||
|
// this value will be checked later in `main`
|
||||||
|
// to use another (default) log directory instead
|
||||||
|
default_missing_value=LOG_DIRECTORY_DEFAULT_MISSING_VALUE,
|
||||||
|
help = "Directory for log file. If not set, standard application log directory is used (check README for details).",
|
||||||
|
value_hint = clap::ValueHint::DirPath,
|
||||||
|
)]
|
||||||
|
pub log: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "sound")]
|
||||||
|
/// Custom parser for sound file
|
||||||
|
fn sound_file_parser(s: &str) -> Result<PathBuf, SoundError> {
|
||||||
|
let path = PathBuf::from(s);
|
||||||
|
sound::validate_sound_file(&path)?;
|
||||||
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/common.rs
@@ -1,8 +1,8 @@
|
|||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use ratatui::symbols::shade;
|
use ratatui::symbols::shade;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::format_description;
|
use strum::EnumString;
|
||||||
use time::OffsetDateTime;
|
use time::{OffsetDateTime, format_description};
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize,
|
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize,
|
||||||
@@ -15,6 +15,14 @@ pub enum Content {
|
|||||||
Timer,
|
Timer,
|
||||||
#[value(name = "pomodoro", alias = "p")]
|
#[value(name = "pomodoro", alias = "p")]
|
||||||
Pomodoro,
|
Pomodoro,
|
||||||
|
#[value(name = "localtime", alias = "l")]
|
||||||
|
LocalTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum ClockTypeId {
|
||||||
|
Countdown,
|
||||||
|
Timer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
|
#[derive(Debug, Copy, Clone, ValueEnum, Default, Serialize, Deserialize)]
|
||||||
@@ -65,7 +73,7 @@ impl Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, EnumString, Serialize, Deserialize)]
|
||||||
pub enum AppTimeFormat {
|
pub enum AppTimeFormat {
|
||||||
/// `hh:mm:ss`
|
/// `hh:mm:ss`
|
||||||
#[default]
|
#[default]
|
||||||
@@ -74,17 +82,22 @@ pub enum AppTimeFormat {
|
|||||||
HhMm,
|
HhMm,
|
||||||
/// `hh:mm AM` (or PM)
|
/// `hh:mm AM` (or PM)
|
||||||
Hh12Mm,
|
Hh12Mm,
|
||||||
/// `` (empty)
|
|
||||||
Hidden,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppTimeFormat {
|
impl AppTimeFormat {
|
||||||
|
pub const fn first() -> Self {
|
||||||
|
Self::HhMmSs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn last() -> Self {
|
||||||
|
Self::Hh12Mm
|
||||||
|
}
|
||||||
|
|
||||||
pub fn next(&self) -> Self {
|
pub fn next(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
AppTimeFormat::HhMmSs => AppTimeFormat::HhMm,
|
AppTimeFormat::HhMmSs => AppTimeFormat::HhMm,
|
||||||
AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm,
|
AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm,
|
||||||
AppTimeFormat::Hh12Mm => AppTimeFormat::Hidden,
|
AppTimeFormat::Hh12Mm => AppTimeFormat::HhMmSs,
|
||||||
AppTimeFormat::Hidden => AppTimeFormat::HhMmSs,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,16 +118,22 @@ impl From<AppTime> for OffsetDateTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppTime {
|
impl AppTime {
|
||||||
|
#[allow(clippy::new_without_default)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
match OffsetDateTime::now_local() {
|
||||||
|
Ok(t) => AppTime::Local(t),
|
||||||
|
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn format(&self, app_format: &AppTimeFormat) -> String {
|
pub fn format(&self, app_format: &AppTimeFormat) -> String {
|
||||||
let parse_str = match app_format {
|
let parse_str = match app_format {
|
||||||
AppTimeFormat::HhMmSs => Some("[hour]:[minute]:[second]"),
|
AppTimeFormat::HhMmSs => "[hour]:[minute]:[second]",
|
||||||
AppTimeFormat::HhMm => Some("[hour]:[minute]"),
|
AppTimeFormat::HhMm => "[hour]:[minute]",
|
||||||
AppTimeFormat::Hh12Mm => Some("[hour repr:12 padding:none]:[minute] [period]"),
|
AppTimeFormat::Hh12Mm => "[hour repr:12 padding:none]:[minute] [period]",
|
||||||
AppTimeFormat::Hidden => None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(str) = parse_str {
|
format_description::parse(parse_str)
|
||||||
format_description::parse(str)
|
|
||||||
.map_err(|_| "parse error")
|
.map_err(|_| "parse error")
|
||||||
.and_then(|fd| {
|
.and_then(|fd| {
|
||||||
OffsetDateTime::from(*self)
|
OffsetDateTime::from(*self)
|
||||||
@@ -122,9 +141,41 @@ impl AppTime {
|
|||||||
.map_err(|_| "format error")
|
.map_err(|_| "format error")
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|e| e.to_string())
|
.unwrap_or_else(|e| e.to_string())
|
||||||
} else {
|
|
||||||
"".to_owned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn format_date(&self) -> String {
|
||||||
|
format_description::parse("[year]-[month]-[day]")
|
||||||
|
.map_err(|_| "parse error")
|
||||||
|
.and_then(|fd| {
|
||||||
|
OffsetDateTime::from(*self)
|
||||||
|
.format(&fd)
|
||||||
|
.map_err(|_| "format error")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_period(&self) -> String {
|
||||||
|
format_description::parse("[period]")
|
||||||
|
.map_err(|_| "parse error")
|
||||||
|
.and_then(|fd| {
|
||||||
|
OffsetDateTime::from(*self)
|
||||||
|
.format(&fd)
|
||||||
|
.map_err(|_| "format error")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts `AppTime` into a `Duration` representing elapsed time since midnight (today).
|
||||||
|
pub fn as_duration_of_today(&self) -> std::time::Duration {
|
||||||
|
let dt = OffsetDateTime::from(*self);
|
||||||
|
let time = dt.time();
|
||||||
|
|
||||||
|
let total_nanos = u64::from(time.hour()) * 3_600_000_000_000
|
||||||
|
+ u64::from(time.minute()) * 60_000_000_000
|
||||||
|
+ u64::from(time.second()) * 1_000_000_000
|
||||||
|
+ u64::from(time.nanosecond());
|
||||||
|
|
||||||
|
std::time::Duration::from_nanos(total_nanos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +186,24 @@ pub enum AppEditMode {
|
|||||||
Time,
|
Time,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum Toggle {
|
||||||
|
#[value(name = "on")]
|
||||||
|
On,
|
||||||
|
#[default]
|
||||||
|
#[value(name = "off")]
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<bool> for Toggle {
|
||||||
|
fn from(value: bool) -> Self {
|
||||||
|
match value {
|
||||||
|
true => Toggle::On,
|
||||||
|
false => Toggle::Off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
@@ -181,12 +250,5 @@ mod tests {
|
|||||||
"6:06 PM",
|
"6:06 PM",
|
||||||
"local"
|
"local"
|
||||||
);
|
);
|
||||||
// hidden
|
|
||||||
assert_eq!(AppTime::Utc(dt).format(&AppTimeFormat::Hidden), "", "utc");
|
|
||||||
assert_eq!(
|
|
||||||
AppTime::Local(dt).format(&AppTimeFormat::Hidden),
|
|
||||||
"",
|
|
||||||
"local"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::constants::APP_NAME;
|
use crate::constants::APP_NAME;
|
||||||
use color_eyre::eyre::{eyre, Result};
|
use color_eyre::eyre::{Result, eyre};
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub log_dir: PathBuf,
|
pub log_dir: PathBuf,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
@@ -10,8 +11,11 @@ pub struct Config {
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn init() -> Result<Self> {
|
pub fn init() -> Result<Self> {
|
||||||
|
// default logs dir
|
||||||
let log_dir = get_default_state_dir()?.join("logs");
|
let log_dir = get_default_state_dir()?.join("logs");
|
||||||
fs::create_dir_all(&log_dir)?;
|
fs::create_dir_all(&log_dir)?;
|
||||||
|
|
||||||
|
// default data dir
|
||||||
let data_dir = get_default_state_dir()?.join("data");
|
let data_dir = get_default_state_dir()?.join("data");
|
||||||
fs::create_dir_all(&data_dir)?;
|
fs::create_dir_all(&data_dir)?;
|
||||||
|
|
||||||
|
|||||||
497
src/duration.rs
@@ -1,14 +1,12 @@
|
|||||||
use color_eyre::{
|
use color_eyre::{
|
||||||
eyre::{ensure, eyre},
|
|
||||||
Report,
|
Report,
|
||||||
|
eyre::{ensure, eyre},
|
||||||
};
|
};
|
||||||
|
use std::cmp::min;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
|
use crate::common::AppTime;
|
||||||
pub const ONE_SECOND: Duration = Duration::from_secs(1);
|
|
||||||
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
|
|
||||||
pub const ONE_HOUR: Duration = Duration::from_secs(MINS_PER_HOUR * SECS_PER_MINUTE);
|
|
||||||
|
|
||||||
// unstable
|
// unstable
|
||||||
// https://doc.rust-lang.org/src/core/time.rs.html#32
|
// https://doc.rust-lang.org/src/core/time.rs.html#32
|
||||||
@@ -20,9 +18,33 @@ pub const MINS_PER_HOUR: u64 = 60;
|
|||||||
// https://doc.rust-lang.org/src/core/time.rs.html#36
|
// https://doc.rust-lang.org/src/core/time.rs.html#36
|
||||||
const HOURS_PER_DAY: u64 = 24;
|
const HOURS_PER_DAY: u64 = 24;
|
||||||
|
|
||||||
// max. 99:59:59
|
pub const ONE_DECI_SECOND: Duration = Duration::from_millis(100);
|
||||||
pub const MAX_DURATION: Duration =
|
pub const ONE_SECOND: Duration = Duration::from_secs(1);
|
||||||
Duration::from_secs(100 * MINS_PER_HOUR * SECS_PER_MINUTE).saturating_sub(ONE_SECOND);
|
pub const ONE_MINUTE: Duration = Duration::from_secs(SECS_PER_MINUTE);
|
||||||
|
pub const ONE_HOUR: Duration = Duration::from_secs(MINS_PER_HOUR * SECS_PER_MINUTE);
|
||||||
|
pub const ONE_DAY: Duration = Duration::from_secs(HOURS_PER_DAY * MINS_PER_HOUR * SECS_PER_MINUTE);
|
||||||
|
pub const ONE_YEAR: Duration =
|
||||||
|
Duration::from_secs(DAYS_PER_YEAR * HOURS_PER_DAY * MINS_PER_HOUR * SECS_PER_MINUTE);
|
||||||
|
|
||||||
|
// Days per year
|
||||||
|
// "There are 365 days in a year in a common year of the Gregorian calendar and 366 days in a leap year.
|
||||||
|
// Leap years occur every four years. The average number of days in a year is 365.2425 days."
|
||||||
|
// ^ https://www.math.net/days-in-a-year
|
||||||
|
const DAYS_PER_YEAR: u64 = 365; // ignore leap year of 366 days
|
||||||
|
|
||||||
|
// max. 999y 364d 23:59:59.9 (1000 years - 1 decisecond)
|
||||||
|
pub const MAX_DURATION: Duration = ONE_YEAR
|
||||||
|
.saturating_mul(1000)
|
||||||
|
.saturating_sub(ONE_DECI_SECOND);
|
||||||
|
|
||||||
|
/// `Duration` with direction in time (past or future)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DirectedDuration {
|
||||||
|
/// Time `until` a future moment (positive `Duration`)
|
||||||
|
Until(Duration),
|
||||||
|
/// Time `since` a past moment (negative duration, but still represented as positive `Duration`)
|
||||||
|
Since(Duration),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialOrd)]
|
#[derive(Debug, Clone, Copy, PartialOrd)]
|
||||||
pub struct DurationEx {
|
pub struct DurationEx {
|
||||||
@@ -48,22 +70,36 @@ impl From<DurationEx> for Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DurationEx {
|
impl DurationEx {
|
||||||
pub fn seconds(&self) -> u64 {
|
pub fn years(&self) -> u64 {
|
||||||
self.inner.as_secs()
|
self.days() / DAYS_PER_YEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seconds_mod(&self) -> u64 {
|
pub fn days(&self) -> u64 {
|
||||||
self.seconds() % SECS_PER_MINUTE
|
self.hours() / HOURS_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days in a year
|
||||||
|
pub fn days_mod(&self) -> u64 {
|
||||||
|
self.days() % DAYS_PER_YEAR
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hours(&self) -> u64 {
|
pub fn hours(&self) -> u64 {
|
||||||
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
|
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hours as 24-hour clock
|
||||||
pub fn hours_mod(&self) -> u64 {
|
pub fn hours_mod(&self) -> u64 {
|
||||||
self.hours() % HOURS_PER_DAY
|
self.hours() % HOURS_PER_DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hours as 12-hour clock
|
||||||
|
pub fn hours_mod_12(&self) -> u64 {
|
||||||
|
// 0 => 12,
|
||||||
|
// 1..=12 => hours,
|
||||||
|
// 13..=23 => hours - 12,
|
||||||
|
(self.hours_mod() + 11) % 12 + 1
|
||||||
|
}
|
||||||
|
|
||||||
pub fn minutes(&self) -> u64 {
|
pub fn minutes(&self) -> u64 {
|
||||||
self.seconds() / MINS_PER_HOUR
|
self.seconds() / MINS_PER_HOUR
|
||||||
}
|
}
|
||||||
@@ -72,6 +108,14 @@ impl DurationEx {
|
|||||||
self.minutes() % SECS_PER_MINUTE
|
self.minutes() % SECS_PER_MINUTE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn seconds(&self) -> u64 {
|
||||||
|
self.inner.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seconds_mod(&self) -> u64 {
|
||||||
|
self.seconds() % SECS_PER_MINUTE
|
||||||
|
}
|
||||||
|
|
||||||
// deciseconds
|
// deciseconds
|
||||||
pub fn decis(&self) -> u64 {
|
pub fn decis(&self) -> u64 {
|
||||||
(self.inner.subsec_millis() / 100) as u64
|
(self.inner.subsec_millis() / 100) as u64
|
||||||
@@ -98,7 +142,26 @@ impl DurationEx {
|
|||||||
|
|
||||||
impl fmt::Display for DurationEx {
|
impl fmt::Display for DurationEx {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
if self.hours() >= 10 {
|
if self.years() >= 1 {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}y {}d {:02}:{:02}:{:02}",
|
||||||
|
self.years(),
|
||||||
|
self.days_mod(),
|
||||||
|
self.hours_mod(),
|
||||||
|
self.minutes_mod(),
|
||||||
|
self.seconds_mod(),
|
||||||
|
)
|
||||||
|
} else if self.hours() >= HOURS_PER_DAY {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}d {:02}:{:02}:{:02}",
|
||||||
|
self.days_mod(),
|
||||||
|
self.hours_mod(),
|
||||||
|
self.minutes_mod(),
|
||||||
|
self.seconds_mod(),
|
||||||
|
)
|
||||||
|
} else if self.hours() >= 10 {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
"{:02}:{:02}:{:02}",
|
"{:02}:{:02}:{:02}",
|
||||||
@@ -126,45 +189,183 @@ impl fmt::Display for DurationEx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
|
/// Parse seconds (must be < 60)
|
||||||
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
|
fn parse_seconds(s: &str) -> Result<u8, Report> {
|
||||||
let parts: Vec<&str> = arg.split(':').rev().collect();
|
let secs = s.parse::<u8>().map_err(|_| eyre!("Invalid seconds"))?;
|
||||||
|
|
||||||
let parse_seconds = |s: &str| -> Result<u64, Report> {
|
|
||||||
let secs = s.parse::<u64>().map_err(|_| eyre!("Invalid seconds"))?;
|
|
||||||
ensure!(secs < 60, "Seconds must be less than 60.");
|
ensure!(secs < 60, "Seconds must be less than 60.");
|
||||||
Ok(secs)
|
Ok(secs)
|
||||||
};
|
}
|
||||||
|
|
||||||
let parse_minutes = |m: &str| -> Result<u64, Report> {
|
/// Parse minutes (must be < 60)
|
||||||
let mins = m.parse::<u64>().map_err(|_| eyre!("Invalid minutes"))?;
|
fn parse_minutes(m: &str) -> Result<u8, Report> {
|
||||||
|
let mins = m.parse::<u8>().map_err(|_| eyre!("Invalid minutes"))?;
|
||||||
ensure!(mins < 60, "Minutes must be less than 60.");
|
ensure!(mins < 60, "Minutes must be less than 60.");
|
||||||
Ok(mins)
|
Ok(mins)
|
||||||
};
|
}
|
||||||
|
|
||||||
let parse_hours = |h: &str| -> Result<u64, Report> {
|
/// Parse hours
|
||||||
let hours = h.parse::<u64>().map_err(|_| eyre!("Invalid hours"))?;
|
fn parse_hours(h: &str) -> Result<u8, Report> {
|
||||||
ensure!(hours < 100, "Hours must be less than 100.");
|
let hours = h.parse::<u8>().map_err(|_| eyre!("Invalid hours"))?;
|
||||||
Ok(hours)
|
Ok(hours)
|
||||||
};
|
}
|
||||||
|
|
||||||
let seconds = match parts.as_slice() {
|
/// Parses `DirectedDuration` from following formats:
|
||||||
[ss] => parse_seconds(ss)?,
|
/// - `yyyy-mm-dd hh:mm:ss`
|
||||||
[ss, mm] => {
|
/// - `yyyy-mm-dd hh:mm`
|
||||||
let s = parse_seconds(ss)?;
|
/// - `hh:mm:ss`
|
||||||
|
/// - `hh:mm`
|
||||||
|
/// - `mm`
|
||||||
|
///
|
||||||
|
/// Returns `DirectedDuration::Until` for future times, `DirectedDuration::Since` for past times
|
||||||
|
pub fn parse_duration_by_time(arg: &str) -> Result<DirectedDuration, Report> {
|
||||||
|
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
|
||||||
|
|
||||||
|
let now: OffsetDateTime = AppTime::new().into();
|
||||||
|
|
||||||
|
let target_time = if arg.contains('-') {
|
||||||
|
// First: `YYYY-MM-DD HH:MM:SS`
|
||||||
|
// Then: `YYYY-MM-DD HH:MM`
|
||||||
|
let format_with_seconds =
|
||||||
|
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
|
||||||
|
let format_without_seconds = format_description!("[year]-[month]-[day] [hour]:[minute]");
|
||||||
|
|
||||||
|
let pdt = PrimitiveDateTime::parse(arg, format_with_seconds)
|
||||||
|
.or_else(|_| PrimitiveDateTime::parse(arg, format_without_seconds))
|
||||||
|
.map_err(|e| {
|
||||||
|
eyre!("Invalid datetime '{}'. Use format 'yyyy-mm-dd hh:mm:ss' or 'yyyy-mm-dd hh:mm'. Error: {}", arg, e)
|
||||||
|
})?;
|
||||||
|
pdt.assume_offset(now.offset())
|
||||||
|
} else {
|
||||||
|
// Parse time parts: interpret as HH:MM:SS, HH:MM, or SS
|
||||||
|
let parts: Vec<&str> = arg.split(':').collect();
|
||||||
|
|
||||||
|
let (hour, minute, second) = match parts.as_slice() {
|
||||||
|
[mm] => {
|
||||||
|
// Single part: treat as minutes in current hour
|
||||||
let m = parse_minutes(mm)?;
|
let m = parse_minutes(mm)?;
|
||||||
m * 60 + s
|
(now.hour(), m, 0)
|
||||||
}
|
}
|
||||||
[ss, mm, hh] => {
|
[hh, mm] => {
|
||||||
let s = parse_seconds(ss)?;
|
// Two parts: treat as HH:MM (time of day)
|
||||||
let m = parse_minutes(mm)?;
|
|
||||||
let h = parse_hours(hh)?;
|
let h = parse_hours(hh)?;
|
||||||
h * 60 * 60 + m * 60 + s
|
let m = parse_minutes(mm)?;
|
||||||
|
(h, m, 0)
|
||||||
|
}
|
||||||
|
[hh, mm, ss] => {
|
||||||
|
// Three parts: HH:MM:SS
|
||||||
|
let h = parse_hours(hh)?;
|
||||||
|
let m = parse_minutes(mm)?;
|
||||||
|
let s = parse_seconds(ss)?;
|
||||||
|
(h, m, s)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Invalid time format. Use 'hh:mm:ss', 'hh:mm', or 'mm'"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
_ => return Err(eyre!("Invalid time format. Use 'ss', mm:ss, or hh:mm:ss")),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Duration::from_secs(seconds))
|
now.replace_time(
|
||||||
|
time::Time::from_hms(hour, minute, second).map_err(|_| eyre!("Invalid time"))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut duration_secs = (target_time - now).whole_seconds();
|
||||||
|
|
||||||
|
// `Since` for past times
|
||||||
|
if duration_secs < 0 {
|
||||||
|
duration_secs *= -1;
|
||||||
|
Ok(DirectedDuration::Since(Duration::from_secs(
|
||||||
|
duration_secs as u64,
|
||||||
|
)))
|
||||||
|
} else
|
||||||
|
// `Until` for future times,
|
||||||
|
{
|
||||||
|
Ok(DirectedDuration::Until(Duration::from_secs(
|
||||||
|
duration_secs as u64,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
|
||||||
|
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
|
||||||
|
let parts: Vec<&str> = arg.split(':').collect();
|
||||||
|
|
||||||
|
let (hours, minutes, seconds) = match parts.as_slice() {
|
||||||
|
[ss] => {
|
||||||
|
// Single part: seconds only
|
||||||
|
let s = parse_seconds(ss)?;
|
||||||
|
(0u64, 0u64, s as u64)
|
||||||
|
}
|
||||||
|
[mm, ss] => {
|
||||||
|
// Two parts: MM:SS
|
||||||
|
let m = parse_minutes(mm)?;
|
||||||
|
let s = parse_seconds(ss)?;
|
||||||
|
(0u64, m as u64, s as u64)
|
||||||
|
}
|
||||||
|
[hh, mm, ss] => {
|
||||||
|
// Three parts: HH:MM:SS
|
||||||
|
let h = parse_hours(hh)?;
|
||||||
|
let m = parse_minutes(mm)?;
|
||||||
|
let s = parse_seconds(ss)?;
|
||||||
|
(h as u64, m as u64, s as u64)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Invalid time format. Use 'ss', 'mm:ss', or 'hh:mm:ss'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_seconds = hours * 3600 + minutes * 60 + seconds;
|
||||||
|
Ok(Duration::from_secs(total_seconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Similar to `parse_duration`, but it parses `years` and `days` in addition
|
||||||
|
/// Formats: `Yy Dd`, `Yy` or `Dd` in any combination to other time formats
|
||||||
|
/// Examples: `10y 3d 12:10:03`, `2d 10:00`, `101y 33`, `5:30`
|
||||||
|
pub fn parse_long_duration(arg: &str) -> Result<Duration, Report> {
|
||||||
|
let arg = arg.trim();
|
||||||
|
|
||||||
|
// parts are separated by whitespaces:
|
||||||
|
// 3 parts: years, days, time
|
||||||
|
let parts: Vec<&str> = arg.split_whitespace().collect();
|
||||||
|
ensure!(parts.len() <= 3, "Invalid format. Too many parts.");
|
||||||
|
|
||||||
|
let mut total_duration = Duration::ZERO;
|
||||||
|
let mut time_part: Option<&str> = None;
|
||||||
|
|
||||||
|
for part in parts {
|
||||||
|
// years
|
||||||
|
if let Some(years_str) = part.strip_suffix('y') {
|
||||||
|
let years = years_str
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| eyre!("Invalid years value: '{}'", years_str))?;
|
||||||
|
total_duration = total_duration.saturating_add(ONE_YEAR.saturating_mul(years as u32));
|
||||||
|
}
|
||||||
|
// days
|
||||||
|
else if let Some(days_str) = part.strip_suffix('d') {
|
||||||
|
let days = days_str
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| eyre!("Invalid days value: '{}'", days_str))?;
|
||||||
|
total_duration = total_duration.saturating_add(ONE_DAY.saturating_mul(days as u32));
|
||||||
|
}
|
||||||
|
// possible time format
|
||||||
|
else {
|
||||||
|
time_part = Some(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// time format
|
||||||
|
if let Some(time) = time_part {
|
||||||
|
let time_duration = parse_duration(time)?;
|
||||||
|
total_duration = total_duration.saturating_add(time_duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// avoid overflow
|
||||||
|
total_duration = min(MAX_DURATION, total_duration);
|
||||||
|
|
||||||
|
Ok(total_duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -173,26 +374,58 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const MINUTE_IN_SECONDS: u64 = ONE_MINUTE.as_secs();
|
||||||
|
const HOUR_IN_SECONDS: u64 = ONE_HOUR.as_secs();
|
||||||
|
const DAY_IN_SECONDS: u64 = ONE_DAY.as_secs();
|
||||||
|
const YEAR_IN_SECONDS: u64 = ONE_YEAR.as_secs();
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_fmt() {
|
fn test_fmt() {
|
||||||
|
// 1y Dd hh:mm:ss (single year)
|
||||||
|
let ex: DurationEx =
|
||||||
|
Duration::from_secs(YEAR_IN_SECONDS + 10 * DAY_IN_SECONDS + 36001).into();
|
||||||
|
assert_eq!(format!("{ex}"), "1y 10d 10:00:01");
|
||||||
|
// 5y Dd hh:mm:ss (multiple years)
|
||||||
|
let ex: DurationEx = Duration::from_secs(
|
||||||
|
5 * YEAR_IN_SECONDS + 100 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1,
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
assert_eq!(format!("{ex}"), "5y 100d 10:00:01");
|
||||||
|
// 150y Dd hh:mm:ss (more than 100 years)
|
||||||
|
let ex: DurationEx = Duration::from_secs(
|
||||||
|
150 * YEAR_IN_SECONDS + 200 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1,
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
assert_eq!(format!("{ex}"), "150y 200d 10:00:01");
|
||||||
|
// 366d hh:mm:ss (days more than a year)
|
||||||
|
let ex: DurationEx =
|
||||||
|
Duration::from_secs(366 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
|
||||||
|
assert_eq!(format!("{ex}"), "1y 1d 10:00:01");
|
||||||
|
// 1d hh:mm:ss (single day)
|
||||||
|
let ex: DurationEx = Duration::from_secs(DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
|
||||||
|
assert_eq!(format!("{ex}"), "1d 10:00:01");
|
||||||
|
// 2d hh:mm:ss (multiple days)
|
||||||
|
let ex: DurationEx =
|
||||||
|
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * HOUR_IN_SECONDS + 1).into();
|
||||||
|
assert_eq!(format!("{ex}"), "2d 10:00:01");
|
||||||
// hh:mm:ss
|
// hh:mm:ss
|
||||||
let ex: DurationEx = Duration::from_secs(36001).into();
|
let ex: DurationEx = Duration::from_secs(10 * HOUR_IN_SECONDS + 1).into();
|
||||||
assert_eq!(format!("{}", ex), "10:00:01");
|
assert_eq!(format!("{ex}"), "10:00:01");
|
||||||
// h:mm:ss
|
// h:mm:ss
|
||||||
let ex: DurationEx = Duration::from_secs(3601).into();
|
let ex: DurationEx = Duration::from_secs(HOUR_IN_SECONDS + 1).into();
|
||||||
assert_eq!(format!("{}", ex), "1:00:01");
|
assert_eq!(format!("{ex}"), "1:00:01");
|
||||||
// mm:ss
|
// mm:ss
|
||||||
let ex: DurationEx = Duration::from_secs(71).into();
|
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 11).into();
|
||||||
assert_eq!(format!("{}", ex), "1:11");
|
assert_eq!(format!("{ex}"), "1:11");
|
||||||
// m:ss
|
// m:ss
|
||||||
let ex: DurationEx = Duration::from_secs(61).into();
|
let ex: DurationEx = Duration::from_secs(MINUTE_IN_SECONDS + 1).into();
|
||||||
assert_eq!(format!("{}", ex), "1:01");
|
assert_eq!(format!("{ex}"), "1:01");
|
||||||
// ss
|
// ss
|
||||||
let ex: DurationEx = Duration::from_secs(11).into();
|
let ex: DurationEx = Duration::from_secs(11).into();
|
||||||
assert_eq!(format!("{}", ex), "11");
|
assert_eq!(format!("{ex}"), "11");
|
||||||
// s
|
// s
|
||||||
let ex: DurationEx = Duration::from_secs(1).into();
|
let ex: DurationEx = Duration::from_secs(1).into();
|
||||||
assert_eq!(format!("{}", ex), "1");
|
assert_eq!(format!("{ex}"), "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -200,7 +433,7 @@ mod tests {
|
|||||||
let ex: DurationEx = Duration::from_secs(10).into();
|
let ex: DurationEx = Duration::from_secs(10).into();
|
||||||
let ex2: DurationEx = Duration::from_secs(1).into();
|
let ex2: DurationEx = Duration::from_secs(1).into();
|
||||||
let ex3 = ex.saturating_sub(ex2);
|
let ex3 = ex.saturating_sub(ex2);
|
||||||
assert_eq!(format!("{}", ex3), "9");
|
assert_eq!(format!("{ex3}"), "9");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -208,7 +441,35 @@ mod tests {
|
|||||||
let ex: DurationEx = Duration::from_secs(10).into();
|
let ex: DurationEx = Duration::from_secs(10).into();
|
||||||
let ex2: DurationEx = Duration::from_secs(1).into();
|
let ex2: DurationEx = Duration::from_secs(1).into();
|
||||||
let ex3 = ex.saturating_add(ex2);
|
let ex3 = ex.saturating_add(ex2);
|
||||||
assert_eq!(format!("{}", ex3), "11");
|
assert_eq!(format!("{ex3}"), "11");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hours_mod_12() {
|
||||||
|
// 24 -> 12
|
||||||
|
let ex: DurationEx = ONE_HOUR.saturating_mul(24).into();
|
||||||
|
let result = ex.hours_mod_12();
|
||||||
|
assert_eq!(result, 12);
|
||||||
|
|
||||||
|
// 12 -> 12
|
||||||
|
let ex: DurationEx = ONE_HOUR.saturating_mul(12).into();
|
||||||
|
let result = ex.hours_mod_12();
|
||||||
|
assert_eq!(result, 12);
|
||||||
|
|
||||||
|
// 0 -> 12
|
||||||
|
let ex: DurationEx = ONE_SECOND.into();
|
||||||
|
let result = ex.hours_mod_12();
|
||||||
|
assert_eq!(result, 12);
|
||||||
|
|
||||||
|
// 13 -> 1
|
||||||
|
let ex: DurationEx = ONE_HOUR.saturating_mul(13).into();
|
||||||
|
let result = ex.hours_mod_12();
|
||||||
|
assert_eq!(result, 1);
|
||||||
|
|
||||||
|
// 1 -> 1
|
||||||
|
let ex: DurationEx = ONE_HOUR.saturating_mul(1).into();
|
||||||
|
let result = ex.hours_mod_12();
|
||||||
|
assert_eq!(result, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -228,8 +489,142 @@ mod tests {
|
|||||||
// errors
|
// errors
|
||||||
assert!(parse_duration("1:60").is_err()); // invalid seconds
|
assert!(parse_duration("1:60").is_err()); // invalid seconds
|
||||||
assert!(parse_duration("60:00").is_err()); // invalid minutes
|
assert!(parse_duration("60:00").is_err()); // invalid minutes
|
||||||
assert!(parse_duration("100:00:00").is_err()); // invalid hours
|
|
||||||
assert!(parse_duration("abc").is_err()); // invalid input
|
assert!(parse_duration("abc").is_err()); // invalid input
|
||||||
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
|
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_duration_by_time() {
|
||||||
|
// YYYY-MM-DD HH:MM:SS - future
|
||||||
|
assert!(matches!(
|
||||||
|
parse_duration_by_time("2050-06-15 14:30:45"),
|
||||||
|
Ok(DirectedDuration::Until(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
// YYYY-MM-DD HH:MM - future
|
||||||
|
assert!(matches!(
|
||||||
|
parse_duration_by_time("2050-06-15 14:30"),
|
||||||
|
Ok(DirectedDuration::Until(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
// HH:MM:SS - past
|
||||||
|
assert!(matches!(
|
||||||
|
parse_duration_by_time("2000-01-01 23:59:59"),
|
||||||
|
Ok(DirectedDuration::Since(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
// HH:MM - Until or Since depending on current time
|
||||||
|
assert!(parse_duration_by_time("18:00").is_ok());
|
||||||
|
|
||||||
|
// MM - Until or Since depending on current time
|
||||||
|
assert!(parse_duration_by_time("45").is_ok());
|
||||||
|
|
||||||
|
// errors
|
||||||
|
assert!(parse_duration_by_time("60").is_err()); // invalid minutes
|
||||||
|
assert!(parse_duration_by_time("24:00").is_err()); // invalid hours
|
||||||
|
assert!(parse_duration_by_time("24:00:00").is_err()); // invalid hours
|
||||||
|
assert!(parse_duration_by_time("2030-13-01 12:00:00").is_err()); // invalid month
|
||||||
|
assert!(parse_duration_by_time("2030-06-32 12:00:00").is_err()); // invalid day
|
||||||
|
assert!(parse_duration_by_time("abc").is_err()); // invalid input
|
||||||
|
assert!(parse_duration_by_time("01:02:03:04").is_err()); // too many parts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_long_duration() {
|
||||||
|
// `Yy`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("10y").unwrap(),
|
||||||
|
Duration::from_secs(10 * YEAR_IN_SECONDS)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("101y").unwrap(),
|
||||||
|
Duration::from_secs(101 * YEAR_IN_SECONDS)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Dd`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("2d").unwrap(),
|
||||||
|
Duration::from_secs(2 * DAY_IN_SECONDS)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Yy Dd`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("10y 3d").unwrap(),
|
||||||
|
Duration::from_secs(10 * YEAR_IN_SECONDS + 3 * DAY_IN_SECONDS)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Yy Dd hh:mm:ss`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("10y 3d 12:10:03").unwrap(),
|
||||||
|
Duration::from_secs(
|
||||||
|
10 * YEAR_IN_SECONDS
|
||||||
|
+ 3 * DAY_IN_SECONDS
|
||||||
|
+ 12 * HOUR_IN_SECONDS
|
||||||
|
+ 10 * MINUTE_IN_SECONDS
|
||||||
|
+ 3
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Dd hh:mm`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("2d 10:00").unwrap(),
|
||||||
|
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * 60)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Yy ss`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("101y 33").unwrap(),
|
||||||
|
Duration::from_secs(101 * YEAR_IN_SECONDS + 33)
|
||||||
|
);
|
||||||
|
|
||||||
|
// time formats (backward compatibility with `parse_duration`)
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("5:30").unwrap(),
|
||||||
|
Duration::from_secs(5 * MINUTE_IN_SECONDS + 30)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("01:30:45").unwrap(),
|
||||||
|
Duration::from_secs(HOUR_IN_SECONDS + 30 * MINUTE_IN_SECONDS + 45)
|
||||||
|
);
|
||||||
|
assert_eq!(parse_long_duration("42").unwrap(), Duration::from_secs(42));
|
||||||
|
|
||||||
|
// `Dd ss`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("5d 30").unwrap(),
|
||||||
|
Duration::from_secs(5 * DAY_IN_SECONDS + 30)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Yy hh:mm:ss`
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("1y 01:30:00").unwrap(),
|
||||||
|
Duration::from_secs(YEAR_IN_SECONDS + HOUR_IN_SECONDS + 30 * MINUTE_IN_SECONDS)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Whitespace handling
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration(" 2d 10:00 ").unwrap(),
|
||||||
|
Duration::from_secs(2 * DAY_IN_SECONDS + 10 * MINUTE_IN_SECONDS)
|
||||||
|
);
|
||||||
|
|
||||||
|
// MAX_DURATION clamping
|
||||||
|
assert_eq!(parse_long_duration("1000y").unwrap(), MAX_DURATION);
|
||||||
|
assert_eq!(
|
||||||
|
parse_long_duration("999y 364d 23:59:59").unwrap(),
|
||||||
|
Duration::from_secs(
|
||||||
|
999 * YEAR_IN_SECONDS
|
||||||
|
+ 364 * DAY_IN_SECONDS
|
||||||
|
+ 23 * HOUR_IN_SECONDS
|
||||||
|
+ 59 * MINUTE_IN_SECONDS
|
||||||
|
+ 59
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// errors
|
||||||
|
assert!(parse_long_duration("10x").is_err()); // invalid unit
|
||||||
|
assert!(parse_long_duration("abc").is_err()); // invalid input
|
||||||
|
assert!(parse_long_duration("10y 60:00").is_err()); // invalid minutes in time part
|
||||||
|
assert!(parse_long_duration("5d 1:60").is_err()); // invalid seconds in time part
|
||||||
|
assert!(parse_long_duration("1y 2d 3d 4:00").is_err()); // too many parts (4 parts)
|
||||||
|
assert!(parse_long_duration("1y 2d 3h 4m 5s").is_err()); // too many parts (5 parts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
|
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind};
|
||||||
use futures::{Stream, StreamExt};
|
use futures::{Stream, StreamExt};
|
||||||
use std::{pin::Pin, time::Duration};
|
use std::{pin::Pin, time::Duration};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::interval;
|
use tokio::time::interval;
|
||||||
use tokio_stream::{wrappers::IntervalStream, StreamMap};
|
use tokio_stream::{StreamMap, wrappers::IntervalStream};
|
||||||
|
|
||||||
|
use crate::common::ClockTypeId;
|
||||||
use crate::constants::{FPS_VALUE_MS, TICK_VALUE_MS};
|
use crate::constants::{FPS_VALUE_MS, TICK_VALUE_MS};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
|
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
|
||||||
@@ -12,8 +14,9 @@ enum StreamKey {
|
|||||||
Render,
|
Render,
|
||||||
Crossterm,
|
Crossterm,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum Event {
|
pub enum TuiEvent {
|
||||||
Error,
|
Error,
|
||||||
Tick,
|
Tick,
|
||||||
Render,
|
Render,
|
||||||
@@ -21,8 +24,17 @@ pub enum Event {
|
|||||||
Resize,
|
Resize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
ClockDone(ClockTypeId, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
|
||||||
|
pub type AppEventRx = mpsc::UnboundedReceiver<AppEvent>;
|
||||||
|
|
||||||
pub struct Events {
|
pub struct Events {
|
||||||
streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = Event>>>>,
|
streams: StreamMap<StreamKey, Pin<Box<dyn Stream<Item = TuiEvent>>>>,
|
||||||
|
app_channel: (AppEventTx, AppEventRx),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Events {
|
impl Default for Events {
|
||||||
@@ -33,31 +45,46 @@ impl Default for Events {
|
|||||||
(StreamKey::Render, render_stream()),
|
(StreamKey::Render, render_stream()),
|
||||||
(StreamKey::Crossterm, crossterm_stream()),
|
(StreamKey::Crossterm, crossterm_stream()),
|
||||||
]),
|
]),
|
||||||
|
app_channel: mpsc::unbounded_channel(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum Event {
|
||||||
|
Terminal(TuiEvent),
|
||||||
|
App(AppEvent),
|
||||||
|
}
|
||||||
|
|
||||||
impl Events {
|
impl Events {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn next(&mut self) -> Option<Event> {
|
pub async fn next(&mut self) -> Option<Event> {
|
||||||
self.streams.next().await.map(|(_, event)| event)
|
let streams = &mut self.streams;
|
||||||
|
let app_rx = &mut self.app_channel.1;
|
||||||
|
tokio::select! {
|
||||||
|
Some((_, event)) = streams.next() => Some(Event::Terminal(event)),
|
||||||
|
Some(app_event) = app_rx.recv() => Some(Event::App(app_event)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_app_event_tx(&self) -> AppEventTx {
|
||||||
|
self.app_channel.0.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
fn tick_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
|
||||||
let tick_interval = interval(Duration::from_millis(TICK_VALUE_MS));
|
let tick_interval = interval(Duration::from_millis(TICK_VALUE_MS));
|
||||||
Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick))
|
Box::pin(IntervalStream::new(tick_interval).map(|_| TuiEvent::Tick))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
fn render_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
|
||||||
let render_interval = interval(Duration::from_millis(FPS_VALUE_MS));
|
let render_interval = interval(Duration::from_millis(FPS_VALUE_MS));
|
||||||
Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render))
|
Box::pin(IntervalStream::new(render_interval).map(|_| TuiEvent::Render))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
|
||||||
Box::pin(
|
Box::pin(
|
||||||
EventStream::new()
|
EventStream::new()
|
||||||
.fuse()
|
.fuse()
|
||||||
@@ -65,16 +92,16 @@ fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
|||||||
.filter_map(|event| async move {
|
.filter_map(|event| async move {
|
||||||
match event {
|
match event {
|
||||||
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
|
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => {
|
||||||
Some(Event::Key(key))
|
Some(TuiEvent::Key(key))
|
||||||
}
|
}
|
||||||
Ok(CrosstermEvent::Resize(_, _)) => Some(Event::Resize),
|
Ok(CrosstermEvent::Resize(_, _)) => Some(TuiEvent::Resize),
|
||||||
Err(_) => Some(Event::Error),
|
Err(_) => Some(TuiEvent::Error),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait EventHandler {
|
pub trait TuiEventHandler {
|
||||||
fn update(&mut self, _: Event) -> Option<Event>;
|
fn update(&mut self, _: TuiEvent) -> Option<TuiEvent>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use color_eyre::eyre::Result;
|
use color_eyre::eyre::{Result, eyre};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
@@ -17,7 +17,13 @@ impl Logger {
|
|||||||
|
|
||||||
pub fn init(&self) -> Result<()> {
|
pub fn init(&self) -> Result<()> {
|
||||||
let log_path = self.log_dir.join("app.log");
|
let log_path = self.log_dir.join("app.log");
|
||||||
let log_file = fs::File::create(log_path)?;
|
let log_file = fs::File::create(log_path).map_err(|err| {
|
||||||
|
eyre!(
|
||||||
|
"Could not create a log file in {:?} : {}",
|
||||||
|
self.log_dir,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_line_number(true)
|
.with_line_number(true)
|
||||||
|
|||||||
48
src/main.rs
@@ -3,7 +3,6 @@ mod common;
|
|||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod events;
|
mod events;
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
mod logging;
|
mod logging;
|
||||||
|
|
||||||
mod args;
|
mod args;
|
||||||
@@ -13,24 +12,50 @@ mod terminal;
|
|||||||
mod utils;
|
mod utils;
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
use app::{App, AppArgs};
|
#[cfg(feature = "sound")]
|
||||||
use args::Args;
|
mod sound;
|
||||||
|
|
||||||
|
use app::{App, FromAppArgs};
|
||||||
|
use args::{Args, LOG_DIRECTORY_DEFAULT_MISSING_VALUE};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
use std::path::PathBuf;
|
||||||
use storage::{AppStorage, Storage};
|
use storage::{AppStorage, Storage};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
// init `Config`
|
||||||
let cfg = Config::init()?;
|
let cfg = Config::init()?;
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
logging::Logger::new(cfg.log_dir).init()?;
|
|
||||||
|
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
// get args given by CLI
|
// get args given by CLI
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
// Note:
|
||||||
|
// `log` arg can have three different values:
|
||||||
|
// (1) not set => None
|
||||||
|
// (2) set with path => Some(Some(path))
|
||||||
|
// (3) set without path => Some(None)
|
||||||
|
let custom_log_dir: Option<Option<&PathBuf>> = if let Some(path) = &args.log {
|
||||||
|
if path.ne(PathBuf::from(LOG_DIRECTORY_DEFAULT_MISSING_VALUE).as_os_str()) {
|
||||||
|
// (2)
|
||||||
|
Some(Some(path))
|
||||||
|
} else {
|
||||||
|
// (3)
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// (1)
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let terminal = terminal::setup()?;
|
if let Some(log_dir) = custom_log_dir {
|
||||||
|
let dir: PathBuf = log_dir.unwrap_or(&cfg.log_dir).to_path_buf();
|
||||||
|
logging::Logger::new(dir).init()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut terminal = terminal::setup()?;
|
||||||
let events = events::Events::new();
|
let events = events::Events::new();
|
||||||
|
|
||||||
// check persistant storage
|
// check persistant storage
|
||||||
@@ -42,9 +67,14 @@ async fn main() -> Result<()> {
|
|||||||
storage.load().unwrap_or_default()
|
storage.load().unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// merge `Args` and `AppStorage`.
|
let app_storage = App::from(FromAppArgs {
|
||||||
let app_args = AppArgs::from((args, stg));
|
args,
|
||||||
let app_storage = App::new(app_args).run(terminal, events).await?.to_storage();
|
stg,
|
||||||
|
app_tx: events.get_app_event_tx(),
|
||||||
|
})
|
||||||
|
.run(&mut terminal, events)
|
||||||
|
.await?
|
||||||
|
.to_storage();
|
||||||
// store app state persistantly
|
// store app state persistantly
|
||||||
storage.save(app_storage)?;
|
storage.save(app_storage)?;
|
||||||
|
|
||||||
|
|||||||
73
src/sound.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use rodio::{Decoder, OutputStream, Sink};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SoundError {
|
||||||
|
#[error("Sound output stream error: {0}")]
|
||||||
|
OutputStream(String),
|
||||||
|
#[error("Sound file error: {0}")]
|
||||||
|
File(String),
|
||||||
|
#[error("Sound sink error: {0}")]
|
||||||
|
Sink(String),
|
||||||
|
#[error("Sound decoder error: {0}")]
|
||||||
|
Decoder(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_sound_file(path: &PathBuf) -> Result<&PathBuf, SoundError> {
|
||||||
|
// validate path
|
||||||
|
if !path.exists() {
|
||||||
|
let err = SoundError::File(format!("File not found: {:?}", path));
|
||||||
|
return Err(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.filter(|ext| ["mp3", "wav"].contains(&ext.to_lowercase().as_str()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
SoundError::File(
|
||||||
|
"Unsupported file extension. Only .mp3 and .wav are supported".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[derive(Clone)]
|
||||||
|
pub struct Sound {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sound {
|
||||||
|
pub fn new(path: PathBuf) -> Result<Self, SoundError> {
|
||||||
|
Ok(Self { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self) -> Result<(), SoundError> {
|
||||||
|
// validate file again
|
||||||
|
validate_sound_file(&self.path)?;
|
||||||
|
// before playing the sound
|
||||||
|
let path = self.path.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || -> Result<(), SoundError> {
|
||||||
|
// Important note: Never (ever) use a single `_` as a placeholder here. `_stream` or something is fine!
|
||||||
|
// The value will dropped and the sound will fail without any errors
|
||||||
|
// see https://github.com/RustAudio/rodio/issues/330
|
||||||
|
let (_stream, handle) =
|
||||||
|
OutputStream::try_default().map_err(|e| SoundError::OutputStream(e.to_string()))?;
|
||||||
|
let file = File::open(&path).map_err(|e| SoundError::File(e.to_string()))?;
|
||||||
|
let sink = Sink::try_new(&handle).map_err(|e| SoundError::Sink(e.to_string()))?;
|
||||||
|
let decoder = Decoder::new(BufReader::new(file))
|
||||||
|
.map_err(|e| SoundError::Decoder(e.to_string()))?;
|
||||||
|
sink.append(decoder);
|
||||||
|
sink.sleep_until_end();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,37 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::{AppTimeFormat, Content, Style},
|
common::{AppTimeFormat, Content, Style, Toggle},
|
||||||
widgets::pomodoro::Mode as PomodoroMode,
|
widgets::pomodoro::Mode as PomodoroMode,
|
||||||
};
|
};
|
||||||
use color_eyre::eyre::Result;
|
use color_eyre::eyre::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn deserialize_app_time_format<'de, D>(deserializer: D) -> Result<AppTimeFormat, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
match s.as_str() {
|
||||||
|
// Hidden is deprecated - use `default` value instead
|
||||||
|
"Hidden" => Ok(AppTimeFormat::default()),
|
||||||
|
_ => s.parse().map_err(serde::de::Error::custom),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct AppStorage {
|
pub struct AppStorage {
|
||||||
pub content: Content,
|
pub content: Content,
|
||||||
pub show_menu: bool,
|
pub show_menu: bool,
|
||||||
|
pub notification: Toggle,
|
||||||
|
pub blink: Toggle,
|
||||||
|
#[serde(deserialize_with = "deserialize_app_time_format")]
|
||||||
pub app_time_format: AppTimeFormat,
|
pub app_time_format: AppTimeFormat,
|
||||||
pub style: Style,
|
pub style: Style,
|
||||||
pub with_decis: bool,
|
pub with_decis: bool,
|
||||||
pub pomodoro_mode: PomodoroMode,
|
pub pomodoro_mode: PomodoroMode,
|
||||||
|
pub pomodoro_count: u64,
|
||||||
// pomodoro -> work
|
// pomodoro -> work
|
||||||
pub inital_value_work: Duration,
|
pub inital_value_work: Duration,
|
||||||
pub current_value_work: Duration,
|
pub current_value_work: Duration,
|
||||||
@@ -28,6 +44,8 @@ pub struct AppStorage {
|
|||||||
pub elapsed_value_countdown: Duration,
|
pub elapsed_value_countdown: Duration,
|
||||||
// timer
|
// timer
|
||||||
pub current_value_timer: Duration,
|
pub current_value_timer: Duration,
|
||||||
|
// footer
|
||||||
|
pub footer_app_time: Toggle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppStorage {
|
impl Default for AppStorage {
|
||||||
@@ -38,10 +56,13 @@ impl Default for AppStorage {
|
|||||||
AppStorage {
|
AppStorage {
|
||||||
content: Content::default(),
|
content: Content::default(),
|
||||||
show_menu: true,
|
show_menu: true,
|
||||||
|
notification: Toggle::Off,
|
||||||
|
blink: Toggle::Off,
|
||||||
app_time_format: AppTimeFormat::default(),
|
app_time_format: AppTimeFormat::default(),
|
||||||
style: Style::default(),
|
style: Style::default(),
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
pomodoro_mode: PomodoroMode::Work,
|
pomodoro_mode: PomodoroMode::Work,
|
||||||
|
pomodoro_count: 1,
|
||||||
// pomodoro -> work
|
// pomodoro -> work
|
||||||
inital_value_work: DEFAULT_WORK,
|
inital_value_work: DEFAULT_WORK,
|
||||||
current_value_work: DEFAULT_WORK,
|
current_value_work: DEFAULT_WORK,
|
||||||
@@ -54,6 +75,8 @@ impl Default for AppStorage {
|
|||||||
elapsed_value_countdown: Duration::ZERO,
|
elapsed_value_countdown: Duration::ZERO,
|
||||||
// timer
|
// timer
|
||||||
current_value_timer: Duration::ZERO,
|
current_value_timer: Duration::ZERO,
|
||||||
|
// footer
|
||||||
|
footer_app_time: Toggle::Off,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ use crossterm::{
|
|||||||
cursor, execute,
|
cursor, execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal as RatatuiTerminal};
|
use ratatui::{Terminal as RatatuiTerminal, backend::CrosstermBackend};
|
||||||
|
|
||||||
pub type Terminal = RatatuiTerminal<CrosstermBackend<io::Stdout>>;
|
pub type Terminal = RatatuiTerminal<CrosstermBackend<io::Stdout>>;
|
||||||
|
|
||||||
pub fn setup() -> Result<Terminal> {
|
pub fn setup() -> Result<Terminal> {
|
||||||
let mut stdout = std::io::stdout();
|
let mut stdout = std::io::stdout();
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
|
set_panic_hook();
|
||||||
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
|
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
|
||||||
let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout))?;
|
let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout))?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
@@ -24,3 +25,13 @@ pub fn teardown() -> Result<()> {
|
|||||||
crossterm::terminal::disable_raw_mode()?;
|
crossterm::terminal::disable_raw_mode()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panic hook
|
||||||
|
// see https://ratatui.rs/tutorials/counter-app/error-handling/#setup-hooks
|
||||||
|
fn set_panic_hook() {
|
||||||
|
let hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |panic_info| {
|
||||||
|
let _ = teardown(); // ignore any errors as we are already failing
|
||||||
|
hook(panic_info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod countdown;
|
|||||||
pub mod edit_time;
|
pub mod edit_time;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
pub mod local_time;
|
||||||
pub mod pomodoro;
|
pub mod pomodoro;
|
||||||
pub mod progressbar;
|
pub mod progressbar;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
|
|||||||
1234
src/widgets/clock.rs
@@ -7,9 +7,13 @@ use ratatui::{
|
|||||||
pub const DIGIT_SIZE: usize = 5;
|
pub const DIGIT_SIZE: usize = 5;
|
||||||
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
|
pub const DIGIT_WIDTH: u16 = DIGIT_SIZE as u16;
|
||||||
pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */;
|
pub const DIGIT_HEIGHT: u16 = DIGIT_SIZE as u16 + 1 /* border height */;
|
||||||
|
pub const TWO_DIGITS_WIDTH: u16 = DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit
|
||||||
|
pub const THREE_DIGITS_WIDTH: u16 =
|
||||||
|
DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH + DIGIT_SPACE_WIDTH + DIGIT_WIDTH; // digit-space-digit-space-digit
|
||||||
pub const COLON_WIDTH: u16 = 4; // incl. padding left + padding right
|
pub const COLON_WIDTH: u16 = 4; // incl. padding left + padding right
|
||||||
pub const DOT_WIDTH: u16 = 4; // incl. padding left + padding right
|
pub const DOT_WIDTH: u16 = 4; // incl. padding left + padding right
|
||||||
pub const DIGIT_SPACE_WIDTH: u16 = 1; // space between digits
|
pub const DIGIT_SPACE_WIDTH: u16 = 1; // space between digits
|
||||||
|
pub const DIGIT_LABEL_WIDTH: u16 = 3; // label (single char) incl. padding left + padding right
|
||||||
|
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
const DIGIT_0: [u8; DIGIT_SIZE * DIGIT_SIZE] = [
|
const DIGIT_0: [u8; DIGIT_SIZE * DIGIT_SIZE] = [
|
||||||
|
|||||||
@@ -1,34 +1,259 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
duration::{ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND},
|
common::ClockTypeId,
|
||||||
|
duration::{
|
||||||
|
MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND, ONE_YEAR,
|
||||||
|
},
|
||||||
widgets::clock::*,
|
widgets::clock::*,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn default_args() -> ClockStateArgs {
|
||||||
|
ClockStateArgs {
|
||||||
|
initial_value: ONE_HOUR,
|
||||||
|
current_value: ONE_HOUR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_toggle_edit() {
|
fn test_type_id() {
|
||||||
|
let c = ClockState::<Timer>::new(default_args());
|
||||||
|
assert!(matches!(c.get_type_id(), ClockTypeId::Timer));
|
||||||
|
let c = ClockState::<Countdown>::new(default_args());
|
||||||
|
assert!(matches!(c.get_type_id(), ClockTypeId::Countdown));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_format_seconds() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_SECOND * 5,
|
||||||
|
current_value: ONE_SECOND * 5,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
// S
|
||||||
|
assert_eq!(c.get_format(), &Format::S);
|
||||||
|
// Ss
|
||||||
|
c.set_current_value(Duration::from_secs(15).into());
|
||||||
|
assert_eq!(c.get_format(), &Format::Ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_format_minutes() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_MINUTE,
|
||||||
|
current_value: ONE_MINUTE,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
// MSs
|
||||||
|
assert_eq!(c.get_format(), &Format::MSs);
|
||||||
|
// MmSs
|
||||||
|
c.set_current_value((ONE_MINUTE * 11).into()); // 10+ minutes
|
||||||
|
assert_eq!(c.get_format(), &Format::MmSs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_format_hours() {
|
||||||
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
initial_value: ONE_HOUR,
|
initial_value: ONE_HOUR,
|
||||||
current_value: ONE_HOUR,
|
current_value: ONE_HOUR,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
// off by default
|
// HMmSS
|
||||||
assert!(!c.is_edit_mode());
|
assert_eq!(c.get_format(), &Format::HMmSs);
|
||||||
// toggle on
|
// HhMmSs
|
||||||
c.toggle_edit();
|
c.set_current_value((10 * ONE_HOUR).into());
|
||||||
assert!(c.is_edit_mode());
|
assert_eq!(c.get_format(), &Format::HhMmSs);
|
||||||
// toggle off
|
}
|
||||||
c.toggle_edit();
|
|
||||||
assert!(!c.is_edit_mode());
|
#[test]
|
||||||
|
fn test_format_by_duration_boundaries() {
|
||||||
|
// S
|
||||||
|
assert_eq!(format_by_duration(&(ONE_SECOND * 9).into()), Format::S);
|
||||||
|
// Ss
|
||||||
|
assert_eq!(format_by_duration(&(10 * ONE_SECOND).into()), Format::Ss);
|
||||||
|
// Ss
|
||||||
|
assert_eq!(format_by_duration(&(59 * ONE_SECOND).into()), Format::Ss);
|
||||||
|
// MSs
|
||||||
|
assert_eq!(format_by_duration(&ONE_MINUTE.into()), Format::MSs);
|
||||||
|
// HhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::HhMmSs
|
||||||
|
);
|
||||||
|
// DHhMmSs
|
||||||
|
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
|
||||||
|
// DHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::DHhMmSs
|
||||||
|
);
|
||||||
|
// DdHhMmSs
|
||||||
|
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
|
||||||
|
// DdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::DdHhMmSs
|
||||||
|
);
|
||||||
|
// DddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_DAY).into()),
|
||||||
|
Format::DddHhMmSs
|
||||||
|
);
|
||||||
|
// DddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
|
||||||
|
Format::DddHhMmSs
|
||||||
|
);
|
||||||
|
// YDHhMmSs
|
||||||
|
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
|
||||||
|
// YDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::YDdHhMmSs
|
||||||
|
);
|
||||||
|
// YDddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YDddHhMmSs
|
||||||
|
);
|
||||||
|
// YDddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::YDddHhMmSs
|
||||||
|
);
|
||||||
|
// YyDHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR).into()),
|
||||||
|
Format::YyDHhMmSs
|
||||||
|
);
|
||||||
|
// YyDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
|
Format::YyDdHhMmSs
|
||||||
|
);
|
||||||
|
// YyDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::YyDdHhMmSs
|
||||||
|
);
|
||||||
|
// YyDddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YyDddHhMmSs
|
||||||
|
);
|
||||||
|
// YyDddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::YyDddHhMmSs
|
||||||
|
);
|
||||||
|
// YyyDHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR).into()),
|
||||||
|
Format::YyyDHhMmSs
|
||||||
|
);
|
||||||
|
// YyyDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
|
Format::YyyDdHhMmSs
|
||||||
|
);
|
||||||
|
// YyyDdHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
|
||||||
|
Format::YyyDdHhMmSs
|
||||||
|
);
|
||||||
|
// YyyDddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YyyDddHhMmSs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_by_duration_days() {
|
||||||
|
// DHhMmSs
|
||||||
|
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
|
||||||
|
// DdHhMmSs
|
||||||
|
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
|
||||||
|
// DddHhMmSs
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(101 * ONE_DAY).into()),
|
||||||
|
Format::DddHhMmSs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_by_duration_years() {
|
||||||
|
// YDHhMmSs (1 year, 0 days)
|
||||||
|
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
|
||||||
|
|
||||||
|
// YDHhMmSs (1 year, 1 day)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_YEAR + ONE_DAY).into()),
|
||||||
|
Format::YDHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YDdHhMmSs (1 year, 10 days)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
|
Format::YDdHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YDddHhMmSs (1 year, 100 days)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YDddHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YyDHhMmSs (10 years)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR).into()),
|
||||||
|
Format::YyDHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YyDdHhMmSs (10 years, 10 days)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
|
Format::YyDdHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YyDddHhMmSs (10 years, 100 days)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YyDddHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YyyDHhMmSs (100 years)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR).into()),
|
||||||
|
Format::YyyDHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YyyDdHhMmSs (100 years, 10 days)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
|
||||||
|
Format::YyyDdHhMmSs
|
||||||
|
);
|
||||||
|
|
||||||
|
// YyyDddHhMmSs (100 years, 100 days)
|
||||||
|
assert_eq!(
|
||||||
|
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
|
||||||
|
Format::YyyDddHhMmSs
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_edit_mode_hhmmss() {
|
fn test_default_edit_mode_hhmmss() {
|
||||||
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
initial_value: ONE_HOUR,
|
|
||||||
current_value: ONE_HOUR,
|
|
||||||
tick_value: ONE_DECI_SECOND,
|
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
..default_args()
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -43,6 +268,7 @@ fn test_default_edit_mode_mmss() {
|
|||||||
current_value: ONE_MINUTE,
|
current_value: ONE_MINUTE,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
// toggle on
|
// toggle on
|
||||||
c.toggle_edit();
|
c.toggle_edit();
|
||||||
@@ -56,12 +282,285 @@ fn test_default_edit_mode_ss() {
|
|||||||
current_value: ONE_SECOND,
|
current_value: ONE_SECOND,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
// toggle on
|
// toggle on
|
||||||
c.toggle_edit();
|
c.toggle_edit();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_up_stays_in_seconds() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_MINUTE - ONE_SECOND,
|
||||||
|
current_value: ONE_MINUTE - ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_up();
|
||||||
|
// Edit mode should stay on seconds
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_up_stays_in_minutes() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_HOUR - ONE_SECOND,
|
||||||
|
current_value: ONE_HOUR - ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
c.edit_up();
|
||||||
|
// Edit mode should stay on minutes
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_up_stays_in_hours() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_DAY - ONE_SECOND,
|
||||||
|
current_value: ONE_DAY - ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_up();
|
||||||
|
// Edit mode should stay on hours
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_up_stays_in_days() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_YEAR - ONE_DAY,
|
||||||
|
current_value: ONE_YEAR - ONE_DAY,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next(); // Hours
|
||||||
|
c.edit_next(); // Days
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_up();
|
||||||
|
// Edit mode should stay on days
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_up_overflow_protection() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: MAX_DURATION.saturating_sub(ONE_SECOND),
|
||||||
|
current_value: MAX_DURATION.saturating_sub(ONE_SECOND),
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next(); // Hours
|
||||||
|
c.edit_next(); // Days
|
||||||
|
c.edit_next(); // Years
|
||||||
|
c.edit_up(); // +1y
|
||||||
|
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
|
||||||
|
c.edit_prev(); // Days
|
||||||
|
c.edit_up(); // +1d
|
||||||
|
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
|
||||||
|
c.edit_prev(); // Hours
|
||||||
|
c.edit_up(); // +1h
|
||||||
|
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
|
||||||
|
c.edit_prev(); // Minutes
|
||||||
|
c.edit_up(); // +1m
|
||||||
|
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
|
||||||
|
c.edit_prev(); // Sec.
|
||||||
|
c.edit_up(); // +1s
|
||||||
|
c.edit_up(); // +1s
|
||||||
|
c.edit_up(); // +1s
|
||||||
|
assert!(Duration::from(*c.get_current_value()) <= MAX_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_down_years_to_days() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_YEAR + ONE_DAY,
|
||||||
|
current_value: ONE_YEAR + ONE_DAY,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next(); // Hours
|
||||||
|
c.edit_next(); // Days
|
||||||
|
c.edit_next(); // Years
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_down();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_down_days_to_hours() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_DAY + ONE_HOUR,
|
||||||
|
current_value: ONE_DAY + ONE_HOUR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next(); // Hours
|
||||||
|
c.edit_next(); // Days
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_down();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_down_hours_to_minutes() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_HOUR + ONE_MINUTE,
|
||||||
|
current_value: ONE_HOUR + ONE_MINUTE,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next(); // Hours
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_down();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_down_minutes_to_seconds() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_MINUTE,
|
||||||
|
current_value: ONE_MINUTE,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
c.edit_down();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_next_ydddhhmmssd() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_YEAR,
|
||||||
|
current_value: ONE_YEAR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on - should start at Minutes
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_hours_in_dhhmmss_format() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_DAY + ONE_HOUR,
|
||||||
|
current_value: ONE_DAY + ONE_HOUR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next(); // Move to Hours
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
|
||||||
|
// Increment hours - should stay in Hours edit mode
|
||||||
|
c.edit_up();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
assert_eq!(
|
||||||
|
Duration::from(*c.get_current_value()),
|
||||||
|
ONE_DAY + 2 * ONE_HOUR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_next_ydddhhmmss() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_YEAR,
|
||||||
|
current_value: ONE_YEAR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on - should start at Minutes
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_next_dhhmmssd() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_DAY,
|
||||||
|
current_value: ONE_DAY,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on - should start at Minutes (following existing pattern)
|
||||||
|
c.toggle_edit();
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_edit_next_hhmmssd() {
|
fn test_edit_next_hhmmssd() {
|
||||||
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
@@ -69,6 +568,7 @@ fn test_edit_next_hhmmssd() {
|
|||||||
current_value: ONE_HOUR,
|
current_value: ONE_HOUR,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -76,6 +576,10 @@ fn test_edit_next_hhmmssd() {
|
|||||||
c.edit_next();
|
c.edit_next();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
c.edit_next();
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_next();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
c.edit_next();
|
c.edit_next();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
@@ -90,6 +594,7 @@ fn test_edit_next_hhmmss() {
|
|||||||
current_value: ONE_HOUR,
|
current_value: ONE_HOUR,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -97,6 +602,10 @@ fn test_edit_next_hhmmss() {
|
|||||||
c.edit_next();
|
c.edit_next();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
c.edit_next();
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_next();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
c.edit_next();
|
c.edit_next();
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
@@ -109,6 +618,7 @@ fn test_edit_next_mmssd() {
|
|||||||
current_value: ONE_MINUTE,
|
current_value: ONE_MINUTE,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -128,6 +638,7 @@ fn test_edit_next_mmss() {
|
|||||||
current_value: ONE_MINUTE,
|
current_value: ONE_MINUTE,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -145,6 +656,7 @@ fn test_edit_next_ssd() {
|
|||||||
current_value: ONE_SECOND * 3,
|
current_value: ONE_SECOND * 3,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -153,6 +665,25 @@ fn test_edit_next_ssd() {
|
|||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_next_sd() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_SECOND,
|
||||||
|
current_value: ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_edit_next_ss() {
|
fn test_edit_next_ss() {
|
||||||
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
@@ -160,15 +691,109 @@ fn test_edit_next_ss() {
|
|||||||
current_value: ONE_SECOND * 3,
|
current_value: ONE_SECOND * 3,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
c.toggle_edit();
|
c.toggle_edit();
|
||||||
c.edit_next();
|
c.edit_next();
|
||||||
println!("mode -> {:?}", c.get_mode());
|
|
||||||
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_next_s() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_SECOND,
|
||||||
|
current_value: ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_next();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_prev_ydddhhmmssd() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_YEAR,
|
||||||
|
current_value: ONE_YEAR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on - should start at Minutes
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_prev_ydddhhmmss() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_YEAR,
|
||||||
|
current_value: ONE_YEAR,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on - should start at Minutes
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Years, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_prev_dhhmmssd() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_DAY,
|
||||||
|
current_value: ONE_DAY,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on - should start at Minutes
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Days, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Hours, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Minutes, _)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_edit_prev_hhmmssd() {
|
fn test_edit_prev_hhmmssd() {
|
||||||
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
@@ -176,6 +801,7 @@ fn test_edit_prev_hhmmssd() {
|
|||||||
current_value: ONE_HOUR,
|
current_value: ONE_HOUR,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -196,6 +822,7 @@ fn test_edit_prev_hhmmss() {
|
|||||||
current_value: ONE_HOUR,
|
current_value: ONE_HOUR,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -214,6 +841,7 @@ fn test_edit_prev_mmssd() {
|
|||||||
current_value: ONE_MINUTE,
|
current_value: ONE_MINUTE,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -234,6 +862,7 @@ fn test_edit_prev_mmss() {
|
|||||||
current_value: ONE_MINUTE,
|
current_value: ONE_MINUTE,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -252,6 +881,26 @@ fn test_edit_prev_ssd() {
|
|||||||
current_value: ONE_SECOND,
|
current_value: ONE_SECOND,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: true,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Decis, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_prev_sd() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_SECOND,
|
||||||
|
current_value: ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -270,6 +919,24 @@ fn test_edit_prev_ss() {
|
|||||||
current_value: ONE_SECOND,
|
current_value: ONE_SECOND,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle on
|
||||||
|
c.toggle_edit();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
c.edit_prev();
|
||||||
|
assert!(matches!(c.get_mode(), Mode::Editable(Time::Seconds, _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_prev_s() {
|
||||||
|
let mut c = ClockState::<Timer>::new(ClockStateArgs {
|
||||||
|
initial_value: ONE_SECOND,
|
||||||
|
current_value: ONE_SECOND,
|
||||||
|
tick_value: ONE_DECI_SECOND,
|
||||||
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -285,7 +952,8 @@ fn test_edit_up_ss() {
|
|||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: Duration::ZERO,
|
current_value: Duration::ZERO,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -301,7 +969,8 @@ fn test_edit_up_mmss() {
|
|||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: Duration::from_secs(60),
|
current_value: Duration::from_secs(60),
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -320,7 +989,8 @@ fn test_edit_up_hhmmss() {
|
|||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: Duration::from_secs(3600),
|
current_value: Duration::from_secs(3600),
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -341,7 +1011,8 @@ fn test_edit_down_ss() {
|
|||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: ONE_SECOND,
|
current_value: ONE_SECOND,
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -361,7 +1032,8 @@ fn test_edit_down_mmss() {
|
|||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: Duration::from_secs(120),
|
current_value: Duration::from_secs(120),
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
@@ -383,7 +1055,8 @@ fn test_edit_down_hhmmss() {
|
|||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: Duration::from_secs(3600),
|
current_value: Duration::from_secs(3600),
|
||||||
tick_value: ONE_DECI_SECOND,
|
tick_value: ONE_DECI_SECOND,
|
||||||
with_decis: false,
|
with_decis: true,
|
||||||
|
app_tx: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// toggle on
|
// toggle on
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use crate::{
|
|||||||
common::{AppTime, Style},
|
common::{AppTime, Style},
|
||||||
constants::TICK_VALUE_MS,
|
constants::TICK_VALUE_MS,
|
||||||
duration::{DurationEx, MAX_DURATION},
|
duration::{DurationEx, MAX_DURATION},
|
||||||
events::{Event, EventHandler},
|
events::{AppEventTx, TuiEvent, TuiEventHandler},
|
||||||
utils::center,
|
utils::center,
|
||||||
widgets::{
|
widgets::{
|
||||||
clock::{self, ClockState, ClockStateArgs, ClockWidget, Mode as ClockMode},
|
clock::{self, ClockState, ClockStateArgs, ClockWidget, Mode as ClockMode},
|
||||||
edit_time::EditTimeState,
|
edit_time::{EditTimeState, EditTimeStateArgs, EditTimeWidget},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::KeyModifiers;
|
||||||
@@ -17,15 +17,20 @@ use ratatui::{
|
|||||||
text::Line,
|
text::Line,
|
||||||
widgets::{StatefulWidget, Widget},
|
widgets::{StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::ops::Sub;
|
use std::ops::Sub;
|
||||||
use std::{cmp::max, time::Duration};
|
use std::{cmp::max, time::Duration};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::edit_time::{EditTimeStateArgs, EditTimeWidget};
|
pub struct CountdownStateArgs {
|
||||||
|
pub initial_value: Duration,
|
||||||
|
pub current_value: Duration,
|
||||||
|
pub elapsed_value: Duration,
|
||||||
|
pub app_time: AppTime,
|
||||||
|
pub with_decis: bool,
|
||||||
|
pub app_tx: AppEventTx,
|
||||||
|
}
|
||||||
|
|
||||||
/// State for Countdown Widget
|
/// State for Countdown Widget
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CountdownState {
|
pub struct CountdownState {
|
||||||
/// clock to count down
|
/// clock to count down
|
||||||
clock: ClockState<clock::Countdown>,
|
clock: ClockState<clock::Countdown>,
|
||||||
@@ -37,19 +42,32 @@ pub struct CountdownState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CountdownState {
|
impl CountdownState {
|
||||||
pub fn new(
|
pub fn new(args: CountdownStateArgs) -> Self {
|
||||||
clock: ClockState<clock::Countdown>,
|
let CountdownStateArgs {
|
||||||
elapsed_value: Duration,
|
initial_value,
|
||||||
app_time: AppTime,
|
current_value,
|
||||||
) -> Self {
|
elapsed_value,
|
||||||
|
with_decis,
|
||||||
|
app_time,
|
||||||
|
app_tx,
|
||||||
|
} = args;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
clock,
|
clock: ClockState::<clock::Countdown>::new(ClockStateArgs {
|
||||||
|
initial_value,
|
||||||
|
current_value,
|
||||||
|
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
||||||
|
with_decis,
|
||||||
|
app_tx: Some(app_tx.clone()),
|
||||||
|
}),
|
||||||
elapsed_clock: ClockState::<clock::Timer>::new(ClockStateArgs {
|
elapsed_clock: ClockState::<clock::Timer>::new(ClockStateArgs {
|
||||||
initial_value: Duration::ZERO,
|
initial_value: Duration::ZERO,
|
||||||
current_value: elapsed_value,
|
current_value: elapsed_value,
|
||||||
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
||||||
with_decis: false,
|
with_decis: false,
|
||||||
|
app_tx: None,
|
||||||
})
|
})
|
||||||
|
.with_name("MET".to_owned())
|
||||||
// A previous `elapsed_value > 0` means the `Clock` was running before,
|
// A previous `elapsed_value > 0` means the `Clock` was running before,
|
||||||
// but not in `Initial` state anymore. Updating `Mode` here
|
// but not in `Initial` state anymore. Updating `Mode` here
|
||||||
// is needed to handle `Event::Tick` in `EventHandler::update` properly
|
// is needed to handle `Event::Tick` in `EventHandler::update` properly
|
||||||
@@ -124,15 +142,14 @@ impl CountdownState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventHandler for CountdownState {
|
impl TuiEventHandler for CountdownState {
|
||||||
fn update(&mut self, event: Event) -> Option<Event> {
|
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
|
||||||
let is_edit_clock = self.clock.is_edit_mode();
|
|
||||||
let is_edit_time = self.edit_time.is_some();
|
|
||||||
match event {
|
match event {
|
||||||
Event::Tick => {
|
TuiEvent::Tick => {
|
||||||
if !self.clock.is_done() {
|
if !self.clock.is_done() {
|
||||||
self.clock.tick();
|
self.clock.tick();
|
||||||
} else {
|
} else {
|
||||||
|
self.clock.update_done_count();
|
||||||
self.elapsed_clock.tick();
|
self.elapsed_clock.tick();
|
||||||
if self.elapsed_clock.is_initial() {
|
if self.elapsed_clock.is_initial() {
|
||||||
self.elapsed_clock.run();
|
self.elapsed_clock.run();
|
||||||
@@ -145,7 +162,103 @@ impl EventHandler for CountdownState {
|
|||||||
edit_time.set_max_time(max_time);
|
edit_time.set_max_time(max_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Key(key) => match key.code {
|
// EDIT CLOCK mode
|
||||||
|
TuiEvent::Key(key) if self.is_clock_edit_mode() => match key.code {
|
||||||
|
// skip editing
|
||||||
|
KeyCode::Esc => {
|
||||||
|
// Important: set current value first
|
||||||
|
self.clock.set_current_value(*self.clock.get_prev_value());
|
||||||
|
// before toggling back to non-edit mode
|
||||||
|
self.clock.toggle_edit();
|
||||||
|
}
|
||||||
|
// Apply changes and set new initial value
|
||||||
|
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
// toggle edit mode
|
||||||
|
self.clock.toggle_edit();
|
||||||
|
// set initial value
|
||||||
|
self.clock
|
||||||
|
.set_initial_value(*self.clock.get_current_value());
|
||||||
|
// always reset `elapsed_clock`
|
||||||
|
self.elapsed_clock.reset();
|
||||||
|
}
|
||||||
|
// Apply changes
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
// toggle edit mode
|
||||||
|
self.clock.toggle_edit();
|
||||||
|
// always reset `elapsed_clock`
|
||||||
|
self.elapsed_clock.reset();
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.clock.edit_prev();
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.clock.edit_next();
|
||||||
|
}
|
||||||
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.clock.edit_jump_up();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.clock.edit_up();
|
||||||
|
}
|
||||||
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.clock.edit_jump_down();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.clock.edit_down();
|
||||||
|
}
|
||||||
|
_ => return Some(event),
|
||||||
|
},
|
||||||
|
// EDIT LOCAL TIME mode
|
||||||
|
TuiEvent::Key(key) if self.is_time_edit_mode() => match key.code {
|
||||||
|
// skip editing
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.edit_time = None;
|
||||||
|
}
|
||||||
|
// Apply changes and set new initial value
|
||||||
|
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
if let Some(edit_time) = &mut self.edit_time.clone() {
|
||||||
|
// Order matters:
|
||||||
|
// 1. update current value
|
||||||
|
self.edit_time_done(edit_time);
|
||||||
|
// 2. set initial value
|
||||||
|
self.clock
|
||||||
|
.set_initial_value(*self.clock.get_current_value());
|
||||||
|
}
|
||||||
|
// always reset `elapsed_clock`
|
||||||
|
self.elapsed_clock.reset();
|
||||||
|
}
|
||||||
|
// Apply changes of editing by local time
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
if let Some(edit_time) = &mut self.edit_time.clone() {
|
||||||
|
self.edit_time_done(edit_time)
|
||||||
|
}
|
||||||
|
// always reset `elapsed_clock`
|
||||||
|
self.elapsed_clock.reset();
|
||||||
|
}
|
||||||
|
// move edit position to the left
|
||||||
|
KeyCode::Left => {
|
||||||
|
// safe unwrap because we are in `is_time_edit_mode`
|
||||||
|
self.edit_time.as_mut().unwrap().next();
|
||||||
|
}
|
||||||
|
// move edit position to the right
|
||||||
|
KeyCode::Right => {
|
||||||
|
// safe unwrap because we are in `is_time_edit_mode`
|
||||||
|
self.edit_time.as_mut().unwrap().prev();
|
||||||
|
}
|
||||||
|
// Value up
|
||||||
|
KeyCode::Up => {
|
||||||
|
// safe unwrap because of previous check in `is_time_edit_mode`
|
||||||
|
self.edit_time.as_mut().unwrap().up();
|
||||||
|
}
|
||||||
|
// Value down
|
||||||
|
KeyCode::Down => {
|
||||||
|
// safe unwrap because of previous check in `is_time_edit_mode`
|
||||||
|
self.edit_time.as_mut().unwrap().down();
|
||||||
|
}
|
||||||
|
_ => return Some(event),
|
||||||
|
},
|
||||||
|
// default mode
|
||||||
|
TuiEvent::Key(key) => match key.code {
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
// reset both clocks to use intial values
|
// reset both clocks to use intial values
|
||||||
self.clock.reset();
|
self.clock.reset();
|
||||||
@@ -170,85 +283,29 @@ impl EventHandler for CountdownState {
|
|||||||
self.edit_time_done(edit_time);
|
self.edit_time_done(edit_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// STRG + e => toggle edit time
|
// Enter edit by local time mode
|
||||||
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
// stop editing clock
|
// set `edit_time`
|
||||||
if self.clock.is_edit_mode() {
|
|
||||||
// toggle edit mode
|
|
||||||
self.clock.toggle_edit();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(edit_time) = &mut self.edit_time.clone() {
|
|
||||||
self.edit_time_done(edit_time)
|
|
||||||
} else {
|
|
||||||
// update `edit_time`
|
|
||||||
self.edit_time = Some(EditTimeState::new(EditTimeStateArgs {
|
self.edit_time = Some(EditTimeState::new(EditTimeStateArgs {
|
||||||
time: self.time_to_edit(),
|
time: self.time_to_edit(),
|
||||||
min: self.min_time_to_edit(),
|
min: self.min_time_to_edit(),
|
||||||
max: self.max_time_to_edit(),
|
max: self.max_time_to_edit(),
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
// stop `clock`
|
// pause `elapsed_clock`
|
||||||
if self.clock.is_running() {
|
|
||||||
self.clock.toggle_pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop `elapsed_clock`
|
|
||||||
if self.elapsed_clock.is_running() {
|
if self.elapsed_clock.is_running() {
|
||||||
self.elapsed_clock.toggle_pause();
|
self.elapsed_clock.toggle_pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// STRG + e => toggle edit clock
|
// Enter edit clock mode
|
||||||
KeyCode::Char('e') => {
|
KeyCode::Char('e') => {
|
||||||
// toggle edit mode
|
// toggle edit mode
|
||||||
self.clock.toggle_edit();
|
self.clock.toggle_edit();
|
||||||
|
|
||||||
// stop `elapsed_clock`
|
// pause `elapsed_clock`
|
||||||
if self.elapsed_clock.is_running() {
|
if self.elapsed_clock.is_running() {
|
||||||
self.elapsed_clock.toggle_pause();
|
self.elapsed_clock.toggle_pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
// finish `edit_time` and continue for using `clock`
|
|
||||||
if let Some(edit_time) = &mut self.edit_time.clone() {
|
|
||||||
self.edit_time_done(edit_time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Left if is_edit_clock => {
|
|
||||||
self.clock.edit_next();
|
|
||||||
}
|
|
||||||
KeyCode::Left if is_edit_time => {
|
|
||||||
// safe unwrap because of previous check in `is_edit_time`
|
|
||||||
self.edit_time.as_mut().unwrap().next();
|
|
||||||
}
|
|
||||||
KeyCode::Right if is_edit_clock => {
|
|
||||||
self.clock.edit_prev();
|
|
||||||
}
|
|
||||||
KeyCode::Right if is_edit_time => {
|
|
||||||
// safe unwrap because of previous check in `is_edit_time`
|
|
||||||
self.edit_time.as_mut().unwrap().prev();
|
|
||||||
}
|
|
||||||
KeyCode::Up if is_edit_clock => {
|
|
||||||
self.clock.edit_up();
|
|
||||||
// whenever `clock`'s value is changed, reset `elapsed_clock`
|
|
||||||
self.elapsed_clock.reset();
|
|
||||||
}
|
|
||||||
KeyCode::Up if is_edit_time => {
|
|
||||||
// safe unwrap because of previous check in `is_edit_time`
|
|
||||||
self.edit_time.as_mut().unwrap().up();
|
|
||||||
// whenever `clock`'s value is changed, reset `elapsed_clock`
|
|
||||||
self.elapsed_clock.reset();
|
|
||||||
}
|
|
||||||
KeyCode::Down if is_edit_clock => {
|
|
||||||
self.clock.edit_down();
|
|
||||||
// whenever clock value is changed, reset timer
|
|
||||||
self.elapsed_clock.reset();
|
|
||||||
}
|
|
||||||
KeyCode::Down if is_edit_time => {
|
|
||||||
// safe unwrap because of previous check in `is_edit_time`
|
|
||||||
self.edit_time.as_mut().unwrap().down();
|
|
||||||
// whenever clock value is changed, reset timer
|
|
||||||
self.elapsed_clock.reset();
|
|
||||||
}
|
}
|
||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
},
|
},
|
||||||
@@ -260,6 +317,7 @@ impl EventHandler for CountdownState {
|
|||||||
|
|
||||||
pub struct Countdown {
|
pub struct Countdown {
|
||||||
pub style: Style,
|
pub style: Style,
|
||||||
|
pub blink: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn human_days_diff(a: &OffsetDateTime, b: &OffsetDateTime) -> String {
|
fn human_days_diff(a: &OffsetDateTime, b: &OffsetDateTime) -> String {
|
||||||
@@ -267,7 +325,7 @@ fn human_days_diff(a: &OffsetDateTime, b: &OffsetDateTime) -> String {
|
|||||||
match days_diff {
|
match days_diff {
|
||||||
0 => "today".to_owned(),
|
0 => "today".to_owned(),
|
||||||
1 => "tomorrow".to_owned(),
|
1 => "tomorrow".to_owned(),
|
||||||
n => format!("+{}days", n),
|
n => format!("+{n}days"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,11 +377,12 @@ impl StatefulWidget for Countdown {
|
|||||||
}
|
}
|
||||||
.to_uppercase(),
|
.to_uppercase(),
|
||||||
);
|
);
|
||||||
let widget = ClockWidget::new(self.style);
|
let widget = ClockWidget::new(self.style, self.blink);
|
||||||
|
|
||||||
let area = center(
|
let area = center(
|
||||||
area,
|
area,
|
||||||
Constraint::Length(max(
|
Constraint::Length(max(
|
||||||
widget.get_width(&state.clock.get_format(), state.clock.with_decis),
|
widget.get_width(state.clock.get_format(), state.clock.with_decis),
|
||||||
label.width() as u16,
|
label.width() as u16,
|
||||||
)),
|
)),
|
||||||
Constraint::Length(widget.get_height() + 1 /* height of label */),
|
Constraint::Length(widget.get_height() + 1 /* height of label */),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
common::Style,
|
common::Style,
|
||||||
widgets::clock_elements::{Colon, Digit, COLON_WIDTH, DIGIT_SPACE_WIDTH, DIGIT_WIDTH},
|
widgets::clock_elements::{COLON_WIDTH, Colon, DIGIT_SPACE_WIDTH, DIGIT_WIDTH, Digit},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::clock_elements::DIGIT_HEIGHT;
|
use super::clock_elements::DIGIT_HEIGHT;
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ use ratatui::{
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FooterState {
|
pub struct FooterState {
|
||||||
show_menu: bool,
|
show_menu: bool,
|
||||||
app_time_format: AppTimeFormat,
|
app_time_format: Option<AppTimeFormat>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FooterState {
|
impl FooterState {
|
||||||
pub const fn new(show_menu: bool, app_time_format: AppTimeFormat) -> Self {
|
pub const fn new(show_menu: bool, app_time_format: Option<AppTimeFormat>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
show_menu,
|
show_menu,
|
||||||
app_time_format,
|
app_time_format,
|
||||||
@@ -32,12 +32,12 @@ impl FooterState {
|
|||||||
self.show_menu
|
self.show_menu
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn app_time_format(&self) -> &AppTimeFormat {
|
pub const fn app_time_format(&self) -> &Option<AppTimeFormat> {
|
||||||
&self.app_time_format
|
&self.app_time_format
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_app_time_format(&mut self) {
|
pub const fn set_app_time_format(&mut self, value: Option<AppTimeFormat>) {
|
||||||
self.app_time_format = self.app_time_format.next();
|
self.app_time_format = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ impl StatefulWidget for Footer {
|
|||||||
(Content::Countdown, "[c]ountdown"),
|
(Content::Countdown, "[c]ountdown"),
|
||||||
(Content::Timer, "[t]imer"),
|
(Content::Timer, "[t]imer"),
|
||||||
(Content::Pomodoro, "[p]omodoro"),
|
(Content::Pomodoro, "[p]omodoro"),
|
||||||
|
(Content::LocalTime, "[l]ocal time"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let [_, area] =
|
let [_, area] =
|
||||||
@@ -71,11 +72,12 @@ impl StatefulWidget for Footer {
|
|||||||
)
|
)
|
||||||
.title(
|
.title(
|
||||||
Line::from(
|
Line::from(
|
||||||
match state.app_time_format {
|
match (state.app_time_format, self.selected_content) {
|
||||||
// `Hidden` -> no (empty) title
|
// Show time
|
||||||
AppTimeFormat::Hidden => "".into(),
|
(Some(v), content) if content != Content::LocalTime => format!(" {} " // add some space around
|
||||||
// others -> add some space around
|
, self.app_time.format(&v)),
|
||||||
_ => format!(" {} ", self.app_time.format(&state.app_time_format))
|
// Hide time -> empty
|
||||||
|
_ => "".into(),
|
||||||
}
|
}
|
||||||
).right_aligned())
|
).right_aligned())
|
||||||
.border_set(border::PLAIN)
|
.border_set(border::PLAIN)
|
||||||
@@ -89,7 +91,7 @@ impl StatefulWidget for Footer {
|
|||||||
let mut style = Style::default();
|
let mut style = Style::default();
|
||||||
// Add space for all except last
|
// Add space for all except last
|
||||||
let label = if index < content_labels.len() - 1 {
|
let label = if index < content_labels.len() - 1 {
|
||||||
format!("{} ", label)
|
format!("{label} ")
|
||||||
} else {
|
} else {
|
||||||
label.to_string()
|
label.to_string()
|
||||||
};
|
};
|
||||||
@@ -102,9 +104,8 @@ impl StatefulWidget for Footer {
|
|||||||
|
|
||||||
const SPACE: &str = " "; // 2 empty spaces
|
const SPACE: &str = " "; // 2 empty spaces
|
||||||
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
|
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
|
||||||
let table = Table::new(
|
let mut table_rows = vec![
|
||||||
[
|
// screens
|
||||||
// content
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(Span::styled(
|
Cell::from(Span::styled(
|
||||||
"screens",
|
"screens",
|
||||||
@@ -112,7 +113,7 @@ impl StatefulWidget for Footer {
|
|||||||
)),
|
)),
|
||||||
Cell::from(Line::from(content_labels)),
|
Cell::from(Line::from(content_labels)),
|
||||||
]),
|
]),
|
||||||
// format
|
// appearance
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(Span::styled(
|
Cell::from(Span::styled(
|
||||||
"appearance",
|
"appearance",
|
||||||
@@ -132,7 +133,11 @@ impl StatefulWidget for Footer {
|
|||||||
)),
|
)),
|
||||||
])),
|
])),
|
||||||
]),
|
]),
|
||||||
// edit
|
];
|
||||||
|
|
||||||
|
if self.selected_content != Content::LocalTime {
|
||||||
|
table_rows.extend_from_slice(&[
|
||||||
|
// controls - 1. row
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(Span::styled(
|
Cell::from(Span::styled(
|
||||||
"controls",
|
"controls",
|
||||||
@@ -141,55 +146,105 @@ impl StatefulWidget for Footer {
|
|||||||
Cell::from(Line::from({
|
Cell::from(Line::from({
|
||||||
match self.app_edit_mode {
|
match self.app_edit_mode {
|
||||||
AppEditMode::None => {
|
AppEditMode::None => {
|
||||||
let mut spans = vec![
|
let mut spans = vec![Span::from(if self.running_clock {
|
||||||
Span::from(if self.running_clock {
|
|
||||||
"[s]top"
|
"[s]top"
|
||||||
} else {
|
} else {
|
||||||
"[s]tart"
|
"[s]tart"
|
||||||
}),
|
})];
|
||||||
Span::from(SPACE),
|
spans.extend_from_slice(&[
|
||||||
Span::from("[r]eset"),
|
|
||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
Span::from("[e]dit"),
|
Span::from("[e]dit"),
|
||||||
];
|
]);
|
||||||
if self.selected_content == Content::Countdown {
|
if self.selected_content == Content::Countdown {
|
||||||
spans.extend_from_slice(&[
|
spans.extend_from_slice(&[
|
||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
Span::from("[ctrl+e]dit by local time"),
|
Span::from("[^e]dit by local time"),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
spans.extend_from_slice(&[
|
||||||
|
Span::from(SPACE),
|
||||||
|
Span::from("[r]eset clock"),
|
||||||
|
]);
|
||||||
if self.selected_content == Content::Pomodoro {
|
if self.selected_content == Content::Pomodoro {
|
||||||
spans.extend_from_slice(&[
|
spans.extend_from_slice(&[
|
||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
Span::from("[← →]switch work/pause"),
|
Span::from("[^r]eset clocks+rounds"),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
others => vec![
|
_ => {
|
||||||
Span::from(match others {
|
let mut spans = vec![Span::from("[s]ave changes")];
|
||||||
AppEditMode::Clock => "[e]dit done",
|
if self.selected_content == Content::Countdown
|
||||||
AppEditMode::Time => "[ctrl+e]dit done",
|
|| self.selected_content == Content::Pomodoro
|
||||||
_ => "",
|
{
|
||||||
}),
|
spans.extend_from_slice(&[
|
||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
|
Span::from("[^s]ave initial value"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
spans.extend_from_slice(&[
|
||||||
|
Span::from(SPACE),
|
||||||
|
Span::from("[esc]skip changes"),
|
||||||
|
]);
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
// controls - 2. row
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(Line::from("")),
|
||||||
|
Cell::from(Line::from({
|
||||||
|
match self.app_edit_mode {
|
||||||
|
AppEditMode::None => {
|
||||||
|
let mut spans = vec![];
|
||||||
|
if self.selected_content == Content::Pomodoro {
|
||||||
|
spans.extend_from_slice(&[Span::from(
|
||||||
|
"[← →]switch work/pause",
|
||||||
|
)]);
|
||||||
|
}
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
_ => vec![
|
||||||
Span::from(format!(
|
Span::from(format!(
|
||||||
"[{} {}]edit selection",
|
// ← →,
|
||||||
|
"[{} {}]change selection",
|
||||||
scrollbar::HORIZONTAL.begin,
|
scrollbar::HORIZONTAL.begin,
|
||||||
scrollbar::HORIZONTAL.end
|
scrollbar::HORIZONTAL.end
|
||||||
)), // ← →,
|
)),
|
||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
Span::from(format!("[{}]edit up", scrollbar::VERTICAL.begin)), // ↑
|
Span::from(format!(
|
||||||
|
// ↑
|
||||||
|
"[{}]edit up",
|
||||||
|
scrollbar::VERTICAL.begin
|
||||||
|
)),
|
||||||
Span::from(SPACE),
|
Span::from(SPACE),
|
||||||
Span::from(format!("[{}]edit up", scrollbar::VERTICAL.end)), // ↓,
|
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
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
]),
|
]),
|
||||||
],
|
])
|
||||||
widths,
|
}
|
||||||
)
|
|
||||||
.column_spacing(1);
|
let table = Table::new(table_rows, widths).column_spacing(1);
|
||||||
|
|
||||||
Widget::render(table, menu_area, buf);
|
Widget::render(table, menu_area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
195
src/widgets/local_time.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{StatefulWidget, Widget},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
common::{AppTime, AppTimeFormat, Style as DigitStyle},
|
||||||
|
duration::DurationEx,
|
||||||
|
events::{TuiEvent, TuiEventHandler},
|
||||||
|
utils::center,
|
||||||
|
widgets::clock_elements::{
|
||||||
|
COLON_WIDTH, Colon, DIGIT_HEIGHT, DIGIT_SPACE_WIDTH, DIGIT_WIDTH, Digit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::cmp::max;
|
||||||
|
|
||||||
|
/// State for `LocalTimeWidget`
|
||||||
|
pub struct LocalTimeState {
|
||||||
|
time: AppTime,
|
||||||
|
format: AppTimeFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalTimeStateArgs {
|
||||||
|
pub app_time: AppTime,
|
||||||
|
pub app_time_format: AppTimeFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalTimeState {
|
||||||
|
pub fn new(args: LocalTimeStateArgs) -> Self {
|
||||||
|
let LocalTimeStateArgs {
|
||||||
|
app_time,
|
||||||
|
app_time_format,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
time: app_time,
|
||||||
|
format: app_time_format,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_time(&mut self, app_time: AppTime) {
|
||||||
|
self.time = app_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_app_time_format(&mut self, format: AppTimeFormat) {
|
||||||
|
self.format = format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiEventHandler for LocalTimeState {
|
||||||
|
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
|
||||||
|
Some(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LocalTimeWidget {
|
||||||
|
pub style: DigitStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalTimeWidget {
|
||||||
|
fn get_horizontal_lengths(&self, format: &AppTimeFormat) -> Vec<u16> {
|
||||||
|
const PERIOD_WIDTH: u16 = 2; // PM or AM
|
||||||
|
|
||||||
|
match format {
|
||||||
|
AppTimeFormat::HhMmSs => vec![
|
||||||
|
DIGIT_WIDTH, // H
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // h
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
DIGIT_WIDTH, // M
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // m
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
DIGIT_WIDTH, // S
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // s
|
||||||
|
],
|
||||||
|
AppTimeFormat::HhMm => vec![
|
||||||
|
DIGIT_WIDTH, // H
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // h
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
DIGIT_WIDTH, // M
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // m
|
||||||
|
],
|
||||||
|
AppTimeFormat::Hh12Mm => vec![
|
||||||
|
DIGIT_SPACE_WIDTH + PERIOD_WIDTH, // (space) + (empty period) to center everything well horizontally
|
||||||
|
DIGIT_WIDTH, // H
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // h
|
||||||
|
COLON_WIDTH, // :
|
||||||
|
DIGIT_WIDTH, // M
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
DIGIT_WIDTH, // m
|
||||||
|
DIGIT_SPACE_WIDTH, // (space)
|
||||||
|
PERIOD_WIDTH, // period
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatefulWidget for LocalTimeWidget {
|
||||||
|
type State = LocalTimeState;
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
|
let current_value: DurationEx = state.time.as_duration_of_today().into();
|
||||||
|
let hours = current_value.hours_mod();
|
||||||
|
let hours12 = current_value.hours_mod_12();
|
||||||
|
let minutes = current_value.minutes_mod();
|
||||||
|
let seconds = current_value.seconds_mod();
|
||||||
|
let symbol = self.style.get_digit_symbol();
|
||||||
|
|
||||||
|
let label = Line::raw("Local Time".to_uppercase());
|
||||||
|
let label_date = Line::raw(state.time.format_date().to_uppercase());
|
||||||
|
let mut content_width = max(label.width(), label_date.width()) as u16;
|
||||||
|
|
||||||
|
let format = state.format;
|
||||||
|
let widths = self.get_horizontal_lengths(&format);
|
||||||
|
let mut widths = widths;
|
||||||
|
// Special case for `Hh12Mm`
|
||||||
|
// It might be `h:Mm` OR `Hh:Mm` depending on `hours12`
|
||||||
|
if state.format == AppTimeFormat::Hh12Mm && hours12 < 10 {
|
||||||
|
// single digit means, no (zero) width's for `H` and `space`
|
||||||
|
widths[1] = 0; // `H`
|
||||||
|
widths[2] = 0; // `space`
|
||||||
|
}
|
||||||
|
|
||||||
|
content_width = max(widths.iter().sum(), content_width);
|
||||||
|
let v_heights = [
|
||||||
|
1, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only
|
||||||
|
DIGIT_HEIGHT, // local time
|
||||||
|
1, // label
|
||||||
|
1, // date
|
||||||
|
];
|
||||||
|
|
||||||
|
let area = center(
|
||||||
|
area,
|
||||||
|
Constraint::Length(content_width),
|
||||||
|
Constraint::Length(v_heights.iter().sum()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let [_, v1, v2, v3] = Layout::vertical(Constraint::from_lengths(v_heights)).areas(area);
|
||||||
|
|
||||||
|
match state.format {
|
||||||
|
AppTimeFormat::HhMmSs => {
|
||||||
|
let [hh, _, h, c_hm, mm, _, m, c_ms, ss, _, s] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(v1);
|
||||||
|
Digit::new(hours / 10, false, symbol).render(hh, buf);
|
||||||
|
Digit::new(hours % 10, false, symbol).render(h, buf);
|
||||||
|
Colon::new(symbol).render(c_hm, buf);
|
||||||
|
Digit::new(minutes / 10, false, symbol).render(mm, buf);
|
||||||
|
Digit::new(minutes % 10, false, symbol).render(m, buf);
|
||||||
|
Colon::new(symbol).render(c_ms, buf);
|
||||||
|
Digit::new(seconds / 10, false, symbol).render(ss, buf);
|
||||||
|
Digit::new(seconds % 10, false, symbol).render(s, buf);
|
||||||
|
}
|
||||||
|
AppTimeFormat::HhMm => {
|
||||||
|
let [hh, _, h, c_hm, mm, _, m] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(v1);
|
||||||
|
Digit::new(hours / 10, false, symbol).render(hh, buf);
|
||||||
|
Digit::new(hours % 10, false, symbol).render(h, buf);
|
||||||
|
Colon::new(symbol).render(c_hm, buf);
|
||||||
|
Digit::new(minutes / 10, false, symbol).render(mm, buf);
|
||||||
|
Digit::new(minutes % 10, false, symbol).render(m, buf);
|
||||||
|
}
|
||||||
|
AppTimeFormat::Hh12Mm => {
|
||||||
|
let [_, hh, _, h, c_hm, mm, _, m, _, p] =
|
||||||
|
Layout::horizontal(Constraint::from_lengths(widths)).areas(v1);
|
||||||
|
// Hh
|
||||||
|
if hours12 >= 10 {
|
||||||
|
Digit::new(hours12 / 10, false, symbol).render(hh, buf);
|
||||||
|
Digit::new(hours12 % 10, false, symbol).render(h, buf);
|
||||||
|
}
|
||||||
|
// h
|
||||||
|
else {
|
||||||
|
Digit::new(hours12, false, symbol).render(h, buf);
|
||||||
|
}
|
||||||
|
Colon::new(symbol).render(c_hm, buf);
|
||||||
|
Digit::new(minutes / 10, false, symbol).render(mm, buf);
|
||||||
|
Digit::new(minutes % 10, false, symbol).render(m, buf);
|
||||||
|
Span::styled(
|
||||||
|
state.time.get_period().to_uppercase(),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.render(p, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label.centered().render(v2, buf);
|
||||||
|
label_date.centered().render(v3, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::Style,
|
common::Style,
|
||||||
constants::TICK_VALUE_MS,
|
constants::TICK_VALUE_MS,
|
||||||
events::{Event, EventHandler},
|
events::{AppEventTx, TuiEvent, TuiEventHandler},
|
||||||
utils::center,
|
utils::center,
|
||||||
widgets::clock::{ClockState, ClockWidget, Countdown},
|
widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown},
|
||||||
};
|
};
|
||||||
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
crossterm::event::KeyCode,
|
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
text::Line,
|
text::Line,
|
||||||
widgets::{StatefulWidget, Widget},
|
widgets::{StatefulWidget, Widget},
|
||||||
};
|
};
|
||||||
use std::{cmp::max, time::Duration};
|
|
||||||
|
|
||||||
use strum::Display;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{cmp::max, time::Duration};
|
||||||
use super::clock::ClockStateArgs;
|
use strum::Display;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Display, Hash, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
@@ -26,7 +22,6 @@ pub enum Mode {
|
|||||||
Pause,
|
Pause,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ClockMap {
|
pub struct ClockMap {
|
||||||
work: ClockState<Countdown>,
|
work: ClockState<Countdown>,
|
||||||
pause: ClockState<Countdown>,
|
pause: ClockState<Countdown>,
|
||||||
@@ -47,10 +42,10 @@ impl ClockMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PomodoroState {
|
pub struct PomodoroState {
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
clock_map: ClockMap,
|
clock_map: ClockMap,
|
||||||
|
round: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PomodoroStateArgs {
|
pub struct PomodoroStateArgs {
|
||||||
@@ -60,6 +55,8 @@ pub struct PomodoroStateArgs {
|
|||||||
pub initial_value_pause: Duration,
|
pub initial_value_pause: Duration,
|
||||||
pub current_value_pause: Duration,
|
pub current_value_pause: Duration,
|
||||||
pub with_decis: bool,
|
pub with_decis: bool,
|
||||||
|
pub app_tx: AppEventTx,
|
||||||
|
pub round: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PomodoroState {
|
impl PomodoroState {
|
||||||
@@ -71,6 +68,8 @@ impl PomodoroState {
|
|||||||
initial_value_pause,
|
initial_value_pause,
|
||||||
current_value_pause,
|
current_value_pause,
|
||||||
with_decis,
|
with_decis,
|
||||||
|
app_tx,
|
||||||
|
round,
|
||||||
} = args;
|
} = args;
|
||||||
Self {
|
Self {
|
||||||
mode,
|
mode,
|
||||||
@@ -80,14 +79,19 @@ impl PomodoroState {
|
|||||||
current_value: current_value_work,
|
current_value: current_value_work,
|
||||||
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
||||||
with_decis,
|
with_decis,
|
||||||
}),
|
app_tx: Some(app_tx.clone()),
|
||||||
|
})
|
||||||
|
.with_name("Work".to_owned()),
|
||||||
pause: ClockState::<Countdown>::new(ClockStateArgs {
|
pause: ClockState::<Countdown>::new(ClockStateArgs {
|
||||||
initial_value: initial_value_pause,
|
initial_value: initial_value_pause,
|
||||||
current_value: current_value_pause,
|
current_value: current_value_pause,
|
||||||
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
tick_value: Duration::from_millis(TICK_VALUE_MS),
|
||||||
with_decis,
|
with_decis,
|
||||||
}),
|
app_tx: Some(app_tx),
|
||||||
|
})
|
||||||
|
.with_name("Pause".to_owned()),
|
||||||
},
|
},
|
||||||
|
round,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +107,26 @@ impl PomodoroState {
|
|||||||
&self.clock_map.work
|
&self.clock_map.work
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_clock_work_mut(&mut self) -> &mut ClockState<Countdown> {
|
||||||
|
self.clock_map.get_mut(&Mode::Work)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_clock_pause(&self) -> &ClockState<Countdown> {
|
pub fn get_clock_pause(&self) -> &ClockState<Countdown> {
|
||||||
&self.clock_map.pause
|
&self.clock_map.pause
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_clock_pause_mut(&mut self) -> &mut ClockState<Countdown> {
|
||||||
|
self.clock_map.get_mut(&Mode::Pause)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_mode(&self) -> &Mode {
|
pub fn get_mode(&self) -> &Mode {
|
||||||
&self.mode
|
&self.mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_round(&self) -> u64 {
|
||||||
|
self.round
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_with_decis(&mut self, with_decis: bool) {
|
pub fn set_with_decis(&mut self, with_decis: bool) {
|
||||||
self.clock_map.work.with_decis = with_decis;
|
self.clock_map.work.with_decis = with_decis;
|
||||||
self.clock_map.pause.with_decis = with_decis;
|
self.clock_map.pause.with_decis = with_decis;
|
||||||
@@ -124,40 +140,84 @@ impl PomodoroState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventHandler for PomodoroState {
|
impl TuiEventHandler for PomodoroState {
|
||||||
fn update(&mut self, event: Event) -> Option<Event> {
|
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
|
||||||
let edit_mode = self.get_clock().is_edit_mode();
|
let edit_mode = self.get_clock().is_edit_mode();
|
||||||
match event {
|
match event {
|
||||||
Event::Tick => {
|
TuiEvent::Tick => {
|
||||||
self.get_clock_mut().tick();
|
self.get_clock_mut().tick();
|
||||||
|
self.get_clock_mut().update_done_count();
|
||||||
}
|
}
|
||||||
Event::Key(key) => match key.code {
|
// EDIT mode
|
||||||
|
TuiEvent::Key(key) if edit_mode => match key.code {
|
||||||
|
// Skip changes
|
||||||
|
KeyCode::Esc => {
|
||||||
|
let clock = self.get_clock_mut();
|
||||||
|
// Important: set current value first
|
||||||
|
clock.set_current_value(*clock.get_prev_value());
|
||||||
|
// before toggling back to non-edit mode
|
||||||
|
clock.toggle_edit();
|
||||||
|
}
|
||||||
|
// Apply changes and update initial value
|
||||||
|
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.get_clock_mut().toggle_edit();
|
||||||
|
// update initial value
|
||||||
|
let c = *self.get_clock().get_current_value();
|
||||||
|
self.get_clock_mut().set_initial_value(c);
|
||||||
|
}
|
||||||
|
// Apply changes
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
self.get_clock_mut().toggle_edit();
|
||||||
|
}
|
||||||
|
// Value up
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.get_clock_mut().edit_up();
|
||||||
|
}
|
||||||
|
// Value down
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.get_clock_mut().edit_down();
|
||||||
|
}
|
||||||
|
// move edit position to the left
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.get_clock_mut().edit_next();
|
||||||
|
}
|
||||||
|
// move edit position to the right
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.get_clock_mut().edit_prev();
|
||||||
|
}
|
||||||
|
_ => return Some(event),
|
||||||
|
},
|
||||||
|
// default mode
|
||||||
|
TuiEvent::Key(key) => match key.code {
|
||||||
|
// Toggle run/pause
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.get_clock_mut().toggle_pause();
|
self.get_clock_mut().toggle_pause();
|
||||||
}
|
}
|
||||||
|
// Enter edit mode
|
||||||
KeyCode::Char('e') => {
|
KeyCode::Char('e') => {
|
||||||
self.get_clock_mut().toggle_edit();
|
self.get_clock_mut().toggle_edit();
|
||||||
}
|
}
|
||||||
KeyCode::Left if edit_mode => {
|
// toggle WORK/PAUSE
|
||||||
self.get_clock_mut().edit_next();
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
// `next` is acting as same as a `prev` function, we don't have
|
// `next` is acting as same as a "prev" function we don't have
|
||||||
self.next();
|
self.next();
|
||||||
}
|
}
|
||||||
KeyCode::Right if edit_mode => {
|
// toggle WORK/PAUSE
|
||||||
self.get_clock_mut().edit_prev();
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
self.next();
|
self.next();
|
||||||
}
|
}
|
||||||
KeyCode::Up if edit_mode => {
|
// reset rounds AND clocks
|
||||||
self.get_clock_mut().edit_up();
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
}
|
self.round = 1;
|
||||||
KeyCode::Down if edit_mode => {
|
self.get_clock_work_mut().reset();
|
||||||
self.get_clock_mut().edit_down();
|
self.get_clock_pause_mut().reset();
|
||||||
}
|
}
|
||||||
|
// reset current clock
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
|
// increase round before (!!) resetting the clock
|
||||||
|
if self.get_mode() == &Mode::Work && self.get_clock().is_done() {
|
||||||
|
self.round += 1;
|
||||||
|
}
|
||||||
self.get_clock_mut().reset();
|
self.get_clock_mut().reset();
|
||||||
}
|
}
|
||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
@@ -170,12 +230,13 @@ impl EventHandler for PomodoroState {
|
|||||||
|
|
||||||
pub struct PomodoroWidget {
|
pub struct PomodoroWidget {
|
||||||
pub style: Style,
|
pub style: Style,
|
||||||
|
pub blink: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatefulWidget for PomodoroWidget {
|
impl StatefulWidget for PomodoroWidget {
|
||||||
type State = PomodoroState;
|
type State = PomodoroState;
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
let clock_widget = ClockWidget::new(self.style);
|
let clock_widget = ClockWidget::new(self.style, self.blink);
|
||||||
let label = Line::raw(
|
let label = Line::raw(
|
||||||
(format!(
|
(format!(
|
||||||
"Pomodoro {} {}",
|
"Pomodoro {} {}",
|
||||||
@@ -184,23 +245,34 @@ impl StatefulWidget for PomodoroWidget {
|
|||||||
))
|
))
|
||||||
.to_uppercase(),
|
.to_uppercase(),
|
||||||
);
|
);
|
||||||
|
let label_round = Line::raw((format!("round {}", state.get_round(),)).to_uppercase());
|
||||||
|
|
||||||
let area = center(
|
let area = center(
|
||||||
area,
|
area,
|
||||||
Constraint::Length(max(
|
Constraint::Length(max(
|
||||||
clock_widget.get_width(
|
clock_widget
|
||||||
&state.get_clock().get_format(),
|
.get_width(state.get_clock().get_format(), state.get_clock().with_decis),
|
||||||
state.get_clock().with_decis,
|
|
||||||
),
|
|
||||||
label.width() as u16,
|
label.width() as u16,
|
||||||
)),
|
)),
|
||||||
Constraint::Length(clock_widget.get_height() + 1 /* height of mode_str */),
|
Constraint::Length(
|
||||||
|
// empty label + height of `label` + `label_round`
|
||||||
|
clock_widget.get_height() + 3,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let [v1, v2] =
|
let [v1, v2, v3, v4] = Layout::vertical(Constraint::from_lengths([
|
||||||
Layout::vertical(Constraint::from_lengths([clock_widget.get_height(), 1])).areas(area);
|
1,
|
||||||
|
clock_widget.get_height(),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
]))
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
clock_widget.render(v1, buf, state.get_clock_mut());
|
// empty line keep everything in center vertically comparing to other
|
||||||
label.centered().render(v2, buf);
|
// views (which have one label below the clock only)
|
||||||
|
Line::raw("").centered().render(v1, buf);
|
||||||
|
clock_widget.render(v2, buf, state.get_clock_mut());
|
||||||
|
label.centered().render(v3, buf);
|
||||||
|
label_round.centered().render(v4, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::Style,
|
common::Style,
|
||||||
events::{Event, EventHandler},
|
events::{TuiEvent, TuiEventHandler},
|
||||||
utils::center,
|
utils::center,
|
||||||
widgets::clock::{self, ClockState, ClockWidget},
|
widgets::clock::{self, ClockState, ClockWidget},
|
||||||
};
|
};
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
crossterm::event::KeyCode,
|
crossterm::event::KeyCode,
|
||||||
@@ -13,7 +14,6 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use std::cmp::max;
|
use std::cmp::max;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TimerState {
|
pub struct TimerState {
|
||||||
clock: ClockState<clock::Timer>,
|
clock: ClockState<clock::Timer>,
|
||||||
}
|
}
|
||||||
@@ -32,35 +32,65 @@ impl TimerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventHandler for TimerState {
|
impl TuiEventHandler for TimerState {
|
||||||
fn update(&mut self, event: Event) -> Option<Event> {
|
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
|
||||||
let edit_mode = self.clock.is_edit_mode();
|
let edit_mode = self.clock.is_edit_mode();
|
||||||
match event {
|
match event {
|
||||||
Event::Tick => {
|
TuiEvent::Tick => {
|
||||||
self.clock.tick();
|
self.clock.tick();
|
||||||
|
self.clock.update_done_count();
|
||||||
}
|
}
|
||||||
Event::Key(key) => match key.code {
|
// EDIT mode
|
||||||
|
TuiEvent::Key(key) if edit_mode => match key.code {
|
||||||
|
// Skip changes
|
||||||
|
KeyCode::Esc => {
|
||||||
|
// Important: set current value first
|
||||||
|
self.clock.set_current_value(*self.clock.get_prev_value());
|
||||||
|
// before toggling back to non-edit mode
|
||||||
|
self.clock.toggle_edit();
|
||||||
|
}
|
||||||
|
// Apply changes
|
||||||
|
KeyCode::Char('s') => {
|
||||||
|
self.clock.toggle_edit();
|
||||||
|
}
|
||||||
|
// move change position to the left
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.clock.edit_next();
|
||||||
|
}
|
||||||
|
// move change position to the right
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.clock.edit_prev();
|
||||||
|
}
|
||||||
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.clock.edit_jump_up();
|
||||||
|
}
|
||||||
|
// change value up
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.clock.edit_up();
|
||||||
|
}
|
||||||
|
// change value down
|
||||||
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.clock.edit_jump_down();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.clock.edit_down();
|
||||||
|
}
|
||||||
|
_ => return Some(event),
|
||||||
|
},
|
||||||
|
// default mode
|
||||||
|
TuiEvent::Key(key) => match key.code {
|
||||||
|
// Toggle run/pause
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.clock.toggle_pause();
|
self.clock.toggle_pause();
|
||||||
}
|
}
|
||||||
|
// reset clock
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
self.clock.reset();
|
self.clock.reset();
|
||||||
}
|
}
|
||||||
|
// enter edit mode
|
||||||
KeyCode::Char('e') => {
|
KeyCode::Char('e') => {
|
||||||
self.clock.toggle_edit();
|
self.clock.toggle_edit();
|
||||||
}
|
}
|
||||||
KeyCode::Left if edit_mode => {
|
|
||||||
self.clock.edit_next();
|
|
||||||
}
|
|
||||||
KeyCode::Right if edit_mode => {
|
|
||||||
self.clock.edit_prev();
|
|
||||||
}
|
|
||||||
KeyCode::Up if edit_mode => {
|
|
||||||
self.clock.edit_up();
|
|
||||||
}
|
|
||||||
KeyCode::Down if edit_mode => {
|
|
||||||
self.clock.edit_down();
|
|
||||||
}
|
|
||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
},
|
},
|
||||||
_ => return Some(event),
|
_ => return Some(event),
|
||||||
@@ -71,19 +101,20 @@ impl EventHandler for TimerState {
|
|||||||
|
|
||||||
pub struct Timer {
|
pub struct Timer {
|
||||||
pub style: Style,
|
pub style: Style,
|
||||||
|
pub blink: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatefulWidget for Timer {
|
impl StatefulWidget for Timer {
|
||||||
type State = TimerState;
|
type State = TimerState;
|
||||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||||
let clock = &mut state.clock;
|
let clock = &mut state.clock;
|
||||||
let clock_widget = ClockWidget::new(self.style);
|
let clock_widget = ClockWidget::new(self.style, self.blink);
|
||||||
let label = Line::raw((format!("Timer {}", clock.get_mode())).to_uppercase());
|
let label = Line::raw((format!("Timer {}", clock.get_mode())).to_uppercase());
|
||||||
|
|
||||||
let area = center(
|
let area = center(
|
||||||
area,
|
area,
|
||||||
Constraint::Length(max(
|
Constraint::Length(max(
|
||||||
clock_widget.get_width(&clock.get_format(), clock.with_decis),
|
clock_widget.get_width(clock.get_format(), clock.with_decis),
|
||||||
label.width() as u16,
|
label.width() as u16,
|
||||||
)),
|
)),
|
||||||
Constraint::Length(clock_widget.get_height() + 1 /* height of label */),
|
Constraint::Length(clock_widget.get_height() + 1 /* height of label */),
|
||||||
|
|||||||