├── .config ├── config.default.toml ├── dracula.yaml ├── github.yaml └── rose-pine.yaml ├── .data └── vhs.tape ├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── release-plz.yml │ └── release.yml ├── .gitignore ├── .release-plz.toml ├── .rustfmt.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── rust-toolchain.toml └── src ├── action.rs ├── app.rs ├── cli.rs ├── command.rs ├── config.rs ├── crates_io_api_helper.rs ├── errors.rs ├── events.rs ├── logging.rs ├── main.rs ├── serde_helper.rs ├── tui.rs ├── widgets.rs └── widgets ├── crate_info_table.rs ├── help.rs ├── popup_message.rs ├── search_filter_prompt.rs ├── search_page.rs ├── search_results.rs ├── status_bar.rs ├── summary.rs └── tabs.rs /.config/config.default.toml: -------------------------------------------------------------------------------- 1 | tick_rate = 1.0 2 | frame_rate = 15.0 3 | key_refresh_rate = 0.5 4 | enable_mouse = false 5 | enable_paste = false 6 | prompt_padding = 1 7 | 8 | [key_bindings.common] 9 | Esc = "Quit" 10 | Tab = "NextTab" 11 | Shift-Tab = "PreviousTab" 12 | Shift-Backtab = "PreviousTab" 13 | Down = "ScrollDown" 14 | Up = "ScrollUp" 15 | Home= "ScrollTop" 16 | End= "ScrollBottom" 17 | 18 | [key_bindings.popup] 19 | "?" = { SwitchMode = "help" } 20 | Enter = "ClosePopup" 21 | Esc = "ClosePopup" 22 | q = "ClosePopup" 23 | j = "ScrollDown" 24 | k = "ScrollUp" 25 | 26 | [key_bindings.picker_show_crate_info] 27 | "?" = { SwitchMode = "help" } 28 | q = { SwitchMode = "picker_hide_crate_info" } 29 | Esc= { SwitchMode = "picker_hide_crate_info" } 30 | "/" = { SwitchMode = "search" } 31 | "f" = { SwitchMode = "filter" } 32 | 33 | j = "ScrollDown" 34 | k = "ScrollUp" 35 | 36 | "" = "ScrollTop" 37 | G= "ScrollBottom" 38 | 39 | l = "IncrementPage" 40 | h = "DecrementPage" 41 | Left = "DecrementPage" 42 | Right = "IncrementPage" 43 | 44 | ctrl-j = "ScrollCrateInfoDown" 45 | ctrl-k = "ScrollCrateInfoUp" 46 | 47 | ctrl-s = { ToggleSortBy = { reload = true, forward = true } } 48 | alt-s = { ToggleSortBy = { reload = true, forward = false } } 49 | 50 | r = "ReloadData" 51 | Enter = "ToggleShowCrateInfo" 52 | "" = "OpenDocsUrlInBrowser" 53 | "" = "OpenCratesIOUrlInBrowser" 54 | c = "CopyCargoAddCommandToClipboard" 55 | 56 | 57 | [key_bindings.picker_hide_crate_info] 58 | "?" = { SwitchMode = "help" } 59 | "/" = { SwitchMode = "search" } 60 | f = { SwitchMode = "filter" } 61 | 62 | j = "ScrollDown" 63 | k = "ScrollUp" 64 | 65 | "" = "ScrollTop" 66 | G = "ScrollBottom" 67 | 68 | l = "IncrementPage" 69 | h = "DecrementPage" 70 | Left = "DecrementPage" 71 | Right = "IncrementPage" 72 | 73 | ctrl-j = "ScrollCrateInfoDown" 74 | ctrl-k = "ScrollCrateInfoUp" 75 | 76 | ctrl-s = { ToggleSortBy = { reload = true, forward = true } } 77 | alt-s = { ToggleSortBy = { reload = true, forward = false } } 78 | 79 | r = "ReloadData" 80 | Enter = "ToggleShowCrateInfo" 81 | "" = "OpenDocsUrlInBrowser" 82 | "" = "OpenCratesIOUrlInBrowser" 83 | c = "CopyCargoAddCommandToClipboard" 84 | 85 | 86 | [key_bindings.summary] 87 | "?" = { SwitchMode = "help" } 88 | q = "Quit" 89 | j = "ScrollDown" 90 | k = "ScrollUp" 91 | Down = "ScrollDown" 92 | Up = "ScrollUp" 93 | h = "PreviousSummaryMode" 94 | Left = "PreviousSummaryMode" 95 | l = "NextSummaryMode" 96 | Right = "NextSummaryMode" 97 | "Enter" = "OpenCratesIOUrlInBrowser" 98 | 99 | [key_bindings.help] 100 | Esc = "SwitchToLastMode" 101 | q = "SwitchToLastMode" 102 | j = "ScrollDown" 103 | k = "ScrollUp" 104 | 105 | 106 | [key_bindings.search] 107 | "F1" = { SwitchMode = "help" } 108 | ctrl-s = { ToggleSortBy = { reload = false, forward = true } } 109 | alt-s = { ToggleSortBy = { reload = false, forward = false } } 110 | Esc = { SwitchMode = "picker_hide_crate_info" } 111 | Enter = "SubmitSearch" 112 | ctrl-j = "ScrollSearchResultsDown" 113 | ctrl-k = "ScrollSearchResultsUp" 114 | 115 | [key_bindings.filter] 116 | "F1" = { SwitchMode = "help" } 117 | Esc = { SwitchMode = "picker_hide_crate_info" } 118 | Enter = { SwitchMode = "picker_hide_crate_info" } 119 | ctrl-j = "ScrollSearchResultsDown" 120 | ctrl-k = "ScrollSearchResultsUp" 121 | -------------------------------------------------------------------------------- /.config/dracula.yaml: -------------------------------------------------------------------------------- 1 | scheme: "Dracula" 2 | author: "Mike Barkmin (http://github.com/mikebarkmin) based on Dracula Theme (http://github.com/dracula)" 3 | base00: "#282936" 4 | base01: "#3a3c4e" 5 | base02: "#4d4f68" 6 | base03: "#626483" 7 | base04: "#62d6e8" 8 | base05: "#e9e9f4" 9 | base06: "#f1f2f8" 10 | base07: "#f7f7fb" 11 | base08: "#ea51b2" 12 | base09: "#b45bcf" 13 | base0a: "#00f769" 14 | base0b: "#ebff87" 15 | base0c: "#a1efe4" 16 | base0d: "#62d6e8" 17 | base0e: "#b45bcf" 18 | base0f: "#00f769" 19 | -------------------------------------------------------------------------------- /.config/github.yaml: -------------------------------------------------------------------------------- 1 | scheme: "Github" 2 | author: "Defman21" 3 | base00: "#ffffff" 4 | base01: "#f5f5f5" 5 | base02: "#c8c8fa" 6 | base03: "#969896" 7 | base04: "#e8e8e8" 8 | base05: "#333333" 9 | base06: "#ffffff" 10 | base07: "#ffffff" 11 | base08: "#ed6a43" 12 | base09: "#0086b3" 13 | base0a: "#795da3" 14 | base0b: "#183691" 15 | base0c: "#183691" 16 | base0d: "#795da3" 17 | base0e: "#a71d5d" 18 | base0f: "#333333" 19 | -------------------------------------------------------------------------------- /.config/rose-pine.yaml: -------------------------------------------------------------------------------- 1 | scheme: "Rosé Pine" 2 | author: "Emilia Dunfelt " 3 | base00: "#191724" 4 | base01: "#1f1d2e" 5 | base02: "#26233a" 6 | base03: "#6e6a86" 7 | base04: "#908caa" 8 | base05: "#e0def4" 9 | base06: "#e0def4" 10 | base07: "#524f67" 11 | base08: "#eb6f92" 12 | base09: "#f6c177" 13 | base0a: "#ebbcba" 14 | base0b: "#31748f" 15 | base0c: "#9ccfd8" 16 | base0d: "#c4a7e7" 17 | base0e: "#f6c177" 18 | base0f: "#524f67" 19 | -------------------------------------------------------------------------------- /.data/vhs.tape: -------------------------------------------------------------------------------- 1 | # This is a vhs script. See https://github.com/charmbracelet/vhs for more info. 2 | # To run this script, install vhs and run `vhs ./.data/vhs.tape` 3 | Output "target/crates-tui.mov" 4 | Set Theme "Rose Pine" 5 | Set Width 1800 6 | Set Height 1200 7 | Hide 8 | Type "cargo run" 9 | Enter 10 | Sleep 3s 11 | Show 12 | Sleep 2s 13 | Right @2s 3 14 | Sleep 2s 15 | Down @2s 3 16 | Sleep 2s 17 | Tab 1 18 | Sleep 2s 19 | Type @200ms "ratatui" 20 | Sleep 1s 21 | Enter 22 | Sleep 5s 23 | Down @2 3 24 | Enter 25 | Sleep 10s 26 | Down @2 3 27 | Enter 28 | Sleep 2s 29 | Up @0.5 6 30 | Sleep 5s 31 | Type "c" 32 | Sleep 10s 33 | Enter 34 | Sleep 2s 35 | Type "?" 36 | Sleep 5s 37 | Down @2 10 38 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | # FIXME: document this 2 | export CRATES_TUI_CONFIG_HOME=$(pwd)/.config 3 | export CRATES_TUI_DATA_HOME=$(pwd)/.data 4 | export CRATES_TUI_LOG_LEVEL=DEBUG 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "cargo" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | groups: 17 | cargo-dependencies: 18 | patterns: ["*"] 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Build & Test 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - name: Checkout the repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | with: 22 | targets: x86_64-unknown-linux-gnu 23 | components: rustfmt, clippy 24 | 25 | - name: Cache Cargo dependencies 26 | uses: actions/cache@v4 27 | with: 28 | path: | 29 | ~/.cargo/bin/ 30 | ~/.cargo/registry/index/ 31 | ~/.cargo/registry/cache/ 32 | ~/.cargo/git/db/ 33 | target/ 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | 36 | - name: Check the formatting 37 | uses: actions-rs/cargo@v1 38 | with: 39 | command: fmt 40 | args: -- --check --verbose 41 | 42 | - name: Build the project 43 | uses: actions-rs/cargo@v1 44 | with: 45 | command: check 46 | args: --locked --verbose 47 | 48 | - name: Check the lints 49 | uses: actions-rs/cargo@v1 50 | with: 51 | command: clippy 52 | args: --tests --verbose -- -D warnings 53 | 54 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | # see https://marcoieni.github.io/release-plz/github/index.html#example-release-pr-and-release 3 | # for more information 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | on: 10 | push: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | release-plz: 16 | name: Release-plz 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: Install Rust toolchain (stable) 24 | uses: dtolnay/rust-toolchain@stable 25 | - name: Run release-plz 26 | uses: MarcoIeni/release-plz-action@v0.5 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022-2023, axodotdev 2 | # SPDX-License-Identifier: MIT or Apache-2.0 3 | # 4 | # CI that: 5 | # 6 | # * checks for a Git Tag that looks like a release 7 | # * builds artifacts with cargo-dist (archives, installers, hashes) 8 | # * uploads those artifacts to temporary workflow zip 9 | # * on success, uploads the artifacts to a Github Release 10 | # 11 | # Note that the Github Release will be created with a generated 12 | # title/body based on your changelogs. 13 | 14 | name: Release 15 | 16 | permissions: 17 | contents: write 18 | 19 | # This task will run whenever you push a git tag that looks like a version 20 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 21 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 22 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 23 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 24 | # 25 | # If PACKAGE_NAME is specified, then the announcement will be for that 26 | # package (erroring out if it doesn't have the given version or isn't cargo-dist-able). 27 | # 28 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 29 | # (cargo-dist-able) packages in the workspace with that version (this mode is 30 | # intended for workspaces with only one dist-able package, or with all dist-able 31 | # packages versioned/released in lockstep). 32 | # 33 | # If you push multiple tags at once, separate instances of this workflow will 34 | # spin up, creating an independent announcement for each one. However Github 35 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 36 | # mistake. 37 | # 38 | # If there's a prerelease-style suffix to the version, then the release(s) 39 | # will be marked as a prerelease. 40 | on: 41 | push: 42 | tags: 43 | - '**[0-9]+.[0-9]+.[0-9]+*' 44 | pull_request: 45 | 46 | jobs: 47 | # Run 'cargo dist plan' (or host) to determine what tasks we need to do 48 | plan: 49 | runs-on: ubuntu-latest 50 | outputs: 51 | val: ${{ steps.plan.outputs.manifest }} 52 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 53 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 54 | publishing: ${{ !github.event.pull_request }} 55 | env: 56 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: 60 | submodules: recursive 61 | - name: Install cargo-dist 62 | # we specify bash to get pipefail; it guards against the `curl` command 63 | # failing. otherwise `sh` won't catch that `curl` returned non-0 64 | shell: bash 65 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.9.0/cargo-dist-installer.sh | sh" 66 | # sure would be cool if github gave us proper conditionals... 67 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 68 | # functionality based on whether this is a pull_request, and whether it's from a fork. 69 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 70 | # but also really annoying to build CI around when it needs secrets to work right.) 71 | - id: plan 72 | run: | 73 | cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 74 | echo "cargo dist ran successfully" 75 | cat plan-dist-manifest.json 76 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 77 | - name: "Upload dist-manifest.json" 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: artifacts-plan-dist-manifest 81 | path: plan-dist-manifest.json 82 | 83 | # Build and packages all the platform-specific things 84 | build-local-artifacts: 85 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 86 | # Let the initial task tell us to not run (currently very blunt) 87 | needs: 88 | - plan 89 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 90 | strategy: 91 | fail-fast: false 92 | # Target platforms/runners are computed by cargo-dist in create-release. 93 | # Each member of the matrix has the following arguments: 94 | # 95 | # - runner: the github runner 96 | # - dist-args: cli flags to pass to cargo dist 97 | # - install-dist: expression to run to install cargo-dist on the runner 98 | # 99 | # Typically there will be: 100 | # - 1 "global" task that builds universal installers 101 | # - N "local" tasks that build each platform's binaries and platform-specific installers 102 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 103 | runs-on: ${{ matrix.runner }} 104 | env: 105 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 107 | steps: 108 | - uses: actions/checkout@v4 109 | with: 110 | submodules: recursive 111 | - uses: swatinem/rust-cache@v2 112 | - name: Install cargo-dist 113 | run: ${{ matrix.install_dist }} 114 | # Get the dist-manifest 115 | - name: Fetch local artifacts 116 | uses: actions/download-artifact@v4 117 | with: 118 | pattern: artifacts-* 119 | path: target/distrib/ 120 | merge-multiple: true 121 | - name: Install dependencies 122 | run: | 123 | ${{ matrix.packages_install }} 124 | - name: Build artifacts 125 | run: | 126 | # Actually do builds and make zips and whatnot 127 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 128 | echo "cargo dist ran successfully" 129 | - id: cargo-dist 130 | name: Post-build 131 | # We force bash here just because github makes it really hard to get values up 132 | # to "real" actions without writing to env-vars, and writing to env-vars has 133 | # inconsistent syntax between shell and powershell. 134 | shell: bash 135 | run: | 136 | # Parse out what we just built and upload it to scratch storage 137 | echo "paths<> "$GITHUB_OUTPUT" 138 | jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT" 139 | echo "EOF" >> "$GITHUB_OUTPUT" 140 | 141 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 142 | - name: "Upload artifacts" 143 | uses: actions/upload-artifact@v4 144 | with: 145 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 146 | path: | 147 | ${{ steps.cargo-dist.outputs.paths }} 148 | ${{ env.BUILD_MANIFEST_NAME }} 149 | 150 | # Build and package all the platform-agnostic(ish) things 151 | build-global-artifacts: 152 | needs: 153 | - plan 154 | - build-local-artifacts 155 | runs-on: "ubuntu-20.04" 156 | env: 157 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 158 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 159 | steps: 160 | - uses: actions/checkout@v4 161 | with: 162 | submodules: recursive 163 | - name: Install cargo-dist 164 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.9.0/cargo-dist-installer.sh | sh" 165 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 166 | - name: Fetch local artifacts 167 | uses: actions/download-artifact@v4 168 | with: 169 | pattern: artifacts-* 170 | path: target/distrib/ 171 | merge-multiple: true 172 | - id: cargo-dist 173 | shell: bash 174 | run: | 175 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 176 | echo "cargo dist ran successfully" 177 | 178 | # Parse out what we just built and upload it to scratch storage 179 | echo "paths<> "$GITHUB_OUTPUT" 180 | jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT" 181 | echo "EOF" >> "$GITHUB_OUTPUT" 182 | 183 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 184 | - name: "Upload artifacts" 185 | uses: actions/upload-artifact@v4 186 | with: 187 | name: artifacts-build-global 188 | path: | 189 | ${{ steps.cargo-dist.outputs.paths }} 190 | ${{ env.BUILD_MANIFEST_NAME }} 191 | # Determines if we should publish/announce 192 | host: 193 | needs: 194 | - plan 195 | - build-local-artifacts 196 | - build-global-artifacts 197 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 198 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 199 | env: 200 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 201 | runs-on: "ubuntu-20.04" 202 | outputs: 203 | val: ${{ steps.host.outputs.manifest }} 204 | steps: 205 | - uses: actions/checkout@v4 206 | with: 207 | submodules: recursive 208 | - name: Install cargo-dist 209 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.9.0/cargo-dist-installer.sh | sh" 210 | # Fetch artifacts from scratch-storage 211 | - name: Fetch artifacts 212 | uses: actions/download-artifact@v4 213 | with: 214 | pattern: artifacts-* 215 | path: target/distrib/ 216 | merge-multiple: true 217 | # This is a harmless no-op for Github Releases, hosting for that happens in "announce" 218 | - id: host 219 | shell: bash 220 | run: | 221 | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 222 | echo "artifacts uploaded and released successfully" 223 | cat dist-manifest.json 224 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 225 | - name: "Upload dist-manifest.json" 226 | uses: actions/upload-artifact@v4 227 | with: 228 | # Overwrite the previous copy 229 | name: artifacts-dist-manifest 230 | path: dist-manifest.json 231 | 232 | # Create a Github Release while uploading all files to it 233 | announce: 234 | needs: 235 | - plan 236 | - host 237 | # use "always() && ..." to allow us to wait for all publish jobs while 238 | # still allowing individual publish jobs to skip themselves (for prereleases). 239 | # "host" however must run to completion, no skipping allowed! 240 | if: ${{ always() && needs.host.result == 'success' }} 241 | runs-on: "ubuntu-20.04" 242 | env: 243 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 244 | steps: 245 | - uses: actions/checkout@v4 246 | with: 247 | submodules: recursive 248 | - name: "Download Github Artifacts" 249 | uses: actions/download-artifact@v4 250 | with: 251 | pattern: artifacts-* 252 | path: artifacts 253 | merge-multiple: true 254 | - name: Cleanup 255 | run: | 256 | # Remove the granular manifests 257 | rm -f artifacts/*-dist-manifest.json 258 | - name: Create Github Release 259 | uses: ncipollo/release-action@v1 260 | with: 261 | tag: ${{ needs.plan.outputs.tag }} 262 | name: ${{ fromJson(needs.host.outputs.val).announcement_title }} 263 | body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }} 264 | prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }} 265 | artifacts: "artifacts/*" 266 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .data/*.log 3 | -------------------------------------------------------------------------------- /.release-plz.toml: -------------------------------------------------------------------------------- 1 | # configuration for https://github.com/MarcoIeni/release-plz 2 | 3 | [workspace] 4 | 5 | # enable changelog updates 6 | changelog_update = true 7 | 8 | # update dependencies with `cargo update` 9 | dependencies_update = true 10 | 11 | # create tags for the releases 12 | git_tag_enable = true 13 | 14 | # disable GitHub releases 15 | git_release_enable = false 16 | 17 | # labels for the release PR 18 | pr_labels = ["release"] 19 | 20 | # disallow updating repositories with uncommitted changes 21 | allow_dirty = false 22 | 23 | # disallow packaging with uncommitted changes 24 | publish_allow_dirty = false 25 | 26 | # disable running `cargo-semver-checks` 27 | semver_check = false 28 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | reorder_imports = true 2 | use_field_init_shorthand = true 3 | use_try_shorthand = true 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project 6 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.25](https://github.com/ratatui/crates-tui/compare/v0.1.24...v0.1.25) - 2025-02-05 11 | 12 | ### Other 13 | 14 | - remove unused deps (#127) 15 | - *(deps)* bump the cargo-dependencies group across 1 directory with 3 updates (#133) 16 | - *(deps)* bump the cargo-dependencies group across 1 directory with 5 updates (#131) 17 | - *(deps)* bump the cargo-dependencies group with 3 updates (#128) 18 | 19 | ## [0.1.24](https://github.com/ratatui/crates-tui/compare/v0.1.23...v0.1.24) - 2024-12-16 20 | 21 | ### Other 22 | 23 | - *(deps)* bump serde from 1.0.215 to 1.0.216 in the cargo-dependencies group (#124) 24 | 25 | ## [0.1.23](https://github.com/ratatui/crates-tui/compare/v0.1.22...v0.1.23) - 2024-12-09 26 | 27 | ### Other 28 | 29 | - *(deps)* bump the cargo-dependencies group with 5 updates (#122) 30 | 31 | ## [0.1.22](https://github.com/ratatui/crates-tui/compare/v0.1.21...v0.1.22) - 2024-12-03 32 | 33 | ### Fixed 34 | 35 | - url of `just updated` crates ([#120](https://github.com/ratatui/crates-tui/pull/120)) 36 | 37 | ### Other 38 | 39 | - *(deps)* bump the cargo-dependencies group with 6 updates ([#117](https://github.com/ratatui/crates-tui/pull/117)) 40 | 41 | ## [0.1.21](https://github.com/ratatui/crates-tui/compare/v0.1.20...v0.1.21) - 2024-11-20 42 | 43 | ### Other 44 | 45 | - *(deps)* bump the cargo-dependencies group across 1 directory with 9 updates ([#116](https://github.com/ratatui/crates-tui/pull/116)) 46 | - Accept a query parameter to start ([#102](https://github.com/ratatui/crates-tui/pull/102)) 47 | - *(deps)* bump the cargo-dependencies group across 1 directory with 7 updates ([#108](https://github.com/ratatui/crates-tui/pull/108)) 48 | - *(deps)* bump the cargo-dependencies group with 4 updates ([#103](https://github.com/ratatui/crates-tui/pull/103)) 49 | 50 | ## [0.1.20](https://github.com/ratatui/crates-tui/compare/v0.1.19...v0.1.20) - 2024-09-02 51 | 52 | ### Other 53 | - Better help page ([#94](https://github.com/ratatui/crates-tui/pull/94)) 54 | 55 | ## [0.1.19](https://github.com/ratatui/crates-tui/compare/v0.1.18...v0.1.19) - 2024-09-02 56 | 57 | ### Other 58 | - *(deps)* bump the cargo-dependencies group across 1 directory with 8 updates ([#99](https://github.com/ratatui/crates-tui/pull/99)) 59 | 60 | ## [0.1.18](https://github.com/ratatui-org/crates-tui/compare/v0.1.17...v0.1.18) - 2024-08-13 61 | 62 | ### Other 63 | - Fix cli data-dir ([#93](https://github.com/ratatui-org/crates-tui/pull/93)) 64 | 65 | ## [0.1.17](https://github.com/ratatui-org/crates-tui/compare/v0.1.16...v0.1.17) - 2024-07-29 66 | 67 | ### Other 68 | - *(deps)* bump the cargo-dependencies group with 4 updates ([#87](https://github.com/ratatui-org/crates-tui/pull/87)) 69 | 70 | ## [0.1.16](https://github.com/ratatui-org/crates-tui/compare/v0.1.15...v0.1.16) - 2024-07-22 71 | 72 | ### Other 73 | - *(deps)* bump the cargo-dependencies group with 3 updates ([#85](https://github.com/ratatui-org/crates-tui/pull/85)) 74 | 75 | ## [0.1.15](https://github.com/ratatui-org/crates-tui/compare/v0.1.14...v0.1.15) - 2024-07-15 76 | 77 | ### Other 78 | - *(deps)* bump the cargo-dependencies group with 4 updates ([#83](https://github.com/ratatui-org/crates-tui/pull/83)) 79 | 80 | ## [0.1.14](https://github.com/ratatui-org/crates-tui/compare/v0.1.13...v0.1.14) - 2024-07-08 81 | 82 | ### Other 83 | - *(deps)* bump the cargo-dependencies group with 2 updates ([#81](https://github.com/ratatui-org/crates-tui/pull/81)) 84 | 85 | ## [0.1.13](https://github.com/ratatui-org/crates-tui/compare/v0.1.12...v0.1.13) - 2024-07-01 86 | 87 | ### Other 88 | - *(deps)* bump the cargo-dependencies group with 5 updates ([#79](https://github.com/ratatui-org/crates-tui/pull/79)) 89 | 90 | ## [0.1.12](https://github.com/ratatui-org/crates-tui/compare/v0.1.11...v0.1.12) - 2024-06-24 91 | 92 | ### Other 93 | - *(deps)* bump the cargo-dependencies group with 4 updates ([#75](https://github.com/ratatui-org/crates-tui/pull/75)) 94 | 95 | ## [0.1.11](https://github.com/ratatui-org/crates-tui/compare/v0.1.10...v0.1.11) - 2024-06-14 96 | 97 | ### Fixed 98 | - remove unused fields ([#74](https://github.com/ratatui-org/crates-tui/pull/74)) 99 | 100 | ### Other 101 | - *(deps)* bump the cargo-dependencies group across 1 directory with 10 updates ([#73](https://github.com/ratatui-org/crates-tui/pull/73)) 102 | - *(deps)* bump the cargo-dependencies group with 2 updates ([#69](https://github.com/ratatui-org/crates-tui/pull/69)) 103 | - *(deps)* bump the cargo-dependencies group with 4 updates ([#68](https://github.com/ratatui-org/crates-tui/pull/68)) 104 | - *(deps)* bump the cargo-dependencies group with 3 updates ([#66](https://github.com/ratatui-org/crates-tui/pull/66)) 105 | 106 | ## [0.1.10](https://github.com/ratatui-org/crates-tui/compare/v0.1.9...v0.1.10) - 2024-04-26 107 | 108 | ### Other 109 | - *(deps)* bump the cargo-dependencies group with 4 updates ([#64](https://github.com/ratatui-org/crates-tui/pull/64)) 110 | 111 | ## [0.1.9](https://github.com/ratatui-org/crates-tui/compare/v0.1.8...v0.1.9) - 2024-04-15 112 | 113 | ### Added 114 | - Update dependabot.yml to group cargo PRs ([#63](https://github.com/ratatui-org/crates-tui/pull/63)) 115 | 116 | ### Other 117 | - *(deps)* bump webbrowser from 0.8.14 to 0.8.15 ([#62](https://github.com/ratatui-org/crates-tui/pull/62)) 118 | - *(deps)* bump figment from 0.10.15 to 0.10.16 ([#61](https://github.com/ratatui-org/crates-tui/pull/61)) 119 | - *(deps)* bump crates_io_api from 0.9.0 to 0.11.0 ([#60](https://github.com/ratatui-org/crates-tui/pull/60)) 120 | - *(deps)* bump chrono from 0.4.37 to 0.4.38 ([#59](https://github.com/ratatui-org/crates-tui/pull/59)) 121 | - *(deps)* bump ratatui from 0.26.1 to 0.26.2 ([#58](https://github.com/ratatui-org/crates-tui/pull/58)) 122 | - *(deps)* bump webbrowser from 0.8.13 to 0.8.14 ([#56](https://github.com/ratatui-org/crates-tui/pull/56)) 123 | 124 | ## [0.1.8](https://github.com/ratatui-org/crates-tui/compare/v0.1.7...v0.1.8) - 2024-04-01 125 | 126 | ### Fixed 127 | - restore bracketed paste in tui::restore ([#37](https://github.com/ratatui-org/crates-tui/pull/37)) 128 | 129 | ### Other 130 | - use github token instead of kd personal token ([#54](https://github.com/ratatui-org/crates-tui/pull/54)) 131 | - *(deps)* bump mio from 0.8.10 to 0.8.11 ([#55](https://github.com/ratatui-org/crates-tui/pull/55)) 132 | - *(deps)* bump clap from 4.5.3 to 4.5.4 ([#53](https://github.com/ratatui-org/crates-tui/pull/53)) 133 | - *(deps)* bump tokio from 1.36.0 to 1.37.0 ([#52](https://github.com/ratatui-org/crates-tui/pull/52)) 134 | - *(deps)* bump chrono from 0.4.35 to 0.4.37 ([#51](https://github.com/ratatui-org/crates-tui/pull/51)) 135 | - *(deps)* bump toml from 0.8.10 to 0.8.12 ([#50](https://github.com/ratatui-org/crates-tui/pull/50)) 136 | - *(deps)* bump uuid from 1.7.0 to 1.8.0 ([#49](https://github.com/ratatui-org/crates-tui/pull/49)) 137 | - *(deps)* bump color-eyre from 0.6.2 to 0.6.3 ([#47](https://github.com/ratatui-org/crates-tui/pull/47)) 138 | - *(deps)* bump serde_with from 3.6.1 to 3.7.0 ([#43](https://github.com/ratatui-org/crates-tui/pull/43)) 139 | - *(deps)* bump tokio-stream from 0.1.14 to 0.1.15 ([#44](https://github.com/ratatui-org/crates-tui/pull/44)) 140 | - *(deps)* bump figment from 0.10.14 to 0.10.15 ([#45](https://github.com/ratatui-org/crates-tui/pull/45)) 141 | - *(deps)* bump clap from 4.5.2 to 4.5.3 ([#46](https://github.com/ratatui-org/crates-tui/pull/46)) 142 | - *(deps)* bump clap from 4.5.1 to 4.5.2 ([#41](https://github.com/ratatui-org/crates-tui/pull/41)) 143 | - *(deps)* bump webbrowser from 0.8.12 to 0.8.13 ([#42](https://github.com/ratatui-org/crates-tui/pull/42)) 144 | - *(deps)* bump chrono from 0.4.34 to 0.4.35 ([#40](https://github.com/ratatui-org/crates-tui/pull/40)) 145 | - *(deps)* bump strum from 0.26.1 to 0.26.2 ([#39](https://github.com/ratatui-org/crates-tui/pull/39)) 146 | - *(deps)* bump serde from 1.0.196 to 1.0.197 ([#38](https://github.com/ratatui-org/crates-tui/pull/38)) 147 | - *(deps)* bump textwrap from 0.16.0 to 0.16.1 ([#36](https://github.com/ratatui-org/crates-tui/pull/36)) 148 | - *(deps)* bump clap from 4.5.0 to 4.5.1 ([#35](https://github.com/ratatui-org/crates-tui/pull/35)) 149 | - jm/refactor tui ([#33](https://github.com/ratatui-org/crates-tui/pull/33)) 150 | 151 | ## [0.1.7](https://github.com/ratatui-org/crates-tui/compare/v0.1.6...v0.1.7) - 2024-02-12 152 | 153 | ### Other 154 | - clippy ([#32](https://github.com/ratatui-org/crates-tui/pull/32)) 155 | - simplify help.rs ([#27](https://github.com/ratatui-org/crates-tui/pull/27)) 156 | - *(deps)* bump chrono from 0.4.33 to 0.4.34 ([#30](https://github.com/ratatui-org/crates-tui/pull/30)) 157 | - *(deps)* bump ratatui from 0.26.1-alpha.1 to 0.26.1 ([#31](https://github.com/ratatui-org/crates-tui/pull/31)) 158 | - remove the specific crossterm events ([#28](https://github.com/ratatui-org/crates-tui/pull/28)) 159 | 160 | ## [0.1.6](https://github.com/ratatui-org/crates-tui/compare/v0.1.5...v0.1.6) - 2024-02-11 161 | 162 | ### Added 163 | - refactor app.rs ([#21](https://github.com/ratatui-org/crates-tui/pull/21)) 164 | - Increase spacing between top and bottom in summary 165 | 166 | ### Fixed 167 | - version string even without git ([#24](https://github.com/ratatui-org/crates-tui/pull/24)) 168 | 169 | ### Other 170 | - Add AUR instructions ([#22](https://github.com/ratatui-org/crates-tui/pull/22)) 171 | 172 | ## [0.1.5](https://github.com/ratatui-org/crates-tui/compare/v0.1.4...v0.1.5) - 2024-02-09 173 | 174 | ### Added 175 | - Show cargo copy in demo 176 | - Show help in demo 177 | - Add vhs tape 178 | - Better help menu with offset and UX for new users 179 | - Add Action::Ignore 180 | 181 | ### Fixed 182 | - Don't update crate info when scrolling help 183 | - Change resolution in tape 184 | - Missing enter in vhs tape 185 | 186 | ### Other 187 | - Update gitignore to only ignore log files 188 | - Update README.md with new demo 189 | - more tweaks to the demo by scrolling help 190 | - Tweak resolution and timing of the demo 191 | - Increase resolution of demo 192 | - Change demo to move up faster at the end 193 | 194 | ## [0.1.4](https://github.com/ratatui-org/crates-tui/compare/v0.1.3...v0.1.4) - 2024-02-09 195 | 196 | ### Fixed 197 | - version string 198 | 199 | ## [0.1.3](https://github.com/ratatui-org/crates-tui/compare/v0.1.2...v0.1.3) - 2024-02-09 200 | 201 | ### Other 202 | - Remove musl automated build 203 | 204 | ## [0.1.2](https://github.com/ratatui-org/crates-tui/compare/v0.1.1...v0.1.2) - 2024-02-09 205 | 206 | ### Other 207 | - Add token to checkout 208 | 209 | ## [0.1.1](https://github.com/ratatui-org/crates-tui/compare/v0.1.0...v0.1.1) - 2024-02-09 210 | 211 | ### Added 212 | 213 | - Open crates io pages from summary view 214 | - Color theme support and configurable colors 215 | - Better popup scroll 216 | - Add copy cargo add command to clipboard 217 | - Always show spinner in top right 218 | - Add page number 219 | - Better prompt 220 | - Add summary screen 221 | - Only show keywords instead of versions 222 | 223 | ### Fixed 224 | 225 | - Popup scroll bug 226 | 227 | ### Other 228 | 229 | - simplify popup ([#12](https://github.com/ratatui-org/crates-tui/pull/12)) 230 | - better keybinds ([#11](https://github.com/ratatui-org/crates-tui/pull/11)) 231 | - use cfg_if crate for better cfg checks ([#9](https://github.com/ratatui-org/crates-tui/pull/9)) 232 | - move events from tui to events module ([#8](https://github.com/ratatui-org/crates-tui/pull/8)) 233 | - simplify tui, events, errors ([#7](https://github.com/ratatui-org/crates-tui/pull/7)) 234 | - cleanup config.rs ([#6](https://github.com/ratatui-org/crates-tui/pull/6)) 235 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crates-tui" 3 | version = "0.1.25" 4 | edition = "2021" 5 | description = "A TUI for crates.io" 6 | license = "MIT" 7 | repository = "https://github.com/ratatui-org/crates-tui" 8 | authors = ["The Ratatui Developers"] 9 | build = "build.rs" 10 | 11 | [package.metadata.wix] 12 | upgrade-guid = "75B519B6-FF67-49E6-A6D3-5D5794A5A6AA" 13 | path-guid = "C3C0C045-C8A0-4585-A888-BE5C46534B7D" 14 | license = false 15 | eula = false 16 | 17 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 18 | 19 | [dependencies] 20 | better-panic = "0.3.0" 21 | cfg-if = "1.0.0" 22 | chrono = "0.4.39" 23 | clap = { version = "4.5.27", features = [ 24 | "derive", 25 | "cargo", 26 | "wrap_help", 27 | "unicode", 28 | "string", 29 | "unstable-styles", 30 | "color", 31 | ] } 32 | color-eyre = "0.6.3" 33 | copypasta = "0.10.1" 34 | crates_io_api = "0.11.0" 35 | crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } 36 | derive_deref = "1.1.1" 37 | directories = "6.0.0" 38 | figment = { version = "0.10.19", features = ["env", "toml", "yaml"] } 39 | futures = "0.3.31" 40 | human-panic = "2.0.2" 41 | itertools = "0.14.0" 42 | num-format = "0.4.4" 43 | ratatui = { version = "0.29.0", features = ["serde", "macros"] } 44 | serde = { version = "1.0.217", features = ["derive"] } 45 | serde_with = "3.12.0" 46 | strum = { version = "0.26.3", features = ["derive"] } 47 | textwrap = "0.16.1" 48 | tokio = { version = "1.43.0", features = ["full"] } 49 | tokio-stream = "0.1.17" 50 | toml = "0.8.16" 51 | tracing = "0.1.41" 52 | tracing-error = "0.2.1" 53 | tracing-subscriber = { version = "0.3.19", features = [ 54 | "env-filter", 55 | "serde", 56 | "serde_json", 57 | ] } 58 | tui-input = "0.11.1" 59 | unicode-width = "0.2.0" 60 | uuid = "1.12.1" 61 | webbrowser = "1.0.3" 62 | 63 | [build-dependencies] 64 | vergen = { version = "8.3.2", features = ["build", "git", "git2", "cargo"] } 65 | 66 | # The profile that 'cargo dist' will build with 67 | [profile.dist] 68 | inherits = "release" 69 | lto = "thin" 70 | 71 | # Config for 'cargo dist' 72 | [workspace.metadata.dist] 73 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 74 | cargo-dist-version = "0.9.0" 75 | # CI backends to support 76 | ci = ["github"] 77 | # The installers to generate for each app 78 | installers = ["shell", "powershell"] 79 | # Target platforms to build apps for (Rust target-triple syntax) 80 | targets = [ 81 | "aarch64-apple-darwin", 82 | "x86_64-apple-darwin", 83 | "x86_64-unknown-linux-gnu", 84 | "x86_64-pc-windows-msvc", 85 | ] 86 | # Publish jobs to run in CI 87 | pr-run-mode = "plan" 88 | 89 | [package.metadata.cargo-machete] 90 | ignored = ["chrono"] 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dheepak Krishnamurthy 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 | # crates-tui 2 | 3 | `crates-tui` is a simple terminal user interface explorer for crates.io based on [Ratatui](https://ratatui.rs/). 4 | 5 | https://github.com/ratatui-org/crates-tui/assets/1813121/ecbb6fcb-8dd9-4997-aaa2-2a60b0c4a004 6 | 7 | It supports features like: 8 | 9 | - copy `cargo add` command to clipboard 10 | - open the docs page in the browser 11 | - open crates.io page in the brower 12 | 13 | image 14 | image 15 | image 16 | image 17 | 18 | ## Install 19 | 20 | ```rust 21 | cargo install crates-tui 22 | ``` 23 | 24 | ### Arch Linux 25 | 26 | `crates-tui` can be installed with an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers): 27 | 28 | ```sh 29 | paru -S crates-tui 30 | ``` 31 | 32 | ## Screenshots 33 | 34 | ### Open in browser 35 | 36 | https://github.com/ratatui-org/crates-tui/assets/1813121/362d7dc3-d9ef-43df-8d2e-cc56001ef31c 37 | 38 | ### Logging 39 | 40 | https://github.com/ratatui-org/crates-tui/assets/1813121/9609a0f1-4da7-426d-8ce8-2c5a77c54754 41 | 42 | ### Base16 Theme 43 | 44 | [**Dracula**](https://github.com/dracula/base16-dracula-scheme/blob/master/dracula.yaml) 45 | 46 | image 47 | 48 | [**Rose Pine**](https://github.com/edunfelt/base16-rose-pine-scheme) 49 | 50 | image 51 | 52 | [**GitHub**](https://github.com/Defman21/base16-github-scheme) 53 | 54 | image 55 | 56 | You can find example color [configurations here](./.config/). 57 | 58 | ### Help 59 | 60 | https://github.com/ratatui-org/crates-tui/assets/1813121/4c2a3deb-f546-41e6-a48d-998831182ab6 61 | 62 | ### Key to Action configurations per mode 63 | 64 | You can find [the default configuration here](./.config/config.default.toml). 65 | 66 | ## Background 67 | 68 | This repository contains an opinionated way of organizing a small to medium sized Ratatui TUI 69 | applications. 70 | 71 | It has several features, notably: 72 | 73 | - Uses `async` to fetch crate information without blocking the UI 74 | - Multiple custom widgets 75 | - Selection tab 76 | - Input prompt 77 | - Search results table 78 | - Summary view 79 | - Has configurable key chords that map to actions 80 | 81 | This repository is meant to serve as a reference for some patterns you may follow when developing 82 | Ratatui applications. The code will function as a reference for the tutorial material on 83 | https://ratatui.rs as well. 84 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() -> Result<(), Box> { 2 | vergen::EmitBuilder::builder() 3 | .all_build() 4 | .all_git() 5 | .emit()?; 6 | Ok(()) 7 | } 8 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | -------------------------------------------------------------------------------- /src/action.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::Display; 3 | 4 | use crate::app::Mode; 5 | 6 | #[derive(Debug, Display, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 | pub enum Action { 8 | Tick, 9 | Render, 10 | KeyRefresh, 11 | Resize(u16, u16), 12 | Suspend, 13 | Resume, 14 | Quit, 15 | Init { query: Option }, 16 | Refresh, 17 | NextTab, 18 | PreviousTab, 19 | ShowErrorPopup(String), 20 | ShowInfoPopup(String), 21 | ClosePopup, 22 | Help, 23 | GetCrates, 24 | SwitchMode(Mode), 25 | SwitchToLastMode, 26 | IncrementPage, 27 | DecrementPage, 28 | NextSummaryMode, 29 | PreviousSummaryMode, 30 | ToggleSortBy { reload: bool, forward: bool }, 31 | ScrollBottom, 32 | ScrollTop, 33 | ScrollDown, 34 | ScrollUp, 35 | ScrollCrateInfoDown, 36 | ScrollCrateInfoUp, 37 | ScrollSearchResultsDown, 38 | ScrollSearchResultsUp, 39 | SubmitSearch, 40 | UpdateSearchTableResults, 41 | UpdateSummary, 42 | UpdateCurrentSelectionCrateInfo, 43 | UpdateCurrentSelectionSummary, 44 | ReloadData, 45 | ToggleShowCrateInfo, 46 | StoreTotalNumberOfCrates(u64), 47 | ClearTaskDetailsHandle(String), 48 | CopyCargoAddCommandToClipboard, 49 | OpenDocsUrlInBrowser, 50 | OpenCratesIOUrlInBrowser, 51 | ShowFullCrateInfo, 52 | } 53 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{ 2 | atomic::{AtomicBool, Ordering}, 3 | Arc, 4 | }; 5 | 6 | use color_eyre::eyre::Result; 7 | use crossterm::event::{Event as CrosstermEvent, KeyEvent}; 8 | use ratatui::{prelude::*, widgets::*}; 9 | use serde::{Deserialize, Serialize}; 10 | use strum::{Display, EnumIs}; 11 | use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; 12 | use tracing::{debug, error, info}; 13 | 14 | use crate::{ 15 | action::Action, 16 | config, 17 | events::{Event, Events}, 18 | serde_helper::keybindings::key_event_to_string, 19 | tui::Tui, 20 | widgets::{ 21 | help::{Help, HelpWidget}, 22 | popup_message::{PopupMessageState, PopupMessageWidget}, 23 | search_filter_prompt::SearchFilterPromptWidget, 24 | search_page::SearchPage, 25 | search_page::SearchPageWidget, 26 | status_bar::StatusBarWidget, 27 | summary::{Summary, SummaryWidget}, 28 | tabs::SelectedTab, 29 | }, 30 | }; 31 | 32 | #[derive( 33 | Default, Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIs, 34 | )] 35 | #[serde(rename_all = "snake_case")] 36 | pub enum Mode { 37 | Common, 38 | #[default] 39 | Summary, 40 | PickerShowCrateInfo, 41 | PickerHideCrateInfo, 42 | Search, 43 | Filter, 44 | Popup, 45 | Help, 46 | Quit, 47 | } 48 | 49 | impl Mode { 50 | pub fn is_prompt(&self) -> bool { 51 | self.is_search() || self.is_filter() 52 | } 53 | 54 | pub fn is_picker(&self) -> bool { 55 | self.is_picker_hide_crate_info() || self.is_picker_show_crate_info() 56 | } 57 | } 58 | 59 | struct AppWidget; 60 | 61 | #[derive(Debug)] 62 | pub struct App { 63 | /// Receiver end of an asynchronous channel for actions that the app needs 64 | /// to process. 65 | rx: UnboundedReceiver, 66 | 67 | /// Sender end of an asynchronous channel for dispatching actions from 68 | /// various parts of the app to be handled by the event loop. 69 | tx: UnboundedSender, 70 | 71 | /// A thread-safe indicator of whether data is currently being loaded, 72 | /// allowing different parts of the app to know if it's in a loading state. 73 | loading_status: Arc, 74 | 75 | /// The active mode of the application, which could change how user inputs 76 | /// and commands are interpreted. 77 | mode: Mode, 78 | 79 | /// The active mode of the application, which could change how user inputs 80 | /// and commands are interpreted. 81 | last_mode: Mode, 82 | 83 | /// A list of key events that have been held since the last tick, useful for 84 | /// interpreting sequences of key presses. 85 | last_tick_key_events: Vec, 86 | 87 | /// frame counter 88 | frame_count: usize, 89 | 90 | summary: Summary, 91 | search: SearchPage, 92 | popup: Option<(PopupMessageWidget, PopupMessageState)>, 93 | help: Help, 94 | selected_tab: SelectedTab, 95 | } 96 | 97 | impl App { 98 | pub fn new() -> Self { 99 | let (tx, rx) = mpsc::unbounded_channel(); 100 | let loading_status = Arc::new(AtomicBool::default()); 101 | let search = SearchPage::new(tx.clone(), loading_status.clone()); 102 | let summary = Summary::new(tx.clone(), loading_status.clone()); 103 | Self { 104 | rx, 105 | tx, 106 | mode: Mode::default(), 107 | last_mode: Mode::default(), 108 | loading_status, 109 | search, 110 | summary, 111 | popup: Default::default(), 112 | last_tick_key_events: Default::default(), 113 | frame_count: Default::default(), 114 | help: Default::default(), 115 | selected_tab: Default::default(), 116 | } 117 | } 118 | 119 | /// Runs the main loop of the application, handling events and actions 120 | pub async fn run( 121 | &mut self, 122 | mut tui: Tui, 123 | mut events: Events, 124 | query: Option, 125 | ) -> Result<()> { 126 | // uncomment to test error handling 127 | // panic!("test panic"); 128 | // Err(color_eyre::eyre::eyre!("Error"))?; 129 | self.tx.send(Action::Init { query })?; 130 | 131 | loop { 132 | if let Some(e) = events.next().await { 133 | self.handle_event(e)?.map(|action| self.tx.send(action)); 134 | } 135 | while let Ok(action) = self.rx.try_recv() { 136 | self.handle_action(action.clone())?; 137 | if matches!(action, Action::Resize(_, _) | Action::Render) { 138 | self.draw(&mut tui)?; 139 | } 140 | } 141 | if self.should_quit() { 142 | break; 143 | } 144 | } 145 | Ok(()) 146 | } 147 | 148 | /// Handles an event by producing an optional `Action` that the application 149 | /// should perform in response. 150 | /// 151 | /// This method maps incoming events from the terminal user interface to 152 | /// specific `Action` that represents tasks or operations the 153 | /// application needs to carry out. 154 | fn handle_event(&mut self, e: Event) -> Result> { 155 | let maybe_action = match e { 156 | Event::Quit => Some(Action::Quit), 157 | Event::Tick => Some(Action::Tick), 158 | Event::KeyRefresh => Some(Action::KeyRefresh), 159 | Event::Render => Some(Action::Render), 160 | Event::Crossterm(CrosstermEvent::Resize(x, y)) => Some(Action::Resize(x, y)), 161 | Event::Crossterm(CrosstermEvent::Key(key)) => self.handle_key_event(key)?, 162 | _ => None, 163 | }; 164 | Ok(maybe_action) 165 | } 166 | 167 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 168 | debug!("Received key {:?}", key); 169 | match self.mode { 170 | Mode::Search => { 171 | self.search.handle_key(key); 172 | } 173 | Mode::Filter => { 174 | self.search.handle_key(key); 175 | self.search.handle_filter_prompt_change(); 176 | } 177 | _ => (), 178 | }; 179 | Ok(self.handle_key_events_from_config(key)) 180 | } 181 | 182 | /// Evaluates a sequence of key events against user-configured key bindings 183 | /// to determine if an `Action` should be triggered. 184 | /// 185 | /// This method supports user-configurable key sequences by collecting key 186 | /// events over time and then translating them into actions according to the 187 | /// current mode. 188 | fn handle_key_events_from_config(&mut self, key: KeyEvent) -> Option { 189 | self.last_tick_key_events.push(key); 190 | let config = config::get(); 191 | config 192 | .key_bindings 193 | .event_to_command(self.mode, &self.last_tick_key_events) 194 | .or_else(|| { 195 | config 196 | .key_bindings 197 | .event_to_command(Mode::Common, &self.last_tick_key_events) 198 | }) 199 | .map(|command| config.key_bindings.command_to_action(command)) 200 | } 201 | 202 | /// Performs the `Action` by calling on a respective app method. 203 | /// 204 | /// Upon receiving an action, this function updates the application state, performs necessary 205 | /// operations like drawing or resizing the view, or changing the mode. Actions that affect the 206 | /// navigation within the application, are also handled. Certain actions generate a follow-up 207 | /// action which will be to be processed in the next iteration of the main event loop. 208 | fn handle_action(&mut self, action: Action) -> Result<()> { 209 | if action != Action::Tick && action != Action::Render && action != Action::KeyRefresh { 210 | info!("{action:?}"); 211 | } 212 | match action { 213 | Action::Quit => self.quit(), 214 | Action::KeyRefresh => self.key_refresh_tick(), 215 | Action::Init { ref query } => self.init(query)?, 216 | Action::Tick => self.tick(), 217 | Action::StoreTotalNumberOfCrates(n) => self.store_total_number_of_crates(n), 218 | Action::ScrollUp => self.scroll_up(), 219 | Action::ScrollDown => self.scroll_down(), 220 | 221 | Action::ScrollTop 222 | | Action::ScrollBottom 223 | | Action::ScrollSearchResultsUp 224 | | Action::ScrollSearchResultsDown => self.search.handle_action(action.clone()), 225 | 226 | Action::ScrollCrateInfoUp => self.search.crate_info.scroll_previous(), 227 | Action::ScrollCrateInfoDown => self.search.crate_info.scroll_next(), 228 | Action::ReloadData => self.search.reload_data(), 229 | Action::IncrementPage => self.search.increment_page(), 230 | Action::DecrementPage => self.search.decrement_page(), 231 | Action::NextSummaryMode => self.summary.next_mode(), 232 | Action::PreviousSummaryMode => self.summary.previous_mode(), 233 | Action::NextTab => self.goto_next_tab(), 234 | Action::PreviousTab => self.goto_previous_tab(), 235 | Action::SwitchMode(mode) => self.switch_mode(mode), 236 | Action::SwitchToLastMode => self.switch_to_last_mode(), 237 | Action::SubmitSearch => self.search.submit_query(), 238 | Action::ToggleShowCrateInfo => self.search.toggle_show_crate_info(), 239 | Action::UpdateCurrentSelectionCrateInfo => self.update_current_selection_crate_info(), 240 | Action::UpdateSearchTableResults => self.search.update_search_table_results(), 241 | Action::UpdateSummary => self.summary.update(), 242 | Action::ShowFullCrateInfo => self.show_full_crate_details(), 243 | Action::ShowErrorPopup(ref err) => self.show_error_popup(err.clone()), 244 | Action::ShowInfoPopup(ref info) => self.show_info_popup(info.clone()), 245 | Action::ClosePopup => self.close_popup(), 246 | Action::ToggleSortBy { reload, forward } => { 247 | self.search.toggle_sort_by(reload, forward)? 248 | } 249 | Action::ClearTaskDetailsHandle(ref id) => self 250 | .search 251 | .clear_task_details_handle(uuid::Uuid::parse_str(id)?)?, 252 | Action::OpenDocsUrlInBrowser => self.open_docs_url_in_browser()?, 253 | Action::OpenCratesIOUrlInBrowser if self.mode.is_summary() => { 254 | self.open_summary_url_in_browser()? 255 | } 256 | Action::OpenCratesIOUrlInBrowser => self.open_crates_io_url_in_browser()?, 257 | Action::CopyCargoAddCommandToClipboard => self.copy_cargo_add_command_to_clipboard()?, 258 | _ => {} 259 | } 260 | match action { 261 | Action::ScrollUp | Action::ScrollDown | Action::ScrollTop | Action::ScrollBottom 262 | if self.mode.is_prompt() || self.mode.is_picker() => 263 | { 264 | let _ = self.tx.send(Action::UpdateCurrentSelectionCrateInfo); 265 | } 266 | Action::SubmitSearch => { 267 | let _ = self.tx.send(Action::ReloadData); 268 | } 269 | _ => {} 270 | }; 271 | Ok(()) 272 | } 273 | 274 | // Render the `AppWidget` as a stateful widget using `self` as the `State` 275 | fn draw(&mut self, tui: &mut Tui) -> Result<()> { 276 | tui.draw(|frame| { 277 | frame.render_stateful_widget(AppWidget, frame.area(), self); 278 | self.update_frame_count(frame); 279 | self.update_cursor(frame); 280 | })?; 281 | Ok(()) 282 | } 283 | } 284 | 285 | impl App { 286 | fn tick(&mut self) { 287 | self.search.update_search_table_results(); 288 | } 289 | 290 | fn init(&mut self, query: &Option) -> Result<()> { 291 | if let Some(query) = query { 292 | self.search.search = query.clone(); 293 | let _ = self.tx.send(Action::SwitchMode(Mode::Search)); 294 | let _ = self.tx.send(Action::SubmitSearch); 295 | } else { 296 | self.summary.request()?; 297 | } 298 | Ok(()) 299 | } 300 | 301 | fn key_refresh_tick(&mut self) { 302 | self.last_tick_key_events.drain(..); 303 | } 304 | 305 | fn should_quit(&self) -> bool { 306 | self.mode == Mode::Quit 307 | } 308 | 309 | fn quit(&mut self) { 310 | self.mode = Mode::Quit 311 | } 312 | 313 | fn scroll_up(&mut self) { 314 | match self.mode { 315 | Mode::Popup => { 316 | if let Some((_, popup_state)) = &mut self.popup { 317 | popup_state.scroll_up(); 318 | } 319 | } 320 | Mode::Summary => self.summary.scroll_previous(), 321 | Mode::Help => self.help.scroll_up(), 322 | _ => self.search.scroll_up(), 323 | } 324 | } 325 | 326 | fn scroll_down(&mut self) { 327 | match self.mode { 328 | Mode::Popup => { 329 | if let Some((_, popup_state)) = &mut self.popup { 330 | popup_state.scroll_down(); 331 | } 332 | } 333 | Mode::Summary => self.summary.scroll_next(), 334 | Mode::Help => self.help.scroll_down(), 335 | _ => self.search.scroll_down(), 336 | } 337 | } 338 | 339 | fn switch_mode(&mut self, mode: Mode) { 340 | self.last_mode = self.mode; 341 | self.mode = mode; 342 | self.search.mode = mode; 343 | match self.mode { 344 | Mode::Search => { 345 | self.selected_tab.select(SelectedTab::Search); 346 | self.search.enter_search_insert_mode(); 347 | } 348 | Mode::Filter => { 349 | self.selected_tab.select(SelectedTab::Search); 350 | self.search.enter_filter_insert_mode(); 351 | } 352 | Mode::Summary => { 353 | self.search.enter_normal_mode(); 354 | self.selected_tab.select(SelectedTab::Summary); 355 | } 356 | Mode::Help => { 357 | self.search.enter_normal_mode(); 358 | self.help.mode = Some(self.last_mode); 359 | self.selected_tab.select(SelectedTab::None) 360 | } 361 | Mode::PickerShowCrateInfo | Mode::PickerHideCrateInfo => { 362 | self.search.enter_normal_mode(); 363 | self.selected_tab.select(SelectedTab::Search) 364 | } 365 | _ => { 366 | self.search.enter_normal_mode(); 367 | self.selected_tab.select(SelectedTab::None) 368 | } 369 | } 370 | } 371 | 372 | fn switch_to_last_mode(&mut self) { 373 | self.switch_mode(self.last_mode); 374 | } 375 | 376 | fn goto_next_tab(&mut self) { 377 | match self.mode { 378 | Mode::Summary => self.switch_mode(Mode::Search), 379 | Mode::Search => self.switch_mode(Mode::Summary), 380 | _ => self.switch_mode(Mode::Summary), 381 | } 382 | } 383 | 384 | fn goto_previous_tab(&mut self) { 385 | match self.mode { 386 | Mode::Summary => self.switch_mode(Mode::Search), 387 | Mode::Search => self.switch_mode(Mode::Summary), 388 | _ => self.switch_mode(Mode::Summary), 389 | } 390 | } 391 | 392 | fn show_error_popup(&mut self, message: String) { 393 | error!("Error: {message}"); 394 | self.popup = Some(( 395 | PopupMessageWidget::new("Error".into(), message), 396 | PopupMessageState::default(), 397 | )); 398 | self.switch_mode(Mode::Popup); 399 | } 400 | 401 | fn show_info_popup(&mut self, info: String) { 402 | info!("Info: {info}"); 403 | self.popup = Some(( 404 | PopupMessageWidget::new("Info".into(), info), 405 | PopupMessageState::default(), 406 | )); 407 | self.switch_mode(Mode::Popup); 408 | } 409 | 410 | fn close_popup(&mut self) { 411 | self.popup = None; 412 | if self.last_mode.is_popup() { 413 | self.switch_mode(Mode::Search); 414 | } else { 415 | self.switch_mode(self.last_mode); 416 | } 417 | } 418 | 419 | fn update_current_selection_crate_info(&mut self) { 420 | self.search.clear_all_previous_task_details_handles(); 421 | self.search.request_crate_details(); 422 | } 423 | 424 | fn show_full_crate_details(&mut self) { 425 | self.search.clear_all_previous_task_details_handles(); 426 | self.search.request_full_crate_details(); 427 | } 428 | 429 | fn store_total_number_of_crates(&mut self, n: u64) { 430 | self.search.total_num_crates = Some(n) 431 | } 432 | 433 | fn open_docs_url_in_browser(&self) -> Result<()> { 434 | if let Some(crate_response) = self.search.crate_response.lock().unwrap().clone() { 435 | let name = crate_response.crate_data.name; 436 | webbrowser::open(&format!("https://docs.rs/{name}/latest"))?; 437 | } 438 | Ok(()) 439 | } 440 | 441 | fn open_summary_url_in_browser(&self) -> Result<()> { 442 | if let Some(url) = self.summary.url() { 443 | webbrowser::open(&url)?; 444 | } else { 445 | let _ = self.tx.send(Action::ShowErrorPopup( 446 | "Unable to open URL in browser: No summary data loaded".into(), 447 | )); 448 | } 449 | Ok(()) 450 | } 451 | 452 | fn open_crates_io_url_in_browser(&self) -> Result<()> { 453 | if let Some(crate_response) = self.search.crate_response.lock().unwrap().clone() { 454 | let name = crate_response.crate_data.name; 455 | webbrowser::open(&format!("https://crates.io/crates/{name}"))?; 456 | } 457 | Ok(()) 458 | } 459 | 460 | fn copy_cargo_add_command_to_clipboard(&self) -> Result<()> { 461 | use copypasta::ClipboardProvider; 462 | match copypasta::ClipboardContext::new() { 463 | Ok(mut ctx) => { 464 | if let Some(crate_response) = self.search.crate_response.lock().unwrap().clone() { 465 | let msg = format!("cargo add {}", crate_response.crate_data.name); 466 | let _ = match ctx.set_contents(msg.clone()).ok() { 467 | Some(_) => self.tx.send(Action::ShowInfoPopup(format!( 468 | "Copied to clipboard: `{msg}`" 469 | ))), 470 | None => self.tx.send(Action::ShowErrorPopup(format!( 471 | "Unable to copied to clipboard: `{msg}`" 472 | ))), 473 | }; 474 | } else { 475 | let _ = self 476 | .tx 477 | .send(Action::ShowErrorPopup("No selection made to copy".into())); 478 | } 479 | } 480 | Err(err) => { 481 | let _ = self.tx.send(Action::ShowErrorPopup(format!( 482 | "Unable to create ClipboardContext: {}", 483 | err 484 | ))); 485 | } 486 | } 487 | Ok(()) 488 | } 489 | 490 | // Sets the frame count 491 | fn update_frame_count(&mut self, frame: &mut Frame<'_>) { 492 | self.frame_count = frame.count(); 493 | } 494 | 495 | // Sets cursor for the prompt 496 | fn update_cursor(&mut self, frame: &mut Frame<'_>) { 497 | if self.mode.is_prompt() { 498 | if let Some(cursor_position) = self.search.cursor_position() { 499 | frame.set_cursor_position(cursor_position); 500 | } 501 | } 502 | } 503 | 504 | fn events_widget(&self) -> Option { 505 | if self.last_tick_key_events.is_empty() { 506 | return None; 507 | } 508 | 509 | let title = format!( 510 | "{:?}", 511 | self.last_tick_key_events 512 | .iter() 513 | .map(key_event_to_string) 514 | .collect::>() 515 | ); 516 | Some( 517 | Block::default() 518 | .title(title) 519 | .title_position(ratatui::widgets::block::Position::Top) 520 | .title_alignment(ratatui::layout::Alignment::Right), 521 | ) 522 | } 523 | 524 | fn loading(&self) -> bool { 525 | self.loading_status.load(Ordering::SeqCst) 526 | } 527 | } 528 | 529 | impl StatefulWidget for AppWidget { 530 | type State = App; 531 | 532 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 533 | // Background color 534 | Block::default() 535 | .bg(config::get().color.base00) 536 | .render(area, buf); 537 | 538 | use Constraint::*; 539 | let [header, main] = Layout::vertical([Length(1), Fill(1)]).areas(area); 540 | let [tabs, events] = Layout::horizontal([Min(15), Fill(1)]).areas(header); 541 | 542 | state.render_tabs(tabs, buf); 543 | state.events_widget().render(events, buf); 544 | 545 | let mode = if matches!(state.mode, Mode::Popup | Mode::Quit) { 546 | state.last_mode 547 | } else { 548 | state.mode 549 | }; 550 | match mode { 551 | Mode::Summary => state.render_summary(main, buf), 552 | Mode::Help => state.render_help(main, buf), 553 | 554 | Mode::Search => state.render_search(main, buf), 555 | Mode::Filter => state.render_search(main, buf), 556 | Mode::PickerShowCrateInfo => state.render_search(main, buf), 557 | Mode::PickerHideCrateInfo => state.render_search(main, buf), 558 | 559 | Mode::Common => {} 560 | Mode::Popup => {} 561 | Mode::Quit => {} 562 | }; 563 | 564 | if state.loading() { 565 | Line::from(state.spinner()) 566 | .right_aligned() 567 | .render(main, buf); 568 | } 569 | 570 | if let Some((popup, popup_state)) = &mut state.popup { 571 | popup.render(area, buf, popup_state); 572 | } 573 | } 574 | } 575 | 576 | impl App { 577 | fn render_tabs(&self, area: Rect, buf: &mut Buffer) { 578 | use strum::IntoEnumIterator; 579 | let titles = SelectedTab::iter().map(|tab| tab.title()); 580 | let highlight_style = SelectedTab::highlight_style(); 581 | 582 | let selected_tab_index = self.selected_tab as usize; 583 | Tabs::new(titles) 584 | .highlight_style(highlight_style) 585 | .select(selected_tab_index) 586 | .padding("", "") 587 | .divider(" ") 588 | .render(area, buf); 589 | } 590 | 591 | fn render_summary(&mut self, area: Rect, buf: &mut Buffer) { 592 | let [main, status_bar] = 593 | Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).areas(area); 594 | SummaryWidget.render(main, buf, &mut self.summary); 595 | self.render_status_bar(status_bar, buf); 596 | } 597 | 598 | fn render_help(&mut self, area: Rect, buf: &mut Buffer) { 599 | let [main, status_bar] = 600 | Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).areas(area); 601 | HelpWidget.render(main, buf, &mut self.help); 602 | self.render_status_bar(status_bar, buf); 603 | } 604 | 605 | fn render_search(&mut self, area: Rect, buf: &mut Buffer) { 606 | let prompt_height = if self.mode.is_prompt() && self.search.is_prompt() { 607 | 5 608 | } else { 609 | 0 610 | }; 611 | let [main, prompt, status_bar] = Layout::vertical([ 612 | Constraint::Min(0), 613 | Constraint::Length(prompt_height), 614 | Constraint::Length(1), 615 | ]) 616 | .areas(area); 617 | 618 | SearchPageWidget.render(main, buf, &mut self.search); 619 | 620 | self.render_prompt(prompt, buf); 621 | self.render_status_bar(status_bar, buf); 622 | } 623 | 624 | fn render_prompt(&mut self, area: Rect, buf: &mut Buffer) { 625 | let p = SearchFilterPromptWidget::new( 626 | self.mode, 627 | self.search.sort.clone(), 628 | &self.search.input, 629 | self.search.search_mode, 630 | ); 631 | p.render(area, buf, &mut self.search.prompt); 632 | } 633 | 634 | fn render_status_bar(&mut self, area: Rect, buf: &mut Buffer) { 635 | let s = StatusBarWidget::new( 636 | self.mode, 637 | self.search.sort.clone(), 638 | self.search.input.value().to_string(), 639 | ); 640 | s.render(area, buf); 641 | } 642 | 643 | fn spinner(&self) -> String { 644 | let spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; 645 | let index = self.frame_count % spinner.len(); 646 | let symbol = spinner[index]; 647 | symbol.into() 648 | } 649 | } 650 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{ 4 | builder::{styling::AnsiColor, Styles}, 5 | Parser, 6 | }; 7 | use serde::Serialize; 8 | use serde_with::{serde_as, skip_serializing_none, NoneAsEmptyString}; 9 | use tracing::level_filters::LevelFilter; 10 | 11 | pub fn version() -> String { 12 | let git_describe = if env!("VERGEN_GIT_DESCRIBE") != "VERGEN_IDEMPOTENT_OUTPUT" { 13 | format!("-{}", env!("VERGEN_GIT_DESCRIBE")) 14 | } else { 15 | "".into() 16 | }; 17 | let version_message = format!( 18 | "{}{} ({})", 19 | env!("CARGO_PKG_VERSION"), 20 | git_describe, 21 | env!("VERGEN_BUILD_DATE"), 22 | ); 23 | let author = clap::crate_authors!(); 24 | 25 | format!( 26 | "\ 27 | {version_message} 28 | 29 | Authors: {author}" 30 | ) 31 | } 32 | 33 | const HELP_STYLES: Styles = Styles::styled() 34 | .header(AnsiColor::Blue.on_default().bold()) 35 | .usage(AnsiColor::Blue.on_default().bold()) 36 | .literal(AnsiColor::White.on_default()) 37 | .placeholder(AnsiColor::Green.on_default()); 38 | 39 | /// Command line arguments. 40 | /// 41 | /// Implements Serialize so that we can use it as a source for Figment 42 | /// configuration. 43 | #[serde_as] 44 | #[skip_serializing_none] 45 | #[derive(Debug, Default, Parser, Serialize)] 46 | #[command(author, version = version(), about, long_about = None, styles = HELP_STYLES)] 47 | pub struct Cli { 48 | /// Initial Query 49 | #[arg(value_name = "QUERY")] 50 | pub query: Option, 51 | 52 | /// Print default configuration 53 | #[arg(long)] 54 | pub print_default_config: bool, 55 | 56 | /// A path to a crates-tui configuration file. 57 | #[arg( 58 | short, 59 | long, 60 | value_name = "FILE", 61 | default_value = get_default_config_path() 62 | )] 63 | pub config_file: Option, 64 | 65 | /// A path to a base16 color file. 66 | #[arg(long, value_name = "FILE", default_value = get_default_color_file())] 67 | pub color_file: Option, 68 | 69 | /// Frame rate, i.e. number of frames per second 70 | #[arg(short, long, value_name = "FLOAT", default_value_t = 15.0)] 71 | pub frame_rate: f64, 72 | 73 | /// The directory to use for storing application data. 74 | #[arg(long, value_name = "DIR", default_value = get_default_data_dir())] 75 | pub data_dir: Option, 76 | 77 | /// The log level to use. Valid values are: error, warn, info, debug, trace, off. 78 | /// 79 | /// [default: info] 80 | #[arg(long, value_name = "LEVEL", alias = "log")] 81 | #[serde_as(as = "NoneAsEmptyString")] 82 | pub log_level: Option, 83 | } 84 | 85 | fn get_default_config_path() -> String { 86 | crate::config::default_config_file() 87 | .to_string_lossy() 88 | .into_owned() 89 | } 90 | 91 | fn get_default_color_file() -> String { 92 | crate::config::default_color_file() 93 | .to_string_lossy() 94 | .into_owned() 95 | } 96 | 97 | fn get_default_data_dir() -> String { 98 | crate::config::default_data_dir() 99 | .to_string_lossy() 100 | .into_owned() 101 | } 102 | 103 | pub fn parse() -> Cli { 104 | Cli::parse() 105 | } 106 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::Display; 3 | 4 | use crate::app::Mode; 5 | 6 | #[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 | pub enum Command { 8 | Quit, 9 | NextTab, 10 | PreviousTab, 11 | ClosePopup, 12 | SwitchMode(Mode), 13 | SwitchToLastMode, 14 | IncrementPage, 15 | DecrementPage, 16 | NextSummaryMode, 17 | PreviousSummaryMode, 18 | ToggleSortBy { reload: bool, forward: bool }, 19 | ScrollBottom, 20 | ScrollTop, 21 | ScrollDown, 22 | ScrollUp, 23 | ScrollCrateInfoDown, 24 | ScrollCrateInfoUp, 25 | ScrollSearchResultsDown, 26 | ScrollSearchResultsUp, 27 | SubmitSearch, 28 | ReloadData, 29 | ToggleShowCrateInfo, 30 | CopyCargoAddCommandToClipboard, 31 | OpenDocsUrlInBrowser, 32 | OpenCratesIOUrlInBrowser, 33 | } 34 | 35 | pub const HELP_COMMANDS: &[Command] = &[Command::SwitchToLastMode]; 36 | pub const PICKER_COMMANDS: &[Command] = &[ 37 | Command::SwitchMode(Mode::Help), 38 | Command::SwitchMode(Mode::Summary), 39 | Command::SwitchMode(Mode::Search), 40 | Command::SwitchMode(Mode::Filter), 41 | Command::ScrollUp, 42 | Command::ScrollDown, 43 | Command::ScrollCrateInfoUp, 44 | Command::ScrollCrateInfoDown, 45 | Command::ToggleSortBy { 46 | reload: true, 47 | forward: true, 48 | }, 49 | Command::ToggleSortBy { 50 | reload: true, 51 | forward: false, 52 | }, 53 | Command::ToggleSortBy { 54 | reload: false, 55 | forward: true, 56 | }, 57 | Command::ToggleSortBy { 58 | reload: false, 59 | forward: false, 60 | }, 61 | Command::IncrementPage, 62 | Command::DecrementPage, 63 | Command::ReloadData, 64 | Command::ToggleShowCrateInfo, 65 | Command::OpenDocsUrlInBrowser, 66 | Command::OpenCratesIOUrlInBrowser, 67 | Command::CopyCargoAddCommandToClipboard, 68 | ]; 69 | pub const SUMMARY_COMMANDS: &[Command] = &[ 70 | Command::Quit, 71 | Command::ScrollDown, 72 | Command::ScrollUp, 73 | Command::PreviousSummaryMode, 74 | Command::NextSummaryMode, 75 | Command::SwitchMode(Mode::Help), 76 | Command::SwitchMode(Mode::Search), 77 | Command::SwitchMode(Mode::Filter), 78 | ]; 79 | pub const SEARCH_COMMANDS: &[Command] = &[ 80 | Command::SwitchMode(Mode::PickerHideCrateInfo), 81 | Command::SubmitSearch, 82 | Command::ToggleSortBy { 83 | reload: false, 84 | forward: true, 85 | }, 86 | Command::ToggleSortBy { 87 | reload: false, 88 | forward: false, 89 | }, 90 | Command::ToggleSortBy { 91 | reload: true, 92 | forward: true, 93 | }, 94 | Command::ToggleSortBy { 95 | reload: true, 96 | forward: false, 97 | }, 98 | Command::ScrollSearchResultsUp, 99 | Command::ScrollSearchResultsDown, 100 | Command::SwitchMode(Mode::PickerHideCrateInfo), 101 | Command::ScrollSearchResultsUp, 102 | Command::ScrollSearchResultsDown, 103 | ]; 104 | pub const ALL_COMMANDS: &[(Mode, &[Command])] = &[ 105 | (Mode::Help, HELP_COMMANDS), 106 | (Mode::PickerHideCrateInfo, PICKER_COMMANDS), 107 | (Mode::Summary, SUMMARY_COMMANDS), 108 | (Mode::Search, SEARCH_COMMANDS), 109 | ]; 110 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::PathBuf, str::FromStr, sync::OnceLock}; 2 | 3 | use color_eyre::eyre::{eyre, Result}; 4 | use directories::ProjectDirs; 5 | use figment::{ 6 | providers::{Env, Format, Serialized, Toml, Yaml}, 7 | Figment, 8 | }; 9 | use ratatui::style::Color; 10 | use serde::{Deserialize, Serialize}; 11 | use serde_with::{serde_as, DisplayFromStr, NoneAsEmptyString}; 12 | use tracing::level_filters::LevelFilter; 13 | 14 | use crate::{cli::Cli, serde_helper::keybindings::KeyBindings}; 15 | 16 | static CONFIG: OnceLock = OnceLock::new(); 17 | pub const CONFIG_DEFAULT: &str = include_str!("../.config/config.default.toml"); 18 | 19 | #[serde_as] 20 | #[derive(Debug, Clone, Copy, Deserialize, Serialize)] 21 | pub struct Base16Palette { 22 | /// Default Background 23 | #[serde_as(as = "DisplayFromStr")] 24 | pub base00: Color, 25 | 26 | /// Lighter Background (Used for status bars, line number and folding marks) 27 | #[serde_as(as = "DisplayFromStr")] 28 | pub base01: Color, 29 | 30 | /// Selection Background (Settings where you need to highlight text, such as find results) 31 | #[serde_as(as = "DisplayFromStr")] 32 | pub base02: Color, 33 | 34 | /// Comments, Invisibles, Line Highlighting 35 | #[serde_as(as = "DisplayFromStr")] 36 | pub base03: Color, 37 | 38 | /// Dark Foreground (Used for status bars) 39 | #[serde_as(as = "DisplayFromStr")] 40 | pub base04: Color, 41 | 42 | /// Default Foreground, Caret, Delimiters, Operators 43 | #[serde_as(as = "DisplayFromStr")] 44 | pub base05: Color, 45 | 46 | /// Light Foreground (Not often used, could be used for hover states or dividers) 47 | #[serde_as(as = "DisplayFromStr")] 48 | pub base06: Color, 49 | 50 | /// Light Background (Probably at most for cursor line background color) 51 | #[serde_as(as = "DisplayFromStr")] 52 | pub base07: Color, 53 | 54 | /// Variables, XML Tags, Markup Link Text, Markup Lists, Diff Deleted 55 | #[serde_as(as = "DisplayFromStr")] 56 | pub base08: Color, 57 | 58 | /// Integers, Boolean, Constants, XML Attributes, Markup Link Url 59 | #[serde_as(as = "DisplayFromStr")] 60 | pub base09: Color, 61 | 62 | /// Classes, Keywords, Storage, Selector, Markup Italic, Diff Changed 63 | #[serde_as(as = "DisplayFromStr")] 64 | pub base0a: Color, 65 | 66 | /// Strings, Inherited Class, Markup Code, Diff Inserted 67 | #[serde_as(as = "DisplayFromStr")] 68 | pub base0b: Color, 69 | 70 | /// Support, Regular Expressions, Escape Characters, Markup Quotes 71 | #[serde_as(as = "DisplayFromStr")] 72 | pub base0c: Color, 73 | 74 | /// Functions, Methods, Attribute IDs, Headings 75 | #[serde_as(as = "DisplayFromStr")] 76 | pub base0d: Color, 77 | 78 | /// Keywords, Storage, Selector, Markup Bold, Diff Renamed 79 | #[serde_as(as = "DisplayFromStr")] 80 | pub base0e: Color, 81 | 82 | /// Deprecated, Opening/Closing Embedded Language Tags e.g., `` 83 | #[serde_as(as = "DisplayFromStr")] 84 | pub base0f: Color, 85 | } 86 | 87 | impl Default for Base16Palette { 88 | fn default() -> Self { 89 | Self { 90 | base00: Color::from_str("#191724").unwrap(), 91 | base01: Color::from_str("#1f1d2e").unwrap(), 92 | base02: Color::from_str("#26233a").unwrap(), 93 | base03: Color::from_str("#6e6a86").unwrap(), 94 | base04: Color::from_str("#908caa").unwrap(), 95 | base05: Color::from_str("#e0def4").unwrap(), 96 | base06: Color::from_str("#e0def4").unwrap(), 97 | base07: Color::from_str("#524f67").unwrap(), 98 | base08: Color::from_str("#eb6f92").unwrap(), 99 | base09: Color::from_str("#f6c177").unwrap(), 100 | base0a: Color::from_str("#ebbcba").unwrap(), 101 | base0b: Color::from_str("#31748f").unwrap(), 102 | base0c: Color::from_str("#9ccfd8").unwrap(), 103 | base0d: Color::from_str("#c4a7e7").unwrap(), 104 | base0e: Color::from_str("#f6c177").unwrap(), 105 | base0f: Color::from_str("#524f67").unwrap(), 106 | } 107 | } 108 | } 109 | 110 | /// Application configuration. 111 | /// 112 | /// This is the main configuration struct for the application. 113 | #[serde_as] 114 | #[derive(Debug, Deserialize, Serialize)] 115 | pub struct Config { 116 | /// The directory to use for storing application data (logs etc.). 117 | pub data_dir: PathBuf, 118 | 119 | /// The directory to use for storing application configuration (colors 120 | /// etc.). 121 | pub config_home: PathBuf, 122 | 123 | /// The directory to use for storing application configuration (colors 124 | /// etc.). 125 | pub config_file: PathBuf, 126 | 127 | /// The log level to use. Valid values are: error, warn, info, debug, trace, 128 | /// off. The default is info. 129 | #[serde_as(as = "NoneAsEmptyString")] 130 | pub log_level: Option, 131 | 132 | pub tick_rate: f64, 133 | 134 | pub frame_rate: f64, 135 | 136 | pub key_refresh_rate: f64, 137 | 138 | pub enable_mouse: bool, 139 | 140 | pub enable_paste: bool, 141 | 142 | pub prompt_padding: u16, 143 | 144 | pub key_bindings: KeyBindings, 145 | 146 | pub color: Base16Palette, 147 | } 148 | 149 | impl Default for Config { 150 | fn default() -> Self { 151 | let key_bindings: KeyBindings = Default::default(); 152 | let rose_pine = Base16Palette::default(); 153 | 154 | Self { 155 | data_dir: default_data_dir(), 156 | config_home: default_config_dir(), 157 | config_file: default_config_file(), 158 | log_level: None, 159 | tick_rate: 1.0, 160 | frame_rate: 15.0, 161 | key_refresh_rate: 0.5, 162 | enable_mouse: false, 163 | enable_paste: false, 164 | prompt_padding: 1, 165 | key_bindings, 166 | color: rose_pine, 167 | } 168 | } 169 | } 170 | 171 | /// Initialize the application configuration. 172 | /// 173 | /// This function should be called before any other function in the application. 174 | /// It will initialize the application config from the following sources: 175 | /// - default values 176 | /// - a configuration file 177 | /// - environment variables 178 | /// - command line arguments 179 | pub fn init(cli: &Cli) -> Result<()> { 180 | let config_file = cli.config_file.clone().unwrap_or_else(default_config_file); 181 | let color_file = cli.color_file.clone().unwrap_or_else(default_color_file); 182 | let mut config = Figment::new() 183 | .merge(Serialized::defaults(Config::default())) 184 | .merge(Toml::string(CONFIG_DEFAULT)) 185 | .merge(Toml::file(config_file)) 186 | .merge(Env::prefixed("CRATES_TUI_")) 187 | .merge(Serialized::defaults(cli)) 188 | .extract::()?; 189 | let base16 = Figment::new() 190 | .merge(Serialized::defaults(Base16Palette::default())) 191 | .merge(Yaml::file(color_file)) 192 | .extract::()?; 193 | config.color = base16; 194 | if let Some(data_dir) = cli.data_dir.clone() { 195 | config.data_dir = data_dir; 196 | } 197 | CONFIG 198 | .set(config) 199 | .map_err(|config| eyre!("failed to set config {config:?}")) 200 | } 201 | 202 | /// Get the application configuration. 203 | /// 204 | /// This function should only be called after [`init()`] has been called. 205 | /// 206 | /// # Panics 207 | /// 208 | /// This function will panic if [`init()`] has not been called. 209 | pub fn get() -> &'static Config { 210 | CONFIG.get().expect("config not initialized") 211 | } 212 | 213 | /// Returns the path to the default configuration file. 214 | pub fn default_config_file() -> PathBuf { 215 | default_config_dir().join("config.toml") 216 | } 217 | 218 | /// Returns the path to the default configuration file. 219 | pub fn default_color_file() -> PathBuf { 220 | default_config_dir().join("color.yaml") 221 | } 222 | 223 | /// Returns the directory to use for storing config files. 224 | fn default_config_dir() -> PathBuf { 225 | env::var("CRATES_TUI_CONFIG_HOME") 226 | .map(PathBuf::from) 227 | .or_else(|_| project_dirs().map(|dirs| dirs.config_local_dir().to_path_buf())) 228 | .unwrap_or(PathBuf::from(".").join(".config")) 229 | } 230 | 231 | /// Returns the directory to use for storing data files. 232 | pub fn default_data_dir() -> PathBuf { 233 | env::var("CRATES_TUI_DATA_HOME") 234 | .map(PathBuf::from) 235 | .or_else(|_| project_dirs().map(|dirs| dirs.data_local_dir().to_path_buf())) 236 | .unwrap_or(PathBuf::from(".").join(".data")) 237 | } 238 | 239 | /// Returns the project directories. 240 | fn project_dirs() -> Result { 241 | ProjectDirs::from("rs", "ratatui", "crates-tui") 242 | .ok_or_else(|| eyre!("user home directory not found")) 243 | } 244 | 245 | #[cfg(test)] 246 | mod tests { 247 | use crate::serde_helper::keybindings::parse_key_sequence; 248 | 249 | use super::*; 250 | 251 | #[test] 252 | 253 | fn create_config() { 254 | let mut c = Config::default(); 255 | c.key_bindings.insert( 256 | crate::app::Mode::PickerShowCrateInfo, 257 | &parse_key_sequence("q").unwrap(), 258 | crate::command::Command::Quit, 259 | ); 260 | 261 | println!("{}", toml::to_string_pretty(&c).unwrap()); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/crates_io_api_helper.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{atomic::AtomicBool, Arc, Mutex}; 2 | 3 | use crates_io_api::CratesQuery; 4 | use tokio::sync::mpsc::UnboundedSender; 5 | 6 | use crate::action::Action; 7 | use color_eyre::Result; 8 | 9 | /// Represents the parameters needed for fetching crates asynchronously. 10 | pub struct SearchParameters { 11 | pub search: String, 12 | pub page: u64, 13 | pub page_size: u64, 14 | pub crates: Arc>>, 15 | pub versions: Arc>>, 16 | pub loading_status: Arc, 17 | pub sort: crates_io_api::Sort, 18 | pub tx: UnboundedSender, 19 | } 20 | 21 | /// Performs the actual search, and sends the result back through the 22 | /// sender. 23 | pub async fn request_search_results(params: &SearchParameters) -> Result<(), String> { 24 | // Fetch crates using the created client with the error handling in one place. 25 | let client = create_client()?; 26 | let query = create_query(params); 27 | let (crates, versions, total) = fetch_crates_and_metadata(client, query).await?; 28 | update_state_with_fetched_crates(crates, versions, total, params); 29 | Ok(()) 30 | } 31 | 32 | /// Helper function to create client and fetch crates, wrapping both actions 33 | /// into a result pattern. 34 | fn create_client() -> Result { 35 | // Attempt to create the API client 36 | crates_io_api::AsyncClient::new( 37 | "crates-tui (crates-tui@kdheepak.com)", 38 | std::time::Duration::from_millis(1000), 39 | ) 40 | .map_err(|err| format!("API Client Error: {err:#?}")) 41 | } 42 | 43 | fn create_query(params: &SearchParameters) -> CratesQuery { 44 | // Form the query and fetch the crates, passing along any errors. 45 | crates_io_api::CratesQueryBuilder::default() 46 | .search(¶ms.search) 47 | .page(params.page) 48 | .page_size(params.page_size) 49 | .sort(params.sort.clone()) 50 | .build() 51 | } 52 | 53 | async fn fetch_crates_and_metadata( 54 | client: crates_io_api::AsyncClient, 55 | query: crates_io_api::CratesQuery, 56 | ) -> Result<(Vec, Vec, u64), String> { 57 | let page_result = client 58 | .crates(query) 59 | .await 60 | .map_err(|err| format!("API Client Error: {err:#?}"))?; 61 | let crates = page_result.crates; 62 | let total = page_result.meta.total; 63 | let versions = page_result.versions; 64 | 65 | Ok((crates, versions, total)) 66 | } 67 | 68 | /// Handles the result after fetching crates and sending corresponding 69 | /// actions. 70 | fn update_state_with_fetched_crates( 71 | crates: Vec, 72 | versions: Vec, 73 | total: u64, 74 | params: &SearchParameters, 75 | ) { 76 | // Lock and update the shared state container 77 | let mut app_crates = params.crates.lock().unwrap(); 78 | app_crates.clear(); 79 | app_crates.extend(crates); 80 | 81 | let mut app_versions = params.versions.lock().unwrap(); 82 | app_versions.clear(); 83 | app_versions.extend(versions); 84 | 85 | // After a successful fetch, send relevant actions based on the result 86 | if app_crates.is_empty() { 87 | let _ = params.tx.send(Action::ShowErrorPopup(format!( 88 | "Could not find any crates with query `{}`.", 89 | params.search 90 | ))); 91 | } else { 92 | let _ = params.tx.send(Action::StoreTotalNumberOfCrates(total)); 93 | let _ = params.tx.send(Action::Tick); 94 | let _ = params.tx.send(Action::ScrollDown); 95 | } 96 | } 97 | 98 | // Performs the async fetch of crate details. 99 | pub async fn request_crate_details( 100 | crate_name: &str, 101 | crate_info: Arc>>, 102 | ) -> Result<(), String> { 103 | let client = create_client()?; 104 | 105 | let crate_data = client 106 | .get_crate(crate_name) 107 | .await 108 | .map_err(|err| format!("Error fetching crate details: {err:#?}"))?; 109 | *crate_info.lock().unwrap() = Some(crate_data); 110 | Ok(()) 111 | } 112 | 113 | // Performs the async fetch of crate details. 114 | pub async fn request_full_crate_details( 115 | crate_name: &str, 116 | full_crate_info: Arc>>, 117 | ) -> Result<(), String> { 118 | let client = create_client()?; 119 | 120 | let full_crate_data = client 121 | .full_crate(crate_name, false) 122 | .await 123 | .map_err(|err| format!("Error fetching crate details: {err:#?}"))?; 124 | 125 | *full_crate_info.lock().unwrap() = Some(full_crate_data); 126 | Ok(()) 127 | } 128 | 129 | pub async fn request_summary( 130 | summary: Arc>>, 131 | ) -> Result<(), String> { 132 | let client = create_client()?; 133 | 134 | let summary_data = client 135 | .summary() 136 | .await 137 | .map_err(|err| format!("Error fetching crate details: {err:#?}"))?; 138 | *summary.lock().unwrap() = Some(summary_data); 139 | Ok(()) 140 | } 141 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::panic; 2 | 3 | use color_eyre::{ 4 | config::{EyreHook, HookBuilder, PanicHook}, 5 | eyre::{self, Result}, 6 | }; 7 | use tracing::error; 8 | 9 | use crate::tui; 10 | use cfg_if::cfg_if; 11 | 12 | pub fn install_hooks() -> Result<()> { 13 | let (panic_hook, eyre_hook) = HookBuilder::default() 14 | .panic_section(format!( 15 | "This is a bug. Consider reporting it at {}", 16 | env!("CARGO_PKG_REPOSITORY") 17 | )) 18 | .capture_span_trace_by_default(false) 19 | .display_location_section(false) 20 | .display_env_section(false) 21 | .into_hooks(); 22 | 23 | cfg_if! { 24 | if #[cfg(debug_assertions)] { 25 | install_better_panic(); 26 | } else { 27 | human_panic::setup_panic!(); 28 | } 29 | } 30 | install_color_eyre_panic_hook(panic_hook); 31 | install_eyre_hook(eyre_hook)?; 32 | 33 | Ok(()) 34 | } 35 | 36 | #[allow(dead_code)] 37 | fn install_better_panic() { 38 | better_panic::Settings::auto() 39 | .most_recent_first(false) 40 | .verbosity(better_panic::Verbosity::Full) 41 | .install() 42 | } 43 | 44 | fn install_color_eyre_panic_hook(panic_hook: PanicHook) { 45 | // convert from a `color_eyre::config::PanicHook`` to a `Box` 47 | let panic_hook = panic_hook.into_panic_hook(); 48 | panic::set_hook(Box::new(move |panic_info| { 49 | if let Err(err) = tui::restore() { 50 | error!("Unable to restore terminal: {err:?}"); 51 | } 52 | 53 | // not sure about this 54 | // let msg = format!("{}", panic_hook.panic_report(panic_info)); 55 | // error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 56 | panic_hook(panic_info); 57 | })); 58 | } 59 | 60 | fn install_eyre_hook(eyre_hook: EyreHook) -> color_eyre::Result<()> { 61 | let eyre_hook = eyre_hook.into_eyre_hook(); 62 | eyre::set_hook(Box::new(move |error| { 63 | tui::restore().unwrap(); 64 | eyre_hook(error) 65 | }))?; 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /src/events.rs: -------------------------------------------------------------------------------- 1 | use std::{pin::Pin, time::Duration}; 2 | 3 | use crossterm::event::{Event as CrosstermEvent, *}; 4 | use futures::{Stream, StreamExt}; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::time::interval; 7 | use tokio_stream::{wrappers::IntervalStream, StreamMap}; 8 | 9 | use crate::config; 10 | 11 | pub struct Events { 12 | streams: StreamMap>>>, 13 | } 14 | 15 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] 16 | enum StreamName { 17 | Ticks, 18 | KeyRefresh, 19 | Render, 20 | Crossterm, 21 | } 22 | 23 | #[derive(Clone, Debug, Serialize, Deserialize)] 24 | pub enum Event { 25 | Init, 26 | Quit, 27 | Error, 28 | Closed, 29 | Tick, 30 | KeyRefresh, 31 | Render, 32 | Crossterm(CrosstermEvent), 33 | } 34 | 35 | impl Events { 36 | pub fn new() -> Self { 37 | Self { 38 | streams: StreamMap::from_iter([ 39 | (StreamName::Ticks, tick_stream()), 40 | (StreamName::KeyRefresh, key_refresh_stream()), 41 | (StreamName::Render, render_stream()), 42 | (StreamName::Crossterm, crossterm_stream()), 43 | ]), 44 | } 45 | } 46 | 47 | pub async fn next(&mut self) -> Option { 48 | self.streams.next().await.map(|(_name, event)| event) 49 | } 50 | } 51 | 52 | fn tick_stream() -> Pin>> { 53 | let tick_delay = Duration::from_secs_f64(1.0 / config::get().tick_rate); 54 | let tick_interval = interval(tick_delay); 55 | Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick)) 56 | } 57 | 58 | fn key_refresh_stream() -> Pin>> { 59 | let key_refresh_delay = Duration::from_secs_f64(1.0 / config::get().key_refresh_rate); 60 | let key_refresh_interval = interval(key_refresh_delay); 61 | Box::pin(IntervalStream::new(key_refresh_interval).map(|_| Event::KeyRefresh)) 62 | } 63 | 64 | fn render_stream() -> Pin>> { 65 | let render_delay = Duration::from_secs_f64(1.0 / config::get().frame_rate); 66 | let render_interval = interval(render_delay); 67 | Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render)) 68 | } 69 | 70 | fn crossterm_stream() -> Pin>> { 71 | Box::pin(EventStream::new().fuse().filter_map(|event| async move { 72 | match event { 73 | // Ignore key release / repeat events 74 | Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Release => None, 75 | Ok(event) => Some(Event::Crossterm(event)), 76 | Err(_) => Some(Event::Error), 77 | } 78 | })) 79 | } 80 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::Result; 2 | use tracing::level_filters::LevelFilter; 3 | use tracing_error::ErrorLayer; 4 | use tracing_subscriber::{ 5 | self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, 6 | }; 7 | 8 | use crate::config; 9 | 10 | pub fn init() -> Result<()> { 11 | let config = config::get(); 12 | let directory = config.data_dir.clone(); 13 | std::fs::create_dir_all(directory.clone())?; 14 | let log_file = format!("{}.log", env!("CARGO_PKG_NAME")); 15 | let log_path = directory.join(log_file); 16 | let log_file = std::fs::File::create(log_path)?; 17 | let file_subscriber = tracing_subscriber::fmt::layer() 18 | .with_file(true) 19 | .with_line_number(true) 20 | .with_writer(log_file) 21 | .with_target(false) 22 | .with_ansi(false); 23 | tracing_subscriber::registry() 24 | .with(file_subscriber) 25 | .with(ErrorLayer::default()) 26 | .with( 27 | tracing_subscriber::filter::EnvFilter::from_default_env() 28 | .add_directive("tokio_util=off".parse().unwrap()) 29 | .add_directive("hyper=off".parse().unwrap()) 30 | .add_directive("reqwest=off".parse().unwrap()) 31 | .add_directive(config.log_level.unwrap_or(LevelFilter::OFF).into()), 32 | ) 33 | .init(); 34 | Ok(()) 35 | } 36 | 37 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 38 | /// than printing to stdout. 39 | /// 40 | /// By default, the verbosity level for the generated events is `DEBUG`, but 41 | /// this can be customized. 42 | /// 43 | /// Originally from https://github.com/tokio-rs/tracing/blob/baeba47cdaac9ed32d5ef3f6f1d7b0cc71ffdbdf/tracing-macros/src/lib.rs#L27 44 | #[macro_export] 45 | macro_rules! trace_dbg { 46 | (target: $target:expr, level: $level:expr, $ex:expr) => {{ 47 | match $ex { 48 | value => { 49 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 50 | value 51 | } 52 | } 53 | }}; 54 | (level: $level:expr, $ex:expr) => { 55 | trace_dbg!(target: module_path!(), level: $level, $ex) 56 | }; 57 | (target: $target:expr, $ex:expr) => { 58 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 59 | }; 60 | ($ex:expr) => { 61 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod app; 3 | mod cli; 4 | mod command; 5 | mod config; 6 | mod crates_io_api_helper; 7 | mod errors; 8 | mod events; 9 | mod logging; 10 | mod serde_helper; 11 | mod tui; 12 | mod widgets; 13 | 14 | use app::App; 15 | use color_eyre::eyre::Result; 16 | 17 | #[tokio::main] 18 | async fn main() -> Result<()> { 19 | let cli = cli::parse(); 20 | config::init(&cli)?; 21 | logging::init()?; 22 | errors::install_hooks()?; 23 | 24 | if cli.print_default_config { 25 | println!("{}", toml::to_string_pretty(config::get())?); 26 | return Ok(()); 27 | } 28 | 29 | let tui = tui::init()?; 30 | let events = events::Events::new(); 31 | App::new().run(tui, events, cli.query).await?; 32 | tui::restore()?; 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /src/serde_helper.rs: -------------------------------------------------------------------------------- 1 | pub mod keybindings { 2 | use std::collections::HashMap; 3 | 4 | use color_eyre::eyre::Result; 5 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 6 | use derive_deref::{Deref, DerefMut}; 7 | use itertools::Itertools; 8 | use serde::{de::Deserializer, Deserialize, Serialize, Serializer}; 9 | 10 | use crate::{action::Action, app::Mode, command::Command}; 11 | 12 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 13 | pub struct KeyBindings(pub HashMap, Command>>); 14 | 15 | impl KeyBindings { 16 | pub fn command_to_action(&self, command: Command) -> Action { 17 | match command { 18 | Command::Quit => Action::Quit, 19 | Command::NextTab => Action::NextTab, 20 | Command::PreviousTab => Action::PreviousTab, 21 | Command::ClosePopup => Action::ClosePopup, 22 | Command::SwitchMode(m) => Action::SwitchMode(m), 23 | Command::SwitchToLastMode => Action::SwitchToLastMode, 24 | Command::IncrementPage => Action::IncrementPage, 25 | Command::DecrementPage => Action::DecrementPage, 26 | Command::NextSummaryMode => Action::NextSummaryMode, 27 | Command::PreviousSummaryMode => Action::PreviousSummaryMode, 28 | Command::ToggleSortBy { reload, forward } => { 29 | Action::ToggleSortBy { reload, forward } 30 | } 31 | Command::ScrollBottom => Action::ScrollBottom, 32 | Command::ScrollTop => Action::ScrollTop, 33 | Command::ScrollDown => Action::ScrollDown, 34 | Command::ScrollUp => Action::ScrollUp, 35 | Command::ScrollCrateInfoDown => Action::ScrollCrateInfoDown, 36 | Command::ScrollCrateInfoUp => Action::ScrollCrateInfoUp, 37 | Command::ScrollSearchResultsDown => Action::ScrollSearchResultsDown, 38 | Command::ScrollSearchResultsUp => Action::ScrollSearchResultsUp, 39 | Command::SubmitSearch => Action::SubmitSearch, 40 | Command::ReloadData => Action::ReloadData, 41 | Command::ToggleShowCrateInfo => Action::ToggleShowCrateInfo, 42 | Command::CopyCargoAddCommandToClipboard => Action::CopyCargoAddCommandToClipboard, 43 | Command::OpenDocsUrlInBrowser => Action::OpenDocsUrlInBrowser, 44 | Command::OpenCratesIOUrlInBrowser => Action::OpenCratesIOUrlInBrowser, 45 | } 46 | } 47 | 48 | #[allow(dead_code)] 49 | pub fn insert(&mut self, mode: Mode, key_events: &[KeyEvent], command: Command) { 50 | // Convert the slice of `KeyEvent`(s) to a `Vec`. 51 | let key_events_vec = key_events.to_vec(); 52 | 53 | // Retrieve or create the inner `HashMap` corresponding to the mode. 54 | let bindings_for_mode = self.0.entry(mode).or_default(); 55 | 56 | // Insert the `Command` into the inner `HashMap` using the key events `Vec` as 57 | // the key. 58 | bindings_for_mode.insert(key_events_vec, command); 59 | } 60 | 61 | pub fn event_to_command(&self, mode: Mode, key_events: &[KeyEvent]) -> Option { 62 | if key_events.is_empty() { 63 | None 64 | } else if let Some(Some(command)) = self.0.get(&mode).map(|kb| kb.get(key_events)) { 65 | Some(*command) 66 | } else { 67 | self.event_to_command(mode, &key_events[1..]) 68 | } 69 | } 70 | 71 | pub fn get_keybindings_for_command( 72 | &self, 73 | mode: Mode, 74 | command: Command, 75 | ) -> Vec> { 76 | let bindings_for_mode = self.0.get(&mode).cloned().unwrap_or_default(); 77 | bindings_for_mode 78 | .into_iter() 79 | .filter(|(_, v)| *v == command) 80 | .map(|(k, _)| k) 81 | .collect_vec() 82 | } 83 | 84 | pub fn get_config_for_command(&self, mode: Mode, command: Command) -> Vec { 85 | self.get_keybindings_for_command(mode, command) 86 | .iter() 87 | .map(|key_events| { 88 | key_events 89 | .iter() 90 | .map(key_event_to_string) 91 | .collect_vec() 92 | .join("") 93 | }) 94 | .collect_vec() 95 | } 96 | } 97 | 98 | impl<'de> Deserialize<'de> for KeyBindings { 99 | fn deserialize(deserializer: D) -> Result 100 | where 101 | D: Deserializer<'de>, 102 | { 103 | let parsed_map = HashMap::>::deserialize(deserializer)?; 104 | 105 | let keybindings = parsed_map 106 | .into_iter() 107 | .map(|(mode, inner_map)| { 108 | let converted_inner_map = inner_map 109 | .into_iter() 110 | .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) 111 | .collect(); 112 | (mode, converted_inner_map) 113 | }) 114 | .collect(); 115 | 116 | Ok(KeyBindings(keybindings)) 117 | } 118 | } 119 | 120 | impl Serialize for KeyBindings { 121 | fn serialize(&self, serializer: S) -> Result 122 | where 123 | S: Serializer, 124 | { 125 | let mut serialized_map: HashMap> = HashMap::new(); 126 | 127 | for (mode, key_event_map) in self.0.iter() { 128 | let mut string_event_map = HashMap::new(); 129 | 130 | for (key_events, command) in key_event_map { 131 | let key_string = key_events 132 | .iter() 133 | .map(|key_event| format!("<{}>", key_event_to_string(key_event))) 134 | .collect::>() 135 | .join(""); 136 | 137 | string_event_map.insert(key_string, *command); 138 | } 139 | 140 | serialized_map.insert(*mode, string_event_map); 141 | } 142 | 143 | serialized_map.serialize(serializer) 144 | } 145 | } 146 | 147 | fn parse_key_event(raw: &str) -> Result { 148 | let (remaining, modifiers) = extract_modifiers(raw); 149 | parse_key_code_with_modifiers(remaining, modifiers) 150 | } 151 | 152 | fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { 153 | let mut modifiers = KeyModifiers::empty(); 154 | let mut current = raw; 155 | 156 | loop { 157 | match current { 158 | rest if rest.to_lowercase().starts_with("ctrl-") => { 159 | modifiers.insert(KeyModifiers::CONTROL); 160 | current = &rest[5..]; 161 | } 162 | rest if rest.to_lowercase().starts_with("alt-") => { 163 | modifiers.insert(KeyModifiers::ALT); 164 | current = &rest[4..]; 165 | } 166 | rest if rest.to_lowercase().starts_with("shift-") => { 167 | modifiers.insert(KeyModifiers::SHIFT); 168 | current = &rest[6..]; 169 | } 170 | _ => break, // break out of the loop if no known prefix is detected 171 | }; 172 | } 173 | 174 | (current, modifiers) 175 | } 176 | 177 | // FIXME - seems excessively verbose. Use strum to simplify? 178 | fn parse_key_code_with_modifiers( 179 | raw: &str, 180 | mut modifiers: KeyModifiers, 181 | ) -> Result { 182 | let c = match raw.to_lowercase().as_str() { 183 | "esc" => KeyCode::Esc, 184 | "enter" => KeyCode::Enter, 185 | "left" => KeyCode::Left, 186 | "right" => KeyCode::Right, 187 | "up" => KeyCode::Up, 188 | "down" => KeyCode::Down, 189 | "home" => KeyCode::Home, 190 | "end" => KeyCode::End, 191 | "pageup" => KeyCode::PageUp, 192 | "pagedown" => KeyCode::PageDown, 193 | "backtab" => { 194 | modifiers.insert(KeyModifiers::SHIFT); 195 | KeyCode::BackTab 196 | } 197 | "backspace" => KeyCode::Backspace, 198 | "delete" => KeyCode::Delete, 199 | "insert" => KeyCode::Insert, 200 | "f1" => KeyCode::F(1), 201 | "f2" => KeyCode::F(2), 202 | "f3" => KeyCode::F(3), 203 | "f4" => KeyCode::F(4), 204 | "f5" => KeyCode::F(5), 205 | "f6" => KeyCode::F(6), 206 | "f7" => KeyCode::F(7), 207 | "f8" => KeyCode::F(8), 208 | "f9" => KeyCode::F(9), 209 | "f10" => KeyCode::F(10), 210 | "f11" => KeyCode::F(11), 211 | "f12" => KeyCode::F(12), 212 | "space" => KeyCode::Char(' '), 213 | "hyphen" => KeyCode::Char('-'), 214 | "minus" => KeyCode::Char('-'), 215 | "tab" => KeyCode::Tab, 216 | c if c.len() == 1 => { 217 | let mut c = raw.chars().next().unwrap(); 218 | if modifiers.contains(KeyModifiers::SHIFT) { 219 | c = c.to_ascii_uppercase(); 220 | } 221 | KeyCode::Char(c) 222 | } 223 | _ => return Err(format!("Unable to parse {raw}")), 224 | }; 225 | Ok(KeyEvent::new(c, modifiers)) 226 | } 227 | 228 | pub fn key_event_to_string(key_event: &KeyEvent) -> String { 229 | let char; 230 | let key_code = match key_event.code { 231 | KeyCode::Backspace => "Backspace", 232 | KeyCode::Enter => "Enter", 233 | KeyCode::Left => "Left", 234 | KeyCode::Right => "Right", 235 | KeyCode::Up => "Up", 236 | KeyCode::Down => "Down", 237 | KeyCode::Home => "Home", 238 | KeyCode::End => "End", 239 | KeyCode::PageUp => "PageUp", 240 | KeyCode::PageDown => "PageDown", 241 | KeyCode::Tab => "Tab", 242 | KeyCode::BackTab => "Backtab", 243 | KeyCode::Delete => "Delete", 244 | KeyCode::Insert => "Insert", 245 | KeyCode::F(c) => { 246 | char = format!("F({c})"); 247 | &char 248 | } 249 | KeyCode::Char(' ') => "Space", 250 | KeyCode::Char(c) => { 251 | char = c.to_string(); 252 | &char 253 | } 254 | KeyCode::Esc => "Esc", 255 | KeyCode::Null => "", 256 | KeyCode::CapsLock => "", 257 | KeyCode::Menu => "", 258 | KeyCode::ScrollLock => "", 259 | KeyCode::Media(_) => "", 260 | KeyCode::NumLock => "", 261 | KeyCode::PrintScreen => "", 262 | KeyCode::Pause => "", 263 | KeyCode::KeypadBegin => "", 264 | KeyCode::Modifier(_) => "", 265 | }; 266 | 267 | let mut modifiers = Vec::with_capacity(3); 268 | 269 | if key_event.modifiers.intersects(KeyModifiers::CONTROL) { 270 | modifiers.push("Ctrl"); 271 | } 272 | 273 | if key_event.modifiers.intersects(KeyModifiers::SHIFT) { 274 | modifiers.push("Shift"); 275 | } 276 | 277 | if key_event.modifiers.intersects(KeyModifiers::ALT) { 278 | modifiers.push("Alt"); 279 | } 280 | 281 | let mut key = modifiers.join("-"); 282 | 283 | if !key.is_empty() { 284 | key.push('-'); 285 | } 286 | key.push_str(key_code); 287 | 288 | key 289 | } 290 | 291 | pub fn parse_key_sequence(raw: &str) -> Result, String> { 292 | if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { 293 | return Err(format!("Unable to parse `{}`", raw)); 294 | } 295 | let raw = if !raw.contains("><") { 296 | let raw = raw.strip_prefix('<').unwrap_or(raw); 297 | let raw = raw.strip_prefix('>').unwrap_or(raw); 298 | raw 299 | } else { 300 | raw 301 | }; 302 | let sequences = raw 303 | .split("><") 304 | .map(|seq| { 305 | if let Some(s) = seq.strip_prefix('<') { 306 | s 307 | } else if let Some(s) = seq.strip_suffix('>') { 308 | s 309 | } else { 310 | seq 311 | } 312 | }) 313 | .collect::>(); 314 | 315 | sequences.into_iter().map(parse_key_event).collect() 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stdout, Stdout}; 2 | 3 | use color_eyre::eyre::Result; 4 | use crossterm::{event::*, execute, terminal::*}; 5 | use ratatui::prelude::*; 6 | 7 | use crate::config; 8 | 9 | pub type Tui = Terminal>; 10 | 11 | pub fn init() -> Result { 12 | enable_raw_mode()?; 13 | execute!(stdout(), EnterAlternateScreen)?; 14 | if config::get().enable_mouse { 15 | execute!(stdout(), EnableMouseCapture)?; 16 | } 17 | if config::get().enable_paste { 18 | execute!(stdout(), EnableBracketedPaste)?; 19 | } 20 | let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; 21 | terminal.clear()?; 22 | terminal.hide_cursor()?; 23 | Ok(terminal) 24 | } 25 | 26 | pub fn restore() -> Result<()> { 27 | if config::get().enable_paste { 28 | execute!(stdout(), DisableBracketedPaste)?; 29 | } 30 | if config::get().enable_mouse { 31 | execute!(stdout(), DisableMouseCapture)?; 32 | } 33 | execute!(stdout(), LeaveAlternateScreen)?; 34 | disable_raw_mode()?; 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /src/widgets.rs: -------------------------------------------------------------------------------- 1 | pub mod crate_info_table; 2 | pub mod help; 3 | pub mod popup_message; 4 | pub mod search_filter_prompt; 5 | pub mod search_page; 6 | pub mod search_results; 7 | pub mod status_bar; 8 | pub mod summary; 9 | pub mod tabs; 10 | -------------------------------------------------------------------------------- /src/widgets/crate_info_table.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use ratatui::{prelude::*, widgets::*}; 3 | 4 | use crate::config; 5 | 6 | #[derive(Debug, Default)] 7 | pub struct CrateInfo { 8 | crate_info: TableState, 9 | } 10 | 11 | impl CrateInfo { 12 | pub fn scroll_previous(&mut self) { 13 | let i = self 14 | .crate_info 15 | .selected() 16 | .map_or(0, |i| i.saturating_sub(1)); 17 | self.crate_info.select(Some(i)); 18 | } 19 | 20 | pub fn scroll_next(&mut self) { 21 | let i = self 22 | .crate_info 23 | .selected() 24 | .map_or(0, |i| i.saturating_add(1)); 25 | self.crate_info.select(Some(i)); 26 | } 27 | } 28 | 29 | pub struct CrateInfoTableWidget { 30 | crate_info: crates_io_api::CrateResponse, 31 | } 32 | 33 | impl CrateInfoTableWidget { 34 | pub fn new(crate_info: crates_io_api::CrateResponse) -> Self { 35 | Self { crate_info } 36 | } 37 | } 38 | 39 | impl StatefulWidget for CrateInfoTableWidget { 40 | type State = CrateInfo; 41 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 42 | let ci = self.crate_info.clone(); 43 | 44 | let created_at = ci 45 | .crate_data 46 | .created_at 47 | .format("%Y-%m-%d %H:%M:%S") 48 | .to_string(); 49 | let updated_at = ci 50 | .crate_data 51 | .updated_at 52 | .format("%Y-%m-%d %H:%M:%S") 53 | .to_string(); 54 | 55 | let mut rows = [ 56 | ["Name", &ci.crate_data.name], 57 | ["Created At", &created_at], 58 | ["Updated At", &updated_at], 59 | ["Max Version", &ci.crate_data.max_version], 60 | ] 61 | .iter() 62 | .map(|row| { 63 | let cells = row.iter().map(|cell| Cell::from(*cell)); 64 | Row::new(cells) 65 | }) 66 | .collect_vec(); 67 | let keywords = self 68 | .crate_info 69 | .keywords 70 | .iter() 71 | .map(|k| k.keyword.clone()) 72 | .map(Line::from) 73 | .join(", "); 74 | let keywords = textwrap::wrap(&keywords, (area.width as f64 * 0.75) as usize) 75 | .iter() 76 | .map(|s| Line::from(s.to_string())) 77 | .collect_vec(); 78 | let height = keywords.len(); 79 | rows.push( 80 | Row::new(vec![ 81 | Cell::from("Keywords"), 82 | Cell::from(Text::from(keywords)), 83 | ]) 84 | .height(height as u16), 85 | ); 86 | 87 | if let Some(description) = self.crate_info.crate_data.description { 88 | // assume description is wrapped in 75% 89 | let desc = textwrap::wrap(&description, (area.width as f64 * 0.75) as usize) 90 | .iter() 91 | .map(|s| Line::from(s.to_string())) 92 | .collect_vec(); 93 | let height = desc.len(); 94 | rows.push( 95 | Row::new(vec![ 96 | Cell::from("Description"), 97 | Cell::from(Text::from(desc)), 98 | ]) 99 | .height(height as u16), 100 | ); 101 | } 102 | if let Some(homepage) = self.crate_info.crate_data.homepage { 103 | rows.push(Row::new(vec![Cell::from("Homepage"), Cell::from(homepage)])); 104 | } 105 | if let Some(repository) = self.crate_info.crate_data.repository { 106 | rows.push(Row::new(vec![ 107 | Cell::from("Repository"), 108 | Cell::from(repository), 109 | ])); 110 | } 111 | if let Some(recent_downloads) = self.crate_info.crate_data.recent_downloads { 112 | rows.push(Row::new(vec![ 113 | Cell::from("Recent Downloads"), 114 | Cell::from(recent_downloads.to_string()), 115 | ])); 116 | } 117 | if let Some(max_stable_version) = self.crate_info.crate_data.max_stable_version { 118 | rows.push(Row::new(vec![ 119 | Cell::from("Max Stable Version"), 120 | Cell::from(max_stable_version), 121 | ])); 122 | } 123 | 124 | let selected_max = rows.len().saturating_sub(1); 125 | 126 | let widths = [Constraint::Fill(1), Constraint::Fill(4)]; 127 | let table_widget = Table::new(rows, widths) 128 | .style( 129 | Style::default() 130 | .fg(config::get().color.base05) 131 | .bg(config::get().color.base00), 132 | ) 133 | .block(Block::default().borders(Borders::ALL)) 134 | .highlight_symbol("\u{2022} ") 135 | .row_highlight_style(config::get().color.base05) 136 | .highlight_spacing(HighlightSpacing::Always); 137 | 138 | if let Some(i) = state.crate_info.selected() { 139 | state.crate_info.select(Some(i.min(selected_max))); 140 | } else { 141 | state.crate_info.select(Some(0)); 142 | } 143 | StatefulWidget::render(table_widget, area, buf, &mut state.crate_info); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/widgets/help.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use ratatui::{prelude::*, widgets::*}; 3 | 4 | use crate::{ 5 | app::Mode, 6 | command::{Command, ALL_COMMANDS}, 7 | config, 8 | }; 9 | 10 | #[derive(Default, Debug, Clone)] 11 | pub struct Help { 12 | pub state: TableState, 13 | pub mode: Option, 14 | } 15 | 16 | impl Help { 17 | pub fn new(state: TableState, mode: Option) -> Self { 18 | Self { state, mode } 19 | } 20 | 21 | pub fn scroll_up(&mut self) { 22 | let i = self.state.selected().map_or(0, |i| i.saturating_sub(1)); 23 | self.state.select(Some(i)); 24 | } 25 | 26 | pub fn scroll_down(&mut self) { 27 | let i = self.state.selected().map_or(0, |i| i.saturating_add(1)); 28 | self.state.select(Some(i)); 29 | } 30 | } 31 | 32 | pub struct HelpWidget; 33 | 34 | const HIGHLIGHT_SYMBOL: &str = "█ "; 35 | 36 | impl StatefulWidget for &HelpWidget { 37 | type State = Help; 38 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 39 | use Constraint::*; 40 | let [_, area] = Layout::vertical([Min(0), Percentage(90)]).areas(area); 41 | let [_, area, _] = Layout::horizontal([Min(0), Percentage(85), Min(0)]).areas(area); 42 | 43 | let all_key_bindings = all_key_bindings(); 44 | select_by_mode(state, &all_key_bindings); 45 | 46 | let widths = [Max(10), Max(10), Min(0)]; 47 | let header = Row::new(["Mode", "Keys", "Command"].map(|h| Line::from(h.bold()))) 48 | .fg(config::get().color.base05) 49 | .bg(config::get().color.base00); 50 | let table = Table::new(into_rows(&all_key_bindings), widths) 51 | .header(header) 52 | .column_spacing(5) 53 | .highlight_symbol(HIGHLIGHT_SYMBOL) 54 | .row_highlight_style(config::get().color.base05) 55 | .highlight_spacing(HighlightSpacing::Always); 56 | StatefulWidget::render(table, area, buf, &mut state.state); 57 | } 58 | } 59 | 60 | /// Returns all key bindings for all commands and modes 61 | /// 62 | /// The result is a vector of tuples containing the mode, command and key bindings joined by a comma 63 | fn all_key_bindings() -> Vec<(Mode, Command, String)> { 64 | ALL_COMMANDS 65 | .iter() 66 | .flat_map(|(mode, commands)| { 67 | commands.iter().map(|command| { 68 | let key_bindings = key_bindings_for_command(*mode, *command); 69 | let key_bindings = key_bindings.join(", "); 70 | (*mode, *command, key_bindings) 71 | }) 72 | }) 73 | .collect_vec() 74 | } 75 | 76 | /// Returns the key bindings for a specific command and mode 77 | fn key_bindings_for_command(mode: Mode, command: Command) -> Vec { 78 | config::get() 79 | .key_bindings 80 | .get_config_for_command(mode, command) 81 | } 82 | 83 | /// updates the selected index based on the current mode 84 | /// 85 | /// Only changes the selected index for the first render 86 | fn select_by_mode(state: &mut Help, rows: &[(Mode, Command, String)]) { 87 | if let Some(mode) = state.mode { 88 | tracing::debug!("{:?}", mode); 89 | let selected = rows 90 | .iter() 91 | .find_position(|(m, _, _)| *m == mode) 92 | .map(|(index, _)| index) 93 | .unwrap_or_default(); 94 | *state.state.selected_mut() = Some(selected); 95 | *state.state.offset_mut() = selected.saturating_sub(2); 96 | // Reset the mode after the first render - let the user scroll 97 | state.mode = None; 98 | }; 99 | // ensure the selected index is within the bounds 100 | *state.state.selected_mut() = Some( 101 | state 102 | .state 103 | .selected() 104 | .unwrap_or_default() 105 | .min(rows.len().saturating_sub(1)), 106 | ); 107 | } 108 | 109 | fn into_rows(rows: &[(Mode, Command, String)]) -> impl Iterator> { 110 | rows.iter().map(|(mode, command, keys)| { 111 | Row::new([ 112 | Line::styled(format!("{} ", mode), Color::DarkGray), 113 | Line::raw(keys.to_string()), 114 | Line::raw(format!("{:?} ", command)), 115 | ]) 116 | .fg(config::get().color.base05) 117 | .bg(config::get().color.base00) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /src/widgets/popup_message.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use ratatui::{layout::Flex, prelude::*, widgets::*}; 3 | 4 | #[derive(Debug, Default, Clone, Copy)] 5 | pub struct PopupMessageState { 6 | scroll: usize, 7 | } 8 | 9 | impl PopupMessageState { 10 | pub fn scroll_up(&mut self) { 11 | self.scroll = self.scroll.saturating_sub(1) 12 | } 13 | 14 | pub fn scroll_down(&mut self) { 15 | self.scroll = self.scroll.saturating_add(1) 16 | } 17 | 18 | pub fn scroll_top(&mut self) { 19 | self.scroll = 0; 20 | } 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct PopupMessageWidget { 25 | title: String, 26 | message: String, 27 | } 28 | 29 | impl PopupMessageWidget { 30 | pub fn new(title: String, message: String) -> Self { 31 | Self { title, message } 32 | } 33 | } 34 | 35 | impl StatefulWidget for &PopupMessageWidget { 36 | type State = PopupMessageState; 37 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 38 | let [center] = Layout::horizontal([Constraint::Percentage(50)]) 39 | .flex(Flex::Center) 40 | .areas(area); 41 | 42 | let message = textwrap::wrap(&self.message, center.width as usize) 43 | .iter() 44 | .map(|s| Line::from(s.to_string())) 45 | .collect_vec(); 46 | let line_count = message.len(); 47 | let [center] = Layout::vertical([Constraint::Length(line_count as u16 + 3)]) 48 | .flex(Flex::Center) 49 | .areas(center); 50 | 51 | state.scroll = state.scroll.min(line_count.saturating_sub(1)); 52 | let instruction = Line::from(vec!["Esc".bold(), " to close".into()]).right_aligned(); 53 | let block = Block::bordered() 54 | .border_style(Color::DarkGray) 55 | .title(self.title.clone()) 56 | .title_bottom(instruction); 57 | Clear.render(center, buf); 58 | Paragraph::new(self.message.clone()) 59 | .block(block) 60 | .wrap(Wrap { trim: false }) 61 | .scroll((state.scroll as u16, 0)) 62 | .render(center, buf); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/widgets/search_filter_prompt.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{layout::Constraint::*, layout::Position, prelude::*, widgets::*}; 2 | 3 | use crate::{app::Mode, config}; 4 | 5 | use super::search_page::SearchMode; 6 | 7 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] 8 | pub struct SearchFilterPrompt { 9 | cursor_position: Option, 10 | } 11 | 12 | impl SearchFilterPrompt { 13 | pub fn cursor_position(&self) -> Option { 14 | self.cursor_position 15 | } 16 | } 17 | 18 | pub struct SearchFilterPromptWidget<'a> { 19 | mode: Mode, 20 | sort: crates_io_api::Sort, 21 | input: &'a tui_input::Input, 22 | vertical_margin: u16, 23 | horizontal_margin: u16, 24 | search_mode: SearchMode, 25 | } 26 | 27 | impl<'a> SearchFilterPromptWidget<'a> { 28 | pub fn new( 29 | mode: Mode, 30 | sort: crates_io_api::Sort, 31 | input: &'a tui_input::Input, 32 | search_mode: SearchMode, 33 | ) -> Self { 34 | Self { 35 | mode, 36 | sort, 37 | input, 38 | vertical_margin: 2, 39 | horizontal_margin: 2, 40 | search_mode, 41 | } 42 | } 43 | } 44 | 45 | impl StatefulWidget for SearchFilterPromptWidget<'_> { 46 | type State = SearchFilterPrompt; 47 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 48 | let [input, meta] = Layout::horizontal([Percentage(75), Fill(0)]).areas(area); 49 | 50 | self.input_block().render(area, buf); 51 | 52 | if self.search_mode.is_focused() { 53 | self.sort_by_info().render(meta.inner(self.margin()), buf); 54 | } 55 | self.input_text(input.width as usize) 56 | .render(input.inner(self.margin()), buf); 57 | 58 | self.update_cursor_state(area, state); 59 | } 60 | } 61 | 62 | impl SearchFilterPromptWidget<'_> { 63 | fn input_block(&self) -> Block { 64 | let borders = if self.search_mode.is_focused() { 65 | Borders::ALL 66 | } else { 67 | Borders::NONE 68 | }; 69 | let border_color = match self.mode { 70 | Mode::Search => config::get().color.base0a, 71 | Mode::Filter => config::get().color.base0b, 72 | _ => config::get().color.base06, 73 | }; 74 | let input_block = Block::default() 75 | .borders(borders) 76 | .fg(config::get().color.base05) 77 | .border_style(border_color); 78 | input_block 79 | } 80 | 81 | fn sort_by_info(&self) -> impl Widget { 82 | Paragraph::new(Line::from(vec![ 83 | "Sort By: ".into(), 84 | format!("{:?}", self.sort.clone()).fg(config::get().color.base0d), 85 | ])) 86 | .right_aligned() 87 | } 88 | 89 | fn input_text(&self, width: usize) -> impl Widget + '_ { 90 | let scroll = self.input.cursor().saturating_sub(width.saturating_sub(4)); 91 | let text = if self.search_mode.is_focused() { 92 | Line::from(vec![self.input.value().into()]) 93 | } else if self.mode.is_summary() || self.mode.is_help() { 94 | Line::from(vec![]) 95 | } else { 96 | Line::from(vec![ 97 | self.input.value().into(), 98 | " (".into(), 99 | format!("{:?}", self.sort.clone()).fg(config::get().color.base0d), 100 | ")".into(), 101 | ]) 102 | }; 103 | Paragraph::new(text).scroll((0, scroll as u16)) 104 | } 105 | 106 | fn update_cursor_state(&self, area: Rect, state: &mut SearchFilterPrompt) { 107 | let width = ((area.width as f64 * 0.75) as u16).saturating_sub(2); 108 | if self.search_mode.is_focused() { 109 | let margin = self.margin(); 110 | state.cursor_position = Some(Position::new( 111 | (area.x + margin.horizontal + self.input.cursor() as u16).min(width), 112 | area.y + margin.vertical, 113 | )); 114 | } else { 115 | state.cursor_position = None 116 | } 117 | } 118 | 119 | fn margin(&self) -> Margin { 120 | if self.search_mode.is_focused() { 121 | Margin::new(self.horizontal_margin, self.vertical_margin) 122 | } else { 123 | Margin::default() 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/widgets/search_page.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use std::{ 3 | collections::HashMap, 4 | sync::{ 5 | atomic::{AtomicBool, Ordering}, 6 | Arc, Mutex, 7 | }, 8 | }; 9 | use strum::EnumIs; 10 | use tracing::info; 11 | 12 | use crossterm::event::{Event as CrosstermEvent, KeyEvent}; 13 | use itertools::Itertools; 14 | use ratatui::prelude::*; 15 | use ratatui::{layout::Position, widgets::StatefulWidget}; 16 | use tokio::{sync::mpsc::UnboundedSender, task::JoinHandle}; 17 | use tui_input::{backend::crossterm::EventHandler, Input}; 18 | 19 | use crate::{ 20 | action::Action, 21 | app::Mode, 22 | crates_io_api_helper, 23 | widgets::{search_filter_prompt::SearchFilterPrompt, search_results::SearchResults}, 24 | }; 25 | 26 | use super::{ 27 | crate_info_table::{CrateInfo, CrateInfoTableWidget}, 28 | search_results::SearchResultsWidget, 29 | }; 30 | 31 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, EnumIs)] 32 | pub enum SearchMode { 33 | #[default] 34 | Search, 35 | Filter, 36 | ResultsHideCrate, 37 | ResultsShowCrate, 38 | } 39 | 40 | impl SearchMode { 41 | pub fn is_focused(&self) -> bool { 42 | matches!(self, SearchMode::Search | SearchMode::Filter) 43 | } 44 | 45 | pub fn toggle_show_crate_info(&mut self) { 46 | *self = match self { 47 | SearchMode::ResultsShowCrate => SearchMode::ResultsHideCrate, 48 | SearchMode::ResultsHideCrate => SearchMode::ResultsShowCrate, 49 | _ => *self, 50 | }; 51 | } 52 | 53 | pub fn should_show_crate_info(&self) -> bool { 54 | matches!(self, SearchMode::ResultsShowCrate) 55 | } 56 | } 57 | 58 | #[derive(Debug)] 59 | pub struct SearchPage { 60 | pub mode: Mode, 61 | pub search_mode: SearchMode, 62 | pub crate_info: CrateInfo, 63 | 64 | /// A string for the current search input by the user, submitted to 65 | /// crates.io as a query 66 | pub search: String, 67 | 68 | /// A string for the current filter input by the user, used only locally 69 | /// for filtering for the list of crates in the current view. 70 | pub filter: String, 71 | 72 | /// A table component designed to handle the listing and selection of crates 73 | /// within the terminal UI. 74 | pub results: SearchResults, 75 | 76 | /// An input handler component for managing raw user input into textual 77 | /// form. 78 | pub input: tui_input::Input, 79 | 80 | /// A prompt displaying the current search or filter query, if any, that the 81 | /// user can interact with. 82 | pub prompt: SearchFilterPrompt, 83 | 84 | /// The current page number being displayed or interacted with in the UI. 85 | pub page: u64, 86 | 87 | /// The number of crates displayed per page in the UI. 88 | pub page_size: u64, 89 | 90 | /// Sort preference for search results 91 | pub sort: crates_io_api::Sort, 92 | 93 | /// The total number of crates fetchable from crates.io, which may not be 94 | /// known initially and can be used for UI elements like pagination. 95 | pub total_num_crates: Option, 96 | 97 | /// A thread-safe, shared vector holding the list of crates fetched from 98 | /// crates.io, wrapped in a mutex to control concurrent access. 99 | pub crates: Arc>>, 100 | 101 | /// A thread-safe, shared vector holding the list of version fetched from 102 | /// crates.io, wrapped in a mutex to control concurrent access. 103 | pub versions: Arc>>, 104 | 105 | /// A thread-safe shared container holding the detailed information about 106 | /// the currently selected crate; this can be `None` if no crate is 107 | /// selected. 108 | pub full_crate_info: Arc>>, 109 | 110 | /// A thread-safe shared container holding the detailed information about 111 | /// the currently selected crate; this can be `None` if no crate is 112 | /// selected. 113 | pub crate_response: Arc>>, 114 | 115 | pub last_task_details_handle: HashMap>, 116 | 117 | /// Sender end of an asynchronous channel for dispatching actions from 118 | /// various parts of the app to be handled by the event loop. 119 | tx: UnboundedSender, 120 | 121 | /// A thread-safe indicator of whether data is currently being loaded, 122 | /// allowing different parts of the app to know if it's in a loading state. 123 | loading_status: Arc, 124 | } 125 | 126 | impl SearchPage { 127 | pub fn new(tx: UnboundedSender, loading_status: Arc) -> Self { 128 | Self { 129 | mode: Default::default(), 130 | search_mode: Default::default(), 131 | search: String::new(), 132 | filter: String::new(), 133 | results: SearchResults::default(), 134 | input: Input::default(), 135 | prompt: SearchFilterPrompt::default(), 136 | page: 1, 137 | page_size: 25, 138 | sort: crates_io_api::Sort::Relevance, 139 | total_num_crates: None, 140 | crates: Default::default(), 141 | versions: Default::default(), 142 | full_crate_info: Default::default(), 143 | crate_info: Default::default(), 144 | crate_response: Default::default(), 145 | last_task_details_handle: Default::default(), 146 | tx, 147 | loading_status, 148 | } 149 | } 150 | 151 | pub fn handle_action(&mut self, action: Action) { 152 | match action { 153 | Action::ScrollTop => self.results.scroll_to_top(), 154 | Action::ScrollBottom => self.results.scroll_to_bottom(), 155 | Action::ScrollSearchResultsUp => self.scroll_up(), 156 | Action::ScrollSearchResultsDown => self.scroll_down(), 157 | _ => {} 158 | } 159 | } 160 | 161 | pub fn update_search_table_results(&mut self) { 162 | self.results.content_length(self.results.crates.len()); 163 | 164 | let filter = self.filter.clone(); 165 | let filter_words = filter.split_whitespace().collect::>(); 166 | 167 | let crates: Vec<_> = self 168 | .crates 169 | .lock() 170 | .unwrap() 171 | .iter() 172 | .filter(|c| { 173 | filter_words.iter().all(|word| { 174 | c.name.to_lowercase().contains(word) 175 | || c.description 176 | .clone() 177 | .unwrap_or_default() 178 | .to_lowercase() 179 | .contains(word) 180 | }) 181 | }) 182 | .cloned() 183 | .collect_vec(); 184 | self.results.crates = crates; 185 | } 186 | 187 | pub fn scroll_up(&mut self) { 188 | self.results.scroll_previous(); 189 | } 190 | 191 | pub fn scroll_down(&mut self) { 192 | self.results.scroll_next(); 193 | } 194 | 195 | pub fn handle_key(&mut self, key: KeyEvent) { 196 | self.input.handle_event(&CrosstermEvent::Key(key)); 197 | } 198 | 199 | pub fn handle_filter_prompt_change(&mut self) { 200 | self.filter = self.input.value().into(); 201 | self.results.select(None); 202 | } 203 | 204 | pub fn cursor_position(&self) -> Option { 205 | self.prompt.cursor_position() 206 | } 207 | 208 | pub fn increment_page(&mut self) { 209 | if let Some(n) = self.total_num_crates { 210 | let max_page_size = (n / self.page_size) + 1; 211 | if self.page < max_page_size { 212 | self.page = self.page.saturating_add(1).min(max_page_size); 213 | self.reload_data(); 214 | } 215 | } 216 | } 217 | 218 | pub fn decrement_page(&mut self) { 219 | let min_page_size = 1; 220 | if self.page > min_page_size { 221 | self.page = self.page.saturating_sub(1).max(min_page_size); 222 | self.reload_data(); 223 | } 224 | } 225 | 226 | pub fn clear_task_details_handle(&mut self, id: uuid::Uuid) -> Result<()> { 227 | if let Some((_, handle)) = self.last_task_details_handle.remove_entry(&id) { 228 | handle.abort() 229 | } 230 | Ok(()) 231 | } 232 | 233 | pub fn is_prompt(&self) -> bool { 234 | self.search_mode.is_focused() 235 | } 236 | 237 | pub fn clear_all_previous_task_details_handles(&mut self) { 238 | *self.full_crate_info.lock().unwrap() = None; 239 | for (_, v) in self.last_task_details_handle.iter() { 240 | v.abort() 241 | } 242 | self.last_task_details_handle.clear() 243 | } 244 | 245 | pub fn submit_query(&mut self) { 246 | self.clear_all_previous_task_details_handles(); 247 | self.filter.clear(); 248 | self.search = self.input.value().into(); 249 | let _ = self.tx.send(Action::SwitchMode(Mode::PickerHideCrateInfo)); 250 | } 251 | 252 | /// Reloads the list of crates based on the current search parameters, 253 | /// updating the application state accordingly. This involves fetching 254 | /// data asynchronously from the crates.io API and updating various parts of 255 | /// the application state, such as the crates listing, current crate 256 | /// info, and loading status. 257 | pub fn reload_data(&mut self) { 258 | self.prepare_reload(); 259 | let search_params = self.create_search_parameters(); 260 | self.request_search_results(search_params); 261 | } 262 | 263 | /// Clears current search results and resets the UI to prepare for new data. 264 | pub fn prepare_reload(&mut self) { 265 | self.results.select(None); 266 | *self.full_crate_info.lock().unwrap() = None; 267 | *self.crate_response.lock().unwrap() = None; 268 | } 269 | 270 | /// Creates the parameters required for the search task. 271 | pub fn create_search_parameters(&self) -> crates_io_api_helper::SearchParameters { 272 | crates_io_api_helper::SearchParameters { 273 | search: self.search.clone(), 274 | page: self.page.clamp(1, u64::MAX), 275 | page_size: self.page_size, 276 | crates: self.crates.clone(), 277 | versions: self.versions.clone(), 278 | loading_status: self.loading_status.clone(), 279 | sort: self.sort.clone(), 280 | tx: self.tx.clone(), 281 | } 282 | } 283 | 284 | /// Spawns an asynchronous task to fetch crate data from crates.io. 285 | pub fn request_search_results(&self, params: crates_io_api_helper::SearchParameters) { 286 | tokio::spawn(async move { 287 | params.loading_status.store(true, Ordering::SeqCst); 288 | if let Err(error_message) = crates_io_api_helper::request_search_results(¶ms).await 289 | { 290 | let _ = params.tx.send(Action::ShowErrorPopup(error_message)); 291 | } 292 | let _ = params.tx.send(Action::UpdateSearchTableResults); 293 | params.loading_status.store(false, Ordering::SeqCst); 294 | }); 295 | } 296 | 297 | /// Spawns an asynchronous task to fetch crate details from crates.io based 298 | /// on currently selected crate 299 | pub fn request_crate_details(&mut self) { 300 | if self.results.crates.is_empty() { 301 | return; 302 | } 303 | if let Some(crate_name) = self.results.selected_crate_name() { 304 | let tx = self.tx.clone(); 305 | let crate_response = self.crate_response.clone(); 306 | let loading_status = self.loading_status.clone(); 307 | 308 | // Spawn the async work to fetch crate details. 309 | let uuid = uuid::Uuid::new_v4(); 310 | let last_task_details_handle = tokio::spawn(async move { 311 | info!("Requesting details for {crate_name}: {uuid}"); 312 | loading_status.store(true, Ordering::SeqCst); 313 | if let Err(error_message) = 314 | crates_io_api_helper::request_crate_details(&crate_name, crate_response).await 315 | { 316 | let _ = tx.send(Action::ShowErrorPopup(error_message)); 317 | }; 318 | loading_status.store(false, Ordering::SeqCst); 319 | info!("Retrieved details for {crate_name}: {uuid}"); 320 | let _ = tx.send(Action::ClearTaskDetailsHandle(uuid.to_string())); 321 | }); 322 | self.last_task_details_handle 323 | .insert(uuid, last_task_details_handle); 324 | } 325 | } 326 | 327 | /// Spawns an asynchronous task to fetch crate details from crates.io based 328 | /// on currently selected crate 329 | pub fn request_full_crate_details(&mut self) { 330 | if self.results.crates.is_empty() { 331 | return; 332 | } 333 | if let Some(crate_name) = self.results.selected_crate_name() { 334 | let tx = self.tx.clone(); 335 | let full_crate_info = self.full_crate_info.clone(); 336 | let loading_status = self.loading_status.clone(); 337 | 338 | // Spawn the async work to fetch crate details. 339 | let uuid = uuid::Uuid::new_v4(); 340 | let last_task_details_handle = tokio::spawn(async move { 341 | info!("Requesting details for {crate_name}: {uuid}"); 342 | loading_status.store(true, Ordering::SeqCst); 343 | if let Err(error_message) = 344 | crates_io_api_helper::request_full_crate_details(&crate_name, full_crate_info) 345 | .await 346 | { 347 | let _ = tx.send(Action::ShowErrorPopup(error_message)); 348 | }; 349 | loading_status.store(false, Ordering::SeqCst); 350 | info!("Retrieved details for {crate_name}: {uuid}"); 351 | let _ = tx.send(Action::ClearTaskDetailsHandle(uuid.to_string())); 352 | }); 353 | self.last_task_details_handle 354 | .insert(uuid, last_task_details_handle); 355 | } 356 | } 357 | 358 | pub fn results_status(&self) -> String { 359 | let selected = self.selected_with_page_context(); 360 | let ncrates = self.total_num_crates.unwrap_or_default(); 361 | format!("{}/{} Results", selected, ncrates) 362 | } 363 | 364 | pub fn selected_with_page_context(&self) -> u64 { 365 | self.results.selected().map_or(0, |n| { 366 | (self.page.saturating_sub(1) * self.page_size) + n as u64 + 1 367 | }) 368 | } 369 | 370 | pub fn page_number_status(&self) -> String { 371 | let max_page_size = (self.total_num_crates.unwrap_or_default() / self.page_size) + 1; 372 | format!("Page: {}/{}", self.page, max_page_size) 373 | } 374 | 375 | pub fn enter_normal_mode(&mut self) { 376 | self.search_mode = SearchMode::ResultsHideCrate; 377 | if !self.results.crates.is_empty() && self.results.selected().is_none() { 378 | self.results.select(Some(0)) 379 | } 380 | } 381 | 382 | pub fn enter_filter_insert_mode(&mut self) { 383 | self.search_mode = SearchMode::Filter; 384 | self.input = self.input.clone().with_value(self.filter.clone()); 385 | } 386 | 387 | pub fn enter_search_insert_mode(&mut self) { 388 | self.search_mode = SearchMode::Search; 389 | self.input = self.input.clone().with_value(self.search.clone()); 390 | } 391 | 392 | pub fn toggle_show_crate_info(&mut self) { 393 | self.search_mode.toggle_show_crate_info(); 394 | if self.search_mode.should_show_crate_info() { 395 | self.request_crate_details() 396 | } else { 397 | self.clear_all_previous_task_details_handles(); 398 | } 399 | } 400 | 401 | fn toggle_sort_by_forward(&mut self) { 402 | use crates_io_api::Sort as S; 403 | self.sort = match self.sort { 404 | S::Alphabetical => S::Relevance, 405 | S::Relevance => S::Downloads, 406 | S::Downloads => S::RecentDownloads, 407 | S::RecentDownloads => S::RecentUpdates, 408 | S::RecentUpdates => S::NewlyAdded, 409 | S::NewlyAdded => S::Alphabetical, 410 | }; 411 | } 412 | 413 | fn toggle_sort_by_backward(&mut self) { 414 | use crates_io_api::Sort as S; 415 | self.sort = match self.sort { 416 | S::Relevance => S::Alphabetical, 417 | S::Downloads => S::Relevance, 418 | S::RecentDownloads => S::Downloads, 419 | S::RecentUpdates => S::RecentDownloads, 420 | S::NewlyAdded => S::RecentUpdates, 421 | S::Alphabetical => S::NewlyAdded, 422 | }; 423 | } 424 | 425 | pub fn toggle_sort_by(&mut self, reload: bool, forward: bool) -> Result<()> { 426 | if forward { 427 | self.toggle_sort_by_forward() 428 | } else { 429 | self.toggle_sort_by_backward() 430 | }; 431 | if reload { 432 | self.tx.send(Action::ReloadData)?; 433 | } 434 | Ok(()) 435 | } 436 | 437 | fn is_focused(&self) -> bool { 438 | self.mode.is_picker() 439 | } 440 | } 441 | 442 | pub struct SearchPageWidget; 443 | 444 | impl SearchPageWidget { 445 | fn render_crate_info(&self, area: Rect, buf: &mut Buffer, state: &mut SearchPage) { 446 | if let Some(ci) = state.crate_response.lock().unwrap().clone() { 447 | CrateInfoTableWidget::new(ci).render(area, buf, &mut state.crate_info); 448 | } 449 | } 450 | } 451 | 452 | impl StatefulWidget for SearchPageWidget { 453 | type State = SearchPage; 454 | 455 | fn render( 456 | self, 457 | area: ratatui::prelude::Rect, 458 | buf: &mut ratatui::prelude::Buffer, 459 | state: &mut Self::State, 460 | ) { 461 | let area = if state.search_mode.is_results_show_crate() { 462 | let [area, info] = 463 | Layout::vertical([Constraint::Min(0), Constraint::Max(15)]).areas(area); 464 | self.render_crate_info(info, buf, state); 465 | area 466 | } else { 467 | area 468 | }; 469 | 470 | SearchResultsWidget::new(!state.is_prompt() && state.is_focused()).render( 471 | area, 472 | buf, 473 | &mut state.results, 474 | ); 475 | 476 | Line::from(state.page_number_status()) 477 | .left_aligned() 478 | .render( 479 | area.inner(Margin { 480 | horizontal: 1, 481 | vertical: 2, 482 | }), 483 | buf, 484 | ); 485 | 486 | Line::from(state.results_status()).right_aligned().render( 487 | area.inner(Margin { 488 | horizontal: 1, 489 | vertical: 2, 490 | }), 491 | buf, 492 | ); 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /src/widgets/search_results.rs: -------------------------------------------------------------------------------- 1 | use crates_io_api::Crate; 2 | use itertools::Itertools; 3 | use num_format::{Locale, ToFormattedString}; 4 | use ratatui::{prelude::*, widgets::*}; 5 | use unicode_width::UnicodeWidthStr; 6 | 7 | use crate::config; 8 | 9 | #[derive(Debug, Default)] 10 | pub struct SearchResults { 11 | pub crates: Vec, 12 | pub table_state: TableState, 13 | pub scrollbar_state: ScrollbarState, 14 | } 15 | 16 | impl SearchResults { 17 | pub fn selected_crate_name(&self) -> Option { 18 | self.selected() 19 | .and_then(|index| self.crates.get(index)) 20 | .filter(|krate| !krate.name.is_empty()) 21 | .map(|krate| krate.name.clone()) 22 | } 23 | 24 | pub fn selected(&self) -> Option { 25 | self.table_state.selected() 26 | } 27 | 28 | pub fn content_length(&mut self, content_length: usize) { 29 | self.scrollbar_state = self.scrollbar_state.content_length(content_length) 30 | } 31 | 32 | pub fn select(&mut self, index: Option) { 33 | self.table_state.select(index) 34 | } 35 | 36 | pub fn scroll_next(&mut self) { 37 | let wrap_index = self.crates.len().max(1); 38 | let next = self 39 | .table_state 40 | .selected() 41 | .map_or(0, |i| (i + 1) % wrap_index); 42 | self.scroll_to(next); 43 | } 44 | 45 | pub fn scroll_previous(&mut self) { 46 | let last = self.crates.len().saturating_sub(1); 47 | let wrap_index = self.crates.len().max(1); 48 | let previous = self 49 | .table_state 50 | .selected() 51 | .map_or(last, |i| (i + last) % wrap_index); 52 | self.scroll_to(previous); 53 | } 54 | 55 | pub fn scroll_to_top(&mut self) { 56 | self.scroll_to(0); 57 | } 58 | 59 | pub fn scroll_to_bottom(&mut self) { 60 | let bottom = self.crates.len().saturating_sub(1); 61 | self.scroll_to(bottom); 62 | } 63 | 64 | fn scroll_to(&mut self, index: usize) { 65 | if self.crates.is_empty() { 66 | self.table_state.select(None) 67 | } else { 68 | self.table_state.select(Some(index)); 69 | self.scrollbar_state = self.scrollbar_state.position(index); 70 | } 71 | } 72 | } 73 | 74 | pub struct SearchResultsWidget { 75 | highlight: bool, 76 | } 77 | 78 | impl SearchResultsWidget { 79 | pub fn new(highlight: bool) -> Self { 80 | Self { highlight } 81 | } 82 | } 83 | 84 | impl StatefulWidget for SearchResultsWidget { 85 | type State = SearchResults; 86 | 87 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 88 | use Constraint::*; 89 | const TABLE_HEADER_HEIGHT: u16 = 3; 90 | const COLUMN_SPACING: u16 = 3; 91 | 92 | let [table_area, scrollbar_area] = Layout::horizontal([Fill(1), Length(1)]).areas(area); 93 | let [_, scrollbar_area] = 94 | Layout::vertical([Length(TABLE_HEADER_HEIGHT), Fill(1)]).areas(scrollbar_area); 95 | 96 | Scrollbar::default() 97 | .track_symbol(Some(" ")) 98 | .thumb_symbol("▐") 99 | .begin_symbol(None) 100 | .end_symbol(None) 101 | .track_style(config::get().color.base06) 102 | .render(scrollbar_area, buf, &mut state.scrollbar_state); 103 | 104 | let highlight_symbol = if self.highlight { 105 | " █ " 106 | } else { 107 | " \u{2022} " 108 | }; 109 | 110 | let column_widths = [Max(20), Fill(1), Max(11)]; 111 | 112 | // Emulate the table layout calculations using Layout so we can render the vertical borders 113 | // in the space between the columns and can wrap the description field based on the actual 114 | // width of the description column 115 | let highlight_symbol_width = highlight_symbol.width() as u16; 116 | let [_highlight_column, table_columns] = 117 | Layout::horizontal([Length(highlight_symbol_width), Fill(1)]).areas(table_area); 118 | let column_layout = Layout::horizontal(column_widths).spacing(COLUMN_SPACING); 119 | let [_name_column, description_column, _downloads_column] = 120 | column_layout.areas(table_columns); 121 | let spacers: [Rect; 4] = column_layout.spacers(table_columns); 122 | 123 | let vertical_pad = |line| Text::from(vec!["".into(), line, "".into()]); 124 | 125 | let header_cells = ["Name", "Description", "Downloads"] 126 | .map(|h| h.bold().into()) 127 | .map(vertical_pad); 128 | let header = Row::new(header_cells) 129 | .fg(config::get().color.base05) 130 | .bg(config::get().color.base00) 131 | .height(TABLE_HEADER_HEIGHT); 132 | 133 | let description_column_width = description_column.width as usize; 134 | let selected_index = state.selected().unwrap_or_default(); 135 | let rows = state 136 | .crates 137 | .iter() 138 | .enumerate() 139 | .map(|(index, krate)| { 140 | row_from_crate(krate, description_column_width, index, selected_index) 141 | }) 142 | .collect_vec(); 143 | 144 | let table = Table::new(rows, column_widths) 145 | .header(header) 146 | .column_spacing(COLUMN_SPACING) 147 | .highlight_symbol(vertical_pad(highlight_symbol.into())) 148 | .row_highlight_style(config::get().color.base05) 149 | .highlight_spacing(HighlightSpacing::Always); 150 | 151 | StatefulWidget::render(table, table_area, buf, &mut state.table_state); 152 | 153 | render_table_borders(state, spacers, buf); 154 | } 155 | } 156 | 157 | fn row_from_crate( 158 | krate: &Crate, 159 | description_column_width: usize, 160 | index: usize, 161 | selected_index: usize, 162 | ) -> Row { 163 | let mut description = textwrap::wrap( 164 | &krate.description.clone().unwrap_or_default(), 165 | description_column_width, 166 | ) 167 | .iter() 168 | .map(|s| Line::from(s.to_string())) 169 | .collect_vec(); 170 | description.insert(0, "".into()); 171 | description.push("".into()); 172 | let vertical_padded = |line| Text::from(vec!["".into(), line, "".into()]); 173 | let crate_name = Line::from(krate.name.clone()); 174 | let downloads = Line::from(krate.downloads.to_formatted_string(&Locale::en)).right_aligned(); 175 | let description_height = description.len() as u16; 176 | Row::new([ 177 | vertical_padded(crate_name), 178 | Text::from(description), 179 | vertical_padded(downloads), 180 | ]) 181 | .height(description_height) 182 | .fg(config::get().color.base05) 183 | .bg(bg_color(index, selected_index)) 184 | } 185 | 186 | fn bg_color(index: usize, selected_index: usize) -> Color { 187 | if index == selected_index { 188 | config::get().color.base02 189 | } else { 190 | match index % 2 { 191 | 0 => config::get().color.base00, 192 | 1 => config::get().color.base01, 193 | _ => unreachable!("mod 2 is always 0 or 1"), 194 | } 195 | } 196 | } 197 | 198 | fn render_table_borders(state: &mut SearchResults, spacers: [Rect; 4], buf: &mut Buffer) { 199 | // only render margins when there's items in the table 200 | if !state.crates.is_empty() { 201 | // don't render margin for the first column 202 | for space in spacers.iter().skip(1).copied() { 203 | Text::from( 204 | std::iter::once(" ".into()) 205 | .chain(std::iter::once(" ".into())) 206 | .chain(std::iter::once(" ".into())) 207 | .chain( 208 | std::iter::repeat(" │".fg(config::get().color.base0f)) 209 | .take(space.height as usize), 210 | ) 211 | .map(Line::from) 212 | .collect_vec(), 213 | ) 214 | .render(space, buf); 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/widgets/status_bar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{prelude::*, widgets::*}; 2 | 3 | use crate::{app::Mode, command::Command, config}; 4 | 5 | pub struct StatusBarWidget { 6 | text: String, 7 | mode: Mode, 8 | sort: crates_io_api::Sort, 9 | } 10 | 11 | impl StatusBarWidget { 12 | pub fn new(mode: Mode, sort: crates_io_api::Sort, text: String) -> Self { 13 | Self { text, mode, sort } 14 | } 15 | } 16 | 17 | impl Widget for StatusBarWidget { 18 | fn render(self, area: Rect, buf: &mut Buffer) { 19 | self.status().render(area, buf); 20 | } 21 | } 22 | 23 | impl StatusBarWidget { 24 | fn input_text(&self) -> Line { 25 | if self.mode.is_picker() { 26 | Line::from(vec![ 27 | self.text.clone().into(), 28 | " (".into(), 29 | format!("{:?}", self.sort.clone()).fg(config::get().color.base0d), 30 | ")".into(), 31 | ]) 32 | } else { 33 | "".into() 34 | } 35 | } 36 | 37 | fn status(&self) -> Block { 38 | let line = if self.mode.is_filter() { 39 | let help = config::get() 40 | .key_bindings 41 | .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) 42 | .into_iter() 43 | .next() 44 | .unwrap_or_default(); 45 | vec![ 46 | "Enter".bold(), 47 | " to submit, ".into(), 48 | help.bold(), 49 | " for help".into(), 50 | ] 51 | } else if self.mode.is_search() { 52 | let toggle_sort = config::get() 53 | .key_bindings 54 | .get_config_for_command( 55 | Mode::Search, 56 | Command::ToggleSortBy { 57 | reload: false, 58 | forward: true, 59 | }, 60 | ) 61 | .into_iter() 62 | .next() 63 | .unwrap_or_default(); 64 | let help = config::get() 65 | .key_bindings 66 | .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) 67 | .into_iter() 68 | .next() 69 | .unwrap_or_default(); 70 | vec![ 71 | toggle_sort.bold(), 72 | " to toggle sort, ".into(), 73 | "Enter".bold(), 74 | " to submit, ".into(), 75 | help.bold(), 76 | " for help".into(), 77 | ] 78 | } else if self.mode.is_summary() { 79 | let help = config::get() 80 | .key_bindings 81 | .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) 82 | .into_iter() 83 | .next() 84 | .unwrap_or_default(); 85 | let open_in_browser = config::get() 86 | .key_bindings 87 | .get_config_for_command(self.mode, Command::OpenCratesIOUrlInBrowser) 88 | .into_iter() 89 | .next() 90 | .unwrap_or_default(); 91 | let search = config::get() 92 | .key_bindings 93 | .get_config_for_command(Mode::Common, Command::NextTab) 94 | .into_iter() 95 | .next() 96 | .unwrap_or_default(); 97 | vec![ 98 | open_in_browser.bold(), 99 | " to open in browser, ".into(), 100 | search.bold(), 101 | " to enter search, ".into(), 102 | help.bold(), 103 | " for help".into(), 104 | ] 105 | } else if self.mode.is_help() { 106 | vec!["ESC".bold(), " to return".into()] 107 | } else { 108 | let search = config::get() 109 | .key_bindings 110 | .get_config_for_command(self.mode, Command::SwitchMode(Mode::Search)) 111 | .into_iter() 112 | .next() 113 | .unwrap_or_default(); 114 | let filter = config::get() 115 | .key_bindings 116 | .get_config_for_command(self.mode, Command::SwitchMode(Mode::Filter)) 117 | .into_iter() 118 | .next() 119 | .unwrap_or_default(); 120 | let help = config::get() 121 | .key_bindings 122 | .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) 123 | .into_iter() 124 | .next() 125 | .unwrap_or_default(); 126 | vec![ 127 | search.bold(), 128 | " to search, ".into(), 129 | filter.bold(), 130 | " to filter, ".into(), 131 | help.bold(), 132 | " for help".into(), 133 | ] 134 | }; 135 | let border_color = match self.mode { 136 | Mode::Search => config::get().color.base0a, 137 | Mode::Filter => config::get().color.base0b, 138 | _ => config::get().color.base06, 139 | }; 140 | Block::default() 141 | .title(Line::from(line).right_aligned()) 142 | .title(self.input_text().left_aligned()) 143 | .fg(config::get().color.base05) 144 | .border_style(border_color) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/widgets/summary.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use std::sync::{ 3 | atomic::{AtomicBool, Ordering}, 4 | Arc, Mutex, 5 | }; 6 | 7 | use itertools::Itertools; 8 | use ratatui::{layout::Flex, prelude::*, widgets::*}; 9 | use strum::{Display, EnumIs, EnumIter, FromRepr}; 10 | use tokio::sync::mpsc::UnboundedSender; 11 | 12 | use crate::{action::Action, config, crates_io_api_helper}; 13 | 14 | #[derive(Default, Debug, Clone, Copy, EnumIs, FromRepr, Display, EnumIter)] 15 | pub enum SummaryMode { 16 | #[default] 17 | NewCrates, 18 | MostDownloaded, 19 | JustUpdated, 20 | MostRecentlyDownloaded, 21 | PopularKeywords, 22 | PopularCategories, 23 | } 24 | 25 | const HIGHLIGHT_SYMBOL: &str = " █ "; 26 | 27 | impl SummaryMode { 28 | /// Get the previous tab, if there is no previous tab return the current tab. 29 | fn previous(&mut self) { 30 | let current_index: usize = *self as usize; 31 | let previous_index = current_index.saturating_sub(1); 32 | *self = Self::from_repr(previous_index).unwrap_or(*self) 33 | } 34 | 35 | /// Get the next tab, if there is no next tab return the current tab. 36 | fn next(&mut self) { 37 | let current_index = *self as usize; 38 | let next_index = current_index.saturating_add(1); 39 | *self = Self::from_repr(next_index).unwrap_or(*self) 40 | } 41 | 42 | fn url_prefix(&self) -> String { 43 | match self { 44 | SummaryMode::NewCrates => "https://crates.io/crates/", 45 | SummaryMode::MostDownloaded => "https://crates.io/crates/", 46 | SummaryMode::JustUpdated => "https://crates.io/crates/", 47 | SummaryMode::MostRecentlyDownloaded => "https://crates.io/crates/", 48 | SummaryMode::PopularKeywords => "https://crates.io/keywords/", 49 | SummaryMode::PopularCategories => "https://crates.io/categories/", 50 | } 51 | .into() 52 | } 53 | } 54 | 55 | #[derive(Debug, Clone)] 56 | pub struct Summary { 57 | pub state: [ListState; 6], 58 | pub last_selection: [usize; 6], 59 | pub mode: SummaryMode, 60 | pub summary_data: Option, 61 | 62 | /// A thread-safe shared container holding the detailed information about 63 | /// the currently selected crate; this can be `None` if no crate is 64 | /// selected. 65 | pub data: Arc>>, 66 | 67 | /// Sender end of an asynchronous channel for dispatching actions from 68 | /// various parts of the app to be handled by the event loop. 69 | tx: UnboundedSender, 70 | 71 | /// A thread-safe indicator of whether data is currently being loaded, 72 | /// allowing different parts of the app to know if it's in a loading state. 73 | loading_status: Arc, 74 | } 75 | 76 | impl Summary { 77 | pub fn new(tx: UnboundedSender, loading_status: Arc) -> Self { 78 | Self { 79 | tx, 80 | loading_status, 81 | state: Default::default(), 82 | last_selection: Default::default(), 83 | mode: Default::default(), 84 | summary_data: Default::default(), 85 | data: Default::default(), 86 | } 87 | } 88 | pub fn mode(&self) -> SummaryMode { 89 | self.mode 90 | } 91 | 92 | pub fn url(&self) -> Option { 93 | let prefix = self.mode.url_prefix(); 94 | if let Some(ref summary) = self.summary_data { 95 | let state = self.get_state(self.mode); 96 | let i = state.selected().unwrap_or_default().saturating_sub(1); // starting index for list is 1 because we render empty line as the 0th element 97 | tracing::debug!("i = {i}"); 98 | let suffix = match self.mode { 99 | SummaryMode::NewCrates => summary.new_crates[i].name.clone(), 100 | SummaryMode::MostDownloaded => summary.most_downloaded[i].name.clone(), 101 | SummaryMode::JustUpdated => summary.just_updated[i].name.clone(), 102 | SummaryMode::MostRecentlyDownloaded => { 103 | summary.most_recently_downloaded[i].name.clone() 104 | } 105 | SummaryMode::PopularKeywords => summary.popular_keywords[i].id.clone(), 106 | SummaryMode::PopularCategories => summary.popular_categories[i].slug.clone(), 107 | }; 108 | Some(format!("{prefix}{suffix}")) 109 | } else { 110 | None 111 | } 112 | } 113 | 114 | pub fn get_state_mut(&mut self, mode: SummaryMode) -> &mut ListState { 115 | &mut self.state[mode as usize] 116 | } 117 | 118 | pub fn get_state(&self, mode: SummaryMode) -> &ListState { 119 | &self.state[mode as usize] 120 | } 121 | 122 | pub fn selected(&self, mode: SummaryMode) -> Option { 123 | self.get_state(mode).selected().map(|i| i.max(1)) // never let index go to 0 because we render an empty line as a the first element 124 | } 125 | 126 | pub fn scroll_previous(&mut self) { 127 | let state = self.get_state_mut(self.mode); 128 | let i = state.selected().map_or(0, |i| i.saturating_sub(1)); 129 | state.select(Some(i)); 130 | } 131 | 132 | pub fn scroll_next(&mut self) { 133 | let state = self.get_state_mut(self.mode); 134 | let i = state.selected().map_or(0, |i| i.saturating_add(1)); 135 | state.select(Some(i)); 136 | } 137 | 138 | pub fn save_state(&mut self) { 139 | if let Some(i) = self.get_state(self.mode).selected() { 140 | self.last_selection[self.mode as usize] = i 141 | } 142 | } 143 | 144 | pub fn next_mode(&mut self) { 145 | self.save_state(); 146 | let old_state = self.get_state_mut(self.mode); 147 | *old_state.selected_mut() = None; 148 | self.mode.next(); 149 | let i = self.last_selection[self.mode as usize]; 150 | let new_state = self.get_state_mut(self.mode); 151 | *new_state.selected_mut() = Some(i); 152 | } 153 | 154 | pub fn previous_mode(&mut self) { 155 | self.save_state(); 156 | let old_state = self.get_state_mut(self.mode); 157 | *old_state.selected_mut() = None; 158 | self.mode.previous(); 159 | let i = self.last_selection[self.mode as usize]; 160 | let new_state = self.get_state_mut(self.mode); 161 | *new_state.selected_mut() = Some(i); 162 | } 163 | 164 | pub fn request(&self) -> Result<()> { 165 | let tx = self.tx.clone(); 166 | let loading_status = self.loading_status.clone(); 167 | let summary = self.data.clone(); 168 | tokio::spawn(async move { 169 | loading_status.store(true, Ordering::SeqCst); 170 | if let Err(error_message) = crates_io_api_helper::request_summary(summary).await { 171 | let _ = tx.send(Action::ShowErrorPopup(error_message)); 172 | } 173 | loading_status.store(false, Ordering::SeqCst); 174 | let _ = tx.send(Action::UpdateSummary); 175 | let _ = tx.send(Action::ScrollDown); 176 | }); 177 | Ok(()) 178 | } 179 | 180 | pub fn update(&mut self) { 181 | if let Some(summary) = self.data.lock().unwrap().clone() { 182 | self.summary_data = Some(summary); 183 | } else { 184 | self.summary_data = None; 185 | } 186 | } 187 | } 188 | 189 | impl Summary { 190 | fn borders(&self, _selected: bool) -> Borders { 191 | Borders::NONE 192 | } 193 | 194 | fn new_crates(&self) -> List<'static> { 195 | let selected = self.mode.is_new_crates(); 196 | let borders = self.borders(selected); 197 | let items = std::iter::once(Text::from(Line::raw(""))) 198 | .chain( 199 | self.summary_data 200 | .as_ref() 201 | .unwrap() 202 | .new_crates 203 | .iter() 204 | .map(|item| { 205 | Text::from(vec![ 206 | Line::styled(item.name.clone(), config::get().color.base05), 207 | Line::raw(""), 208 | ]) 209 | }), 210 | ) 211 | .collect_vec(); 212 | list_builder(items, "New Crates", selected, borders) 213 | } 214 | 215 | fn most_downloaded(&self) -> List<'static> { 216 | let selected = self.mode.is_most_downloaded(); 217 | let borders = self.borders(selected); 218 | let items = std::iter::once(Text::from(Line::raw(""))) 219 | .chain( 220 | self.summary_data 221 | .as_ref() 222 | .unwrap() 223 | .most_downloaded 224 | .iter() 225 | .map(|item| { 226 | Text::from(vec![ 227 | Line::styled(item.name.clone(), config::get().color.base05), 228 | Line::raw(""), 229 | ]) 230 | }), 231 | ) 232 | .collect_vec(); 233 | list_builder(items, "Most Downloaded", selected, borders) 234 | } 235 | 236 | fn just_updated(&self) -> List<'static> { 237 | let selected = self.mode.is_just_updated(); 238 | let borders = self.borders(selected); 239 | let items = std::iter::once(Text::from(Line::raw(""))) 240 | .chain( 241 | self.summary_data 242 | .as_ref() 243 | .unwrap() 244 | .just_updated 245 | .iter() 246 | .map(|item| { 247 | Text::from(vec![ 248 | Line::from(vec![ 249 | item.name.clone().fg(config::get().color.base05), 250 | " ".into(), 251 | Span::styled( 252 | format!("v{}", item.max_version), 253 | Style::default().fg(config::get().color.base05), 254 | ), 255 | ]), 256 | Line::raw(""), 257 | ]) 258 | }), 259 | ) 260 | .collect_vec(); 261 | list_builder(items, "Just Updated", selected, borders) 262 | } 263 | 264 | fn most_recently_downloaded(&self) -> List<'static> { 265 | let selected = self.mode.is_most_recently_downloaded(); 266 | let borders = self.borders(selected); 267 | let items = std::iter::once(Text::from(Line::raw(""))) 268 | .chain( 269 | self.summary_data 270 | .as_ref() 271 | .unwrap() 272 | .most_recently_downloaded 273 | .iter() 274 | .map(|item| { 275 | Text::from(vec![ 276 | Line::styled(item.name.clone(), config::get().color.base05), 277 | Line::raw(""), 278 | ]) 279 | }), 280 | ) 281 | .collect_vec(); 282 | list_builder(items, "Most Recently Downloaded", selected, borders) 283 | } 284 | 285 | fn popular_keywords(&self) -> List<'static> { 286 | let selected = self.mode.is_popular_keywords(); 287 | let borders = self.borders(selected); 288 | let items = std::iter::once(Text::from(Line::raw(""))) 289 | .chain( 290 | self.summary_data 291 | .as_ref() 292 | .unwrap() 293 | .popular_keywords 294 | .iter() 295 | .map(|item| { 296 | Text::from(vec![ 297 | Line::styled(item.keyword.clone(), config::get().color.base05), 298 | Line::raw(""), 299 | ]) 300 | }), 301 | ) 302 | .collect_vec(); 303 | list_builder(items, "Popular Keywords", selected, borders) 304 | } 305 | 306 | fn popular_categories(&self) -> List<'static> { 307 | let selected = self.mode.is_popular_categories(); 308 | let borders = self.borders(selected); 309 | let items = std::iter::once(Text::from(Line::raw(""))) 310 | .chain( 311 | self.summary_data 312 | .as_ref() 313 | .unwrap() 314 | .popular_categories 315 | .iter() 316 | .map(|item| { 317 | Text::from(vec![ 318 | Line::styled(item.category.clone(), config::get().color.base05), 319 | Line::raw(""), 320 | ]) 321 | }), 322 | ) 323 | .collect_vec(); 324 | list_builder(items, "Popular Categories", selected, borders) 325 | } 326 | } 327 | 328 | fn list_builder<'a>( 329 | items: Vec>, 330 | title: &'a str, 331 | selected: bool, 332 | borders: Borders, 333 | ) -> List<'a> { 334 | let title_style = if selected { 335 | Style::default() 336 | .fg(config::get().color.base00) 337 | .bg(config::get().color.base0a) 338 | .bold() 339 | } else { 340 | Style::default().fg(config::get().color.base0d).bold() 341 | }; 342 | List::new(items) 343 | .block( 344 | Block::default() 345 | .borders(borders) 346 | .title(Line::from(vec![" ".into(), title.into(), " ".into()])) 347 | .title_style(title_style) 348 | .title_alignment(Alignment::Left), 349 | ) 350 | .highlight_symbol(HIGHLIGHT_SYMBOL) 351 | .highlight_style(config::get().color.base05) 352 | .highlight_spacing(HighlightSpacing::Always) 353 | } 354 | 355 | pub struct SummaryWidget; 356 | 357 | impl SummaryWidget { 358 | fn render_list( 359 | &self, 360 | area: Rect, 361 | buf: &mut Buffer, 362 | list: List, 363 | mode: SummaryMode, 364 | state: &mut Summary, 365 | ) { 366 | *(state.get_state_mut(mode).selected_mut()) = state 367 | .selected(mode) 368 | .map(|i| i.min(list.len().saturating_sub(1))); 369 | StatefulWidget::render(list, area, buf, state.get_state_mut(mode)); 370 | } 371 | } 372 | 373 | impl StatefulWidget for &SummaryWidget { 374 | type State = Summary; 375 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 376 | if state.summary_data.is_none() { 377 | return; 378 | } 379 | use Constraint::*; 380 | 381 | let [_, area] = Layout::vertical([Min(0), Percentage(90)]).areas(area); 382 | 383 | let [_, area, _] = Layout::horizontal([Min(0), Percentage(85), Min(0)]).areas(area); 384 | 385 | let [top, bottom] = Layout::vertical([Percentage(50), Percentage(50)]) 386 | .spacing(1) 387 | .areas(area); 388 | 389 | let [new_crates, most_downloaded, just_updated] = 390 | Layout::horizontal([Percentage(30), Percentage(30), Percentage(30)]) 391 | .flex(Flex::Center) 392 | .spacing(2) 393 | .areas(top); 394 | 395 | let list = state.new_crates(); 396 | self.render_list(new_crates, buf, list, SummaryMode::NewCrates, state); 397 | 398 | let list = state.most_downloaded(); 399 | self.render_list( 400 | most_downloaded, 401 | buf, 402 | list, 403 | SummaryMode::MostDownloaded, 404 | state, 405 | ); 406 | 407 | let list = state.just_updated(); 408 | self.render_list(just_updated, buf, list, SummaryMode::JustUpdated, state); 409 | 410 | let [most_recently_downloaded, popular_keywords, popular_categories] = 411 | Layout::horizontal([Percentage(30), Percentage(30), Percentage(30)]) 412 | .flex(Flex::Center) 413 | .spacing(2) 414 | .areas(bottom); 415 | 416 | let list = state.most_recently_downloaded(); 417 | self.render_list( 418 | most_recently_downloaded, 419 | buf, 420 | list, 421 | SummaryMode::MostRecentlyDownloaded, 422 | state, 423 | ); 424 | 425 | let list = state.popular_categories(); 426 | self.render_list( 427 | popular_categories, 428 | buf, 429 | list, 430 | SummaryMode::PopularCategories, 431 | state, 432 | ); 433 | 434 | let list = state.popular_keywords(); 435 | self.render_list( 436 | popular_keywords, 437 | buf, 438 | list, 439 | SummaryMode::PopularKeywords, 440 | state, 441 | ); 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/widgets/tabs.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{prelude::*, widgets::*}; 2 | use strum::{Display, EnumIter, FromRepr}; 3 | 4 | use crate::config; 5 | 6 | #[derive(Debug, Default, Clone, Copy, Display, FromRepr, EnumIter)] 7 | pub enum SelectedTab { 8 | #[default] 9 | Summary, 10 | Search, 11 | None, 12 | } 13 | 14 | impl SelectedTab { 15 | pub fn select(&mut self, selected_tab: SelectedTab) { 16 | *self = selected_tab 17 | } 18 | 19 | pub fn highlight_style() -> Style { 20 | Style::default() 21 | .fg(config::get().color.base00) 22 | .bg(config::get().color.base0a) 23 | .bold() 24 | } 25 | } 26 | 27 | impl Widget for &SelectedTab { 28 | fn render(self, area: Rect, buf: &mut Buffer) { 29 | match self { 30 | SelectedTab::Summary => self.render_tab_summary(area, buf), 31 | SelectedTab::Search => self.render_tab_search(area, buf), 32 | SelectedTab::None => (), 33 | } 34 | } 35 | } 36 | 37 | impl SelectedTab { 38 | pub fn title(&self) -> Line<'static> { 39 | match self { 40 | SelectedTab::None => "".into(), 41 | _ => format!(" {self} ") 42 | .fg(config::get().color.base0d) 43 | .bg(config::get().color.base00) 44 | .into(), 45 | } 46 | } 47 | 48 | fn render_tab_summary(&self, area: Rect, buf: &mut Buffer) { 49 | Paragraph::new("Summary") 50 | .block(self.block()) 51 | .render(area, buf) 52 | } 53 | 54 | fn render_tab_search(&self, area: Rect, buf: &mut Buffer) { 55 | Paragraph::new("Search") 56 | .block(self.block()) 57 | .render(area, buf) 58 | } 59 | 60 | fn block(&self) -> Block<'static> { 61 | Block::default() 62 | .borders(Borders::ALL) 63 | .border_set(symbols::border::PLAIN) 64 | .padding(Padding::horizontal(1)) 65 | .border_style(config::get().color.base03) 66 | } 67 | } 68 | --------------------------------------------------------------------------------