├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── demo.gif ├── docs ├── uair.1.scd ├── uair.5.scd └── uairctl.1.scd ├── resources └── uair.toml ├── rustfmt.toml ├── shell.nix └── src ├── bin ├── uair │ ├── app.rs │ ├── config.rs │ ├── main.rs │ ├── session.rs │ ├── socket.rs │ └── timer.rs └── uairctl │ └── main.rs └── lib.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: test 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: 12 | - ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Install latest stable Rust 17 | uses: dtolnay/rust-toolchain@stable 18 | - name: Check 19 | run: cargo check --locked 20 | - name: Lint 21 | run: cargo clippy --all-targets -- -D warnings 22 | - name: Format 23 | run: cargo fmt --check 24 | - name: Test 25 | run: cargo test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.6.3 2 | 3 | ### Added 4 | 5 | - Log warnings about unknown fields when deserializing the config, letting the user know immediately that the field is invalid. (@0xangelo) 6 | 7 | ### Fixed 8 | 9 | - Automatically reap zombie(terminated) processes of spawned commands by ourselves, as it may not be reaped in some environments 10 | 11 | ### Changed 12 | 13 | - stdin, stdout and stderr of the parent process(`uair`) are no longer inherited by the spawned commands 14 | 15 | ### v0.6.2 16 | 17 | ### Added 18 | 19 | - New `uairctl` flag for `listen` subcommand: `-e` or `--exit`. Allows to output remaining time and exit the listening instance immediately. 20 | 21 | ### Fixed 22 | 23 | - `uair` now returns exit code 1 on failure. 24 | - Fixed building with rust version >= 1.8.0. 25 | 26 | ### v0.6.1 27 | 28 | #### Fixed 29 | 30 | Fixed version mismatch in Cargo.lock. 31 | 32 | ### v0.6.0 33 | 34 | #### Added 35 | 36 | - New `uairctl` subcommand: `listen`. Allows to output time in the same manner as that of `uair` and hence allowing multiple synchronized timers. 37 | - New `uair` config session property: `overrides`. Allows to create named overrides which can be optionally specified during invocation of `listen`. Overrides allow the listening instance to output time in a different format than that of the main instance. 38 | - New `uairctl` flag for `listen` subcommand: `-o` or `--override`. Allows to specify a named override created in `uair` config. 39 | - New `uair` config session propery: `id`. Allows to uniquely identify each session. 40 | - New `uairctl` subcommand: `jump`. Allows to directly jump to a session with a given id. 41 | - New `uairctl` subcommand: `reload`. Allows to reload the config file. 42 | - New `uair` flag: `-l` or `--log`. Specifies the path for a log file. 43 | - New `uair` flag: `-q` or `--quiet`. Allows to run `uair` without writing to standard output. 44 | - New `uair` flag: `-v` or `--version`. Displays version number. 45 | 46 | #### Deprecated 47 | 48 | - `startup_text` key in `uair` config. 49 | 50 | #### Removed 51 | 52 | - `after` and `before` session properties for `uair` config. Use `format` session property instead. 53 | 54 | ### v0.5.1 55 | 56 | #### Fixed 57 | 58 | - Fixed `uair(5)` man page build error. 59 | 60 | ### v0.5.0 61 | 62 | #### Added 63 | 64 | - New `uair` format specifier `{state}` and session properties `paused_state_text` and `resumed_state_text`. Allows to display different text depending on the state (paused/resumed) of the timer. 65 | - New `uair` config key: `iterations`. Allows to specify a finite amount of iterations of all sessions. 66 | - New `uairctl` subcommand: `fetch`, to fetch information from the timer and display it in a specified format. 67 | - New `uairctl` subcommand: `finish`, to instantly finish the current session, invoking the session's specified command. 68 | - New `uair` config session property: `time_format`. Specifies the format in which `{time}` format specifier prints time. 69 | 70 | #### Changed 71 | 72 | - Improve error message by indicating a missing config file. (@thled) 73 | 74 | #### Removed 75 | 76 | - `-p` and `-r` `uairctl` flags. Use `pause`, `resume` and `toggle` subcommands instead. 77 | - `-h` flag in `uair` and `uairctl`. Use `--help` to display the help message. This is due to a limitation in `argh`, the new argument-parsing library `uair` depends on. 78 | 79 | ### v0.4.0 80 | 81 | - New `uair` config session property: `format` and format specifiers: `{name}`, `{percent}`, `{time}`, `{total}`, `{black}`, `{red}`, `{green}`, `{yellow}`, `{blue}`, `{purple}`, `{cyan}`, `{white}`, `{end}`. 82 | - New `uair` session command environment variables: `$name` and `$duration`. 83 | 84 | #### Deprecated 85 | 86 | - `after` and `before` session properties in `uair` config. Use `format` property instead. 87 | 88 | ### v0.3.1 89 | 90 | - `uair` performance improvement: prevent allocation of buffer each time a command is received. 91 | 92 | ### v0.3.0 93 | 94 | - Config file and socket file now follow XDG Base Directory Specification. 95 | - New `uair` and `uairctl` command-line flag: -s or --socket. It specifies `uair` server socket path. 96 | - New config file options: `loop_on_end`, `pause_at_start` and `startup_text`. 97 | - Bug Fix: resuming while timer is running should now be a no-op. 98 | - New `uairctl` subcommands: `pause`, `resume` and `toggle`. 99 | - New `uairctl` subcommands: `next` and `prev`, to jump to next and previous sessions. 100 | 101 | #### Deprecated 102 | 103 | - `-p` and `-r` `uairctl` flags. Use `pause`, `resume` and `toggle` subcommands instead. 104 | 105 | ### v0.2.0 106 | 107 | - Default properties for sessions can now be configured. 108 | - New config file option: autostart. It controls whether a particular session starts automatically. 109 | 110 | ### v0.1.2 111 | 112 | - Command mentioned in the config for a session should now run as intended. 113 | 114 | ### v0.1.1 115 | 116 | - Changed configuration file format from RON to TOML. 117 | 118 | ### v0.1.0 119 | 120 | First public release 121 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "argh" 7 | version = "0.1.13" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "34ff18325c8a36b82f992e533ece1ec9f9a9db446bd1c14d4f936bac88fcd240" 10 | dependencies = [ 11 | "argh_derive", 12 | "argh_shared", 13 | "rust-fuzzy-search", 14 | ] 15 | 16 | [[package]] 17 | name = "argh_derive" 18 | version = "0.1.13" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "adb7b2b83a50d329d5d8ccc620f5c7064028828538bdf5646acd60dc1f767803" 21 | dependencies = [ 22 | "argh_shared", 23 | "proc-macro2", 24 | "quote", 25 | "syn", 26 | ] 27 | 28 | [[package]] 29 | name = "argh_shared" 30 | version = "0.1.13" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "a464143cc82dedcdc3928737445362466b7674b5db4e2eb8e869846d6d84f4f6" 33 | dependencies = [ 34 | "serde", 35 | ] 36 | 37 | [[package]] 38 | name = "async-channel" 39 | version = "2.3.1" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" 42 | dependencies = [ 43 | "concurrent-queue", 44 | "event-listener-strategy", 45 | "futures-core", 46 | "pin-project-lite", 47 | ] 48 | 49 | [[package]] 50 | name = "async-io" 51 | version = "2.4.0" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" 54 | dependencies = [ 55 | "async-lock", 56 | "cfg-if", 57 | "concurrent-queue", 58 | "futures-io", 59 | "futures-lite", 60 | "parking", 61 | "polling", 62 | "rustix", 63 | "slab", 64 | "tracing", 65 | "windows-sys", 66 | ] 67 | 68 | [[package]] 69 | name = "async-lock" 70 | version = "3.4.0" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" 73 | dependencies = [ 74 | "event-listener", 75 | "event-listener-strategy", 76 | "pin-project-lite", 77 | ] 78 | 79 | [[package]] 80 | name = "async-net" 81 | version = "2.0.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" 84 | dependencies = [ 85 | "async-io", 86 | "blocking", 87 | "futures-lite", 88 | ] 89 | 90 | [[package]] 91 | name = "async-process" 92 | version = "2.3.0" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" 95 | dependencies = [ 96 | "async-channel", 97 | "async-io", 98 | "async-lock", 99 | "async-signal", 100 | "async-task", 101 | "blocking", 102 | "cfg-if", 103 | "event-listener", 104 | "futures-lite", 105 | "rustix", 106 | "tracing", 107 | ] 108 | 109 | [[package]] 110 | name = "async-signal" 111 | version = "0.2.10" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" 114 | dependencies = [ 115 | "async-io", 116 | "async-lock", 117 | "atomic-waker", 118 | "cfg-if", 119 | "futures-core", 120 | "futures-io", 121 | "rustix", 122 | "signal-hook-registry", 123 | "slab", 124 | "windows-sys", 125 | ] 126 | 127 | [[package]] 128 | name = "async-task" 129 | version = "4.7.1" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" 132 | 133 | [[package]] 134 | name = "atomic-waker" 135 | version = "1.1.2" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 138 | 139 | [[package]] 140 | name = "autocfg" 141 | version = "1.4.0" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 144 | 145 | [[package]] 146 | name = "bincode" 147 | version = "1.3.3" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 150 | dependencies = [ 151 | "serde", 152 | ] 153 | 154 | [[package]] 155 | name = "bitflags" 156 | version = "2.8.0" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" 159 | 160 | [[package]] 161 | name = "blocking" 162 | version = "1.6.1" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" 165 | dependencies = [ 166 | "async-channel", 167 | "async-task", 168 | "futures-io", 169 | "futures-lite", 170 | "piper", 171 | ] 172 | 173 | [[package]] 174 | name = "cfg-if" 175 | version = "1.0.0" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 178 | 179 | [[package]] 180 | name = "concurrent-queue" 181 | version = "2.5.0" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 184 | dependencies = [ 185 | "crossbeam-utils", 186 | ] 187 | 188 | [[package]] 189 | name = "crossbeam-utils" 190 | version = "0.8.21" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 193 | 194 | [[package]] 195 | name = "deranged" 196 | version = "0.3.11" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 199 | dependencies = [ 200 | "powerfmt", 201 | ] 202 | 203 | [[package]] 204 | name = "equivalent" 205 | version = "1.0.1" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 208 | 209 | [[package]] 210 | name = "errno" 211 | version = "0.3.10" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 214 | dependencies = [ 215 | "libc", 216 | "windows-sys", 217 | ] 218 | 219 | [[package]] 220 | name = "event-listener" 221 | version = "5.4.0" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" 224 | dependencies = [ 225 | "concurrent-queue", 226 | "parking", 227 | "pin-project-lite", 228 | ] 229 | 230 | [[package]] 231 | name = "event-listener-strategy" 232 | version = "0.5.3" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" 235 | dependencies = [ 236 | "event-listener", 237 | "pin-project-lite", 238 | ] 239 | 240 | [[package]] 241 | name = "fastrand" 242 | version = "2.3.0" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 245 | 246 | [[package]] 247 | name = "futures-core" 248 | version = "0.3.31" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 251 | 252 | [[package]] 253 | name = "futures-io" 254 | version = "0.3.31" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 257 | 258 | [[package]] 259 | name = "futures-lite" 260 | version = "2.6.0" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" 263 | dependencies = [ 264 | "fastrand", 265 | "futures-core", 266 | "futures-io", 267 | "parking", 268 | "pin-project-lite", 269 | ] 270 | 271 | [[package]] 272 | name = "hashbrown" 273 | version = "0.15.2" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 276 | 277 | [[package]] 278 | name = "hermit-abi" 279 | version = "0.4.0" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" 282 | 283 | [[package]] 284 | name = "humantime" 285 | version = "2.1.0" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 288 | 289 | [[package]] 290 | name = "humantime-serde" 291 | version = "1.1.1" 292 | source = "registry+https://github.com/rust-lang/crates.io-index" 293 | checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" 294 | dependencies = [ 295 | "humantime", 296 | "serde", 297 | ] 298 | 299 | [[package]] 300 | name = "indexmap" 301 | version = "2.7.1" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 304 | dependencies = [ 305 | "equivalent", 306 | "hashbrown", 307 | ] 308 | 309 | [[package]] 310 | name = "itoa" 311 | version = "1.0.14" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 314 | 315 | [[package]] 316 | name = "libc" 317 | version = "0.2.169" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 320 | 321 | [[package]] 322 | name = "linux-raw-sys" 323 | version = "0.4.15" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 326 | 327 | [[package]] 328 | name = "log" 329 | version = "0.4.25" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" 332 | 333 | [[package]] 334 | name = "memchr" 335 | version = "2.7.4" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 338 | 339 | [[package]] 340 | name = "num-conv" 341 | version = "0.1.0" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 344 | 345 | [[package]] 346 | name = "num_threads" 347 | version = "0.1.7" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 350 | dependencies = [ 351 | "libc", 352 | ] 353 | 354 | [[package]] 355 | name = "parking" 356 | version = "2.2.1" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 359 | 360 | [[package]] 361 | name = "pin-project-lite" 362 | version = "0.2.16" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 365 | 366 | [[package]] 367 | name = "piper" 368 | version = "0.2.4" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" 371 | dependencies = [ 372 | "atomic-waker", 373 | "fastrand", 374 | "futures-io", 375 | ] 376 | 377 | [[package]] 378 | name = "polling" 379 | version = "3.7.4" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" 382 | dependencies = [ 383 | "cfg-if", 384 | "concurrent-queue", 385 | "hermit-abi", 386 | "pin-project-lite", 387 | "rustix", 388 | "tracing", 389 | "windows-sys", 390 | ] 391 | 392 | [[package]] 393 | name = "powerfmt" 394 | version = "0.2.0" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 397 | 398 | [[package]] 399 | name = "proc-macro2" 400 | version = "1.0.93" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 403 | dependencies = [ 404 | "unicode-ident", 405 | ] 406 | 407 | [[package]] 408 | name = "quote" 409 | version = "1.0.38" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 412 | dependencies = [ 413 | "proc-macro2", 414 | ] 415 | 416 | [[package]] 417 | name = "rust-fuzzy-search" 418 | version = "0.1.1" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2" 421 | 422 | [[package]] 423 | name = "rustix" 424 | version = "0.38.44" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 427 | dependencies = [ 428 | "bitflags", 429 | "errno", 430 | "libc", 431 | "linux-raw-sys", 432 | "windows-sys", 433 | ] 434 | 435 | [[package]] 436 | name = "serde" 437 | version = "1.0.217" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 440 | dependencies = [ 441 | "serde_derive", 442 | ] 443 | 444 | [[package]] 445 | name = "serde_derive" 446 | version = "1.0.217" 447 | source = "registry+https://github.com/rust-lang/crates.io-index" 448 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 449 | dependencies = [ 450 | "proc-macro2", 451 | "quote", 452 | "syn", 453 | ] 454 | 455 | [[package]] 456 | name = "serde_ignored" 457 | version = "0.1.10" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "a8e319a36d1b52126a0d608f24e93b2d81297091818cd70625fcf50a15d84ddf" 460 | dependencies = [ 461 | "serde", 462 | ] 463 | 464 | [[package]] 465 | name = "serde_spanned" 466 | version = "0.6.8" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 469 | dependencies = [ 470 | "serde", 471 | ] 472 | 473 | [[package]] 474 | name = "signal-hook-registry" 475 | version = "1.4.2" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 478 | dependencies = [ 479 | "libc", 480 | ] 481 | 482 | [[package]] 483 | name = "simplelog" 484 | version = "0.12.2" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" 487 | dependencies = [ 488 | "log", 489 | "termcolor", 490 | "time", 491 | ] 492 | 493 | [[package]] 494 | name = "slab" 495 | version = "0.4.9" 496 | source = "registry+https://github.com/rust-lang/crates.io-index" 497 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 498 | dependencies = [ 499 | "autocfg", 500 | ] 501 | 502 | [[package]] 503 | name = "syn" 504 | version = "2.0.96" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" 507 | dependencies = [ 508 | "proc-macro2", 509 | "quote", 510 | "unicode-ident", 511 | ] 512 | 513 | [[package]] 514 | name = "termcolor" 515 | version = "1.4.1" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 518 | dependencies = [ 519 | "winapi-util", 520 | ] 521 | 522 | [[package]] 523 | name = "testing_logger" 524 | version = "0.1.1" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "6d92b727cb45d33ae956f7f46b966b25f1bc712092aeef9dba5ac798fc89f720" 527 | dependencies = [ 528 | "log", 529 | ] 530 | 531 | [[package]] 532 | name = "thiserror" 533 | version = "2.0.11" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 536 | dependencies = [ 537 | "thiserror-impl", 538 | ] 539 | 540 | [[package]] 541 | name = "thiserror-impl" 542 | version = "2.0.11" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 545 | dependencies = [ 546 | "proc-macro2", 547 | "quote", 548 | "syn", 549 | ] 550 | 551 | [[package]] 552 | name = "time" 553 | version = "0.3.37" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" 556 | dependencies = [ 557 | "deranged", 558 | "itoa", 559 | "libc", 560 | "num-conv", 561 | "num_threads", 562 | "powerfmt", 563 | "serde", 564 | "time-core", 565 | "time-macros", 566 | ] 567 | 568 | [[package]] 569 | name = "time-core" 570 | version = "0.1.2" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" 573 | 574 | [[package]] 575 | name = "time-macros" 576 | version = "0.2.19" 577 | source = "registry+https://github.com/rust-lang/crates.io-index" 578 | checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" 579 | dependencies = [ 580 | "num-conv", 581 | "time-core", 582 | ] 583 | 584 | [[package]] 585 | name = "toml" 586 | version = "0.8.19" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 589 | dependencies = [ 590 | "serde", 591 | "serde_spanned", 592 | "toml_datetime", 593 | "toml_edit", 594 | ] 595 | 596 | [[package]] 597 | name = "toml_datetime" 598 | version = "0.6.8" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 601 | dependencies = [ 602 | "serde", 603 | ] 604 | 605 | [[package]] 606 | name = "toml_edit" 607 | version = "0.22.23" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee" 610 | dependencies = [ 611 | "indexmap", 612 | "serde", 613 | "serde_spanned", 614 | "toml_datetime", 615 | "winnow", 616 | ] 617 | 618 | [[package]] 619 | name = "tracing" 620 | version = "0.1.41" 621 | source = "registry+https://github.com/rust-lang/crates.io-index" 622 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 623 | dependencies = [ 624 | "pin-project-lite", 625 | "tracing-core", 626 | ] 627 | 628 | [[package]] 629 | name = "tracing-core" 630 | version = "0.1.33" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 633 | 634 | [[package]] 635 | name = "uair" 636 | version = "0.6.3" 637 | dependencies = [ 638 | "argh", 639 | "async-io", 640 | "async-net", 641 | "async-process", 642 | "async-signal", 643 | "bincode", 644 | "futures-lite", 645 | "humantime", 646 | "humantime-serde", 647 | "log", 648 | "serde", 649 | "serde_ignored", 650 | "simplelog", 651 | "testing_logger", 652 | "thiserror", 653 | "toml", 654 | "winnow", 655 | ] 656 | 657 | [[package]] 658 | name = "unicode-ident" 659 | version = "1.0.16" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" 662 | 663 | [[package]] 664 | name = "winapi-util" 665 | version = "0.1.9" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 668 | dependencies = [ 669 | "windows-sys", 670 | ] 671 | 672 | [[package]] 673 | name = "windows-sys" 674 | version = "0.59.0" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 677 | dependencies = [ 678 | "windows-targets", 679 | ] 680 | 681 | [[package]] 682 | name = "windows-targets" 683 | version = "0.52.6" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 686 | dependencies = [ 687 | "windows_aarch64_gnullvm", 688 | "windows_aarch64_msvc", 689 | "windows_i686_gnu", 690 | "windows_i686_gnullvm", 691 | "windows_i686_msvc", 692 | "windows_x86_64_gnu", 693 | "windows_x86_64_gnullvm", 694 | "windows_x86_64_msvc", 695 | ] 696 | 697 | [[package]] 698 | name = "windows_aarch64_gnullvm" 699 | version = "0.52.6" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 702 | 703 | [[package]] 704 | name = "windows_aarch64_msvc" 705 | version = "0.52.6" 706 | source = "registry+https://github.com/rust-lang/crates.io-index" 707 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 708 | 709 | [[package]] 710 | name = "windows_i686_gnu" 711 | version = "0.52.6" 712 | source = "registry+https://github.com/rust-lang/crates.io-index" 713 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 714 | 715 | [[package]] 716 | name = "windows_i686_gnullvm" 717 | version = "0.52.6" 718 | source = "registry+https://github.com/rust-lang/crates.io-index" 719 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 720 | 721 | [[package]] 722 | name = "windows_i686_msvc" 723 | version = "0.52.6" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 726 | 727 | [[package]] 728 | name = "windows_x86_64_gnu" 729 | version = "0.52.6" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 732 | 733 | [[package]] 734 | name = "windows_x86_64_gnullvm" 735 | version = "0.52.6" 736 | source = "registry+https://github.com/rust-lang/crates.io-index" 737 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 738 | 739 | [[package]] 740 | name = "windows_x86_64_msvc" 741 | version = "0.52.6" 742 | source = "registry+https://github.com/rust-lang/crates.io-index" 743 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 744 | 745 | [[package]] 746 | name = "winnow" 747 | version = "0.7.0" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "7e49d2d35d3fad69b39b94139037ecfb4f359f08958b9c11e7315ce770462419" 750 | dependencies = [ 751 | "memchr", 752 | ] 753 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "uair" 3 | version = "0.6.3" 4 | authors = ["Rishabh Das "] 5 | edition = "2021" 6 | description = "An extensible pomodoro timer" 7 | license = "MIT" 8 | repository = "https://github.com/metent/uair/" 9 | keywords = ["pomodoro", "timer", "countdown", "cli", "productivity"] 10 | categories = ["command-line-utilities"] 11 | 12 | [dependencies] 13 | argh = "0.1.13" 14 | async-io = "2.4.0" 15 | async-net = "2.0.0" 16 | async-process = "2.3.0" 17 | async-signal = "0.2.10" 18 | bincode = "1.3.3" 19 | futures-lite = "2.6.0" 20 | humantime = "2.1.0" 21 | humantime-serde = "1.1.1" 22 | log = "0.4.25" 23 | serde = { version = "1.0.217", features = ["derive"] } 24 | serde_ignored = "0.1.10" 25 | simplelog = "0.12.2" 26 | thiserror = "2.0.11" 27 | toml = "0.8.19" 28 | winnow = "0.7.0" 29 | 30 | [dev-dependencies] 31 | testing_logger = "0.1" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uair 2 | 3 | `uair` is a minimal pomodoro timer for UNIX-like operating systems. Unlike other timers, `uair` simply prints the remaining time to standard output. Other than making the code more maintainable, this design allows `uair` to be very extensible as it can be used in various status bars and even command-line and graphical user interfaces. 4 | 5 | ![](demo.gif) 6 | 7 | ## Features 8 | 9 | - Extensible: Can be used in status bars, desktop widgets, CLIs, GUIs, etc. 10 | - Keyboard-driven: Uses `uairctl` command-line utility for pausing/resuming the timer. It can be binded to a keyboard shortcut. 11 | - Resource-efficient: Uses concurrency instead of spawing multiple threads. 12 | - Multiple synchronized timers: Multiple synchronized `uair` timers can co-exist while sharing a single handle 13 | - Minimal: It adheres to the UNIX philosophy of focusing in doing one thing, and doing it well. 14 | 15 | ## Installation 16 | 17 | ### From Arch User Repository 18 | 19 | `uair` is [packaged](https://aur.archlinux.org/packages/uair) for the AUR. Use an AUR helper or `makepkg` to install. 20 | 21 | ``` 22 | paru -S uair 23 | ``` 24 | 25 | or 26 | 27 | ``` 28 | yay -S uair 29 | ``` 30 | 31 | or 32 | 33 | ``` 34 | git clone https://aur.archlinux.org/uair.git 35 | cd uair 36 | makepkg -si 37 | ``` 38 | 39 | ### From the official NetBSD repositories 40 | 41 | ``` 42 | pkgin install uair 43 | ``` 44 | 45 | ### From crates.io 46 | 47 | ``` 48 | cargo install uair 49 | ``` 50 | 51 | Make sure to include `$HOME/.cargo/bin` in the `PATH` variable. 52 | 53 | ## Usage 54 | 55 | ### Quickstart 56 | 57 | Copy `resources/uair.toml` under the project directory to `~/.config/uair/`. 58 | 59 | ``` 60 | mkdir -p ~/.config/uair 61 | cp -r resources/uair.toml ~/.config/uair/uair.toml 62 | ``` 63 | 64 | Start uair. 65 | 66 | ``` 67 | uair 68 | ``` 69 | 70 | When `uair` is started, or a session is completed, the timer is in a paused state. In order to start the session, `uairctl` command must be used. Start the session by resuming the timer by invoking `uairctl` from another shell. 71 | 72 | ``` 73 | uairctl resume 74 | ``` 75 | 76 | and pause the session using 77 | 78 | ``` 79 | uairctl pause 80 | ``` 81 | 82 | To toggle between pause and resume states, use 83 | 84 | ``` 85 | uairctl toggle 86 | ``` 87 | 88 | To start another instance synced with this one, you can use 89 | ``` 90 | uairctl listen 91 | ``` 92 | 93 | ### Configuration 94 | 95 | Configuration is done in TOML. If a config file is not specified by the `-c` flag, it is sourced according to the XDG Base Directory Specification, i.e. it looks for the config file in the following order, until it successfully finds one. 96 | 97 | - $XDG_CONFIG_HOME/uair/uair.toml 98 | - $HOME/.config/uair/uair.toml 99 | - ~/.config/uair/uair.toml 100 | 101 | Example Config: 102 | 103 | ```toml 104 | [defaults] 105 | format = "{time}\n" 106 | 107 | [[sessions]] 108 | id = "work" 109 | name = "Work" 110 | duration = "30m" 111 | command = "notify-send 'Work Done!'" 112 | 113 | [[sessions]] 114 | id = "rest" 115 | name = "Rest" 116 | duration = "5m" 117 | command = "notify-send 'Rest Done!'" 118 | 119 | [[sessions]] 120 | id = "hardwork" 121 | name = "Work, but harder" 122 | duration = "1h 30m" 123 | command = "notify-send 'Hard Work Done!'" 124 | ``` 125 | 126 | A list of sessions has to be provided in the `sessions` key. Each session is a table containing the properties of the session. Some of those properties are listed as follows: 127 | 128 | - `id`: unique identifier of the session 129 | - `name`: name of the session 130 | - `duration`: duration of the session 131 | - `command`: command which is run when the session finishes 132 | - `format`: specifies the format in which text is printed each second 133 | - `autostart`: boolean value (true or false) which dictates whether the session automatically starts. 134 | 135 | If a property of a session in the array is unspecified, the default value specified in the `defaults` section is used instead. The exception to this rule is the `id` property which, if unspecified, defaults to the index(starting from 0) of session in the sessions array. If the property is not mentioned in the default section too, then the property is sourced from a list of hard-coded defaults. 136 | 137 | It is recommended to specify an `id` for every session as it makes it possible for `uair` to keep track of the current session while reloading the config file. It also makes it convenient to jump to any session using its `id` using `uairctl jump` command. 138 | 139 | ### Integration with polybar 140 | 141 | Include pomodoro module in the config. 142 | 143 | ```ini 144 | [module/uair] 145 | type = custom/script 146 | exec = uair 147 | label = %output% 148 | tail = true 149 | ``` 150 | 151 | Remember to include the module in the modules list. 152 | 153 | ``` 154 | modules-right = filesystem uair pulseaudio xkeyboard memory cpu wlan eth date 155 | ``` 156 | 157 | In order for it to be displayed, a newline should be printed after printing the remaining time. 158 | 159 | ```toml 160 | [defaults] 161 | format = "{time}\n" 162 | 163 | [[sessions]] 164 | id = "work" 165 | name = "Work" 166 | duration = "30m" 167 | command = "notify-send 'Work Done!'" 168 | ``` 169 | 170 | ### Simple CLI timer 171 | 172 | ```toml 173 | [defaults] 174 | format = "\r{time} " 175 | 176 | [[sessions]] 177 | id = "work" 178 | name = "Work" 179 | duration = "1h 30m" 180 | command = "notify-send 'Work Done!'" 181 | ``` 182 | 183 | Run with: 184 | 185 | ``` 186 | clear && uair 187 | ``` 188 | 189 | ### GUI with yad 190 | 191 | ```toml 192 | [defaults] 193 | format = "{percent}\n#{time}\n" 194 | 195 | [[sessions]] 196 | id = "work" 197 | name = "Work" 198 | duration = "1h 30m" 199 | command = "notify-send 'Work Done!'" 200 | ``` 201 | 202 | Run with: 203 | 204 | ``` 205 | uair | yad --progress --no-buttons --css="* { font-size: 80px; }" 206 | ``` 207 | 208 | ## Roadmap 209 | 210 | - [X] Basic pause/resume functionality using UNIX sockets 211 | - [X] next/prev subcommands 212 | - [X] Format specifiers 213 | - [X] Ability to reload configuration 214 | - [X] uairctl listen subcommand: for multiple synchonized timers 215 | - [ ] Dedicated GUI client 216 | - [ ] Dedicated crossterm client 217 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metent/uair/a638d3fddce325fa519bd621ca4630a00102dc67/demo.gif -------------------------------------------------------------------------------- /docs/uair.1.scd: -------------------------------------------------------------------------------- 1 | uair(1) 2 | 3 | # NAME 4 | 5 | uair - An extensible pomodoro timer 6 | 7 | # SYPNOSIS 8 | 9 | *uair* [options..] 10 | 11 | # OPTIONS 12 | 13 | *-c, --config* 14 | Specifies a config file. 15 | 16 | *-s, --socket* 17 | Specifies a socket file. 18 | 19 | *-l, --log* 20 | Specifies a log file. (use "-" for stdout, which is the default option) 21 | 22 | *-q, --quiet* 23 | Allows to run `uair` without writing to standard output. 24 | 25 | *-v, --version* 26 | Displays version number then exits. 27 | 28 | *--help* 29 | Show help message and quit. 30 | 31 | # DESCRIPTION 32 | 33 | uair is a minimal pomodoro timer for UNIX-like operating systems. Unlike other timers, uair simply prints the remaining time to standard output. Other than making the code more maintainable, this design allows uair to be very extensible as it can be used in various status bars and even command-line and graphical user interfaces. 34 | 35 | # CONFIGURATION 36 | 37 | Configuration is done in TOML. If a config file is not specified by the *-c* flag, it is sourced according to the XDG Base Directory Specification, i.e. it looks for the config file in the following order, until it successfully finds one. 38 | 39 | - $XDG_CONFIG_HOME/uair/uair.toml 40 | - $HOME/.config/uair/uair.toml 41 | - ~/.config/uair/uair.toml 42 | 43 | For information on the config file format, see uair(5). 44 | 45 | # SEE ALSO 46 | 47 | *uair*(5) *uairctl*(1) 48 | -------------------------------------------------------------------------------- /docs/uair.5.scd: -------------------------------------------------------------------------------- 1 | uair(5) 2 | 3 | # NAME 4 | 5 | uair - configuration file 6 | 7 | # DESCRIPTION 8 | 9 | The number of pomodoro sessions and their properties can be specified by an uair configuration file in TOML format. A config file consists of the following keys: 10 | 11 | *loop_on_end* 12 | This is a boolean value (true or false) which controls whether uair repeats all sessions after the completion of the last session. 13 | 14 | *iterations* 15 | This is a non-negative integer which specifies the number of times sessions mentioned in the sessions array are iterated. 16 | 17 | *pause_at_start* 18 | This is a boolean value (true or false) which controls whether uair is at paused state at startup. 19 | 20 | *startup_text* 21 | It specifies the text to be printed at startup. (Deprecated) 22 | 23 | *defaults* 24 | This is a table containing default session properties. If a session has a property unspecified, the value of the corresponding key in this table is used instead. Specifying a default id is not allowed. 25 | 26 | *sessions* 27 | This is an array of tables. Each table in this array corresponds to a session. The order of sessions in this array is the order in which they are scheduled. Each table in this array consists of keys which describe the properties of the session, which are discussed in the following section. 28 | 29 | # SESSION PROPERTIES 30 | 31 | *id* 32 | Unique identifier of the session. If unspecified, it is automatically set to the zero-indexed position of the session in the sessions array. 33 | 34 | *name* 35 | Name of the session. 36 | 37 | *duration* 38 | Duration of the session. Can be specified in human readable format. e.g.: "1h 47m" 39 | 40 | *command* 41 | Command which is run when the session finishes. See COMMAND ENVIRONMENT section for information on environment variables which are passed to the command. 42 | 43 | *format* 44 | Specifies the format in which text is printed each second. See FORMAT SPECIFIERS section for details. 45 | 46 | *time_format* 47 | Specifies the format in which *{time}* format specifier prints time. See TIME FORMAT SPECIFIERS section for details. 48 | 49 | *autostart* 50 | Boolean value (true or false) which dictates whether the session automatically starts. 51 | 52 | *paused_state_text* 53 | Text which is displayed by the *{state}* format specifier when the timer is paused. 54 | 55 | *resumed_state_text* 56 | Text which is displayed by the *{state}* format specifier when the timer is resumed. 57 | 58 | *overrides* 59 | A table consisting of OVERRIDABLES as values and their names as keys. It allows to specify named overrides to be applied during `uairctl listen` See OVERRIDABLES section for more information and for the list of overridable properties. 60 | 61 | # FORMAT SPECIFIERS 62 | 63 | The format property of a session is a string which specifies what and how text is printed every second. For example, the following format string can be used to print the name of the session followed by the remaining time, followed by the total duration of the session, all in cyan color. 64 | 65 | {cyan}{name}: {time} / {total}{end} 66 | 67 | The list of format specifiers is: 68 | 69 | *{name}* 70 | Session name 71 | 72 | *{percent}* 73 | Percentage of time remaining 74 | 75 | *{time}* 76 | Remaining time for session 77 | 78 | *{state}* 79 | Text which depends on the state (paused/resumed) of the timer. This text is configurable through *paused_state_text* and *resumed_state_text* session properties. 80 | 81 | *{total}* 82 | Total duration of session 83 | 84 | *{black}* 85 | Start black color text 86 | 87 | *{red}* 88 | Start red color text 89 | 90 | *{green}* 91 | Start green color text 92 | 93 | *{yellow}* 94 | Start yellow color text 95 | 96 | *{blue}* 97 | Start blue color text 98 | 99 | *{purple}* 100 | Start purple color text 101 | 102 | *{cyan}* 103 | Start cyan color text 104 | 105 | *{white}* 106 | Start white color text 107 | 108 | *{end}* 109 | Start end color text 110 | 111 | # TIME FORMAT SPECIFIERS 112 | 113 | The time_format property of a session is a string which specifies what and how text produced by *{time}* format specifier is printed. Time format specifiers have the following syntax. 114 | 115 | %[optional skip flag][optional padding flag]alphabet 116 | 117 | As an example, the following time format string can be used to print time in a digital clock-like format. 118 | 119 | %H:%M:%S 120 | 121 | The list of interpreted sequences is: 122 | 123 | *%Y* 124 | Remaining years 125 | 126 | *%B* 127 | Remaining months in a year 128 | 129 | *%D* 130 | Remaining days in a month 131 | 132 | *%H* 133 | Remaining hours in a day 134 | 135 | *%M* 136 | Remaining minutes in an hour 137 | 138 | *%S* 139 | Remaining seconds in a minute 140 | 141 | *%P* 142 | Prints 's' if the quantity specified by the format specifier before it is plural and nothing if singular. 143 | 144 | The list of optional padding flags is: 145 | 146 | *0* 147 | Pad with zeroes (default) 148 | 149 | *\_* 150 | Pad with spaces 151 | 152 | *-* 153 | Do not pad 154 | 155 | An optional '\*' flag may follow '%' to skip the quantity specified by the format specifier and skip all text before the next quantifiable format specifier. 156 | 157 | # OVERRIDABLES 158 | 159 | Each value in the 'overrides' session property is a table named OVERRIDABLES which can contain one or more of the following properties. 160 | 161 | *format* 162 | Specifies the format in which text is printed each second. See FORMAT SPECIFIERS section for details. 163 | 164 | *time_format* 165 | Specifies the format in which *{time}* format specifier prints time. See TIME FORMAT SPECIFIERS section for details. 166 | 167 | *paused_state_text* 168 | Text which is displayed by the *{state}* format specifier when the timer is paused. 169 | 170 | *resumed_state_text* 171 | Text which is displayed by the *{state}* format specifier when the timer is resumed. 172 | 173 | # COMMAND ENVIRONMENT 174 | 175 | Some environment variables are passed to the command specified by the command property of a session which enables printing various session properties. They are as follows 176 | 177 | *$name* 178 | name property of session 179 | 180 | *$duration* 181 | duration property of session 182 | 183 | # SEE ALSO 184 | 185 | *uair*(1) *uairctl*(1) 186 | -------------------------------------------------------------------------------- /docs/uairctl.1.scd: -------------------------------------------------------------------------------- 1 | uairctl(1) 2 | 3 | # NAME 4 | 5 | uairctl - Command-line application for controlling uair 6 | 7 | # SYPNOSIS 8 | 9 | *uairctl* [options..] command 10 | 11 | # OPTIONS 12 | 13 | *-s, --socket* 14 | Specifies a socket file. 15 | 16 | *--help* 17 | Show help message and quit. 18 | 19 | # COMMANDS 20 | 21 | pause 22 | Pauses the timer. 23 | 24 | resume 25 | Resumes the timer. 26 | 27 | toggle 28 | Toggles the state of the timer. 29 | 30 | next 31 | Jumps to the next session. 32 | 33 | prev 34 | Jumps to the previous session. 35 | 36 | finish 37 | Instantly finishes the current session, invoking the session's specified command. 38 | 39 | jump [ID] 40 | Jumps to the session with the given id, [ID]. 41 | 42 | reload 43 | Reload the config file. If the new configuration contains a session with the same ID as that of the current session, this session is treated as the new current session, otherwise, the first session is treated as the new current session. The state of the timer (paused or resumed) or the remaining duration of the current session remains unchanged. 44 | 45 | fetch [FORMAT] 46 | Fetches information and displays it in the format specified by the format text [FORMAT]. Formatting of input text is done using the same format specifiers specified in FORMAT SPECIFIERS sections in uair(5). 47 | 48 | listen [-o | --override OVERRIDE] [-e | --exit] 49 | Output time continuously, while remaining in sync with the main 'uair' instance. Using the optional '-o' flag, a named override specified in uair config can be mentioned, which allows the listening instance to output time in a different format. See 'overrides' property in SESSION PROPERTIES section and the OVERRIDABLES section in uair(5) for more details. 50 | Using the optional '-e' flag, uairctl outputs the remaining time for the current session and exits immediately. 51 | 52 | # DESCRIPTION 53 | 54 | uairctl is a command line application for controlling uair. It can be binded to a keyboard shortcut for for quickly pausing and resuming the timer. 55 | 56 | # SEE ALSO 57 | 58 | *uair*(5) *uairctl*(1) 59 | -------------------------------------------------------------------------------- /resources/uair.toml: -------------------------------------------------------------------------------- 1 | [defaults] 2 | format = "{time}\n" 3 | 4 | [[sessions]] 5 | id = "work" 6 | name = "Work" 7 | duration = "25m" 8 | command = "notify-send 'Work Done!'" 9 | 10 | [[sessions]] 11 | id = "rest" 12 | name = "Rest" 13 | duration = "5m" 14 | command = "notify-send 'Rest Done!'" 15 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | with import { }; 2 | stdenv.mkDerivation { 3 | name = "uair"; 4 | buildInputs = [ 5 | cargo 6 | cargo-watch 7 | clippy 8 | rustc 9 | rustfmt 10 | ]; 11 | } 12 | -------------------------------------------------------------------------------- /src/bin/uair/app.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Config, ConfigBuilder}; 2 | use crate::session::{Overridables, Session, SessionId}; 3 | use crate::socket::{Listener, Stream}; 4 | use crate::timer::{State, UairTimer}; 5 | use crate::{Args, Error}; 6 | use futures_lite::FutureExt; 7 | use log::error; 8 | use std::fs; 9 | use std::io::{self, Error as IoError, ErrorKind, Write}; 10 | use std::time::{Duration, Instant}; 11 | use uair::{Command, FetchArgs, JumpArgs, ListenArgs, PauseArgs, ResumeArgs}; 12 | 13 | pub struct App { 14 | data: AppData, 15 | timer: UairTimer, 16 | } 17 | 18 | impl App { 19 | pub fn new(args: Args) -> Result { 20 | let timer = UairTimer::new(Duration::from_secs(1), args.quiet); 21 | let data = AppData::new(args)?; 22 | Ok(App { data, timer }) 23 | } 24 | 25 | pub async fn run(mut self) -> Result<(), Error> { 26 | let mut stdout = io::stdout(); 27 | write!(stdout, "{}", self.data.config.startup_text)?; 28 | stdout.flush()?; 29 | 30 | loop { 31 | match match self.timer.state { 32 | State::PreInit => self.start_up().await, 33 | State::Paused(duration) => self.pause_session(duration).await, 34 | State::Resumed(start, dest) => self.run_session(start, dest).await, 35 | State::Finished => break, 36 | } { 37 | Err(Error::ConfError(err)) => error!("{}", err), 38 | Err(Error::DeserError(err)) => error!("{}", err), 39 | Err(err) => return Err(err), 40 | _ => {} 41 | } 42 | } 43 | Ok(()) 44 | } 45 | 46 | async fn start_up(&mut self) -> Result<(), Error> { 47 | if !self.data.config.pause_at_start { 48 | self.timer.state = self.data.initial_state(); 49 | return Ok(()); 50 | } 51 | 52 | match self.data.handle_commands::().await? { 53 | Event::Finished | Event::Command(Command::Resume(_) | Command::Next(_)) => { 54 | self.timer.state = self.data.initial_state(); 55 | } 56 | Event::Command(Command::Prev(_)) => {} 57 | Event::Jump(idx) => { 58 | self.timer.state = self.data.initial_jump(idx); 59 | } 60 | Event::Command(Command::Reload(_)) => self.data.read_conf::()?, 61 | Event::Fetch(format, stream) => { 62 | self.data 63 | .handle_fetch_paused( 64 | Some(&Overridables::new().format(&format)), 65 | stream, 66 | Duration::ZERO, 67 | ) 68 | .await? 69 | } 70 | Event::Listen(overrid, stream) => self 71 | .timer 72 | .writer 73 | .add_stream(stream.into_blocking(), overrid), 74 | Event::ListenExit(overrid, stream) => { 75 | self.data 76 | .handle_fetch_paused( 77 | overrid.and_then(|o| self.data.curr_session().overrides.get(&o)), 78 | stream, 79 | Duration::ZERO, 80 | ) 81 | .await? 82 | } 83 | _ => unreachable!(), 84 | } 85 | 86 | Ok(()) 87 | } 88 | 89 | async fn run_session(&mut self, start: Instant, dest: Instant) -> Result<(), Error> { 90 | match self 91 | .timer 92 | .start(self.data.curr_session(), start, dest) 93 | .or(self.data.handle_commands::()) 94 | .await? 95 | { 96 | Event::Finished => { 97 | let res = self.data.curr_session().run_command(); 98 | self.timer.state = if self.data.sid.is_last() { 99 | State::Finished 100 | } else { 101 | self.data.next_session() 102 | }; 103 | res?; 104 | } 105 | Event::Command(Command::Pause(_)) => { 106 | self.timer.state = State::Paused(dest - Instant::now()) 107 | } 108 | Event::Command(Command::Next(_)) => self.timer.state = self.data.next_session(), 109 | Event::Command(Command::Prev(_)) => self.timer.state = self.data.prev_session(), 110 | Event::Jump(idx) => self.timer.state = self.data.jump_session(idx), 111 | Event::Command(Command::Reload(_)) => self.data.read_conf::()?, 112 | Event::Fetch(format, stream) => { 113 | self.data 114 | .handle_fetch_resumed(Some(&Overridables::new().format(&format)), stream, dest) 115 | .await? 116 | } 117 | Event::Listen(overrid, stream) => self 118 | .timer 119 | .writer 120 | .add_stream(stream.into_blocking(), overrid), 121 | Event::ListenExit(overrid, stream) => { 122 | self.data 123 | .handle_fetch_resumed( 124 | overrid.and_then(|o| self.data.curr_session().overrides.get(&o)), 125 | stream, 126 | dest, 127 | ) 128 | .await? 129 | } 130 | _ => unreachable!(), 131 | } 132 | Ok(()) 133 | } 134 | 135 | async fn pause_session(&mut self, duration: Duration) -> Result<(), Error> { 136 | const DELTA: Duration = Duration::from_nanos(1_000_000_000 - 1); 137 | 138 | self.timer 139 | .writer 140 | .write::(self.data.curr_session(), duration + DELTA)?; 141 | 142 | match self.data.handle_commands::().await? { 143 | Event::Finished => { 144 | let res = self.data.curr_session().run_command(); 145 | self.timer.state = if self.data.sid.is_last() { 146 | State::Finished 147 | } else { 148 | self.data.next_session() 149 | }; 150 | res?; 151 | } 152 | Event::Command(Command::Resume(_)) => { 153 | let start = Instant::now(); 154 | self.timer.state = State::Resumed(start, start + duration); 155 | self.timer 156 | .writer 157 | .write::(self.data.curr_session(), duration + DELTA)?; 158 | } 159 | Event::Command(Command::Next(_)) => self.timer.state = self.data.next_session(), 160 | Event::Command(Command::Prev(_)) => self.timer.state = self.data.prev_session(), 161 | Event::Jump(idx) => self.timer.state = self.data.jump_session(idx), 162 | Event::Command(Command::Reload(_)) => self.data.read_conf::()?, 163 | Event::Fetch(format, stream) => { 164 | self.data 165 | .handle_fetch_paused( 166 | Some(&Overridables::new().format(&format)), 167 | stream, 168 | duration + DELTA, 169 | ) 170 | .await? 171 | } 172 | Event::Listen(overrid, stream) => self 173 | .timer 174 | .writer 175 | .add_stream(stream.into_blocking(), overrid), 176 | Event::ListenExit(overrid, stream) => { 177 | self.data 178 | .handle_fetch_paused( 179 | overrid.and_then(|o| self.data.curr_session().overrides.get(&o)), 180 | stream, 181 | duration + DELTA, 182 | ) 183 | .await? 184 | } 185 | _ => unreachable!(), 186 | } 187 | Ok(()) 188 | } 189 | } 190 | 191 | pub enum Event { 192 | Command(Command), 193 | Jump(usize), 194 | Fetch(String, Stream), 195 | Finished, 196 | Listen(Option, Stream), 197 | ListenExit(Option, Stream), 198 | } 199 | 200 | struct AppData { 201 | listener: Listener, 202 | sid: SessionId, 203 | config: Config, 204 | config_path: String, 205 | } 206 | 207 | impl AppData { 208 | fn new(args: Args) -> Result { 209 | let mut data = AppData { 210 | listener: Listener::new(&args.socket)?, 211 | sid: SessionId::default(), 212 | config: Config::default(), 213 | config_path: args.config, 214 | }; 215 | data.read_conf::()?; 216 | Ok(data) 217 | } 218 | 219 | fn read_conf(&mut self) -> Result<(), Error> { 220 | let conf_data = fs::read_to_string(&self.config_path).map_err(|_| { 221 | Error::IoError(IoError::new( 222 | ErrorKind::NotFound, 223 | format!("Could not load config file \"{}\"", self.config_path), 224 | )) 225 | })?; 226 | let config = ConfigBuilder::deserialize(&conf_data)?.build()?; 227 | let mut sid = SessionId::new(&config.sessions, config.iterations); 228 | 229 | if R { 230 | let curr_id = &self.curr_session().id; 231 | if let Some(&idx) = config.idmap.get(curr_id) { 232 | sid = sid.jump(idx); 233 | } 234 | if self.sid.iter_no < sid.total_iter { 235 | sid.iter_no = self.sid.iter_no; 236 | } 237 | } 238 | 239 | self.config = config; 240 | self.sid = sid; 241 | Ok(()) 242 | } 243 | 244 | async fn handle_commands(&self) -> Result { 245 | let mut buffer = Vec::new(); 246 | loop { 247 | let mut stream = self.listener.listen().await?; 248 | buffer.clear(); 249 | let msg = stream.read(&mut buffer).await?; 250 | let command: Command = bincode::deserialize(msg)?; 251 | match command { 252 | Command::Pause(_) | Command::Toggle(_) if R => { 253 | return Ok(Event::Command(Command::Pause(PauseArgs {}))) 254 | } 255 | Command::Resume(_) | Command::Toggle(_) if !R => { 256 | return Ok(Event::Command(Command::Resume(ResumeArgs {}))) 257 | } 258 | Command::Next(_) if !self.sid.is_last() => return Ok(Event::Command(command)), 259 | Command::Prev(_) if !self.sid.is_first() => return Ok(Event::Command(command)), 260 | Command::Finish(_) => return Ok(Event::Finished), 261 | Command::Jump(JumpArgs { id }) => { 262 | if let Some(idx) = self.config.idmap.get(&id) { 263 | return Ok(Event::Jump(*idx)); 264 | } 265 | } 266 | Command::Reload(_) => return Ok(Event::Command(command)), 267 | Command::Fetch(FetchArgs { format }) => return Ok(Event::Fetch(format, stream)), 268 | Command::Listen(ListenArgs { overrid, exit }) => { 269 | return if exit { 270 | Ok(Event::ListenExit(overrid, stream)) 271 | } else { 272 | Ok(Event::Listen(overrid, stream)) 273 | } 274 | } 275 | _ => {} 276 | } 277 | } 278 | } 279 | 280 | async fn handle_fetch_resumed( 281 | &self, 282 | overrides: Option<&Overridables>, 283 | mut stream: Stream, 284 | dest: Instant, 285 | ) -> Result<(), Error> { 286 | let remaining = dest - Instant::now(); 287 | let displayed = self.curr_session().display::(remaining, overrides); 288 | stream.write(format!("{}", displayed).as_bytes()).await?; 289 | Ok(()) 290 | } 291 | 292 | async fn handle_fetch_paused( 293 | &self, 294 | overrides: Option<&Overridables>, 295 | mut stream: Stream, 296 | duration: Duration, 297 | ) -> Result<(), Error> { 298 | let displayed = self.curr_session().display::(duration, overrides); 299 | stream.write(format!("{}", displayed).as_bytes()).await?; 300 | Ok(()) 301 | } 302 | 303 | fn initial_state(&self) -> State { 304 | if self.config.iterations != Some(0) && !self.config.sessions.is_empty() { 305 | self.new_state() 306 | } else { 307 | State::Finished 308 | } 309 | } 310 | 311 | fn initial_jump(&mut self, idx: usize) -> State { 312 | if self.config.iterations != Some(0) { 313 | self.jump_session(idx) 314 | } else { 315 | State::Finished 316 | } 317 | } 318 | 319 | fn curr_session(&self) -> &Session { 320 | &self.config.sessions[self.sid.curr()] 321 | } 322 | 323 | fn next_session(&mut self) -> State { 324 | self.sid = self.sid.next(); 325 | self.new_state() 326 | } 327 | 328 | fn prev_session(&mut self) -> State { 329 | self.sid = self.sid.prev(); 330 | self.new_state() 331 | } 332 | 333 | fn jump_session(&mut self, idx: usize) -> State { 334 | self.sid = self.sid.jump(idx); 335 | self.new_state() 336 | } 337 | 338 | fn new_state(&self) -> State { 339 | let session = self.curr_session(); 340 | if session.autostart { 341 | let start = Instant::now(); 342 | State::Resumed(start, start + session.duration) 343 | } else { 344 | State::Paused(session.duration) 345 | } 346 | } 347 | } 348 | 349 | #[cfg(test)] 350 | mod tests { 351 | use crate::{app::App, Args}; 352 | 353 | #[test] 354 | fn indicate_missing_config_file() { 355 | let result = App::new(Args { 356 | config: "~/.config/uair/no_uair.toml".into(), 357 | socket: "/tmp/uair.sock".into(), 358 | log: "-".into(), 359 | quiet: false, 360 | version: false, 361 | }); 362 | assert_eq!( 363 | result.err().unwrap().to_string(), 364 | "IO Error: Could not load config file \"~/.config/uair/no_uair.toml\"", 365 | ); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/bin/uair/config.rs: -------------------------------------------------------------------------------- 1 | use crate::session::{Color, Overridables, Session, TimeFormatToken, Token}; 2 | use log::warn; 3 | use serde::de::Error as _; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::HashMap; 6 | use std::str::FromStr; 7 | use std::time::Duration; 8 | use toml::de::Error; 9 | 10 | #[derive(Default)] 11 | pub struct Config { 12 | pub iterations: Option, 13 | pub pause_at_start: bool, 14 | pub startup_text: String, 15 | pub sessions: Vec, 16 | pub idmap: HashMap, 17 | } 18 | 19 | #[derive(Serialize, Deserialize)] 20 | pub struct ConfigBuilder { 21 | #[serde(default)] 22 | loop_on_end: bool, 23 | iterations: Option, 24 | #[serde(default)] 25 | pause_at_start: bool, 26 | #[serde(default)] 27 | startup_text: String, 28 | #[serde(default)] 29 | defaults: Defaults, 30 | sessions: Vec, 31 | } 32 | 33 | impl ConfigBuilder { 34 | pub fn deserialize(conf: &str) -> Result { 35 | let deserializer = toml::Deserializer::new(conf); 36 | serde_ignored::deserialize(deserializer, |path| { 37 | warn!("{path} is not a valid config and will be ignored.") 38 | }) 39 | } 40 | 41 | pub fn build(self) -> Result { 42 | let mut idmap = HashMap::new(); 43 | let mut sessions = Vec::new(); 44 | for (idx, session) in self.sessions.into_iter().enumerate() { 45 | let session = session.build(&self.defaults, idx); 46 | if let Some(idx2) = idmap.get(&session.id) { 47 | return Err(Error::custom(format!( 48 | "Duplicate identifier {} present at index {} and {}.", 49 | session.id, idx, idx2 50 | ))); 51 | } 52 | idmap.insert(session.id.clone(), idx); 53 | sessions.push(session); 54 | } 55 | Ok(Config { 56 | iterations: if self.loop_on_end && self.iterations != Some(0) { 57 | None 58 | } else if self.iterations.is_some() { 59 | self.iterations 60 | } else { 61 | Some(1) 62 | }, 63 | pause_at_start: self.pause_at_start, 64 | startup_text: self.startup_text, 65 | sessions, 66 | idmap, 67 | }) 68 | } 69 | } 70 | 71 | #[derive(Serialize, Deserialize)] 72 | pub struct Defaults { 73 | #[serde(default = "Defaults::name")] 74 | name: String, 75 | #[serde(with = "humantime_serde")] 76 | #[serde(default = "Defaults::duration")] 77 | duration: Duration, 78 | #[serde(default = "Defaults::command")] 79 | command: String, 80 | #[serde(default = "Defaults::format")] 81 | format: String, 82 | #[serde(default = "Defaults::time_format")] 83 | time_format: String, 84 | #[serde(default = "Defaults::autostart")] 85 | autostart: bool, 86 | #[serde(default = "Defaults::paused_state_text")] 87 | paused_state_text: String, 88 | #[serde(default = "Defaults::resumed_state_text")] 89 | resumed_state_text: String, 90 | #[serde(default = "Defaults::overrides")] 91 | overrides: HashMap, 92 | } 93 | 94 | impl Defaults { 95 | fn name() -> String { 96 | "Work".into() 97 | } 98 | fn duration() -> Duration { 99 | Duration::from_secs(25 * 60) 100 | } 101 | fn command() -> String { 102 | "notify-send 'Session Completed!'".into() 103 | } 104 | fn format() -> String { 105 | "{time}\n".into() 106 | } 107 | fn time_format() -> String { 108 | "%*-Yyear%P %*-Bmonth%P %*-Dday%P %*-Hh %*-Mm %*-Ss".into() 109 | } 110 | fn autostart() -> bool { 111 | false 112 | } 113 | fn paused_state_text() -> String { 114 | "⏸".into() 115 | } 116 | fn resumed_state_text() -> String { 117 | "⏵".into() 118 | } 119 | fn overrides() -> HashMap { 120 | HashMap::new() 121 | } 122 | } 123 | 124 | impl Default for Defaults { 125 | fn default() -> Self { 126 | Defaults { 127 | name: Defaults::name(), 128 | duration: Defaults::duration(), 129 | command: Defaults::command(), 130 | format: Defaults::format(), 131 | time_format: Defaults::time_format(), 132 | autostart: Defaults::autostart(), 133 | paused_state_text: Defaults::paused_state_text(), 134 | resumed_state_text: Defaults::resumed_state_text(), 135 | overrides: Defaults::overrides(), 136 | } 137 | } 138 | } 139 | 140 | #[derive(Serialize, Deserialize)] 141 | struct SessionBuilder { 142 | id: Option, 143 | name: Option, 144 | #[serde(with = "humantime_serde")] 145 | #[serde(default)] 146 | duration: Option, 147 | command: Option, 148 | format: Option, 149 | time_format: Option, 150 | autostart: Option, 151 | paused_state_text: Option, 152 | resumed_state_text: Option, 153 | #[serde(default)] 154 | overrides: HashMap, 155 | } 156 | 157 | impl SessionBuilder { 158 | fn build(self, defaults: &Defaults, idx: usize) -> Session { 159 | let mut default_overrides = defaults.overrides.clone(); 160 | default_overrides.extend(self.overrides); 161 | let overrides = default_overrides 162 | .into_iter() 163 | .map(|(k, v)| { 164 | let default = defaults.overrides.get(&k); 165 | (k, v.build(default)) 166 | }) 167 | .collect(); 168 | Session { 169 | id: self.id.unwrap_or_else(|| idx.to_string()), 170 | name: self.name.unwrap_or_else(|| defaults.name.clone()), 171 | duration: self.duration.unwrap_or(defaults.duration), 172 | command: self.command.unwrap_or_else(|| defaults.command.clone()), 173 | format: self 174 | .format 175 | .map(|f| Token::parse(&f)) 176 | .unwrap_or_else(|| Token::parse(&defaults.format)), 177 | time_format: TimeFormatToken::parse( 178 | self.time_format.as_ref().unwrap_or(&defaults.time_format), 179 | ), 180 | autostart: self.autostart.unwrap_or(defaults.autostart), 181 | paused_state_text: self 182 | .paused_state_text 183 | .unwrap_or_else(|| defaults.paused_state_text.clone()), 184 | resumed_state_text: self 185 | .resumed_state_text 186 | .unwrap_or_else(|| defaults.resumed_state_text.clone()), 187 | overrides, 188 | } 189 | } 190 | } 191 | 192 | impl FromStr for Token { 193 | type Err = (); 194 | 195 | fn from_str(s: &str) -> Result { 196 | match s { 197 | "{name}" => Ok(Token::Name), 198 | "{percent}" => Ok(Token::Percent), 199 | "{time}" => Ok(Token::Time), 200 | "{total}" => Ok(Token::Total), 201 | "{state}" => Ok(Token::State), 202 | "{black}" => Ok(Token::Color(Color::Black)), 203 | "{red}" => Ok(Token::Color(Color::Red)), 204 | "{green}" => Ok(Token::Color(Color::Green)), 205 | "{yellow}" => Ok(Token::Color(Color::Yellow)), 206 | "{blue}" => Ok(Token::Color(Color::Blue)), 207 | "{purple}" => Ok(Token::Color(Color::Purple)), 208 | "{cyan}" => Ok(Token::Color(Color::Cyan)), 209 | "{white}" => Ok(Token::Color(Color::White)), 210 | "{end}" => Ok(Token::Color(Color::End)), 211 | _ => Err(()), 212 | } 213 | } 214 | } 215 | 216 | #[derive(Serialize, Deserialize, Default, Clone)] 217 | struct OverridablesBuilder { 218 | format: Option, 219 | time_format: Option, 220 | paused_state_text: Option, 221 | resumed_state_text: Option, 222 | } 223 | 224 | impl OverridablesBuilder { 225 | fn build(self, defaults: Option<&OverridablesBuilder>) -> Overridables { 226 | let default_ob = OverridablesBuilder::default(); 227 | let defaults = defaults.unwrap_or(&default_ob); 228 | Overridables { 229 | format: self 230 | .format 231 | .or(defaults.format.clone()) 232 | .map(|f| Token::parse(&f)), 233 | time_format: self 234 | .time_format 235 | .or(defaults.time_format.clone()) 236 | .map(|f| TimeFormatToken::parse(&f)), 237 | paused_state_text: self 238 | .paused_state_text 239 | .or(defaults.paused_state_text.clone()), 240 | resumed_state_text: self 241 | .resumed_state_text 242 | .or(defaults.resumed_state_text.clone()), 243 | } 244 | } 245 | } 246 | 247 | #[cfg(test)] 248 | mod tests { 249 | use super::*; 250 | use toml::de::Error; 251 | 252 | use log::Level; 253 | 254 | const TOML_CFG: &str = r#" 255 | loop-on-end = true 256 | 257 | [defaults] 258 | format = "{time}\n" 259 | time_format = "%M:%S" 260 | 261 | [[sessions]] 262 | "#; 263 | 264 | #[test] 265 | fn unknown_config_field_warning() -> Result<(), Error> { 266 | testing_logger::setup(); 267 | ConfigBuilder::deserialize(TOML_CFG)?; 268 | testing_logger::validate(|captured_logs| { 269 | assert_eq!(captured_logs.len(), 1); 270 | assert!(captured_logs[0] 271 | .body 272 | .contains("is not a valid config and will be ignored")); 273 | assert_eq!(captured_logs[0].level, Level::Warn); 274 | }); 275 | Ok(()) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/bin/uair/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod config; 3 | mod session; 4 | mod socket; 5 | mod timer; 6 | 7 | use crate::app::App; 8 | use argh::FromArgs; 9 | use async_signal::{Signal, Signals}; 10 | use futures_lite::{FutureExt, StreamExt}; 11 | use log::{error, LevelFilter}; 12 | use simplelog::{ColorChoice, Config as LogConfig, TermLogger, TerminalMode, WriteLogger}; 13 | use std::env; 14 | use std::fmt::Display; 15 | use std::fs::File; 16 | use std::io::{self, Write}; 17 | use std::process::ExitCode; 18 | use uair::get_socket_path; 19 | 20 | fn main() -> ExitCode { 21 | let args: Args = argh::from_env(); 22 | if args.version { 23 | _ = writeln!( 24 | io::stdout(), 25 | "{} version {}", 26 | env!("CARGO_PKG_NAME"), 27 | env!("CARGO_PKG_VERSION"), 28 | ); 29 | return ExitCode::SUCCESS; 30 | } 31 | 32 | let enable_stderr = args.log != "-"; 33 | 34 | if let Err(err) = init_logger(&args) { 35 | return raise_err(err, enable_stderr); 36 | } 37 | 38 | let app = match App::new(args) { 39 | Ok(app) => app, 40 | Err(err) => { 41 | return raise_err(err, enable_stderr); 42 | } 43 | }; 44 | 45 | if let Err(err) = async_io::block_on(app.run().or(catch_term_signals())) { 46 | return raise_err(err, enable_stderr); 47 | } 48 | 49 | ExitCode::SUCCESS 50 | } 51 | 52 | #[derive(FromArgs)] 53 | /// An extensible pomodoro timer 54 | pub struct Args { 55 | /// specifies a config file. 56 | #[argh(option, short = 'c', default = "get_config_path()")] 57 | config: String, 58 | 59 | /// specifies a socket file. 60 | #[argh(option, short = 's', default = "get_socket_path()")] 61 | socket: String, 62 | 63 | /// specifies a log file. 64 | #[argh(option, short = 'l', default = "\"-\".into()")] 65 | log: String, 66 | 67 | /// run without writing to standard output. 68 | #[argh(switch, short = 'q')] 69 | quiet: bool, 70 | 71 | /// display version number and then exit. 72 | #[argh(switch, short = 'v')] 73 | version: bool, 74 | } 75 | 76 | fn get_config_path() -> String { 77 | if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { 78 | xdg_config_home + "/uair/uair.toml" 79 | } else if let Ok(home) = env::var("HOME") { 80 | home + "/.config/uair/uair.toml" 81 | } else { 82 | "~/.config/uair/uair.toml".into() 83 | } 84 | } 85 | 86 | async fn catch_term_signals() -> Result<(), Error> { 87 | let mut signals = Signals::new([Signal::Term, Signal::Int, Signal::Quit])?; 88 | signals.next().await; 89 | Ok(()) 90 | } 91 | 92 | fn init_logger(args: &Args) -> Result<(), Error> { 93 | if args.log == "-" { 94 | TermLogger::init( 95 | LevelFilter::Info, 96 | LogConfig::default(), 97 | TerminalMode::Stderr, 98 | ColorChoice::Auto, 99 | )?; 100 | } else { 101 | WriteLogger::init( 102 | LevelFilter::Info, 103 | LogConfig::default(), 104 | File::create(&args.log)?, 105 | )?; 106 | } 107 | Ok(()) 108 | } 109 | 110 | fn raise_err(err: impl Display, enable_stderr: bool) -> ExitCode { 111 | error!("{}", err); 112 | if enable_stderr { 113 | eprintln!("{}", err) 114 | } 115 | ExitCode::FAILURE 116 | } 117 | 118 | #[derive(thiserror::Error, Debug)] 119 | pub enum Error { 120 | #[error("Log Error: {0}")] 121 | LogError(#[from] log::SetLoggerError), 122 | #[error("IO Error: {0}")] 123 | IoError(#[from] io::Error), 124 | #[error("Config Error: {0}")] 125 | ConfError(#[from] toml::de::Error), 126 | #[error("Deserialization Error: {0}")] 127 | DeserError(#[from] bincode::Error), 128 | } 129 | -------------------------------------------------------------------------------- /src/bin/uair/session.rs: -------------------------------------------------------------------------------- 1 | use async_process::Command; 2 | use humantime::format_duration; 3 | use std::collections::HashMap; 4 | use std::fmt::{self, Display, Formatter}; 5 | use std::io; 6 | use std::process::Stdio; 7 | use std::time::Duration; 8 | use winnow::combinator::{alt, opt, peek, preceded, repeat}; 9 | use winnow::token::{any, one_of, rest, take_until}; 10 | use winnow::{ModalResult, Parser}; 11 | 12 | pub struct Session { 13 | pub id: String, 14 | pub name: String, 15 | pub duration: Duration, 16 | pub command: String, 17 | pub format: Vec, 18 | pub time_format: Vec, 19 | pub autostart: bool, 20 | pub paused_state_text: String, 21 | pub resumed_state_text: String, 22 | pub overrides: HashMap, 23 | } 24 | 25 | impl Session { 26 | pub fn display<'s, const R: bool>( 27 | &'s self, 28 | time: Duration, 29 | overrid: Option<&'s Overridables>, 30 | ) -> DisplayableSession<'s, R> { 31 | DisplayableSession { 32 | session: self, 33 | time: DisplayableTime { 34 | time, 35 | format: overrid 36 | .and_then(|o| o.time_format.as_ref()) 37 | .unwrap_or(&self.time_format), 38 | }, 39 | format: overrid 40 | .and_then(|o| o.format.as_ref()) 41 | .unwrap_or(&self.format), 42 | pst_override: overrid.and_then(|o| o.paused_state_text.as_deref()), 43 | rst_override: overrid.and_then(|o| o.resumed_state_text.as_deref()), 44 | } 45 | } 46 | 47 | pub fn run_command(&self) -> io::Result<()> { 48 | if !self.command.is_empty() { 49 | let duration = humantime::format_duration(self.duration).to_string(); 50 | Command::new("/bin/sh") 51 | .env("name", &self.name) 52 | .env("duration", duration) 53 | .arg("-c") 54 | .arg(&self.command) 55 | .stdin(Stdio::null()) 56 | .stdout(Stdio::null()) 57 | .stderr(Stdio::null()) 58 | .spawn()?; 59 | } 60 | Ok(()) 61 | } 62 | } 63 | 64 | #[derive(Clone, Default)] 65 | pub struct Overridables { 66 | pub format: Option>, 67 | pub time_format: Option>, 68 | pub paused_state_text: Option, 69 | pub resumed_state_text: Option, 70 | } 71 | 72 | impl Overridables { 73 | pub fn new() -> Self { 74 | Overridables::default() 75 | } 76 | 77 | pub fn format(self, format: &str) -> Self { 78 | Overridables { 79 | format: Some(Token::parse(format)), 80 | ..self 81 | } 82 | } 83 | } 84 | 85 | pub struct DisplayableSession<'s, const R: bool> { 86 | session: &'s Session, 87 | time: DisplayableTime<'s>, 88 | format: &'s [Token], 89 | pst_override: Option<&'s str>, 90 | rst_override: Option<&'s str>, 91 | } 92 | 93 | impl Display for DisplayableSession<'_, R> { 94 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 95 | for token in self.format { 96 | match token { 97 | Token::Name => write!(f, "{}", self.session.name)?, 98 | Token::Percent => write!( 99 | f, 100 | "{}", 101 | (self.time.time.as_secs_f32() * 100.0 / self.session.duration.as_secs_f32()) 102 | as u8 103 | )?, 104 | Token::Time => write!(f, "{}", self.time)?, 105 | Token::Total => write!(f, "{}", format_duration(self.session.duration))?, 106 | Token::State => write!( 107 | f, 108 | "{}", 109 | if R { 110 | self.rst_override 111 | .unwrap_or(&self.session.resumed_state_text) 112 | } else { 113 | self.pst_override.unwrap_or(&self.session.paused_state_text) 114 | } 115 | )?, 116 | Token::Color(Color::Black) => write!(f, "\x1b[0;30m")?, 117 | Token::Color(Color::Red) => write!(f, "\x1b[0;31m")?, 118 | Token::Color(Color::Green) => write!(f, "\x1b[0;32m")?, 119 | Token::Color(Color::Yellow) => write!(f, "\x1b[0;33m")?, 120 | Token::Color(Color::Blue) => write!(f, "\x1b[0;34m")?, 121 | Token::Color(Color::Purple) => write!(f, "\x1b[0;35m")?, 122 | Token::Color(Color::Cyan) => write!(f, "\x1b[0;36m")?, 123 | Token::Color(Color::White) => write!(f, "\x1b[0;37m")?, 124 | Token::Color(Color::End) => write!(f, "\x1b[0m")?, 125 | Token::Literal(literal) => write!(f, "{}", literal)?, 126 | }; 127 | } 128 | Ok(()) 129 | } 130 | } 131 | 132 | struct DisplayableTime<'s> { 133 | time: Duration, 134 | format: &'s [TimeFormatToken], 135 | } 136 | 137 | impl Display for DisplayableTime<'_> { 138 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 139 | let secs = self.time.as_secs(); 140 | let years = secs / 31_557_600; 141 | let ydays = secs % 31_557_600; 142 | let months = ydays / 2_630_016; 143 | let mdays = ydays % 2_630_016; 144 | let days = mdays / 86400; 145 | let day_secs = mdays % 86400; 146 | let hours = day_secs / 3600; 147 | let minutes = day_secs % 3600 / 60; 148 | let seconds = day_secs % 60; 149 | 150 | let mut skip = false; 151 | let mut plural = ""; 152 | for token in self.format { 153 | match token { 154 | TimeFormatToken::Numeric(n, pad, s) => { 155 | let val = match n { 156 | Numeric::Year => years, 157 | Numeric::Month => months, 158 | Numeric::Day => days, 159 | Numeric::Hour => hours, 160 | Numeric::Minute => minutes, 161 | Numeric::Second => seconds, 162 | }; 163 | 164 | if *s && val == 0 { 165 | skip = true; 166 | continue; 167 | } 168 | skip = false; 169 | 170 | plural = if val > 1 { "s" } else { "" }; 171 | 172 | match pad { 173 | Pad::Zero => write!(f, "{:0>2}", val)?, 174 | Pad::Space => write!(f, "{:>2}", val)?, 175 | Pad::None => write!(f, "{}", val)?, 176 | } 177 | } 178 | TimeFormatToken::Literal(literal) if !skip => write!(f, "{}", literal)?, 179 | TimeFormatToken::Plural if !skip => write!(f, "{}", plural)?, 180 | _ => {} 181 | } 182 | } 183 | Ok(()) 184 | } 185 | } 186 | 187 | #[derive(Clone)] 188 | #[cfg_attr(test, derive(PartialEq, Debug))] 189 | pub enum Token { 190 | Name, 191 | Percent, 192 | Time, 193 | Total, 194 | State, 195 | Color(Color), 196 | Literal(String), 197 | } 198 | 199 | impl Token { 200 | pub fn parse(format: &str) -> Vec { 201 | let mut tokens = Vec::new(); 202 | let mut k = 0; 203 | let mut open = None; 204 | 205 | for (i, c) in format.char_indices() { 206 | match c { 207 | '{' => open = Some(i), 208 | '}' => { 209 | if let Some(j) = open { 210 | if let Ok(token) = format[j..=i].parse() { 211 | if k != j { 212 | tokens.push(Token::Literal(format[k..j].into())) 213 | }; 214 | tokens.push(token); 215 | k = i + 1; 216 | } 217 | } 218 | } 219 | _ => {} 220 | } 221 | } 222 | if k != format.len() { 223 | tokens.push(Token::Literal(format[k..].into())) 224 | }; 225 | 226 | tokens 227 | } 228 | } 229 | 230 | #[derive(Clone)] 231 | #[cfg_attr(test, derive(PartialEq, Debug))] 232 | pub enum TimeFormatToken { 233 | Numeric(Numeric, Pad, bool), 234 | Literal(String), 235 | Plural, 236 | } 237 | 238 | impl TimeFormatToken { 239 | pub fn parse(mut format: &str) -> Vec { 240 | let res: ModalResult> = repeat( 241 | 0.., 242 | alt(( 243 | preceded( 244 | "%", 245 | (opt(one_of('*')), opt(one_of(['-', '_', '0'])), opt(any)).map(Self::identify), 246 | ), 247 | take_until(0.., "%").map(|s: &str| TimeFormatToken::Literal(s.into())), 248 | (peek(any), rest).map(|(_, s): (char, &str)| TimeFormatToken::Literal(s.into())), 249 | )), 250 | ) 251 | .parse_next(&mut format); 252 | res.unwrap() 253 | } 254 | 255 | fn identify((star, flag, spec): (Option, Option, Option)) -> TimeFormatToken { 256 | let skip = star.is_some(); 257 | let pad = match flag { 258 | Some('-') => Pad::None, 259 | Some('_') => Pad::Space, 260 | Some('0') | None => Pad::Zero, 261 | _ => unreachable!(), 262 | }; 263 | match spec { 264 | Some('Y') => TimeFormatToken::Numeric(Numeric::Year, pad, skip), 265 | Some('B') => TimeFormatToken::Numeric(Numeric::Month, pad, skip), 266 | Some('D') => TimeFormatToken::Numeric(Numeric::Day, pad, skip), 267 | Some('H') => TimeFormatToken::Numeric(Numeric::Hour, pad, skip), 268 | Some('M') => TimeFormatToken::Numeric(Numeric::Minute, pad, skip), 269 | Some('S') => TimeFormatToken::Numeric(Numeric::Second, pad, skip), 270 | Some('P') => TimeFormatToken::Plural, 271 | _ => { 272 | let mut l = "%".to_string(); 273 | if let Some(s) = star { 274 | l.push(s) 275 | }; 276 | if let Some(f) = flag { 277 | l.push(f) 278 | }; 279 | if let Some(c) = spec { 280 | l.push(c) 281 | }; 282 | TimeFormatToken::Literal(l) 283 | } 284 | } 285 | } 286 | } 287 | 288 | #[derive(Clone)] 289 | #[cfg_attr(test, derive(PartialEq, Debug))] 290 | pub enum Numeric { 291 | Year, 292 | Month, 293 | Day, 294 | Hour, 295 | Minute, 296 | Second, 297 | } 298 | 299 | #[derive(Clone)] 300 | #[cfg_attr(test, derive(PartialEq, Debug))] 301 | pub enum Pad { 302 | Zero, 303 | Space, 304 | None, 305 | } 306 | 307 | #[derive(Clone)] 308 | #[cfg_attr(test, derive(PartialEq, Debug))] 309 | pub enum Color { 310 | Black, 311 | Red, 312 | Green, 313 | Yellow, 314 | Blue, 315 | Purple, 316 | Cyan, 317 | White, 318 | End, 319 | } 320 | 321 | #[derive(Copy, Clone, Default)] 322 | pub struct SessionId { 323 | index: usize, 324 | len: usize, 325 | infinite: bool, 326 | pub iter_no: u64, 327 | pub total_iter: u64, 328 | } 329 | 330 | impl SessionId { 331 | pub fn new(sessions: &[Session], iterations: Option) -> Self { 332 | SessionId { 333 | index: 0, 334 | len: sessions.len(), 335 | infinite: iterations.is_none(), 336 | iter_no: 0, 337 | total_iter: iterations.unwrap_or(0), 338 | } 339 | } 340 | 341 | pub fn curr(&self) -> usize { 342 | self.index 343 | } 344 | 345 | pub fn next(&self) -> SessionId { 346 | let mut next = SessionId { ..*self }; 347 | if self.index < self.len - 1 { 348 | next.index += 1; 349 | } else if self.infinite { 350 | next.index = 0; 351 | } else if self.iter_no < self.total_iter - 1 { 352 | next.index = 0; 353 | next.iter_no += 1; 354 | } 355 | next 356 | } 357 | 358 | pub fn prev(&self) -> SessionId { 359 | let mut prev = SessionId { ..*self }; 360 | if self.index > 0 { 361 | prev.index -= 1; 362 | } else if self.infinite { 363 | prev.index = self.len - 1 364 | } else if self.iter_no > 0 { 365 | prev.index = self.len - 1; 366 | prev.iter_no -= 1; 367 | } 368 | prev 369 | } 370 | 371 | pub fn jump(&self, idx: usize) -> SessionId { 372 | SessionId { 373 | index: idx, 374 | ..*self 375 | } 376 | } 377 | 378 | pub fn is_last(&self) -> bool { 379 | self.index == self.len - 1 && !self.infinite && self.iter_no == self.total_iter - 1 380 | } 381 | 382 | pub fn is_first(&self) -> bool { 383 | self.index == 0 && !self.infinite && self.iter_no == 0 384 | } 385 | } 386 | 387 | #[cfg(test)] 388 | mod tests { 389 | use super::{Color, Numeric, Pad, TimeFormatToken, Token}; 390 | 391 | #[test] 392 | fn parse_format() { 393 | assert_eq!( 394 | &Token::parse("{cyan}{time}{end}\n"), 395 | &[ 396 | Token::Color(Color::Cyan), 397 | Token::Time, 398 | Token::Color(Color::End), 399 | Token::Literal("\n".into()), 400 | ] 401 | ); 402 | assert_eq!( 403 | &Token::parse("String with {time} with some text ahead."), 404 | &[ 405 | Token::Literal("String with ".into()), 406 | Token::Time, 407 | Token::Literal(" with some text ahead.".into()) 408 | ] 409 | ); 410 | assert_eq!( 411 | &Token::parse("}}{}{{}{}}}{{}{{}}}"), 412 | &[Token::Literal("}}{}{{}{}}}{{}{{}}}".into())] 413 | ); 414 | assert_eq!( 415 | &Token::parse("{time} text {time}"), 416 | &[Token::Time, Token::Literal(" text ".into()), Token::Time,] 417 | ); 418 | } 419 | 420 | #[test] 421 | fn parse_time_format() { 422 | assert_eq!( 423 | &TimeFormatToken::parse("%H:%M:%S"), 424 | &[ 425 | TimeFormatToken::Numeric(Numeric::Hour, Pad::Zero, false), 426 | TimeFormatToken::Literal(":".into()), 427 | TimeFormatToken::Numeric(Numeric::Minute, Pad::Zero, false), 428 | TimeFormatToken::Literal(":".into()), 429 | TimeFormatToken::Numeric(Numeric::Second, Pad::Zero, false), 430 | ] 431 | ); 432 | assert_eq!( 433 | &TimeFormatToken::parse("%L:%M:%S"), 434 | &[ 435 | TimeFormatToken::Literal("%L".into()), 436 | TimeFormatToken::Literal(":".into()), 437 | TimeFormatToken::Numeric(Numeric::Minute, Pad::Zero, false), 438 | TimeFormatToken::Literal(":".into()), 439 | TimeFormatToken::Numeric(Numeric::Second, Pad::Zero, false), 440 | ] 441 | ); 442 | assert_eq!( 443 | &TimeFormatToken::parse("%H:%M:%"), 444 | &[ 445 | TimeFormatToken::Numeric(Numeric::Hour, Pad::Zero, false), 446 | TimeFormatToken::Literal(":".into()), 447 | TimeFormatToken::Numeric(Numeric::Minute, Pad::Zero, false), 448 | TimeFormatToken::Literal(":".into()), 449 | TimeFormatToken::Literal("%".into()), 450 | ] 451 | ); 452 | assert_eq!( 453 | &TimeFormatToken::parse("%_H:%-M:%S"), 454 | &[ 455 | TimeFormatToken::Numeric(Numeric::Hour, Pad::Space, false), 456 | TimeFormatToken::Literal(":".into()), 457 | TimeFormatToken::Numeric(Numeric::Minute, Pad::None, false), 458 | TimeFormatToken::Literal(":".into()), 459 | TimeFormatToken::Numeric(Numeric::Second, Pad::Zero, false), 460 | ] 461 | ); 462 | assert_eq!( 463 | &TimeFormatToken::parse("%H:%*-M:%S"), 464 | &[ 465 | TimeFormatToken::Numeric(Numeric::Hour, Pad::Zero, false), 466 | TimeFormatToken::Literal(":".into()), 467 | TimeFormatToken::Numeric(Numeric::Minute, Pad::None, true), 468 | TimeFormatToken::Literal(":".into()), 469 | TimeFormatToken::Numeric(Numeric::Second, Pad::Zero, false), 470 | ] 471 | ); 472 | assert_eq!( 473 | &TimeFormatToken::parse("%*-Hh %*-Mm %-Ss"), 474 | &[ 475 | TimeFormatToken::Numeric(Numeric::Hour, Pad::None, true), 476 | TimeFormatToken::Literal("h ".into()), 477 | TimeFormatToken::Numeric(Numeric::Minute, Pad::None, true), 478 | TimeFormatToken::Literal("m ".into()), 479 | TimeFormatToken::Numeric(Numeric::Second, Pad::None, false), 480 | TimeFormatToken::Literal("s".into()), 481 | ] 482 | ); 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/bin/uair/socket.rs: -------------------------------------------------------------------------------- 1 | use async_net::unix::{UnixListener, UnixStream}; 2 | use futures_lite::io::BlockOn; 3 | use futures_lite::{AsyncReadExt, AsyncWriteExt}; 4 | use std::fs; 5 | use std::io::{self, Write}; 6 | use std::path::PathBuf; 7 | 8 | pub struct Listener { 9 | path: PathBuf, 10 | listener: UnixListener, 11 | } 12 | 13 | impl Listener { 14 | pub fn new(path: &str) -> io::Result { 15 | Ok(Listener { 16 | path: path.into(), 17 | listener: UnixListener::bind(path)?, 18 | }) 19 | } 20 | 21 | pub async fn listen(&self) -> io::Result { 22 | let (stream, _) = self.listener.accept().await?; 23 | Ok(Stream { stream }) 24 | } 25 | } 26 | 27 | impl Drop for Listener { 28 | fn drop(&mut self) { 29 | _ = fs::remove_file(&self.path); 30 | } 31 | } 32 | 33 | pub struct Stream { 34 | stream: UnixStream, 35 | } 36 | 37 | impl Stream { 38 | pub async fn read<'buf>(&mut self, buffer: &'buf mut Vec) -> io::Result<&'buf [u8]> { 39 | let n_bytes = self.stream.read_to_end(buffer).await?; 40 | Ok(&buffer[..n_bytes]) 41 | } 42 | 43 | pub async fn write(&mut self, data: &[u8]) -> io::Result<()> { 44 | self.stream.write_all(data).await?; 45 | Ok(()) 46 | } 47 | 48 | pub fn into_blocking(self) -> BlockingStream { 49 | BlockingStream { 50 | stream: BlockOn::new(self.stream), 51 | } 52 | } 53 | } 54 | 55 | pub struct BlockingStream { 56 | stream: BlockOn, 57 | } 58 | 59 | impl BlockingStream { 60 | pub fn write(&mut self, data: &[u8]) -> io::Result<()> { 61 | self.stream.write_all(data)?; 62 | self.stream.flush()?; 63 | Ok(()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/bin/uair/timer.rs: -------------------------------------------------------------------------------- 1 | use crate::app::Event; 2 | use crate::session::Session; 3 | use crate::socket::BlockingStream; 4 | use crate::Error; 5 | use async_io::Timer; 6 | use std::fmt::Write as _; 7 | use std::io::{self, Stdout, Write}; 8 | use std::time::{Duration, Instant}; 9 | 10 | pub struct UairTimer { 11 | interval: Duration, 12 | pub writer: Writer, 13 | pub state: State, 14 | } 15 | 16 | impl UairTimer { 17 | pub fn new(interval: Duration, quiet: bool) -> Self { 18 | UairTimer { 19 | interval, 20 | writer: Writer::new(quiet), 21 | state: State::PreInit, 22 | } 23 | } 24 | 25 | pub async fn start( 26 | &mut self, 27 | session: &Session, 28 | start: Instant, 29 | dest: Instant, 30 | ) -> Result { 31 | let _guard = StateGuard(&mut self.state); 32 | 33 | let duration = dest - start; 34 | let first_interval = Duration::from_nanos(duration.subsec_nanos().into()); 35 | let mut end = start + first_interval; 36 | 37 | while end <= dest { 38 | Timer::at(end).await; 39 | self.writer.write::(session, dest - end)?; 40 | end += self.interval; 41 | } 42 | 43 | Ok(Event::Finished) 44 | } 45 | } 46 | 47 | pub struct Writer { 48 | streams: Vec<(BlockingStream, Option)>, 49 | stdout: Option, 50 | buf: String, 51 | } 52 | 53 | impl Writer { 54 | fn new(quiet: bool) -> Self { 55 | Writer { 56 | streams: Vec::new(), 57 | stdout: (!quiet).then(io::stdout), 58 | buf: "".into(), 59 | } 60 | } 61 | 62 | pub fn write( 63 | &mut self, 64 | session: &Session, 65 | duration: Duration, 66 | ) -> Result<(), Error> { 67 | if let Some(stdout) = &mut self.stdout { 68 | _ = write!(self.buf, "{}", session.display::(duration, None)); 69 | if write!(stdout, "{}", self.buf) 70 | .and_then(|_| stdout.flush()) 71 | .is_err() 72 | { 73 | self.stdout = None; 74 | } 75 | self.buf.clear(); 76 | } 77 | self.streams.retain_mut(|(stream, overrid)| { 78 | let overrid = overrid.as_ref().and_then(|o| session.overrides.get(o)); 79 | _ = write!(self.buf, "{}\0", session.display::(duration, overrid)); 80 | let res = stream.write(self.buf.as_bytes()).is_ok(); 81 | self.buf.clear(); 82 | res 83 | }); 84 | Ok(()) 85 | } 86 | 87 | pub fn add_stream(&mut self, stream: BlockingStream, overrid: Option) { 88 | self.streams.push((stream, overrid)); 89 | } 90 | } 91 | 92 | pub enum State { 93 | PreInit, 94 | Paused(Duration), 95 | Resumed(Instant, Instant), 96 | Finished, 97 | } 98 | 99 | struct StateGuard<'s>(&'s mut State); 100 | 101 | impl Drop for StateGuard<'_> { 102 | fn drop(&mut self) { 103 | if let State::Resumed(_, dest) = self.0 { 104 | *self.0 = State::Resumed(Instant::now(), *dest); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/bin/uairctl/main.rs: -------------------------------------------------------------------------------- 1 | use argh::FromArgs; 2 | use std::io::{self, BufRead, BufReader, Read, Write}; 3 | use std::net::Shutdown; 4 | use std::os::unix::net::UnixStream; 5 | use std::str; 6 | use uair::{get_socket_path, Command, FetchArgs}; 7 | 8 | fn main() -> Result<(), Error> { 9 | let mut args: Args = argh::from_env(); 10 | if let Command::Fetch(FetchArgs { format }) = &mut args.command { 11 | *format = unescape(format); 12 | } 13 | 14 | let command = bincode::serialize(&args.command)?; 15 | let mut stream = UnixStream::connect(&args.socket)?; 16 | 17 | stream.write_all(&command)?; 18 | stream.shutdown(Shutdown::Write)?; 19 | 20 | match args.command { 21 | Command::Fetch(_) => { 22 | let mut buf = String::new(); 23 | stream.read_to_string(&mut buf)?; 24 | 25 | write!(io::stdout(), "{}", buf)?; 26 | } 27 | Command::Listen(_) => { 28 | let mut reader = BufReader::new(stream); 29 | let mut buf = Vec::new(); 30 | 31 | loop { 32 | reader.read_until(b'\0', &mut buf)?; 33 | if buf.is_empty() { 34 | break; 35 | } 36 | write!(io::stdout(), "{}", str::from_utf8(&buf)?)?; 37 | buf.clear(); 38 | } 39 | } 40 | _ => {} 41 | } 42 | 43 | Ok(()) 44 | } 45 | 46 | fn unescape(input: &str) -> String { 47 | let mut res = String::new(); 48 | let mut chars = input.char_indices(); 49 | 'outer: while let Some((_, c)) = chars.next() { 50 | if c != '\\' { 51 | res.push(c); 52 | } else if let Some((i, c)) = chars.next() { 53 | match c { 54 | 'b' => res.push('\u{0008}'), 55 | 'f' => res.push('\u{000c}'), 56 | 'n' => res.push('\n'), 57 | 'r' => res.push('\r'), 58 | 't' => res.push('\t'), 59 | '\"' => res.push('\"'), 60 | '\\' => res.push('\\'), 61 | 'u' => { 62 | for _ in 0..4 { 63 | if chars.next().is_none() { 64 | res.push_str(&input[i - 1..]); 65 | break 'outer; 66 | } 67 | } 68 | match u32::from_str_radix(&input[i + 1..i + 5], 16).map(char::from_u32) { 69 | Ok(Some(num)) => res.push(num), 70 | _ => res.push_str(&input[i - 1..i + 5]), 71 | } 72 | } 73 | 'U' => { 74 | for _ in 0..8 { 75 | if chars.next().is_none() { 76 | res.push_str(&input[i - 1..]); 77 | break 'outer; 78 | } 79 | } 80 | match u32::from_str_radix(&input[i + 1..i + 9], 16).map(char::from_u32) { 81 | Ok(Some(num)) => res.push(num), 82 | _ => res.push_str(&input[i - 1..i + 9]), 83 | } 84 | } 85 | _ => res.push_str(&input[i - 1..i + 1]), 86 | } 87 | } else { 88 | res.push('\\'); 89 | break; 90 | } 91 | } 92 | 93 | res 94 | } 95 | 96 | #[derive(FromArgs)] 97 | /// An extensible pomodoro timer 98 | struct Args { 99 | /// specifies the socket file. 100 | #[argh(option, short = 's', default = "get_socket_path()")] 101 | socket: String, 102 | 103 | #[argh(subcommand)] 104 | command: Command, 105 | } 106 | 107 | #[derive(thiserror::Error, Debug)] 108 | enum Error { 109 | #[error("Serialization Error: {0}")] 110 | Ser(#[from] bincode::Error), 111 | #[error("Socket Connection Error: {0}")] 112 | Io(#[from] io::Error), 113 | #[error("UTF8 Error: {0}")] 114 | Utf8(#[from] str::Utf8Error), 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use super::unescape; 120 | 121 | #[test] 122 | fn unescape_test() { 123 | assert_eq!(unescape(r"\u0000"), "\0"); 124 | assert_eq!(unescape(r"\u0009"), "\t"); 125 | assert_eq!(unescape(r"\u000a"), "\n"); 126 | assert_eq!(unescape(r"\uffff"), "\u{ffff}"); 127 | assert_eq!(unescape(r"\u0000Foo"), "\0Foo"); 128 | 129 | assert_eq!(unescape(r"\nFoo"), "\nFoo"); 130 | assert_eq!(unescape(r"Foo\"), "Foo\\"); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use argh::FromArgs; 2 | use serde::{Deserialize, Serialize}; 3 | use std::env; 4 | 5 | #[derive(FromArgs, Serialize, Deserialize)] 6 | #[argh(subcommand)] 7 | pub enum Command { 8 | Pause(PauseArgs), 9 | Resume(ResumeArgs), 10 | Toggle(ToggleArgs), 11 | Next(NextArgs), 12 | Prev(PrevArgs), 13 | Finish(FinishArgs), 14 | Jump(JumpArgs), 15 | Reload(ReloadArgs), 16 | Fetch(FetchArgs), 17 | Listen(ListenArgs), 18 | } 19 | 20 | #[derive(FromArgs, Serialize, Deserialize)] 21 | /// Pause the timer. 22 | #[argh(subcommand, name = "pause")] 23 | pub struct PauseArgs {} 24 | 25 | #[derive(FromArgs, Serialize, Deserialize)] 26 | /// Resume the timer. 27 | #[argh(subcommand, name = "resume")] 28 | pub struct ResumeArgs {} 29 | 30 | #[derive(FromArgs, Serialize, Deserialize)] 31 | /// Toggle the state of the timer. 32 | #[argh(subcommand, name = "toggle")] 33 | pub struct ToggleArgs {} 34 | 35 | #[derive(FromArgs, Serialize, Deserialize)] 36 | /// Jump to the next session. 37 | #[argh(subcommand, name = "next")] 38 | pub struct NextArgs {} 39 | 40 | #[derive(FromArgs, Serialize, Deserialize)] 41 | /// Jump to the previous session. 42 | #[argh(subcommand, name = "prev")] 43 | pub struct PrevArgs {} 44 | 45 | #[derive(FromArgs, Serialize, Deserialize)] 46 | /// Instantly finishes the current session, invoking the session's specified command. 47 | #[argh(subcommand, name = "finish")] 48 | pub struct FinishArgs {} 49 | 50 | #[derive(FromArgs, Serialize, Deserialize)] 51 | /// Jump to the session with the given id. 52 | #[argh(subcommand, name = "jump")] 53 | pub struct JumpArgs { 54 | /// id of the session 55 | #[argh(positional)] 56 | pub id: String, 57 | } 58 | 59 | #[derive(FromArgs, Serialize, Deserialize)] 60 | /// Reload the config file. 61 | #[argh(subcommand, name = "reload")] 62 | pub struct ReloadArgs {} 63 | 64 | #[derive(FromArgs, Serialize, Deserialize)] 65 | /// Fetch timer information. 66 | #[argh(subcommand, name = "fetch")] 67 | pub struct FetchArgs { 68 | /// output format 69 | #[argh(positional)] 70 | pub format: String, 71 | } 72 | 73 | #[derive(FromArgs, Serialize, Deserialize)] 74 | /// Output time continuously, while remaining in sync with the main 'uair' instance. 75 | #[argh(subcommand, name = "listen")] 76 | pub struct ListenArgs { 77 | /// override to apply 78 | #[argh(option, short = 'o', long = "override")] 79 | pub overrid: Option, 80 | /// output time and exit listening instance immediately 81 | #[argh(switch, short = 'e')] 82 | pub exit: bool, 83 | } 84 | 85 | pub fn get_socket_path() -> String { 86 | if let Ok(xdg_runtime_dir) = env::var("XDG_RUNTIME_DIR") { 87 | xdg_runtime_dir + "/uair.sock" 88 | } else if let Ok(tmp_dir) = env::var("TMPDIR") { 89 | tmp_dir + "/uair.sock" 90 | } else { 91 | "/tmp/uair.sock".into() 92 | } 93 | } 94 | --------------------------------------------------------------------------------