├── .github └── workflows │ ├── cd.yaml │ └── ci.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md └── crates ├── model ├── Cargo.toml └── src │ ├── direction.rs │ ├── lib.rs │ ├── pipe.rs │ ├── pipe │ ├── color.rs │ └── kind.rs │ └── position.rs ├── pipes-rs ├── Cargo.toml └── src │ ├── config.rs │ ├── lib.rs │ ├── main.rs │ └── usage ├── rng ├── Cargo.toml └── src │ └── lib.rs └── terminal ├── Cargo.toml └── src ├── lib.rs └── screen.rs /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | env: 8 | RELEASE_BIN: pipes-rs 9 | 10 | jobs: 11 | create_release: 12 | name: Create release 13 | runs-on: ubuntu-latest 14 | outputs: 15 | upload_url: ${{ steps.step.outputs.upload_url }} 16 | 17 | steps: 18 | - uses: softprops/action-gh-release@v1 19 | id: step 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | build_release: 24 | name: Build release 25 | strategy: 26 | matrix: 27 | include: 28 | - os: ubuntu-latest 29 | targets: [x86_64-unknown-linux-gnu] 30 | suffix: linux-x86_64.tar.gz 31 | - os: macos-latest 32 | targets: [x86_64-apple-darwin, aarch64-apple-darwin] 33 | suffix: mac-universal.tar.gz 34 | - os: windows-latest 35 | targets: [x86_64-pc-windows-msvc] 36 | suffix: windows-x86_64.zip 37 | runs-on: ${{ matrix.os }} 38 | needs: create_release 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Install Rust toolchain 44 | uses: dtolnay/rust-toolchain@stable 45 | with: 46 | targets: ${{ join(matrix.targets, ',') }} 47 | 48 | - name: Build 49 | shell: bash 50 | run: | 51 | for target in ${{ join(matrix.targets, ' ') }}; do 52 | cargo build --release --target $target 53 | done 54 | 55 | - name: Create Linux archive 56 | run: tar -czvf ./${{ env.RELEASE_BIN }}-linux-x86_64.tar.gz ./target/x86_64-unknown-linux-gnu/release/${{ env.RELEASE_BIN }} 57 | if: matrix.os == 'ubuntu-latest' 58 | 59 | - name: Create Windows archive 60 | run: 7z a -tzip ./${{ env.RELEASE_BIN }}-windows-x86_64.zip ./target/x86_64-pc-windows-msvc/release/${{ env.RELEASE_BIN }}.exe 61 | if: matrix.os == 'windows-latest' 62 | 63 | - name: Create macOS archive 64 | run: | 65 | lipo -create -output ./${{ env.RELEASE_BIN }}-mac-universal ./target/x86_64-apple-darwin/release/${{ env.RELEASE_BIN }} ./target/aarch64-apple-darwin/release/${{ env.RELEASE_BIN }} 66 | codesign --sign - --options runtime ./${{ env.RELEASE_BIN }}-mac-universal 67 | tar -czvf ./${{ env.RELEASE_BIN }}-mac-universal.tar.gz ./${{ env.RELEASE_BIN }}-mac-universal 68 | if: matrix.os == 'macos-latest' 69 | 70 | - name: Upload archive 71 | uses: shogo82148/actions-upload-release-asset@v1 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | with: 75 | upload_url: ${{ needs.create_release.outputs.upload_url }} 76 | asset_path: ./${{ env.RELEASE_BIN }}-${{ matrix.suffix }} 77 | asset_name: ${{ env.RELEASE_BIN }}-${{ matrix.suffix }} 78 | 79 | - name: Get version 80 | id: get-version 81 | run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\//} 82 | if: matrix.os == 'macos-latest' 83 | 84 | - name: Bump Homebrew formula 85 | uses: mislav/bump-homebrew-formula-action@v3 86 | with: 87 | formula-name: pipes-rs 88 | homebrew-tap: lhvy/homebrew-tap 89 | download-url: https://github.com/lhvy/pipes-rs/releases/download/${{ steps.get-version.outputs.version }}/pipes-rs-mac-universal.tar.gz 90 | env: 91 | COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }} 92 | if: matrix.os == 'macos-latest' 93 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: master 7 | paths: 8 | - "**.rs" 9 | - "**.toml" 10 | - "**.lock" 11 | - "**.yaml" 12 | 13 | env: 14 | RUSTFLAGS: "--deny warnings --warn unreachable-pub" 15 | 16 | jobs: 17 | rust: 18 | name: Rust 19 | 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, windows-latest, macos-latest] 23 | 24 | runs-on: ${{ matrix.os }} 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Install Rust 30 | uses: dtolnay/rust-toolchain@stable 31 | with: 32 | components: clippy 33 | 34 | - name: Load Rust/Cargo cache 35 | uses: Swatinem/rust-cache@v2 36 | 37 | - name: Build 38 | run: cargo build --all-targets --all-features --locked 39 | 40 | - name: Clippy 41 | run: cargo clippy --all-targets --all-features 42 | 43 | - name: Test 44 | run: cargo test --all-targets --all-features --locked 45 | 46 | fmt: 47 | name: Formatting 48 | runs-on: ubuntu-latest 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - name: Install Rust 54 | uses: dtolnay/rust-toolchain@stable 55 | with: 56 | components: rustfmt 57 | 58 | - name: Check formatting 59 | run: cargo fmt -- --check 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /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 = "anyhow" 7 | version = "1.0.75" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" 10 | 11 | [[package]] 12 | name = "autocfg" 13 | version = "1.1.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 16 | 17 | [[package]] 18 | name = "bitflags" 19 | version = "1.3.2" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 22 | 23 | [[package]] 24 | name = "bitflags" 25 | version = "2.4.1" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" 28 | 29 | [[package]] 30 | name = "cc" 31 | version = "1.0.83" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 34 | dependencies = [ 35 | "libc", 36 | ] 37 | 38 | [[package]] 39 | name = "cfg-if" 40 | version = "1.0.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 43 | 44 | [[package]] 45 | name = "crossterm" 46 | version = "0.27.0" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" 49 | dependencies = [ 50 | "bitflags 2.4.1", 51 | "crossterm_winapi", 52 | "libc", 53 | "mio", 54 | "parking_lot", 55 | "signal-hook", 56 | "signal-hook-mio", 57 | "winapi", 58 | ] 59 | 60 | [[package]] 61 | name = "crossterm_winapi" 62 | version = "0.9.1" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 65 | dependencies = [ 66 | "winapi", 67 | ] 68 | 69 | [[package]] 70 | name = "equivalent" 71 | version = "1.0.1" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 74 | 75 | [[package]] 76 | name = "hashbrown" 77 | version = "0.14.1" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12" 80 | 81 | [[package]] 82 | name = "home" 83 | version = "0.5.5" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" 86 | dependencies = [ 87 | "windows-sys", 88 | ] 89 | 90 | [[package]] 91 | name = "indexmap" 92 | version = "2.0.2" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" 95 | dependencies = [ 96 | "equivalent", 97 | "hashbrown", 98 | ] 99 | 100 | [[package]] 101 | name = "libc" 102 | version = "0.2.149" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" 105 | 106 | [[package]] 107 | name = "libmimalloc-sys" 108 | version = "0.1.35" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "3979b5c37ece694f1f5e51e7ecc871fdb0f517ed04ee45f88d15d6d553cb9664" 111 | dependencies = [ 112 | "cc", 113 | "libc", 114 | ] 115 | 116 | [[package]] 117 | name = "lock_api" 118 | version = "0.4.10" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" 121 | dependencies = [ 122 | "autocfg", 123 | "scopeguard", 124 | ] 125 | 126 | [[package]] 127 | name = "log" 128 | version = "0.4.20" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 131 | 132 | [[package]] 133 | name = "memchr" 134 | version = "2.6.4" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" 137 | 138 | [[package]] 139 | name = "mimalloc" 140 | version = "0.1.39" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "fa01922b5ea280a911e323e4d2fd24b7fe5cc4042e0d2cda3c40775cdc4bdc9c" 143 | dependencies = [ 144 | "libmimalloc-sys", 145 | ] 146 | 147 | [[package]] 148 | name = "mio" 149 | version = "0.8.8" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" 152 | dependencies = [ 153 | "libc", 154 | "log", 155 | "wasi", 156 | "windows-sys", 157 | ] 158 | 159 | [[package]] 160 | name = "model" 161 | version = "0.0.0" 162 | dependencies = [ 163 | "anyhow", 164 | "rng", 165 | "serde", 166 | "terminal", 167 | "tincture", 168 | ] 169 | 170 | [[package]] 171 | name = "once_cell" 172 | version = "1.18.0" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" 175 | 176 | [[package]] 177 | name = "oorandom" 178 | version = "11.1.3" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" 181 | 182 | [[package]] 183 | name = "parking_lot" 184 | version = "0.12.1" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" 187 | dependencies = [ 188 | "lock_api", 189 | "parking_lot_core", 190 | ] 191 | 192 | [[package]] 193 | name = "parking_lot_core" 194 | version = "0.9.8" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" 197 | dependencies = [ 198 | "cfg-if", 199 | "libc", 200 | "redox_syscall", 201 | "smallvec", 202 | "windows-targets", 203 | ] 204 | 205 | [[package]] 206 | name = "pipes-rs" 207 | version = "1.6.3" 208 | dependencies = [ 209 | "anyhow", 210 | "home", 211 | "mimalloc", 212 | "model", 213 | "rng", 214 | "serde", 215 | "terminal", 216 | "toml", 217 | ] 218 | 219 | [[package]] 220 | name = "proc-macro2" 221 | version = "1.0.69" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" 224 | dependencies = [ 225 | "unicode-ident", 226 | ] 227 | 228 | [[package]] 229 | name = "quote" 230 | version = "1.0.33" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 233 | dependencies = [ 234 | "proc-macro2", 235 | ] 236 | 237 | [[package]] 238 | name = "redox_syscall" 239 | version = "0.3.5" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" 242 | dependencies = [ 243 | "bitflags 1.3.2", 244 | ] 245 | 246 | [[package]] 247 | name = "rng" 248 | version = "0.0.0" 249 | dependencies = [ 250 | "once_cell", 251 | "oorandom", 252 | "parking_lot", 253 | ] 254 | 255 | [[package]] 256 | name = "scopeguard" 257 | version = "1.2.0" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 260 | 261 | [[package]] 262 | name = "serde" 263 | version = "1.0.189" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537" 266 | dependencies = [ 267 | "serde_derive", 268 | ] 269 | 270 | [[package]] 271 | name = "serde_derive" 272 | version = "1.0.189" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5" 275 | dependencies = [ 276 | "proc-macro2", 277 | "quote", 278 | "syn", 279 | ] 280 | 281 | [[package]] 282 | name = "serde_spanned" 283 | version = "0.6.3" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" 286 | dependencies = [ 287 | "serde", 288 | ] 289 | 290 | [[package]] 291 | name = "signal-hook" 292 | version = "0.3.17" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 295 | dependencies = [ 296 | "libc", 297 | "signal-hook-registry", 298 | ] 299 | 300 | [[package]] 301 | name = "signal-hook-mio" 302 | version = "0.2.3" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" 305 | dependencies = [ 306 | "libc", 307 | "mio", 308 | "signal-hook", 309 | ] 310 | 311 | [[package]] 312 | name = "signal-hook-registry" 313 | version = "1.4.1" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" 316 | dependencies = [ 317 | "libc", 318 | ] 319 | 320 | [[package]] 321 | name = "smallvec" 322 | version = "1.11.1" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" 325 | 326 | [[package]] 327 | name = "syn" 328 | version = "2.0.38" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" 331 | dependencies = [ 332 | "proc-macro2", 333 | "quote", 334 | "unicode-ident", 335 | ] 336 | 337 | [[package]] 338 | name = "terminal" 339 | version = "0.0.0" 340 | dependencies = [ 341 | "anyhow", 342 | "crossterm", 343 | "unicode-width", 344 | ] 345 | 346 | [[package]] 347 | name = "tincture" 348 | version = "1.0.0" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "29d41631100909c3fba47be2e71076de7518ebc380df9210c1422a843aecb8da" 351 | 352 | [[package]] 353 | name = "toml" 354 | version = "0.8.2" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" 357 | dependencies = [ 358 | "serde", 359 | "serde_spanned", 360 | "toml_datetime", 361 | "toml_edit", 362 | ] 363 | 364 | [[package]] 365 | name = "toml_datetime" 366 | version = "0.6.3" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" 369 | dependencies = [ 370 | "serde", 371 | ] 372 | 373 | [[package]] 374 | name = "toml_edit" 375 | version = "0.20.2" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" 378 | dependencies = [ 379 | "indexmap", 380 | "serde", 381 | "serde_spanned", 382 | "toml_datetime", 383 | "winnow", 384 | ] 385 | 386 | [[package]] 387 | name = "unicode-ident" 388 | version = "1.0.12" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 391 | 392 | [[package]] 393 | name = "unicode-width" 394 | version = "0.1.11" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" 397 | 398 | [[package]] 399 | name = "wasi" 400 | version = "0.11.0+wasi-snapshot-preview1" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 403 | 404 | [[package]] 405 | name = "winapi" 406 | version = "0.3.9" 407 | source = "registry+https://github.com/rust-lang/crates.io-index" 408 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 409 | dependencies = [ 410 | "winapi-i686-pc-windows-gnu", 411 | "winapi-x86_64-pc-windows-gnu", 412 | ] 413 | 414 | [[package]] 415 | name = "winapi-i686-pc-windows-gnu" 416 | version = "0.4.0" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 419 | 420 | [[package]] 421 | name = "winapi-x86_64-pc-windows-gnu" 422 | version = "0.4.0" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 425 | 426 | [[package]] 427 | name = "windows-sys" 428 | version = "0.48.0" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 431 | dependencies = [ 432 | "windows-targets", 433 | ] 434 | 435 | [[package]] 436 | name = "windows-targets" 437 | version = "0.48.5" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 440 | dependencies = [ 441 | "windows_aarch64_gnullvm", 442 | "windows_aarch64_msvc", 443 | "windows_i686_gnu", 444 | "windows_i686_msvc", 445 | "windows_x86_64_gnu", 446 | "windows_x86_64_gnullvm", 447 | "windows_x86_64_msvc", 448 | ] 449 | 450 | [[package]] 451 | name = "windows_aarch64_gnullvm" 452 | version = "0.48.5" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 455 | 456 | [[package]] 457 | name = "windows_aarch64_msvc" 458 | version = "0.48.5" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 461 | 462 | [[package]] 463 | name = "windows_i686_gnu" 464 | version = "0.48.5" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 467 | 468 | [[package]] 469 | name = "windows_i686_msvc" 470 | version = "0.48.5" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 473 | 474 | [[package]] 475 | name = "windows_x86_64_gnu" 476 | version = "0.48.5" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 479 | 480 | [[package]] 481 | name = "windows_x86_64_gnullvm" 482 | version = "0.48.5" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 485 | 486 | [[package]] 487 | name = "windows_x86_64_msvc" 488 | version = "0.48.5" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 491 | 492 | [[package]] 493 | name = "winnow" 494 | version = "0.5.17" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "a3b801d0e0a6726477cc207f60162da452f3a95adb368399bef20a946e06f65c" 497 | dependencies = [ 498 | "memchr", 499 | ] 500 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [profile.dev] 6 | panic = "abort" 7 | 8 | [profile.release] 9 | panic = "abort" 10 | codegen-units = 1 11 | lto = "fat" 12 | strip = true 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Blue Oak Model License 2 | 3 | Version 1.0.0 4 | 5 | ## Purpose 6 | 7 | This license gives everyone as much permission to work with 8 | this software as possible, while protecting contributors 9 | from liability. 10 | 11 | ## Acceptance 12 | 13 | In order to receive this license, you must agree to its 14 | rules. The rules of this license are both obligations 15 | under that agreement and conditions to your license. 16 | You must not do anything with this software that triggers 17 | a rule that you cannot or will not follow. 18 | 19 | ## Copyright 20 | 21 | Each contributor licenses you to do everything with this 22 | software that would otherwise infringe that contributor's 23 | copyright in it. 24 | 25 | ## Notices 26 | 27 | You must ensure that everyone who gets a copy of 28 | any part of this software from you, with or without 29 | changes, also gets the text of this license or a link to 30 | . 31 | 32 | ## Excuse 33 | 34 | If anyone notifies you in writing that you have not 35 | complied with [Notices](#notices), you can keep your 36 | license by taking all practical steps to comply within 30 37 | days after the notice. If you do not do so, your license 38 | ends immediately. 39 | 40 | ## Patent 41 | 42 | Each contributor licenses you to do everything with this 43 | software that would otherwise infringe any patent claims 44 | they can license or become able to license. 45 | 46 | ## Reliability 47 | 48 | No contributor can revoke this license. 49 | 50 | ## No Liability 51 | 52 | **_As far as the law allows, this software comes as is, 53 | without any warranty or condition, and no contributor 54 | will be liable to anyone for any damages related to this 55 | software or this license, under any kind of legal claim._** 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pipes-rs 2 | 3 | ![GitHub Actions CI status](https://github.com/lhvy/pipes-rs/actions/workflows/ci.yaml/badge.svg) 4 | 5 | > An over-engineered rewrite of pipes.sh in Rust 6 | 7 | ![pipes-rs preview](https://github.com/lhvy/i/raw/master/pipes-rs-preview.gif) 8 | 9 | ## Installation 10 | 11 | Install the latest version release via git on any platform using Cargo: 12 | 13 | ```sh 14 | cargo install --git https://github.com/lhvy/pipes-rs 15 | ``` 16 | 17 | Alternatively for macOS, install via Homebrew: 18 | 19 | ```sh 20 | brew install lhvy/tap/pipes-rs 21 | ``` 22 | 23 | Alternatively for Windows, install via Scoop: 24 | 25 | ```sh 26 | scoop bucket add extras 27 | scoop install pipes-rs 28 | ``` 29 | 30 | ### Manual Download 31 | 32 | Download compiled binaries from [releases](https://github.com/lhvy/pipes-rs/releases/latest). 33 | 34 | ## Windows Font Issues 35 | 36 | There have been reports that some characters pipes-rs uses are missing on Windows, which causes them to appear as [tofu](https://en.wikipedia.org/wiki/Noto_fonts#Etymology). If you experience this issue, try using a font with a large character set, such as [Noto Mono](https://www.google.com/get/noto/). 37 | 38 | ## Keybindings 39 | 40 | - r: reset the screen 41 | - q or ^C: exit the program 42 | 43 | ## Configuration 44 | 45 | pipes-rs can be configured using TOML located at `~/.config/pipes-rs/config.toml`. 46 | The following is an example file with the default settings: 47 | 48 | ```toml 49 | bold = true 50 | color_mode = "ansi" # ansi, rgb or none 51 | palette = "default" # default, darker, pastel or matrix 52 | rainbow = 0 # 0-255 53 | delay_ms = 20 54 | inherit_style = false 55 | kinds = ["heavy"] # heavy, light, curved, knobby, emoji, outline, dots, blocks, sus 56 | num_pipes = 1 57 | reset_threshold = 0.5 # 0.0–1.0 58 | turn_chance = 0.15 # 0.0–1.0 59 | ``` 60 | 61 | ### Color Modes 62 | 63 | | Mode | Description | 64 | | :----- | :-------------------------------------------------------------------------------- | 65 | | `ansi` | pipe colors are randomly selected from the terminal color profile, default option | 66 | | `rgb` | pipe colors are randomly generated rgb values, unsupported in some terminals | 67 | | `none` | pipe colors will not be set and use the current terminal text color | 68 | 69 | ### Palettes 70 | 71 | | Palette | Description | 72 | | :-------- | :--------------------------------------------------------------- | 73 | | `default` | bright colors – good on dark backgrounds, default option | 74 | | `darker` | darker colors – good on light backgrounds | 75 | | `pastel` | pastel colors – good on dark backgrounds | 76 | | `matrix` | colors based on [Matrix digital rain] – good on dark backgrounds | 77 | 78 | ### Pipe Kinds 79 | 80 | | Kind | Preview | 81 | | :-------- | :------------------------ | 82 | | `heavy` | `┃ ┃ ━ ━ ┏ ┓ ┗ ┛` | 83 | | `light` | `│ │ ─ ─ ┌ ┐ └ ┘` | 84 | | `curved` | `│ │ ─ ─ ╭ ╮ ╰ ╯` | 85 | | `knobby` | `╽ ╿ ╼ ╾ ┎ ┒ ┖ ┚` | 86 | | `emoji` | `👆 👇 👈 👉 👌 👌 👌 👌` | 87 | | `outline` | `║ ║ ═ ═ ╔ ╗ ╚ ╝` | 88 | | `dots` | `• • • • • • • •` | 89 | | `blocks` | `█ █ ▀ ▀ █ █ ▀ ▀` | 90 | | `sus` | `ඞ ඞ ඞ ඞ ඞ ඞ ඞ ඞ` | 91 | 92 | _Due to emojis having a different character width, using the emoji pipe kind along side another pipe kind can cause spacing issues._ 93 | 94 | ## Options 95 | 96 | There are also command line options that can be used to override parts of the configuration file: 97 | 98 | | Option | Usage | Example | 99 | | :---------- | :-------------------------------------------------------------------------------- | :----------------- | 100 | | `-b` | toggles bold text | `-b true` | 101 | | `-c` | sets the color mode | `-c rgb` | 102 | | `-d` | sets the delay in ms | `-d 15` | 103 | | `-i` | toggles if pipes inherit style when hitting the edge | `-i false` | 104 | | `-k` | sets the kinds of pipes, each kind separated by commas | `-k heavy,curved` | 105 | | `-p` | sets the number of pipes on screen | `-p 5` | 106 | | `-r` | sets the percentage of the screen to be filled before resetting | `-r 0.75` | 107 | | `-t` | chance of a pipe turning each frame | `-t 0.15` | 108 | | `--palette` | sets the color palette, RGB mode only | `--palette pastel` | 109 | | `--rainbow` | sets the number of degrees per frame to shift the hue of each pipe, RGB mode only | `--rainbow 5` | 110 | 111 | ## Credits 112 | 113 | ### Contributors 114 | 115 | pipes-rs is maintained by [lhvy](https://github.com/lhvy) and [lunacookies](https://github.com/lunacookies); any other contributions via PRs are welcome! Forks and modifications are implicitly licensed under the Blue Oak Model License 1.0.0. Please credit the above contributors and pipes.sh when making forks or other derivative works. 116 | 117 | ### Inspiration 118 | 119 | This project is based off of [pipes.sh](https://github.com/pipeseroni/pipes.sh). 120 | 121 | [matrix digital rain]: https://en.wikipedia.org/wiki/Matrix_digital_rain 122 | -------------------------------------------------------------------------------- /crates/model/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = "2021" 3 | license = "BlueOak-1.0.0" 4 | name = "model" 5 | version = "0.0.0" 6 | 7 | [dependencies] 8 | anyhow = "1.0.70" 9 | rng = {path = "../rng"} 10 | serde = {version = "1.0.159", features = ["derive"]} 11 | terminal = {path = "../terminal"} 12 | tincture = "1.0.0" 13 | -------------------------------------------------------------------------------- /crates/model/src/direction.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, PartialEq)] 2 | pub(crate) enum Direction { 3 | Up, 4 | Down, 5 | Left, 6 | Right, 7 | } 8 | 9 | impl Direction { 10 | pub(crate) fn maybe_turn(self, turn_chance: f32) -> Direction { 11 | if !rng::gen_bool(turn_chance) { 12 | return self; 13 | } 14 | 15 | if rng::gen_bool(0.5) { 16 | // turn left 17 | match self { 18 | Direction::Up => Direction::Left, 19 | Direction::Down => Direction::Right, 20 | Direction::Left => Direction::Up, 21 | Direction::Right => Direction::Down, 22 | } 23 | } else { 24 | // turn right 25 | match self { 26 | Direction::Up => Direction::Right, 27 | Direction::Down => Direction::Left, 28 | Direction::Left => Direction::Down, 29 | Direction::Right => Direction::Up, 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/model/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod direction; 2 | pub mod pipe; 3 | pub mod position; 4 | -------------------------------------------------------------------------------- /crates/model/src/pipe.rs: -------------------------------------------------------------------------------- 1 | mod color; 2 | mod kind; 3 | 4 | pub use color::{ColorMode, Palette}; 5 | pub use kind::{Kind, KindSet}; 6 | 7 | use self::color::Color; 8 | use crate::direction::Direction; 9 | use crate::position::{InScreenBounds, Position}; 10 | 11 | pub struct Pipe { 12 | current_direction: Direction, 13 | previous_direction: Direction, 14 | pub position: Position, 15 | pub color: Option, 16 | kind: Kind, 17 | } 18 | 19 | impl Pipe { 20 | pub fn new(size: (u16, u16), color_mode: ColorMode, palette: Palette, kind: Kind) -> Self { 21 | let color = color::gen_random_color(color_mode, palette); 22 | let (direction, position) = gen_random_direction_and_position(size); 23 | 24 | Self { 25 | current_direction: direction, 26 | previous_direction: direction, 27 | position, 28 | color, 29 | kind, 30 | } 31 | } 32 | 33 | pub fn dup(&self, size: (u16, u16)) -> Self { 34 | let (direction, position) = gen_random_direction_and_position(size); 35 | 36 | Self { 37 | current_direction: direction, 38 | previous_direction: direction, 39 | position, 40 | color: self.color, 41 | kind: self.kind, 42 | } 43 | } 44 | 45 | pub fn tick(&mut self, size: (u16, u16), turn_chance: f32, hue_shift: u8) -> InScreenBounds { 46 | let InScreenBounds(in_screen_bounds) = self.position.move_in(self.current_direction, size); 47 | 48 | if let Some(color) = &mut self.color { 49 | color.update(hue_shift.into()); 50 | } 51 | 52 | if !in_screen_bounds { 53 | return InScreenBounds(false); 54 | } 55 | 56 | self.previous_direction = self.current_direction; 57 | self.current_direction = self.current_direction.maybe_turn(turn_chance); 58 | 59 | InScreenBounds(true) 60 | } 61 | 62 | pub fn to_char(&self) -> char { 63 | match (self.previous_direction, self.current_direction) { 64 | (Direction::Up, Direction::Left) | (Direction::Right, Direction::Down) => { 65 | self.kind.top_right() 66 | } 67 | (Direction::Up, Direction::Right) | (Direction::Left, Direction::Down) => { 68 | self.kind.top_left() 69 | } 70 | (Direction::Down, Direction::Left) | (Direction::Right, Direction::Up) => { 71 | self.kind.bottom_right() 72 | } 73 | (Direction::Down, Direction::Right) | (Direction::Left, Direction::Up) => { 74 | self.kind.bottom_left() 75 | } 76 | (Direction::Up, Direction::Up) => self.kind.up(), 77 | (Direction::Down, Direction::Down) => self.kind.down(), 78 | (Direction::Left, Direction::Left) => self.kind.left(), 79 | (Direction::Right, Direction::Right) => self.kind.right(), 80 | _ => unreachable!(), 81 | } 82 | } 83 | } 84 | 85 | fn gen_random_direction_and_position((columns, rows): (u16, u16)) -> (Direction, Position) { 86 | let direction = match rng::gen_range(0..4) { 87 | 0 => Direction::Up, 88 | 1 => Direction::Down, 89 | 2 => Direction::Left, 90 | 3 => Direction::Right, 91 | _ => unreachable!(), 92 | }; 93 | 94 | let position = match direction { 95 | Direction::Up => Position { 96 | x: rng::gen_range_16(0..columns), 97 | y: rows - 1, 98 | }, 99 | Direction::Down => Position { 100 | x: rng::gen_range_16(0..columns), 101 | y: 0, 102 | }, 103 | Direction::Left => Position { 104 | x: columns - 1, 105 | y: rng::gen_range_16(0..rows), 106 | }, 107 | Direction::Right => Position { 108 | x: 0, 109 | y: rng::gen_range_16(0..rows), 110 | }, 111 | }; 112 | 113 | (direction, position) 114 | } 115 | -------------------------------------------------------------------------------- /crates/model/src/pipe/color.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Range; 2 | 3 | #[derive(Clone, Copy)] 4 | pub struct Color { 5 | pub terminal: terminal::Color, 6 | pub(crate) oklch: Option, 7 | } 8 | 9 | impl Color { 10 | pub(crate) fn update(&mut self, hue_shift: f32) { 11 | if let Some(oklch) = &mut self.oklch { 12 | oklch.h += hue_shift.to_radians(); 13 | let oklab = tincture::oklch_to_oklab(*oklch); 14 | let lrgb = tincture::oklab_to_linear_srgb(oklab); 15 | let srgb = tincture::linear_srgb_to_srgb(lrgb); 16 | self.terminal = terminal::Color::Rgb { 17 | r: (srgb.r * 255.0) as u8, 18 | g: (srgb.g * 255.0) as u8, 19 | b: (srgb.b * 255.0) as u8, 20 | }; 21 | } 22 | } 23 | } 24 | 25 | pub(super) fn gen_random_color(color_mode: ColorMode, palette: Palette) -> Option { 26 | match color_mode { 27 | ColorMode::Ansi => Some(gen_random_ansi_color()), 28 | ColorMode::Rgb => Some(gen_random_rgb_color(palette)), 29 | ColorMode::None => None, 30 | } 31 | } 32 | 33 | fn gen_random_ansi_color() -> Color { 34 | let num = rng::gen_range(0..12); 35 | 36 | Color { 37 | terminal: match num { 38 | 0 => terminal::Color::Red, 39 | 1 => terminal::Color::DarkRed, 40 | 2 => terminal::Color::Green, 41 | 3 => terminal::Color::DarkGreen, 42 | 4 => terminal::Color::Yellow, 43 | 5 => terminal::Color::DarkYellow, 44 | 6 => terminal::Color::Blue, 45 | 7 => terminal::Color::DarkBlue, 46 | 8 => terminal::Color::Magenta, 47 | 9 => terminal::Color::DarkMagenta, 48 | 10 => terminal::Color::Cyan, 49 | 11 => terminal::Color::DarkCyan, 50 | _ => unreachable!(), 51 | }, 52 | oklch: None, 53 | } 54 | } 55 | 56 | fn gen_random_rgb_color(palette: Palette) -> Color { 57 | let hue = rng::gen_range_float(palette.get_hue_range()); 58 | let lightness = rng::gen_range_float(palette.get_lightness_range()); 59 | 60 | let oklch = tincture::Oklch { 61 | l: lightness, 62 | c: palette.get_chroma(), 63 | h: hue.to_radians(), 64 | }; 65 | let oklab = tincture::oklch_to_oklab(oklch); 66 | let lrgb = tincture::oklab_to_linear_srgb(oklab); 67 | let srgb = tincture::linear_srgb_to_srgb(lrgb); 68 | debug_assert!( 69 | (0.0..=1.0).contains(&srgb.r) 70 | && (0.0..=1.0).contains(&srgb.g) 71 | && (0.0..=1.0).contains(&srgb.b) 72 | ); 73 | 74 | Color { 75 | terminal: terminal::Color::Rgb { 76 | r: (srgb.r * 255.0) as u8, 77 | g: (srgb.g * 255.0) as u8, 78 | b: (srgb.b * 255.0) as u8, 79 | }, 80 | oklch: Some(oklch), 81 | } 82 | } 83 | 84 | #[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] 85 | #[serde(rename_all = "snake_case")] 86 | pub enum ColorMode { 87 | Ansi, 88 | Rgb, 89 | None, 90 | } 91 | 92 | #[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] 93 | #[serde(rename_all = "snake_case")] 94 | pub enum Palette { 95 | Default, 96 | Darker, 97 | Pastel, 98 | Matrix, 99 | } 100 | 101 | impl Palette { 102 | pub(super) fn get_hue_range(self) -> Range { 103 | match self { 104 | Self::Matrix => 145.0..145.0, 105 | _ => 0.0..360.0, 106 | } 107 | } 108 | 109 | pub(super) fn get_lightness_range(self) -> Range { 110 | match self { 111 | Self::Default => 0.75..0.75, 112 | Self::Darker => 0.65..0.65, 113 | Self::Pastel => 0.8..0.8, 114 | Self::Matrix => 0.5..0.9, 115 | } 116 | } 117 | 118 | pub(super) fn get_chroma(self) -> f32 { 119 | match self { 120 | Self::Default => 0.125, 121 | Self::Darker => 0.11, 122 | Self::Pastel => 0.085, 123 | Self::Matrix => 0.11, 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /crates/model/src/pipe/kind.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | use std::str::FromStr; 3 | 4 | #[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash, Clone, Copy)] 5 | #[serde(rename_all = "snake_case")] 6 | pub enum Kind { 7 | Heavy, 8 | Light, 9 | Curved, 10 | Knobby, 11 | Emoji, 12 | Outline, 13 | Dots, 14 | Blocks, 15 | Sus, 16 | } 17 | 18 | impl Kind { 19 | pub fn up(self) -> char { 20 | self.chars()[0] 21 | } 22 | 23 | pub fn down(self) -> char { 24 | self.chars()[1] 25 | } 26 | 27 | pub fn left(self) -> char { 28 | self.chars()[2] 29 | } 30 | 31 | pub fn right(self) -> char { 32 | self.chars()[3] 33 | } 34 | 35 | pub fn top_left(self) -> char { 36 | self.chars()[4] 37 | } 38 | 39 | pub fn top_right(self) -> char { 40 | self.chars()[5] 41 | } 42 | 43 | pub fn bottom_left(self) -> char { 44 | self.chars()[6] 45 | } 46 | 47 | pub fn bottom_right(self) -> char { 48 | self.chars()[7] 49 | } 50 | 51 | fn chars(self) -> [char; 8] { 52 | match self { 53 | Self::Heavy => Self::HEAVY, 54 | Self::Light => Self::LIGHT, 55 | Self::Curved => Self::CURVED, 56 | Self::Knobby => Self::KNOBBY, 57 | Self::Emoji => Self::EMOJI, 58 | Self::Outline => Self::OUTLINE, 59 | Self::Dots => Self::DOTS, 60 | Self::Blocks => Self::BLOCKS, 61 | Self::Sus => Self::SUS, 62 | } 63 | } 64 | 65 | fn width(self) -> KindWidth { 66 | match self { 67 | Self::Dots | Self::Sus => KindWidth::Custom(NonZeroUsize::new(2).unwrap()), 68 | _ => KindWidth::Auto, 69 | } 70 | } 71 | 72 | const HEAVY: [char; 8] = ['┃', '┃', '━', '━', '┏', '┓', '┗', '┛']; 73 | const LIGHT: [char; 8] = ['│', '│', '─', '─', '┌', '┐', '└', '┘']; 74 | const CURVED: [char; 8] = ['│', '│', '─', '─', '╭', '╮', '╰', '╯']; 75 | const KNOBBY: [char; 8] = ['╽', '╿', '╼', '╾', '┎', '┒', '┖', '┚']; 76 | const EMOJI: [char; 8] = ['👆', '👇', '👈', '👉', '👌', '👌', '👌', '👌']; 77 | const OUTLINE: [char; 8] = ['║', '║', '═', '═', '╔', '╗', '╚', '╝']; 78 | const DOTS: [char; 8] = ['•', '•', '•', '•', '•', '•', '•', '•']; 79 | const BLOCKS: [char; 8] = ['█', '█', '▀', '▀', '█', '█', '▀', '▀']; 80 | const SUS: [char; 8] = ['ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ']; 81 | } 82 | 83 | #[derive(Clone, Copy)] 84 | enum KindWidth { 85 | Auto, 86 | Custom(NonZeroUsize), 87 | } 88 | 89 | #[derive(serde::Serialize, serde::Deserialize, Clone)] 90 | pub struct KindSet(Vec); 91 | 92 | impl FromStr for KindSet { 93 | type Err = anyhow::Error; 94 | 95 | fn from_str(s: &str) -> Result { 96 | let mut set = Vec::new(); 97 | 98 | for kind in s.split(',') { 99 | let kind = match kind { 100 | "heavy" => Kind::Heavy, 101 | "light" => Kind::Light, 102 | "curved" => Kind::Curved, 103 | "knobby" => Kind::Knobby, 104 | "emoji" => Kind::Emoji, 105 | "outline" => Kind::Outline, 106 | "dots" => Kind::Dots, 107 | "blocks" => Kind::Blocks, 108 | "sus" => Kind::Sus, 109 | _ => anyhow::bail!( 110 | r#"unknown pipe kind (expected “heavy”, “light”, “curved”, “knobby”, “emoji”, “outline”, “dots”, “blocks”, or “sus”)"#, 111 | ), 112 | }; 113 | 114 | if !set.contains(&kind) { 115 | set.push(kind); 116 | } 117 | } 118 | 119 | Ok(Self(set)) 120 | } 121 | } 122 | 123 | impl KindSet { 124 | pub fn from_one(kind: Kind) -> Self { 125 | Self(vec![kind]) 126 | } 127 | 128 | pub fn choose_random(&self) -> Kind { 129 | let idx = rng::gen_range(0..self.0.len() as u32); 130 | self.0[idx as usize] 131 | } 132 | 133 | pub fn chars(&self) -> impl Iterator + '_ { 134 | self.0.iter().flat_map(|kind| kind.chars()) 135 | } 136 | 137 | pub fn custom_widths(&self) -> impl Iterator + '_ { 138 | self.0.iter().filter_map(|kind| match kind.width() { 139 | KindWidth::Custom(n) => Some(n), 140 | KindWidth::Auto => None, 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /crates/model/src/position.rs: -------------------------------------------------------------------------------- 1 | use crate::direction::Direction; 2 | 3 | pub struct Position { 4 | pub x: u16, 5 | pub y: u16, 6 | } 7 | 8 | impl Position { 9 | pub(crate) fn move_in(&mut self, dir: Direction, size: (u16, u16)) -> InScreenBounds { 10 | match dir { 11 | Direction::Up => { 12 | if self.y == 0 { 13 | return InScreenBounds(false); 14 | } 15 | self.y -= 1; 16 | } 17 | Direction::Down => self.y += 1, 18 | Direction::Left => { 19 | if self.x == 0 { 20 | return InScreenBounds(false); 21 | } 22 | self.x -= 1; 23 | } 24 | Direction::Right => self.x += 1, 25 | } 26 | 27 | InScreenBounds(self.in_screen_bounds(size)) 28 | } 29 | 30 | fn in_screen_bounds(&self, (columns, rows): (u16, u16)) -> bool { 31 | self.x < columns && self.y < rows 32 | } 33 | } 34 | 35 | #[derive(PartialEq, Eq)] 36 | pub struct InScreenBounds(pub bool); 37 | -------------------------------------------------------------------------------- /crates/pipes-rs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | description = "An over-engineered rewrite of pipes.sh in Rust" 3 | edition = "2021" 4 | license = "BlueOak-1.0.0" 5 | name = "pipes-rs" 6 | version = "1.6.3" 7 | 8 | [dependencies] 9 | anyhow = "1.0.70" 10 | home = "0.5.5" 11 | mimalloc = { version = "0.1.36", default-features = false } 12 | model = { path = "../model" } 13 | rng = { path = "../rng" } 14 | serde = "1.0.159" 15 | terminal = { path = "../terminal" } 16 | toml = "0.8.2" 17 | -------------------------------------------------------------------------------- /crates/pipes-rs/src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use model::pipe::{ColorMode, Kind, KindSet, Palette}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::path::PathBuf; 5 | use std::time::Duration; 6 | use std::{env, fs}; 7 | 8 | #[derive(Serialize, Deserialize, Default)] 9 | pub struct Config { 10 | pub color_mode: Option, 11 | pub palette: Option, 12 | pub rainbow: Option, 13 | pub delay_ms: Option, 14 | pub fps: Option, 15 | pub reset_threshold: Option, 16 | pub kinds: Option, 17 | pub bold: Option, 18 | pub inherit_style: Option, 19 | pub num_pipes: Option, 20 | pub turn_chance: Option, 21 | } 22 | 23 | impl Config { 24 | pub fn read() -> anyhow::Result { 25 | let config = Self::read_from_disk_with_default()?; 26 | config.validate()?; 27 | 28 | Ok(config) 29 | } 30 | 31 | fn read_from_disk_with_default() -> anyhow::Result { 32 | let path = Self::path()?; 33 | 34 | if !path.exists() { 35 | return Ok(Config::default()); 36 | } 37 | 38 | Self::read_from_disk(path) 39 | } 40 | 41 | fn path() -> anyhow::Result { 42 | let config_dir = 'config_dir: { 43 | if let Ok(d) = env::var("XDG_CONFIG_HOME") { 44 | let d = PathBuf::from(d); 45 | if d.is_absolute() { 46 | break 'config_dir d; 47 | } 48 | } 49 | 50 | match home::home_dir() { 51 | Some(d) => d.join(".config/"), 52 | None => anyhow::bail!("could not determine home directory"), 53 | } 54 | }; 55 | 56 | Ok(config_dir.join("pipes-rs/config.toml")) 57 | } 58 | 59 | fn read_from_disk(path: PathBuf) -> anyhow::Result { 60 | let contents = fs::read_to_string(path)?; 61 | toml::from_str(&contents).context("failed to read config") 62 | } 63 | 64 | pub fn validate(&self) -> anyhow::Result<()> { 65 | if let Some(reset_threshold) = self.reset_threshold() { 66 | if !(0.0..=1.0).contains(&reset_threshold) { 67 | anyhow::bail!("reset threshold should be within 0 and 1") 68 | } 69 | } 70 | 71 | if !(0.0..=1.0).contains(&self.turn_chance()) { 72 | anyhow::bail!("turn chance should be within 0 and 1") 73 | } 74 | 75 | if self.delay_ms.is_some() && self.fps.is_some() { 76 | anyhow::bail!("both delay and FPS can’t be set simultaneously"); 77 | } 78 | 79 | Ok(()) 80 | } 81 | 82 | pub fn color_mode(&self) -> ColorMode { 83 | self.color_mode.unwrap_or(ColorMode::Ansi) 84 | } 85 | 86 | pub fn palette(&self) -> Palette { 87 | self.palette.unwrap_or(Palette::Default) 88 | } 89 | 90 | pub fn rainbow(&self) -> u8 { 91 | self.rainbow.unwrap_or(0) 92 | } 93 | 94 | pub fn tick_length(&self) -> Duration { 95 | if let Some(fps) = self.fps { 96 | if fps == 0.0 { 97 | return Duration::ZERO; 98 | } 99 | return Duration::from_secs_f32(1.0 / fps); 100 | } 101 | 102 | if let Some(delay_ms) = self.delay_ms { 103 | return Duration::from_millis(delay_ms); // assume rendering a frame takes no time 104 | } 105 | 106 | Duration::from_secs_f32(1.0 / 50.0) // default to 50 FPS 107 | } 108 | 109 | pub fn reset_threshold(&self) -> Option { 110 | match self.reset_threshold { 111 | Some(n) if n == 0.0 => None, 112 | Some(n) => Some(n), 113 | None => Some(0.5), 114 | } 115 | } 116 | 117 | pub fn kinds(&self) -> KindSet { 118 | self.kinds 119 | .clone() 120 | .unwrap_or_else(|| KindSet::from_one(Kind::Heavy)) 121 | } 122 | 123 | pub fn bold(&self) -> bool { 124 | self.bold.unwrap_or(true) 125 | } 126 | 127 | pub fn inherit_style(&self) -> bool { 128 | self.inherit_style.unwrap_or(false) 129 | } 130 | 131 | pub fn num_pipes(&self) -> u32 { 132 | self.num_pipes.unwrap_or(1) 133 | } 134 | 135 | pub fn turn_chance(&self) -> f32 { 136 | self.turn_chance.unwrap_or(0.15) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /crates/pipes-rs/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | pub use config::Config; 3 | 4 | use model::pipe::{KindSet, Pipe}; 5 | use model::position::InScreenBounds; 6 | use std::{io, thread, time}; 7 | use terminal::{Event, Terminal}; 8 | 9 | pub struct App { 10 | terminal: Terminal, 11 | config: Config, 12 | kinds: KindSet, 13 | } 14 | 15 | impl App { 16 | pub fn new(config: Config) -> anyhow::Result { 17 | let kinds = config.kinds(); 18 | 19 | let stdout = io::stdout().lock(); 20 | let largest_custom_width = kinds.custom_widths().max(); 21 | let terminal = Terminal::new(stdout, kinds.chars(), largest_custom_width)?; 22 | 23 | Ok(Self { 24 | terminal, 25 | config, 26 | kinds, 27 | }) 28 | } 29 | 30 | pub fn run(mut self) -> anyhow::Result<()> { 31 | self.terminal.enter_alternate_screen()?; 32 | self.terminal.set_raw_mode(true)?; 33 | self.terminal.set_cursor_visibility(false)?; 34 | if self.config.bold() { 35 | self.terminal.enable_bold()?; 36 | } 37 | 38 | let mut pipes = self.create_pipes(); 39 | 40 | loop { 41 | if let ControlFlow::Break = self.reset_loop(&mut pipes)? { 42 | break; 43 | } 44 | } 45 | 46 | self.terminal.set_raw_mode(false)?; 47 | self.terminal.set_cursor_visibility(true)?; 48 | self.terminal.leave_alternate_screen()?; 49 | 50 | Ok(()) 51 | } 52 | 53 | pub fn reset_loop(&mut self, pipes: &mut Vec) -> anyhow::Result { 54 | self.terminal.clear()?; 55 | 56 | for pipe in &mut *pipes { 57 | *pipe = self.create_pipe(); 58 | } 59 | 60 | while self.under_threshold() { 61 | let control_flow = self.tick_loop(pipes)?; 62 | match control_flow { 63 | ControlFlow::Break | ControlFlow::Reset => return Ok(control_flow), 64 | ControlFlow::Continue => {} 65 | } 66 | } 67 | 68 | Ok(ControlFlow::Continue) 69 | } 70 | 71 | pub fn tick_loop(&mut self, pipes: &mut Vec) -> anyhow::Result { 72 | let start_time = time::Instant::now(); 73 | 74 | match self.terminal.get_event()? { 75 | Some(Event::Exit) => return Ok(ControlFlow::Break), 76 | Some(Event::Reset) => return Ok(ControlFlow::Reset), 77 | None => {} 78 | } 79 | 80 | for pipe in pipes { 81 | self.render_pipe(pipe)?; 82 | self.tick_pipe(pipe); 83 | } 84 | 85 | self.terminal.flush()?; 86 | 87 | let tick_length_so_far = start_time.elapsed(); 88 | 89 | // If we’ve taken more time than we have a budget for, 90 | // then just don’t sleep. 91 | let took_too_long = tick_length_so_far >= self.config.tick_length(); 92 | if !took_too_long { 93 | thread::sleep(self.config.tick_length() - tick_length_so_far); 94 | } 95 | 96 | Ok(ControlFlow::Continue) 97 | } 98 | 99 | fn tick_pipe(&mut self, pipe: &mut Pipe) { 100 | let InScreenBounds(stayed_onscreen) = pipe.tick( 101 | self.terminal.size(), 102 | self.config.turn_chance(), 103 | self.config.rainbow(), 104 | ); 105 | 106 | if !stayed_onscreen { 107 | *pipe = if self.config.inherit_style() { 108 | pipe.dup(self.terminal.size()) 109 | } else { 110 | self.create_pipe() 111 | }; 112 | } 113 | } 114 | 115 | fn render_pipe(&mut self, pipe: &Pipe) -> anyhow::Result<()> { 116 | self.terminal 117 | .move_cursor_to(pipe.position.x, pipe.position.y)?; 118 | 119 | if let Some(color) = pipe.color { 120 | self.terminal.set_text_color(color.terminal)?; 121 | } 122 | 123 | self.terminal.print(if rng::gen_bool(0.99999) { 124 | pipe.to_char() 125 | } else { 126 | '🦀' 127 | })?; 128 | 129 | Ok(()) 130 | } 131 | 132 | pub fn create_pipes(&mut self) -> Vec { 133 | (0..self.config.num_pipes()) 134 | .map(|_| self.create_pipe()) 135 | .collect() 136 | } 137 | 138 | fn create_pipe(&mut self) -> Pipe { 139 | let kind = self.kinds.choose_random(); 140 | 141 | Pipe::new( 142 | self.terminal.size(), 143 | self.config.color_mode(), 144 | self.config.palette(), 145 | kind, 146 | ) 147 | } 148 | 149 | fn under_threshold(&self) -> bool { 150 | match self.config.reset_threshold() { 151 | Some(reset_threshold) => self.terminal.portion_covered() < reset_threshold, 152 | None => true, 153 | } 154 | } 155 | } 156 | 157 | #[must_use] 158 | pub enum ControlFlow { 159 | Continue, 160 | Break, 161 | Reset, 162 | } 163 | -------------------------------------------------------------------------------- /crates/pipes-rs/src/main.rs: -------------------------------------------------------------------------------- 1 | use mimalloc::MiMalloc; 2 | use model::pipe::{ColorMode, Palette}; 3 | use pipes_rs::{App, Config}; 4 | use std::{env, process}; 5 | 6 | #[global_allocator] 7 | static GLOBAL: MiMalloc = MiMalloc; 8 | 9 | fn main() -> anyhow::Result<()> { 10 | let mut config = Config::read()?; 11 | parse_args(&mut config); 12 | config.validate()?; 13 | 14 | let app = App::new(config)?; 15 | app.run()?; 16 | 17 | Ok(()) 18 | } 19 | 20 | fn parse_args(config: &mut Config) { 21 | let args: Vec<_> = env::args().skip(1).collect(); 22 | let mut args_i = args.iter(); 23 | 24 | while let Some(arg) = args_i.next() { 25 | match arg.as_str() { 26 | "--license" => { 27 | if args.len() != 1 { 28 | eprintln!("error: provided arguments other than --license"); 29 | process::exit(1); 30 | } 31 | 32 | println!("pipes-rs is licensed under the Blue Oak Model License 1.0.0,"); 33 | println!("the text of which you will find below."); 34 | println!("\n{}", include_str!("../../../LICENSE.md")); 35 | process::exit(0); 36 | } 37 | 38 | "--version" | "-V" => { 39 | if args.len() != 1 { 40 | eprintln!("error: provided arguments other than --version"); 41 | process::exit(1); 42 | } 43 | 44 | println!("pipes-rs {}", env!("CARGO_PKG_VERSION")); 45 | process::exit(0); 46 | } 47 | 48 | "--help" | "-h" => { 49 | println!("{}", include_str!("usage")); 50 | process::exit(0); 51 | } 52 | 53 | _ => {} 54 | } 55 | 56 | if !arg.starts_with('-') { 57 | eprintln!("error: unexpected argument “{arg}” found"); 58 | eprintln!("see --help"); 59 | process::exit(1); 60 | } 61 | 62 | let (option, value) = arg.split_once('=').unwrap_or_else(|| match args_i.next() { 63 | Some(value) => (arg, value), 64 | None => required_value(arg), 65 | }); 66 | 67 | match option { 68 | "--color-mode" | "-c" => { 69 | config.color_mode = match value { 70 | "ansi" => Some(ColorMode::Ansi), 71 | "rgb" => Some(ColorMode::Rgb), 72 | "none" => Some(ColorMode::None), 73 | _ => invalid_value(option, value, "“ansi”, “rgb” or “none”"), 74 | } 75 | } 76 | 77 | "--palette" => { 78 | config.palette = match value { 79 | "default" => Some(Palette::Default), 80 | "darker" => Some(Palette::Darker), 81 | "pastel" => Some(Palette::Pastel), 82 | "matrix" => Some(Palette::Matrix), 83 | _ => invalid_value(option, value, "“default”, “darker”, “pastel” or “matrix”"), 84 | } 85 | } 86 | 87 | "--rainbow" => { 88 | config.rainbow = match value.parse() { 89 | Ok(v) => Some(v), 90 | Err(_) => invalid_value(option, value, "an integer between 0 and 255"), 91 | } 92 | } 93 | 94 | "--delay" | "-d" => { 95 | config.delay_ms = match value.parse() { 96 | Ok(v) => Some(v), 97 | Err(_) => invalid_value(option, value, "a positive integer"), 98 | } 99 | } 100 | 101 | "--fps" | "-f" => { 102 | config.fps = match value.parse() { 103 | Ok(v) => Some(v), 104 | Err(_) => invalid_value(option, value, "a number"), 105 | } 106 | } 107 | 108 | "--reset-threshold" | "-r" => { 109 | config.reset_threshold = match value.parse() { 110 | Ok(v) => Some(v), 111 | Err(_) => invalid_value(option, value, "a number"), 112 | } 113 | } 114 | 115 | "--kinds" | "-k" => { 116 | config.kinds = match value.parse() { 117 | Ok(v) => Some(v), 118 | Err(_) => invalid_value(option, value, "kinds of pipes separated by commas"), 119 | } 120 | } 121 | 122 | "--bold" | "-b" => { 123 | config.bold = match value.parse() { 124 | Ok(v) => Some(v), 125 | Err(_) => invalid_value(option, value, "“true” or “false”"), 126 | } 127 | } 128 | 129 | "--inherit-style" | "-i" => { 130 | config.inherit_style = match value.parse() { 131 | Ok(v) => Some(v), 132 | Err(_) => invalid_value(option, value, "“true” or “false”"), 133 | } 134 | } 135 | 136 | "--pipe-num" | "-p" => { 137 | config.num_pipes = match value.parse() { 138 | Ok(v) => Some(v), 139 | Err(_) => invalid_value(option, value, "a positive integer"), 140 | } 141 | } 142 | 143 | "--turn-chance" | "-t" => { 144 | config.turn_chance = match value.parse() { 145 | Ok(v) => Some(v), 146 | Err(_) => invalid_value(option, value, "a number"), 147 | } 148 | } 149 | 150 | _ => { 151 | eprintln!("error: unrecognized option {option}"); 152 | eprintln!("see --help"); 153 | process::exit(1); 154 | } 155 | } 156 | } 157 | } 158 | 159 | fn required_value(option: &str) -> ! { 160 | eprintln!("error: a value is required for {option} but none was supplied"); 161 | eprintln!("see --help"); 162 | process::exit(1); 163 | } 164 | 165 | fn invalid_value(option: &str, actual: &str, expected: &str) -> ! { 166 | eprintln!("error: invalid value “{actual}” for {option}"); 167 | eprintln!(" expected {expected}"); 168 | eprintln!("see --help"); 169 | process::exit(1); 170 | } 171 | -------------------------------------------------------------------------------- /crates/pipes-rs/src/usage: -------------------------------------------------------------------------------- 1 | An over-engineered rewrite of pipes.sh in Rust. 2 | 3 | Usage: pipes-rs [OPTIONS] 4 | 5 | Options: 6 | -c, --color-mode what kind of terminal coloring to use 7 | --palette the color palette used assign colors to pipes 8 | --rainbow cycle hue of pipes 9 | -d, --delay delay between frames in milliseconds 10 | -f, --fps number of frames of animation that are displayed in a second; use 0 for unlimited 11 | -r, --reset-threshold portion of screen covered before resetting (0.0–1.0) 12 | -k, --kinds kinds of pipes separated by commas, e.g. heavy,curved 13 | -b, --bold whether to use bold [possible values: true, false] 14 | -i, --inherit-style whether pipes should retain style after hitting the edge [possible values: true, false] 15 | -p, --pipe-num number of pipes 16 | -t, --turn-chance chance of a pipe turning (0.0–1.0) 17 | --license Print license 18 | -h, --help Print help 19 | -V, --version Print version 20 | -------------------------------------------------------------------------------- /crates/rng/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = "2021" 3 | license = "BlueOak-1.0.0" 4 | name = "rng" 5 | version = "0.0.0" 6 | 7 | [dependencies] 8 | once_cell = "1.17.1" 9 | oorandom = "11.1.3" 10 | parking_lot = "0.12.1" 11 | -------------------------------------------------------------------------------- /crates/rng/src/lib.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use parking_lot::Mutex; 3 | use std::ops::Range; 4 | 5 | static RNG: Lazy> = Lazy::new(|| { 6 | let seed = std::time::SystemTime::now() 7 | .duration_since(std::time::UNIX_EPOCH) 8 | .expect("system time cannot be before unix epoch") 9 | .as_millis() as u64; 10 | 11 | Mutex::new(Rng { 12 | rand_32: oorandom::Rand32::new(seed), 13 | }) 14 | }); 15 | 16 | struct Rng { 17 | rand_32: oorandom::Rand32, 18 | } 19 | 20 | pub fn gen_range(range: Range) -> u32 { 21 | RNG.lock().rand_32.rand_range(range) 22 | } 23 | 24 | pub fn gen_range_float(range: Range) -> f32 { 25 | RNG.lock().rand_32.rand_float() * (range.end - range.start) + range.start 26 | } 27 | 28 | pub fn gen_range_16(range: Range) -> u16 { 29 | RNG.lock() 30 | .rand_32 31 | .rand_range(range.start as u32..range.end as u32) as u16 32 | } 33 | 34 | pub fn gen_bool(probability: f32) -> bool { 35 | assert!(probability >= 0.0); 36 | assert!(probability <= 1.0); 37 | 38 | RNG.lock().rand_32.rand_float() < probability 39 | } 40 | -------------------------------------------------------------------------------- /crates/terminal/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | edition = "2021" 3 | license = "BlueOak-1.0.0" 4 | name = "terminal" 5 | version = "0.0.0" 6 | 7 | [dependencies] 8 | anyhow = "1.0.70" 9 | crossterm = "0.27.0" 10 | unicode-width = "0.1.10" 11 | -------------------------------------------------------------------------------- /crates/terminal/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod screen; 2 | 3 | use crossterm::event::{ 4 | self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, 5 | }; 6 | use crossterm::{cursor, queue, style, terminal}; 7 | use screen::Screen; 8 | use std::io::{self, Write}; 9 | use std::num::NonZeroUsize; 10 | use std::time::Duration; 11 | use unicode_width::UnicodeWidthChar; 12 | 13 | pub struct Terminal { 14 | screen: Screen, 15 | stdout: io::StdoutLock<'static>, 16 | max_char_width: u16, 17 | size: (u16, u16), 18 | } 19 | 20 | impl Terminal { 21 | pub fn new( 22 | stdout: io::StdoutLock<'static>, 23 | chars: impl Iterator, 24 | custom_width: Option, 25 | ) -> anyhow::Result { 26 | let max_char_width = Self::determine_max_char_width(chars, custom_width); 27 | 28 | let size = { 29 | let (width, height) = terminal::size()?; 30 | (width / max_char_width, height) 31 | }; 32 | 33 | let screen = Screen::new(size.0 as usize, size.1 as usize); 34 | 35 | Ok(Self { 36 | screen, 37 | stdout, 38 | max_char_width, 39 | size, 40 | }) 41 | } 42 | 43 | fn determine_max_char_width( 44 | chars: impl Iterator, 45 | custom_width: Option, 46 | ) -> u16 { 47 | let max_char_width = chars.map(|c| c.width().unwrap() as u16).max().unwrap(); 48 | 49 | match custom_width { 50 | Some(custom_width) => max_char_width.max(custom_width.get() as u16), 51 | None => max_char_width, 52 | } 53 | } 54 | 55 | pub fn enable_bold(&mut self) -> anyhow::Result<()> { 56 | queue!(self.stdout, style::SetAttribute(style::Attribute::Bold))?; 57 | Ok(()) 58 | } 59 | 60 | pub fn reset_style(&mut self) -> anyhow::Result<()> { 61 | queue!(self.stdout, style::SetAttribute(style::Attribute::Reset))?; 62 | Ok(()) 63 | } 64 | 65 | pub fn set_cursor_visibility(&mut self, visible: bool) -> anyhow::Result<()> { 66 | if visible { 67 | queue!(self.stdout, cursor::Show)?; 68 | } else { 69 | queue!(self.stdout, cursor::Hide)?; 70 | } 71 | 72 | Ok(()) 73 | } 74 | 75 | pub fn clear(&mut self) -> anyhow::Result<()> { 76 | queue!(self.stdout, terminal::Clear(terminal::ClearType::All))?; 77 | self.screen.clear(); 78 | 79 | Ok(()) 80 | } 81 | 82 | pub fn set_raw_mode(&self, enabled: bool) -> anyhow::Result<()> { 83 | if enabled { 84 | terminal::enable_raw_mode()?; 85 | } else { 86 | terminal::disable_raw_mode()?; 87 | } 88 | 89 | Ok(()) 90 | } 91 | 92 | pub fn enter_alternate_screen(&mut self) -> anyhow::Result<()> { 93 | queue!(self.stdout, terminal::EnterAlternateScreen)?; 94 | Ok(()) 95 | } 96 | 97 | pub fn leave_alternate_screen(&mut self) -> anyhow::Result<()> { 98 | queue!(self.stdout, terminal::LeaveAlternateScreen)?; 99 | Ok(()) 100 | } 101 | 102 | pub fn set_text_color(&mut self, color: Color) -> anyhow::Result<()> { 103 | let color = style::Color::from(color); 104 | queue!(self.stdout, style::SetForegroundColor(color))?; 105 | 106 | Ok(()) 107 | } 108 | 109 | pub fn move_cursor_to(&mut self, x: u16, y: u16) -> anyhow::Result<()> { 110 | queue!(self.stdout, cursor::MoveTo(x * self.max_char_width, y))?; 111 | self.screen.move_cursor_to(x as usize, y as usize); 112 | 113 | Ok(()) 114 | } 115 | 116 | pub fn portion_covered(&self) -> f32 { 117 | self.screen.portion_covered() 118 | } 119 | 120 | pub fn size(&self) -> (u16, u16) { 121 | self.size 122 | } 123 | 124 | pub fn print(&mut self, c: char) -> anyhow::Result<()> { 125 | self.screen.print(); 126 | self.stdout.write_all(c.to_string().as_bytes())?; 127 | 128 | Ok(()) 129 | } 130 | 131 | pub fn flush(&mut self) -> anyhow::Result<()> { 132 | self.stdout.flush()?; 133 | Ok(()) 134 | } 135 | 136 | pub fn get_event(&mut self) -> anyhow::Result> { 137 | if !event::poll(Duration::ZERO)? { 138 | return Ok(None); 139 | } 140 | 141 | match event::read()? { 142 | CrosstermEvent::Resize(width, height) => { 143 | self.resize(width, height); 144 | Ok(Some(Event::Reset)) 145 | } 146 | 147 | CrosstermEvent::Key( 148 | KeyEvent { 149 | code: KeyCode::Char('c'), 150 | modifiers: KeyModifiers::CONTROL, 151 | kind: KeyEventKind::Press, 152 | .. 153 | } 154 | | KeyEvent { 155 | code: KeyCode::Char('q'), 156 | kind: KeyEventKind::Press, 157 | .. 158 | }, 159 | ) => Ok(Some(Event::Exit)), 160 | 161 | CrosstermEvent::Key(KeyEvent { 162 | code: KeyCode::Char('r'), 163 | .. 164 | }) => Ok(Some(Event::Reset)), 165 | 166 | _ => Ok(None), 167 | } 168 | } 169 | 170 | fn resize(&mut self, width: u16, height: u16) { 171 | self.size = (width, height); 172 | self.screen.resize(width as usize, height as usize); 173 | } 174 | } 175 | 176 | #[derive(Clone, Copy)] 177 | pub enum Color { 178 | Red, 179 | DarkRed, 180 | Green, 181 | DarkGreen, 182 | Yellow, 183 | DarkYellow, 184 | Blue, 185 | DarkBlue, 186 | Magenta, 187 | DarkMagenta, 188 | Cyan, 189 | DarkCyan, 190 | Rgb { r: u8, g: u8, b: u8 }, 191 | } 192 | 193 | impl From for style::Color { 194 | fn from(color: Color) -> Self { 195 | match color { 196 | Color::Red => Self::Red, 197 | Color::DarkRed => Self::DarkRed, 198 | Color::Green => Self::Green, 199 | Color::DarkGreen => Self::DarkGreen, 200 | Color::Yellow => Self::Yellow, 201 | Color::DarkYellow => Self::DarkYellow, 202 | Color::Blue => Self::Blue, 203 | Color::DarkBlue => Self::DarkBlue, 204 | Color::Magenta => Self::Magenta, 205 | Color::DarkMagenta => Self::DarkMagenta, 206 | Color::Cyan => Self::Cyan, 207 | Color::DarkCyan => Self::DarkCyan, 208 | Color::Rgb { r, g, b } => Self::Rgb { r, g, b }, 209 | } 210 | } 211 | } 212 | 213 | pub enum Event { 214 | Exit, 215 | Reset, 216 | } 217 | -------------------------------------------------------------------------------- /crates/terminal/src/screen.rs: -------------------------------------------------------------------------------- 1 | pub(crate) struct Screen { 2 | cells: Vec, 3 | cursor: (usize, usize), 4 | width: usize, 5 | height: usize, 6 | num_covered: usize, 7 | } 8 | 9 | impl Screen { 10 | pub(crate) fn new(width: usize, height: usize) -> Self { 11 | Self { 12 | cells: vec![Cell { is_covered: false }; width * height], 13 | cursor: (0, 0), 14 | width, 15 | height, 16 | num_covered: 0, 17 | } 18 | } 19 | 20 | pub(crate) fn resize(&mut self, width: usize, height: usize) { 21 | self.cells 22 | .resize(width * height, Cell { is_covered: false }); 23 | self.cursor = (0, 0); 24 | self.width = width; 25 | self.height = height; 26 | self.clear(); 27 | } 28 | 29 | pub(crate) fn move_cursor_to(&mut self, x: usize, y: usize) { 30 | assert!(x < self.width); 31 | assert!(y < self.height); 32 | 33 | self.cursor = (x, y); 34 | } 35 | 36 | pub(crate) fn print(&mut self) { 37 | let current_cell = self.current_cell(); 38 | if !current_cell.is_covered { 39 | current_cell.is_covered = true; 40 | self.num_covered += 1; 41 | } 42 | } 43 | 44 | pub(crate) fn clear(&mut self) { 45 | for cell in &mut self.cells { 46 | cell.is_covered = false; 47 | } 48 | self.num_covered = 0; 49 | } 50 | 51 | pub(crate) fn portion_covered(&self) -> f32 { 52 | debug_assert_eq!( 53 | self.num_covered, 54 | self.cells.iter().filter(|c| c.is_covered).count() 55 | ); 56 | self.num_covered as f32 / self.cells.len() as f32 57 | } 58 | 59 | fn current_cell(&mut self) -> &mut Cell { 60 | &mut self.cells[self.cursor.1 * self.width + self.cursor.0] 61 | } 62 | } 63 | 64 | #[derive(Clone, Copy)] 65 | struct Cell { 66 | is_covered: bool, 67 | } 68 | --------------------------------------------------------------------------------