├── .github └── workflows │ └── release.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── doc ├── examples │ ├── bookmarks.yml │ ├── i3ctl.yml │ ├── tydra-confirm │ └── tydra-popup ├── screenshot1.png ├── tydra-actions.5 ├── tydra-actions.5.md ├── tydra.1 └── tydra.1.md ├── generate-docs.sh ├── src ├── actions │ ├── action_file.rs │ ├── entry.rs │ ├── group.rs │ ├── mod.rs │ ├── page.rs │ ├── rendering.rs │ ├── settings.rs │ └── validator.rs ├── main.rs └── runner.rs └── tests └── fixtures ├── complex.yml ├── minimal.yml └── unknown_page.yml /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | package: 10 | runs-on: '${{ matrix.os }}' 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macOS-latest] #, windows-latest] 14 | include: 15 | - os: ubuntu-latest 16 | target: x86_64-unknown-linux-musl 17 | - os: macOS-latest 18 | target: x86_64-apple-darwin 19 | - os: windows-latest 20 | target: x86_64-pc-windows-msvc 21 | steps: 22 | - uses: hecrj/setup-rust-action@v1 23 | with: 24 | rust-version: stable 25 | - uses: actions/checkout@v2 26 | - name: Setup target 27 | run: rustup target add ${{ matrix.target }} 28 | - name: Install musl 29 | run: sudo apt-get install musl-tools 30 | if: contains(matrix.target, 'linux-musl') 31 | - name: Build in release configuration 32 | run: cargo build --target ${{ matrix.target }} ${{ matrix.flags }} --release --verbose 33 | - name: Strip binary 34 | run: strip 'target/${{ matrix.target }}/release/tydra' 35 | if: "!contains(matrix.target, 'windows')" 36 | - uses: olegtarasov/get-tag@v1 37 | - name: Build package 38 | id: package 39 | shell: bash 40 | run: | 41 | ARCHIVE_NAME="tydra-${GITHUB_TAG_NAME}-${{ matrix.target }}" 42 | if [[ '${{ matrix.target }}' == *windows* ]]; then 43 | ARCHIVE_FILE="${ARCHIVE_NAME}.zip" 44 | mv LICENSE LICENSE.txt 45 | 7z a "${ARCHIVE_FILE}" "./target/${{ matrix.target }}/release/tydra.exe" ./README.md ./CHANGELOG.md ./LICENSE.txt 46 | echo ::set-output "name=file::${ARCHIVE_FILE}" 47 | echo ::set-output "name=name::${ARCHIVE_NAME}.zip" 48 | else 49 | ARCHIVE_FILE="/tmp/${ARCHIVE_NAME}.tar.gz" 50 | mkdir "/tmp/${ARCHIVE_NAME}" 51 | cp README.md CHANGELOG.md LICENSE "target/${{ matrix.target }}/release/tydra" "/tmp/${ARCHIVE_NAME}" 52 | tar -czf "${ARCHIVE_FILE}" -C /tmp/ "${ARCHIVE_NAME}" 53 | echo ::set-output "name=file::${ARCHIVE_FILE}" 54 | echo ::set-output "name=name::${ARCHIVE_NAME}.tar.gz" 55 | fi 56 | - name: Upload package 57 | uses: svenstaro/upload-release-action@v1-release 58 | with: 59 | repo_token: ${{ secrets.GITHUB_TOKEN }} 60 | file: ${{ steps.package.outputs.file }} 61 | asset_name: ${{ steps.package.outputs.name }} 62 | tag: ${{ github.ref }} 63 | overwrite: true 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /target 3 | **/*.rs.bk 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | 3 | # Required for apt install, etc. below 4 | sudo: required 5 | 6 | rust: 7 | - stable 8 | - beta 9 | - nightly 10 | 11 | matrix: 12 | allow_failures: 13 | - rust: nightly 14 | fast_finish: true 15 | 16 | # Install addons for kcov / Codecov 17 | addons: 18 | apt: 19 | packages: 20 | - libcurl4-openssl-dev 21 | - libelf-dev 22 | - libdw-dev 23 | - cmake 24 | - gcc 25 | - binutils-dev 26 | - libiberty-dev 27 | 28 | cache: cargo 29 | 30 | # Upload code coverage 31 | # See https://github.com/codecov/example-rust 32 | after_success: | 33 | wget https://github.com/SimonKagstrom/kcov/archive/master.tar.gz && 34 | tar xzf master.tar.gz && 35 | cd kcov-master && 36 | mkdir build && 37 | cd build && 38 | cmake .. && 39 | make && 40 | make install DESTDIR=../../kcov-build && 41 | cd ../.. && 42 | rm -rf kcov-master && 43 | for file in target/debug/tydra-*[^\.d]; do mkdir -p "target/cov/$(basename $file)"; ./kcov-build/usr/local/bin/kcov --exclude-pattern=/.cargo,/usr/lib --verify "target/cov/$(basename $file)" "$file"; done && 44 | bash <(curl -s https://codecov.io/bash) && 45 | echo "Uploaded code coverage" 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.0.3] - 2022-06-07 11 | 12 | Updated some dependencies to work around build failures and security 13 | vulnerabilities. 14 | 15 | ## [1.0.2] - 2020-03-06 16 | 17 | ### Fixed 18 | 19 | * The `--generate-completions` option mistakenly still required a filename to 20 | an action file. 21 | 22 | ### Added 23 | 24 | * All shells supported by the `--generate-completions` command are now 25 | mentioned in the README. 26 | 27 | ## [1.0.1] - 2020-03-06 28 | 29 | ### Added 30 | 31 | * Some reference links in the documentation to Hydra and a YAML introduction. 32 | ([@ngirard](https://github.com/ngirard)) 33 | 34 | ### Fixed 35 | 36 | * Show correct version number in `man` pages. 37 | * Added `--generate-completions` to `man` page. 38 | 39 | ### Changed 40 | 41 | * Updated dependencies and to Rust 2018 edition. 42 | ([@ngirard](https://github.com/ngirard)) 43 | 44 | ## [1.0.0] - 2018-01-19 45 | 46 | Initial release. 47 | 48 | [Unreleased]: https://github.com/Mange/tydra/compare/v1.0.3...HEAD 49 | [1.0.2]: https://github.com/Mange/tydra/releases/tag/v1.0.3 50 | [1.0.2]: https://github.com/Mange/tydra/releases/tag/v1.0.2 51 | [1.0.1]: https://github.com/Mange/tydra/releases/tag/v1.0.1 52 | [1.0.0]: https://github.com/Mange/tydra/releases/tag/v1.0.0 53 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.17.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "ansi_term" 22 | version = "0.12.1" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 25 | dependencies = [ 26 | "winapi", 27 | ] 28 | 29 | [[package]] 30 | name = "atty" 31 | version = "0.2.14" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 34 | dependencies = [ 35 | "hermit-abi", 36 | "libc", 37 | "winapi", 38 | ] 39 | 40 | [[package]] 41 | name = "autocfg" 42 | version = "1.1.0" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 45 | 46 | [[package]] 47 | name = "backtrace" 48 | version = "0.3.65" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61" 51 | dependencies = [ 52 | "addr2line", 53 | "cc", 54 | "cfg-if 1.0.0", 55 | "libc", 56 | "miniz_oxide", 57 | "object", 58 | "rustc-demangle", 59 | ] 60 | 61 | [[package]] 62 | name = "bitflags" 63 | version = "1.3.2" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 66 | 67 | [[package]] 68 | name = "cassowary" 69 | version = "0.3.0" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 72 | 73 | [[package]] 74 | name = "cc" 75 | version = "1.0.73" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" 78 | 79 | [[package]] 80 | name = "cfg-if" 81 | version = "0.1.10" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 84 | 85 | [[package]] 86 | name = "cfg-if" 87 | version = "1.0.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 90 | 91 | [[package]] 92 | name = "clap" 93 | version = "2.34.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" 96 | dependencies = [ 97 | "ansi_term", 98 | "atty", 99 | "bitflags", 100 | "strsim", 101 | "textwrap", 102 | "unicode-width", 103 | "vec_map", 104 | ] 105 | 106 | [[package]] 107 | name = "either" 108 | version = "1.6.1" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 111 | 112 | [[package]] 113 | name = "failure" 114 | version = "0.1.8" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" 117 | dependencies = [ 118 | "backtrace", 119 | "failure_derive", 120 | ] 121 | 122 | [[package]] 123 | name = "failure_derive" 124 | version = "0.1.8" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" 127 | dependencies = [ 128 | "proc-macro2", 129 | "quote", 130 | "syn", 131 | "synstructure", 132 | ] 133 | 134 | [[package]] 135 | name = "gimli" 136 | version = "0.26.1" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" 139 | 140 | [[package]] 141 | name = "hashbrown" 142 | version = "0.11.2" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 145 | 146 | [[package]] 147 | name = "heck" 148 | version = "0.3.3" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 151 | dependencies = [ 152 | "unicode-segmentation", 153 | ] 154 | 155 | [[package]] 156 | name = "hermit-abi" 157 | version = "0.1.19" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 160 | dependencies = [ 161 | "libc", 162 | ] 163 | 164 | [[package]] 165 | name = "indexmap" 166 | version = "1.8.2" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" 169 | dependencies = [ 170 | "autocfg", 171 | "hashbrown", 172 | ] 173 | 174 | [[package]] 175 | name = "itertools" 176 | version = "0.7.11" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "0d47946d458e94a1b7bcabbf6521ea7c037062c81f534615abcad76e84d4970d" 179 | dependencies = [ 180 | "either", 181 | ] 182 | 183 | [[package]] 184 | name = "lazy_static" 185 | version = "1.4.0" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 188 | 189 | [[package]] 190 | name = "libc" 191 | version = "0.2.126" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" 194 | 195 | [[package]] 196 | name = "linked-hash-map" 197 | version = "0.5.4" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" 200 | 201 | [[package]] 202 | name = "log" 203 | version = "0.4.17" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 206 | dependencies = [ 207 | "cfg-if 1.0.0", 208 | ] 209 | 210 | [[package]] 211 | name = "memchr" 212 | version = "2.5.0" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 215 | 216 | [[package]] 217 | name = "miniz_oxide" 218 | version = "0.5.3" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" 221 | dependencies = [ 222 | "adler", 223 | ] 224 | 225 | [[package]] 226 | name = "nix" 227 | version = "0.17.0" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "50e4785f2c3b7589a0d0c1dd60285e1188adac4006e8abd6dd578e1567027363" 230 | dependencies = [ 231 | "bitflags", 232 | "cc", 233 | "cfg-if 0.1.10", 234 | "libc", 235 | "void", 236 | ] 237 | 238 | [[package]] 239 | name = "numtoa" 240 | version = "0.1.0" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 243 | 244 | [[package]] 245 | name = "object" 246 | version = "0.28.4" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "e42c982f2d955fac81dd7e1d0e1426a7d702acd9c98d19ab01083a6a0328c424" 249 | dependencies = [ 250 | "memchr", 251 | ] 252 | 253 | [[package]] 254 | name = "proc-macro-error" 255 | version = "1.0.4" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 258 | dependencies = [ 259 | "proc-macro-error-attr", 260 | "proc-macro2", 261 | "quote", 262 | "syn", 263 | "version_check", 264 | ] 265 | 266 | [[package]] 267 | name = "proc-macro-error-attr" 268 | version = "1.0.4" 269 | source = "registry+https://github.com/rust-lang/crates.io-index" 270 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 271 | dependencies = [ 272 | "proc-macro2", 273 | "quote", 274 | "version_check", 275 | ] 276 | 277 | [[package]] 278 | name = "proc-macro2" 279 | version = "1.0.39" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" 282 | dependencies = [ 283 | "unicode-ident", 284 | ] 285 | 286 | [[package]] 287 | name = "quote" 288 | version = "1.0.18" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" 291 | dependencies = [ 292 | "proc-macro2", 293 | ] 294 | 295 | [[package]] 296 | name = "redox_syscall" 297 | version = "0.2.13" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" 300 | dependencies = [ 301 | "bitflags", 302 | ] 303 | 304 | [[package]] 305 | name = "redox_termios" 306 | version = "0.1.2" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" 309 | dependencies = [ 310 | "redox_syscall", 311 | ] 312 | 313 | [[package]] 314 | name = "rustc-demangle" 315 | version = "0.1.21" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" 318 | 319 | [[package]] 320 | name = "ryu" 321 | version = "1.0.10" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" 324 | 325 | [[package]] 326 | name = "serde" 327 | version = "1.0.137" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" 330 | 331 | [[package]] 332 | name = "serde_derive" 333 | version = "1.0.137" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" 336 | dependencies = [ 337 | "proc-macro2", 338 | "quote", 339 | "syn", 340 | ] 341 | 342 | [[package]] 343 | name = "serde_yaml" 344 | version = "0.8.24" 345 | source = "registry+https://github.com/rust-lang/crates.io-index" 346 | checksum = "707d15895415db6628332b737c838b88c598522e4dc70647e59b72312924aebc" 347 | dependencies = [ 348 | "indexmap", 349 | "ryu", 350 | "serde", 351 | "yaml-rust", 352 | ] 353 | 354 | [[package]] 355 | name = "strsim" 356 | version = "0.8.0" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 359 | 360 | [[package]] 361 | name = "structopt" 362 | version = "0.3.26" 363 | source = "registry+https://github.com/rust-lang/crates.io-index" 364 | checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" 365 | dependencies = [ 366 | "clap", 367 | "lazy_static", 368 | "structopt-derive", 369 | ] 370 | 371 | [[package]] 372 | name = "structopt-derive" 373 | version = "0.4.18" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" 376 | dependencies = [ 377 | "heck", 378 | "proc-macro-error", 379 | "proc-macro2", 380 | "quote", 381 | "syn", 382 | ] 383 | 384 | [[package]] 385 | name = "syn" 386 | version = "1.0.96" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" 389 | dependencies = [ 390 | "proc-macro2", 391 | "quote", 392 | "unicode-ident", 393 | ] 394 | 395 | [[package]] 396 | name = "synstructure" 397 | version = "0.12.6" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" 400 | dependencies = [ 401 | "proc-macro2", 402 | "quote", 403 | "syn", 404 | "unicode-xid", 405 | ] 406 | 407 | [[package]] 408 | name = "termion" 409 | version = "1.5.6" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" 412 | dependencies = [ 413 | "libc", 414 | "numtoa", 415 | "redox_syscall", 416 | "redox_termios", 417 | ] 418 | 419 | [[package]] 420 | name = "textwrap" 421 | version = "0.11.0" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 424 | dependencies = [ 425 | "unicode-width", 426 | ] 427 | 428 | [[package]] 429 | name = "tui" 430 | version = "0.2.3" 431 | source = "registry+https://github.com/rust-lang/crates.io-index" 432 | checksum = "19c126c95f5c5320a77a477818e20e910ab708ea7a408c98ec03bd2754510029" 433 | dependencies = [ 434 | "bitflags", 435 | "cassowary", 436 | "itertools", 437 | "log", 438 | "termion", 439 | "unicode-segmentation", 440 | "unicode-width", 441 | ] 442 | 443 | [[package]] 444 | name = "tydra" 445 | version = "1.0.2" 446 | dependencies = [ 447 | "failure", 448 | "failure_derive", 449 | "nix", 450 | "quote", 451 | "serde", 452 | "serde_derive", 453 | "serde_yaml", 454 | "structopt", 455 | "termion", 456 | "tui", 457 | ] 458 | 459 | [[package]] 460 | name = "unicode-ident" 461 | version = "1.0.0" 462 | source = "registry+https://github.com/rust-lang/crates.io-index" 463 | checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" 464 | 465 | [[package]] 466 | name = "unicode-segmentation" 467 | version = "1.9.0" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" 470 | 471 | [[package]] 472 | name = "unicode-width" 473 | version = "0.1.9" 474 | source = "registry+https://github.com/rust-lang/crates.io-index" 475 | checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" 476 | 477 | [[package]] 478 | name = "unicode-xid" 479 | version = "0.2.3" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" 482 | 483 | [[package]] 484 | name = "vec_map" 485 | version = "0.8.2" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 488 | 489 | [[package]] 490 | name = "version_check" 491 | version = "0.9.4" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 494 | 495 | [[package]] 496 | name = "void" 497 | version = "1.0.2" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" 500 | 501 | [[package]] 502 | name = "winapi" 503 | version = "0.3.9" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 506 | dependencies = [ 507 | "winapi-i686-pc-windows-gnu", 508 | "winapi-x86_64-pc-windows-gnu", 509 | ] 510 | 511 | [[package]] 512 | name = "winapi-i686-pc-windows-gnu" 513 | version = "0.4.0" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 516 | 517 | [[package]] 518 | name = "winapi-x86_64-pc-windows-gnu" 519 | version = "0.4.0" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 522 | 523 | [[package]] 524 | name = "yaml-rust" 525 | version = "0.4.5" 526 | source = "registry+https://github.com/rust-lang/crates.io-index" 527 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 528 | dependencies = [ 529 | "linked-hash-map", 530 | ] 531 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tydra" 3 | description = "Shortcut menu-based task runner, inspired by Emacs Hydra" 4 | version = "1.0.3" 5 | authors = ["Magnus Bergmark "] 6 | edition = "2018" 7 | 8 | [dependencies] 9 | quote = "1.0.16" 10 | serde = "1.0.104" 11 | serde_yaml = "0.8.11" 12 | serde_derive = "1.0.104" 13 | structopt = "0.3" 14 | failure = "0.1.6" 15 | failure_derive = "0.1.6" 16 | tui = "0.2.3" 17 | termion = "1.5.5" 18 | nix = "0.17.0" 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Magnus Bergmark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | | | | 2 | |---|---| 3 | | ⚠️ | **NOTE:** This repository is now marked as read-only and has been officially abandoned due to lack of use and motivation to keep it going. | 4 | 5 | # tydra 6 | 7 | > Terminal Hydra 8 | 9 | Tydra is a menu-based shortcut runner based on the great [Hydra 10 | system](https://github.com/abo-abo/hydra) for Emacs by Oleh Krehel. 11 | 12 | It works by reading an "action file" that defines the full menu. Each menu has 13 | several pages, where one page at a time can be shown. Each page has one or more 14 | entries, each of which has a shortcut key, a command, and a label. 15 | 16 | With these building blocks you can build deeply nested menus with a diverse 17 | range of commands, all behind a very simple shortcut system. 18 | 19 | Contrast having to remember long commands (in terminal), or long complicated 20 | shortcuts (in GUI), with a single command/shortcut and then having a menu 21 | overlaid with each entry a single keystroke away. 22 | 23 | Tydra makes it easier to add new things to your setup without having to come up 24 | with a globally unique shortcut, while still being possible to remember it even 25 | when it is not used often. 26 | 27 | Some possible use-cases: 28 | 29 | * Control your media player. 30 | * Change your screen brightness and volume without RSI. 31 | * Bookmark programs with specific arguments, or websites. 32 | * Keep track of commonly used "recipes" and scripts. 33 | 34 | [![](doc/screenshot1.png)](doc/screenshot1.png) 35 | 36 | ## Usage 37 | 38 | See [doc/tydra.1.md](doc/tydra.1.md) for more information. 39 | 40 | **Note:** If you install through the AUR, then this documentation is also 41 | availabe as `man` pages `tydra(1)` and `tydra-actions(5)`. 42 | 43 | ## Installing 44 | 45 | 46 | Packaging status 47 | 48 | 49 | This package is available through Arch Linux's AUR repository as `tydra`. You 50 | may also compile it from source by downloading the source code and install it 51 | using `cargo` (Rust's build system and package manager): 52 | 53 | ```bash 54 | cargo install 55 | ``` 56 | 57 | ### Completions 58 | 59 | This command comes with support for shell autocompletions for **bash**, 60 | **zsh**, **fish**, **powershell**, and **elvish**. 61 | 62 | **Note:** If you install through the AUR, then most of these completions are 63 | already installed for you automatically. 64 | 65 | You can generate and install the common completions globally: 66 | 67 | ```bash 68 | tydra --generate-completions zsh > _tydra 69 | tydra --generate-completions bash > tydra.bash 70 | tydra --generate-completions fish > tydra.fish 71 | 72 | sudo install -Dm644 _tydra \ 73 | /usr/share/zsh/site-functions/_tydra 74 | 75 | sudo install -Dm644 tydra.bash \ 76 | /usr/share/bash-completion/completions/tydra 77 | 78 | sudo install -Dm644 tydra.fish \ 79 | /usr/share/fish/completions/tydra.fish 80 | ``` 81 | 82 | If you have a local source for completions, redirect the output of the 83 | `--generate-completions` command to the appropriate location. 84 | 85 | ## Copyright 86 | 87 | Copyright 2018 Magnus Bergmark 88 | 89 | Code is released under MIT license, see `LICENSE`. 90 | -------------------------------------------------------------------------------- /doc/examples/bookmarks.yml: -------------------------------------------------------------------------------- 1 | # This is an example showing how to present bookmarks in tydra. 2 | # 3 | # The repeated "mode: background" causes the command to run without a 4 | # connection to this process or terminal, effectively detaching from the 5 | # terminal. The assumption being that `xdg-open` would spawn an X app. If you 6 | # are using your terminal for these bookmarks, the "exec" mode is a better 7 | # choice. 8 | # "mode: background" is not needed for most web browsers as they usually fork 9 | # or signal an already running browser to open a new tab, but a lot of file 10 | # manager do not fork like that and would keep the terminal open while you 11 | # browsed files. 12 | # 13 | pages: 14 | root: 15 | title: Bookmarks 16 | settings: 17 | layout: columns 18 | shortcut_color: blue 19 | groups: 20 | - title: Favorites 21 | entries: 22 | - shortcut: 1 23 | title: Google 24 | command: xdg-open "https://google.com" 25 | mode: background 26 | - shortcut: 2 27 | title: Youtube 28 | command: xdg-open "https://youtube.com" 29 | mode: background 30 | - shortcut: 3 31 | title: GitHub 32 | command: xdg-open "https://github.com" 33 | mode: background 34 | - shortcut: 4 35 | title: Downloads 36 | command: xdg-open "$HOME/Downloads" 37 | mode: background 38 | - title: Others 39 | settings: 40 | shortcut_color: green 41 | entries: 42 | - shortcut: f 43 | title: File system 44 | return: fs 45 | - shortcut: w 46 | title: Web 47 | return: web 48 | 49 | fs: 50 | title: File system bookmarks 51 | groups: 52 | - entries: 53 | - shortcut: h 54 | title: Home 55 | command: xdg-open "$HOME" 56 | mode: background 57 | - shortcut: d 58 | title: Downloads 59 | command: xdg-open "$HOME/Downloads" 60 | mode: background 61 | - shortcut: D 62 | title: Documents 63 | command: xdg-open "$HOME/Documents" 64 | mode: background 65 | 66 | web: 67 | title: Web bookmarks 68 | groups: 69 | - entries: 70 | - shortcut: y 71 | title: YouTube 72 | command: xdg-open "https://youtube.com" 73 | mode: background 74 | - shortcut: g 75 | title: Google 76 | command: xdg-open "https://google.com" 77 | mode: background 78 | - shortcut: h 79 | title: GitHub 80 | command: xdg-open "https://github.com" 81 | mode: background 82 | - title: Reddit 83 | entries: 84 | - shortcut: f 85 | title: Frontpage 86 | command: xdg-open "https://reddit.com" 87 | mode: background 88 | - shortcut: u 89 | title: r/unixporn 90 | command: xdg-open "https://reddit.com/r/unixporn" 91 | mode: background 92 | - shortcut: m 93 | title: r/linuxmemes 94 | command: xdg-open "https://reddit.com/r/linuxmemes" 95 | mode: background 96 | -------------------------------------------------------------------------------- /doc/examples/i3ctl.yml: -------------------------------------------------------------------------------- 1 | # This example file is for controlling i3. 2 | 3 | pages: 4 | root: 5 | title: i3 control 6 | settings: 7 | layout: columns 8 | shortcut_color: green 9 | groups: 10 | - title: Environment 11 | entries: 12 | - shortcut: r 13 | title: Reload config 14 | command: i3-msg reload 15 | - shortcut: R 16 | title: Restart i3 17 | command: i3-msg restart 18 | shortcut_color: red 19 | # If you are running i3-gaps. Will crash on normal i3. 20 | - title: Gaps 21 | entries: 22 | - shortcut: "=" 23 | title: Increase inner 24 | mode: background 25 | return: true 26 | shortcut_color: yellow 27 | command: i3-msg gaps inner current plus 5 28 | 29 | - shortcut: "-" 30 | title: Decrease inner 31 | mode: background 32 | return: true 33 | shortcut_color: yellow 34 | command: i3-msg gaps inner current minus 5 35 | 36 | - shortcut: "0" 37 | title: Reset inner 38 | mode: background 39 | return: true 40 | shortcut_color: yellow 41 | command: i3-msg gaps inner current set 10 42 | 43 | - shortcut: k 44 | title: Increase outer 45 | mode: background 46 | return: true 47 | shortcut_color: cyan 48 | command: i3-msg gaps outer current plus 5 49 | 50 | - shortcut: j 51 | title: Decrease outer 52 | mode: background 53 | return: true 54 | shortcut_color: cyan 55 | command: i3-msg gaps outer current minus 5 56 | 57 | - shortcut: h 58 | title: Reset outer 59 | mode: background 60 | return: true 61 | shortcut_color: cyan 62 | command: i3-msg gaps outer current set 0 63 | - entries: 64 | - shortcut: q 65 | title: Quit 66 | shortcut_color: green 67 | return: false 68 | -------------------------------------------------------------------------------- /doc/examples/tydra-confirm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use tydra to confirm something. 3 | # 4 | # Example: 5 | # if tydra-confirm "Really kiss them?"; then 6 | # # ... 7 | # else 8 | # # ... 9 | # fi 10 | 11 | if [[ $1 == "--help" ]]; then 12 | cat < /dev/null; then 33 | echo "Cannot find tydra in your PATH" > /dev/stderr 34 | exit 2 35 | fi 36 | 37 | default=1 38 | if [[ $1 == "--default-yes" ]]; then 39 | shift 40 | default=0 41 | fi 42 | 43 | if [[ $# -ge 1 ]]; then 44 | message="$*" 45 | else 46 | message="Do you want to proceed?" 47 | fi 48 | 49 | # Run tydra, where each option will exit with a specific number. Exit 0 if when 50 | # escape was picked and it and no selection happened. 51 | tydra <(cat </dev/null; then 5 | exec kitty tydra "$@" 6 | elif hash termite 2>/dev/null; then 7 | exec termite -e "tydra $(printf '%q ' "$@")" 8 | elif hash gnome-terminal 2>/dev/null; then 9 | exec gnome-terminal -e "tydra $(printf '%q ' "$@")" 10 | else 11 | echo "Please add support for your terminal emulator..." > /dev/stderr 12 | exit 2 13 | fi 14 | -------------------------------------------------------------------------------- /doc/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mange/tydra/12a96f525fb792d795f6d5bf6ab1c069d7d87118/doc/screenshot1.png -------------------------------------------------------------------------------- /doc/tydra-actions.5: -------------------------------------------------------------------------------- 1 | .\" Automatically generated by Pandoc 2.9.2 2 | .\" 3 | .TH "TYDRA-ACTIONS" "5" "March 2020" "" "Version 1.0.2" 4 | .hy 5 | .SH NAME 6 | .PP 7 | tydra-actions \[en] Tydra action file reference. 8 | .SH SYNOPSIS 9 | .PP 10 | Tydra action files are stored in YAML files. 11 | It is recommended that you read up on YAML so you understand the syntax, 12 | such as \[lq]Learn YAML in five 13 | minutes (https://www.codeproject.com/Articles/1214409/Learn-YAML-in-five-minutes)\[rq]. 14 | .PP 15 | Particular areas that might give you issues unless you understand them 16 | include: 17 | .IP \[bu] 2 18 | Some strings needing quotes, while others do not. 19 | .IP \[bu] 2 20 | Lists of maps and indentation rules for them. 21 | .PP 22 | The main outline of the file can be illustrated using this small 23 | example: 24 | .IP 25 | .nf 26 | \f[C] 27 | global: 28 | layout: columns 29 | pages: 30 | root: 31 | groups: 32 | - entries: 33 | - shortcut: a 34 | title: Print a 35 | command: echo a 36 | \f[R] 37 | .fi 38 | .TP 39 | \f[B]global\f[R] (optional) 40 | See \f[B]GLOBAL SETTINGS\f[R] below. 41 | .TP 42 | \f[B]pages\f[R] (required) 43 | A map of pages, where the name of the page is the key and the value is 44 | the page specification. 45 | See \f[B]PAGE\f[R] below. 46 | .SS GLOBAL SETTINGS 47 | .PP 48 | The global settings allows you to set configuration that applies by 49 | default to pages / groups / entries. 50 | You don\[cq]t need to provide any settings if you don\[cq]t want to. 51 | .TP 52 | \f[B]layout\f[R] (optional) 53 | Sets the default layout for pages that don\[cq]t override it. 54 | Allowed values are \f[I]columns\f[R] and \f[I]list\f[R]. 55 | .TP 56 | \f[B]shortcut_color\f[R] (optional) 57 | Sets the color to use when rendering the shortcut key. 58 | Allowed values are \f[I]reset\f[R], \f[I]black\f[R], \f[I]blue\f[R], 59 | \f[I]cyan\f[R], \f[I]green\f[R], \f[I]magenta\f[R], \f[I]red\f[R], 60 | \f[I]white\f[R], and \f[I]yellow\f[R]. 61 | .SS PAGE 62 | .PP 63 | Pages contains groups of entries (see \f[B]GROUP\f[R]) and some 64 | additional settings and texts. 65 | The groups will be rendered in the \f[I]layout\f[R] set in the settings. 66 | .TP 67 | \f[B]title\f[R] (optional) 68 | The title of the page. 69 | .TP 70 | \f[B]header\f[R] (optional) 71 | Introduction text before showing the entries. 72 | You could explain the page here. 73 | .TP 74 | \f[B]footer\f[R] (optional) 75 | Text after showing the entries. 76 | You could place notices or some other information about the page here. 77 | .TP 78 | \f[B]settings\f[R] (optional) 79 | The same collection of settings that are inside the \f[B]GLOBAL 80 | SETTINGS\f[R], only here they only apply to the current page instead. 81 | If this is not provided, then the global settings will be used as-is. 82 | .TP 83 | \f[B]groups\f[R] (required) 84 | A list of groups. 85 | See \f[B]GROUPS\f[R]. 86 | .SS GROUPS 87 | .PP 88 | Groups is a single grouping of menu entries (see \f[B]ENTRY\f[R]) along 89 | with some additional metadata and settings. 90 | .TP 91 | \f[B]title\f[R] (optional) 92 | The title of this group. 93 | .TP 94 | \f[B]settings\f[R] (optional) 95 | The same settings as \f[B]GLOBAL SETTINGS\f[R], but only the settings 96 | that affect entries will be taken into account. 97 | Settings not provided here will be inherited from the parent 98 | \f[B]page\f[R], then the global settings. 99 | .TP 100 | \f[B]entries\f[R] (required) 101 | A list of entries that should be inside this group. 102 | See \f[B]ENTRY\f[R]. 103 | .SS ENTRY 104 | .PP 105 | Entries are the thing that you select in menus. 106 | They have a lot of things to customize. 107 | .TP 108 | \f[B]title\f[R] (required) 109 | The title of the entry. 110 | This is the text that you\[cq]ll see on the screen. 111 | .TP 112 | \f[B]shortcut\f[R] (required) 113 | A single character that will be used to trigger this entry. 114 | For example \f[I]a\f[R] would mean that the entry is selected by 115 | pressing \f[I]a\f[R] on your keyboard. 116 | Shortcuts must be unique for a single \f[B]page\f[R] or else you will 117 | get a validation error. 118 | .TP 119 | \f[B]command\f[R] (optional) 120 | The command to execute when triggering this entry. 121 | It is optional because sometimes you want entries to navigate to 122 | different pages, and in those cases you do not need a command. 123 | See \f[B]return\f[R]. 124 | Commands can be given in two formats. 125 | Either as a single string, which means that it will be run as a 126 | shell-script to \f[I]/bin/sh\f[R], or as a structure with a 127 | \f[I]name\f[R] and \f[I]args\f[R] key. 128 | .IP 129 | .nf 130 | \f[C] 131 | command: xdg-open https://example.net 132 | 133 | command: | 134 | if [[ -f \[ti]/.local/bookmarks.html ]]; then 135 | xdg-open \[ti]/.local/bookmarks.html 136 | fi 137 | 138 | command: 139 | name: xdg-open 140 | args: 141 | - \[dq]https://example.net\[dq] 142 | \f[R] 143 | .fi 144 | .TP 145 | \f[B]shortcut_color\f[R] (optional) 146 | Sets the color for the shortcut character when shown to the user. 147 | Will be taken from the \f[B]settings\f[R] in the parent \f[B]GROUP\f[R], 148 | then \f[B]PAGE\f[R], and lastly the \f[B]GLOBAL SETTING\f[R] if not 149 | provided before then. 150 | See \f[B]GLOBAL SETTINGS\f[R] for a complete list of supported colors. 151 | .TP 152 | \f[B]mode\f[R] (optional) 153 | Instructs tydra on how to run the command. 154 | Supported values are \f[I]normal\f[R], \f[I]wait\f[R], \f[I]exec\f[R] 155 | and \f[I]background\f[R]. 156 | .TP 157 | \f[I]normal\f[R] (default) 158 | This mode pauses tydra and runs the \f[B]command\f[R] in your normal 159 | terminal screen. 160 | .TP 161 | \f[I]wait\f[R] 162 | This is identical to \f[I]normal\f[R], but waits for the user to press 163 | \f[I]enter\f[R] before continuing after the command has finished. 164 | This is useful for entries that are meant to resume tydra after being 165 | run (see \f[B]return\f[R]) where the user want to see the output before 166 | continuing. 167 | .TP 168 | \f[I]exec\f[R] 169 | This mode replaces tydra with the new command. 170 | This is great if you don\[cq]t want to resume tydra after the command 171 | (see \f[B]return\f[R]) or if the process is long-running as 172 | \f[I]exec\f[R] prevents tydra from being the process parent. 173 | .TP 174 | \f[I]background\f[R] 175 | Runs the command in the background, disconnected from tydra. 176 | Depending on \f[B]return\f[R] tydra either resumes immediately, or exits 177 | immediately. 178 | This is great for spawning GUI applications, or to run commands that do 179 | not require feedback (like increasing volume). 180 | .TP 181 | \f[B]return\f[R] (optional) 182 | Sets the return mode of the entry. 183 | Allowed values are \f[I]false\f[R], \f[I]true\f[R], or the name of 184 | another page. 185 | Note that when (or if at all) tydra returns depend on the \f[B]mode\f[R] 186 | of the entry; if you \f[I]exec\f[R] the command tydra cannot return 187 | after it. 188 | If you run the command in \f[I]background\f[R], then this return action 189 | will be taken immediately, but both \f[I]normal\f[R] and \f[I]wait\f[R] 190 | will only perform it after the command finishes. 191 | If tydra does not run with the \f[B]--ignore-exit-status\f[R] option, 192 | then a failing command will also exit tydra. 193 | This is to help you find bugs in your scripts. 194 | .TP 195 | \f[I]true\f[R] 196 | Return to the same page again after the command runs. 197 | .TP 198 | \f[I]false\f[R] 199 | Exit tydra after the command runs. 200 | .TP 201 | \f[I]Another page\[cq]s name\f[R] 202 | Return to this page after the command runs. 203 | If the page name cannot be found in the action file, you will get a 204 | validation error. 205 | .SH EXAMPLES 206 | .PP 207 | Examples are not currently provided. 208 | .SH AUTHORS 209 | Magnus Bergmark . 210 | -------------------------------------------------------------------------------- /doc/tydra-actions.5.md: -------------------------------------------------------------------------------- 1 | % TYDRA-ACTIONS(5) | Version 1.0.2 2 | % Magnus Bergmark 3 | % March 2020 4 | 5 | # NAME 6 | 7 | tydra-actions -- Tydra action file reference. 8 | 9 | # SYNOPSIS 10 | 11 | Tydra action files are stored in YAML files. It is recommended that you read up 12 | on YAML so you understand the syntax, such as "[Learn YAML in five minutes](https://www.codeproject.com/Articles/1214409/Learn-YAML-in-five-minutes)". 13 | 14 | Particular areas that might give you issues unless you understand them include: 15 | 16 | * Some strings needing quotes, while others do not. 17 | * Lists of maps and indentation rules for them. 18 | 19 | The main outline of the file can be illustrated using this small example: 20 | 21 | ```yaml 22 | global: 23 | layout: columns 24 | pages: 25 | root: 26 | groups: 27 | - entries: 28 | - shortcut: a 29 | title: Print a 30 | command: echo a 31 | ``` 32 | 33 | **global** (optional) 34 | 35 | : See **GLOBAL SETTINGS** below. 36 | 37 | **pages** (required) 38 | 39 | : A map of pages, where the name of the page is the key and the value is the 40 | page specification. See **PAGE** below. 41 | 42 | ## GLOBAL SETTINGS 43 | 44 | The global settings allows you to set configuration that applies by default to 45 | pages / groups / entries. You don't need to provide any settings if you don't 46 | want to. 47 | 48 | **layout** (optional) 49 | 50 | : Sets the default layout for pages that don't override it. Allowed values are 51 | *columns* and *list*. 52 | 53 | **shortcut_color** (optional) 54 | 55 | : Sets the color to use when rendering the shortcut key. Allowed values are 56 | *reset*, *black*, *blue*, *cyan*, *green*, *magenta*, *red*, *white*, and 57 | *yellow*. 58 | 59 | ## PAGE 60 | 61 | Pages contains groups of entries (see **GROUP**) and some additional settings 62 | and texts. The groups will be rendered in the *layout* set in the settings. 63 | 64 | **title** (optional) 65 | 66 | : The title of the page. 67 | 68 | **header** (optional) 69 | 70 | : Introduction text before showing the entries. You could explain the page here. 71 | 72 | **footer** (optional) 73 | 74 | : Text after showing the entries. You could place notices or some other 75 | information about the page here. 76 | 77 | **settings** (optional) 78 | 79 | : The same collection of settings that are inside the **GLOBAL SETTINGS**, only 80 | here they only apply to the current page instead. If this is not provided, then 81 | the global settings will be used as-is. 82 | 83 | **groups** (required) 84 | 85 | : A list of groups. See **GROUPS**. 86 | 87 | ## GROUPS 88 | 89 | Groups is a single grouping of menu entries (see **ENTRY**) along with some 90 | additional metadata and settings. 91 | 92 | **title** (optional) 93 | 94 | : The title of this group. 95 | 96 | **settings** (optional) 97 | 98 | : The same settings as **GLOBAL SETTINGS**, but only the settings that affect 99 | entries will be taken into account. Settings not provided here will be 100 | inherited from the parent **page**, then the global settings. 101 | 102 | **entries** (required) 103 | 104 | : A list of entries that should be inside this group. See **ENTRY**. 105 | 106 | ## ENTRY 107 | 108 | Entries are the thing that you select in menus. They have a lot of things to 109 | customize. 110 | 111 | **title** (required) 112 | 113 | : The title of the entry. This is the text that you'll see on the screen. 114 | 115 | **shortcut** (required) 116 | 117 | : A single character that will be used to trigger this entry. For example *a* 118 | would mean that the entry is selected by pressing *a* on your keyboard. 119 | Shortcuts must be unique for a single **page** or else you will get a 120 | validation error. 121 | 122 | **command** (optional) 123 | 124 | : The command to execute when triggering this entry. It is optional because 125 | sometimes you want entries to navigate to different pages, and in those cases 126 | you do not need a command. See **return**. 127 | Commands can be given in two formats. Either as a single string, which means 128 | that it will be run as a shell-script to */bin/sh*, or as a structure with a 129 | *name* and *args* key. 130 | 131 | ```yaml 132 | command: xdg-open https://example.net 133 | 134 | command: | 135 | if [[ -f ~/.local/bookmarks.html ]]; then 136 | xdg-open ~/.local/bookmarks.html 137 | fi 138 | 139 | command: 140 | name: xdg-open 141 | args: 142 | - "https://example.net" 143 | ``` 144 | 145 | **shortcut_color** (optional) 146 | 147 | : Sets the color for the shortcut character when shown to the user. Will be 148 | taken from the **settings** in the parent **GROUP**, then **PAGE**, and lastly 149 | the **GLOBAL SETTING** if not provided before then. See **GLOBAL SETTINGS** for 150 | a complete list of supported colors. 151 | 152 | **mode** (optional) 153 | 154 | : Instructs tydra on how to run the command. Supported values are *normal*, 155 | *wait*, *exec* and *background*. 156 | 157 | *normal* (default) 158 | 159 | : This mode pauses tydra and runs the **command** in your normal terminal screen. 160 | 161 | *wait* 162 | 163 | : This is identical to *normal*, but waits for the user to press *enter* before 164 | continuing after the command has finished. This is useful for entries that are 165 | meant to resume tydra after being run (see **return**) where the user want to 166 | see the output before continuing. 167 | 168 | *exec* 169 | 170 | : This mode replaces tydra with the new command. This is great if you don't 171 | want to resume tydra after the command (see **return**) or if the process is 172 | long-running as *exec* prevents tydra from being the process parent. 173 | 174 | *background* 175 | 176 | : Runs the command in the background, disconnected from tydra. Depending on 177 | **return** tydra either resumes immediately, or exits immediately. This is 178 | great for spawning GUI applications, or to run commands that do not require 179 | feedback (like increasing volume). 180 | 181 | **return** (optional) 182 | 183 | : Sets the return mode of the entry. Allowed values are *false*, *true*, or the 184 | name of another page. Note that when (or if at all) tydra returns depend on the 185 | **mode** of the entry; if you *exec* the command tydra cannot return after it. 186 | If you run the command in *background*, then this return action will be taken 187 | immediately, but both *normal* and *wait* will only perform it after the 188 | command finishes. If tydra does not run with the **\--ignore-exit-status** 189 | option, then a failing command will also exit tydra. This is to help you find 190 | bugs in your scripts. 191 | 192 | *true* 193 | 194 | : Return to the same page again after the command runs. 195 | 196 | *false* 197 | 198 | : Exit tydra after the command runs. 199 | 200 | *Another page's name* 201 | 202 | : Return to this page after the command runs. If the page name cannot be found 203 | in the action file, you will get a validation error. 204 | 205 | # EXAMPLES 206 | 207 | Examples are not currently provided. 208 | -------------------------------------------------------------------------------- /doc/tydra.1: -------------------------------------------------------------------------------- 1 | .\" Automatically generated by Pandoc 2.9.2 2 | .\" 3 | .TH "TYDRA" "1" "March 2020" "" "Version 1.0.2" 4 | .hy 5 | .SH NAME 6 | .PP 7 | tydra \[en] Shortcut menu-based task runner, inspired by Emacs Hydra 8 | .SH SYNOPSIS 9 | .PP 10 | \f[B]tydra\f[R] [\f[I]-e\f[R]|\f[I]--ignore-exit-status\f[R]] [\f[I]-p 11 | NAME\f[R]|\f[I]--page NAME\f[R]] <\f[I]ACTION_FILE\f[R]> 12 | .PD 0 13 | .P 14 | .PD 15 | \f[B]tydra\f[R] [\f[I]-p NAME\f[R]|\f[I]--page NAME\f[R]] 16 | \f[I]--validate\f[R] <\f[I]ACTION_FILE\f[R]> 17 | .PD 0 18 | .P 19 | .PD 20 | \f[B]tydra\f[R] \f[I]--help\f[R] 21 | .PD 0 22 | .P 23 | .PD 24 | \f[B]tydra\f[R] \f[I]--version\f[R] 25 | .PD 0 26 | .P 27 | .PD 28 | \f[B]tydra\f[R] \f[I]--generate-completions\f[R] <\f[I]SHELL\f[R]> 29 | .SH DESCRIPTION 30 | .PP 31 | Tydra is a menu-based shortcut runner based on the Hydra system in 32 | Emacs. 33 | .PP 34 | It works by reading an \[lq]action file\[rq] that defines the full menu. 35 | Each menu has several pages, where one page at a time can be shown. 36 | Each page has one or more entries, each of which has a shortcut key, a 37 | command, and a label. 38 | .PP 39 | With these building blocks you can build deeply nested menus with a 40 | diverse range of commands, all behind a very simple shortcut system. 41 | .PP 42 | Contrast having to remember long commands (in terminal), or long 43 | complicated shortcuts (in GUI), with a single command/shortcut and then 44 | having a menu overlaid with each entry a single keystroke away. 45 | .PP 46 | Tydra makes it easier to add new things to your setup without having to 47 | come up with a globally unique shortcut, while still being possible to 48 | remember it even when it is not used often. 49 | .PP 50 | Some possible use-cases: 51 | .IP \[bu] 2 52 | Control your media player. 53 | .IP \[bu] 2 54 | Change your screen brightness and volume without RSI. 55 | .IP \[bu] 2 56 | Bookmark programs with specific arguments, or websites. 57 | .IP \[bu] 2 58 | Keep track of commonly used \[lq]recipes\[rq] and scripts. 59 | .SS OPTIONS 60 | .TP 61 | \f[B]-h\f[R], \f[B]--help\f[R] 62 | Prints quick reference of options. 63 | .TP 64 | \f[B]-e\f[R], \f[B]--ignore-exit-status\f[R] 65 | Do not exit Tydra when a command fails. 66 | .TP 67 | \f[B]--validate\f[R] 68 | Instead of running the menu, exit with exit status \f[I]0\f[R] if the 69 | provided menu file is valid. 70 | If it is not valid, all validation errors will be shown on 71 | \f[I]stderr\f[R] and the program will exit with a non-zero status code. 72 | .TP 73 | \f[B]--version\f[R] 74 | Show the version of the process and exit. 75 | .TP 76 | \f[B]-p\f[R] \f[I]NAME\f[R], \f[B]--page\f[R] \f[I]NAME\f[R] 77 | Start on the page with the provided \f[I]NAME\f[R]. 78 | Defaults to \f[I]root\f[R] if not specified. 79 | Note that \f[B]--validate\f[R] will take this into account too. 80 | .TP 81 | \f[B]--generate-completions\f[R] \f[I]SHELL\f[R] 82 | Generate a completion script for the given shell, print it to standard 83 | out, and exit. 84 | This will ignore any other options. 85 | You can find a list of supported shells in the \f[B]--help\f[R] output. 86 | .SH SEE ALSO 87 | .TP 88 | \f[B]tydra-actions(5)\f[R] 89 | Reference for the action file format, including examples. 90 | .SH AUTHORS 91 | Magnus Bergmark . 92 | -------------------------------------------------------------------------------- /doc/tydra.1.md: -------------------------------------------------------------------------------- 1 | % TYDRA(1) | Version 1.0.2 2 | % Magnus Bergmark 3 | % March 2020 4 | 5 | # NAME 6 | 7 | tydra -- Shortcut menu-based task runner, inspired by Emacs Hydra 8 | 9 | # SYNOPSIS 10 | 11 | | **tydra** \[*-e*|*\--ignore-exit-status*\] \[*-p NAME*|*\--page NAME*\] <*ACTION_FILE*> 12 | | **tydra** \[*-p NAME*|*\--page NAME*\] *\--validate* <*ACTION_FILE*> 13 | | **tydra** *\--help* 14 | | **tydra** *\--version* 15 | | **tydra** *\--generate-completions* <*SHELL*> 16 | 17 | # DESCRIPTION 18 | 19 | Tydra is a menu-based shortcut runner based on the Hydra system in Emacs. 20 | 21 | It works by reading an "action file" that defines the full menu. Each menu has 22 | several pages, where one page at a time can be shown. Each page has one or more 23 | entries, each of which has a shortcut key, a command, and a label. 24 | 25 | With these building blocks you can build deeply nested menus with a diverse 26 | range of commands, all behind a very simple shortcut system. 27 | 28 | Contrast having to remember long commands (in terminal), or long complicated 29 | shortcuts (in GUI), with a single command/shortcut and then having a menu 30 | overlaid with each entry a single keystroke away. 31 | 32 | Tydra makes it easier to add new things to your setup without having to come up 33 | with a globally unique shortcut, while still being possible to remember it even 34 | when it is not used often. 35 | 36 | Some possible use-cases: 37 | 38 | * Control your media player. 39 | * Change your screen brightness and volume without RSI. 40 | * Bookmark programs with specific arguments, or websites. 41 | * Keep track of commonly used "recipes" and scripts. 42 | 43 | ## OPTIONS 44 | 45 | **-h**, **\--help** 46 | 47 | : Prints quick reference of options. 48 | 49 | **-e**, **\--ignore-exit-status** 50 | 51 | : Do not exit Tydra when a command fails. 52 | 53 | **\--validate** 54 | 55 | : Instead of running the menu, exit with exit status *0* if the provided menu 56 | file is valid. If it is not valid, all validation errors will be shown on 57 | *stderr* and the program will exit with a non-zero status code. 58 | 59 | **\--version** 60 | 61 | : Show the version of the process and exit. 62 | 63 | **-p** *NAME*, **\--page** *NAME* 64 | 65 | : Start on the page with the provided *NAME*. Defaults to *root* if not 66 | specified. Note that **\--validate** will take this into account too. 67 | 68 | **\--generate-completions** *SHELL* 69 | 70 | : Generate a completion script for the given shell, print it to standard out, 71 | and exit. This will ignore any other options. You can find a list of supported 72 | shells in the **\--help** output. 73 | 74 | 75 | # SEE ALSO 76 | 77 | **tydra-actions(5)** 78 | 79 | : Reference for the action file format, including examples. 80 | -------------------------------------------------------------------------------- /generate-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Check for dependencies 5 | if ! hash pandoc 2>/dev/null; then 6 | echo "You need to have pandoc installed!" > /dev/stderr 7 | exit 1 8 | fi 9 | 10 | if ! hash cargo 2>/dev/null; then 11 | echo "You need to have cargo installed!" > /dev/stderr 12 | exit 1 13 | fi 14 | 15 | man_pages=(tydra.1.md tydra-actions.5.md) 16 | current_version="$( 17 | grep -oE --max-count=1 "version = \"[0-9.]+\"" Cargo.toml | cut -d " " -f 3 | tr -d '"' 18 | )" 19 | 20 | # Lint the documentation a bit 21 | 22 | ## Check version numbers 23 | lint-version-number() { 24 | local file="doc/$1" 25 | local actual_version 26 | actual_version="$( 27 | grep -oE --max-count=1 "Version [0-9.]+" "$file" | cut -d " " -f 2 28 | )" 29 | 30 | if [[ "$current_version" != "$actual_version" ]]; then 31 | echo "Error: Expected version in $file to be $current_version but was $actual_version" > /dev/stderr 32 | exit 1 33 | fi 34 | } 35 | 36 | # Check that all options seem to be in there 37 | match-only-options() { 38 | LC_LANG=C grep -Eo -- "-(-[a-z-]+|[a-z]\\b)" "$@" 39 | } 40 | 41 | lint-options-present() { 42 | local file="doc/tydra.1.md" 43 | local actual_options 44 | local expected_options 45 | actual_options="$(match-only-options "$file" | sort --unique)" 46 | expected_options="$(cargo run -- --help 2>/dev/null | match-only-options | sort --unique)" 47 | 48 | if [[ "$actual_options" != "$expected_options" ]]; then 49 | ( 50 | echo "Seems like the options do not match." 51 | echo "This was documented, but not part of the --help output:" 52 | comm -13 <(echo "$expected_options") <(echo "$actual_options") 53 | echo "This was not documented, despite being part of the --help output:" 54 | comm -23 <(echo "$expected_options") <(echo "$actual_options") 55 | ) > /dev/stderr 56 | exit 1 57 | fi 58 | } 59 | 60 | lint-options-present 61 | for file in "${man_pages[@]}"; do 62 | echo "Linting $file" 63 | lint-version-number "$file" 64 | done 65 | 66 | # Generate man pages 67 | for input_file in "${man_pages[@]}"; do 68 | output_file="${input_file%.md}" 69 | if [[ "$input_file" == "$output_file" ]]; then 70 | echo "ABORTING! Input and output file would be the same! $input_file" > /dev/stderr 71 | exit 1 72 | fi 73 | echo "Generating $output_file" 74 | pandoc --standalone --to=man "doc/$input_file" > "doc/$output_file" 75 | done 76 | -------------------------------------------------------------------------------- /src/actions/action_file.rs: -------------------------------------------------------------------------------- 1 | use super::{validator, Page, Settings, SettingsAccumulator, ValidationError}; 2 | use std::collections::BTreeMap; 3 | use crate::AppOptions; 4 | 5 | #[derive(Debug, Deserialize)] 6 | #[serde(deny_unknown_fields)] 7 | pub struct ActionFile { 8 | #[serde(rename = "global", default = "Settings::default")] 9 | global_settings: Settings, 10 | pages: BTreeMap, // BTreeMap so order is preserved; helps with validation logic, etc. 11 | } 12 | 13 | impl ActionFile { 14 | pub fn pages_with_names(&self) -> impl Iterator { 15 | self.pages.iter().map(|(name, page)| (page, name.as_ref())) 16 | } 17 | 18 | pub fn validate(&self, options: &AppOptions) -> Result<(), Vec> { 19 | validator::validate(self, &options.start_page) 20 | } 21 | 22 | pub fn has_page(&self, page_name: &str) -> bool { 23 | self.pages.contains_key(page_name) 24 | } 25 | 26 | pub fn get_page(&self, page_name: &str) -> &Page { 27 | &self.pages[page_name] 28 | } 29 | 30 | pub fn settings_accumulator(&self) -> SettingsAccumulator { 31 | SettingsAccumulator::from(&self.global_settings) 32 | } 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::*; 38 | extern crate serde_yaml; 39 | 40 | fn default_options() -> AppOptions { 41 | AppOptions { 42 | filename: Some(String::from("/dev/null")), 43 | ignore_exit_status: false, 44 | start_page: String::from("root"), 45 | validate: false, 46 | generate_completions: None, 47 | } 48 | } 49 | 50 | #[test] 51 | fn it_loads_minimal_yaml() { 52 | let options = default_options(); 53 | let actions: ActionFile = 54 | serde_yaml::from_str(include_str!("../../tests/fixtures/minimal.yml")).unwrap(); 55 | actions.validate(&options).unwrap(); 56 | } 57 | 58 | #[test] 59 | fn it_loads_complex_yaml() { 60 | let options = default_options(); 61 | let actions: ActionFile = 62 | serde_yaml::from_str(include_str!("../../tests/fixtures/complex.yml")).unwrap(); 63 | actions.validate(&options).unwrap(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/actions/entry.rs: -------------------------------------------------------------------------------- 1 | extern crate serde; 2 | 3 | use super::Color; 4 | use serde::de::{self, Deserialize, Deserializer, Visitor}; 5 | use std::fmt; 6 | 7 | /// Represents a single entry in the action file. This entry is something a user can select when 8 | /// they are on the page that contains this entry. 9 | #[derive(Debug, Deserialize)] 10 | #[serde(deny_unknown_fields)] 11 | pub struct Entry { 12 | /// The title of the entry. Will be rendered in the menu. 13 | title: String, 14 | 15 | /// The character used to activate this shortcut; e.g. 'c' to activate when user presses the C 16 | /// key on their keyboard, or 'C' to activate when user presses Shift+C keys. 17 | shortcut: char, 18 | 19 | /// The Command to run when activating this entry. 20 | #[serde(default)] 21 | command: Command, 22 | 23 | /// Optional color to use when rendering the shortcut key in the menu. Will be inherited from 24 | /// the Page's settings if unset here. 25 | shortcut_color: Option, 26 | 27 | /// The runner mode, e.g. if the command should run in the background, replace the process, or 28 | /// some other runner mode. 29 | #[serde(default, rename = "mode")] 30 | runner_mode: RunMode, 31 | 32 | /// Specification on where to return to after executing the command. 33 | #[serde(rename = "return", default)] 34 | return_to: Return, 35 | } 36 | 37 | /// Represents something to execute when an Entry is selected. 38 | #[derive(Debug, Deserialize, Clone, PartialEq)] 39 | #[serde(deny_unknown_fields, untagged)] 40 | pub enum Command { 41 | /// Run no command and instead only act on the "Return" setting. 42 | None, 43 | 44 | /// A full shell script; will be run inside /bin/sh. 45 | ShellScript(String), 46 | 47 | /// A raw executable and a list of arguments. Will not do any shell processing or extra 48 | /// wrapping of the executable. 49 | Executable { 50 | /// Command name (from $PATH) or full path. 51 | name: String, 52 | 53 | /// List of arguments to pass to the command. 54 | #[serde(default)] 55 | args: Vec, 56 | }, 57 | } 58 | 59 | /// An action, aka something to do in the menu event loop. 60 | #[derive(Debug)] 61 | pub enum Action { 62 | /// Run a command in normal mode. 63 | Run { 64 | command: Command, 65 | return_to: Return, 66 | 67 | /// For RunMode::Wait commands 68 | wait: bool, 69 | }, 70 | /// Replace tydra with a Command. 71 | RunExec { command: Command }, 72 | 73 | /// Run a Command in the background and return to tydra. 74 | RunBackground { command: Command, return_to: Return }, 75 | 76 | /// Exit tydra. 77 | Exit, 78 | 79 | /// Redraw (re-render) the menu again. Good if your terminal window has been resized or on any 80 | /// other display problems. 81 | Redraw, 82 | 83 | /// Place tydra in the background (^Z) 84 | Pause, 85 | } 86 | 87 | #[derive(Debug, Clone, Copy, PartialEq, Deserialize)] 88 | #[serde(rename_all = "lowercase")] 89 | pub enum RunMode { 90 | /// Runs the command and then returns to tydra as soon as it has finished. 91 | Normal, 92 | 93 | /// Display a "Press enter to continue" prompt after the command has finished before 94 | /// progressing. This lets the user read all the output before the next action takes place. 95 | Wait, 96 | 97 | /// Replace this process with the given command instead of just running it as a child process. 98 | Exec, 99 | 100 | /// Fork and exec the command with no terminal devices still attached. This is useful for 101 | /// starting GUI programs. 102 | Background, 103 | } 104 | 105 | #[derive(Debug, Clone, PartialEq)] 106 | pub enum Return { 107 | Quit, 108 | SamePage, 109 | OtherPage(String), 110 | } 111 | 112 | impl Entry { 113 | pub fn shortcut(&self) -> char { 114 | self.shortcut 115 | } 116 | 117 | pub fn title(&self) -> &str { 118 | &self.title 119 | } 120 | 121 | pub fn shortcut_color(&self) -> Option { 122 | self.shortcut_color 123 | } 124 | 125 | pub fn return_to(&self) -> &Return { 126 | &self.return_to 127 | } 128 | 129 | pub fn command(&self) -> &Command { 130 | &self.command 131 | } 132 | 133 | pub fn runner_mode(&self) -> RunMode { 134 | self.runner_mode 135 | } 136 | } 137 | 138 | impl<'a> From<&'a Entry> for Action { 139 | /// Convert a Entry into an Action for consumption by the main event loop. 140 | fn from(entry: &'a Entry) -> Action { 141 | let command = entry.command.clone(); 142 | match entry.runner_mode { 143 | RunMode::Normal | RunMode::Wait => Action::Run { 144 | command, 145 | return_to: entry.return_to.clone(), 146 | wait: entry.runner_mode.is_wait(), 147 | }, 148 | RunMode::Exec => Action::RunExec { command }, 149 | RunMode::Background => Action::RunBackground { 150 | command, 151 | return_to: entry.return_to.clone(), 152 | }, 153 | } 154 | } 155 | } 156 | 157 | impl Default for Command { 158 | /// The default command should be not running anything. 159 | fn default() -> Command { 160 | Command::None 161 | } 162 | } 163 | 164 | impl Default for RunMode { 165 | fn default() -> RunMode { 166 | RunMode::Normal 167 | } 168 | } 169 | 170 | impl RunMode { 171 | fn is_wait(self) -> bool { 172 | match self { 173 | RunMode::Wait => true, 174 | _ => false, 175 | } 176 | } 177 | } 178 | 179 | impl Default for Return { 180 | fn default() -> Return { 181 | Return::Quit 182 | } 183 | } 184 | 185 | /// Parse a string as a page name, or true as "SamePage" and false as "Quit". 186 | struct ReturnVisitor; 187 | 188 | impl<'de> Visitor<'de> for ReturnVisitor { 189 | type Value = Return; 190 | 191 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 192 | formatter.write_str("a boolean or string") 193 | } 194 | 195 | fn visit_bool(self, value: bool) -> Result 196 | where 197 | E: de::Error, 198 | { 199 | if value { 200 | Ok(Return::SamePage) 201 | } else { 202 | Ok(Return::Quit) 203 | } 204 | } 205 | 206 | fn visit_string(self, value: String) -> Result 207 | where 208 | E: de::Error, 209 | { 210 | Ok(Return::OtherPage(value)) 211 | } 212 | 213 | fn visit_str(self, value: &str) -> Result 214 | where 215 | E: de::Error, 216 | { 217 | Ok(Return::OtherPage(value.to_owned())) 218 | } 219 | 220 | fn visit_unit(self) -> Result 221 | where 222 | E: de::Error, 223 | { 224 | Ok(Return::default()) 225 | } 226 | } 227 | 228 | impl<'de> Deserialize<'de> for Return { 229 | /// Parse a string as a page name, or true as "SamePage" and false as "Quit". 230 | fn deserialize(deserializer: D) -> Result 231 | where 232 | D: Deserializer<'de>, 233 | { 234 | deserializer.deserialize_any(ReturnVisitor) 235 | } 236 | } 237 | 238 | impl fmt::Display for Command { 239 | fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 240 | match *self { 241 | Command::None => write!(formatter, "(Nothing)"), 242 | Command::ShellScript(ref script) => script.fmt(formatter), 243 | Command::Executable { ref name, ref args } => { 244 | if args.is_empty() { 245 | write!(formatter, "{}", name) 246 | } else { 247 | write!(formatter, "{} {:?}", name, args) 248 | } 249 | } 250 | } 251 | } 252 | } 253 | 254 | #[cfg(test)] 255 | mod tests { 256 | use super::*; 257 | extern crate serde_yaml; 258 | 259 | #[derive(Debug, Deserialize, PartialEq)] 260 | #[serde(deny_unknown_fields)] 261 | pub struct OnlyReturn { 262 | #[serde(rename = "return")] 263 | return_to: Return, 264 | } 265 | 266 | #[derive(Debug, Deserialize, PartialEq)] 267 | #[serde(deny_unknown_fields)] 268 | pub struct OnlyCommand { 269 | #[serde(default)] 270 | command: Command, 271 | } 272 | 273 | #[test] 274 | fn it_displays_commands() { 275 | let script = Command::ShellScript(String::from("echo foo bar baz")); 276 | let executable = Command::Executable { 277 | name: String::from("ls"), 278 | args: vec![String::from("-l"), String::from("/")], 279 | }; 280 | let no_args = Command::Executable { 281 | name: String::from("/bin/true"), 282 | args: vec![], 283 | }; 284 | let none = Command::None; 285 | 286 | assert_eq!(&format!("{}", script), "echo foo bar baz"); 287 | assert_eq!(&format!("{}", executable), "ls [\"-l\", \"/\"]"); 288 | assert_eq!(&format!("{}", no_args), "/bin/true"); 289 | assert_eq!(&format!("{}", none), "(Nothing)"); 290 | } 291 | 292 | #[test] 293 | fn it_deserializes_command() { 294 | assert_eq!( 295 | serde_yaml::from_str::("command:").unwrap(), 296 | OnlyCommand { 297 | command: Command::None, 298 | }, 299 | ); 300 | 301 | assert_eq!( 302 | serde_yaml::from_str::(r#"command: null"#).unwrap(), 303 | OnlyCommand { 304 | command: Command::None, 305 | }, 306 | ); 307 | 308 | assert_eq!( 309 | serde_yaml::from_str::(r#"command: echo hello world"#).unwrap(), 310 | OnlyCommand { 311 | command: Command::ShellScript("echo hello world".into()), 312 | }, 313 | ); 314 | 315 | assert_eq!( 316 | serde_yaml::from_str::(r#"command: "true""#).unwrap(), 317 | OnlyCommand { 318 | command: Command::ShellScript("true".into()), 319 | }, 320 | ); 321 | 322 | assert_eq!( 323 | serde_yaml::from_str::(r#"command: {"name": "cat", "args": ["file"]}"#) 324 | .unwrap(), 325 | OnlyCommand { 326 | command: Command::Executable { 327 | name: "cat".into(), 328 | args: vec![String::from("file")], 329 | } 330 | }, 331 | ); 332 | } 333 | 334 | #[test] 335 | fn it_deserializes_returns() { 336 | assert_eq!( 337 | serde_yaml::from_str::(r#"return: false"#).unwrap(), 338 | OnlyReturn { 339 | return_to: Return::Quit 340 | }, 341 | ); 342 | 343 | assert_eq!( 344 | serde_yaml::from_str::(r#"return: true"#).unwrap(), 345 | OnlyReturn { 346 | return_to: Return::SamePage 347 | }, 348 | ); 349 | 350 | assert_eq!( 351 | serde_yaml::from_str::(r#"return: foobar"#).unwrap(), 352 | OnlyReturn { 353 | return_to: Return::OtherPage("foobar".into()), 354 | }, 355 | ); 356 | 357 | assert_eq!( 358 | serde_yaml::from_str::(r#"return: "#).expect("Failed to parse empty value"), 359 | OnlyReturn { 360 | return_to: Return::Quit 361 | }, 362 | ); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/actions/group.rs: -------------------------------------------------------------------------------- 1 | use super::{Entry, Settings}; 2 | 3 | #[derive(Debug, Deserialize)] 4 | #[serde(deny_unknown_fields)] 5 | pub struct Group { 6 | title: Option, 7 | settings: Option, 8 | entries: Vec, 9 | } 10 | 11 | impl Group { 12 | pub fn title(&self) -> Option<&str> { 13 | self.title.as_ref().map(String::as_ref) 14 | } 15 | 16 | pub fn settings(&self) -> Option<&Settings> { 17 | self.settings.as_ref() 18 | } 19 | 20 | pub fn entries(&self) -> &[Entry] { 21 | self.entries.as_slice() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/actions/mod.rs: -------------------------------------------------------------------------------- 1 | mod action_file; 2 | mod entry; 3 | mod group; 4 | mod page; 5 | mod rendering; 6 | mod settings; 7 | mod validator; 8 | 9 | pub use self::action_file::ActionFile; 10 | pub use self::entry::{Action, Command, Entry, RunMode, Return}; 11 | pub use self::group::Group; 12 | pub use self::page::Page; 13 | pub use self::rendering::render; 14 | pub use self::settings::{Color, Layout, Settings, SettingsAccumulator}; 15 | pub use self::validator::ValidationError; 16 | -------------------------------------------------------------------------------- /src/actions/page.rs: -------------------------------------------------------------------------------- 1 | use super::{Entry, Group, Settings}; 2 | 3 | #[derive(Debug, Deserialize)] 4 | #[serde(deny_unknown_fields)] 5 | pub struct Page { 6 | #[serde(default = "Page::default_title")] 7 | title: String, 8 | header: Option, 9 | footer: Option, 10 | settings: Option, 11 | groups: Vec, 12 | } 13 | 14 | impl Page { 15 | fn default_title() -> String { 16 | String::from("Tydra") 17 | } 18 | 19 | pub fn all_entries(&self) -> impl Iterator { 20 | self.groups.iter().flat_map(|group| group.entries()) 21 | } 22 | 23 | pub fn entry_with_shortcut(&self, shortcut: char) -> Option<&Entry> { 24 | self.all_entries() 25 | .find(|entry| entry.shortcut() == shortcut) 26 | } 27 | 28 | pub fn title(&self) -> &str { 29 | &self.title 30 | } 31 | 32 | pub fn header(&self) -> Option<&str> { 33 | self.header.as_ref().map(String::as_ref) 34 | } 35 | 36 | pub fn footer(&self) -> Option<&str> { 37 | self.footer.as_ref().map(String::as_ref) 38 | } 39 | 40 | pub fn settings(&self) -> Option<&Settings> { 41 | self.settings.as_ref() 42 | } 43 | 44 | pub fn groups(&self) -> &[Group] { 45 | self.groups.as_slice() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/actions/rendering.rs: -------------------------------------------------------------------------------- 1 | use crate::actions::{Entry, Group, Layout, Page, SettingsAccumulator}; 2 | use failure::Error; 3 | use tui::layout::{self, Direction, Rect, Size}; 4 | use tui::widgets::{Paragraph, Widget}; 5 | use crate::Term; 6 | 7 | pub fn render(term: &mut Term, page: &Page, settings: &SettingsAccumulator) -> Result<(), Error> { 8 | match settings.layout() { 9 | Layout::List => render_list_layout(term, page, settings), 10 | Layout::Columns => render_columns_layout(term, page, settings), 11 | } 12 | } 13 | 14 | pub fn render_list_layout( 15 | term: &mut Term, 16 | page: &Page, 17 | settings: &SettingsAccumulator, 18 | ) -> Result<(), Error> { 19 | let size = term.size()?; 20 | let max_width = size.width as usize; 21 | 22 | let mut text = String::new(); 23 | 24 | text.push_str("== "); 25 | text.push_str(page.title()); 26 | text.push_str(" =="); 27 | 28 | if let Some(ref header) = page.header() { 29 | text.push_str("\n"); 30 | text.push_str(header); 31 | } 32 | 33 | for group in page.groups() { 34 | let settings = settings.with_group(group); 35 | 36 | if let Some(title) = group.title() { 37 | text.push_str(&format!("\n\n{}:\n", title)); 38 | } else { 39 | text.push_str("\n\n"); 40 | } 41 | 42 | let mut current_line_length = 0; 43 | for entry in group.entries() { 44 | let entry_length = render_entry(entry).len(); 45 | if current_line_length + entry_length > max_width { 46 | text.push('\n'); 47 | current_line_length = 0; 48 | } 49 | text.push_str(&render_entry_color(entry, &settings)); 50 | current_line_length += entry_length; 51 | } 52 | } 53 | 54 | if let Some(footer) = page.footer() { 55 | text.push_str("\n"); 56 | text.push_str(footer); 57 | } 58 | 59 | Paragraph::default() 60 | .wrap(true) 61 | .text(&text) 62 | .render(term, &size); 63 | term.draw().map_err(|e| e.into()) 64 | } 65 | 66 | pub fn render_columns_layout( 67 | term: &mut Term, 68 | page: &Page, 69 | settings: &SettingsAccumulator, 70 | ) -> Result<(), Error> { 71 | let term_size = term.size()?; 72 | let width = term_size.width as usize; 73 | let column_widths: Vec = page 74 | .groups() 75 | .iter() 76 | .map(|group| { 77 | group 78 | .entries() 79 | .iter() 80 | .map(render_entry) 81 | .map(|s| s.len()) 82 | .max() 83 | .unwrap_or(0) 84 | }) 85 | .collect(); 86 | let required_width = column_widths.iter().sum(); 87 | 88 | if width < required_width { 89 | render_list_layout(term, page, settings) 90 | } else { 91 | let header_lines = required_lines_option(page.header(), width); 92 | let footer_lines = required_lines_option(page.footer(), width); 93 | 94 | layout::Group::default() 95 | .direction(Direction::Vertical) 96 | .sizes(&[ 97 | Size::Fixed(1), 98 | Size::Fixed(header_lines as u16), 99 | Size::Min(10), 100 | Size::Fixed(footer_lines as u16), 101 | ]) 102 | .render(term, &term_size, |t, chunks| { 103 | render_columns_title(t, chunks[0], page.title(), required_width); 104 | if let Some(text) = page.header() { 105 | render_columns_text(t, chunks[1], &text); 106 | } 107 | render_columns(t, chunks[2], &column_widths, page.groups(), &settings); 108 | if let Some(text) = page.footer() { 109 | render_columns_text(t, chunks[3], &text); 110 | } 111 | }); 112 | 113 | term.draw().map_err(|e| e.into()) 114 | } 115 | } 116 | 117 | fn render_columns_text(term: &mut Term, rect: Rect, text: &str) { 118 | Paragraph::default().text(&text).render(term, &rect); 119 | } 120 | 121 | fn render_columns_title(term: &mut Term, rect: Rect, title: &str, width: usize) { 122 | let centered_title = format!("{title:^width$}", title = title, width = width); 123 | Paragraph::default() 124 | .text(¢ered_title) 125 | .render(term, &rect); 126 | } 127 | 128 | fn render_columns( 129 | term: &mut Term, 130 | rect: Rect, 131 | column_widths: &[usize], 132 | groups: &[Group], 133 | settings: &SettingsAccumulator, 134 | ) { 135 | assert!(column_widths.len() == groups.len()); 136 | 137 | let sizes: Vec = column_widths 138 | .into_iter() 139 | .map(|width| Size::Fixed(*width as u16)) 140 | .collect(); 141 | 142 | layout::Group::default() 143 | .direction(Direction::Horizontal) 144 | .sizes(&sizes) 145 | .render(term, &rect, |t, chunks| { 146 | for (chunk, group) in chunks.into_iter().zip(groups.iter()) { 147 | render_column(t, *chunk, group, &settings); 148 | } 149 | }); 150 | } 151 | 152 | fn render_column(term: &mut Term, rect: Rect, group: &Group, settings: &SettingsAccumulator) { 153 | let settings = settings.with_group(group); 154 | let mut text = String::new(); 155 | 156 | if let Some(title) = group.title() { 157 | text.push_str(&format!("{}:\n", title)); 158 | } 159 | 160 | for entry in group.entries() { 161 | text.push_str(&render_entry_color(entry, &settings)); 162 | text.push('\n'); 163 | } 164 | 165 | Paragraph::default() 166 | .wrap(true) 167 | .text(&text) 168 | .render(term, &rect); 169 | } 170 | 171 | fn render_entry(entry: &Entry) -> String { 172 | format!("[{}] {} ", entry.shortcut(), entry.title()) 173 | } 174 | 175 | fn render_entry_color(entry: &Entry, settings: &SettingsAccumulator) -> String { 176 | let settings = settings.with_entry(entry); 177 | format!( 178 | "[{{fg={color} {shortcut}}}] {title} ", 179 | shortcut = entry.shortcut(), 180 | title = entry.title(), 181 | color = settings.shortcut_color.markup_name() 182 | ) 183 | } 184 | 185 | fn required_lines_option(option: Option<&str>, max_width: usize) -> usize { 186 | match option { 187 | Some(string) => required_lines(string, max_width), 188 | None => 0, 189 | } 190 | } 191 | 192 | fn required_lines(string: &str, max_width: usize) -> usize { 193 | // TODO: Do real calculation with word-wrap (instead of char-wrap), and markup strings removed. 194 | // This is a very naive implementation right now. 195 | string 196 | .split('\n') 197 | .map(|line| (line.len() / max_width) + 1) 198 | .sum() 199 | } 200 | -------------------------------------------------------------------------------- /src/actions/settings.rs: -------------------------------------------------------------------------------- 1 | use super::{Entry, Group, Page}; 2 | 3 | #[derive(Debug, Deserialize, Clone)] 4 | #[serde(deny_unknown_fields)] 5 | pub struct Settings { 6 | layout: Option, 7 | shortcut_color: Option, 8 | } 9 | 10 | #[derive(Debug, Default, Clone)] 11 | pub struct SettingsAccumulator { 12 | pub layout: Layout, 13 | pub shortcut_color: Color, 14 | } 15 | 16 | #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] 17 | #[serde(rename_all = "lowercase")] 18 | pub enum Layout { 19 | List, 20 | Columns, 21 | } 22 | 23 | #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] 24 | #[serde(rename_all = "lowercase")] 25 | pub enum Color { 26 | Reset, 27 | Black, 28 | Blue, 29 | Cyan, 30 | Green, 31 | Magenta, 32 | Red, 33 | White, 34 | Yellow, 35 | } 36 | 37 | impl Default for Settings { 38 | fn default() -> Settings { 39 | Settings { 40 | shortcut_color: Some(Color::Red), 41 | layout: Some(Layout::default()), 42 | } 43 | } 44 | } 45 | 46 | impl SettingsAccumulator { 47 | pub fn with_settings(&self, settings: &Settings) -> SettingsAccumulator { 48 | SettingsAccumulator { 49 | layout: settings.layout.unwrap_or(self.layout), 50 | shortcut_color: settings.shortcut_color.unwrap_or(self.shortcut_color), 51 | } 52 | } 53 | 54 | pub fn with_page(&self, page: &Page) -> SettingsAccumulator { 55 | match page.settings() { 56 | Some(settings) => self.with_settings(settings), 57 | None => self.clone(), 58 | } 59 | } 60 | 61 | pub fn with_group(&self, group: &Group) -> SettingsAccumulator { 62 | match group.settings() { 63 | Some(settings) => self.with_settings(settings), 64 | None => self.clone(), 65 | } 66 | } 67 | 68 | pub fn with_entry(&self, entry: &Entry) -> SettingsAccumulator { 69 | SettingsAccumulator { 70 | layout: self.layout, 71 | shortcut_color: entry.shortcut_color().unwrap_or(self.shortcut_color), 72 | } 73 | } 74 | 75 | pub fn layout(&self) -> Layout { 76 | self.layout 77 | } 78 | } 79 | 80 | impl<'a> From<&'a Settings> for SettingsAccumulator { 81 | fn from(settings: &'a Settings) -> SettingsAccumulator { 82 | let default_settings = Settings::default(); 83 | SettingsAccumulator { 84 | layout: settings 85 | .layout 86 | .or(default_settings.layout) 87 | .unwrap_or_default(), 88 | shortcut_color: settings 89 | .shortcut_color 90 | .or(default_settings.shortcut_color) 91 | .unwrap_or_default(), 92 | } 93 | } 94 | } 95 | 96 | impl Default for Layout { 97 | fn default() -> Layout { 98 | Layout::List 99 | } 100 | } 101 | 102 | impl Default for Color { 103 | fn default() -> Color { 104 | Color::Reset 105 | } 106 | } 107 | 108 | impl Color { 109 | pub fn markup_name(self) -> &'static str { 110 | match self { 111 | Color::Reset => "reset", 112 | Color::Black => "black", 113 | Color::Blue => "blue", 114 | Color::Cyan => "cyan", 115 | Color::Green => "green", 116 | Color::Magenta => "magenta", 117 | Color::Red => "red", 118 | Color::White => "white", 119 | Color::Yellow => "yellow", 120 | } 121 | } 122 | } 123 | 124 | #[cfg(test)] 125 | mod tests { 126 | use super::*; 127 | 128 | #[test] 129 | fn it_has_sane_defaults() { 130 | let default_settings = Settings::default(); 131 | assert_eq!(default_settings.layout, Some(Layout::List)); 132 | assert_eq!(default_settings.shortcut_color, Some(Color::Red)); 133 | } 134 | 135 | #[test] 136 | fn it_accumulates_settings() { 137 | let settings1 = Settings { 138 | layout: Some(Layout::Columns), 139 | shortcut_color: Some(Color::Green), 140 | }; 141 | let settings2 = Settings { 142 | layout: None, 143 | shortcut_color: Some(Color::Yellow), 144 | }; 145 | 146 | let accumulator = SettingsAccumulator::from(&settings1); 147 | assert_eq!(accumulator.layout, Layout::Columns); 148 | assert_eq!(accumulator.shortcut_color, Color::Green); 149 | 150 | let accumulator = accumulator.with_settings(&settings2); 151 | assert_eq!(accumulator.layout, Layout::Columns); 152 | assert_eq!(accumulator.shortcut_color, Color::Yellow); 153 | } 154 | 155 | #[test] 156 | fn it_accumulates_default_settings_on_none() { 157 | let default_settings = Settings::default(); 158 | 159 | let blank_settings = Settings { 160 | layout: None, 161 | shortcut_color: None, 162 | }; 163 | 164 | let accumulator = SettingsAccumulator::from(&blank_settings); 165 | assert_eq!(accumulator.layout, default_settings.layout.unwrap()); 166 | assert_eq!( 167 | accumulator.shortcut_color, 168 | default_settings.shortcut_color.unwrap() 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/actions/validator.rs: -------------------------------------------------------------------------------- 1 | use crate::actions::{ActionFile, Command, Entry, Return, RunMode}; 2 | use std::collections::HashSet; 3 | 4 | #[derive(Debug, PartialEq, Fail)] 5 | pub enum ValidationError { 6 | #[fail( 7 | display = "Found reference to an unknown page: {}", 8 | page_name 9 | )] 10 | UnknownPage { page_name: String }, 11 | #[fail(display = "Found page with no entries: {}", page_name)] 12 | EmptyPage { page_name: String }, 13 | #[fail( 14 | display = "Specified root page does not exist: {}", 15 | root_name 16 | )] 17 | NoRoot { root_name: String }, 18 | #[fail( 19 | display = "Page {} has a duplicated shortcut: {} ({})", 20 | page_name, 21 | shortcut, 22 | title 23 | )] 24 | DuplicatedShortcut { 25 | page_name: String, 26 | shortcut: char, 27 | title: String, 28 | }, 29 | #[fail( 30 | display = "Entry cannot return and exec at the same time; exec will replace tydra process (page {}, shortcut {}).", 31 | page_name, 32 | shortcut 33 | )] 34 | ExecWithReturn { page_name: String, shortcut: char }, 35 | #[fail( 36 | display = "Entry cannot exec without a command (page {}, shortcut {}).", 37 | page_name, 38 | shortcut 39 | )] 40 | ExecWithoutCommand { page_name: String, shortcut: char }, 41 | } 42 | 43 | pub fn validate(actions: &ActionFile, root_name: &str) -> Result<(), Vec> { 44 | let mut errors: Vec = Vec::new(); 45 | 46 | if !actions.has_page(root_name) { 47 | errors.push(ValidationError::NoRoot { 48 | root_name: root_name.into(), 49 | }); 50 | } 51 | 52 | for (page, page_name) in actions.pages_with_names() { 53 | let mut seen_shortcuts = HashSet::new(); 54 | 55 | if page.all_entries().next().is_none() { 56 | errors.push(ValidationError::EmptyPage { 57 | page_name: page_name.to_owned(), 58 | }); 59 | } 60 | 61 | for entry in page.all_entries() { 62 | validate_shortcut_duplicates(&mut errors, entry, &mut seen_shortcuts, page_name); 63 | validate_return_link(&mut errors, entry, actions); 64 | validate_mode(&mut errors, entry, page_name); 65 | } 66 | } 67 | 68 | if errors.is_empty() { 69 | Ok(()) 70 | } else { 71 | Err(errors) 72 | } 73 | } 74 | 75 | fn validate_shortcut_duplicates( 76 | errors: &mut Vec, 77 | entry: &Entry, 78 | seen_shortcuts: &mut HashSet, 79 | page_name: &str, 80 | ) { 81 | let shortcut = entry.shortcut(); 82 | if !seen_shortcuts.insert(shortcut) { 83 | errors.push(ValidationError::DuplicatedShortcut { 84 | page_name: page_name.to_owned(), 85 | shortcut, 86 | title: entry.title().into(), 87 | }); 88 | } 89 | } 90 | 91 | fn validate_return_link(errors: &mut Vec, entry: &Entry, actions: &ActionFile) { 92 | if let Return::OtherPage(page_name) = entry.return_to() { 93 | if !actions.has_page(page_name) { 94 | errors.push(ValidationError::UnknownPage { 95 | page_name: page_name.clone(), 96 | }); 97 | } 98 | } 99 | } 100 | 101 | fn validate_mode(errors: &mut Vec, entry: &Entry, page_name: &str) { 102 | if entry.runner_mode() == RunMode::Exec { 103 | match entry.return_to() { 104 | Return::SamePage | Return::OtherPage(_) => { 105 | errors.push(ValidationError::ExecWithReturn { 106 | page_name: page_name.to_owned(), 107 | shortcut: entry.shortcut(), 108 | }); 109 | } 110 | Return::Quit => {} 111 | } 112 | match entry.command() { 113 | Command::None => errors.push(ValidationError::ExecWithoutCommand { 114 | page_name: page_name.to_owned(), 115 | shortcut: entry.shortcut(), 116 | }), 117 | _ => {} 118 | } 119 | } 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use super::*; 125 | extern crate serde_yaml; 126 | 127 | #[test] 128 | fn it_validates_missing_pages() { 129 | let actions: ActionFile = 130 | serde_yaml::from_str(include_str!("../../tests/fixtures/unknown_page.yml")).unwrap(); 131 | let errors = validate(&actions, "root").unwrap_err(); 132 | 133 | assert_eq!(errors.len(), 2); 134 | assert_eq!( 135 | errors[0], 136 | ValidationError::UnknownPage { 137 | page_name: "speling_error".into(), 138 | }, 139 | ); 140 | assert_eq!( 141 | errors[1], 142 | ValidationError::UnknownPage { 143 | page_name: "does_not_exist".into(), 144 | }, 145 | ); 146 | } 147 | 148 | #[test] 149 | fn it_validates_empty_pages() { 150 | let actions: ActionFile = serde_yaml::from_str( 151 | r#" 152 | pages: 153 | root: 154 | groups: 155 | - entries: 156 | - shortcut: a 157 | title: Working 158 | this_page_is_empty: 159 | groups: 160 | - entries: []"#, 161 | ).unwrap(); 162 | 163 | let errors = validate(&actions, "root").unwrap_err(); 164 | 165 | assert_eq!(errors.len(), 1); 166 | assert_eq!( 167 | errors[0], 168 | ValidationError::EmptyPage { 169 | page_name: "this_page_is_empty".into(), 170 | }, 171 | ); 172 | } 173 | 174 | #[test] 175 | fn it_validates_no_root_page() { 176 | let actions: ActionFile = serde_yaml::from_str( 177 | r#" 178 | pages: 179 | potato: 180 | groups: 181 | - entries: 182 | - shortcut: a 183 | title: Working"#, 184 | ).unwrap(); 185 | 186 | let errors = validate(&actions, "horseradish").unwrap_err(); 187 | 188 | assert_eq!(errors.len(), 1); 189 | assert_eq!( 190 | errors[0], 191 | ValidationError::NoRoot { 192 | root_name: String::from("horseradish") 193 | } 194 | ); 195 | } 196 | 197 | #[test] 198 | fn it_validates_duplicated_keys() { 199 | let actions: ActionFile = serde_yaml::from_str( 200 | r#" 201 | pages: 202 | root: 203 | groups: 204 | - entries: 205 | - shortcut: a 206 | title: This is fine 207 | - entries: 208 | - shortcut: b 209 | title: This is fine 210 | bad_page: 211 | groups: 212 | - entries: 213 | - shortcut: a 214 | title: First one 215 | - entries: 216 | - shortcut: a 217 | title: Duplicated shortcut"#, 218 | ).unwrap(); 219 | 220 | let errors = validate(&actions, "root").unwrap_err(); 221 | 222 | assert_eq!(errors.len(), 1); 223 | assert_eq!( 224 | errors[0], 225 | ValidationError::DuplicatedShortcut { 226 | page_name: "bad_page".into(), 227 | shortcut: 'a', 228 | title: "Duplicated shortcut".into(), 229 | } 230 | ); 231 | } 232 | 233 | #[test] 234 | fn it_validates_no_exec_with_return() { 235 | let actions: ActionFile = serde_yaml::from_str( 236 | r#" 237 | pages: 238 | root: 239 | groups: 240 | - entries: 241 | - shortcut: a 242 | title: This is fine 243 | command: /bin/true 244 | mode: exec 245 | - shortcut: b 246 | title: This makes no sense 247 | command: /bin/true 248 | mode: exec 249 | return: true 250 | - shortcut: c 251 | title: This neither 252 | command: /bin/true 253 | mode: exec 254 | return: root"#, 255 | ).unwrap(); 256 | 257 | let errors = validate(&actions, "root").unwrap_err(); 258 | 259 | assert_eq!(errors.len(), 2); 260 | assert_eq!( 261 | errors[0], 262 | ValidationError::ExecWithReturn { 263 | page_name: "root".into(), 264 | shortcut: 'b', 265 | } 266 | ); 267 | assert_eq!( 268 | errors[1], 269 | ValidationError::ExecWithReturn { 270 | page_name: "root".into(), 271 | shortcut: 'c', 272 | } 273 | ); 274 | } 275 | 276 | #[test] 277 | fn it_validates_exec_with_no_command() { 278 | let actions: ActionFile = serde_yaml::from_str( 279 | r#" 280 | pages: 281 | root: 282 | groups: 283 | - entries: 284 | - shortcut: a 285 | title: This is fine 286 | mode: exec 287 | command: /bin/true 288 | - shortcut: b 289 | title: This makes no sense (explicitly no command) 290 | mode: exec 291 | command: null 292 | - shortcut: c 293 | title: This neither (no command mentioned) 294 | mode: exec"#, 295 | ).unwrap(); 296 | 297 | let errors = validate(&actions, "root").unwrap_err(); 298 | 299 | assert_eq!(errors.len(), 2); 300 | assert_eq!( 301 | errors[0], 302 | ValidationError::ExecWithoutCommand { 303 | page_name: "root".into(), 304 | shortcut: 'b', 305 | } 306 | ); 307 | assert_eq!( 308 | errors[1], 309 | ValidationError::ExecWithoutCommand { 310 | page_name: "root".into(), 311 | shortcut: 'c', 312 | } 313 | ); 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate failure; 3 | 4 | #[macro_use] 5 | extern crate failure_derive; 6 | 7 | #[macro_use] 8 | extern crate serde_derive; 9 | 10 | extern crate structopt; 11 | 12 | mod actions; 13 | mod runner; 14 | 15 | use actions::{render, Action, ActionFile, Page, Return}; 16 | use failure::Error; 17 | use structopt::clap::Shell; 18 | use structopt::StructOpt; 19 | use termion::event; 20 | use tui::backend::AlternateScreenBackend; 21 | use tui::Terminal; 22 | 23 | type Term = Terminal; 24 | 25 | #[derive(Debug, StructOpt)] 26 | #[structopt(setting = structopt::clap::AppSettings::ColoredHelp)] 27 | pub struct AppOptions { 28 | /// Read menu contents from this file. 29 | #[structopt(value_name = "ACTION_FILE", required_unless = "generate-completions")] 30 | filename: Option, 31 | 32 | /// Start on this page. 33 | #[structopt(long = "page", short = "p", default_value = "root")] 34 | start_page: String, 35 | 36 | /// Instead of showing the menu, validate the action file. 37 | #[structopt(long = "validate")] 38 | validate: bool, 39 | 40 | /// When a command fails, ignore it and do not exit tydra. 41 | #[structopt(long = "ignore-exit-status", short = "e")] 42 | ignore_exit_status: bool, 43 | 44 | /// Generate completion script for a given shell and output on STDOUT. 45 | #[structopt( 46 | long = "generate-completions", 47 | value_name = "SHELL", 48 | possible_values = &Shell::variants() 49 | )] 50 | generate_completions: Option, 51 | } 52 | 53 | fn generate_completions(shell: structopt::clap::Shell) { 54 | use std::io; 55 | let mut app = AppOptions::clap(); 56 | let name = app.get_name().to_string(); 57 | 58 | app.gen_completions_to(name, shell, &mut io::stdout()); 59 | } 60 | 61 | fn main() { 62 | let options = AppOptions::from_args(); 63 | 64 | if let Some(shell) = options.generate_completions { 65 | generate_completions(shell); 66 | return; 67 | } 68 | 69 | // Because filename should only ever be None if passed generate_completions options (thanks to 70 | // required_unless), it should be safe to unwrap after checking for generate_completions. 71 | let filename = options.filename.as_ref().unwrap(); 72 | 73 | let actions: ActionFile = match load_actions_from_path(filename) { 74 | Ok(actions) => actions, 75 | Err(error) => { 76 | eprintln!("Error while loading \"{}\": {}", filename, error); 77 | for cause in error.iter_causes() { 78 | eprintln!("Caused by: {}", cause); 79 | } 80 | return; 81 | } 82 | }; 83 | 84 | // Validate the action file so it is semantically correct before continuing. 85 | if let Err(errors) = actions.validate(&options) { 86 | print_validation_errors(&errors); 87 | std::process::exit(1); 88 | } 89 | 90 | // If running in validation mode, exit with a message after passing validations. 91 | if options.validate { 92 | eprintln!("File is valid."); 93 | std::process::exit(0); 94 | } 95 | 96 | // Run the menu. If it fails, then print the error message. 97 | if let Err(error) = run_menu(&actions, &options) { 98 | flush_terminal(); 99 | eprintln!("Error: {}", error); 100 | for cause in error.iter_causes() { 101 | eprintln!("Caused by: {}", cause); 102 | } 103 | } 104 | } 105 | 106 | fn load_actions_from_path(path: &str) -> Result { 107 | std::fs::read_to_string(path) 108 | .map_err(Error::from) 109 | .and_then(|data| serde_yaml::from_str(&data).map_err(Error::from)) 110 | } 111 | 112 | fn flush_terminal() { 113 | // Flush the output from Terminal being dropped; this is not done by termion itself. 114 | // https://gitlab.redox-os.org/redox-os/termion/issues/158 115 | // 116 | // Printing to stderr before stdout is flushed, or letting other processes write to it, 117 | // means that the text ends up on the alternate screen that will be removed as soon as *our* 118 | // stdout buffer is flushed. 119 | use std::io::Write; 120 | ::std::io::stdout().flush().ok(); 121 | } 122 | 123 | /// Wrapper around an AlternateScreen terminal, that handles restoration on drop. 124 | struct TermHandle(Term); 125 | 126 | impl TermHandle { 127 | /// Opens the terminal's "Alternate screen" and hide the cursor. 128 | /// 129 | /// This is like a separate screen that you can ouput to freely, and when this screen is closed 130 | /// the previous screen is restored. Most terminal UIs use this in order to not clobber output 131 | /// from earlier commands. For example, run vim and exit it again and you can see that your 132 | /// terminal is restored to look like it did before you started vim. 133 | /// 134 | /// Will restore cursor when dropped. 135 | fn new() -> Result { 136 | let backend = AlternateScreenBackend::new()?; 137 | let mut terminal = Terminal::new(backend)?; 138 | terminal.hide_cursor()?; 139 | terminal.clear()?; 140 | Ok(TermHandle(terminal)) 141 | } 142 | 143 | fn restart(self) -> Result { 144 | TermHandle::new() 145 | } 146 | } 147 | 148 | impl Drop for TermHandle { 149 | fn drop(&mut self) { 150 | self.0.show_cursor().ok(); 151 | } 152 | } 153 | 154 | /// Starts the main event loop. 155 | /// 156 | /// Start: 157 | /// Begin on root page. 158 | /// Open alternate screen. 159 | /// Loop: 160 | /// Render menu. 161 | /// Wait for a valid input. 162 | /// Process input's event, possibly running a command. 163 | /// Wait for user to press enter, if waiting is enabled. 164 | /// Update which page to be on. 165 | /// Exit if event tells us to. 166 | /// Repeat loop. 167 | /// End: 168 | /// Restore screen. 169 | /// 170 | fn run_menu(actions: &ActionFile, options: &AppOptions) -> Result<(), Error> { 171 | // Code in this function is annotated according to the function documentation comment to help 172 | // navigate it. It is quite big, sadly. 173 | 174 | // Start 175 | let error_on_failure = !options.ignore_exit_status; 176 | let settings = actions.settings_accumulator(); 177 | let mut current_page = actions.get_page(&options.start_page); 178 | let mut page_settings = settings.with_page(¤t_page); 179 | 180 | let mut terminal = TermHandle::new()?; 181 | 182 | // Loop 183 | loop { 184 | render(&mut terminal.0, current_page, &page_settings)?; 185 | 186 | // Wait for an event from user input. 187 | let action = process_input(current_page)?; 188 | let return_to = match action { 189 | // Quit / Exit. 190 | Action::Exit => Return::Quit, 191 | 192 | // Redraw menu. 193 | Action::Redraw => { 194 | terminal = terminal.restart()?; 195 | Return::SamePage 196 | } 197 | 198 | Action::Pause => { 199 | terminal = pause_tydra(terminal)?; 200 | Return::SamePage 201 | } 202 | 203 | // Run a command in normal mode, e.g. pause tydra and run the command. Return to tydra 204 | // after the command exits. 205 | Action::Run { 206 | command, 207 | return_to, 208 | wait, 209 | } => { 210 | terminal = run_normal(terminal, error_on_failure, command, wait)?; 211 | return_to 212 | } 213 | 214 | // Replace tydra with the command's process. 215 | // If it returns, it has to be an error. 216 | Action::RunExec { command } => return Err(run_exec(terminal, command)), 217 | 218 | // Run command in background and immediately return to the menu again. 219 | Action::RunBackground { command, return_to } => unsafe { 220 | runner::run_background(&command)?; 221 | return_to 222 | }, 223 | }; 224 | 225 | // Decide on which page to render now. 226 | match return_to { 227 | Return::Quit => break, 228 | Return::SamePage => continue, 229 | Return::OtherPage(page_name) => { 230 | current_page = actions.get_page(&page_name); 231 | page_settings = settings.with_page(¤t_page); 232 | } 233 | } 234 | } 235 | 236 | Ok(()) 237 | } 238 | 239 | fn run_normal( 240 | terminal: TermHandle, 241 | error_on_failure: bool, 242 | command: actions::Command, 243 | wait: bool, 244 | ) -> Result { 245 | // Run commands on the normal screen. This preserves the command's output even 246 | // after tydra exits. 247 | drop(terminal); 248 | flush_terminal(); 249 | 250 | match runner::run_normal(&command) { 251 | Some(Ok(exit_status)) => { 252 | if error_on_failure && !exit_status.success() { 253 | return Err(format_err!( 254 | "Command exited with exit status {}: {}", 255 | exit_status.code().unwrap_or(1), 256 | command 257 | )); 258 | } 259 | } 260 | Some(Err(err)) => return Err(err), 261 | None => {} 262 | } 263 | 264 | if wait { 265 | wait_for_confirmation()?; 266 | } 267 | 268 | TermHandle::new() 269 | } 270 | 271 | // Can use `!` when it is stable; it never returns a non-error 272 | fn run_exec(terminal: TermHandle, command: actions::Command) -> Error { 273 | // Restore screen for the new command. 274 | drop(terminal); 275 | flush_terminal(); 276 | 277 | // If this returns, then it failed to exec the process so wrap that value in a 278 | // error. 279 | runner::run_exec(&command) 280 | } 281 | 282 | fn pause_tydra(terminal: TermHandle) -> Result { 283 | use nix::sys::signal::{kill, Signal}; 284 | use nix::unistd::Pid; 285 | 286 | drop(terminal); 287 | flush_terminal(); 288 | 289 | // Tell this process to pause (standard ^Z signal) 290 | kill(Pid::this(), Signal::SIGTSTP)?; 291 | 292 | // Now the process is running again. Restore the terminal! 293 | TermHandle::new() 294 | } 295 | 296 | /// Reads input events until a valid event is found and returns it as an Action. Reads actions from 297 | /// provided page to determine what events are valid. 298 | fn process_input(page: &Page) -> Result { 299 | use termion::input::TermRead; 300 | let stdin = std::io::stdin(); 301 | 302 | // Iterate all valid events 303 | for event in stdin.keys().flat_map(Result::ok) { 304 | match event { 305 | event::Key::Esc => return Ok(Action::Exit), 306 | event::Key::Ctrl('l') => return Ok(Action::Redraw), 307 | event::Key::Ctrl('z') => return Ok(Action::Pause), 308 | event::Key::Char(chr) => { 309 | if let Some(entry) = page.entry_with_shortcut(chr) { 310 | return Ok(entry.into()); 311 | } 312 | } 313 | _ => {} 314 | } 315 | } 316 | 317 | Err(format_err!("stdin was closed.")) 318 | } 319 | 320 | /// Waits for the user to press Enter (or Escape, just to be nice) before returning. 321 | fn wait_for_confirmation() -> Result<(), Error> { 322 | use termion::input::TermRead; 323 | let stdin = std::io::stdin(); 324 | 325 | println!("Press enter to continue... "); 326 | 327 | for event in stdin.keys().flat_map(Result::ok) { 328 | match event { 329 | event::Key::Char('\n') | event::Key::Esc => return Ok(()), 330 | _ => {} 331 | } 332 | } 333 | 334 | Err(format_err!("stdin was closed.")) 335 | } 336 | 337 | fn print_validation_errors(errors: &[actions::ValidationError]) { 338 | eprintln!("Actions are invalid:"); 339 | for (index, error) in errors.iter().enumerate() { 340 | eprintln!(" {number}. {message}", number = index + 1, message = error); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/runner.rs: -------------------------------------------------------------------------------- 1 | extern crate nix; 2 | 3 | use crate::actions::Command; 4 | use failure::Error; 5 | use std::process; 6 | use std::process::{ExitStatus, Stdio}; 7 | 8 | impl Command { 9 | fn to_process_command(&self) -> Option { 10 | match *self { 11 | Command::None => None, 12 | Command::ShellScript(ref script) => { 13 | let mut command = process::Command::new("/bin/sh"); 14 | command.arg("-c").arg(script); 15 | Some(command) 16 | } 17 | Command::Executable { ref name, ref args } => { 18 | let mut command = process::Command::new(name); 19 | command.args(args); 20 | Some(command) 21 | } 22 | } 23 | } 24 | } 25 | 26 | pub fn run_normal(command: &Command) -> Option> { 27 | command 28 | .to_process_command() 29 | .map(|mut command| command.status().map_err(|e| e.into())) 30 | } 31 | 32 | #[cfg(unix)] 33 | pub fn run_exec(command: &Command) -> Error { 34 | use std::os::unix::process::CommandExt; 35 | command 36 | .to_process_command() 37 | .expect("Validations did not catch an exec with no command. Please report this as a bug!") 38 | .exec() 39 | .into() 40 | } 41 | 42 | #[cfg(not(unix))] 43 | pub fn run_exec(command: &Command) -> Error { 44 | match run_normal(command) { 45 | Ok(exit_status) => std::process::exit(exit_status.code().unwrap_or(0)), 46 | Err(error) => error, 47 | } 48 | } 49 | 50 | #[cfg(unix)] 51 | pub unsafe fn run_background(command: &Command) -> Result<(), Error> { 52 | use std::os::unix::process::CommandExt; 53 | match command.to_process_command() { 54 | Some(mut command) => command 55 | .stdin(Stdio::null()) 56 | .stdout(Stdio::null()) 57 | .stderr(Stdio::null()) 58 | .pre_exec(|| { 59 | // Make forked process into a new session leader; child will therefore not quit if 60 | // parent quits. 61 | nix::unistd::setsid().ok(); 62 | Ok(()) 63 | }).spawn() 64 | .map_err(|e| e.into()) 65 | .map(|_| ()), 66 | None => Ok(()), 67 | } 68 | } 69 | 70 | #[cfg(not(unix))] 71 | pub fn run_background(_command: &Command) -> Result<(), Error> { 72 | return Err(format_err!( 73 | "Running in background is currently only supported on unix platforms." 74 | )); 75 | } 76 | -------------------------------------------------------------------------------- /tests/fixtures/complex.yml: -------------------------------------------------------------------------------- 1 | # Global settings are applied to each page. Each page can override them 2 | # individually if they so wish. 3 | global: 4 | layout: columns # Render entries in columns 5 | shortcut_color: red # Show shortcut letter in red 6 | pages: 7 | # tydra always start on the "root" page by default: 8 | root: 9 | title: Welcome 10 | header: This is the default page. 11 | footer: "You can always quit using {fg=blue Esc}." 12 | groups: 13 | - title: Desktop 14 | entries: 15 | - shortcut: h 16 | title: Home 17 | command: "xdg-open ~" 18 | mode: background # Run command in background; ignore output and 19 | # return immediately after starting it. 20 | - shortcut: d 21 | title: Downloads 22 | command: "xdg-open ~/Downloads" 23 | mode: background 24 | - shortcut: D 25 | title: Desktop 26 | command: "xdg-open ~/Desktop" 27 | mode: background 28 | 29 | - title: Web 30 | entries: 31 | - shortcut: g 32 | title: Google 33 | # Commands can also be given in a structured form instead of as a 34 | # shell script. No shell-features (like $ENV subsititution, 35 | # redirects, pipes, globbing, ~ expansion, etc.) work in here, but 36 | # that also means that there is no need to escape arguments. 37 | # It also means that when you need none of these shell features, 38 | # then the command should have a faster startup. 39 | command: 40 | name: xdg-open 41 | args: 42 | - https://www.google.com 43 | mode: background 44 | - shortcut: G 45 | title: Github 46 | command: 47 | name: xdg-open 48 | args: 49 | - https://www.github.com 50 | mode: background 51 | - shortcut: l 52 | title: Gitlab 53 | command: 54 | name: xdg-open 55 | args: 56 | - https://www.gitlab.com 57 | mode: background 58 | 59 | - title: Misc 60 | entries: 61 | - shortcut: "?" 62 | title: Show tydra help 63 | command: "tydra --help | less" 64 | return: true # Return to the same page after the command has finished. 65 | - shortcut: p 66 | shortcut_color: blue 67 | title: Packages 68 | # command: # Default to running no command at all; use the "return" as an effect only. 69 | return: packages # Go to the packages page 70 | - shortcut: q 71 | title: Quit 72 | return: false # This is default when not specified 73 | packages: 74 | title: Packages 75 | header: "Perform package operations." 76 | settings: 77 | layout: list 78 | groups: 79 | - entries: 80 | - shortcut: r 81 | title: Refresh package repos 82 | command: "clear; sudo pacman -Sy" 83 | return: true 84 | - shortcut: u 85 | title: Show packages that can be upgraded 86 | command: "clear; pacman -Qu | less -+F" 87 | return: true 88 | - shortcut: U 89 | title: Install upgrades 90 | command: sudo pacman -Su 91 | mode: wait # Wait for user to press enter before returning to menu 92 | return: true 93 | - settings: # Individual groups can also have other default settings 94 | shortcut_color: blue 95 | entries: 96 | - shortcut: q 97 | title: Go back 98 | return: root 99 | -------------------------------------------------------------------------------- /tests/fixtures/minimal.yml: -------------------------------------------------------------------------------- 1 | # This file contains two invalid page references. 2 | pages: 3 | root: 4 | groups: 5 | - entries: 6 | - shortcut: a 7 | title: Working 8 | -------------------------------------------------------------------------------- /tests/fixtures/unknown_page.yml: -------------------------------------------------------------------------------- 1 | # This file contains two invalid page references. 2 | pages: 3 | root: 4 | groups: 5 | - entries: 6 | - shortcut: a 7 | title: Working 8 | return: true 9 | - shortcut: b 10 | title: Correct spelling 11 | return: spelling_error 12 | - shortcut: c 13 | title: Wrong spelling 14 | return: speling_error 15 | spelling_error: 16 | groups: 17 | - entries: 18 | - shortcut: a 19 | title: Broken 20 | return: does_not_exist 21 | - shortcut: b 22 | title: Quit 23 | return: false 24 | - shortcut: c 25 | title: Back 26 | return: root 27 | --------------------------------------------------------------------------------