├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── assets └── picterm.gif └── src ├── app ├── actions.rs ├── mod.rs ├── state.rs └── ui │ ├── help.rs │ ├── image.rs │ ├── image_list.rs │ ├── info.rs │ ├── loading.rs │ ├── mod.rs │ ├── search.rs │ └── title.rs ├── image.rs ├── inputs ├── events.rs ├── key.rs └── mod.rs ├── io ├── handler.rs └── mod.rs ├── lib.rs ├── main.rs └── utils.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | name: Rust ${{ matrix.os }} ${{ matrix.rust }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | rust: 15 | - stable 16 | - beta 17 | - nightly 18 | os: [ubuntu-latest, windows-latest, macos-latest] 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Setup Rust 22 | uses: actions-rs/toolchain@v1 23 | with: 24 | profile: minimal 25 | toolchain: ${{ matrix.rust }} 26 | override: true 27 | - name: Build 28 | run: cargo build 29 | - name: Run tests 30 | run: cargo test 31 | continue-on-error: ${{ matrix.rust == 'nightly' }} 32 | 33 | rustfmt: 34 | name: Rustfmt 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Install Rust 39 | run: rustup update stable && rustup default stable && rustup component add rustfmt 40 | - run: cargo fmt -- --check -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | jobs: 6 | release: 7 | name: release ${{ matrix.target }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | include: 13 | - target: x86_64-pc-windows-gnu 14 | archive: zip 15 | - target: x86_64-unknown-linux-musl 16 | archive: tar.gz tar.xz 17 | - target: x86_64-apple-darwin 18 | archive: zip 19 | steps: 20 | - uses: actions/checkout@master 21 | - name: Compile and release 22 | uses: rust-build/rust-build.action@latest 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | RUSTTARGET: ${{ matrix.target }} 26 | ARCHIVE_TYPES: ${{ matrix.archive }} -------------------------------------------------------------------------------- /.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 = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 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 = "adler32" 22 | version = "1.2.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" 25 | 26 | [[package]] 27 | name = "ansi_rgb" 28 | version = "0.3.2-alpha" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "9f2916ece1f4834fa424632f76e3a93e677de5fd05dbbf5680bbbc4ab6058ad9" 31 | dependencies = [ 32 | "rgb", 33 | ] 34 | 35 | [[package]] 36 | name = "autocfg" 37 | version = "1.0.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 40 | 41 | [[package]] 42 | name = "backtrace" 43 | version = "0.3.69" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 46 | dependencies = [ 47 | "addr2line", 48 | "cc", 49 | "cfg-if", 50 | "libc", 51 | "miniz_oxide 0.7.1", 52 | "object", 53 | "rustc-demangle", 54 | ] 55 | 56 | [[package]] 57 | name = "bitflags" 58 | version = "1.3.2" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 61 | 62 | [[package]] 63 | name = "bitflags" 64 | version = "2.3.3" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" 67 | 68 | [[package]] 69 | name = "byte-unit" 70 | version = "4.0.14" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "95ebf10dda65f19ff0f42ea15572a359ed60d7fc74fdc984d90310937be0014b" 73 | dependencies = [ 74 | "utf8-width", 75 | ] 76 | 77 | [[package]] 78 | name = "bytemuck" 79 | version = "1.8.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "0e851ca7c24871e7336801608a4797d7376545b6928a10d32d75685687141ead" 82 | 83 | [[package]] 84 | name = "byteorder" 85 | version = "1.4.3" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 88 | 89 | [[package]] 90 | name = "bytes" 91 | version = "1.0.1" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" 94 | 95 | [[package]] 96 | name = "cassowary" 97 | version = "0.3.0" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 100 | 101 | [[package]] 102 | name = "cc" 103 | version = "1.0.83" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 106 | dependencies = [ 107 | "libc", 108 | ] 109 | 110 | [[package]] 111 | name = "cfg-if" 112 | version = "1.0.0" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 115 | 116 | [[package]] 117 | name = "color_quant" 118 | version = "1.1.0" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 121 | 122 | [[package]] 123 | name = "crc32fast" 124 | version = "1.2.1" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" 127 | dependencies = [ 128 | "cfg-if", 129 | ] 130 | 131 | [[package]] 132 | name = "crossbeam-channel" 133 | version = "0.5.1" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4" 136 | dependencies = [ 137 | "cfg-if", 138 | "crossbeam-utils", 139 | ] 140 | 141 | [[package]] 142 | name = "crossbeam-deque" 143 | version = "0.8.0" 144 | source = "registry+https://github.com/rust-lang/crates.io-index" 145 | checksum = "94af6efb46fef72616855b036a624cf27ba656ffc9be1b9a3c931cfc7749a9a9" 146 | dependencies = [ 147 | "cfg-if", 148 | "crossbeam-epoch", 149 | "crossbeam-utils", 150 | ] 151 | 152 | [[package]] 153 | name = "crossbeam-epoch" 154 | version = "0.9.5" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "4ec02e091aa634e2c3ada4a392989e7c3116673ef0ac5b72232439094d73b7fd" 157 | dependencies = [ 158 | "cfg-if", 159 | "crossbeam-utils", 160 | "lazy_static", 161 | "memoffset", 162 | "scopeguard", 163 | ] 164 | 165 | [[package]] 166 | name = "crossbeam-utils" 167 | version = "0.8.5" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" 170 | dependencies = [ 171 | "cfg-if", 172 | "lazy_static", 173 | ] 174 | 175 | [[package]] 176 | name = "crossterm" 177 | version = "0.23.1" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "f1fd7173631a4e9e2ca8b32ae2fad58aab9843ea5aaf56642661937d87e28a3e" 180 | dependencies = [ 181 | "bitflags 1.3.2", 182 | "crossterm_winapi", 183 | "libc", 184 | "mio 0.7.13", 185 | "parking_lot", 186 | "signal-hook", 187 | "signal-hook-mio", 188 | "winapi", 189 | ] 190 | 191 | [[package]] 192 | name = "crossterm" 193 | version = "0.26.1" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" 196 | dependencies = [ 197 | "bitflags 1.3.2", 198 | "crossterm_winapi", 199 | "libc", 200 | "mio 0.8.8", 201 | "parking_lot", 202 | "signal-hook", 203 | "signal-hook-mio", 204 | "winapi", 205 | ] 206 | 207 | [[package]] 208 | name = "crossterm_winapi" 209 | version = "0.9.0" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" 212 | dependencies = [ 213 | "winapi", 214 | ] 215 | 216 | [[package]] 217 | name = "deflate" 218 | version = "0.8.6" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "73770f8e1fe7d64df17ca66ad28994a0a623ea497fa69486e14984e715c5d174" 221 | dependencies = [ 222 | "adler32", 223 | "byteorder", 224 | ] 225 | 226 | [[package]] 227 | name = "either" 228 | version = "1.6.1" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 231 | 232 | [[package]] 233 | name = "eyre" 234 | version = "0.6.5" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "221239d1d5ea86bf5d6f91c9d6bc3646ffe471b08ff9b0f91c44f115ac969d2b" 237 | dependencies = [ 238 | "indenter", 239 | "once_cell", 240 | ] 241 | 242 | [[package]] 243 | name = "fuzzy-matcher" 244 | version = "0.3.7" 245 | source = "registry+https://github.com/rust-lang/crates.io-index" 246 | checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" 247 | dependencies = [ 248 | "thread_local", 249 | ] 250 | 251 | [[package]] 252 | name = "gif" 253 | version = "0.11.2" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "5a668f699973d0f573d15749b7002a9ac9e1f9c6b220e7b165601334c173d8de" 256 | dependencies = [ 257 | "color_quant", 258 | "weezl", 259 | ] 260 | 261 | [[package]] 262 | name = "gimli" 263 | version = "0.28.0" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" 266 | 267 | [[package]] 268 | name = "hermit-abi" 269 | version = "0.1.18" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" 272 | dependencies = [ 273 | "libc", 274 | ] 275 | 276 | [[package]] 277 | name = "image" 278 | version = "0.23.14" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "24ffcb7e7244a9bf19d35bf2883b9c080c4ced3c07a9895572178cdb8f13f6a1" 281 | dependencies = [ 282 | "bytemuck", 283 | "byteorder", 284 | "color_quant", 285 | "gif", 286 | "jpeg-decoder", 287 | "num-iter", 288 | "num-rational", 289 | "num-traits", 290 | "png", 291 | "scoped_threadpool", 292 | "tiff", 293 | ] 294 | 295 | [[package]] 296 | name = "indenter" 297 | version = "0.3.3" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" 300 | 301 | [[package]] 302 | name = "indoc" 303 | version = "2.0.3" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "2c785eefb63ebd0e33416dfcb8d6da0bf27ce752843a45632a67bf10d4d4b5c4" 306 | 307 | [[package]] 308 | name = "jpeg-decoder" 309 | version = "0.1.22" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2" 312 | dependencies = [ 313 | "rayon", 314 | ] 315 | 316 | [[package]] 317 | name = "lazy_static" 318 | version = "1.4.0" 319 | source = "registry+https://github.com/rust-lang/crates.io-index" 320 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 321 | 322 | [[package]] 323 | name = "libc" 324 | version = "0.2.147" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" 327 | 328 | [[package]] 329 | name = "lock_api" 330 | version = "0.4.6" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" 333 | dependencies = [ 334 | "scopeguard", 335 | ] 336 | 337 | [[package]] 338 | name = "log" 339 | version = "0.4.14" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 342 | dependencies = [ 343 | "cfg-if", 344 | ] 345 | 346 | [[package]] 347 | name = "memchr" 348 | version = "2.6.4" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" 351 | 352 | [[package]] 353 | name = "memoffset" 354 | version = "0.6.4" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "59accc507f1338036a0477ef61afdae33cde60840f4dfe481319ce3ad116ddf9" 357 | dependencies = [ 358 | "autocfg", 359 | ] 360 | 361 | [[package]] 362 | name = "miniz_oxide" 363 | version = "0.3.7" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "791daaae1ed6889560f8c4359194f56648355540573244a5448a83ba1ecc7435" 366 | dependencies = [ 367 | "adler32", 368 | ] 369 | 370 | [[package]] 371 | name = "miniz_oxide" 372 | version = "0.4.4" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" 375 | dependencies = [ 376 | "adler", 377 | "autocfg", 378 | ] 379 | 380 | [[package]] 381 | name = "miniz_oxide" 382 | version = "0.7.1" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 385 | dependencies = [ 386 | "adler", 387 | ] 388 | 389 | [[package]] 390 | name = "mio" 391 | version = "0.7.13" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" 394 | dependencies = [ 395 | "libc", 396 | "log", 397 | "miow", 398 | "ntapi", 399 | "winapi", 400 | ] 401 | 402 | [[package]] 403 | name = "mio" 404 | version = "0.8.8" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" 407 | dependencies = [ 408 | "libc", 409 | "log", 410 | "wasi", 411 | "windows-sys 0.48.0", 412 | ] 413 | 414 | [[package]] 415 | name = "miow" 416 | version = "0.3.7" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" 419 | dependencies = [ 420 | "winapi", 421 | ] 422 | 423 | [[package]] 424 | name = "ntapi" 425 | version = "0.3.6" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" 428 | dependencies = [ 429 | "winapi", 430 | ] 431 | 432 | [[package]] 433 | name = "num-integer" 434 | version = "0.1.44" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 437 | dependencies = [ 438 | "autocfg", 439 | "num-traits", 440 | ] 441 | 442 | [[package]] 443 | name = "num-iter" 444 | version = "0.1.42" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" 447 | dependencies = [ 448 | "autocfg", 449 | "num-integer", 450 | "num-traits", 451 | ] 452 | 453 | [[package]] 454 | name = "num-rational" 455 | version = "0.3.2" 456 | source = "registry+https://github.com/rust-lang/crates.io-index" 457 | checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" 458 | dependencies = [ 459 | "autocfg", 460 | "num-integer", 461 | "num-traits", 462 | ] 463 | 464 | [[package]] 465 | name = "num-traits" 466 | version = "0.2.14" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 469 | dependencies = [ 470 | "autocfg", 471 | ] 472 | 473 | [[package]] 474 | name = "num_cpus" 475 | version = "1.13.0" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" 478 | dependencies = [ 479 | "hermit-abi", 480 | "libc", 481 | ] 482 | 483 | [[package]] 484 | name = "num_threads" 485 | version = "0.1.6" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" 488 | dependencies = [ 489 | "libc", 490 | ] 491 | 492 | [[package]] 493 | name = "object" 494 | version = "0.32.1" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" 497 | dependencies = [ 498 | "memchr", 499 | ] 500 | 501 | [[package]] 502 | name = "once_cell" 503 | version = "1.8.0" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" 506 | 507 | [[package]] 508 | name = "parking_lot" 509 | version = "0.12.0" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" 512 | dependencies = [ 513 | "lock_api", 514 | "parking_lot_core", 515 | ] 516 | 517 | [[package]] 518 | name = "parking_lot_core" 519 | version = "0.9.1" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954" 522 | dependencies = [ 523 | "cfg-if", 524 | "libc", 525 | "redox_syscall", 526 | "smallvec", 527 | "windows-sys 0.32.0", 528 | ] 529 | 530 | [[package]] 531 | name = "paste" 532 | version = "1.0.14" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" 535 | 536 | [[package]] 537 | name = "picterm" 538 | version = "0.0.13" 539 | dependencies = [ 540 | "ansi_rgb", 541 | "byte-unit", 542 | "crossterm 0.23.1", 543 | "eyre", 544 | "fuzzy-matcher", 545 | "image", 546 | "ratatui", 547 | "rgb", 548 | "seahorse", 549 | "tokio", 550 | ] 551 | 552 | [[package]] 553 | name = "pin-project-lite" 554 | version = "0.2.13" 555 | source = "registry+https://github.com/rust-lang/crates.io-index" 556 | checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" 557 | 558 | [[package]] 559 | name = "png" 560 | version = "0.16.8" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "3c3287920cb847dee3de33d301c463fba14dda99db24214ddf93f83d3021f4c6" 563 | dependencies = [ 564 | "bitflags 1.3.2", 565 | "crc32fast", 566 | "deflate", 567 | "miniz_oxide 0.3.7", 568 | ] 569 | 570 | [[package]] 571 | name = "proc-macro2" 572 | version = "1.0.69" 573 | source = "registry+https://github.com/rust-lang/crates.io-index" 574 | checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" 575 | dependencies = [ 576 | "unicode-ident", 577 | ] 578 | 579 | [[package]] 580 | name = "quote" 581 | version = "1.0.33" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 584 | dependencies = [ 585 | "proc-macro2", 586 | ] 587 | 588 | [[package]] 589 | name = "ratatui" 590 | version = "0.22.0" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "8285baa38bdc9f879d92c0e37cb562ef38aa3aeefca22b3200186bc39242d3d5" 593 | dependencies = [ 594 | "bitflags 2.3.3", 595 | "cassowary", 596 | "crossterm 0.26.1", 597 | "indoc", 598 | "paste", 599 | "time", 600 | "unicode-segmentation", 601 | "unicode-width", 602 | ] 603 | 604 | [[package]] 605 | name = "rayon" 606 | version = "1.5.1" 607 | source = "registry+https://github.com/rust-lang/crates.io-index" 608 | checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90" 609 | dependencies = [ 610 | "autocfg", 611 | "crossbeam-deque", 612 | "either", 613 | "rayon-core", 614 | ] 615 | 616 | [[package]] 617 | name = "rayon-core" 618 | version = "1.9.1" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e" 621 | dependencies = [ 622 | "crossbeam-channel", 623 | "crossbeam-deque", 624 | "crossbeam-utils", 625 | "lazy_static", 626 | "num_cpus", 627 | ] 628 | 629 | [[package]] 630 | name = "redox_syscall" 631 | version = "0.2.9" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "5ab49abadf3f9e1c4bc499e8845e152ad87d2ad2d30371841171169e9d75feee" 634 | dependencies = [ 635 | "bitflags 1.3.2", 636 | ] 637 | 638 | [[package]] 639 | name = "rgb" 640 | version = "0.8.32" 641 | source = "registry+https://github.com/rust-lang/crates.io-index" 642 | checksum = "e74fdc210d8f24a7dbfedc13b04ba5764f5232754ccebfdf5fff1bad791ccbc6" 643 | dependencies = [ 644 | "bytemuck", 645 | ] 646 | 647 | [[package]] 648 | name = "rustc-demangle" 649 | version = "0.1.23" 650 | source = "registry+https://github.com/rust-lang/crates.io-index" 651 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 652 | 653 | [[package]] 654 | name = "scoped_threadpool" 655 | version = "0.1.9" 656 | source = "registry+https://github.com/rust-lang/crates.io-index" 657 | checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" 658 | 659 | [[package]] 660 | name = "scopeguard" 661 | version = "1.1.0" 662 | source = "registry+https://github.com/rust-lang/crates.io-index" 663 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 664 | 665 | [[package]] 666 | name = "seahorse" 667 | version = "2.0.0" 668 | source = "registry+https://github.com/rust-lang/crates.io-index" 669 | checksum = "8dfc08bbde3ce4c9d10e69804e9599d5c2c5c60eb65b78bbffdcf46687654dca" 670 | 671 | [[package]] 672 | name = "serde" 673 | version = "1.0.174" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "3b88756493a5bd5e5395d53baa70b194b05764ab85b59e43e4b8f4e1192fa9b1" 676 | 677 | [[package]] 678 | name = "signal-hook" 679 | version = "0.3.13" 680 | source = "registry+https://github.com/rust-lang/crates.io-index" 681 | checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" 682 | dependencies = [ 683 | "libc", 684 | "signal-hook-registry", 685 | ] 686 | 687 | [[package]] 688 | name = "signal-hook-mio" 689 | version = "0.2.3" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" 692 | dependencies = [ 693 | "libc", 694 | "mio 0.7.13", 695 | "mio 0.8.8", 696 | "signal-hook", 697 | ] 698 | 699 | [[package]] 700 | name = "signal-hook-registry" 701 | version = "1.4.0" 702 | source = "registry+https://github.com/rust-lang/crates.io-index" 703 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" 704 | dependencies = [ 705 | "libc", 706 | ] 707 | 708 | [[package]] 709 | name = "smallvec" 710 | version = "1.6.1" 711 | source = "registry+https://github.com/rust-lang/crates.io-index" 712 | checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" 713 | 714 | [[package]] 715 | name = "socket2" 716 | version = "0.5.4" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" 719 | dependencies = [ 720 | "libc", 721 | "windows-sys 0.48.0", 722 | ] 723 | 724 | [[package]] 725 | name = "syn" 726 | version = "2.0.38" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" 729 | dependencies = [ 730 | "proc-macro2", 731 | "quote", 732 | "unicode-ident", 733 | ] 734 | 735 | [[package]] 736 | name = "thread_local" 737 | version = "1.1.7" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" 740 | dependencies = [ 741 | "cfg-if", 742 | "once_cell", 743 | ] 744 | 745 | [[package]] 746 | name = "tiff" 747 | version = "0.6.1" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "9a53f4706d65497df0c4349241deddf35f84cee19c87ed86ea8ca590f4464437" 750 | dependencies = [ 751 | "jpeg-decoder", 752 | "miniz_oxide 0.4.4", 753 | "weezl", 754 | ] 755 | 756 | [[package]] 757 | name = "time" 758 | version = "0.3.23" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "59e399c068f43a5d116fedaf73b203fa4f9c519f17e2b34f63221d3792f81446" 761 | dependencies = [ 762 | "libc", 763 | "num_threads", 764 | "serde", 765 | "time-core", 766 | ] 767 | 768 | [[package]] 769 | name = "time-core" 770 | version = "0.1.1" 771 | source = "registry+https://github.com/rust-lang/crates.io-index" 772 | checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" 773 | 774 | [[package]] 775 | name = "tokio" 776 | version = "1.32.0" 777 | source = "registry+https://github.com/rust-lang/crates.io-index" 778 | checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" 779 | dependencies = [ 780 | "backtrace", 781 | "bytes", 782 | "libc", 783 | "mio 0.8.8", 784 | "num_cpus", 785 | "parking_lot", 786 | "pin-project-lite", 787 | "signal-hook-registry", 788 | "socket2", 789 | "tokio-macros", 790 | "windows-sys 0.48.0", 791 | ] 792 | 793 | [[package]] 794 | name = "tokio-macros" 795 | version = "2.1.0" 796 | source = "registry+https://github.com/rust-lang/crates.io-index" 797 | checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" 798 | dependencies = [ 799 | "proc-macro2", 800 | "quote", 801 | "syn", 802 | ] 803 | 804 | [[package]] 805 | name = "unicode-ident" 806 | version = "1.0.12" 807 | source = "registry+https://github.com/rust-lang/crates.io-index" 808 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 809 | 810 | [[package]] 811 | name = "unicode-segmentation" 812 | version = "1.10.1" 813 | source = "registry+https://github.com/rust-lang/crates.io-index" 814 | checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" 815 | 816 | [[package]] 817 | name = "unicode-width" 818 | version = "0.1.8" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 821 | 822 | [[package]] 823 | name = "utf8-width" 824 | version = "0.1.6" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" 827 | 828 | [[package]] 829 | name = "wasi" 830 | version = "0.11.0+wasi-snapshot-preview1" 831 | source = "registry+https://github.com/rust-lang/crates.io-index" 832 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 833 | 834 | [[package]] 835 | name = "weezl" 836 | version = "0.1.5" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "d8b77fdfd5a253be4ab714e4ffa3c49caf146b4de743e97510c0656cf90f1e8e" 839 | 840 | [[package]] 841 | name = "winapi" 842 | version = "0.3.9" 843 | source = "registry+https://github.com/rust-lang/crates.io-index" 844 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 845 | dependencies = [ 846 | "winapi-i686-pc-windows-gnu", 847 | "winapi-x86_64-pc-windows-gnu", 848 | ] 849 | 850 | [[package]] 851 | name = "winapi-i686-pc-windows-gnu" 852 | version = "0.4.0" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 855 | 856 | [[package]] 857 | name = "winapi-x86_64-pc-windows-gnu" 858 | version = "0.4.0" 859 | source = "registry+https://github.com/rust-lang/crates.io-index" 860 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 861 | 862 | [[package]] 863 | name = "windows-sys" 864 | version = "0.32.0" 865 | source = "registry+https://github.com/rust-lang/crates.io-index" 866 | checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6" 867 | dependencies = [ 868 | "windows_aarch64_msvc 0.32.0", 869 | "windows_i686_gnu 0.32.0", 870 | "windows_i686_msvc 0.32.0", 871 | "windows_x86_64_gnu 0.32.0", 872 | "windows_x86_64_msvc 0.32.0", 873 | ] 874 | 875 | [[package]] 876 | name = "windows-sys" 877 | version = "0.48.0" 878 | source = "registry+https://github.com/rust-lang/crates.io-index" 879 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 880 | dependencies = [ 881 | "windows-targets", 882 | ] 883 | 884 | [[package]] 885 | name = "windows-targets" 886 | version = "0.48.1" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" 889 | dependencies = [ 890 | "windows_aarch64_gnullvm", 891 | "windows_aarch64_msvc 0.48.0", 892 | "windows_i686_gnu 0.48.0", 893 | "windows_i686_msvc 0.48.0", 894 | "windows_x86_64_gnu 0.48.0", 895 | "windows_x86_64_gnullvm", 896 | "windows_x86_64_msvc 0.48.0", 897 | ] 898 | 899 | [[package]] 900 | name = "windows_aarch64_gnullvm" 901 | version = "0.48.0" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" 904 | 905 | [[package]] 906 | name = "windows_aarch64_msvc" 907 | version = "0.32.0" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" 910 | 911 | [[package]] 912 | name = "windows_aarch64_msvc" 913 | version = "0.48.0" 914 | source = "registry+https://github.com/rust-lang/crates.io-index" 915 | checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" 916 | 917 | [[package]] 918 | name = "windows_i686_gnu" 919 | version = "0.32.0" 920 | source = "registry+https://github.com/rust-lang/crates.io-index" 921 | checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" 922 | 923 | [[package]] 924 | name = "windows_i686_gnu" 925 | version = "0.48.0" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" 928 | 929 | [[package]] 930 | name = "windows_i686_msvc" 931 | version = "0.32.0" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" 934 | 935 | [[package]] 936 | name = "windows_i686_msvc" 937 | version = "0.48.0" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" 940 | 941 | [[package]] 942 | name = "windows_x86_64_gnu" 943 | version = "0.32.0" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" 946 | 947 | [[package]] 948 | name = "windows_x86_64_gnu" 949 | version = "0.48.0" 950 | source = "registry+https://github.com/rust-lang/crates.io-index" 951 | checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" 952 | 953 | [[package]] 954 | name = "windows_x86_64_gnullvm" 955 | version = "0.48.0" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" 958 | 959 | [[package]] 960 | name = "windows_x86_64_msvc" 961 | version = "0.32.0" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" 964 | 965 | [[package]] 966 | name = "windows_x86_64_msvc" 967 | version = "0.48.0" 968 | source = "registry+https://github.com/rust-lang/crates.io-index" 969 | checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" 970 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "picterm" 3 | version = "0.0.13" 4 | authors = ["Keisuke Toyota "] 5 | edition = "2018" 6 | repository = "https://github.com/ksk001100/picterm" 7 | keywords = ["tui", "image"] 8 | license-file = "LICENSE" 9 | readme = "README.md" 10 | description = "TUI image viewer" 11 | 12 | [dependencies] 13 | crossterm = "0.23" 14 | tui = { package = "ratatui", version = "0.22", features = ["all-widgets"]} 15 | image = "0.23" 16 | tokio = { version = "1.32", features = ["full"] } 17 | eyre = "0.6" 18 | seahorse = "2.0.0" 19 | byte-unit = "4.0" 20 | ansi_rgb = "0.3.2-alpha" 21 | rgb = "0.8" 22 | fuzzy-matcher = "0.3.7" 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Keisuke Toyota 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Picterm 2 | 3 | [![crates.io](https://img.shields.io/crates/v/picterm.svg)](https://crates.io/crates/picterm) 4 | ![releases count](https://img.shields.io/github/release/ksk001100/picterm.svg) 5 | ![download count](https://img.shields.io/crates/d/picterm) 6 | ![issues count](https://img.shields.io/github/issues/ksk001100/picterm.svg) 7 | ![forks count](https://img.shields.io/github/forks/ksk001100/picterm.svg) 8 | ![license](https://img.shields.io/github/license/ksk001100/picterm.svg) 9 | ![github actions CI](https://github.com/ksk001100/picterm/workflows/CI/badge.svg?branch=main) 10 | 11 | TUI image viewer 12 | 13 | ![](assets/picterm.gif) 14 | 15 | ## Install 16 | ```bash 17 | $ cargo install picterm 18 | ``` 19 | 20 | or 21 | 22 | ```bash 23 | $ git clone https://github.com/ksk001100/picterm 24 | $ cd picterm 25 | $ cargo install --path . 26 | ``` 27 | or 28 | 29 | Download [here](https://github.com/ksk001100/picterm/releases) 30 | 31 | ## Usage 32 | ```bash 33 | $ picterm --help # => Show help 34 | $ picterm -h 35 | $ picterm # => Current directory 36 | $ picterm ./ 37 | $ picterm $HOME/Downloads/ 38 | $ picterm ~/Pictures/sample.png 39 | $ picterm ~/Pictures/sample.png --gray # => Gray scale mode 40 | $ picterm ~/Pictures/ -g # => Gray scale mode 41 | ``` 42 | 43 | ## Support file format 44 | - PNG 45 | - JPG 46 | - WebP 47 | - BMP 48 | - GIF 49 | -------------------------------------------------------------------------------- /assets/picterm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksk001100/picterm/bfe269931645bb5ca7456815f28da73caac1c5c1/assets/picterm.gif -------------------------------------------------------------------------------- /src/app/actions.rs: -------------------------------------------------------------------------------- 1 | use crate::inputs::key::Key; 2 | use std::{ 3 | collections::HashMap, 4 | fmt::{self, Display}, 5 | slice::Iter, 6 | }; 7 | 8 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 9 | pub enum Action { 10 | Quit, 11 | Increment, 12 | Decrement, 13 | Show, 14 | Search, 15 | } 16 | 17 | impl Action { 18 | pub fn iterator() -> Iter<'static, Action> { 19 | static ACTIONS: [Action; 5] = [ 20 | Action::Quit, 21 | Action::Increment, 22 | Action::Decrement, 23 | Action::Show, 24 | Action::Search, 25 | ]; 26 | ACTIONS.iter() 27 | } 28 | 29 | pub fn keys(&self) -> &[Key] { 30 | match self { 31 | Action::Quit => &[Key::Char('q'), Key::Ctrl('c')], 32 | Action::Increment => &[Key::Char('j'), Key::Ctrl('n'), Key::Down], 33 | Action::Decrement => &[Key::Char('k'), Key::Ctrl('p'), Key::Up], 34 | Action::Show => &[Key::Enter, Key::Ctrl('m')], 35 | Action::Search => &[Key::Char('/'), Key::Ctrl('f')], 36 | } 37 | } 38 | } 39 | 40 | impl Display for Action { 41 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 | let str = match self { 43 | Action::Quit => "Quit", 44 | Action::Increment => "Next", 45 | Action::Decrement => "Prev", 46 | Action::Show => "Show", 47 | Action::Search => "Search", 48 | }; 49 | write!(f, "{}", str) 50 | } 51 | } 52 | 53 | #[derive(Default, Debug, Clone)] 54 | pub struct Actions(Vec); 55 | 56 | impl Actions { 57 | pub fn find(&self, key: Key) -> Option<&Action> { 58 | Action::iterator() 59 | .filter(|action| self.0.contains(action)) 60 | .find(|action| action.keys().contains(&key)) 61 | } 62 | 63 | pub fn actions(&self) -> &[Action] { 64 | self.0.as_slice() 65 | } 66 | } 67 | 68 | impl From> for Actions { 69 | fn from(actions: Vec) -> Self { 70 | let mut map: HashMap> = HashMap::new(); 71 | for action in actions.iter() { 72 | for key in action.keys().iter() { 73 | match map.get_mut(key) { 74 | Some(vec) => vec.push(*action), 75 | None => { 76 | map.insert(*key, vec![*action]); 77 | } 78 | } 79 | } 80 | } 81 | let errors = map 82 | .iter() 83 | .filter(|(_, actions)| actions.len() > 1) 84 | .map(|(key, actions)| { 85 | let actions = actions 86 | .iter() 87 | .map(Action::to_string) 88 | .collect::>() 89 | .join(", "); 90 | format!("Conflict key {} with actions {}", key, actions) 91 | }) 92 | .collect::>(); 93 | if !errors.is_empty() { 94 | panic!("{}", errors.join("; ")) 95 | } 96 | 97 | Self(actions) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod actions; 2 | pub mod state; 3 | pub mod ui; 4 | 5 | use crate::{ 6 | app::{ 7 | actions::{Action, Actions}, 8 | state::AppState, 9 | }, 10 | inputs::key::Key, 11 | io::IoEvent, 12 | utils::ImageMode, 13 | }; 14 | 15 | use self::state::AppMode; 16 | 17 | #[derive(Debug, PartialEq, Eq)] 18 | pub enum AppReturn { 19 | Exit, 20 | Continue, 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct AppConfig { 25 | pub image_mode: ImageMode, 26 | } 27 | 28 | #[derive(Clone)] 29 | pub struct App<'a> { 30 | io_tx: tokio::sync::mpsc::Sender, 31 | actions: Actions, 32 | is_loading: bool, 33 | pub state: AppState<'a>, 34 | pub config: AppConfig, 35 | } 36 | 37 | impl<'a> App<'a> { 38 | pub fn new(io_tx: tokio::sync::mpsc::Sender, image_mode: ImageMode) -> Self { 39 | let actions = vec![Action::Quit].into(); 40 | let is_loading = false; 41 | let state = AppState::default(); 42 | let config = AppConfig { image_mode }; 43 | 44 | Self { 45 | io_tx, 46 | actions, 47 | is_loading, 48 | state, 49 | config, 50 | } 51 | } 52 | 53 | pub async fn do_action(&mut self, key: Key) -> AppReturn { 54 | match self.state.get_app_mode() { 55 | AppMode::Normal => { 56 | if let Some(action) = self.actions.find(key) { 57 | match action { 58 | Action::Quit => AppReturn::Exit, 59 | Action::Increment => { 60 | self.state.increment_index(); 61 | AppReturn::Continue 62 | } 63 | Action::Decrement => { 64 | self.state.decrement_index(); 65 | AppReturn::Continue 66 | } 67 | Action::Show => { 68 | self.dispatch(IoEvent::LoadImage).await; 69 | AppReturn::Continue 70 | } 71 | Action::Search => { 72 | self.state.set_app_mode(AppMode::Search); 73 | AppReturn::Continue 74 | } 75 | } 76 | } else { 77 | AppReturn::Continue 78 | } 79 | } 80 | AppMode::Search => { 81 | let search_term = self.state.get_search_term(); 82 | 83 | match key { 84 | Key::Backspace => { 85 | if search_term.len() > 0 { 86 | self.state 87 | .set_search_term(search_term[..search_term.len() - 1].to_string()) 88 | } 89 | } 90 | Key::Esc => { 91 | self.state.set_search_term("".to_string()); 92 | self.state.set_app_mode(AppMode::Normal); 93 | } 94 | Key::Enter => { 95 | self.state.set_app_mode(AppMode::Normal); 96 | } 97 | oth => self 98 | .state 99 | .set_search_term(format!("{}{}", search_term, oth.key_char())), 100 | } 101 | 102 | self.state.filter_paths(); 103 | return AppReturn::Continue; 104 | } 105 | } 106 | } 107 | 108 | pub async fn update_on_tick(&mut self) -> AppReturn { 109 | AppReturn::Continue 110 | } 111 | 112 | pub async fn dispatch(&mut self, action: IoEvent) { 113 | self.is_loading = true; 114 | if self.io_tx.send(action).await.is_err() { 115 | self.is_loading = false; 116 | }; 117 | } 118 | 119 | pub fn actions(&self) -> &Actions { 120 | &self.actions 121 | } 122 | 123 | pub fn state(&self) -> &AppState { 124 | &self.state 125 | } 126 | 127 | pub fn state_mut(&'a mut self) -> &'a mut AppState { 128 | &mut self.state 129 | } 130 | 131 | pub fn is_loading(&self) -> bool { 132 | self.is_loading 133 | } 134 | 135 | pub fn initialized(&mut self, path: &str) { 136 | self.actions = vec![ 137 | Action::Quit, 138 | Action::Increment, 139 | Action::Decrement, 140 | Action::Show, 141 | Action::Search, 142 | ] 143 | .into(); 144 | self.state = AppState::initialized(path); 145 | } 146 | 147 | pub fn loaded(&mut self) { 148 | self.is_loading = false; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/app/state.rs: -------------------------------------------------------------------------------- 1 | use crate::utils; 2 | use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; 3 | use std::path::PathBuf; 4 | use tui::text::Line; 5 | 6 | #[derive(Debug, Clone, PartialEq)] 7 | pub enum AppMode { 8 | Normal, 9 | Search, 10 | } 11 | 12 | #[derive(Debug, Clone)] 13 | pub enum AppState<'a> { 14 | Init, 15 | Initialized { 16 | paths: Vec, 17 | path: String, 18 | selected_index: usize, 19 | term_size: Option, 20 | current_image: Option>>, 21 | current_image_info: Option, 22 | search_term: String, 23 | app_mode: AppMode, 24 | }, 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub struct TermSize { 29 | pub width: u32, 30 | pub height: u32, 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | pub struct ImageInfo { 35 | pub name: String, 36 | pub size: u64, 37 | pub dimensions: (u32, u32), 38 | } 39 | 40 | impl<'a> AppState<'a> { 41 | pub fn initialized(path: &str) -> Self { 42 | let paths = utils::get_image_paths(path); 43 | let selected_index = 0; 44 | let current_image = None; 45 | let term_size = None; 46 | let current_image_info = None; 47 | let search_term = "".to_string(); 48 | let app_mode = AppMode::Normal; 49 | Self::Initialized { 50 | paths, 51 | path: path.to_string(), 52 | selected_index, 53 | term_size, 54 | current_image, 55 | current_image_info, 56 | search_term, 57 | app_mode, 58 | } 59 | } 60 | 61 | pub fn is_initialized(&self) -> bool { 62 | matches!(self, &Self::Initialized { .. }) 63 | } 64 | 65 | pub fn get_paths(&self) -> Vec { 66 | if let Self::Initialized { paths, .. } = self { 67 | paths.clone() 68 | } else { 69 | vec![] 70 | } 71 | } 72 | 73 | pub fn filter_paths(&mut self) { 74 | if let Self::Initialized { 75 | paths: self_paths, 76 | path, 77 | search_term, 78 | .. 79 | } = self 80 | { 81 | let matcher = SkimMatcherV2::default(); 82 | 83 | // TODO: Load the image paths only once and filter it there. 84 | let paths = utils::get_image_paths(path) 85 | .into_iter() 86 | .filter(|path| { 87 | let file_name = path 88 | .file_name() 89 | .unwrap() 90 | .to_os_string() 91 | .into_string() 92 | .unwrap(); 93 | match matcher.fuzzy_match(&file_name, search_term) { 94 | Some(_) => true, 95 | None => false, 96 | } 97 | }) 98 | .collect(); 99 | *self_paths = paths; 100 | } 101 | } 102 | 103 | pub fn get_path(&self, index: usize) -> Option { 104 | if let Self::Initialized { paths, .. } = self { 105 | if paths.is_empty() { 106 | None 107 | } else { 108 | Some(paths[index].clone()) 109 | } 110 | } else { 111 | None 112 | } 113 | } 114 | 115 | pub fn increment_index(&mut self) { 116 | if let Self::Initialized { 117 | selected_index, 118 | paths: images, 119 | .. 120 | } = self 121 | { 122 | *selected_index += 1; 123 | if *selected_index >= images.len() { 124 | *selected_index = 0; 125 | } 126 | } 127 | } 128 | 129 | pub fn decrement_index(&mut self) { 130 | if let Self::Initialized { 131 | selected_index, 132 | paths: images, 133 | .. 134 | } = self 135 | { 136 | if images.is_empty() { 137 | return; 138 | } 139 | 140 | if *selected_index == 0 { 141 | *selected_index = images.len(); 142 | } 143 | *selected_index -= 1; 144 | } 145 | } 146 | 147 | pub fn get_index(&self) -> Option { 148 | if let Self::Initialized { selected_index, .. } = self { 149 | Some(*selected_index) 150 | } else { 151 | None 152 | } 153 | } 154 | 155 | pub fn set_term_size(&mut self, width: u32, height: u32) { 156 | if let Self::Initialized { term_size, .. } = self { 157 | *term_size = Some(TermSize { width, height }); 158 | } 159 | } 160 | 161 | pub fn get_term_size(&self) -> Option { 162 | if let Self::Initialized { term_size, .. } = self { 163 | term_size.clone() 164 | } else { 165 | None 166 | } 167 | } 168 | 169 | pub fn set_current_image(&mut self, img: Vec>) { 170 | if let Self::Initialized { current_image, .. } = self { 171 | *current_image = Some(img); 172 | } 173 | } 174 | 175 | pub fn get_current_image(&self) -> Option>> { 176 | if let Self::Initialized { current_image, .. } = self { 177 | current_image.clone() 178 | } else { 179 | None 180 | } 181 | } 182 | 183 | pub fn set_current_image_info(&mut self, image_info: ImageInfo) { 184 | if let Self::Initialized { 185 | current_image_info, .. 186 | } = self 187 | { 188 | *current_image_info = Some(image_info); 189 | } 190 | } 191 | 192 | pub fn get_current_image_info(&self) -> Option { 193 | if let Self::Initialized { 194 | current_image_info, .. 195 | } = self 196 | { 197 | current_image_info.clone() 198 | } else { 199 | None 200 | } 201 | } 202 | 203 | pub fn get_search_term(&self) -> &str { 204 | let Self::Initialized { search_term, .. } = self else { 205 | return ""; 206 | }; 207 | search_term.as_ref() 208 | } 209 | 210 | pub fn set_search_term(&mut self, arg: String) { 211 | if let Self::Initialized { search_term, .. } = self { 212 | *search_term = arg; 213 | } 214 | } 215 | 216 | pub fn set_app_mode(&mut self, mode: AppMode) { 217 | if let Self::Initialized { app_mode, .. } = self { 218 | *app_mode = mode; 219 | } 220 | } 221 | pub fn get_app_mode(&self) -> AppMode { 222 | let Self::Initialized { app_mode, .. } = self else { 223 | return AppMode::Normal; 224 | }; 225 | app_mode.clone() 226 | } 227 | } 228 | 229 | impl<'a> Default for AppState<'a> { 230 | fn default() -> Self { 231 | Self::Init 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/app/ui/help.rs: -------------------------------------------------------------------------------- 1 | use crate::app::Actions; 2 | use tui::{ 3 | layout::Constraint, 4 | style::{Color, Style}, 5 | text::Span, 6 | widgets::{Block, BorderType, Borders, Cell, Row, Table}, 7 | }; 8 | 9 | pub fn draw(actions: &Actions) -> Table { 10 | let key_style = Style::default().fg(Color::LightCyan); 11 | let help_style = Style::default().fg(Color::Gray); 12 | 13 | let mut rows = vec![]; 14 | for action in actions.actions().iter() { 15 | let keys: Vec = action.keys().iter().map(|k| k.to_string()).collect(); 16 | let key = keys.join(", "); 17 | let row = Row::new(vec![ 18 | Cell::from(Span::styled(key, key_style)), 19 | Cell::from(Span::styled(action.to_string(), help_style)), 20 | ]); 21 | rows.push(row); 22 | } 23 | 24 | Table::new(rows) 25 | .block( 26 | Block::default() 27 | .borders(Borders::ALL) 28 | .border_type(BorderType::Plain), 29 | ) 30 | .widths(&[Constraint::Length(30), Constraint::Percentage(70)]) 31 | .column_spacing(1) 32 | } 33 | -------------------------------------------------------------------------------- /src/app/ui/image.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::AppState; 2 | use tui::{ 3 | layout::Alignment, 4 | style::{Color, Style}, 5 | widgets::{Block, BorderType, Borders, Paragraph}, 6 | }; 7 | 8 | pub fn draw<'a>(state: &'a AppState) -> Paragraph<'a> { 9 | let result = if let Some(current_image) = state.get_current_image() { 10 | current_image 11 | } else { 12 | vec![] 13 | }; 14 | 15 | Paragraph::new(result) 16 | .block( 17 | Block::default() 18 | .borders(Borders::ALL) 19 | .style(Style::default().fg(Color::White)) 20 | .border_type(BorderType::Plain), 21 | ) 22 | .alignment(Alignment::Center) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/ui/image_list.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::AppState; 2 | use tui::{ 3 | style::{Color, Style}, 4 | widgets::{Block, BorderType, Borders, List, ListItem}, 5 | }; 6 | 7 | pub fn draw<'a>(state: &AppState) -> List<'a> { 8 | let list_items: Vec = state 9 | .get_paths() 10 | .iter() 11 | .map(|img| { 12 | ListItem::new( 13 | img.file_name() 14 | .unwrap() 15 | .to_os_string() 16 | .into_string() 17 | .unwrap(), 18 | ) 19 | }) 20 | .collect(); 21 | 22 | List::new(list_items).highlight_symbol(">>").block( 23 | Block::default() 24 | .borders(Borders::ALL) 25 | .style(Style::default().fg(Color::White)) 26 | .border_type(BorderType::Plain), 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/app/ui/info.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::AppState; 2 | use byte_unit::Byte; 3 | use tui::{ 4 | layout::Constraint, 5 | style::{Color, Style}, 6 | text::Span, 7 | widgets::{Block, BorderType, Borders, Cell, Row, Table}, 8 | }; 9 | 10 | pub fn draw<'a>(state: &AppState) -> Table<'a> { 11 | let key_style = Style::default().fg(Color::LightCyan); 12 | let value_style = Style::default().fg(Color::Gray); 13 | 14 | let rows = if let Some(image_info) = state.get_current_image_info() { 15 | let size = Byte::from(image_info.size) 16 | .get_appropriate_unit(false) 17 | .to_string(); 18 | 19 | vec![ 20 | Row::new(vec![ 21 | Cell::from(Span::styled("Name", key_style)), 22 | Cell::from(Span::styled(image_info.name, value_style)), 23 | ]), 24 | Row::new(vec![ 25 | Cell::from(Span::styled("Dimensions", key_style)), 26 | Cell::from(Span::styled( 27 | format!("{}x{}", image_info.dimensions.0, image_info.dimensions.1), 28 | value_style, 29 | )), 30 | ]), 31 | Row::new(vec![ 32 | Cell::from(Span::styled("Size", key_style)), 33 | Cell::from(Span::styled(size, value_style)), 34 | ]), 35 | ] 36 | } else { 37 | vec![] 38 | }; 39 | 40 | Table::new(rows) 41 | .block( 42 | Block::default() 43 | .borders(Borders::ALL) 44 | .border_type(BorderType::Plain), 45 | ) 46 | .widths(&[Constraint::Length(15), Constraint::Percentage(85)]) 47 | .column_spacing(1) 48 | } 49 | -------------------------------------------------------------------------------- /src/app/ui/loading.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::Alignment, 3 | style::{Color, Modifier, Style}, 4 | text::Span, 5 | widgets::{Block, BorderType, Borders, Paragraph, Wrap}, 6 | }; 7 | 8 | pub fn draw<'a>() -> Paragraph<'a> { 9 | Paragraph::new(Span::styled( 10 | "Loading...", 11 | Style::default() 12 | .fg(Color::White) 13 | .add_modifier(Modifier::BOLD), 14 | )) 15 | .block( 16 | Block::default() 17 | .borders(Borders::ALL) 18 | .style(Style::default().fg(Color::White)) 19 | .border_type(BorderType::Plain), 20 | ) 21 | .alignment(Alignment::Center) 22 | .wrap(Wrap { trim: true }) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod help; 2 | mod image; 3 | mod image_list; 4 | mod info; 5 | mod loading; 6 | mod search; 7 | mod title; 8 | 9 | use crate::app::App; 10 | use std::rc::Rc; 11 | use tui::{ 12 | backend::Backend, 13 | layout::{Constraint, Direction, Layout, Rect}, 14 | widgets::ListState, 15 | Frame, 16 | }; 17 | 18 | use super::state::AppMode; 19 | 20 | pub fn draw(rect: &mut Frame, app: &mut App) 21 | where 22 | B: Backend, 23 | { 24 | let size = rect.size(); 25 | 26 | let main_chunks = main_layout(size); 27 | let header_chunks = header_layout(main_chunks[0]); 28 | let body_chunks = body_layout(main_chunks[1]); 29 | let info_chunks = info_layout(header_chunks[1]); 30 | let search_chunks = search_layout(body_chunks[0]); 31 | let mut body_chunk = body_chunks[0]; 32 | 33 | let title = title::draw(); 34 | let help = help::draw(app.actions()); 35 | let info = info::draw(app.state()); 36 | let image_list = image_list::draw(app.state()); 37 | 38 | rect.render_widget(title, header_chunks[0]); 39 | rect.render_widget(help, info_chunks[0]); 40 | rect.render_widget(info, info_chunks[1]); 41 | 42 | if app.state.get_app_mode() == AppMode::Search { 43 | let block = search::draw(app.state.get_search_term()); 44 | rect.render_widget(block, search_chunks[1]); 45 | body_chunk = search_chunks[0]; 46 | } 47 | 48 | let mut state = ListState::default(); 49 | state.select(app.state.get_index()); 50 | rect.render_stateful_widget(image_list, body_chunk, &mut state); 51 | 52 | let w = body_chunks[1].width as u32; 53 | let h = body_chunks[1].height as u32; 54 | app.state.set_term_size(w, h); 55 | 56 | if app.is_loading() && app.state.get_current_image().is_some() { 57 | let loading = loading::draw(); 58 | rect.render_widget(loading, body_chunks[1]); 59 | } else { 60 | let image = image::draw(app.state()); 61 | rect.render_widget(image, body_chunks[1]); 62 | } 63 | } 64 | 65 | fn main_layout(rect: Rect) -> Rc<[Rect]> { 66 | Layout::default() 67 | .direction(Direction::Vertical) 68 | .constraints([Constraint::Length(9), Constraint::Percentage(90)]) 69 | .margin(1) 70 | .split(rect) 71 | } 72 | 73 | fn header_layout(rect: Rect) -> Rc<[Rect]> { 74 | Layout::default() 75 | .direction(Direction::Vertical) 76 | .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) 77 | .split(rect) 78 | } 79 | 80 | fn info_layout(rect: Rect) -> Rc<[Rect]> { 81 | Layout::default() 82 | .direction(Direction::Horizontal) 83 | .constraints([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref()) 84 | .split(rect) 85 | } 86 | 87 | fn body_layout(rect: Rect) -> Rc<[Rect]> { 88 | Layout::default() 89 | .direction(Direction::Horizontal) 90 | .constraints([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref()) 91 | .split(rect) 92 | } 93 | 94 | fn search_layout(rect: Rect) -> Rc<[Rect]> { 95 | Layout::default() 96 | .direction(Direction::Vertical) 97 | .constraints([Constraint::Percentage(90), Constraint::Length(1)].as_ref()) 98 | .split(rect) 99 | } 100 | -------------------------------------------------------------------------------- /src/app/ui/search.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | style::{Color, Style}, 3 | widgets::{Block, BorderType, Borders, Paragraph}, 4 | }; 5 | 6 | pub fn draw<'a, 'b>(search_term: &'a str) -> Paragraph<'b> 7 | where 8 | 'a: 'b, 9 | { 10 | Paragraph::new(format!("{}█", search_term)).block( 11 | Block::default() 12 | .title("Search") 13 | .borders(Borders::ALL) 14 | .style(Style::default().fg(Color::White)) 15 | .border_type(BorderType::Plain), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/app/ui/title.rs: -------------------------------------------------------------------------------- 1 | use tui::{ 2 | layout::Alignment, 3 | style::{Color, Style}, 4 | widgets::{Block, Paragraph}, 5 | }; 6 | 7 | pub fn draw<'a>() -> Paragraph<'a> { 8 | Paragraph::new(format!("Picterm v{}", env!("CARGO_PKG_VERSION"))) 9 | .style(Style::default().fg(Color::LightCyan)) 10 | .alignment(Alignment::Center) 11 | .block(Block::default().style(Style::default().fg(Color::White))) 12 | } 13 | -------------------------------------------------------------------------------- /src/image.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::ImageMode; 2 | use ansi_rgb::Colorable; 3 | use image::{DynamicImage, GenericImageView, LumaA, Rgba}; 4 | use rgb::RGB8; 5 | 6 | pub fn image_fit_size(img: &DynamicImage, term_w: u32, term_h: u32) -> (u32, u32) { 7 | let (img_width, img_height) = img.dimensions(); 8 | let (w, h) = get_dimensions(img_width, img_height, term_w, term_h); 9 | let h = if h == term_h { h - 1 } else { h }; 10 | (w, h) 11 | } 12 | 13 | pub fn get_dimensions(width: u32, height: u32, bound_width: u32, bound_height: u32) -> (u32, u32) { 14 | let bound_height = 2 * bound_height; 15 | 16 | if width <= bound_width && height <= bound_height { 17 | return (width, std::cmp::max(1, height / 2 + height % 2)); 18 | } 19 | 20 | let ratio = width * bound_height; 21 | let nratio = bound_width * height; 22 | 23 | let use_width = nratio <= ratio; 24 | let intermediate = if use_width { 25 | height * bound_width / width 26 | } else { 27 | width * bound_height / height 28 | }; 29 | 30 | if use_width { 31 | (bound_width, std::cmp::max(1, intermediate / 2)) 32 | } else { 33 | (intermediate, std::cmp::max(1, bound_height / 2)) 34 | } 35 | } 36 | 37 | pub fn print_term_image(img: DynamicImage, mode: ImageMode) { 38 | let size = crossterm::terminal::size().unwrap(); 39 | let (w, h) = image_fit_size(&img, size.0 as u32, size.1 as u32); 40 | let imgbuf = img.resize_exact(w, h, image::imageops::FilterType::Triangle); 41 | let (width, height) = imgbuf.dimensions(); 42 | 43 | match mode { 44 | ImageMode::Rgba => { 45 | let imgbuf = imgbuf.to_rgba8(); 46 | for y in 0..height { 47 | for x in 0..width { 48 | let pixel = imgbuf.get_pixel(x, y); 49 | let Rgba(data) = *pixel; 50 | 51 | if data[3] == 0 { 52 | print!(" "); 53 | } else { 54 | let bg = RGB8::new(data[0], data[1], data[2]); 55 | print!("{}", " ".bg(bg)); 56 | } 57 | } 58 | println!(); 59 | } 60 | } 61 | ImageMode::GrayScale => { 62 | let imgbuf = imgbuf.to_luma_alpha8(); 63 | for y in 0..height { 64 | for x in 0..width { 65 | let pixel = imgbuf.get_pixel(x, y); 66 | let LumaA(data) = *pixel; 67 | 68 | if data[1] == 0 { 69 | print!(" "); 70 | } else { 71 | print!("{}", " ".bg(RGB8::new(data[0], data[0], data[0]))); 72 | } 73 | } 74 | println!(); 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/inputs/events.rs: -------------------------------------------------------------------------------- 1 | use super::{key::Key, InputEvent}; 2 | use std::{ 3 | sync::{ 4 | atomic::{AtomicBool, Ordering}, 5 | Arc, 6 | }, 7 | time::Duration, 8 | }; 9 | 10 | pub struct Events { 11 | rx: tokio::sync::mpsc::Receiver, 12 | stop_capture: Arc, 13 | } 14 | 15 | impl Events { 16 | pub fn new(tick_rate: Duration) -> Events { 17 | let (tx, rx) = tokio::sync::mpsc::channel(1000); 18 | let stop_capture = Arc::new(AtomicBool::new(false)); 19 | 20 | let event_tx = tx.clone(); 21 | let event_stop_capture = stop_capture.clone(); 22 | tokio::spawn(async move { 23 | loop { 24 | if crossterm::event::poll(tick_rate).unwrap() { 25 | if let crossterm::event::Event::Key(key) = crossterm::event::read().unwrap() { 26 | let key = Key::from(key); 27 | let _ = event_tx.send(InputEvent::Input(key)).await; 28 | } 29 | } 30 | let _ = event_tx.send(InputEvent::Tick).await; 31 | if event_stop_capture.load(Ordering::Relaxed) { 32 | break; 33 | } 34 | } 35 | }); 36 | 37 | Events { rx, stop_capture } 38 | } 39 | 40 | pub async fn next(&mut self) -> InputEvent { 41 | self.rx.recv().await.unwrap_or(InputEvent::Tick) 42 | } 43 | 44 | pub fn close(&mut self) { 45 | self.stop_capture.store(true, Ordering::Relaxed) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/inputs/key.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event; 2 | use std::fmt::{self, Display, Formatter}; 3 | 4 | /// Represents an key. 5 | #[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)] 6 | pub enum Key { 7 | /// Both Enter (or Return) and numpad Enter 8 | Enter, 9 | /// Tabulation key 10 | Tab, 11 | /// Backspace key 12 | Backspace, 13 | /// Escape key 14 | Esc, 15 | 16 | /// Left arrow 17 | Left, 18 | /// Right arrow 19 | Right, 20 | /// Up arrow 21 | Up, 22 | /// Down arrow 23 | Down, 24 | 25 | /// Insert key 26 | Ins, 27 | /// Delete key 28 | Delete, 29 | /// Home key 30 | Home, 31 | /// End key 32 | End, 33 | /// Page Up key 34 | PageUp, 35 | /// Page Down key 36 | PageDown, 37 | 38 | /// F0 key 39 | F0, 40 | /// F1 key 41 | F1, 42 | /// F2 key 43 | F2, 44 | /// F3 key 45 | F3, 46 | /// F4 key 47 | F4, 48 | /// F5 key 49 | F5, 50 | /// F6 key 51 | F6, 52 | /// F7 key 53 | F7, 54 | /// F8 key 55 | F8, 56 | /// F9 key 57 | F9, 58 | /// F10 key 59 | F10, 60 | /// F11 key 61 | F11, 62 | /// F12 key 63 | F12, 64 | Char(char), 65 | Ctrl(char), 66 | Alt(char), 67 | Unknown, 68 | } 69 | 70 | impl Key { 71 | /// If exit 72 | pub fn is_exit(&self) -> bool { 73 | matches!(self, Key::Ctrl('c') | Key::Char('q') | Key::Esc) 74 | } 75 | 76 | /// Returns the function key corresponding to the given number 77 | /// 78 | /// 1 -> F1, etc... 79 | /// 80 | /// # Panics 81 | /// 82 | /// If `n == 0 || n > 12` 83 | pub fn from_f(n: u8) -> Key { 84 | match n { 85 | 0 => Key::F0, 86 | 1 => Key::F1, 87 | 2 => Key::F2, 88 | 3 => Key::F3, 89 | 4 => Key::F4, 90 | 5 => Key::F5, 91 | 6 => Key::F6, 92 | 7 => Key::F7, 93 | 8 => Key::F8, 94 | 9 => Key::F9, 95 | 10 => Key::F10, 96 | 11 => Key::F11, 97 | 12 => Key::F12, 98 | _ => panic!("unknown function key: F{}", n), 99 | } 100 | } 101 | pub fn key_char(&self) -> String { 102 | match self { 103 | Key::Char(ch) => format!("{}", ch), 104 | _ => self.to_string(), 105 | } 106 | } 107 | } 108 | 109 | impl Display for Key { 110 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 111 | match *self { 112 | Key::Alt(' ') => write!(f, ""), 113 | Key::Ctrl(' ') => write!(f, ""), 114 | Key::Char(' ') => write!(f, ""), 115 | Key::Alt(c) => write!(f, "", c), 116 | Key::Ctrl(c) => write!(f, "", c), 117 | Key::Char(c) => write!(f, "<{}>", c), 118 | _ => write!(f, "<{:?}>", self), 119 | } 120 | } 121 | } 122 | 123 | impl From for Key { 124 | fn from(key_event: event::KeyEvent) -> Self { 125 | match key_event { 126 | event::KeyEvent { 127 | code: event::KeyCode::Esc, 128 | .. 129 | } => Key::Esc, 130 | event::KeyEvent { 131 | code: event::KeyCode::Backspace, 132 | .. 133 | } => Key::Backspace, 134 | event::KeyEvent { 135 | code: event::KeyCode::Left, 136 | .. 137 | } => Key::Left, 138 | event::KeyEvent { 139 | code: event::KeyCode::Right, 140 | .. 141 | } => Key::Right, 142 | event::KeyEvent { 143 | code: event::KeyCode::Up, 144 | .. 145 | } => Key::Up, 146 | event::KeyEvent { 147 | code: event::KeyCode::Down, 148 | .. 149 | } => Key::Down, 150 | event::KeyEvent { 151 | code: event::KeyCode::Home, 152 | .. 153 | } => Key::Home, 154 | event::KeyEvent { 155 | code: event::KeyCode::End, 156 | .. 157 | } => Key::End, 158 | event::KeyEvent { 159 | code: event::KeyCode::PageUp, 160 | .. 161 | } => Key::PageUp, 162 | event::KeyEvent { 163 | code: event::KeyCode::PageDown, 164 | .. 165 | } => Key::PageDown, 166 | event::KeyEvent { 167 | code: event::KeyCode::Delete, 168 | .. 169 | } => Key::Delete, 170 | event::KeyEvent { 171 | code: event::KeyCode::Insert, 172 | .. 173 | } => Key::Ins, 174 | event::KeyEvent { 175 | code: event::KeyCode::F(n), 176 | .. 177 | } => Key::from_f(n), 178 | event::KeyEvent { 179 | code: event::KeyCode::Enter, 180 | .. 181 | } => Key::Enter, 182 | event::KeyEvent { 183 | code: event::KeyCode::Tab, 184 | .. 185 | } => Key::Tab, 186 | 187 | // First check for char + modifier 188 | event::KeyEvent { 189 | code: event::KeyCode::Char(c), 190 | modifiers: event::KeyModifiers::ALT, 191 | } => Key::Alt(c), 192 | event::KeyEvent { 193 | code: event::KeyCode::Char(c), 194 | modifiers: event::KeyModifiers::CONTROL, 195 | } => Key::Ctrl(c), 196 | 197 | event::KeyEvent { 198 | code: event::KeyCode::Char(c), 199 | .. 200 | } => Key::Char(c), 201 | 202 | _ => Key::Unknown, 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/inputs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod events; 2 | pub mod key; 3 | 4 | use crate::inputs::key::Key; 5 | 6 | pub enum InputEvent { 7 | Input(Key), 8 | Tick, 9 | } 10 | -------------------------------------------------------------------------------- /src/io/handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{state::ImageInfo, App}, 3 | image::image_fit_size, 4 | io::IoEvent, 5 | utils::ImageMode, 6 | }; 7 | use eyre::Result; 8 | use image::{GenericImageView, LumaA, Rgba}; 9 | use std::sync::Arc; 10 | use tui::{ 11 | style::{Color, Style}, 12 | text::{Line, Span}, 13 | }; 14 | 15 | pub struct IoAsyncHandler<'a> { 16 | app: Arc>>, 17 | } 18 | 19 | impl<'a> IoAsyncHandler<'a> { 20 | pub fn new(app: Arc>>) -> Self { 21 | Self { app } 22 | } 23 | 24 | pub async fn handle_io_event(&mut self, io_event: IoEvent) { 25 | let _ = match io_event { 26 | IoEvent::Initialize(path) => self.do_initialize(&path).await, 27 | IoEvent::LoadImage => self.do_load_image().await, 28 | }; 29 | 30 | let mut app = self.app.lock().await; 31 | app.loaded(); 32 | } 33 | 34 | async fn do_initialize(&mut self, path: &str) -> Result<()> { 35 | let mut app = self.app.lock().await; 36 | app.initialized(path); 37 | 38 | Ok(()) 39 | } 40 | 41 | async fn do_load_image(&mut self) -> Result<()> { 42 | let result = Arc::new(tokio::sync::Mutex::new(vec![])); 43 | 44 | let opt_index = { 45 | let app = self.app.lock().await; 46 | app.state.get_index() 47 | }; 48 | 49 | let opt_path = { 50 | let app = self.app.lock().await; 51 | match opt_index { 52 | Some(index) => app.state.get_path(index), 53 | None => None, 54 | } 55 | }; 56 | 57 | let opt_term_size = { 58 | let app = self.app.lock().await; 59 | app.state.get_term_size() 60 | }; 61 | 62 | let mode = { 63 | let app = self.app.lock().await; 64 | app.config.image_mode.clone() 65 | }; 66 | 67 | { 68 | if let Some(path) = opt_path { 69 | if let Some(term_size) = opt_term_size { 70 | let img = tokio::task::block_in_place(|| image::open(&path))?; 71 | let name = path 72 | .file_name() 73 | .unwrap_or_default() 74 | .to_str() 75 | .unwrap() 76 | .to_string(); 77 | let size = match path.metadata() { 78 | Ok(metadata) => metadata.len(), 79 | Err(_) => 0, 80 | }; 81 | let info = ImageInfo { 82 | name, 83 | size, 84 | dimensions: img.dimensions(), 85 | }; 86 | 87 | let (w, h) = image_fit_size(&img, term_size.width, term_size.height); 88 | let imgbuf = img.resize_exact(w, h, image::imageops::FilterType::Triangle); 89 | let (width, height) = imgbuf.dimensions(); 90 | 91 | let mut r = result.lock().await; 92 | 93 | match mode { 94 | ImageMode::Rgba => { 95 | let imgbuf = imgbuf.to_rgba8(); 96 | for y in 0..height { 97 | let mut line = vec![]; 98 | for x in 0..width { 99 | let pixel = imgbuf.get_pixel(x, y); 100 | let Rgba(data) = *pixel; 101 | 102 | if data[3] == 0 { 103 | line.push(Span::from(" ")); 104 | } else { 105 | line.push(Span::styled( 106 | " ", 107 | Style::default() 108 | .bg(Color::Rgb(data[0], data[1], data[2])), 109 | )); 110 | } 111 | } 112 | (*r).push(Line::from(line)) 113 | } 114 | } 115 | ImageMode::GrayScale => { 116 | let imgbuf = imgbuf.to_luma_alpha8(); 117 | for y in 0..height { 118 | let mut line = vec![]; 119 | for x in 0..width { 120 | let pixel = imgbuf.get_pixel(x, y); 121 | let LumaA(data) = *pixel; 122 | 123 | if data[1] == 0 { 124 | line.push(Span::from(" ")); 125 | } else { 126 | line.push(Span::styled( 127 | " ", 128 | Style::default() 129 | .bg(Color::Rgb(data[0], data[0], data[0])), 130 | )); 131 | } 132 | } 133 | (*r).push(Line::from(line)) 134 | } 135 | } 136 | } 137 | 138 | let mut app = self.app.lock().await; 139 | app.state.set_current_image_info(info); 140 | app.state.set_current_image((*r).clone()); 141 | } 142 | } 143 | } 144 | 145 | Ok(()) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/io/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod handler; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum IoEvent { 5 | Initialize(String), 6 | LoadImage, 7 | } 8 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod image; 3 | pub mod inputs; 4 | pub mod io; 5 | pub mod utils; 6 | 7 | use crate::{ 8 | app::{ui, App, AppReturn}, 9 | io::IoEvent, 10 | }; 11 | use crossterm::{ 12 | event::{DisableMouseCapture, EnableMouseCapture}, 13 | execute, 14 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 15 | }; 16 | use eyre::Result; 17 | use inputs::{events::Events, InputEvent}; 18 | use std::{io::stdout, sync::Arc, time::Duration}; 19 | use tui::{backend::CrosstermBackend, Terminal}; 20 | 21 | pub async fn start_ui<'a>(app: &Arc>>, path: String) -> Result<()> { 22 | let mut stdout = stdout(); 23 | enable_raw_mode()?; 24 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 25 | let backend = CrosstermBackend::new(stdout); 26 | let mut terminal = Terminal::new(backend)?; 27 | terminal.clear()?; 28 | terminal.hide_cursor()?; 29 | 30 | let tick_rate = Duration::from_millis(100); 31 | let mut events = Events::new(tick_rate); 32 | 33 | { 34 | let mut app = app.lock().await; 35 | app.dispatch(IoEvent::Initialize(path)).await; 36 | } 37 | 38 | loop { 39 | let mut app = app.lock().await; 40 | 41 | terminal.draw(|rect| ui::draw(rect, &mut app))?; 42 | 43 | let result = match events.next().await { 44 | InputEvent::Input(key) => app.do_action(key).await, 45 | InputEvent::Tick => app.update_on_tick().await, 46 | }; 47 | 48 | if result == AppReturn::Exit { 49 | events.close(); 50 | break; 51 | } 52 | } 53 | 54 | disable_raw_mode()?; 55 | execute!( 56 | terminal.backend_mut(), 57 | LeaveAlternateScreen, 58 | DisableMouseCapture 59 | )?; 60 | terminal.show_cursor()?; 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use eyre::Result; 2 | use picterm::{ 3 | app::App, 4 | image::print_term_image, 5 | io::{handler::IoAsyncHandler, IoEvent}, 6 | start_ui, 7 | utils::{select_mode, ImageMode, RunMode}, 8 | }; 9 | use seahorse::{App as SeahorseApp, Context, Flag, FlagType}; 10 | use std::{env, sync::Arc}; 11 | 12 | fn main() -> Result<()> { 13 | let args = env::args().collect(); 14 | let cli_app = SeahorseApp::new(env!("CARGO_PKG_NAME")) 15 | .description(env!("CARGO_PKG_DESCRIPTION")) 16 | .author(env!("CARGO_PKG_AUTHORS")) 17 | .version(env!("CARGO_PKG_VERSION")) 18 | .usage(format!( 19 | "{} [directory or image file]", 20 | env!("CARGO_PKG_NAME") 21 | )) 22 | .flag( 23 | Flag::new("gray", FlagType::Bool) 24 | .alias("g") 25 | .description("Gray scale mode"), 26 | ) 27 | .action(action); 28 | 29 | cli_app.run(args); 30 | 31 | Ok(()) 32 | } 33 | 34 | fn action(c: &Context) { 35 | match select_mode(&c.args) { 36 | RunMode::CLI => cli_main(c), 37 | RunMode::TUI => tui_main(c), 38 | } 39 | } 40 | 41 | fn cli_main(c: &Context) { 42 | let img = image::open(&c.args[0]).unwrap(); 43 | let mode = if c.bool_flag("gray") { 44 | ImageMode::GrayScale 45 | } else { 46 | ImageMode::Rgba 47 | }; 48 | 49 | print_term_image(img, mode); 50 | } 51 | 52 | fn tui_main(c: &Context) { 53 | let rt = tokio::runtime::Runtime::new().unwrap(); 54 | rt.block_on(async { 55 | let (sync_io_tx, mut sync_io_rx) = tokio::sync::mpsc::channel::(1000); 56 | let mode = if c.bool_flag("gray") { 57 | ImageMode::GrayScale 58 | } else { 59 | ImageMode::Rgba 60 | }; 61 | 62 | let app = Arc::new(tokio::sync::Mutex::new(App::new(sync_io_tx.clone(), mode))); 63 | let app_ui = Arc::clone(&app); 64 | 65 | let path = match c.args.len() { 66 | 1 => c.args[0].clone(), 67 | _ => "./".to_string(), 68 | }; 69 | 70 | tokio::spawn(async move { 71 | let mut handler = IoAsyncHandler::new(app); 72 | while let Some(io_event) = sync_io_rx.recv().await { 73 | handler.handle_io_event(io_event).await; 74 | } 75 | }); 76 | 77 | start_ui(&app_ui, path).await.unwrap(); 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | static FILE_TYPES: [&str; 6] = ["png", "jpg", "jpeg", "webp", "bmp", "gif"]; 7 | 8 | #[derive(Debug, Clone)] 9 | pub enum RunMode { 10 | CLI, 11 | TUI, 12 | } 13 | 14 | #[derive(Debug, Clone)] 15 | pub enum ImageMode { 16 | Rgba, 17 | GrayScale, 18 | } 19 | 20 | pub fn get_image_paths(path: &str) -> Vec { 21 | let paths = fs::read_dir(path).unwrap(); 22 | let mut result = vec![]; 23 | 24 | for path in paths { 25 | let path = path.unwrap().path(); 26 | if let Some(ext) = path.extension() { 27 | if FILE_TYPES.iter().any(|&f| f == ext) { 28 | result.push(path); 29 | } 30 | } 31 | } 32 | 33 | result.sort_by(|a, b| a.file_name().cmp(&b.file_name())); 34 | 35 | result 36 | } 37 | 38 | pub fn select_mode(args: &[String]) -> RunMode { 39 | match args.len() { 40 | 0 => RunMode::TUI, 41 | 1 => { 42 | if Path::new(&args[0]).is_dir() { 43 | RunMode::TUI 44 | } else if FILE_TYPES 45 | .contains(&Path::new(&args[0]).extension().unwrap().to_str().unwrap()) 46 | { 47 | RunMode::CLI 48 | } else { 49 | eprintln!("The argument must be a directory or a single image file."); 50 | std::process::exit(1); 51 | } 52 | } 53 | _ => { 54 | eprintln!("The argument must be a directory or a single image file."); 55 | std::process::exit(1); 56 | } 57 | } 58 | } 59 | --------------------------------------------------------------------------------