Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9583e9b7f6 | ||
|
|
7411bdc4a7 | ||
|
|
15e0fd9d08 | ||
|
|
a62f54e85b | ||
|
|
a92bfe79cc | ||
|
|
c8448c364e | ||
|
|
a2b3b23cc1 | ||
|
|
ec61470256 | ||
|
|
06d8a6fa8a | ||
|
|
dc1b08095c | ||
|
|
19a3d0927e | ||
|
|
6acc79a5d1 | ||
|
|
ddd3fee887 | ||
|
|
b6a59f6563 | ||
|
|
575b70d4ba | ||
|
|
b0a46f0710 | ||
|
|
3b0cb7e82b | ||
|
|
4d4c157f3a | ||
|
|
b047bd4324 | ||
|
|
366c481827 | ||
|
|
01765bf115 | ||
|
|
c440f75a4a | ||
|
|
714aebc2ed | ||
|
|
e3edf57745 | ||
|
|
627969a23b | ||
|
|
b7f34f795e | ||
|
|
8c559d605e | ||
|
|
d166be47ed | ||
|
|
377fab331f |
@@ -64,9 +64,9 @@ jobs:
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
@@ -180,14 +180,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
@@ -230,14 +230,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -250,14 +250,14 @@ jobs:
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
|
||||
Generated
+383
-1
@@ -23,6 +23,28 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.13.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -278,6 +300,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -309,6 +337,16 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.1"
|
||||
@@ -341,6 +379,50 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"block2",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"web-sys",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -452,6 +534,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deltae"
|
||||
version = "0.3.2"
|
||||
@@ -845,6 +933,64 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.19",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
@@ -960,6 +1106,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -1009,6 +1161,35 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -1046,6 +1227,15 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -1063,6 +1253,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1072,6 +1271,28 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
@@ -1090,6 +1311,55 @@ dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-avf-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
@@ -1097,7 +1367,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -1309,6 +1581,12 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -1335,6 +1613,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -1490,6 +1777,15 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "realfft"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||
dependencies = [
|
||||
"rustfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1537,6 +1833,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -1562,6 +1872,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -1669,6 +1988,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -1693,6 +2028,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -1779,12 +2120,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "termato"
|
||||
version = "0.0.0"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"crossterm",
|
||||
"figlet-rs",
|
||||
"notify-rust",
|
||||
"ratatui",
|
||||
"realfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1985,6 +2328,16 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -2071,6 +2424,16 @@ dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -2131,6 +2494,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
@@ -2219,6 +2592,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
||||
+3
-1
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "termato"
|
||||
version = "0.0.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
repository = "https://github.com/StevanFreeborn/termato"
|
||||
|
||||
[dependencies]
|
||||
cpal = "0.18.1"
|
||||
crossterm = "0.29.0"
|
||||
figlet-rs = "1.0.0"
|
||||
notify-rust = "4.18.0"
|
||||
ratatui = "0.30.2"
|
||||
realfft = "3.5.0"
|
||||
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
|
||||
+8
-1
@@ -4,10 +4,17 @@ members = ["cargo:."]
|
||||
# Config for 'dist'
|
||||
[dist]
|
||||
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||
cargo-dist-version = "0.32.0"
|
||||
cargo-dist-version = "0.31.0"
|
||||
# CI backends to support
|
||||
ci = "github"
|
||||
# The installers to generate for each app
|
||||
installers = ["shell", "powershell"]
|
||||
# Target platforms to build apps for (Rust target-triple syntax)
|
||||
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
||||
# Path that installers should place binaries in
|
||||
install-path = "CARGO_HOME"
|
||||
# Whether to install an updater program
|
||||
install-updater = true
|
||||
|
||||
[dist.dependencies.apt]
|
||||
libasound2-dev = "*"
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
use crate::constants::SECONDS_PER_MIN;
|
||||
use crate::msg::Message;
|
||||
use crate::notification::send_desktop_notification;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum TimerMode {
|
||||
Work,
|
||||
Break,
|
||||
}
|
||||
|
||||
pub struct Termato {
|
||||
pub work_mins: u32,
|
||||
pub break_mins: u32,
|
||||
pub mode: TimerMode,
|
||||
pub is_running: bool,
|
||||
pub duration_in_secs: u32,
|
||||
pub time_remaining_in_sec: u32,
|
||||
pub show_help: bool,
|
||||
pub enable_notifications: bool,
|
||||
should_quit: bool,
|
||||
}
|
||||
|
||||
impl Termato {
|
||||
pub fn new(work_mins: u32, break_mins: u32) -> Self {
|
||||
Termato {
|
||||
work_mins,
|
||||
break_mins,
|
||||
mode: TimerMode::Work,
|
||||
is_running: false,
|
||||
duration_in_secs: work_mins * SECONDS_PER_MIN,
|
||||
time_remaining_in_sec: work_mins * SECONDS_PER_MIN,
|
||||
show_help: false,
|
||||
enable_notifications: false,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_quit(&self) -> bool {
|
||||
self.should_quit
|
||||
}
|
||||
|
||||
pub fn with_notifications(mut self, enable: bool) -> Self {
|
||||
self.enable_notifications = enable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn update(&mut self, msg: Message) {
|
||||
match msg {
|
||||
Message::Tick => self.tick(),
|
||||
Message::ToggleRunning => self.toggle_running(),
|
||||
Message::ToggleMode => self.toggle_mode(),
|
||||
Message::Reset => self.reset(),
|
||||
Message::ToggleHelp => self.toggle_help(),
|
||||
Message::Quit => self.should_quit = true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_running(&mut self) {
|
||||
self.is_running = !self.is_running;
|
||||
}
|
||||
|
||||
pub fn toggle_mode(&mut self) {
|
||||
self.mode = match self.mode {
|
||||
TimerMode::Work => TimerMode::Break,
|
||||
TimerMode::Break => TimerMode::Work,
|
||||
};
|
||||
self.duration_in_secs = match self.mode {
|
||||
TimerMode::Work => self.work_mins * SECONDS_PER_MIN,
|
||||
TimerMode::Break => self.break_mins * SECONDS_PER_MIN,
|
||||
};
|
||||
self.time_remaining_in_sec = self.duration_in_secs;
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
if self.is_running && self.time_remaining_in_sec > 0 {
|
||||
self.time_remaining_in_sec -= 1;
|
||||
|
||||
if self.time_remaining_in_sec == 0 {
|
||||
if self.enable_notifications {
|
||||
match self.mode {
|
||||
TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."),
|
||||
TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."),
|
||||
}
|
||||
}
|
||||
|
||||
self.toggle_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.is_running = false;
|
||||
self.mode = TimerMode::Work;
|
||||
self.duration_in_secs = self.work_mins * SECONDS_PER_MIN;
|
||||
self.time_remaining_in_sec = self.duration_in_secs;
|
||||
}
|
||||
|
||||
pub fn toggle_help(&mut self) {
|
||||
self.show_help = !self.show_help;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn termato_new_when_called_it_should_return_expected_starting_state() {
|
||||
let result = Termato::new(25, 5);
|
||||
|
||||
assert_eq!(result.work_mins, 25);
|
||||
assert_eq!(result.break_mins, 5);
|
||||
assert_eq!(result.mode, TimerMode::Work);
|
||||
assert!(!result.is_running);
|
||||
assert_eq!(result.duration_in_secs, 1500);
|
||||
assert_eq!(result.time_remaining_in_sec, 1500);
|
||||
assert!(!result.show_help);
|
||||
assert!(!result.enable_notifications);
|
||||
assert!(!result.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_update_when_called_with_messages_it_should_update_state() {
|
||||
let mut termato = Termato::new(25, 5);
|
||||
|
||||
termato.update(Message::ToggleRunning);
|
||||
assert!(termato.is_running);
|
||||
|
||||
termato.update(Message::ToggleHelp);
|
||||
assert!(termato.show_help);
|
||||
|
||||
termato.update(Message::ToggleMode);
|
||||
assert_eq!(termato.mode, TimerMode::Break);
|
||||
|
||||
termato.update(Message::Reset);
|
||||
assert!(!termato.is_running);
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
|
||||
termato.update(Message::Quit);
|
||||
assert!(termato.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_called_and_not_running_it_should_do_nothing() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
|
||||
termato.update(Message::Tick);
|
||||
|
||||
assert_eq!(termato.time_remaining_in_sec, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
termato.time_remaining_in_sec = 0;
|
||||
|
||||
termato.update(Message::Tick);
|
||||
|
||||
assert_eq!(termato.time_remaining_in_sec, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
|
||||
termato.update(Message::Tick);
|
||||
|
||||
assert_eq!(termato.time_remaining_in_sec, 59);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() {
|
||||
let mut termato = Termato::new(1, 2);
|
||||
termato.is_running = true;
|
||||
termato.time_remaining_in_sec = 1;
|
||||
|
||||
termato.update(Message::Tick);
|
||||
|
||||
assert_eq!(termato.mode, TimerMode::Break);
|
||||
assert_eq!(termato.duration_in_secs, 120);
|
||||
assert_eq!(termato.time_remaining_in_sec, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() {
|
||||
let mut termato = Termato::new(2, 1);
|
||||
termato.mode = TimerMode::Break;
|
||||
termato.is_running = true;
|
||||
termato.time_remaining_in_sec = 1;
|
||||
|
||||
termato.update(Message::Tick);
|
||||
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
assert_eq!(termato.duration_in_secs, 120);
|
||||
assert_eq!(termato.time_remaining_in_sec, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_mode_when_called_it_should_toggle_the_mode() {
|
||||
let mut termato = Termato::new(2, 1);
|
||||
|
||||
termato.toggle_mode();
|
||||
assert_eq!(termato.mode, TimerMode::Break);
|
||||
assert_eq!(termato.duration_in_secs, 60);
|
||||
assert_eq!(termato.time_remaining_in_sec, 60);
|
||||
|
||||
termato.toggle_mode();
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
assert_eq!(termato.duration_in_secs, 120);
|
||||
assert_eq!(termato.time_remaining_in_sec, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_running_when_called_and_already_running_it_should_pause() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
|
||||
termato.toggle_running();
|
||||
|
||||
assert!(!termato.is_running)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_running_when_called_and_already_paused_it_should_run() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
|
||||
termato.toggle_running();
|
||||
|
||||
assert!(termato.is_running)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_rest_when_called_it_should_reset_the_apps_state() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
termato.mode = TimerMode::Break;
|
||||
termato.duration_in_secs = 1;
|
||||
termato.time_remaining_in_sec = 1;
|
||||
|
||||
termato.reset();
|
||||
|
||||
assert!(!termato.is_running);
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
assert_eq!(termato.duration_in_secs, 60);
|
||||
assert_eq!(termato.time_remaining_in_sec, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_help_when_called_it_should_toggle_help() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
|
||||
termato.toggle_help();
|
||||
|
||||
assert!(termato.show_help);
|
||||
|
||||
termato.toggle_help();
|
||||
|
||||
assert!(!termato.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_notifications_when_called_it_should_verify_opt_in_behavior() {
|
||||
let result = Termato::new(25, 5);
|
||||
assert!(!result.enable_notifications);
|
||||
|
||||
let result = result.with_notifications(true);
|
||||
assert!(result.enable_notifications);
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
use std::env;
|
||||
|
||||
use crate::constants::{DEFAULT_BREAK_MINS, DEFAULT_WORK_MINS};
|
||||
|
||||
pub const FLAG_HELP: &str = "-h";
|
||||
pub const FLAG_HELP_LONG: &str = "--help";
|
||||
pub const FLAG_VERSION: &str = "-v";
|
||||
pub const FLAG_VERSION_LONG: &str = "--version";
|
||||
pub const FLAG_NOTIFY: &str = "-n";
|
||||
pub const FLAG_NOTIFY_LONG: &str = "--notify";
|
||||
pub const FLAG_VISUALIZER: &str = "-z";
|
||||
pub const FLAG_VISUALIZER_LONG: &str = "--visualizer";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct CliConfig {
|
||||
pub work_mins: u32,
|
||||
pub break_mins: u32,
|
||||
pub enable_notifications: bool,
|
||||
pub enable_visualizer: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CliAction {
|
||||
Run(CliConfig),
|
||||
PrintHelp,
|
||||
PrintVersion,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct Cli;
|
||||
|
||||
impl Cli {
|
||||
pub fn parse() -> CliAction {
|
||||
Self::parse_args(env::args().collect())
|
||||
}
|
||||
|
||||
pub fn parse_args(args: Vec<String>) -> CliAction {
|
||||
let mut enable_notifications = false;
|
||||
let mut enable_visualizer = false;
|
||||
let mut positional = Vec::new();
|
||||
|
||||
for arg in args.into_iter().skip(1) {
|
||||
match arg.as_str() {
|
||||
FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp,
|
||||
FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion,
|
||||
FLAG_NOTIFY | FLAG_NOTIFY_LONG => enable_notifications = true,
|
||||
FLAG_VISUALIZER | FLAG_VISUALIZER_LONG => enable_visualizer = true,
|
||||
"--" => {}
|
||||
s if s.starts_with('-') => {
|
||||
return CliAction::Error(format!("Unknown option: '{}'", s));
|
||||
}
|
||||
s => positional.push(s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
let numbers: Vec<u32> = positional
|
||||
.iter()
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect();
|
||||
|
||||
let work_mins = numbers.first().copied().unwrap_or(DEFAULT_WORK_MINS);
|
||||
let break_mins = numbers.get(1).copied().unwrap_or(DEFAULT_BREAK_MINS);
|
||||
|
||||
if numbers.len() > 2 {
|
||||
return CliAction::Error(format!("Unexpected argument: '{}'", positional[2]));
|
||||
}
|
||||
|
||||
CliAction::Run(CliConfig {
|
||||
work_mins,
|
||||
break_mins,
|
||||
enable_notifications,
|
||||
enable_visualizer,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn print_help() {
|
||||
println!("Usage: termato [options] [work_minutes] [break_minutes]");
|
||||
|
||||
println!();
|
||||
|
||||
println!("Options:");
|
||||
|
||||
println!(
|
||||
" {:<19} Enable desktop notifications",
|
||||
format_args!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG)
|
||||
);
|
||||
|
||||
println!(
|
||||
" {:<19} Display visualizer of playing audio",
|
||||
format_args!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG)
|
||||
);
|
||||
|
||||
println!(
|
||||
" {:<19} Print this help message",
|
||||
format_args!("{}, {}", FLAG_HELP, FLAG_HELP_LONG)
|
||||
);
|
||||
|
||||
println!(
|
||||
" {:<19} Print the version number",
|
||||
format_args!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG)
|
||||
);
|
||||
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"Defaults: work_minutes = {}, break_minutes = {}",
|
||||
DEFAULT_WORK_MINS, DEFAULT_BREAK_MINS
|
||||
);
|
||||
}
|
||||
|
||||
pub fn print_version() {
|
||||
println!("termato version {}", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_defaults() {
|
||||
let args = vec!["termato".to_string()];
|
||||
assert_eq!(
|
||||
Cli::parse_args(args),
|
||||
CliAction::Run(CliConfig {
|
||||
work_mins: 25,
|
||||
break_mins: 5,
|
||||
enable_notifications: false,
|
||||
enable_visualizer: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cargo_run_syntax_with_dot_and_dash_dash() {
|
||||
let args = vec![
|
||||
"target/debug/termato.exe".to_string(),
|
||||
".".to_string(),
|
||||
"--".to_string(),
|
||||
"-z".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
Cli::parse_args(args),
|
||||
CliAction::Run(CliConfig {
|
||||
work_mins: 25,
|
||||
break_mins: 5,
|
||||
enable_notifications: false,
|
||||
enable_visualizer: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_time_and_flags() {
|
||||
let args = vec![
|
||||
"termato".to_string(),
|
||||
"-n".to_string(),
|
||||
"-z".to_string(),
|
||||
"50".to_string(),
|
||||
"10".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
Cli::parse_args(args),
|
||||
CliAction::Run(CliConfig {
|
||||
work_mins: 50,
|
||||
break_mins: 10,
|
||||
enable_notifications: true,
|
||||
enable_visualizer: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_flag() {
|
||||
let args = vec!["termato".to_string(), "--help".to_string()];
|
||||
assert_eq!(Cli::parse_args(args), CliAction::PrintHelp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_flag() {
|
||||
let args = vec!["termato".to_string(), "--foo".to_string()];
|
||||
assert_eq!(
|
||||
Cli::parse_args(args),
|
||||
CliAction::Error("Unknown option: '--foo'".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_numeric_positional_defaults() {
|
||||
let args = vec!["termato".to_string(), "abc".to_string()];
|
||||
assert_eq!(
|
||||
Cli::parse_args(args),
|
||||
CliAction::Run(CliConfig {
|
||||
work_mins: 25,
|
||||
break_mins: 5,
|
||||
enable_notifications: false,
|
||||
enable_visualizer: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use realfft::{RealFftPlanner, num_complex};
|
||||
|
||||
pub struct AudioVisualizer {
|
||||
pub bar_data: Arc<Mutex<Vec<u64>>>,
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
// This is a implementation largely lifted from these
|
||||
// open source implementation. I just wrote it in Rust here:
|
||||
// 1. CAVA (C): https://github.com/karlstav/cava
|
||||
// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer
|
||||
impl AudioVisualizer {
|
||||
pub fn new(num_bars: usize) -> Self {
|
||||
let bar_data = Arc::new(Mutex::new(vec![0; num_bars]));
|
||||
let bar_data_clone = Arc::clone(&bar_data);
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let stop_clone = Arc::clone(&stop_flag);
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars, stop_clone) {
|
||||
eprintln!("Audio capture error: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
bar_data,
|
||||
stop_flag,
|
||||
}
|
||||
}
|
||||
|
||||
// Doing best effort to support cross-platform functionality
|
||||
fn get_audio_device(
|
||||
host: &cpal::Host,
|
||||
) -> Result<(cpal::Device, cpal::StreamConfig), Box<dyn std::error::Error>> {
|
||||
// Try windows WASAPI loopback on default output device
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(device) = host.default_output_device()
|
||||
&& let Ok(config) = device.default_output_config()
|
||||
{
|
||||
return Ok((device, config.into()));
|
||||
}
|
||||
}
|
||||
|
||||
// Try Linux PipeWire/PulseAudio output monitor device
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(devices) = host.devices() {
|
||||
for dev in devices {
|
||||
if let Ok(desc) = dev.description() {
|
||||
let name = desc.to_string();
|
||||
|
||||
// Monitor devices mirror system output under PulseAudio/PipeWire
|
||||
if name.contains("monitor") {
|
||||
if let Ok(config) = dev.default_input_config() {
|
||||
return Ok((dev, config.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default input device
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.or_else(|| host.default_output_device())
|
||||
.ok_or("No audio input or output device found")?;
|
||||
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.or_else(|_| device.default_output_config())?
|
||||
.into();
|
||||
|
||||
Ok((device, config))
|
||||
}
|
||||
|
||||
// we here in doubling octaves but fft outputs linearly spaced bins.
|
||||
// so we bundle bins on a log scale from 20 hz to 12 kHz
|
||||
// this allows bass, mids, and trembls to have equal visual
|
||||
// proportions during display
|
||||
//
|
||||
// i.e.
|
||||
// bins like this [0-500Hz] [500-1k] [1k-1.5k] [1.5k-2k] [2k-2.5k] [2.5k-3k] [3k-12kHz]
|
||||
// vs
|
||||
// bins like this [20-60Hz] [60-250Hz] [250-500Hz] [500-2kHz] [2k-4kHz] [4k-8kHz] [8k-12kHz]
|
||||
fn build_log_bins(num_bars: usize, sample_rate: f32, chunk_size: usize) -> Vec<(usize, usize)> {
|
||||
let nyquist = sample_rate / 2.0;
|
||||
let max_hz = 12000.0f32;
|
||||
|
||||
(0..num_bars)
|
||||
.map(|i| {
|
||||
let low_hz = 20.0 * (max_hz / 20.0).powf(i as f32 / num_bars as f32);
|
||||
let high_hz = 20.0 * (max_hz / 20.0).powf((i + 1) as f32 / num_bars as f32);
|
||||
|
||||
let low = ((low_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize;
|
||||
let high = ((high_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize;
|
||||
|
||||
(low.max(1), high.max(low + 1))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// without we get sharp edges which gives noise in FFT processing
|
||||
fn apply_hann_window(samples: &[f32], input_buffer: &mut [f32]) {
|
||||
let chunk_size = samples.len();
|
||||
|
||||
for (i, sample) in samples.iter().enumerate() {
|
||||
let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / chunk_size as f32).cos());
|
||||
input_buffer[i] = sample * window;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_fft_magnitudes(
|
||||
spectrum: &[num_complex::Complex32],
|
||||
log_bins: &[(usize, usize)],
|
||||
freq_boost: &[f32],
|
||||
prev_heights: &[f32],
|
||||
autosens: f32,
|
||||
smoothing: f32,
|
||||
falloff: f32,
|
||||
) -> Vec<f32> {
|
||||
let num_bars = log_bins.len();
|
||||
let mut current_bars = vec![0.0f32; num_bars];
|
||||
|
||||
// determine magnitude
|
||||
for i in 0..num_bars {
|
||||
let (start, stop) = log_bins[i];
|
||||
let bin_slice = &spectrum[start..stop.min(spectrum.len())];
|
||||
let magnitude_sum: f32 = bin_slice.iter().map(|c| c.norm()).sum();
|
||||
let avg_mag = if !bin_slice.is_empty() {
|
||||
magnitude_sum / bin_slice.len() as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// ignore quiet static noise below specific amplitude
|
||||
let raw_val = if avg_mag < 0.02 {
|
||||
0.0
|
||||
} else {
|
||||
(avg_mag * freq_boost[i] * autosens + 1.0).log10() * 3.5
|
||||
};
|
||||
|
||||
// rise smoothly from previous height
|
||||
let target = (raw_val * (1.0 - smoothing)) + (prev_heights[i] * smoothing);
|
||||
|
||||
// prevent sudden drops
|
||||
if target < prev_heights[i] {
|
||||
current_bars[i] = (prev_heights[i] - falloff).max(0.0);
|
||||
} else {
|
||||
current_bars[i] = target;
|
||||
}
|
||||
}
|
||||
|
||||
current_bars
|
||||
}
|
||||
|
||||
// blend the heights between neighboring freq bins
|
||||
// so instead of sharp spikes we get more of waves
|
||||
fn apply_monstercat_smoothing(bars: &[f32]) -> Vec<f32> {
|
||||
let num_bars = bars.len();
|
||||
let mut smoothed = bars.to_vec();
|
||||
|
||||
for i in 1..(num_bars - 1) {
|
||||
smoothed[i] = (bars[i - 1] * 0.25) + (bars[i] * 0.50) + (bars[i + 1] * 0.25);
|
||||
}
|
||||
|
||||
smoothed
|
||||
}
|
||||
|
||||
// songs can be quiet and loud so we try to adjust
|
||||
// sensitivity to avoid bars becoming flattened or
|
||||
// clipped
|
||||
fn adjust_autosens(autosens: &mut f32, bars: &[f32]) {
|
||||
let max_val = bars.iter().copied().fold(0.0f32, f32::max);
|
||||
|
||||
if max_val > 8.0 {
|
||||
*autosens *= 0.98;
|
||||
} else if max_val < 3.0 && *autosens < 3.0 {
|
||||
*autosens *= 1.01;
|
||||
}
|
||||
}
|
||||
|
||||
// captures output and runs through pipeline
|
||||
// system audio -> audio buffer -> windowing -> fft process -> binning -> floor/autosens -> smoothing
|
||||
fn run_audio_loop(
|
||||
bar_data: Arc<Mutex<Vec<u64>>>,
|
||||
num_bars: usize,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let host = cpal::default_host();
|
||||
let (device, config) = Self::get_audio_device(&host)?;
|
||||
|
||||
let sample_rate = config.sample_rate as f32;
|
||||
let channels = config.channels as usize;
|
||||
|
||||
let chunk_size = 2048;
|
||||
let mut planner = RealFftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(chunk_size);
|
||||
let mut windowed_buffer = fft.make_input_vec();
|
||||
let mut spectrum = fft.make_output_vec();
|
||||
|
||||
let mut prev_heights = vec![0.0f32; num_bars];
|
||||
let smoothing = 0.70f32;
|
||||
let falloff = 0.08f32;
|
||||
let mut autosens = 1.0f32;
|
||||
|
||||
let log_bins = Self::build_log_bins(num_bars, sample_rate, chunk_size);
|
||||
|
||||
let freq_boost: Vec<f32> = (0..num_bars)
|
||||
.map(|i| 1.0 + (3.5 * (i as f32 / num_bars as f32).powf(1.2)))
|
||||
.collect();
|
||||
|
||||
let audio_buffer = Arc::new(Mutex::new(Vec::<f32>::with_capacity(chunk_size * 2)));
|
||||
let buffer_clone = Arc::clone(&audio_buffer);
|
||||
|
||||
let stream = device.build_input_stream(
|
||||
config,
|
||||
move |data: &[f32], _| {
|
||||
if let Ok(mut buf) = buffer_clone.lock() {
|
||||
for chunk in data.chunks(channels) {
|
||||
let mono: f32 = chunk.iter().sum::<f32>() / channels as f32;
|
||||
buf.push(mono);
|
||||
}
|
||||
|
||||
if buf.len() > chunk_size * 2 {
|
||||
let drain_amt = buf.len() - chunk_size;
|
||||
buf.drain(0..drain_amt);
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| eprintln!("Stream error: {}", err),
|
||||
None,
|
||||
)?;
|
||||
|
||||
stream.play()?;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(std::time::Duration::from_millis(16));
|
||||
|
||||
let samples = {
|
||||
let buf = match audio_buffer.lock() {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if buf.len() < chunk_size {
|
||||
continue;
|
||||
}
|
||||
buf[buf.len() - chunk_size..].to_vec()
|
||||
};
|
||||
|
||||
Self::apply_hann_window(&samples, &mut windowed_buffer);
|
||||
|
||||
if let Err(e) = fft.process(&mut windowed_buffer, &mut spectrum) {
|
||||
eprintln!("FFT error: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_bars = Self::process_fft_magnitudes(
|
||||
&spectrum,
|
||||
&log_bins,
|
||||
&freq_boost,
|
||||
&prev_heights,
|
||||
autosens,
|
||||
smoothing,
|
||||
falloff,
|
||||
);
|
||||
|
||||
let smoothed_bars = Self::apply_monstercat_smoothing(&raw_bars);
|
||||
prev_heights = smoothed_bars.clone();
|
||||
|
||||
Self::adjust_autosens(&mut autosens, &smoothed_bars);
|
||||
|
||||
if let Ok(mut bars) = bar_data.lock() {
|
||||
for (i, val) in smoothed_bars.iter().enumerate() {
|
||||
bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioVisualizer {
|
||||
fn drop(&mut self) {
|
||||
self.stop_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub const SECONDS_PER_MIN: u32 = 60;
|
||||
pub const DEFAULT_WORK_MINS: u32 = 25;
|
||||
pub const DEFAULT_BREAK_MINS: u32 = 5;
|
||||
pub const VIZ_NUM_BARS: u16 = 64;
|
||||
@@ -0,0 +1,38 @@
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
use crate::msg::Message;
|
||||
|
||||
pub fn handle_event(event: Event) -> Option<Message> {
|
||||
if let Event::Key(key) = event
|
||||
&& key.kind == event::KeyEventKind::Press
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') => Some(Message::Quit),
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Message::Quit),
|
||||
KeyCode::Char(' ') => Some(Message::ToggleRunning),
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => Some(Message::ToggleMode),
|
||||
KeyCode::Char('r') | KeyCode::Char('R') => Some(Message::Reset),
|
||||
KeyCode::Char('?') => Some(Message::ToggleHelp),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn handle_event_when_called_maps_key_presses_to_messages() {
|
||||
let key_event = Event::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(handle_event(key_event), Some(Message::ToggleRunning));
|
||||
|
||||
let key_event_q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
||||
assert_eq!(handle_event(key_event_q), Some(Message::Quit));
|
||||
|
||||
let key_event_unknown = Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
|
||||
assert_eq!(handle_event(key_event_unknown), None);
|
||||
}
|
||||
}
|
||||
+50
-396
@@ -1,3 +1,13 @@
|
||||
mod app;
|
||||
mod args;
|
||||
mod audio;
|
||||
mod constants;
|
||||
mod events;
|
||||
mod msg;
|
||||
mod notification;
|
||||
mod terminal;
|
||||
mod ui;
|
||||
|
||||
use std::{
|
||||
io::{self, stdout},
|
||||
sync::mpsc,
|
||||
@@ -5,155 +15,38 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
ExecutableCommand,
|
||||
event::{self, Event, KeyCode, KeyModifiers},
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
|
||||
use crossterm::event;
|
||||
use figlet_rs::Toilet;
|
||||
use notify_rust::Notification;
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
|
||||
const SECONDS_PER_MIN: u32 = 60;
|
||||
use app::Termato;
|
||||
use audio::AudioVisualizer;
|
||||
use events::handle_event;
|
||||
use msg::Message;
|
||||
use terminal::TerminalGuard;
|
||||
use ui::render_app;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
enum TimerMode {
|
||||
Work,
|
||||
Break,
|
||||
}
|
||||
use args::{Cli, CliAction};
|
||||
|
||||
fn send_desktop_notification(title: &str, body: &str) {
|
||||
let title = title.to_string();
|
||||
let body = body.to_string();
|
||||
use crate::constants::VIZ_NUM_BARS;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _ = Notification::new()
|
||||
.summary(&title)
|
||||
.body(&body)
|
||||
.appname("termato")
|
||||
.show();
|
||||
});
|
||||
}
|
||||
|
||||
struct Termato {
|
||||
work_mins: u32,
|
||||
break_mins: u32,
|
||||
mode: TimerMode,
|
||||
is_running: bool,
|
||||
duration_in_secs: u32,
|
||||
time_remaining_in_sec: u32,
|
||||
show_help: bool,
|
||||
enable_notifications: bool,
|
||||
}
|
||||
|
||||
impl Termato {
|
||||
fn new(work_mins: u32, break_mins: u32) -> Self {
|
||||
Termato {
|
||||
work_mins,
|
||||
break_mins,
|
||||
mode: TimerMode::Work,
|
||||
is_running: false,
|
||||
duration_in_secs: work_mins * SECONDS_PER_MIN,
|
||||
time_remaining_in_sec: work_mins * SECONDS_PER_MIN,
|
||||
show_help: false,
|
||||
enable_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_notifications(mut self, enable: bool) -> Self {
|
||||
self.enable_notifications = enable;
|
||||
self
|
||||
}
|
||||
|
||||
fn toggle_running(&mut self) {
|
||||
self.is_running = !self.is_running;
|
||||
}
|
||||
|
||||
fn toggle_mode(&mut self) {
|
||||
self.mode = match self.mode {
|
||||
TimerMode::Work => TimerMode::Break,
|
||||
TimerMode::Break => TimerMode::Work,
|
||||
};
|
||||
self.duration_in_secs = match self.mode {
|
||||
TimerMode::Work => self.work_mins * SECONDS_PER_MIN,
|
||||
TimerMode::Break => self.break_mins * SECONDS_PER_MIN,
|
||||
};
|
||||
self.time_remaining_in_sec = self.duration_in_secs;
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
if self.is_running && self.time_remaining_in_sec > 0 {
|
||||
self.time_remaining_in_sec -= 1;
|
||||
|
||||
if self.time_remaining_in_sec == 0 {
|
||||
if self.enable_notifications {
|
||||
match self.mode {
|
||||
TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."),
|
||||
TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."),
|
||||
}
|
||||
}
|
||||
|
||||
self.toggle_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.is_running = false;
|
||||
self.mode = TimerMode::Work;
|
||||
self.duration_in_secs = self.work_mins * SECONDS_PER_MIN;
|
||||
self.time_remaining_in_sec = self.duration_in_secs;
|
||||
}
|
||||
|
||||
fn toggle_help(&mut self) {
|
||||
self.show_help = !self.show_help;
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalGuard;
|
||||
|
||||
impl TerminalGuard {
|
||||
fn new() -> Result<Self, io::Error> {
|
||||
enable_raw_mode()?;
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
Ok(TerminalGuard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = stdout().execute(LeaveAlternateScreen);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Refactor to an Elm like architecture
|
||||
fn main() -> Result<(), io::Error> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
|
||||
println!("Usage: termato [options] [work_minutes] [break_minutes]");
|
||||
println!();
|
||||
println!("Options:");
|
||||
println!(" -n, --notify Enable desktop notifications");
|
||||
println!(" -h, --help Print this help message");
|
||||
println!(" -v, --version Print the version number");
|
||||
println!();
|
||||
println!("Defaults: work_minutes = 25, break_minutes = 5");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if args.iter().any(|arg| arg == "-v" || arg == "--version") {
|
||||
println!("termato version {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let config = match Cli::parse() {
|
||||
CliAction::PrintHelp => {
|
||||
Cli::print_help();
|
||||
return Ok(());
|
||||
}
|
||||
CliAction::PrintVersion => {
|
||||
Cli::print_version();
|
||||
return Ok(());
|
||||
}
|
||||
CliAction::Error(err) => {
|
||||
eprintln!("Error: {}", err);
|
||||
eprintln!("Run 'termato --help' for usage instructions.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
CliAction::Run(cfg) => cfg,
|
||||
};
|
||||
|
||||
let _guard = TerminalGuard::new()?;
|
||||
|
||||
@@ -161,15 +54,14 @@ fn main() -> Result<(), io::Error> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let font = Toilet::smblock().unwrap();
|
||||
|
||||
let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify");
|
||||
let positional_args: Vec<&String> = args.iter()
|
||||
.skip(1)
|
||||
.filter(|arg| *arg != "-n" && *arg != "--notify")
|
||||
.collect();
|
||||
let visualizer = if config.enable_visualizer {
|
||||
Some(AudioVisualizer::new(VIZ_NUM_BARS as usize))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let work_mins = positional_args.get(0).and_then(|s| s.parse().ok()).unwrap_or(25);
|
||||
let break_mins = positional_args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5);
|
||||
let mut termato = Termato::new(work_mins, break_mins).with_notifications(enable_notifications);
|
||||
let mut termato = Termato::new(config.work_mins, config.break_mins)
|
||||
.with_notifications(config.enable_notifications);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
@@ -182,258 +74,20 @@ fn main() -> Result<(), io::Error> {
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| {
|
||||
let size = f.area();
|
||||
while !termato.should_quit() {
|
||||
terminal.draw(|f| render_app(&termato, f, &font, visualizer.as_ref()))?;
|
||||
|
||||
let minutes = termato.time_remaining_in_sec / SECONDS_PER_MIN;
|
||||
let seconds = termato.time_remaining_in_sec % SECONDS_PER_MIN;
|
||||
let time_str = format!("{:02}:{:02}", minutes, seconds);
|
||||
|
||||
let time_color = if !termato.is_running {
|
||||
Color::Yellow
|
||||
} else {
|
||||
match termato.mode {
|
||||
TimerMode::Work => Color::Red,
|
||||
TimerMode::Break => Color::Green,
|
||||
}
|
||||
};
|
||||
|
||||
let big_time_text = if let Some(fig) = font.convert(&time_str) {
|
||||
fig.to_string()
|
||||
} else {
|
||||
time_str
|
||||
};
|
||||
|
||||
let text_height = big_time_text.lines().count() as u16;
|
||||
|
||||
let vertical_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(text_height),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
let timer_paragraph = Paragraph::new(big_time_text)
|
||||
.style(Style::default().fg(time_color))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(timer_paragraph, vertical_layout[1]);
|
||||
|
||||
if termato.show_help {
|
||||
let popup_width = 44;
|
||||
let popup_height = 7;
|
||||
|
||||
if size.width >= popup_width && size.height >= popup_height {
|
||||
let popup_vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length((size.height.saturating_sub(popup_height)) / 2),
|
||||
Constraint::Length(popup_height),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
let popup_horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length((size.width.saturating_sub(popup_width)) / 2),
|
||||
Constraint::Length(popup_width),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(popup_vertical[1]);
|
||||
|
||||
let popup_area = popup_horizontal[1];
|
||||
|
||||
let help_block = Block::default()
|
||||
.title(" Controls ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
let help_content = Paragraph::new(
|
||||
"[Space] Pause / Resume\n\
|
||||
[S] Switch Mode\n\
|
||||
[R] Reset Timer\n\
|
||||
[?] Toggle Help\n\
|
||||
[Q] Quit",
|
||||
)
|
||||
.block(help_block)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Left);
|
||||
|
||||
f.render_widget(Clear, popup_area);
|
||||
f.render_widget(help_content, popup_area);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
&& key.kind == event::KeyEventKind::Press
|
||||
{
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') => break,
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
|
||||
KeyCode::Char(' ') => termato.toggle_running(),
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => termato.toggle_mode(),
|
||||
KeyCode::Char('r') | KeyCode::Char('R') => termato.reset(),
|
||||
KeyCode::Char('?') => termato.toggle_help(),
|
||||
_ => {}
|
||||
if event::poll(Duration::from_millis(16))? {
|
||||
let raw_event = event::read()?;
|
||||
if let Some(msg) = handle_event(raw_event) {
|
||||
termato.update(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if rx.try_recv().is_ok() {
|
||||
termato.tick();
|
||||
termato.update(Message::Tick);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn termato_when_called_it_should_return_expected_starting_state() {
|
||||
let result = Termato::new(25, 5);
|
||||
|
||||
assert_eq!(result.work_mins, 25);
|
||||
assert_eq!(result.break_mins, 5);
|
||||
assert_eq!(result.mode, TimerMode::Work);
|
||||
assert!(!result.is_running);
|
||||
assert_eq!(result.duration_in_secs, 1500);
|
||||
assert_eq!(result.time_remaining_in_sec, 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_called_and_not_running_it_should_do_nothing() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
|
||||
termato.tick();
|
||||
|
||||
assert_eq!(termato.time_remaining_in_sec, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
termato.time_remaining_in_sec = 0;
|
||||
|
||||
termato.tick();
|
||||
|
||||
assert_eq!(termato.time_remaining_in_sec, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
|
||||
termato.tick();
|
||||
|
||||
assert_eq!(termato.time_remaining_in_sec, 59);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() {
|
||||
let mut termato = Termato::new(1, 2);
|
||||
termato.is_running = true;
|
||||
termato.time_remaining_in_sec = 1;
|
||||
|
||||
termato.tick();
|
||||
|
||||
assert_eq!(termato.mode, TimerMode::Break);
|
||||
assert_eq!(termato.duration_in_secs, 120);
|
||||
assert_eq!(termato.time_remaining_in_sec, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() {
|
||||
let mut termato = Termato::new(2, 1);
|
||||
termato.mode = TimerMode::Break;
|
||||
termato.is_running = true;
|
||||
termato.time_remaining_in_sec = 1;
|
||||
|
||||
termato.tick();
|
||||
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
assert_eq!(termato.duration_in_secs, 120);
|
||||
assert_eq!(termato.time_remaining_in_sec, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_mode_when_called_it_should_toggle_the_mode() {
|
||||
let mut termato = Termato::new(2, 1);
|
||||
|
||||
termato.toggle_mode();
|
||||
assert_eq!(termato.mode, TimerMode::Break);
|
||||
assert_eq!(termato.duration_in_secs, 60);
|
||||
assert_eq!(termato.time_remaining_in_sec, 60);
|
||||
|
||||
termato.toggle_mode();
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
assert_eq!(termato.duration_in_secs, 120);
|
||||
assert_eq!(termato.time_remaining_in_sec, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_running_when_called_and_already_running_it_should_pause() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
|
||||
termato.toggle_running();
|
||||
|
||||
assert!(!termato.is_running)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_running_when_called_and_already_paused_it_should_run() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
|
||||
termato.toggle_running();
|
||||
|
||||
assert!(termato.is_running)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_rest_when_called_it_should_reset_the_apps_state() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
termato.is_running = true;
|
||||
termato.mode = TimerMode::Break;
|
||||
termato.duration_in_secs = 1;
|
||||
termato.time_remaining_in_sec = 1;
|
||||
|
||||
termato.reset();
|
||||
|
||||
assert!(!termato.is_running);
|
||||
assert_eq!(termato.mode, TimerMode::Work);
|
||||
assert_eq!(termato.duration_in_secs, 60);
|
||||
assert_eq!(termato.time_remaining_in_sec, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_toggle_help_when_called_it_should_toggle_help() {
|
||||
let mut termato = Termato::new(1, 1);
|
||||
|
||||
termato.toggle_help();
|
||||
|
||||
assert!(termato.show_help);
|
||||
|
||||
termato.toggle_help();
|
||||
|
||||
assert!(!termato.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termato_notifications_when_called_it_should_verify_opt_in_behavior() {
|
||||
let result = Termato::new(25, 5);
|
||||
assert!(!result.enable_notifications);
|
||||
|
||||
let result = result.with_notifications(true);
|
||||
assert!(result.enable_notifications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum Message {
|
||||
Tick,
|
||||
ToggleRunning,
|
||||
ToggleMode,
|
||||
Reset,
|
||||
ToggleHelp,
|
||||
Quit,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use notify_rust::Notification;
|
||||
|
||||
pub fn send_desktop_notification(title: &str, body: &str) {
|
||||
let title = title.to_string();
|
||||
let body = body.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _ = Notification::new()
|
||||
.summary(&title)
|
||||
.body(&body)
|
||||
.appname("termato")
|
||||
.show();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use crossterm::{
|
||||
ExecutableCommand,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use std::io::{self, stdout};
|
||||
|
||||
pub struct TerminalGuard;
|
||||
|
||||
impl TerminalGuard {
|
||||
pub fn new() -> Result<Self, io::Error> {
|
||||
enable_raw_mode()?;
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
Ok(TerminalGuard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = stdout().execute(LeaveAlternateScreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use figlet_rs::Toilet;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
};
|
||||
|
||||
use crate::{audio::AudioVisualizer, constants::VIZ_NUM_BARS};
|
||||
use crate::{
|
||||
app::{Termato, TimerMode},
|
||||
constants::SECONDS_PER_MIN,
|
||||
};
|
||||
|
||||
// use unicode blocks for rendering
|
||||
// Empty -> " "
|
||||
// 1/8th height -> "▂"
|
||||
// 2/8th height -> "▃"
|
||||
// 3/8th height -> "▄"
|
||||
// 4/8th height -> "▅"
|
||||
// 5/8th height -> "▆"
|
||||
// 6/8th height -> "▇"
|
||||
// Full Height -> "█"
|
||||
pub fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) {
|
||||
const BLOCKS: [&str; 8] = ["▂", "▃", "▄", "▅", "▆", "▇", "█", "█"];
|
||||
|
||||
let max_bars = area.width as usize;
|
||||
let line: String = bar_values
|
||||
.iter()
|
||||
.take(max_bars)
|
||||
.map(|&val| {
|
||||
if val == 0 {
|
||||
" "
|
||||
} else {
|
||||
let idx = ((val as f32 / 100.0) * (BLOCKS.len() - 1) as f32)
|
||||
.clamp(0.0, (BLOCKS.len() - 1) as f32) as usize;
|
||||
|
||||
BLOCKS[idx]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let viz_paragraph = Paragraph::new(line)
|
||||
.style(Style::default().fg(Color::Rgb(200, 184, 224)))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(viz_paragraph, area);
|
||||
}
|
||||
|
||||
pub fn render_app(
|
||||
app: &Termato,
|
||||
f: &mut Frame,
|
||||
font: &Toilet,
|
||||
visualizer: Option<&AudioVisualizer>,
|
||||
) {
|
||||
let size = f.area();
|
||||
|
||||
let minutes = app.time_remaining_in_sec / SECONDS_PER_MIN;
|
||||
let seconds = app.time_remaining_in_sec % SECONDS_PER_MIN;
|
||||
let time_str = format!("{:02}:{:02}", minutes, seconds);
|
||||
|
||||
let time_color = if !app.is_running {
|
||||
Color::Rgb(212, 200, 122)
|
||||
} else {
|
||||
match app.mode {
|
||||
TimerMode::Work => Color::Rgb(212, 115, 115),
|
||||
TimerMode::Break => Color::Rgb(126, 200, 192),
|
||||
}
|
||||
};
|
||||
|
||||
let raw_time_text = if let Some(fig) = font.convert(&time_str) {
|
||||
fig.to_string()
|
||||
} else {
|
||||
time_str
|
||||
};
|
||||
|
||||
let trimmed_time_text = raw_time_text.trim_matches('\n');
|
||||
let text_lines: Vec<&str> = trimmed_time_text.lines().collect();
|
||||
let text_height = text_lines.len() as u16;
|
||||
|
||||
let viz_height = if visualizer.is_some() { 1 } else { 0 };
|
||||
let viz_width = VIZ_NUM_BARS;
|
||||
|
||||
let content_height = text_height + viz_height;
|
||||
|
||||
let outer_vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(content_height),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
let inner_vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(text_height),
|
||||
Constraint::Length(viz_height),
|
||||
])
|
||||
.split(outer_vertical[1]);
|
||||
|
||||
let viz_horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(viz_width.min(size.width)),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner_vertical[1]);
|
||||
|
||||
let timer_paragraph = Paragraph::new(trimmed_time_text)
|
||||
.style(Style::default().fg(time_color))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(timer_paragraph, inner_vertical[0]);
|
||||
|
||||
if let Some(viz) = visualizer
|
||||
&& let Ok(bar_data) = viz.bar_data.lock()
|
||||
{
|
||||
render_visualizer(f, viz_horizontal[1], &bar_data);
|
||||
}
|
||||
|
||||
if app.show_help {
|
||||
let popup_width = 44;
|
||||
let popup_height = 7;
|
||||
|
||||
if size.width >= popup_width && size.height >= popup_height {
|
||||
let popup_vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length((size.height.saturating_sub(popup_height)) / 2),
|
||||
Constraint::Length(popup_height),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(size);
|
||||
|
||||
let popup_horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length((size.width.saturating_sub(popup_width)) / 2),
|
||||
Constraint::Length(popup_width),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(popup_vertical[1]);
|
||||
|
||||
let popup_area = popup_horizontal[1];
|
||||
|
||||
let help_block = Block::default()
|
||||
.title(" Controls ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Rgb(58, 62, 70)));
|
||||
|
||||
let help_content = Paragraph::new(
|
||||
"[Space] Pause / Resume\n\
|
||||
[S] Switch Mode\n\
|
||||
[R] Reset Timer\n\
|
||||
[?] Toggle Help\n\
|
||||
[Q] Quit",
|
||||
)
|
||||
.block(help_block)
|
||||
.style(Style::default().fg(Color::Rgb(224, 228, 232)))
|
||||
.alignment(Alignment::Left);
|
||||
|
||||
f.render_widget(Clear, popup_area);
|
||||
f.render_widget(help_content, popup_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user