├── .envrc ├── .github └── workflows │ ├── release.yml │ └── rust-tests.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── changelogs └── 0.5.0.md ├── default.nix ├── flake.lock ├── flake.nix ├── rm-config ├── Cargo.toml ├── defaults │ ├── categories.toml │ ├── config.toml │ └── keymap.toml └── src │ ├── categories.rs │ ├── keymap │ ├── actions │ │ ├── general.rs │ │ ├── mod.rs │ │ ├── search_tab.rs │ │ └── torrents_tab.rs │ └── mod.rs │ ├── lib.rs │ └── main_config │ ├── connection.rs │ ├── general.rs │ ├── icons.rs │ ├── mod.rs │ ├── search_tab.rs │ └── torrents_tab.rs ├── rm-main ├── Cargo.lock ├── Cargo.toml └── src │ ├── cli │ ├── add_torrent.rs │ ├── fetch_rss.rs │ └── mod.rs │ ├── main.rs │ ├── transmission │ ├── action.rs │ ├── fetchers.rs │ ├── mod.rs │ └── utils.rs │ └── tui │ ├── app.rs │ ├── components │ ├── input_manager.rs │ ├── misc.rs │ ├── mod.rs │ └── table.rs │ ├── global_popups │ ├── error.rs │ ├── help.rs │ └── mod.rs │ ├── main_window.rs │ ├── mod.rs │ └── tabs │ ├── mod.rs │ ├── search │ ├── bottom_bar.rs │ ├── mod.rs │ └── popups │ │ ├── mod.rs │ │ └── providers.rs │ └── torrents │ ├── bottom_stats.rs │ ├── mod.rs │ ├── popups │ ├── details.rs │ ├── files.rs │ ├── mod.rs │ └── stats.rs │ ├── rustmission_torrent.rs │ ├── table_manager.rs │ ├── task_manager.rs │ └── tasks │ ├── add_magnet.rs │ ├── change_category.rs │ ├── default.rs │ ├── delete_torrent.rs │ ├── filter.rs │ ├── mod.rs │ ├── move_torrent.rs │ ├── rename.rs │ ├── selection.rs │ ├── sort.rs │ └── status.rs ├── rm-shared ├── Cargo.toml └── src │ ├── action.rs │ ├── header.rs │ ├── lib.rs │ ├── status_task.rs │ └── utils.rs └── shell.nix /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022-2024, 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 | pull_request: 42 | push: 43 | tags: 44 | - '**[0-9]+.[0-9]+.[0-9]+*' 45 | 46 | jobs: 47 | # Run 'cargo dist plan' (or host) to determine what tasks we need to do 48 | plan: 49 | runs-on: "ubuntu-20.04" 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.16.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 | - name: enable windows longpaths 109 | run: | 110 | git config --global core.longpaths true 111 | - uses: actions/checkout@v4 112 | with: 113 | submodules: recursive 114 | - uses: swatinem/rust-cache@v2 115 | with: 116 | key: ${{ join(matrix.targets, '-') }} 117 | cache-provider: ${{ matrix.cache_provider }} 118 | - name: Install cargo-dist 119 | run: ${{ matrix.install_dist }} 120 | # Get the dist-manifest 121 | - name: Fetch local artifacts 122 | uses: actions/download-artifact@v4 123 | with: 124 | pattern: artifacts-* 125 | path: target/distrib/ 126 | merge-multiple: true 127 | - name: Install dependencies 128 | run: | 129 | ${{ matrix.packages_install }} 130 | - name: Build artifacts 131 | run: | 132 | # Actually do builds and make zips and whatnot 133 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 134 | echo "cargo dist ran successfully" 135 | - id: cargo-dist 136 | name: Post-build 137 | # We force bash here just because github makes it really hard to get values up 138 | # to "real" actions without writing to env-vars, and writing to env-vars has 139 | # inconsistent syntax between shell and powershell. 140 | shell: bash 141 | run: | 142 | # Parse out what we just built and upload it to scratch storage 143 | echo "paths<> "$GITHUB_OUTPUT" 144 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 145 | echo "EOF" >> "$GITHUB_OUTPUT" 146 | 147 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 148 | - name: "Upload artifacts" 149 | uses: actions/upload-artifact@v4 150 | with: 151 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 152 | path: | 153 | ${{ steps.cargo-dist.outputs.paths }} 154 | ${{ env.BUILD_MANIFEST_NAME }} 155 | 156 | # Build and package all the platform-agnostic(ish) things 157 | build-global-artifacts: 158 | needs: 159 | - plan 160 | - build-local-artifacts 161 | runs-on: "ubuntu-20.04" 162 | env: 163 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 164 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 165 | steps: 166 | - uses: actions/checkout@v4 167 | with: 168 | submodules: recursive 169 | - name: Install cargo-dist 170 | shell: bash 171 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.16.0/cargo-dist-installer.sh | sh" 172 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 173 | - name: Fetch local artifacts 174 | uses: actions/download-artifact@v4 175 | with: 176 | pattern: artifacts-* 177 | path: target/distrib/ 178 | merge-multiple: true 179 | - id: cargo-dist 180 | shell: bash 181 | run: | 182 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 183 | echo "cargo dist ran successfully" 184 | 185 | # Parse out what we just built and upload it to scratch storage 186 | echo "paths<> "$GITHUB_OUTPUT" 187 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 188 | echo "EOF" >> "$GITHUB_OUTPUT" 189 | 190 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 191 | - name: "Upload artifacts" 192 | uses: actions/upload-artifact@v4 193 | with: 194 | name: artifacts-build-global 195 | path: | 196 | ${{ steps.cargo-dist.outputs.paths }} 197 | ${{ env.BUILD_MANIFEST_NAME }} 198 | # Determines if we should publish/announce 199 | host: 200 | needs: 201 | - plan 202 | - build-local-artifacts 203 | - build-global-artifacts 204 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 205 | 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') }} 206 | env: 207 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 208 | runs-on: "ubuntu-20.04" 209 | outputs: 210 | val: ${{ steps.host.outputs.manifest }} 211 | steps: 212 | - uses: actions/checkout@v4 213 | with: 214 | submodules: recursive 215 | - name: Install cargo-dist 216 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.16.0/cargo-dist-installer.sh | sh" 217 | # Fetch artifacts from scratch-storage 218 | - name: Fetch artifacts 219 | uses: actions/download-artifact@v4 220 | with: 221 | pattern: artifacts-* 222 | path: target/distrib/ 223 | merge-multiple: true 224 | # This is a harmless no-op for GitHub Releases, hosting for that happens in "announce" 225 | - id: host 226 | shell: bash 227 | run: | 228 | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 229 | echo "artifacts uploaded and released successfully" 230 | cat dist-manifest.json 231 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 232 | - name: "Upload dist-manifest.json" 233 | uses: actions/upload-artifact@v4 234 | with: 235 | # Overwrite the previous copy 236 | name: artifacts-dist-manifest 237 | path: dist-manifest.json 238 | 239 | publish-homebrew-formula: 240 | needs: 241 | - plan 242 | - host 243 | runs-on: "ubuntu-20.04" 244 | env: 245 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 246 | PLAN: ${{ needs.plan.outputs.val }} 247 | GITHUB_USER: "axo bot" 248 | GITHUB_EMAIL: "admin+bot@axo.dev" 249 | if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} 250 | steps: 251 | - uses: actions/checkout@v4 252 | with: 253 | repository: "intuis/homebrew-tap" 254 | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 255 | # So we have access to the formula 256 | - name: Fetch homebrew formulae 257 | uses: actions/download-artifact@v4 258 | with: 259 | pattern: artifacts-* 260 | path: Formula/ 261 | merge-multiple: true 262 | # This is extra complex because you can make your Formula name not match your app name 263 | # so we need to find releases with a *.rb file, and publish with that filename. 264 | - name: Commit formula files 265 | run: | 266 | git config --global user.name "${GITHUB_USER}" 267 | git config --global user.email "${GITHUB_EMAIL}" 268 | 269 | for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do 270 | filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) 271 | name=$(echo "$filename" | sed "s/\.rb$//") 272 | version=$(echo "$release" | jq .app_version --raw-output) 273 | 274 | git add "Formula/${filename}" 275 | git commit -m "${name} ${version}" 276 | done 277 | git push 278 | 279 | # Create a GitHub Release while uploading all files to it 280 | announce: 281 | needs: 282 | - plan 283 | - host 284 | - publish-homebrew-formula 285 | # use "always() && ..." to allow us to wait for all publish jobs while 286 | # still allowing individual publish jobs to skip themselves (for prereleases). 287 | # "host" however must run to completion, no skipping allowed! 288 | if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} 289 | runs-on: "ubuntu-20.04" 290 | env: 291 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 292 | steps: 293 | - uses: actions/checkout@v4 294 | with: 295 | submodules: recursive 296 | - name: "Download GitHub Artifacts" 297 | uses: actions/download-artifact@v4 298 | with: 299 | pattern: artifacts-* 300 | path: artifacts 301 | merge-multiple: true 302 | - name: Cleanup 303 | run: | 304 | # Remove the granular manifests 305 | rm -f artifacts/*-dist-manifest.json 306 | - name: Create GitHub Release 307 | env: 308 | PRERELEASE_FLAG: "${{ fromJson(needs.host.outputs.val).announcement_is_prerelease && '--prerelease' || '' }}" 309 | ANNOUNCEMENT_TITLE: "${{ fromJson(needs.host.outputs.val).announcement_title }}" 310 | ANNOUNCEMENT_BODY: "${{ fromJson(needs.host.outputs.val).announcement_github_body }}" 311 | run: | 312 | # Write and read notes from a file to avoid quoting breaking things 313 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 314 | 315 | gh release create "${{ needs.plan.outputs.tag }}" --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" $PRERELEASE_FLAG 316 | gh release upload "${{ needs.plan.outputs.tag }}" artifacts/* 317 | -------------------------------------------------------------------------------- /.github/workflows/rust-tests.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .direnv/ 3 | /result 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "rm-main", 5 | "rm-config", 6 | ] 7 | 8 | [workspace.package] 9 | version = "0.5.1" 10 | edition = "2021" 11 | authors = ["Remigiusz Micielski "] 12 | repository = "https://github.com/intuis/rustmission" 13 | homepage = "https://github.com/intuis/rustmission" 14 | license = "GPL-3.0-or-later" 15 | 16 | [workspace.dependencies] 17 | rm-config = { version = "0.5", path = "rm-config" } 18 | rm-shared = { version = "0.5", path = "rm-shared" } 19 | 20 | intuitils = "0.0.5" 21 | 22 | magnetease = "0.3.1" 23 | anyhow = "1" 24 | serde = { version = "1", features = ["derive"] } 25 | transmission-rpc = "0.4" 26 | fuzzy-matcher = "0.3.7" 27 | clap = { version = "4", features = ["derive"] } 28 | base64 = "0.22" 29 | xdg = "2.5" 30 | url = { version = "2.5", features = ["serde"] } 31 | toml = "0.8" 32 | rss = "2" 33 | reqwest = "0.12" 34 | regex = "1" 35 | thiserror = "1" 36 | chrono = "0.4" 37 | open = "5.3.0" 38 | 39 | # Async 40 | tokio = { version = "1", features = ["macros", "sync", "rt-multi-thread"] } 41 | tokio-util = "0.7" 42 | futures = "0.3" 43 | 44 | # TUI 45 | crossterm = { version = "0.28", features = ["event-stream", "serde"] } 46 | ratatui = { version = "0.29", features = ["serde"] } 47 | tui-input = "0.11" 48 | tui-tree-widget = "0.23" 49 | throbber-widgets-tui = "0.8" 50 | intui-tabs = "0.3" 51 | 52 | # Config for 'cargo dist' 53 | [workspace.metadata.dist] 54 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 55 | cargo-dist-version = "0.16.0" 56 | # CI backends to support 57 | ci = "github" 58 | # The installers to generate for each app 59 | installers = ["shell", "homebrew"] 60 | # A GitHub repo to push Homebrew formulas to 61 | tap = "intuis/homebrew-tap" 62 | # Target platforms to build apps for (Rust target-triple syntax) 63 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu"] 64 | # Publish jobs to run in CI 65 | publish-jobs = ["homebrew"] 66 | # Publish jobs to run in CI 67 | pr-run-mode = "plan" 68 | # Whether to install an updater program 69 | install-updater = true 70 | 71 | [profile.release] 72 | opt-level = 3 73 | strip = "symbols" 74 | lto = "fat" 75 | panic = "abort" 76 | 77 | # The profile that 'cargo dist' will build with 78 | [profile.dist] 79 | inherits = "release" 80 | lto = "thin" 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Rustmission** 4 | 5 | Performant TUI for Transmission capable of managing hundreds of torrents. 6 | It boasts a rich feature set that surpasses other clients, offering a seamless experience :3 7 | 8 | 9 | # 10 |
11 | 12 |

13 | Torrents you see are just samples fabricated by AI. 14 |

15 |
16 | 17 | ## Features 18 | 19 | - **Basic operations**: Add, pause, remove or fuzzy filter your torrents. 20 | - **Categories**: Categorize your torrents, each with its own default directory. 21 | - **Sorting**: Sort your torrents, for example, to get rid of the bulkiest ones. 22 | - **Built-in magnet search**: Search for new magnets without leaving your terminal. 23 | - **Asynchronous**: UI is always responsive. 24 | - **RSS**: Fetch torrents automatically with a cronjob using `--fetch-rss` 25 | 26 | ## Requirements 27 | 28 | - [Transmission](https://github.com/transmission/transmission) daemon running in background and its IP address 29 | - [Nerd Fonts](https://www.nerdfonts.com/) (though you can set alternative set of icons) 30 | 31 | ## Installation 32 | 33 | 34 | Packaging status 35 | 36 | 37 | To install Rustmission, ensure you have Rust and Cargo installed on your system, and then run: 38 | 39 | ```bash 40 | cargo install rustmission 41 | ``` 42 | 43 | or with Nix ( :heart: [@0x61nas](https://github.com/0x61nas) ): 44 | 45 | ```bash 46 | nix-shell -p rustmission 47 | ``` 48 | 49 | or with Brew ( :heart: [@aidanaden](https://github.com/aidanaden) ): 50 | ```bash 51 | brew install intuis/tap/rustmission 52 | ``` 53 | 54 | ## Usage 55 | 56 | Run `rustmission` in your terminal to initialize the config and make adjustments as needed. Subsequently, run `rustmission` again. For a list of keybindings, press `?` or `F1`. 57 | 58 | ## Configuration 59 | 60 | Rustmission stores its configuration in a TOML file located at `~/.config/rustmission/config.toml` by default. You can modify this file to 61 | set the daemon's address. 62 | 63 | ```toml 64 | [general] 65 | # Whether to hide empty columns or not 66 | auto_hide = false 67 | 68 | # Possible values: Red, Green, Blue, Yellow, Magenta, Cyan. 69 | # Use prefix "Light" for a brighter color. 70 | # It can also be a hex, e.g. "#3cb371" 71 | accent_color = "LightMagenta" 72 | 73 | # If enabled, shows various keybindings throughout the program at the cost of 74 | # a little bit cluttered interface. 75 | beginner_mode = true 76 | 77 | # If enabled, hides header row of torrents tab 78 | headers_hide = false 79 | 80 | [connection] 81 | url = "http://CHANGE_ME:9091/transmission/rpc" # REQUIRED! 82 | 83 | # Refresh timings (in seconds) 84 | torrents_refresh = 5 85 | stats_refresh = 5 86 | free_space_refresh = 10 87 | 88 | # If you need username and password to authenticate: 89 | # username = "CHANGE_ME" 90 | # password = "CHANGE_ME" 91 | 92 | [torrents_tab] 93 | # Available fields: 94 | # Id, Name, SizeWhenDone, Progress, DownloadRate, UploadRate, DownloadDir, 95 | # Padding, UploadRatio, UploadedEver, AddedDate, ActivityDate, PeersConnected 96 | # SmallStatus, Category, CategoryIcon 97 | headers = ["Name", "SizeWhenDone", "Progress", "Eta", "DownloadRate", "UploadRate"] 98 | 99 | [search_tab] 100 | # If you uncomment this, providers won't be automatically added in future 101 | # versions of Rustmission. 102 | # providers = ["Knaben", "Nyaa"] 103 | 104 | [icons] 105 | # ... 106 | 107 | ``` 108 | 109 | There's also a self-documenting keymap config located at `~/.config/rustmission/keymap.toml` with sane defaults. 110 | You can also define torrent categories at `~/.config/rustmission/categories.toml`. 111 | 112 | ## Alternatives 113 | - [Transgression](https://github.com/PanAeon/transg-tui) 114 | - [tremc](https://github.com/tremc/tremc) 115 | - [trt](https://github.com/murtaza-u/transmission-remote-tui) 116 | - [stig](https://github.com/rndusr/stig) 117 | 118 | ## Contributing 119 | If you'd like to contribute make sure you fork [this repo](https://github.com/intuis/rustmission) and submit a PR! 120 | If you want to implement something major, create an issue first so it can be discussed. 121 | -------------------------------------------------------------------------------- /changelogs/0.5.0.md: -------------------------------------------------------------------------------- 1 | # Rustmission 0.5.0 2 | 3 | If you don't know what Rustmission is, it's a performant and a featureful TUI for Transmission written in Rust. 4 | This release contains many new features, like sorting, multiple selections and more. 5 | 6 | ## Breaking changes 7 | In `~/.config/rustmission/keymap.toml` under torrents_tab section replace: 8 | ``` 9 | { on = "d", action = "DeleteWithoutFiles" }, 10 | { on = "D", action = "DeleteWithFiles" }, 11 | ``` 12 | with: 13 | ``` 14 | { on = "d", action = "Delete"}, 15 | ``` 16 | 17 | This is due to that Rustmission now asks specifically whether to delete a torrent with files or not. 18 | 19 | ## MULTIPLE SELECTIONS! 20 | ![image](https://github.com/user-attachments/assets/c6571806-d912-4425-a2c9-56e0ff98ec32) 21 | 22 | You can now press `Space` in torrents tab list in order to select multiple torrents. This is useful when you have to delete or move multiple torrents all at once. 23 | 24 | ## SORTING! 25 | 26 | ![image](https://github.com/user-attachments/assets/05f89c82-10a7-4588-b2b0-3440378a11d9) 27 | 28 | Just press `H` or `L` (that's a big H and L respectively) and see the magic happen. 29 | If you have a keymap already, you must update it and bind `MoveToColumnLeft` and `MoveToColumnRight` actions under the `[general]` section in order to make us of this feature like so: 30 | 31 | ``` 32 | { on = "H", action = "MoveToColumnLeft" }, 33 | { on = "L", action = "MoveToColumnRight" }, 34 | ``` 35 | 36 | ## CATEGORIES WITH DEFAULT DIRECTORIES! 37 | 38 | Just define one in your `~/.config/rustmission/categories.toml` (which will be automatically generated with some commented-out examples) like this: 39 | 40 | ```toml 41 | [[categories]] 42 | icon = "[M]" 43 | default_dir = "/mnt/Music/Classical" 44 | color = "Green" 45 | ``` 46 | 47 | Whenever you'll be adding a new torrent, Rustmission will ask you for a category and its directory will be set to the category's default: 48 | 49 | ![image](https://github.com/user-attachments/assets/fbba7373-dbc0-4b9a-be40-a59349dd722d) 50 | 51 | ![image](https://github.com/user-attachments/assets/28b2a89d-d858-4cb3-800b-f5fc1d53d708) 52 | 53 | 54 | If you want to, you can set a category for an already existing torrent using `c`: 55 | 56 | ![image](https://github.com/user-attachments/assets/f27fefeb-b242-43c6-890e-e1e2ec80d0f3) 57 | 58 | Autocompletion works so you can press TAB/CTRL-F/right and it will auto-complete! 59 | 60 | After that, you'll be asked to if you want to move the torrent too: 61 | 62 | ![image](https://github.com/user-attachments/assets/5748052d-f48d-476b-b05c-a6c559527647) 63 | 64 | If you want to make use of this feature and you have your own keymap already, you have to bind `ChangeCategory` action in `keymap.toml` under `[torrents_tab]` like so: 65 | 66 | ``` 67 | { on = "c", action = "ChangeCategory" }, 68 | ``` 69 | 70 | ## YOU CAN NOW SEARCH NYAA.SI FOR MAGNETS! 71 | 72 | ![image](https://github.com/user-attachments/assets/91e9f14d-991f-4c61-a9c3-3ff5887bdac8) 73 | 74 | Also improvements to the code were made so that new search providers can be added more easily. Though the `magnetease` crate still needs some polish. 75 | 76 | If you want to be able to access providers popup, you have to bind `ShowProvidersInfo` action under the `search_tab` section like so: 77 | 78 | ```toml 79 | [search_tab] 80 | keybindings = [ 81 | { on = "p", action = "ShowProvidersInfo" } 82 | ] 83 | ``` 84 | 85 | ## YOU CAN NOW OPEN TORRENTS DIRECTLY WITH XDG-OPEN 86 | 87 | ![image](https://github.com/user-attachments/assets/401b2337-d942-4ea0-9b2e-44e363597ce7) 88 | 89 | 90 | In the image shown, in files popup you can now press `o` in order to open selected file in your default application. You can press `o` within just the torrents tab and it will open currently highlighted torrent's directory. 91 | 92 | If you want to use this feature and you have your own keymap already, you have to bind `XdgOpen` action under the `[general]` section in `keymap.toml` like so: 93 | 94 | ``` 95 | { on = "o", action = "XdgOpen" }, 96 | ``` 97 | 98 | ## Icons are now configurable 99 | 100 | ![image](https://github.com/user-attachments/assets/1cac8aa1-403d-465e-938e-c9df04e81618) 101 | 102 | You can now replace these pesky nerd fonts icons if you don't have nerd fonts installed. 103 | You can configure them at `.config/rustmission/config.toml` under `[icons]` section. 104 | Use `rustmission print-default-config` to see the defaults. 105 | 106 | ## New details popup! 107 | ![image](https://github.com/user-attachments/assets/5a9565dc-5c07-4fca-be72-1e6015d23a97) 108 | 109 | You can now press `Enter` while highlighting a torrent in torrents tab to view details about it (together with some useful hotkeys). 110 | 111 | ## Torrent errors are now being shown! 112 | 113 | ![image](https://github.com/user-attachments/assets/4ad34e07-1feb-4406-9890-0d38e377923c) 114 | 115 | That was actually very easy to do thanks to Ratatui (the TUI library that Rustmission uses). 116 | 117 | ## Graphs in statistics! 118 | 119 | ![image](https://github.com/user-attachments/assets/c27fc0e6-b9e3-4a26-aa3f-a99cf2e42c54) 120 | 121 | Statistics popup isn't now as empty as before. 122 | 123 | ## Help popup is now much prettier! 124 | 125 | ![image](https://github.com/user-attachments/assets/7d93bdf7-341f-4e86-9048-8023a05c083b) 126 | 127 | And also its text shouldn't take so much vertical space as it did before 128 | 129 | ## Default config printing 130 | 131 | You can now type `rustmission print-default-config` or `rustmission print-default-keymap` in order to view the default config/keymap that is up to date. 132 | 133 | ## Other changes 134 | 135 | There have been also performance improvements related to torrents filtering and action handling so Rustmission takes less CPU cycles for itself than it did before. 136 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: 2 | pkgs.rustPlatform.buildRustPackage rec { 3 | pname = "rustmission"; 4 | version = "0.1.0"; 5 | cargoLock.lockFile = ./Cargo.lock; 6 | src = pkgs.lib.cleanSource ./.; 7 | buildInputs = [ pkgs.openssl ]; 8 | nativeBuildInputs = [ pkgs.pkg-config ]; 9 | doCheck = false; 10 | } 11 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1735471104, 24 | "narHash": "sha256-0q9NGQySwDQc7RhAV2ukfnu7Gxa5/ybJ2ANT8DQrQrs=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "88195a94f390381c6afcdaa933c2f6ff93959cb4", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs", 41 | "rust-overlay": "rust-overlay" 42 | } 43 | }, 44 | "rust-overlay": { 45 | "inputs": { 46 | "nixpkgs": [ 47 | "nixpkgs" 48 | ] 49 | }, 50 | "locked": { 51 | "lastModified": 1735698720, 52 | "narHash": "sha256-+skLL6mq/T7s6J5YmSp89ivQOHBPQ40GEU2n8yqp6bs=", 53 | "owner": "oxalica", 54 | "repo": "rust-overlay", 55 | "rev": "a00807363a8a6cae6c3fa84ff494bf9d96333674", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "oxalica", 60 | "repo": "rust-overlay", 61 | "type": "github" 62 | } 63 | }, 64 | "systems": { 65 | "locked": { 66 | "lastModified": 1681028828, 67 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 68 | "owner": "nix-systems", 69 | "repo": "default", 70 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 71 | "type": "github" 72 | }, 73 | "original": { 74 | "owner": "nix-systems", 75 | "repo": "default", 76 | "type": "github" 77 | } 78 | } 79 | }, 80 | "root": "root", 81 | "version": 7 82 | } 83 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Lemmynator"; 3 | inputs = { 4 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | rust-overlay = { 7 | url ="github:oxalica/rust-overlay"; 8 | inputs = { 9 | nixpkgs.follows = "nixpkgs"; 10 | }; 11 | }; 12 | }; 13 | outputs = { self, nixpkgs, flake-utils, rust-overlay }: 14 | flake-utils.lib.eachDefaultSystem (system: 15 | let 16 | overlays = [ (import rust-overlay) ]; 17 | pkgs = import nixpkgs { 18 | inherit system overlays; 19 | }; 20 | in 21 | { 22 | devShells.default = with pkgs; mkShell { 23 | buildInputs = [ 24 | openssl 25 | pkg-config 26 | rust-bin.stable.latest.default 27 | rust-analyzer 28 | ]; 29 | }; 30 | } 31 | ); 32 | } 33 | 34 | -------------------------------------------------------------------------------- /rm-config/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rm-config" 3 | description = "Config library for rustmission" 4 | version.workspace = true 5 | edition.workspace = true 6 | license.workspace = true 7 | authors.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | rm-shared.workspace = true 15 | xdg.workspace = true 16 | toml.workspace = true 17 | serde.workspace = true 18 | anyhow.workspace = true 19 | url.workspace = true 20 | ratatui.workspace = true 21 | crossterm.workspace = true 22 | thiserror.workspace = true 23 | transmission-rpc.workspace = true 24 | magnetease.workspace = true 25 | intuitils.workspace = true 26 | -------------------------------------------------------------------------------- /rm-config/defaults/categories.toml: -------------------------------------------------------------------------------- 1 | # Example category: 2 | # [[categories]] 3 | # name = "Classical Music" # required 4 | # icon = "[M]" # optional, default: "" 5 | # default_dir = "/mnt/Music/Classical" # optional, default: transmission's default 6 | # color = "Green" # optional, default: "White" 7 | -------------------------------------------------------------------------------- /rm-config/defaults/config.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | # Whether to hide empty columns or not 3 | auto_hide = false 4 | 5 | # Possible values: Red, Green, Blue, Yellow, Magenta, Cyan. 6 | # Use prefix "Light" for a brighter color. 7 | # It can also be a hex, e.g. "#3cb371" 8 | accent_color = "LightMagenta" 9 | 10 | # If enabled, shows various keybindings throughout the program at the cost of 11 | # a little bit cluttered interface. 12 | beginner_mode = true 13 | 14 | # If enabled, hides table headers 15 | headers_hide = false 16 | 17 | [connection] 18 | url = "http://CHANGE_ME:9091/transmission/rpc" # REQUIRED! 19 | 20 | # Refresh timings (in seconds) 21 | torrents_refresh = 5 22 | stats_refresh = 5 23 | free_space_refresh = 10 24 | 25 | # If you need username and password to authenticate: 26 | # username = "CHANGE_ME" 27 | # password = "CHANGE_ME" 28 | 29 | 30 | [torrents_tab] 31 | # Available fields: 32 | # Id, Name, SizeWhenDone, Progress, Eta, DownloadRate, UploadRate, DownloadDir, 33 | # Padding, UploadRatio, UploadedEver, AddedDate, ActivityDate, PeersConnected 34 | # SmallStatus, Category, CategoryIcon 35 | headers = ["Name", "SizeWhenDone", "Progress", "Eta", "DownloadRate", "UploadRate"] 36 | 37 | # Default header to sort by: 38 | default_sort = "AddedDate" 39 | # Reverse the default sort? 40 | default_sort_reverse = true 41 | 42 | # Whether to insert category icon into name as declared in categories.toml. 43 | # An alternative to inserting category's icon into torrent's name is adding a 44 | # CategoryIcon header into your headers. 45 | category_icon_insert_into_name = true 46 | 47 | [search_tab] 48 | # If you uncomment this, providers won't be automatically added in future 49 | # versions of Rustmission. 50 | # providers = ["Knaben", "Nyaa"] 51 | 52 | [icons] 53 | # Ascii alternatives # Defaults 54 | # upload = "↑" # "" 55 | # download = "↓" # "" 56 | # arrow_left = "←" # "" 57 | # arrow_right = "→" # "" 58 | # arrow_up = "↑" # "" 59 | # arrow_down = "↓" # "" 60 | # triangle_right = "▶" # "▶" 61 | # triangle_down = "▼" # "▼" 62 | # file = "∷" # "" 63 | # disk = "[D]" # "󰋊" 64 | # help = "[?]" # "󰘥" 65 | # success = "✔" # "" 66 | # failure = "✖" # "" 67 | # searching = "⟳" # "" 68 | # verifying = "⟳" # "󰑓" 69 | # loading = "⌛" # "󱥸" 70 | # pause = "‖" # "󰏤" 71 | # idle = "○" # "󱗼" 72 | # magnifying_glass = "[o]" # "" 73 | # provider_disabled = "⛔" # "󰪎" 74 | # provider_category_general = "[G]" # "" 75 | # provider_category_anime = "[A]" # "󰎁" 76 | # sort_ascending = "↓" # "󰒼" 77 | # sort_descending = "↑" # "󰒽"" 78 | -------------------------------------------------------------------------------- /rm-config/defaults/keymap.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | keybindings = [ 3 | { on = "?", action = "ShowHelp", show_in_help = false }, 4 | { on = "F1", action = "ShowHelp", show_in_help = false }, 5 | 6 | { on = "q", action = "Quit" }, 7 | { on = "Esc", action = "Close" }, 8 | { on = "Enter", action = "Confirm" }, 9 | { on = " ", action = "Select" }, 10 | { on = "Tab", action = "SwitchFocus" }, 11 | { on = "/", action = "Search" }, 12 | { on = "o", action = "XdgOpen" }, 13 | 14 | { on = "1", action = "SwitchToTorrents" }, 15 | { on = "2", action = "SwitchToSearch" }, 16 | 17 | { on = "Home", action = "GoToBeginning" }, 18 | { on = "End", action = "GoToEnd" }, 19 | { on = "PageUp", action = "ScrollPageUp", show_in_help = false }, 20 | { on = "PageDown", action = "ScrollPageDown", show_in_help = false }, 21 | 22 | { modifier = "Ctrl", on = "u", action = "ScrollPageUp" }, 23 | { modifier = "Ctrl", on = "d", action = "ScrollPageDown" }, 24 | 25 | # Arrows 26 | { on = "Left", action = "Left" }, 27 | { on = "Right", action = "Right" }, 28 | { on = "Up", action = "Up"}, 29 | { on = "Down", action = "Down" }, 30 | 31 | # Vi 32 | { on = "h", action = "Left" }, 33 | { on = "l", action = "Right" }, 34 | { on = "k", action = "Up" }, 35 | { on = "j", action = "Down" }, 36 | 37 | # Sorting 38 | { on = "H", action = "MoveToColumnLeft"}, 39 | { on = "L", action = "MoveToColumnRight"}, 40 | ] 41 | 42 | [torrents_tab] 43 | keybindings = [ 44 | { on = "a", action = "AddMagnet" }, 45 | { on = "m", action = "MoveTorrent" }, 46 | { on = "r", action = "Rename" }, 47 | { on = "c", action = "ChangeCategory" }, 48 | { on = "p", action = "Pause" }, 49 | { on = "f", action = "ShowFiles" }, 50 | { on = "s", action = "ShowStats" }, 51 | 52 | { on = "d", action = "Delete" }, 53 | ] 54 | 55 | [search_tab] 56 | keybindings = [ 57 | { on = "p", action = "ShowProvidersInfo" } 58 | ] 59 | 60 | -------------------------------------------------------------------------------- /rm-config/src/categories.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use intuitils::config::IntuiConfig; 4 | use ratatui::style::Color; 5 | use serde::Deserialize; 6 | 7 | #[derive(Deserialize)] 8 | pub struct CategoriesConfig { 9 | #[serde(default)] 10 | pub categories: Vec, 11 | #[serde(skip)] 12 | pub map: HashMap, 13 | #[serde(skip)] 14 | pub max_name_len: u8, 15 | #[serde(skip)] 16 | pub max_icon_len: u8, 17 | } 18 | 19 | impl IntuiConfig for CategoriesConfig { 20 | fn app_name() -> &'static str { 21 | "rustmission" 22 | } 23 | 24 | fn filename() -> &'static str { 25 | "categories.toml" 26 | } 27 | 28 | fn default_config() -> &'static str { 29 | include_str!("../defaults/categories.toml") 30 | } 31 | 32 | fn should_exit_if_not_found() -> bool { 33 | false 34 | } 35 | 36 | fn message_if_not_found() -> Option { 37 | None 38 | } 39 | 40 | fn post_init(&mut self) { 41 | self.populate_hashmap(); 42 | self.set_lengths(); 43 | } 44 | } 45 | 46 | #[derive(Deserialize, Clone)] 47 | pub struct Category { 48 | pub name: String, 49 | #[serde(default)] 50 | pub icon: String, 51 | #[serde(default = "default_color_category")] 52 | pub color: Color, 53 | pub default_dir: Option, 54 | } 55 | 56 | fn default_color_category() -> Color { 57 | Color::White 58 | } 59 | 60 | impl CategoriesConfig { 61 | pub fn is_empty(&self) -> bool { 62 | self.categories.is_empty() 63 | } 64 | 65 | fn populate_hashmap(&mut self) { 66 | for category in &self.categories { 67 | self.map.insert(category.name.clone(), category.clone()); 68 | } 69 | } 70 | 71 | fn set_lengths(&mut self) { 72 | let mut max_icon_len = 0u8; 73 | let mut max_name_len = 0u8; 74 | 75 | for category in &self.categories { 76 | let name_len = u8::try_from(category.name.chars().count()).unwrap_or(u8::MAX); 77 | let icon_len = u8::try_from(category.icon.chars().count()).unwrap_or(u8::MAX); 78 | 79 | if name_len > max_name_len { 80 | max_name_len = name_len; 81 | } 82 | 83 | if icon_len > max_icon_len { 84 | max_icon_len = icon_len 85 | } 86 | } 87 | 88 | self.max_name_len = max_name_len; 89 | self.max_icon_len = max_icon_len; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /rm-config/src/keymap/actions/general.rs: -------------------------------------------------------------------------------- 1 | use intuitils::user_action::UserAction; 2 | use rm_shared::action::Action; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 6 | pub enum GeneralAction { 7 | ShowHelp, 8 | Quit, 9 | Close, 10 | SwitchToTorrents, 11 | SwitchToSearch, 12 | Left, 13 | Right, 14 | Down, 15 | Up, 16 | Search, 17 | SwitchFocus, 18 | Confirm, 19 | Select, 20 | ScrollPageDown, 21 | ScrollPageUp, 22 | GoToBeginning, 23 | GoToEnd, 24 | XdgOpen, 25 | MoveToColumnLeft, 26 | MoveToColumnRight, 27 | } 28 | 29 | pub enum GeneralActionMergable { 30 | MoveUpDown, 31 | MoveLeftRight, 32 | ScrollPageUpDown, 33 | MoveColumnLeftRight, 34 | SwitchToTorrentsSearch, 35 | } 36 | 37 | impl UserAction for GeneralAction { 38 | fn desc(&self) -> &'static str { 39 | match self { 40 | GeneralAction::ShowHelp => "toggle help", 41 | GeneralAction::Quit => "quit Rustmission, a popup", 42 | GeneralAction::Close => "close a popup, a task", 43 | GeneralAction::SwitchToTorrents => "switch to torrents tab", 44 | GeneralAction::SwitchToSearch => "switch to search tab", 45 | GeneralAction::Left => "switch to tab left", 46 | GeneralAction::Right => "switch to tab right", 47 | GeneralAction::Down => "move down", 48 | GeneralAction::Up => "move up", 49 | GeneralAction::Search => "search", 50 | GeneralAction::SwitchFocus => "switch focus", 51 | GeneralAction::Confirm => "confirm", 52 | GeneralAction::Select => "select", 53 | GeneralAction::ScrollPageDown => "scroll page down", 54 | GeneralAction::ScrollPageUp => "scroll page up", 55 | GeneralAction::GoToBeginning => "scroll to beginning", 56 | GeneralAction::GoToEnd => "scroll to end", 57 | GeneralAction::XdgOpen => "open with xdg-open", 58 | GeneralAction::MoveToColumnRight => "move to right column (sorting)", 59 | GeneralAction::MoveToColumnLeft => "move to left column (sorting)", 60 | } 61 | } 62 | 63 | fn merge_desc_with(&self, other: &GeneralAction) -> Option<&'static str> { 64 | match (&self, other) { 65 | (Self::Left, Self::Right) => Some("switch to tab left / right"), 66 | (Self::Right, Self::Left) => Some("switch to tab right / left"), 67 | (Self::Down, Self::Up) => Some("move down / up"), 68 | (Self::Up, Self::Down) => Some("move up / down"), 69 | (Self::SwitchToTorrents, Self::SwitchToSearch) => { 70 | Some("switch to torrents / search tab") 71 | } 72 | (Self::SwitchToSearch, Self::SwitchToTorrents) => { 73 | Some("switch to search / torrents tab") 74 | } 75 | (Self::MoveToColumnLeft, Self::MoveToColumnRight) => { 76 | Some("move to column left / right") 77 | } 78 | (Self::MoveToColumnRight, Self::MoveToColumnLeft) => { 79 | Some("move to column right / left") 80 | } 81 | (Self::ScrollPageDown, Self::ScrollPageUp) => Some("scroll page down / up"), 82 | (Self::ScrollPageUp, Self::ScrollPageDown) => Some("scroll page up / down"), 83 | (Self::GoToBeginning, Self::GoToEnd) => Some("go to beginning / end"), 84 | (Self::GoToEnd, Self::GoToBeginning) => Some("go to end / beginning"), 85 | 86 | _ => None, 87 | } 88 | } 89 | } 90 | 91 | impl From for Action { 92 | fn from(value: GeneralAction) -> Self { 93 | match value { 94 | GeneralAction::ShowHelp => Action::ShowHelp, 95 | GeneralAction::Quit => Action::Quit, 96 | GeneralAction::Close => Action::Close, 97 | GeneralAction::SwitchToTorrents => Action::ChangeTab(1), 98 | GeneralAction::SwitchToSearch => Action::ChangeTab(2), 99 | GeneralAction::Left => Action::Left, 100 | GeneralAction::Right => Action::Right, 101 | GeneralAction::Down => Action::Down, 102 | GeneralAction::Up => Action::Up, 103 | GeneralAction::Search => Action::Search, 104 | GeneralAction::SwitchFocus => Action::ChangeFocus, 105 | GeneralAction::Confirm => Action::Confirm, 106 | GeneralAction::Select => Action::Select, 107 | GeneralAction::ScrollPageDown => Action::ScrollDownPage, 108 | GeneralAction::ScrollPageUp => Action::ScrollUpPage, 109 | GeneralAction::GoToBeginning => Action::Home, 110 | GeneralAction::GoToEnd => Action::End, 111 | GeneralAction::XdgOpen => Action::XdgOpen, 112 | GeneralAction::MoveToColumnLeft => Action::MoveToColumnLeft, 113 | GeneralAction::MoveToColumnRight => Action::MoveToColumnRight, 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /rm-config/src/keymap/actions/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod general; 2 | pub mod search_tab; 3 | pub mod torrents_tab; 4 | -------------------------------------------------------------------------------- /rm-config/src/keymap/actions/search_tab.rs: -------------------------------------------------------------------------------- 1 | use intuitils::user_action::UserAction; 2 | use rm_shared::action::Action; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 6 | pub enum SearchAction { 7 | ShowProvidersInfo, 8 | } 9 | 10 | impl UserAction for SearchAction { 11 | fn desc(&self) -> &'static str { 12 | match self { 13 | SearchAction::ShowProvidersInfo => "show providers info", 14 | } 15 | } 16 | } 17 | 18 | impl From for Action { 19 | fn from(value: SearchAction) -> Self { 20 | match value { 21 | SearchAction::ShowProvidersInfo => Action::ShowProvidersInfo, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /rm-config/src/keymap/actions/torrents_tab.rs: -------------------------------------------------------------------------------- 1 | use intuitils::user_action::UserAction; 2 | use rm_shared::action::Action; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 6 | pub enum TorrentsAction { 7 | AddMagnet, 8 | MoveTorrent, 9 | Rename, 10 | Pause, 11 | Delete, 12 | ShowFiles, 13 | ShowStats, 14 | ChangeCategory, 15 | } 16 | 17 | impl UserAction for TorrentsAction { 18 | fn desc(&self) -> &'static str { 19 | match self { 20 | TorrentsAction::AddMagnet => "add a magnet", 21 | TorrentsAction::MoveTorrent => "move torrent download directory", 22 | TorrentsAction::Pause => "pause/unpause", 23 | TorrentsAction::Delete => "delete", 24 | TorrentsAction::ShowFiles => "show files", 25 | TorrentsAction::ShowStats => "show statistics", 26 | TorrentsAction::ChangeCategory => "change category", 27 | TorrentsAction::Rename => "rename torrent path", 28 | } 29 | } 30 | } 31 | 32 | impl From for Action { 33 | fn from(value: TorrentsAction) -> Self { 34 | match value { 35 | TorrentsAction::AddMagnet => Action::AddMagnet, 36 | TorrentsAction::MoveTorrent => Action::MoveTorrent, 37 | TorrentsAction::Pause => Action::Pause, 38 | TorrentsAction::Delete => Action::Delete, 39 | TorrentsAction::ShowFiles => Action::ShowFiles, 40 | TorrentsAction::ShowStats => Action::ShowStats, 41 | TorrentsAction::ChangeCategory => Action::ChangeCategory, 42 | TorrentsAction::Rename => Action::Rename, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /rm-config/src/keymap/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod actions; 2 | 3 | use intuitils::config::{keybindings::KeybindsHolder, IntuiConfig}; 4 | use serde::Deserialize; 5 | 6 | use rm_shared::action::Action; 7 | 8 | pub use self::actions::{ 9 | general::GeneralAction, search_tab::SearchAction, torrents_tab::TorrentsAction, 10 | }; 11 | 12 | #[derive(Deserialize, Clone)] 13 | pub struct KeymapConfig { 14 | pub general: KeybindsHolder, 15 | pub torrents_tab: KeybindsHolder, 16 | pub search_tab: KeybindsHolder, 17 | } 18 | 19 | impl IntuiConfig for KeymapConfig { 20 | fn app_name() -> &'static str { 21 | "rustmission" 22 | } 23 | 24 | fn filename() -> &'static str { 25 | "keymap.toml" 26 | } 27 | 28 | fn default_config() -> &'static str { 29 | include_str!("../../defaults/keymap.toml") 30 | } 31 | 32 | fn should_exit_if_not_found() -> bool { 33 | false 34 | } 35 | 36 | fn message_if_not_found() -> Option { 37 | None 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rm-config/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod categories; 2 | pub mod keymap; 3 | pub mod main_config; 4 | 5 | use std::{path::PathBuf, sync::LazyLock}; 6 | 7 | use anyhow::Result; 8 | use categories::CategoriesConfig; 9 | use intuitils::config::IntuiConfig; 10 | use keymap::KeymapConfig; 11 | use main_config::MainConfig; 12 | 13 | pub static CONFIG: LazyLock = LazyLock::new(|| { 14 | Config::init().unwrap_or_else(|e| { 15 | eprintln!("{:?}", e); 16 | std::process::exit(1); 17 | }) 18 | }); 19 | 20 | pub struct Config { 21 | pub general: main_config::General, 22 | pub connection: main_config::Connection, 23 | pub torrents_tab: main_config::TorrentsTab, 24 | pub search_tab: main_config::SearchTab, 25 | pub icons: main_config::Icons, 26 | pub keybindings: KeymapConfig, 27 | pub categories: CategoriesConfig, 28 | pub directories: Directories, 29 | } 30 | 31 | pub struct Directories { 32 | pub main_path: &'static PathBuf, 33 | pub keymap_path: &'static PathBuf, 34 | pub categories_path: &'static PathBuf, 35 | } 36 | 37 | impl Config { 38 | fn init() -> Result { 39 | let main_config = MainConfig::init()?; 40 | let keybindings = KeymapConfig::init()?; 41 | let categories = CategoriesConfig::init()?; 42 | 43 | let directories = Directories { 44 | main_path: MainConfig::path(), 45 | keymap_path: KeymapConfig::path(), 46 | categories_path: CategoriesConfig::path(), 47 | }; 48 | 49 | Ok(Self { 50 | general: main_config.general, 51 | connection: main_config.connection, 52 | torrents_tab: main_config.torrents_tab, 53 | search_tab: main_config.search_tab, 54 | icons: main_config.icons, 55 | keybindings, 56 | categories, 57 | directories, 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rm-config/src/main_config/connection.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use url::Url; 3 | 4 | #[derive(Deserialize)] 5 | pub struct Connection { 6 | pub username: Option, 7 | pub password: Option, 8 | pub url: Url, 9 | #[serde(default = "default_refresh")] 10 | pub torrents_refresh: u64, 11 | #[serde(default = "default_refresh")] 12 | pub stats_refresh: u64, 13 | #[serde(default = "default_refresh")] 14 | pub free_space_refresh: u64, 15 | } 16 | 17 | fn default_refresh() -> u64 { 18 | 5 19 | } 20 | -------------------------------------------------------------------------------- /rm-config/src/main_config/general.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | use serde::Deserialize; 3 | 4 | #[derive(Deserialize)] 5 | pub struct General { 6 | #[serde(default)] 7 | pub auto_hide: bool, 8 | #[serde(default = "default_accent_color")] 9 | pub accent_color: Color, 10 | #[serde(default = "default_beginner_mode")] 11 | pub beginner_mode: bool, 12 | #[serde(default)] 13 | pub headers_hide: bool, 14 | } 15 | 16 | impl Default for General { 17 | fn default() -> Self { 18 | Self { 19 | auto_hide: false, 20 | accent_color: default_accent_color(), 21 | beginner_mode: default_beginner_mode(), 22 | headers_hide: false, 23 | } 24 | } 25 | } 26 | 27 | fn default_accent_color() -> Color { 28 | Color::LightMagenta 29 | } 30 | 31 | fn default_beginner_mode() -> bool { 32 | true 33 | } 34 | -------------------------------------------------------------------------------- /rm-config/src/main_config/icons.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Deserialize)] 4 | pub struct Icons { 5 | #[serde(default = "default_upload")] 6 | pub upload: String, 7 | #[serde(default = "default_download")] 8 | pub download: String, 9 | #[serde(default = "default_arrow_left")] 10 | pub arrow_left: String, 11 | #[serde(default = "default_arrow_right")] 12 | pub arrow_right: String, 13 | #[serde(default = "default_arrow_up")] 14 | pub arrow_up: String, 15 | #[serde(default = "default_arrow_down")] 16 | pub arrow_down: String, 17 | #[serde(default = "default_triangle_right")] 18 | pub triangle_right: String, 19 | #[serde(default = "default_triangle_down")] 20 | pub triangle_down: String, 21 | #[serde(default = "default_file")] 22 | pub file: String, 23 | #[serde(default = "default_disk")] 24 | pub disk: String, 25 | #[serde(default = "default_help")] 26 | pub help: String, 27 | #[serde(default = "default_success")] 28 | pub success: String, 29 | #[serde(default = "default_failure")] 30 | pub failure: String, 31 | #[serde(default = "default_searching")] 32 | pub searching: String, 33 | #[serde(default = "default_verifying")] 34 | pub verifying: String, 35 | #[serde(default = "default_loading")] 36 | pub loading: String, 37 | #[serde(default = "default_pause")] 38 | pub pause: String, 39 | #[serde(default = "default_idle")] 40 | pub idle: String, 41 | #[serde(default = "default_magnifying_glass")] 42 | pub magnifying_glass: String, 43 | #[serde(default = "default_provider_disabled")] 44 | pub provider_disabled: String, 45 | #[serde(default = "default_provider_category_general")] 46 | pub provider_category_general: String, 47 | #[serde(default = "default_provider_category_anime")] 48 | pub provider_category_anime: String, 49 | #[serde(default = "default_sort_ascending")] 50 | pub sort_ascending: String, 51 | #[serde(default = "default_sort_descending")] 52 | pub sort_descending: String, 53 | } 54 | 55 | impl Default for Icons { 56 | fn default() -> Self { 57 | Self { 58 | upload: default_upload(), 59 | download: default_download(), 60 | arrow_left: default_arrow_left(), 61 | arrow_right: default_arrow_right(), 62 | arrow_up: default_arrow_up(), 63 | arrow_down: default_arrow_down(), 64 | triangle_right: default_triangle_right(), 65 | triangle_down: default_triangle_down(), 66 | file: default_file(), 67 | disk: default_disk(), 68 | help: default_help(), 69 | success: default_success(), 70 | failure: default_failure(), 71 | searching: default_searching(), 72 | verifying: default_verifying(), 73 | loading: default_loading(), 74 | pause: default_pause(), 75 | idle: default_idle(), 76 | magnifying_glass: default_magnifying_glass(), 77 | provider_disabled: default_provider_disabled(), 78 | provider_category_general: default_provider_category_general(), 79 | provider_category_anime: default_provider_category_anime(), 80 | sort_ascending: default_sort_ascending(), 81 | sort_descending: default_sort_descending(), 82 | } 83 | } 84 | } 85 | fn default_upload() -> String { 86 | "".into() 87 | } 88 | 89 | fn default_download() -> String { 90 | "".into() 91 | } 92 | 93 | fn default_arrow_left() -> String { 94 | "".into() 95 | } 96 | 97 | fn default_arrow_right() -> String { 98 | "".into() 99 | } 100 | 101 | fn default_arrow_up() -> String { 102 | "".into() 103 | } 104 | 105 | fn default_arrow_down() -> String { 106 | "".into() 107 | } 108 | 109 | fn default_triangle_right() -> String { 110 | "▶".into() 111 | } 112 | 113 | fn default_triangle_down() -> String { 114 | "▼".into() 115 | } 116 | 117 | fn default_file() -> String { 118 | "".into() 119 | } 120 | 121 | fn default_disk() -> String { 122 | "󰋊".into() 123 | } 124 | 125 | fn default_help() -> String { 126 | "".into() 127 | } 128 | 129 | fn default_success() -> String { 130 | "".into() 131 | } 132 | 133 | fn default_failure() -> String { 134 | "".into() 135 | } 136 | 137 | fn default_searching() -> String { 138 | "".into() 139 | } 140 | 141 | fn default_verifying() -> String { 142 | "󰑓".into() 143 | } 144 | 145 | fn default_loading() -> String { 146 | "󱥸".into() 147 | } 148 | 149 | fn default_pause() -> String { 150 | "󰏤".into() 151 | } 152 | 153 | fn default_idle() -> String { 154 | "󱗼".into() 155 | } 156 | 157 | fn default_magnifying_glass() -> String { 158 | "".into() 159 | } 160 | 161 | fn default_provider_disabled() -> String { 162 | "󰪎".into() 163 | } 164 | 165 | fn default_provider_category_general() -> String { 166 | "".into() 167 | } 168 | 169 | fn default_provider_category_anime() -> String { 170 | "󰎁".into() 171 | } 172 | 173 | fn default_sort_ascending() -> String { 174 | "󰒼".into() 175 | } 176 | 177 | fn default_sort_descending() -> String { 178 | "󰒽".into() 179 | } 180 | -------------------------------------------------------------------------------- /rm-config/src/main_config/mod.rs: -------------------------------------------------------------------------------- 1 | mod connection; 2 | mod general; 3 | mod icons; 4 | mod search_tab; 5 | mod torrents_tab; 6 | 7 | pub use connection::Connection; 8 | pub use general::General; 9 | pub use icons::Icons; 10 | use intuitils::config::IntuiConfig; 11 | pub use search_tab::SearchTab; 12 | pub use torrents_tab::TorrentsTab; 13 | 14 | use serde::Deserialize; 15 | 16 | #[derive(Deserialize)] 17 | pub struct MainConfig { 18 | #[serde(default)] 19 | pub general: General, 20 | pub connection: Connection, 21 | #[serde(default)] 22 | pub torrents_tab: TorrentsTab, 23 | #[serde(default)] 24 | pub search_tab: SearchTab, 25 | #[serde(default)] 26 | pub icons: Icons, 27 | } 28 | 29 | impl IntuiConfig for MainConfig { 30 | fn app_name() -> &'static str { 31 | "rustmission" 32 | } 33 | 34 | fn filename() -> &'static str { 35 | "config.toml" 36 | } 37 | 38 | fn default_config() -> &'static str { 39 | include_str!("../../defaults/config.toml") 40 | } 41 | 42 | fn should_exit_if_not_found() -> bool { 43 | true 44 | } 45 | 46 | fn message_if_not_found() -> Option { 47 | Some(format!( 48 | "Update {:?} (especially connection url) and start rustmission again", 49 | Self::path() 50 | )) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /rm-config/src/main_config/search_tab.rs: -------------------------------------------------------------------------------- 1 | use magnetease::WhichProvider; 2 | use serde::Deserialize; 3 | 4 | #[derive(Deserialize)] 5 | pub struct SearchTab { 6 | #[serde(default = "default_providers")] 7 | pub providers: Vec, 8 | } 9 | 10 | impl Default for SearchTab { 11 | fn default() -> Self { 12 | Self { 13 | providers: default_providers(), 14 | } 15 | } 16 | } 17 | 18 | fn default_providers() -> Vec { 19 | vec![WhichProvider::Knaben, WhichProvider::Nyaa] 20 | } 21 | -------------------------------------------------------------------------------- /rm-config/src/main_config/torrents_tab.rs: -------------------------------------------------------------------------------- 1 | use rm_shared::header::Header; 2 | use serde::Deserialize; 3 | 4 | #[derive(Deserialize)] 5 | pub struct TorrentsTab { 6 | #[serde(default = "default_headers")] 7 | pub headers: Vec
, 8 | #[serde(default = "default_sort")] 9 | pub default_sort: Header, 10 | #[serde(default = "default_true")] 11 | pub default_sort_reverse: bool, 12 | #[serde(default = "default_true")] 13 | pub category_icon_insert_into_name: bool, 14 | } 15 | 16 | fn default_true() -> bool { 17 | true 18 | } 19 | 20 | fn default_sort() -> Header { 21 | Header::AddedDate 22 | } 23 | 24 | fn default_headers() -> Vec
{ 25 | vec![ 26 | Header::Name, 27 | Header::SizeWhenDone, 28 | Header::Progress, 29 | Header::Eta, 30 | Header::DownloadRate, 31 | Header::UploadRate, 32 | ] 33 | } 34 | 35 | impl Default for TorrentsTab { 36 | fn default() -> Self { 37 | Self { 38 | headers: default_headers(), 39 | default_sort: default_sort(), 40 | default_sort_reverse: default_true(), 41 | category_icon_insert_into_name: default_true(), 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /rm-main/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rustmission" 3 | description = "TUI for Transmission daemon" 4 | version.workspace = true 5 | edition.workspace = true 6 | repository.workspace = true 7 | homepage.workspace = true 8 | license.workspace = true 9 | 10 | [[bin]] 11 | name = "rustmission" 12 | path = "src/main.rs" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | rm-config.workspace = true 18 | rm-shared.workspace = true 19 | magnetease.workspace = true 20 | anyhow.workspace = true 21 | serde.workspace = true 22 | transmission-rpc.workspace = true 23 | fuzzy-matcher.workspace = true 24 | clap.workspace = true 25 | base64.workspace = true 26 | tokio.workspace = true 27 | tokio-util.workspace = true 28 | futures.workspace = true 29 | crossterm.workspace = true 30 | ratatui.workspace = true 31 | tui-input.workspace = true 32 | tui-tree-widget.workspace = true 33 | rss.workspace = true 34 | reqwest.workspace = true 35 | regex.workspace = true 36 | throbber-widgets-tui.workspace = true 37 | chrono.workspace = true 38 | open.workspace = true 39 | intuitils.workspace = true 40 | intui-tabs.workspace = true 41 | -------------------------------------------------------------------------------- /rm-main/src/cli/add_torrent.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Read}; 2 | 3 | use anyhow::Result; 4 | use base64::Engine; 5 | use transmission_rpc::types::TorrentAddArgs; 6 | 7 | use crate::transmission; 8 | 9 | pub(super) async fn add_torrent(torrent: String) -> Result<()> { 10 | let mut transclient = transmission::utils::new_client(); 11 | let args = { 12 | if torrent.starts_with("magnet:") 13 | || torrent.starts_with("http:") 14 | || torrent.starts_with("https:") 15 | { 16 | TorrentAddArgs { 17 | filename: Some(torrent), 18 | ..Default::default() 19 | } 20 | } else if torrent.starts_with("www") { 21 | TorrentAddArgs { 22 | filename: Some(format!("https://{torrent}")), 23 | ..Default::default() 24 | } 25 | } else { 26 | let mut torrent_file = File::open(torrent)?; 27 | let mut buf = vec![]; 28 | torrent_file.read_to_end(&mut buf).unwrap(); 29 | let metainfo = base64::engine::general_purpose::STANDARD.encode(buf); 30 | TorrentAddArgs { 31 | metainfo: Some(metainfo), 32 | ..Default::default() 33 | } 34 | } 35 | }; 36 | 37 | if let Err(e) = transclient.torrent_add(args).await { 38 | eprintln!("error while adding a torrent: {e}"); 39 | if e.to_string().contains("expected value at line") { 40 | eprintln!("Check whether your arguments are valid."); 41 | } 42 | 43 | std::process::exit(1); 44 | }; 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /rm-main/src/cli/fetch_rss.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use regex::Regex; 3 | use transmission_rpc::types::TorrentAddArgs; 4 | 5 | use crate::transmission; 6 | 7 | pub async fn fetch_rss(url: &str, filter: Option<&str>) -> Result<()> { 8 | let mut transclient = transmission::utils::new_client(); 9 | let content = reqwest::get(url).await?.bytes().await?; 10 | let channel = rss::Channel::read_from(&content[..])?; 11 | let re: Option = { 12 | if let Some(filter_str) = filter { 13 | let res = Regex::new(filter_str)?; 14 | Some(res) 15 | } else { 16 | None 17 | } 18 | }; 19 | let items = channel.items().iter().filter_map(|item| { 20 | if let (Some(title), Some(url)) = (item.title(), item.link()) { 21 | if let Some(re) = &re { 22 | if re.is_match(title) { 23 | return Some((title, url)); 24 | } 25 | } else { 26 | return Some((title, url)); 27 | } 28 | } 29 | None 30 | }); 31 | for (title, url) in items { 32 | println!("downloading {title}"); 33 | let args = TorrentAddArgs { 34 | filename: Some(url.to_string()), 35 | ..Default::default() 36 | }; 37 | if let Err(e) = transclient.torrent_add(args).await { 38 | bail!("error while adding a torrent: {e}") 39 | } 40 | } 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /rm-main/src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod add_torrent; 2 | mod fetch_rss; 3 | 4 | use anyhow::Result; 5 | use clap::{Parser, Subcommand}; 6 | 7 | use add_torrent::add_torrent; 8 | use fetch_rss::fetch_rss; 9 | use intuitils::config::IntuiConfig; 10 | 11 | #[derive(Parser)] 12 | #[command(version, about)] 13 | pub struct Args { 14 | #[command(subcommand)] 15 | pub command: Option, 16 | } 17 | 18 | #[derive(Subcommand)] 19 | pub enum Commands { 20 | AddTorrent { torrent: String }, 21 | FetchRss { url: String, filter: Option }, 22 | PrintDefaultConfig {}, 23 | PrintDefaultKeymap {}, 24 | PrintDefaultCategories {}, 25 | } 26 | 27 | pub async fn handle_command(command: Commands) -> Result<()> { 28 | match command { 29 | Commands::AddTorrent { torrent } => add_torrent(torrent).await?, 30 | Commands::FetchRss { url, filter } => fetch_rss(&url, filter.as_deref()).await?, 31 | Commands::PrintDefaultConfig {} => { 32 | println!("{}", rm_config::main_config::MainConfig::default_config()) 33 | } 34 | Commands::PrintDefaultKeymap {} => { 35 | println!("{}", rm_config::keymap::KeymapConfig::default_config()) 36 | } 37 | Commands::PrintDefaultCategories {} => { 38 | println!( 39 | "{}", 40 | rm_config::categories::CategoriesConfig::default_config() 41 | ) 42 | } 43 | } 44 | Ok(()) 45 | } 46 | -------------------------------------------------------------------------------- /rm-main/src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | pub mod transmission; 3 | mod tui; 4 | 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use tui::app::App; 8 | 9 | #[tokio::main()] 10 | async fn main() -> Result<()> { 11 | let args = cli::Args::parse(); 12 | 13 | if let Some(command) = args.command { 14 | cli::handle_command(command).await?; 15 | } else { 16 | run_tui().await?; 17 | } 18 | 19 | Ok(()) 20 | } 21 | 22 | async fn run_tui() -> Result<()> { 23 | let app = App::new().await?; 24 | app.run().await?; 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /rm-main/src/transmission/action.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::mpsc::UnboundedReceiver; 4 | use tokio::sync::mpsc::UnboundedSender; 5 | use tokio::sync::oneshot::Sender; 6 | use transmission_rpc::types::{ 7 | FreeSpace, Id, SessionGet, SessionStats, Torrent, TorrentAction as RPCAction, TorrentAddArgs, 8 | TorrentGetField, TorrentSetArgs, 9 | }; 10 | use transmission_rpc::TransClient; 11 | 12 | use rm_shared::action::ErrorMessage; 13 | use rm_shared::action::UpdateAction; 14 | 15 | const FAILED_TO_COMMUNICATE: &str = "Failed to communicate with Transmission"; 16 | 17 | pub enum TorrentAction { 18 | // Add a torrent with this Magnet/URL, Directory, Label (Category) 19 | Add(String, Option, Option), 20 | // Stop Torrents with these given IDs 21 | Stop(Vec), 22 | // Start Torrents with these given IDs 23 | Start(Vec), 24 | // Torrent ID, Directory to move to 25 | Move(Vec, String), 26 | // Torrent ID, Current name, Name to change to 27 | Rename(Id, String, String), 28 | // Torrent ID, Category to set 29 | ChangeCategory(Vec, String), 30 | // Delete Torrents with these given IDs (without files) 31 | DelWithoutFiles(Vec), 32 | // Delete Torrents with these given IDs (with files) 33 | DelWithFiles(Vec), 34 | // Set various properties to Torrents with these given IDs 35 | SetArgs(Box, Option>), 36 | // Get info about current Transmission session 37 | GetSessionGet(Sender>>), 38 | // Get info about current Transmission session statistics 39 | GetSessionStats(Sender, Box>>), 40 | // Get info about available space on the disk 41 | GetFreeSpace(String, Sender>>), 42 | // Get info about all Torrents with these given Fields. 43 | GetTorrents( 44 | Vec, 45 | Sender, Box>>, 46 | ), 47 | // Get info about specific torrents with these given IDs 48 | GetTorrentsById(Vec, Sender, Box>>), 49 | } 50 | 51 | pub async fn action_handler( 52 | mut client: TransClient, 53 | mut trans_rx: UnboundedReceiver, 54 | update_tx: UnboundedSender, 55 | ) { 56 | while let Some(action) = trans_rx.recv().await { 57 | match action { 58 | TorrentAction::Add(ref url, directory, label) => { 59 | let formatted = { 60 | if url.starts_with("www") { 61 | format!("https://{url}") 62 | } else { 63 | url.to_string() 64 | } 65 | }; 66 | 67 | let label = label.map(|label| vec![label]); 68 | 69 | let args = TorrentAddArgs { 70 | filename: Some(formatted), 71 | download_dir: directory, 72 | labels: label, 73 | ..Default::default() 74 | }; 75 | match client.torrent_add(args).await { 76 | Ok(_) => { 77 | update_tx.send(UpdateAction::StatusTaskSuccess).unwrap(); 78 | } 79 | Err(err) => { 80 | let msg = format!("Failed to add torrent with URL/Path: \"{url}\""); 81 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 82 | update_tx 83 | .send(UpdateAction::Error(Box::new(err_message))) 84 | .unwrap(); 85 | update_tx.send(UpdateAction::StatusTaskFailure).unwrap(); 86 | } 87 | } 88 | } 89 | TorrentAction::Stop(ids) => { 90 | match client.torrent_action(RPCAction::Stop, ids.clone()).await { 91 | Ok(_) => (), 92 | Err(err) => { 93 | let msg = format!("Failed to stop torrents with these IDs: {:?}", ids); 94 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 95 | update_tx 96 | .send(UpdateAction::Error(Box::new(err_message))) 97 | .unwrap(); 98 | } 99 | } 100 | } 101 | TorrentAction::Start(ids) => { 102 | match client.torrent_action(RPCAction::Start, ids.clone()).await { 103 | Ok(_) => (), 104 | Err(err) => { 105 | let msg = format!("Failed to start torrents with these IDs: {:?}", ids); 106 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 107 | update_tx 108 | .send(UpdateAction::Error(Box::new(err_message))) 109 | .unwrap(); 110 | } 111 | } 112 | } 113 | TorrentAction::DelWithFiles(ids) => { 114 | match client.torrent_remove(ids.clone(), true).await { 115 | Ok(_) => update_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), 116 | Err(err) => { 117 | let msg = format!("Failed to remove torrents with these IDs: {:?}", ids); 118 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 119 | update_tx 120 | .send(UpdateAction::Error(Box::new(err_message))) 121 | .unwrap(); 122 | update_tx.send(UpdateAction::StatusTaskFailure).unwrap(); 123 | } 124 | } 125 | } 126 | TorrentAction::DelWithoutFiles(ids) => { 127 | match client.torrent_remove(ids.clone(), false).await { 128 | Ok(_) => update_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), 129 | Err(err) => { 130 | let msg = format!("Failed to remove torrents with these IDs: {:?}", ids); 131 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 132 | update_tx 133 | .send(UpdateAction::Error(Box::new(err_message))) 134 | .unwrap(); 135 | update_tx.send(UpdateAction::StatusTaskFailure).unwrap(); 136 | } 137 | } 138 | } 139 | TorrentAction::SetArgs(args, ids) => { 140 | match client.torrent_set(*args, ids.clone()).await { 141 | Ok(_) => (), 142 | Err(err) => { 143 | let msg = format!( 144 | "Failed to set some properties to torrents with these IDs: {:?}", 145 | ids 146 | ); 147 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 148 | update_tx 149 | .send(UpdateAction::Error(Box::new(err_message))) 150 | .unwrap(); 151 | } 152 | } 153 | } 154 | TorrentAction::GetSessionGet(sender) => match client.session_get().await { 155 | Ok(session_get) => { 156 | sender.send(Ok(session_get.arguments)).unwrap(); 157 | } 158 | Err(err) => { 159 | let msg = "Failed to get session data"; 160 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 161 | update_tx 162 | .send(UpdateAction::Error(Box::new(err_message))) 163 | .unwrap(); 164 | } 165 | }, 166 | TorrentAction::Move(ids, new_directory) => { 167 | if let Err(err) = client 168 | .torrent_set_location(ids, new_directory.clone(), Some(true)) 169 | .await 170 | { 171 | let msg = format!("Failed to move torrent to new directory:\n{new_directory}"); 172 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 173 | update_tx 174 | .send(UpdateAction::Error(Box::new(err_message))) 175 | .unwrap(); 176 | } 177 | } 178 | TorrentAction::GetSessionStats(sender) => match client.session_stats().await { 179 | Ok(stats) => sender.send(Ok(Arc::new(stats.arguments))).unwrap(), 180 | Err(err) => { 181 | let msg = "Failed to get session stats"; 182 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 183 | sender.send(Err(Box::new(err_message))).unwrap(); 184 | } 185 | }, 186 | TorrentAction::GetFreeSpace(path, sender) => match client.free_space(path).await { 187 | Ok(free_space) => sender.send(Ok(free_space.arguments)).unwrap(), 188 | Err(err) => { 189 | let msg = "Failed to get free space info"; 190 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 191 | sender.send(Err(Box::new(err_message))).unwrap(); 192 | } 193 | }, 194 | TorrentAction::GetTorrents(fields, sender) => { 195 | match client.torrent_get(Some(fields), None).await { 196 | Ok(torrents) => sender.send(Ok(torrents.arguments.torrents)).unwrap(), 197 | Err(err) => { 198 | let msg = "Failed to fetch torrent data"; 199 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 200 | sender.send(Err(Box::new(err_message))).unwrap(); 201 | } 202 | } 203 | } 204 | TorrentAction::GetTorrentsById(ids, sender) => { 205 | match client.torrent_get(None, Some(ids.clone())).await { 206 | Ok(torrents) => sender.send(Ok(torrents.arguments.torrents)).unwrap(), 207 | Err(err) => { 208 | let msg = format!("Failed to fetch torrents with these IDs: {:?}", ids); 209 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 210 | sender.send(Err(Box::new(err_message))).unwrap(); 211 | } 212 | } 213 | } 214 | TorrentAction::ChangeCategory(ids, category) => { 215 | let labels = if category.is_empty() { 216 | vec![] 217 | } else { 218 | vec![category] 219 | }; 220 | let args = TorrentSetArgs { 221 | labels: Some(labels), 222 | ..Default::default() 223 | }; 224 | match client.torrent_set(args, Some(ids)).await { 225 | Ok(_) => update_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), 226 | Err(err) => { 227 | let msg = "Failed to set category"; 228 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 229 | update_tx 230 | .send(UpdateAction::Error(Box::new(err_message))) 231 | .unwrap(); 232 | update_tx.send(UpdateAction::StatusTaskFailure).unwrap(); 233 | } 234 | } 235 | } 236 | TorrentAction::Rename(id, current_name, new_name) => { 237 | match client 238 | .torrent_rename_path(vec![id], current_name, new_name) 239 | .await 240 | { 241 | Ok(_) => update_tx.send(UpdateAction::StatusTaskSuccess).unwrap(), 242 | Err(err) => { 243 | let msg = "Failed to rename a torrent"; 244 | let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err); 245 | update_tx 246 | .send(UpdateAction::Error(Box::new(err_message))) 247 | .unwrap(); 248 | update_tx.send(UpdateAction::StatusTaskFailure).unwrap(); 249 | } 250 | } 251 | } 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /rm-main/src/transmission/fetchers.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::Arc, time::Duration}; 2 | 3 | use rm_config::CONFIG; 4 | use tokio::sync::oneshot; 5 | use transmission_rpc::types::TorrentGetField; 6 | 7 | use rm_shared::action::UpdateAction; 8 | 9 | use crate::tui::app::CTX; 10 | 11 | use super::TorrentAction; 12 | 13 | pub async fn stats() { 14 | loop { 15 | let (stats_tx, stats_rx) = oneshot::channel(); 16 | CTX.send_torrent_action(TorrentAction::GetSessionStats(stats_tx)); 17 | 18 | match stats_rx.await.unwrap() { 19 | Ok(stats) => { 20 | CTX.send_update_action(UpdateAction::SessionStats(stats)); 21 | } 22 | Err(err_message) => { 23 | CTX.send_update_action(UpdateAction::Error(err_message)); 24 | } 25 | }; 26 | 27 | tokio::time::sleep(Duration::from_secs(CONFIG.connection.stats_refresh)).await; 28 | } 29 | } 30 | 31 | pub async fn free_space() { 32 | let download_dir = loop { 33 | let (sess_tx, sess_rx) = oneshot::channel(); 34 | CTX.send_torrent_action(TorrentAction::GetSessionGet(sess_tx)); 35 | match sess_rx.await.unwrap() { 36 | Ok(sess) => { 37 | break sess.download_dir.leak(); 38 | } 39 | Err(err_message) => { 40 | CTX.send_update_action(UpdateAction::Error(err_message)); 41 | tokio::time::sleep(Duration::from_secs(10)).await; 42 | } 43 | }; 44 | }; 45 | 46 | loop { 47 | let (space_tx, space_rx) = oneshot::channel(); 48 | CTX.send_torrent_action(TorrentAction::GetFreeSpace( 49 | download_dir.to_string(), 50 | space_tx, 51 | )); 52 | 53 | match space_rx.await.unwrap() { 54 | Ok(free_space) => { 55 | CTX.send_update_action(UpdateAction::FreeSpace(Arc::new(free_space))); 56 | } 57 | Err(err_message) => { 58 | CTX.send_update_action(UpdateAction::Error(err_message)); 59 | } 60 | } 61 | 62 | tokio::time::sleep(Duration::from_secs(CONFIG.connection.free_space_refresh)).await; 63 | } 64 | } 65 | 66 | pub async fn torrents() { 67 | loop { 68 | let fields = vec![ 69 | TorrentGetField::Id, 70 | TorrentGetField::Name, 71 | TorrentGetField::IsFinished, 72 | TorrentGetField::IsStalled, 73 | TorrentGetField::PercentDone, 74 | TorrentGetField::UploadRatio, 75 | TorrentGetField::SizeWhenDone, 76 | TorrentGetField::Eta, 77 | TorrentGetField::RateUpload, 78 | TorrentGetField::RateDownload, 79 | TorrentGetField::Status, 80 | TorrentGetField::DownloadDir, 81 | TorrentGetField::UploadedEver, 82 | TorrentGetField::ActivityDate, 83 | TorrentGetField::AddedDate, 84 | TorrentGetField::PeersConnected, 85 | TorrentGetField::Error, 86 | TorrentGetField::ErrorString, 87 | TorrentGetField::Labels, 88 | ]; 89 | let (torrents_tx, torrents_rx) = oneshot::channel(); 90 | CTX.send_torrent_action(TorrentAction::GetTorrents(fields, torrents_tx)); 91 | 92 | match torrents_rx.await.unwrap() { 93 | Ok(torrents) => { 94 | CTX.send_update_action(UpdateAction::UpdateTorrents(torrents)); 95 | } 96 | Err(err_message) => { 97 | CTX.send_update_action(UpdateAction::Error(err_message)); 98 | } 99 | }; 100 | 101 | tokio::time::sleep(Duration::from_secs(CONFIG.connection.torrents_refresh)).await; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /rm-main/src/transmission/mod.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | pub mod fetchers; 3 | pub mod utils; 4 | 5 | pub use action::{action_handler, TorrentAction}; 6 | -------------------------------------------------------------------------------- /rm-main/src/transmission/utils.rs: -------------------------------------------------------------------------------- 1 | use rm_config::CONFIG; 2 | use transmission_rpc::{types::BasicAuth, TransClient}; 3 | 4 | pub fn new_client() -> TransClient { 5 | let user = CONFIG 6 | .connection 7 | .username 8 | .as_ref() 9 | .unwrap_or(&"".to_string()) 10 | .clone(); 11 | let password = CONFIG 12 | .connection 13 | .password 14 | .as_ref() 15 | .unwrap_or(&"".to_string()) 16 | .clone(); 17 | 18 | let auth = BasicAuth { user, password }; 19 | 20 | TransClient::with_auth(CONFIG.connection.url.clone(), auth) 21 | } 22 | -------------------------------------------------------------------------------- /rm-main/src/tui/app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{LazyLock, Mutex}; 2 | 3 | use crate::{ 4 | transmission::{self, TorrentAction}, 5 | tui::components::Component, 6 | }; 7 | 8 | use intuitils::Terminal; 9 | use rm_config::CONFIG; 10 | use rm_shared::action::{Action, UpdateAction}; 11 | 12 | use anyhow::Result; 13 | use crossterm::event::{Event, KeyCode, KeyModifiers}; 14 | use tokio::sync::{ 15 | mpsc::{self, unbounded_channel, UnboundedReceiver, UnboundedSender}, 16 | oneshot, 17 | }; 18 | 19 | use super::{ 20 | main_window::{CurrentTab, MainWindow}, 21 | tabs::torrents::SESSION_GET, 22 | }; 23 | 24 | pub static CTX: LazyLock = LazyLock::new(|| CTX_RAW.0.clone()); 25 | 26 | static CTX_RAW: LazyLock<( 27 | Ctx, 28 | Mutex< 29 | Option<( 30 | UnboundedReceiver, 31 | UnboundedReceiver, 32 | UnboundedReceiver, 33 | )>, 34 | >, 35 | )> = LazyLock::new(|| { 36 | let (ctx, act_rx, upd_rx, tor_rx) = Ctx::new(); 37 | (ctx, Mutex::new(Some((act_rx, upd_rx, tor_rx)))) 38 | }); 39 | 40 | #[derive(Clone)] 41 | pub struct Ctx { 42 | action_tx: UnboundedSender, 43 | update_tx: UnboundedSender, 44 | trans_tx: UnboundedSender, 45 | } 46 | 47 | impl Ctx { 48 | fn new() -> ( 49 | Self, 50 | UnboundedReceiver, 51 | UnboundedReceiver, 52 | UnboundedReceiver, 53 | ) { 54 | let (action_tx, action_rx) = unbounded_channel(); 55 | let (update_tx, update_rx) = unbounded_channel(); 56 | let (trans_tx, trans_rx) = unbounded_channel(); 57 | 58 | ( 59 | Self { 60 | action_tx, 61 | update_tx, 62 | trans_tx, 63 | }, 64 | action_rx, 65 | update_rx, 66 | trans_rx, 67 | ) 68 | } 69 | 70 | pub(crate) fn send_action(&self, action: Action) { 71 | self.action_tx.send(action).unwrap(); 72 | } 73 | 74 | pub(crate) fn send_torrent_action(&self, action: TorrentAction) { 75 | self.trans_tx.send(action).unwrap(); 76 | } 77 | 78 | pub(crate) fn send_update_action(&self, action: UpdateAction) { 79 | self.update_tx.send(action).unwrap(); 80 | } 81 | } 82 | 83 | pub struct App { 84 | should_quit: bool, 85 | action_rx: UnboundedReceiver, 86 | update_rx: UnboundedReceiver, 87 | main_window: MainWindow, 88 | mode: Mode, 89 | } 90 | 91 | impl App { 92 | pub async fn new() -> Result { 93 | let client = transmission::utils::new_client(); 94 | 95 | let (action_rx, update_rx, torrent_rx) = CTX_RAW 96 | .1 97 | .lock() 98 | .unwrap() 99 | .take() 100 | .expect("it wasn't taken before"); 101 | 102 | tokio::spawn(transmission::action_handler( 103 | client, 104 | torrent_rx, 105 | CTX.update_tx.clone(), 106 | )); 107 | 108 | tokio::spawn(async move { 109 | let (sess_tx, sess_rx) = oneshot::channel(); 110 | 111 | CTX.send_torrent_action(TorrentAction::GetSessionGet(sess_tx)); 112 | SESSION_GET.set(sess_rx.await.unwrap().unwrap()).unwrap(); 113 | }); 114 | 115 | Ok(Self { 116 | should_quit: false, 117 | main_window: MainWindow::new(), 118 | action_rx, 119 | update_rx, 120 | mode: Mode::Normal, 121 | }) 122 | } 123 | 124 | pub async fn run(mut self) -> Result<()> { 125 | let mut terminal = Terminal::new()?; 126 | 127 | terminal.init()?; 128 | 129 | self.render(&mut terminal)?; 130 | 131 | self.main_loop(&mut terminal).await?; 132 | 133 | terminal.exit()?; 134 | Ok(()) 135 | } 136 | 137 | async fn main_loop(mut self, terminal: &mut Terminal) -> Result<()> { 138 | let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(250)); 139 | loop { 140 | let tui_event = terminal.next(); 141 | let action = self.action_rx.recv(); 142 | let update_action = self.update_rx.recv(); 143 | let tick_action = interval.tick(); 144 | 145 | let current_tab = self.main_window.tabs.current(); 146 | 147 | tokio::select! { 148 | _ = tick_action => self.tick(), 149 | 150 | event = tui_event => { 151 | event_to_action(self.mode, current_tab, event.unwrap()); 152 | }, 153 | 154 | update_action = update_action => self.handle_update_action(update_action.unwrap()).await, 155 | 156 | action = action => { 157 | if let Some(action) = action { 158 | if action.is_render() { 159 | tokio::task::block_in_place(|| self.render(terminal).unwrap() ); 160 | } else { 161 | self.handle_user_action(action).await 162 | } 163 | } 164 | } 165 | } 166 | 167 | if self.should_quit { 168 | break Ok(()); 169 | } 170 | } 171 | } 172 | 173 | fn render(&mut self, terminal: &mut Terminal) -> Result<()> { 174 | terminal.draw(|f| { 175 | self.main_window.render(f, f.area()); 176 | })?; 177 | Ok(()) 178 | } 179 | 180 | #[must_use] 181 | async fn handle_user_action(&mut self, action: Action) { 182 | use Action as A; 183 | match &action { 184 | A::HardQuit => { 185 | self.should_quit = true; 186 | } 187 | 188 | _ => { 189 | self.main_window.handle_actions(action); 190 | } 191 | } 192 | } 193 | 194 | async fn handle_update_action(&mut self, action: UpdateAction) { 195 | match action { 196 | UpdateAction::SwitchToInputMode => { 197 | self.mode = Mode::Input; 198 | } 199 | UpdateAction::SwitchToNormalMode => { 200 | self.mode = Mode::Normal; 201 | } 202 | 203 | _ => self.main_window.handle_update_action(action), 204 | }; 205 | CTX.send_action(Action::Render); 206 | } 207 | 208 | fn tick(&mut self) { 209 | self.main_window.tick(); 210 | } 211 | } 212 | 213 | #[derive(Clone, Copy, PartialEq, Eq)] 214 | pub enum Mode { 215 | Input, 216 | Normal, 217 | } 218 | 219 | pub fn event_to_action(mode: Mode, current_tab: CurrentTab, event: Event) { 220 | // Handle CTRL+C first 221 | if let Event::Key(key_event) = event { 222 | if key_event.modifiers == KeyModifiers::CONTROL 223 | && (key_event.code == KeyCode::Char('c') || key_event.code == KeyCode::Char('C')) 224 | { 225 | CTX.send_action(Action::HardQuit); 226 | } 227 | } 228 | 229 | match event { 230 | Event::Key(key) if mode == Mode::Input => CTX.send_action(Action::Input(key)), 231 | Event::Mouse(mouse_event) => match mouse_event.kind { 232 | crossterm::event::MouseEventKind::ScrollDown => { 233 | CTX.send_action(Action::ScrollDownBy(3)) 234 | } 235 | crossterm::event::MouseEventKind::ScrollUp => CTX.send_action(Action::ScrollUpBy(3)), 236 | _ => (), 237 | }, 238 | Event::Key(key) => { 239 | let keymaps = match current_tab { 240 | CurrentTab::Torrents => [ 241 | &CONFIG.keybindings.general.map, 242 | &CONFIG.keybindings.torrents_tab.map, 243 | ], 244 | CurrentTab::Search => [ 245 | &CONFIG.keybindings.general.map, 246 | &CONFIG.keybindings.search_tab.map, 247 | ], 248 | }; 249 | 250 | let keybinding = match key.code { 251 | KeyCode::Char(e) => { 252 | let modifier = if e.is_uppercase() { 253 | KeyModifiers::NONE 254 | } else { 255 | key.modifiers 256 | }; 257 | (key.code, modifier) 258 | } 259 | _ => (key.code, key.modifiers), 260 | }; 261 | 262 | for keymap in keymaps { 263 | if let Some(action) = keymap.get(&keybinding).cloned() { 264 | CTX.send_action(action); 265 | return; 266 | } 267 | } 268 | } 269 | Event::Resize(_, _) => CTX.send_action(Action::Render), 270 | _ => (), 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /rm-main/src/tui/components/input_manager.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; 2 | use ratatui::{ 3 | prelude::*, 4 | widgets::{Clear, Paragraph}, 5 | }; 6 | use rm_config::CONFIG; 7 | use tui_input::{backend::crossterm::to_input_request, Input, InputResponse, StateChanged}; 8 | 9 | use crate::tui::components::Component; 10 | 11 | pub struct InputManager { 12 | input: Input, 13 | prompt: String, 14 | autocompletions: Vec, 15 | } 16 | 17 | impl InputManager { 18 | pub fn new(prompt: String) -> Self { 19 | Self { 20 | prompt, 21 | input: Input::default(), 22 | autocompletions: vec![], 23 | } 24 | } 25 | 26 | pub fn new_with_value(prompt: String, value: String) -> Self { 27 | Self { 28 | prompt, 29 | input: Input::default().with_value(value), 30 | autocompletions: vec![], 31 | } 32 | } 33 | 34 | pub fn autocompletions(mut self, autocompletions: Vec) -> Self { 35 | self.autocompletions = autocompletions; 36 | self 37 | } 38 | 39 | pub fn get_autocompletion(&self) -> Option<&str> { 40 | let mut autocompletion = None; 41 | for possible_autocompletion in &self.autocompletions { 42 | if possible_autocompletion.starts_with(&self.input.to_string()) { 43 | autocompletion = Some(possible_autocompletion); 44 | } 45 | } 46 | autocompletion.map(|x| x.as_str()) 47 | } 48 | 49 | pub fn apply_autocompletion(&mut self) { 50 | let completion = self.get_autocompletion().map(|str| str.to_string()); 51 | if let Some(completion) = completion { 52 | self.set_text(completion); 53 | } 54 | } 55 | 56 | pub fn visual_cursor(&self) -> usize { 57 | self.input.visual_cursor() 58 | } 59 | 60 | pub fn text(&self) -> String { 61 | self.input.to_string() 62 | } 63 | 64 | pub fn handle_key(&mut self, key: KeyEvent) -> InputResponse { 65 | if key.code == KeyCode::Tab { 66 | self.apply_autocompletion(); 67 | return Some(StateChanged { 68 | value: true, 69 | cursor: true, 70 | }); 71 | } 72 | 73 | if (self.visual_cursor() == self.text().len()) 74 | && ((key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('f')) 75 | || key.code == KeyCode::Right) 76 | { 77 | self.apply_autocompletion(); 78 | return Some(StateChanged { 79 | value: true, 80 | cursor: true, 81 | }); 82 | } 83 | 84 | let event = Event::Key(key); 85 | 86 | if let Some(req) = to_input_request(&event) { 87 | self.input.handle(req) 88 | } else { 89 | None 90 | } 91 | } 92 | 93 | pub fn set_prompt(&mut self, new_prompt: impl Into) { 94 | self.prompt = new_prompt.into(); 95 | } 96 | 97 | pub fn set_text(&mut self, new_text: impl Into) { 98 | self.input = self.input.clone().with_value(new_text.into()); 99 | } 100 | } 101 | 102 | impl Component for InputManager { 103 | fn render(&mut self, f: &mut Frame, rect: Rect) { 104 | f.render_widget(Clear, rect); 105 | 106 | let input = self.input.to_string(); 107 | let spans = vec![ 108 | Span::styled( 109 | self.prompt.as_str(), 110 | Style::default().fg(CONFIG.general.accent_color), 111 | ), 112 | Span::styled(self.text(), Style::default().fg(Color::White)), 113 | ]; 114 | 115 | let paragraph = Paragraph::new(Line::from(spans)); 116 | f.render_widget(paragraph, rect); 117 | 118 | let prefix_len = 119 | u16::try_from(self.prompt.len() + self.text().len() - input.len()).unwrap(); 120 | if let Some(completion) = self.get_autocompletion() { 121 | let already_typed = u16::try_from(input.chars().count()).unwrap(); 122 | let span = Span::from(&completion[already_typed as usize..]).dark_gray(); 123 | let completion_rect = rect.inner(Margin { 124 | horizontal: prefix_len + already_typed, 125 | vertical: 0, 126 | }); 127 | f.render_widget(span, completion_rect); 128 | } 129 | 130 | let cursor_offset = u16::try_from(self.input.visual_cursor()).unwrap() + prefix_len; 131 | let cursor_position = Position { 132 | x: rect.x + cursor_offset, 133 | y: rect.y, 134 | }; 135 | f.set_cursor_position(cursor_position); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /rm-main/src/tui/components/misc.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Margin, Rect}, 3 | style::{Style, Styled, Stylize}, 4 | widgets::{ 5 | block::{Position, Title}, 6 | Block, BorderType, 7 | }, 8 | }; 9 | use rm_config::CONFIG; 10 | 11 | use crate::tui::main_window::centered_rect; 12 | 13 | pub fn popup_close_button_highlight() -> Title<'static> { 14 | Title::from(" [ CLOSE ] ".fg(CONFIG.general.accent_color).bold()) 15 | .alignment(Alignment::Right) 16 | .position(Position::Bottom) 17 | } 18 | 19 | pub fn popup_close_button() -> Title<'static> { 20 | Title::from(" [CLOSE] ".bold()) 21 | .alignment(Alignment::Right) 22 | .position(Position::Bottom) 23 | } 24 | 25 | pub fn popup_block(title: &str) -> Block { 26 | let title_style = Style::default().fg(CONFIG.general.accent_color); 27 | Block::bordered() 28 | .border_type(BorderType::Rounded) 29 | .title(Title::from(title.set_style(title_style))) 30 | } 31 | 32 | pub fn popup_block_with_close_highlight(title: &str) -> Block { 33 | popup_block(title).title(popup_close_button_highlight()) 34 | } 35 | 36 | pub fn popup_rects(rect: Rect, percent_x: u16, percent_y: u16) -> (Rect, Rect, Rect) { 37 | let popup_rect = centered_rect(rect, percent_x, percent_y); 38 | let block_rect = popup_rect.inner(Margin::new(1, 1)); 39 | let text_rect = block_rect.inner(Margin::new(3, 2)); 40 | (popup_rect, block_rect, text_rect) 41 | } 42 | 43 | pub fn keybinding_style() -> Style { 44 | Style::default() 45 | .underlined() 46 | .underline_color(CONFIG.general.accent_color) 47 | } 48 | -------------------------------------------------------------------------------- /rm-main/src/tui/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod input_manager; 2 | mod misc; 3 | mod table; 4 | 5 | pub use input_manager::InputManager; 6 | pub use misc::{ 7 | keybinding_style, popup_block, popup_block_with_close_highlight, popup_close_button, 8 | popup_close_button_highlight, popup_rects, 9 | }; 10 | pub use table::GenericTable; 11 | 12 | use ratatui::prelude::*; 13 | 14 | use rm_shared::action::Action; 15 | use rm_shared::action::UpdateAction; 16 | 17 | #[derive(Clone, Copy, PartialEq, Eq)] 18 | pub enum ComponentAction { 19 | Nothing, 20 | Quit, 21 | } 22 | 23 | impl ComponentAction { 24 | pub fn is_quit(self) -> bool { 25 | self == Self::Quit 26 | } 27 | } 28 | 29 | pub trait Component { 30 | fn handle_actions(&mut self, _action: Action) -> ComponentAction { 31 | ComponentAction::Nothing 32 | } 33 | 34 | fn handle_update_action(&mut self, action: UpdateAction) { 35 | let _action = action; 36 | } 37 | 38 | fn render(&mut self, f: &mut Frame, rect: Rect) { 39 | let _f = f; 40 | let _rect = rect; 41 | } 42 | 43 | fn tick(&mut self) {} 44 | } 45 | -------------------------------------------------------------------------------- /rm-main/src/tui/components/table.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use ratatui::widgets::TableState; 4 | 5 | pub struct GenericTable { 6 | pub state: RefCell, 7 | pub items: Vec, 8 | pub overwritten_len: RefCell>, 9 | } 10 | 11 | impl GenericTable { 12 | pub fn new(items: Vec) -> Self { 13 | Self { 14 | state: RefCell::new(TableState::new().with_selected(Some(0))), 15 | items, 16 | overwritten_len: RefCell::new(None), 17 | } 18 | } 19 | 20 | pub fn get_len(&self) -> usize { 21 | self.overwritten_len 22 | .borrow() 23 | .map_or(self.items.len(), |len| len) 24 | } 25 | 26 | pub fn overwrite_len(&self, len: usize) { 27 | *self.overwritten_len.borrow_mut() = Some(len); 28 | } 29 | 30 | pub fn set_items(&mut self, items: Vec) { 31 | self.items = items; 32 | } 33 | 34 | pub fn current_item(&self) -> Option { 35 | let items = &self.items; 36 | let selected = self.state.borrow().selected()?; 37 | items.get(selected).cloned() 38 | } 39 | 40 | pub fn next(&mut self) { 41 | if self.get_len() == 0 { 42 | return; 43 | } 44 | 45 | let mut state = self.state.borrow_mut(); 46 | if let Some(curr) = state.selected() { 47 | let last_idx = self.get_len() - 1; 48 | if curr == last_idx { 49 | state.select(Some(0)); 50 | } else { 51 | state.select(Some(curr + 1)); 52 | } 53 | } else { 54 | state.select(Some(0)); 55 | } 56 | } 57 | 58 | pub fn previous(&mut self) { 59 | if self.get_len() == 0 { 60 | return; 61 | } 62 | 63 | let mut state = self.state.borrow_mut(); 64 | 65 | if let Some(curr) = state.selected() { 66 | let last_idx = self.get_len() - 1; 67 | if curr == 0 { 68 | state.select(Some(last_idx)); 69 | } else { 70 | state.select(Some(curr - 1)); 71 | } 72 | } else { 73 | state.select(Some(0)); 74 | } 75 | } 76 | 77 | pub fn scroll_down_by(&mut self, amount: usize) { 78 | if self.items.is_empty() { 79 | return; 80 | } 81 | 82 | let mut state = self.state.borrow_mut(); 83 | let new_selection = state.selected().unwrap_or_default() + amount; 84 | 85 | if new_selection > self.get_len() { 86 | state.select(Some(self.get_len().saturating_sub(1))); 87 | } else { 88 | state.select(Some(new_selection)); 89 | }; 90 | } 91 | 92 | pub fn scroll_up_by(&mut self, amount: usize) { 93 | let mut state = self.state.borrow_mut(); 94 | let selected = state.selected().unwrap_or_default(); 95 | 96 | if amount >= selected { 97 | state.select(Some(0)); 98 | } else { 99 | state.select(Some(selected - amount)); 100 | } 101 | } 102 | 103 | pub fn select_first(&mut self) { 104 | self.state.borrow_mut().select_first(); 105 | } 106 | 107 | pub fn select_last(&mut self) { 108 | if self.items.is_empty() { 109 | return; 110 | } 111 | 112 | let mut state = self.state.borrow_mut(); 113 | state.select(Some(self.items.len() - 1)); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /rm-main/src/tui/global_popups/error.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | prelude::*, 3 | widgets::{Block, BorderType, Clear, Paragraph, Wrap}, 4 | }; 5 | 6 | use rm_shared::action::Action; 7 | 8 | use crate::tui::components::{popup_rects, Component, ComponentAction}; 9 | 10 | #[derive(Debug, Clone, PartialEq, Eq)] 11 | pub struct ErrorPopup { 12 | // TODO: make sure that title always has padding 13 | title: String, 14 | message: String, 15 | error: String, 16 | } 17 | 18 | impl ErrorPopup { 19 | pub fn new(title: String, message: String, error: String) -> Self { 20 | Self { 21 | title, 22 | message, 23 | error, 24 | } 25 | } 26 | } 27 | 28 | impl Component for ErrorPopup { 29 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 30 | match action { 31 | _ if action.is_soft_quit() => ComponentAction::Quit, 32 | Action::Confirm => ComponentAction::Quit, 33 | _ => ComponentAction::Nothing, 34 | } 35 | } 36 | 37 | fn render(&mut self, f: &mut Frame, _rect: Rect) { 38 | let (popup_rect, block_rect, text_rect) = popup_rects(f.area(), 50, 50); 39 | 40 | let button_rect = Layout::vertical([Constraint::Percentage(100), Constraint::Length(1)]) 41 | .split(text_rect)[1]; 42 | 43 | let button = Paragraph::new("[ OK ]").bold().right_aligned(); 44 | 45 | let block = Block::bordered() 46 | .border_type(BorderType::Rounded) 47 | .title_style(Style::new().red()) 48 | .title(format!(" {} ", self.title)); 49 | 50 | let lines = vec![ 51 | Line::from(self.message.as_str()), 52 | Line::default(), 53 | Line::from(self.error.as_str()).red().on_black(), 54 | ]; 55 | 56 | let error_message = Paragraph::new(lines).wrap(Wrap { trim: false }); 57 | 58 | f.render_widget(Clear, popup_rect); 59 | f.render_widget(block, block_rect); 60 | f.render_widget(error_message, text_rect); 61 | f.render_widget(button, button_rect); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /rm-main/src/tui/global_popups/help.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use crossterm::event::KeyCode; 4 | use ratatui::{ 5 | prelude::*, 6 | widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, 7 | }; 8 | 9 | use rm_config::CONFIG; 10 | use rm_shared::action::Action; 11 | 12 | use crate::tui::{ 13 | app::CTX, 14 | components::{popup_block_with_close_highlight, popup_rects, Component, ComponentAction}, 15 | }; 16 | 17 | macro_rules! add_line { 18 | ($lines:expr, $key:expr, $description:expr) => { 19 | $lines.push(Line::from(vec![ 20 | Span::styled($key, Style::default().bold()), 21 | " - ".into(), 22 | $description.into(), 23 | ])); 24 | }; 25 | } 26 | 27 | pub struct HelpPopup { 28 | scroll: Option, 29 | global_keys: Vec<(String, &'static str)>, 30 | torrent_keys: Vec<(String, &'static str)>, 31 | search_keys: Vec<(String, &'static str)>, 32 | max_key_len: usize, 33 | max_line_len: usize, 34 | } 35 | 36 | struct Scroll { 37 | state: ScrollbarState, 38 | position: u16, 39 | position_max: u16, 40 | } 41 | 42 | impl Scroll { 43 | fn new() -> Self { 44 | Self { 45 | state: ScrollbarState::default(), 46 | position: 0, 47 | position_max: 0, 48 | } 49 | } 50 | } 51 | 52 | impl HelpPopup { 53 | pub fn new() -> Self { 54 | fn override_keycode(key: KeyCode) -> Option> { 55 | match key { 56 | KeyCode::Left => Some(CONFIG.icons.arrow_left.as_str().into()), 57 | KeyCode::Right => Some(CONFIG.icons.arrow_right.as_str().into()), 58 | KeyCode::Up => Some(CONFIG.icons.arrow_up.as_str().into()), 59 | KeyCode::Down => Some(CONFIG.icons.arrow_down.as_str().into()), 60 | _ => None, 61 | } 62 | } 63 | 64 | let mut max_key_len = 0; 65 | let mut max_line_len = 0; 66 | let global_keys = CONFIG 67 | .keybindings 68 | .general 69 | .get_help_repr_with_override(override_keycode); 70 | let torrent_keys = CONFIG 71 | .keybindings 72 | .torrents_tab 73 | .get_help_repr_with_override(override_keycode); 74 | let search_keys = CONFIG 75 | .keybindings 76 | .search_tab 77 | .get_help_repr_with_override(override_keycode); 78 | 79 | let mut calc_max_lens = |keys: &[(String, &'static str)]| { 80 | for (keycode, desc) in keys { 81 | let key_len = keycode.chars().count(); 82 | let desc_len = desc.chars().count(); 83 | let line_len = key_len + desc_len + 3; 84 | if key_len > max_key_len { 85 | max_key_len = key_len; 86 | } 87 | 88 | if line_len > max_line_len { 89 | max_line_len = line_len; 90 | } 91 | } 92 | }; 93 | 94 | calc_max_lens(&global_keys); 95 | calc_max_lens(&torrent_keys); 96 | calc_max_lens(&search_keys); 97 | 98 | debug_assert!(max_key_len > 0); 99 | debug_assert!(max_line_len > 0); 100 | Self { 101 | scroll: None, 102 | global_keys, 103 | torrent_keys, 104 | search_keys, 105 | max_key_len, 106 | max_line_len, 107 | } 108 | } 109 | 110 | fn scroll_down(&mut self) -> ComponentAction { 111 | if let Some(scroll) = &mut self.scroll { 112 | if scroll.position >= scroll.position_max { 113 | return ComponentAction::Nothing; 114 | } 115 | 116 | scroll.position = scroll.position.saturating_add(1); 117 | scroll.state.next(); 118 | CTX.send_action(Action::Render); 119 | } 120 | ComponentAction::Nothing 121 | } 122 | 123 | fn scroll_up(&mut self) -> ComponentAction { 124 | if let Some(scroll) = &mut self.scroll { 125 | scroll.position = scroll.position.saturating_sub(1); 126 | scroll.state.prev(); 127 | CTX.send_action(Action::Render); 128 | } 129 | ComponentAction::Nothing 130 | } 131 | 132 | fn scroll_to_end(&mut self) -> ComponentAction { 133 | if let Some(scroll) = &mut self.scroll { 134 | scroll.position = scroll.position_max; 135 | scroll.state.last(); 136 | CTX.send_action(Action::Render); 137 | } 138 | ComponentAction::Nothing 139 | } 140 | 141 | fn scroll_to_home(&mut self) -> ComponentAction { 142 | if let Some(scroll) = &mut self.scroll { 143 | scroll.position = 0; 144 | scroll.state.first(); 145 | CTX.send_action(Action::Render); 146 | } 147 | ComponentAction::Nothing 148 | } 149 | } 150 | 151 | impl Component for HelpPopup { 152 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 153 | match action { 154 | action if action.is_soft_quit() => ComponentAction::Quit, 155 | Action::Confirm | Action::ShowHelp => ComponentAction::Quit, 156 | Action::Up | Action::ScrollUpBy(_) => self.scroll_up(), 157 | Action::Down | Action::ScrollDownBy(_) => self.scroll_down(), 158 | Action::ScrollUpPage | Action::Home => self.scroll_to_home(), 159 | Action::ScrollDownPage | Action::End => self.scroll_to_end(), 160 | _ => ComponentAction::Nothing, 161 | } 162 | } 163 | 164 | fn render(&mut self, f: &mut Frame, rect: Rect) { 165 | let (popup_rect, block_rect, text_rect) = popup_rects(rect, 75, 75); 166 | 167 | let block = popup_block_with_close_highlight(" Help "); 168 | 169 | let to_pad_additionally = (text_rect 170 | .width 171 | .saturating_sub(self.max_line_len.try_into().unwrap()) 172 | / 2) 173 | .saturating_sub(6); 174 | 175 | let pad_amount = usize::from(to_pad_additionally) + self.max_key_len; 176 | 177 | let padded_keys = |keys: &Vec<(String, &'static str)>| -> Vec<(String, &'static str)> { 178 | let mut new_keys = vec![]; 179 | for key in keys { 180 | let mut keycode = key.0.clone(); 181 | let mut how_much_to_pad = pad_amount.saturating_sub(key.0.chars().count()); 182 | while how_much_to_pad > 0 { 183 | keycode.insert(0, ' '); 184 | how_much_to_pad -= 1; 185 | } 186 | new_keys.push((keycode, key.1)); 187 | } 188 | new_keys 189 | }; 190 | 191 | let global_keys = padded_keys(&mut self.global_keys); 192 | let torrent_keys = padded_keys(&mut self.torrent_keys); 193 | let search_keys = padded_keys(&mut self.search_keys); 194 | 195 | let mut lines = vec![]; 196 | 197 | let insert_keys = |lines: &mut Vec, keys: Vec<(String, &'static str)>| { 198 | lines.push(Line::default()); 199 | for (keycode, desc) in keys { 200 | add_line!(lines, keycode, *desc); 201 | } 202 | lines.push(Line::default()); 203 | }; 204 | 205 | lines.push( 206 | Line::from(vec![Span::styled( 207 | "Global Keybindings", 208 | Style::default().bold().underlined(), 209 | )]) 210 | .centered(), 211 | ); 212 | 213 | insert_keys(&mut lines, global_keys); 214 | 215 | lines.push( 216 | Line::from(vec![Span::styled( 217 | "Torrents Tab", 218 | Style::default().bold().underlined(), 219 | )]) 220 | .centered(), 221 | ); 222 | 223 | insert_keys(&mut lines, torrent_keys); 224 | 225 | lines.push( 226 | Line::from(vec![Span::styled( 227 | "Search Tab", 228 | Style::default().bold().underlined(), 229 | )]) 230 | .centered(), 231 | ); 232 | 233 | insert_keys(&mut lines, search_keys); 234 | 235 | let help_text = Text::from(lines); 236 | 237 | if text_rect.height <= u16::try_from(help_text.lines.len()).unwrap() { 238 | if self.scroll.is_none() { 239 | self.scroll = Some(Scroll::new()); 240 | } 241 | } else { 242 | self.scroll = None; 243 | } 244 | 245 | if let Some(scroll) = &mut self.scroll { 246 | if text_rect.height < 5 { 247 | scroll.position_max = u16::try_from(help_text.lines.len()).unwrap(); 248 | } else { 249 | scroll.position_max = u16::try_from(help_text.lines.len() - 5).unwrap(); 250 | } 251 | 252 | scroll.state = scroll 253 | .state 254 | .content_length(scroll.position_max.into()) 255 | .viewport_content_length(text_rect.height as usize); 256 | } 257 | 258 | let help_paragraph = { 259 | let paragraph = Paragraph::new(help_text); 260 | if let Some(scroll) = &self.scroll { 261 | paragraph 262 | .scroll((scroll.position, 0)) 263 | .block(Block::new().borders(Borders::RIGHT)) 264 | } else { 265 | paragraph 266 | } 267 | }; 268 | 269 | f.render_widget(Clear, popup_rect); 270 | f.render_widget(block, block_rect); 271 | f.render_widget(help_paragraph, text_rect); 272 | 273 | if let Some(scroll) = &mut self.scroll { 274 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 275 | .thumb_style(Style::default().fg(CONFIG.general.accent_color)); 276 | 277 | f.render_stateful_widget( 278 | scrollbar, 279 | text_rect.inner(Margin { 280 | vertical: 1, 281 | horizontal: 0, 282 | }), 283 | &mut scroll.state, 284 | ) 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /rm-main/src/tui/global_popups/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod help; 3 | 4 | use ratatui::prelude::*; 5 | 6 | pub use error::ErrorPopup; 7 | pub use help::HelpPopup; 8 | 9 | use rm_shared::action::Action; 10 | 11 | use super::{ 12 | app::CTX, 13 | components::{Component, ComponentAction}, 14 | }; 15 | 16 | pub(super) struct GlobalPopupManager { 17 | pub error_popup: Option, 18 | pub help_popup: Option, 19 | } 20 | 21 | impl GlobalPopupManager { 22 | pub fn new() -> Self { 23 | Self { 24 | error_popup: None, 25 | help_popup: None, 26 | } 27 | } 28 | 29 | pub const fn needs_action(&self) -> bool { 30 | self.error_popup.is_some() || self.help_popup.is_some() 31 | } 32 | 33 | fn toggle_help(&mut self) { 34 | if self.help_popup.is_some() { 35 | self.help_popup = None; 36 | } else { 37 | self.help_popup = Some(HelpPopup::new()); 38 | } 39 | } 40 | 41 | fn handle_popups(&mut self, action: Action) { 42 | if let Some(popup) = &mut self.error_popup { 43 | if popup.handle_actions(action).is_quit() { 44 | self.error_popup = None; 45 | CTX.send_action(Action::Render); 46 | } 47 | } else if let Some(popup) = &mut self.help_popup { 48 | if popup.handle_actions(action).is_quit() { 49 | self.help_popup = None; 50 | CTX.send_action(Action::Render); 51 | } 52 | } 53 | } 54 | } 55 | 56 | impl Component for GlobalPopupManager { 57 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 58 | use Action as A; 59 | if action == A::ShowHelp { 60 | self.toggle_help(); 61 | CTX.send_action(Action::Render); 62 | return ComponentAction::Nothing; 63 | } 64 | 65 | self.handle_popups(action); 66 | ComponentAction::Nothing 67 | } 68 | 69 | fn render(&mut self, f: &mut Frame, rect: Rect) { 70 | if let Some(popup) = &mut self.error_popup { 71 | popup.render(f, rect) 72 | } else if let Some(popup) = &mut self.help_popup { 73 | popup.render(f, rect); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /rm-main/src/tui/main_window.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use intui_tabs::{Tabs, TabsState}; 4 | use ratatui::prelude::*; 5 | 6 | use rm_config::CONFIG; 7 | use rm_shared::action::{Action, UpdateAction}; 8 | 9 | use crate::tui::app::CTX; 10 | 11 | use super::{ 12 | app, 13 | components::{Component, ComponentAction}, 14 | global_popups::{ErrorPopup, GlobalPopupManager}, 15 | tabs::{search::SearchTab, torrents::TorrentsTab}, 16 | }; 17 | 18 | #[derive(Clone, Copy, PartialEq, Eq)] 19 | pub enum CurrentTab { 20 | Torrents = 0, 21 | Search, 22 | } 23 | 24 | impl Default for CurrentTab { 25 | fn default() -> Self { 26 | CurrentTab::Torrents 27 | } 28 | } 29 | 30 | impl Display for CurrentTab { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | match self { 33 | CurrentTab::Torrents => write!(f, "Torrents"), 34 | CurrentTab::Search => write!(f, "Search"), 35 | } 36 | } 37 | } 38 | 39 | pub struct MainWindow { 40 | pub tabs: intui_tabs::TabsState, 41 | torrents_tab: TorrentsTab, 42 | search_tab: SearchTab, 43 | global_popup_manager: GlobalPopupManager, 44 | } 45 | 46 | impl MainWindow { 47 | pub fn new() -> Self { 48 | Self { 49 | tabs: TabsState::new(vec![CurrentTab::Torrents, CurrentTab::Search]), 50 | torrents_tab: TorrentsTab::new(), 51 | search_tab: SearchTab::new(), 52 | global_popup_manager: GlobalPopupManager::new(), 53 | } 54 | } 55 | } 56 | 57 | impl Component for MainWindow { 58 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 59 | use Action as A; 60 | 61 | match action { 62 | A::ShowHelp => { 63 | self.global_popup_manager.handle_actions(action); 64 | } 65 | _ if self.global_popup_manager.needs_action() => { 66 | self.global_popup_manager.handle_actions(action); 67 | } 68 | A::Left | A::ChangeTab(1) => { 69 | if self.tabs.current() != CurrentTab::Torrents { 70 | self.tabs.set(1); 71 | CTX.send_action(Action::Render); 72 | } 73 | } 74 | A::Right | A::ChangeTab(2) => { 75 | if self.tabs.current() != CurrentTab::Search { 76 | self.tabs.set(2); 77 | CTX.send_action(Action::Render); 78 | } 79 | } 80 | _ if self.tabs.current() == CurrentTab::Torrents => { 81 | self.torrents_tab.handle_actions(action); 82 | } 83 | _ if self.tabs.current() == CurrentTab::Search => { 84 | self.search_tab.handle_actions(action); 85 | } 86 | _ => unreachable!(), 87 | }; 88 | 89 | ComponentAction::Nothing 90 | } 91 | 92 | fn handle_update_action(&mut self, action: UpdateAction) { 93 | match action { 94 | UpdateAction::Error(err) => { 95 | let error_popup = ErrorPopup::new(err.title, err.description, err.source); 96 | self.global_popup_manager.error_popup = Some(error_popup); 97 | } 98 | action if self.tabs.current() == CurrentTab::Torrents => { 99 | self.torrents_tab.handle_update_action(action) 100 | } 101 | action if self.tabs.current() == CurrentTab::Search => { 102 | self.search_tab.handle_update_action(action) 103 | } 104 | _ => unreachable!(), 105 | } 106 | } 107 | 108 | fn tick(&mut self) { 109 | self.search_tab.tick(); 110 | self.torrents_tab.tick(); 111 | } 112 | 113 | fn render(&mut self, f: &mut Frame, rect: Rect) { 114 | let [top_bar, main_window] = 115 | Layout::vertical([Constraint::Length(1), Constraint::Percentage(100)]).areas(rect); 116 | 117 | let tabs = Tabs::new() 118 | .beginner_mode(CONFIG.general.beginner_mode) 119 | .color(CONFIG.general.accent_color); 120 | f.render_stateful_widget(tabs, top_bar, &mut self.tabs); 121 | 122 | match self.tabs.current() { 123 | CurrentTab::Torrents => self.torrents_tab.render(f, main_window), 124 | CurrentTab::Search => self.search_tab.render(f, main_window), 125 | } 126 | 127 | self.global_popup_manager.render(f, f.area()); 128 | } 129 | } 130 | 131 | pub fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect { 132 | let popup_layout = Layout::vertical([ 133 | Constraint::Percentage((100 - percent_y) / 2), 134 | Constraint::Percentage(percent_y), 135 | Constraint::Percentage((100 - percent_y) / 2), 136 | ]) 137 | .split(r); 138 | 139 | Layout::horizontal([ 140 | Constraint::Percentage((100 - percent_x) / 2), 141 | Constraint::Percentage(percent_x), 142 | Constraint::Percentage((100 - percent_x) / 2), 143 | ]) 144 | .split(popup_layout[1])[1] 145 | } 146 | -------------------------------------------------------------------------------- /rm-main/src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | mod components; 3 | mod global_popups; 4 | pub mod main_window; 5 | pub mod tabs; 6 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod search; 2 | pub mod torrents; 3 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/search/bottom_bar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Rect, 3 | style::{Style, Stylize}, 4 | text::{Line, Span}, 5 | widgets::Paragraph, 6 | Frame, 7 | }; 8 | use rm_config::{keymap::SearchAction, CONFIG}; 9 | use rm_shared::action::{Action, UpdateAction}; 10 | use throbber_widgets_tui::ThrobberState; 11 | 12 | use crate::tui::{ 13 | app::CTX, 14 | components::{keybinding_style, Component, ComponentAction}, 15 | tabs::torrents::tasks, 16 | }; 17 | 18 | use super::{ConfiguredProvider, ProviderState}; 19 | 20 | pub struct BottomBar { 21 | pub search_state: SearchState, 22 | pub task: Option, 23 | } 24 | 25 | impl BottomBar { 26 | pub fn new(providers: &Vec) -> Self { 27 | Self { 28 | search_state: SearchState::new(providers), 29 | task: None, 30 | } 31 | } 32 | 33 | pub fn add_magnet(&mut self, magnet: impl Into) { 34 | self.task = Some(tasks::AddMagnet::new().magnet(magnet)); 35 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 36 | } 37 | 38 | pub fn requires_input(&self) -> bool { 39 | self.task.is_some() 40 | } 41 | } 42 | 43 | impl Component for BottomBar { 44 | fn render(&mut self, f: &mut Frame, rect: Rect) { 45 | if let Some(task) = &mut self.task { 46 | task.render(f, rect); 47 | } else { 48 | self.search_state.render(f, rect); 49 | } 50 | } 51 | 52 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 53 | if let Some(task) = &mut self.task { 54 | if task.handle_actions(action).is_quit() { 55 | self.task = None; 56 | CTX.send_update_action(UpdateAction::SwitchToNormalMode); 57 | }; 58 | } 59 | 60 | ComponentAction::Nothing 61 | } 62 | 63 | fn handle_update_action(&mut self, action: UpdateAction) { 64 | self.search_state.handle_update_action(action); 65 | } 66 | 67 | fn tick(&mut self) { 68 | self.search_state.tick(); 69 | } 70 | } 71 | 72 | pub struct SearchState { 73 | stage: SearchStage, 74 | providers_finished: u8, 75 | providers_errored: u8, 76 | providers_count: u8, 77 | } 78 | 79 | #[derive(Clone)] 80 | enum SearchStage { 81 | Nothing, 82 | NoResults, 83 | Searching(ThrobberState), 84 | Found(usize), 85 | } 86 | 87 | impl SearchState { 88 | fn new(providers: &Vec) -> Self { 89 | let mut providers_count = 0u8; 90 | for provider in providers { 91 | if provider.enabled { 92 | providers_count += 1; 93 | } 94 | } 95 | 96 | Self { 97 | stage: SearchStage::Nothing, 98 | providers_errored: 0, 99 | providers_finished: 0, 100 | providers_count, 101 | } 102 | } 103 | 104 | pub fn update_counts(&mut self, providers: &Vec) { 105 | let mut providers_finished = 0; 106 | let mut providers_errored = 0; 107 | for provider in providers { 108 | if provider.enabled { 109 | if matches!(provider.provider_state, ProviderState::Found(_)) { 110 | providers_finished += 1; 111 | } else if matches!(provider.provider_state, ProviderState::Error(_)) { 112 | providers_errored += 1; 113 | } 114 | } 115 | } 116 | 117 | self.providers_finished = providers_finished; 118 | self.providers_errored = providers_errored; 119 | } 120 | 121 | pub fn searching(&mut self) { 122 | self.stage = SearchStage::Searching(ThrobberState::default()); 123 | } 124 | 125 | pub fn not_found(&mut self) { 126 | self.stage = SearchStage::NoResults; 127 | } 128 | 129 | pub fn found(&mut self, count: usize) { 130 | self.stage = SearchStage::Found(count); 131 | } 132 | } 133 | 134 | impl Component for SearchState { 135 | fn handle_update_action(&mut self, action: UpdateAction) { 136 | if let UpdateAction::SearchStarted = action { 137 | self.searching(); 138 | }; 139 | } 140 | 141 | fn render(&mut self, f: &mut Frame, rect: Rect) { 142 | let append_key_info = |line: &mut Line| { 143 | let providers_key = CONFIG 144 | .keybindings 145 | .search_tab 146 | .get_keys_for_action_joined(SearchAction::ShowProvidersInfo); 147 | if let Some(key) = providers_key { 148 | line.push_span(Span::raw("Press ")); 149 | line.push_span(Span::styled(key, keybinding_style())); 150 | line.push_span(Span::raw(" for details.")) 151 | } 152 | }; 153 | 154 | match &mut self.stage { 155 | SearchStage::Nothing => (), 156 | SearchStage::Searching(ref mut state) => { 157 | let label = format!( 158 | "Searching... {}/{}", 159 | self.providers_finished, self.providers_count 160 | ); 161 | let default_throbber = throbber_widgets_tui::Throbber::default() 162 | .label(label) 163 | .style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow)); 164 | f.render_stateful_widget(default_throbber.clone(), rect, state); 165 | } 166 | SearchStage::NoResults => { 167 | let mut line = Line::default(); 168 | line.push_span(Span::styled("", Style::default().red())); 169 | line.push_span(Span::raw(" No results. ")); 170 | append_key_info(&mut line); 171 | let paragraph = Paragraph::new(line); 172 | f.render_widget(paragraph, rect); 173 | } 174 | SearchStage::Found(count) => { 175 | let mut line = Line::default(); 176 | line.push_span(Span::styled("", Style::default().green())); 177 | line.push_span(Span::raw(format!(" Found {count}. "))); 178 | append_key_info(&mut line); 179 | let paragraph = Paragraph::new(line); 180 | f.render_widget(paragraph, rect); 181 | } 182 | } 183 | } 184 | 185 | fn tick(&mut self) { 186 | if let SearchStage::Searching(state) = &mut self.stage { 187 | state.calc_next(); 188 | CTX.send_action(Action::Render); 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/search/popups/mod.rs: -------------------------------------------------------------------------------- 1 | mod providers; 2 | 3 | use crate::tui::app::CTX; 4 | use crate::tui::components::Component; 5 | use crate::tui::components::ComponentAction; 6 | use providers::ProvidersPopup; 7 | use ratatui::prelude::*; 8 | use ratatui::Frame; 9 | use rm_shared::action::Action; 10 | 11 | use super::ConfiguredProvider; 12 | 13 | pub struct PopupManager { 14 | pub current_popup: Option, 15 | } 16 | 17 | pub enum CurrentPopup { 18 | Providers(ProvidersPopup), 19 | } 20 | 21 | impl PopupManager { 22 | pub const fn new() -> Self { 23 | Self { 24 | current_popup: None, 25 | } 26 | } 27 | 28 | pub const fn is_showing_popup(&self) -> bool { 29 | self.current_popup.is_some() 30 | } 31 | 32 | fn show_popup(&mut self, popup: CurrentPopup) { 33 | self.current_popup = Some(popup); 34 | } 35 | 36 | pub fn show_providers_info_popup(&mut self, providers: Vec) { 37 | self.show_popup(CurrentPopup::Providers(ProvidersPopup::new(providers))); 38 | } 39 | 40 | pub fn close_popup(&mut self) { 41 | self.current_popup = None; 42 | } 43 | } 44 | 45 | impl Component for PopupManager { 46 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 47 | if let Some(current_popup) = &mut self.current_popup { 48 | match current_popup { 49 | CurrentPopup::Providers(popup) => { 50 | if popup.handle_actions(action).is_quit() { 51 | self.close_popup(); 52 | CTX.send_action(Action::Render); 53 | } 54 | } 55 | } 56 | } 57 | 58 | ComponentAction::Nothing 59 | } 60 | 61 | fn render(&mut self, f: &mut Frame, rect: Rect) { 62 | if let Some(popup) = &mut self.current_popup { 63 | match popup { 64 | CurrentPopup::Providers(popup) => popup.render(f, rect), 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/search/popups/providers.rs: -------------------------------------------------------------------------------- 1 | use magnetease::ProviderCategory; 2 | use ratatui::{ 3 | layout::Constraint, 4 | prelude::Rect, 5 | style::{Style, Styled, Stylize}, 6 | text::Line, 7 | widgets::{block::Title, Block, BorderType, Clear, Row, Table}, 8 | Frame, 9 | }; 10 | use rm_config::CONFIG; 11 | use rm_shared::action::Action; 12 | 13 | use crate::tui::{ 14 | components::{popup_close_button_highlight, popup_rects, Component, ComponentAction}, 15 | tabs::search::{ConfiguredProvider, ProviderState}, 16 | }; 17 | 18 | pub struct ProvidersPopup { 19 | providers: Vec, 20 | } 21 | 22 | impl From<&ConfiguredProvider> for Row<'_> { 23 | fn from(value: &ConfiguredProvider) -> Self { 24 | let mut name: Line = match value.provider_state { 25 | _ if !value.enabled => format!(" {} ", CONFIG.icons.provider_disabled).into(), 26 | ProviderState::Idle => format!(" {} ", CONFIG.icons.idle).yellow().into(), 27 | ProviderState::Searching => format!(" {} ", CONFIG.icons.searching).yellow().into(), 28 | ProviderState::Found(_) => format!(" {} ", CONFIG.icons.success).green().into(), 29 | ProviderState::Error(_) | ProviderState::Timeout => { 30 | format!(" {} ", CONFIG.icons.failure).red().into() 31 | } 32 | }; 33 | 34 | name.push_span(value.provider.name()); 35 | 36 | let category = match value.provider.category() { 37 | ProviderCategory::General => { 38 | format!("{} General", CONFIG.icons.provider_category_general) 39 | } 40 | ProviderCategory::Anime => { 41 | format!("{} Anime", CONFIG.icons.provider_category_anime) 42 | } 43 | }; 44 | 45 | let url: Line = format!("({})", value.provider.display_url()).into(); 46 | 47 | let status: Line = match &value.provider_state { 48 | _ if !value.enabled => "Disabled".into(), 49 | ProviderState::Idle => "Idle".into(), 50 | ProviderState::Searching => format!("{} Searching...", CONFIG.icons.searching) 51 | .yellow() 52 | .into(), 53 | ProviderState::Found(count) => { 54 | let mut line = Line::default(); 55 | line.push_span("Found("); 56 | line.push_span(count.to_string().green()); 57 | line.push_span(")"); 58 | line 59 | } 60 | ProviderState::Timeout => "Timeout".red().into(), 61 | ProviderState::Error(e) => e.to_string().red().into(), 62 | }; 63 | 64 | let row = Row::new(vec![name, url, category.into(), status]); 65 | 66 | if value.enabled { 67 | row 68 | } else { 69 | row.dark_gray() 70 | } 71 | } 72 | } 73 | 74 | impl ProvidersPopup { 75 | pub const fn new(providers: Vec) -> Self { 76 | Self { providers } 77 | } 78 | 79 | pub fn update_providers(&mut self, providers: Vec) { 80 | self.providers = providers; 81 | } 82 | } 83 | 84 | impl Component for ProvidersPopup { 85 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 86 | match action { 87 | _ if action.is_soft_quit() => ComponentAction::Quit, 88 | Action::Confirm => ComponentAction::Quit, 89 | _ => ComponentAction::Nothing, 90 | } 91 | } 92 | 93 | fn render(&mut self, f: &mut Frame, rect: Rect) { 94 | let (popup_rect, block_rect, table_rect) = popup_rects(rect, 80, 50); 95 | 96 | let title_style = Style::default().fg(CONFIG.general.accent_color); 97 | let block = Block::bordered() 98 | .border_type(BorderType::Rounded) 99 | .title(Title::from(" Providers ".set_style(title_style))) 100 | .title(popup_close_button_highlight()); 101 | 102 | let widths = [ 103 | Constraint::Length(10), // Provider name (and icon status prefix) 104 | Constraint::Length(15), // Provider URL 105 | Constraint::Length(15), // Provider category 106 | Constraint::Length(15), // Provider stuatus 107 | ]; 108 | 109 | let rows: Vec> = self 110 | .providers 111 | .iter() 112 | .map(|provider| provider.into()) 113 | .collect(); 114 | 115 | let table = Table::new(rows, widths); 116 | 117 | f.render_widget(Clear, popup_rect); 118 | f.render_widget(block, block_rect); 119 | f.render_widget(table, table_rect); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/bottom_stats.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use ratatui::{ 4 | layout::{Alignment, Rect}, 5 | widgets::Paragraph, 6 | Frame, 7 | }; 8 | use rm_config::CONFIG; 9 | use rm_shared::utils::bytes_to_human_format; 10 | use transmission_rpc::types::{FreeSpace, SessionStats}; 11 | 12 | use crate::tui::components::Component; 13 | 14 | use super::table_manager::TableManager; 15 | 16 | #[derive(Default)] 17 | pub(super) struct BottomStats { 18 | // TODO: get rid of the Option (requires changes in transmission-rpc so SessionStats impls Default 19 | // TODO: ^ The same thing with FreeSpace 20 | pub(super) stats: Option>, 21 | pub(super) free_space: Option>, 22 | torrent_count: u16, 23 | torrent_currently_selected: u16, 24 | } 25 | 26 | impl BottomStats { 27 | pub fn new() -> Self { 28 | Self::default() 29 | } 30 | 31 | pub fn set_stats(&mut self, stats: Arc) { 32 | self.stats = Some(stats); 33 | } 34 | 35 | pub fn set_free_space(&mut self, free_space: Arc) { 36 | self.free_space = Some(free_space); 37 | } 38 | 39 | pub fn update_selected_indicator(&mut self, table_manager: &TableManager) { 40 | self.torrent_count = u16::try_from(table_manager.table.get_len()).unwrap(); 41 | if let Some(currently_selected) = table_manager.table.state.borrow().selected() { 42 | self.torrent_currently_selected = u16::try_from(currently_selected + 1).unwrap(); 43 | } 44 | } 45 | } 46 | impl Component for BottomStats { 47 | fn render(&mut self, f: &mut Frame, rect: Rect) { 48 | if let Some(stats) = &self.stats { 49 | let download = bytes_to_human_format(stats.download_speed); 50 | let upload = bytes_to_human_format(stats.upload_speed); 51 | 52 | let mut text = format!( 53 | "{} {download} | {} {upload}", 54 | CONFIG.icons.download, CONFIG.icons.upload 55 | ); 56 | 57 | if let Some(free_space) = &self.free_space { 58 | let free_space = bytes_to_human_format(free_space.size_bytes); 59 | text = format!("{} {free_space} | {text}", CONFIG.icons.disk) 60 | } 61 | 62 | if self.torrent_count > 0 { 63 | text = format!( 64 | "{} {}/{} | {text}", 65 | CONFIG.icons.file, self.torrent_currently_selected, self.torrent_count 66 | ); 67 | } else { 68 | // dont display index if nothing is selected 69 | text = format!("{} {} | {text}", CONFIG.icons.file, self.torrent_count); 70 | } 71 | 72 | let paragraph = Paragraph::new(text).alignment(Alignment::Right); 73 | f.render_widget(paragraph, rect); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/popups/details.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | prelude::*, 3 | widgets::{block::Title, Block, BorderType, Clear, Paragraph, Wrap}, 4 | }; 5 | use rm_config::{keymap::TorrentsAction, CONFIG}; 6 | use rm_shared::{action::Action, utils::bytes_to_human_format}; 7 | use style::Styled; 8 | 9 | use crate::tui::{ 10 | app::CTX, 11 | components::{keybinding_style, popup_close_button_highlight, Component, ComponentAction}, 12 | main_window::centered_rect, 13 | tabs::torrents::rustmission_torrent::{CategoryType, RustmissionTorrent}, 14 | }; 15 | 16 | pub struct DetailsPopup { 17 | torrent: RustmissionTorrent, 18 | } 19 | 20 | impl DetailsPopup { 21 | pub fn new(torrent: RustmissionTorrent) -> Self { 22 | Self { torrent } 23 | } 24 | } 25 | 26 | impl Component for DetailsPopup { 27 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 28 | match action { 29 | _ if action.is_soft_quit() => ComponentAction::Quit, 30 | Action::Confirm => ComponentAction::Quit, 31 | Action::Delete => { 32 | CTX.send_action(Action::Delete); 33 | ComponentAction::Quit 34 | } 35 | Action::ShowFiles => { 36 | CTX.send_action(Action::ShowFiles); 37 | ComponentAction::Quit 38 | } 39 | Action::Rename => { 40 | CTX.send_action(Action::Rename); 41 | ComponentAction::Quit 42 | } 43 | Action::ChangeCategory => { 44 | CTX.send_action(Action::ChangeCategory); 45 | ComponentAction::Quit 46 | } 47 | Action::MoveTorrent => { 48 | CTX.send_action(Action::MoveTorrent); 49 | ComponentAction::Quit 50 | } 51 | _ => ComponentAction::Nothing, 52 | } 53 | } 54 | 55 | fn render(&mut self, f: &mut Frame, rect: Rect) { 56 | let popup_rect = centered_rect(rect, 50, 50); 57 | let block_rect = popup_rect.inner(Margin::new(1, 1)); 58 | let text_rect = block_rect.inner(Margin::new(3, 2)); 59 | 60 | let title_style = Style::default().fg(CONFIG.general.accent_color); 61 | let block = Block::bordered() 62 | .border_type(BorderType::Rounded) 63 | .title(Title::from(" Details ".set_style(title_style))) 64 | .title(popup_close_button_highlight()); 65 | 66 | let mut lines = vec![]; 67 | 68 | let name_line = Line::from(format!("Name: {}", self.torrent.torrent_name)); 69 | 70 | let directory_line = Line::from(format!("Directory: {}", self.torrent.download_dir)); 71 | 72 | let uploaded_line = Line::from(format!("Total uploaded: {}", self.torrent.uploaded_ever)); 73 | 74 | let peers_line = Line::from(format!("Peers connected: {}", self.torrent.peers_connected)); 75 | 76 | let ratio = Line::from(format!("Ratio: {}", self.torrent.upload_ratio)); 77 | 78 | let size_line = Line::from(format!( 79 | "Size: {}", 80 | bytes_to_human_format(self.torrent.size_when_done) 81 | )); 82 | 83 | let activity_line = Line::from(format!("Last activity: {}", self.torrent.activity_date)); 84 | 85 | let added_line = Line::from(format!("Added: {}", self.torrent.added_date)); 86 | 87 | let mut show_files_line = Line::default(); 88 | show_files_line.push_span(Span::raw("Show files: ")); 89 | show_files_line.push_span(Span::styled( 90 | CONFIG 91 | .keybindings 92 | .torrents_tab 93 | .get_keys_for_action_joined(TorrentsAction::ShowFiles) 94 | .unwrap_or_default(), 95 | keybinding_style(), 96 | )); 97 | 98 | let mut move_location_line = Line::default(); 99 | move_location_line.push_span(Span::raw("Move location: ")); 100 | move_location_line.push_span(Span::styled( 101 | CONFIG 102 | .keybindings 103 | .torrents_tab 104 | .get_keys_for_action_joined(TorrentsAction::MoveTorrent) 105 | .unwrap_or_default(), 106 | keybinding_style(), 107 | )); 108 | 109 | let mut rename_line = Line::default(); 110 | rename_line.push_span(Span::raw("Rename: ")); 111 | rename_line.push_span(Span::styled( 112 | CONFIG 113 | .keybindings 114 | .torrents_tab 115 | .get_keys_for_action_joined(TorrentsAction::Rename) 116 | .unwrap_or_default(), 117 | keybinding_style(), 118 | )); 119 | 120 | let mut delete_line = Line::default(); 121 | delete_line.push_span(Span::raw("Delete: ")); 122 | delete_line.push_span(Span::styled( 123 | CONFIG 124 | .keybindings 125 | .torrents_tab 126 | .get_keys_for_action_joined(TorrentsAction::Delete) 127 | .unwrap_or_default(), 128 | keybinding_style(), 129 | )); 130 | 131 | let mut change_category_line = Line::default(); 132 | change_category_line.push_span(Span::raw("Change category: ")); 133 | change_category_line.push_span(Span::styled( 134 | CONFIG 135 | .keybindings 136 | .torrents_tab 137 | .get_keys_for_action_joined(TorrentsAction::ChangeCategory) 138 | .unwrap_or_default(), 139 | keybinding_style(), 140 | )); 141 | 142 | let padding_line = Line::default(); 143 | 144 | lines.push(name_line); 145 | 146 | if let Some(error) = &self.torrent.error { 147 | lines.push(Line::from(format!("Error: {error}")).red()); 148 | } 149 | 150 | if let Some(category) = &self.torrent.category { 151 | let mut category_line = Line::from("Category: "); 152 | let mut category_span = Span::raw(category.name()); 153 | 154 | if let CategoryType::Config(category) = category { 155 | category_span = category_span.set_style(Style::default().fg(category.color)) 156 | } 157 | 158 | category_line.push_span(category_span); 159 | 160 | lines.push(category_line); 161 | } 162 | 163 | lines.push(directory_line); 164 | lines.push(size_line); 165 | lines.push(padding_line.clone()); 166 | lines.push(peers_line); 167 | lines.push(uploaded_line); 168 | lines.push(ratio); 169 | lines.push(padding_line.clone()); 170 | lines.push(added_line); 171 | lines.push(activity_line); 172 | lines.push(padding_line); 173 | lines.push(delete_line); 174 | lines.push(show_files_line); 175 | lines.push(rename_line); 176 | lines.push(move_location_line); 177 | lines.push(change_category_line); 178 | 179 | let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); 180 | 181 | f.render_widget(Clear, popup_rect); 182 | f.render_widget(block, block_rect); 183 | f.render_widget(paragraph, text_rect); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/popups/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::{ 2 | app::CTX, 3 | components::{Component, ComponentAction}, 4 | }; 5 | 6 | use self::{files::FilesPopup, stats::StatisticsPopup}; 7 | use details::DetailsPopup; 8 | use rm_shared::action::{Action, UpdateAction}; 9 | 10 | use ratatui::prelude::*; 11 | 12 | pub mod details; 13 | pub mod files; 14 | pub mod stats; 15 | 16 | pub struct PopupManager { 17 | pub current_popup: Option, 18 | } 19 | 20 | pub enum CurrentPopup { 21 | Stats(StatisticsPopup), 22 | Files(FilesPopup), 23 | Details(DetailsPopup), 24 | } 25 | 26 | impl PopupManager { 27 | pub const fn new() -> Self { 28 | Self { 29 | current_popup: None, 30 | } 31 | } 32 | 33 | pub const fn is_showing_popup(&self) -> bool { 34 | self.current_popup.is_some() 35 | } 36 | 37 | pub fn show_popup(&mut self, popup: CurrentPopup) { 38 | self.current_popup = Some(popup); 39 | } 40 | 41 | pub fn close_popup(&mut self) { 42 | self.current_popup = None; 43 | } 44 | } 45 | 46 | impl Component for PopupManager { 47 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 48 | if let Some(current_popup) = &mut self.current_popup { 49 | let should_close = match current_popup { 50 | CurrentPopup::Stats(popup) => popup.handle_actions(action).is_quit(), 51 | CurrentPopup::Files(popup) => popup.handle_actions(action).is_quit(), 52 | CurrentPopup::Details(popup) => popup.handle_actions(action).is_quit(), 53 | }; 54 | 55 | if should_close { 56 | self.close_popup(); 57 | CTX.send_action(Action::Render); 58 | } 59 | } 60 | ComponentAction::Nothing 61 | } 62 | 63 | fn handle_update_action(&mut self, action: UpdateAction) { 64 | if let Some(CurrentPopup::Files(popup)) = &mut self.current_popup { 65 | popup.handle_update_action(action); 66 | } 67 | } 68 | 69 | fn render(&mut self, f: &mut Frame, rect: Rect) { 70 | if let Some(current_popup) = &mut self.current_popup { 71 | match current_popup { 72 | CurrentPopup::Stats(popup) => { 73 | popup.render(f, rect); 74 | } 75 | CurrentPopup::Files(popup) => { 76 | popup.render(f, rect); 77 | } 78 | CurrentPopup::Details(popup) => popup.render(f, rect), 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/popups/stats.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use ratatui::{ 4 | prelude::*, 5 | widgets::{BarChart, Block, Clear, Paragraph}, 6 | }; 7 | use rm_config::CONFIG; 8 | use transmission_rpc::types::SessionStats; 9 | 10 | use rm_shared::{action::Action, utils::bytes_to_human_format}; 11 | 12 | use crate::tui::components::{ 13 | popup_block_with_close_highlight, popup_rects, Component, ComponentAction, 14 | }; 15 | 16 | pub struct StatisticsPopup { 17 | stats: Arc, 18 | upload_data: Vec<(&'static str, u64)>, 19 | download_data: Vec<(&'static str, u64)>, 20 | max_up: i64, 21 | max_down: i64, 22 | } 23 | 24 | impl StatisticsPopup { 25 | pub fn new(stats: Arc) -> Self { 26 | Self { 27 | upload_data: vec![("", stats.upload_speed as u64)], 28 | download_data: vec![("", stats.download_speed as u64)], 29 | max_up: stats.upload_speed, 30 | max_down: stats.download_speed, 31 | stats, 32 | } 33 | } 34 | 35 | pub fn update_stats(&mut self, stats: &SessionStats) { 36 | let up = stats.upload_speed; 37 | let down = stats.download_speed; 38 | 39 | if up > self.max_up { 40 | self.max_up = up; 41 | } 42 | 43 | if down > self.max_down { 44 | self.max_down = down; 45 | } 46 | 47 | self.upload_data.insert(0, ("", up as u64)); 48 | self.download_data.insert(0, ("", down as u64)); 49 | } 50 | } 51 | 52 | impl Component for StatisticsPopup { 53 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 54 | use Action as A; 55 | match action { 56 | _ if action.is_soft_quit() => ComponentAction::Quit, 57 | A::Confirm => ComponentAction::Quit, 58 | _ => ComponentAction::Nothing, 59 | } 60 | } 61 | 62 | fn render(&mut self, f: &mut Frame, rect: Rect) { 63 | let (popup_rect, block_rect, text_rect) = popup_rects(rect, 75, 50); 64 | 65 | let [text_rect, _, upload_rect, download_rect] = Layout::vertical([ 66 | Constraint::Length(3), 67 | Constraint::Length(1), 68 | Constraint::Percentage(50), 69 | Constraint::Percentage(50), 70 | ]) 71 | .areas(text_rect); 72 | 73 | let block = popup_block_with_close_highlight(" Statistics "); 74 | 75 | let upload_barchart = make_barchart("Upload", self.max_up as u64, &self.upload_data); 76 | let download_barchart = 77 | make_barchart("Download", self.max_down as u64, &self.download_data); 78 | 79 | let uploaded_bytes = self.stats.cumulative_stats.uploaded_bytes; 80 | let downloaded_bytes = self.stats.cumulative_stats.downloaded_bytes; 81 | let uploaded = bytes_to_human_format(uploaded_bytes); 82 | let downloaded = bytes_to_human_format(downloaded_bytes); 83 | let ratio = uploaded_bytes as f64 / downloaded_bytes as f64; 84 | let text = format!( 85 | "Total uploaded: {uploaded}\nTotal downloaded: {downloaded}\nRatio: {ratio:.2}" 86 | ); 87 | let paragraph = Paragraph::new(text); 88 | 89 | f.render_widget(Clear, popup_rect); 90 | f.render_widget(block, block_rect); 91 | f.render_widget(paragraph, text_rect); 92 | f.render_widget(upload_barchart, upload_rect); 93 | f.render_widget(download_barchart, download_rect); 94 | } 95 | } 96 | 97 | fn make_barchart<'a>( 98 | name: &'static str, 99 | max: u64, 100 | data: &'a [(&'static str, u64)], 101 | ) -> BarChart<'a> { 102 | let avg = bytes_to_human_format( 103 | (data.iter().fold(0, |acc, x| acc + x.1) / u64::try_from(data.len()).unwrap()) as i64, 104 | ); 105 | 106 | BarChart::default() 107 | .block(Block::new().title(format!( 108 | "{name} (avg {avg}/sec - max {})", 109 | bytes_to_human_format(max as i64) 110 | ))) 111 | .bar_width(1) 112 | .bar_gap(0) 113 | .bar_style(Style::new().fg(CONFIG.general.accent_color)) 114 | .data(data) 115 | .max(max) 116 | } 117 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/table_manager.rs: -------------------------------------------------------------------------------- 1 | use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; 2 | use ratatui::{prelude::*, widgets::Row}; 3 | use rm_config::CONFIG; 4 | use rm_shared::header::Header; 5 | use std::{cmp::Ordering, collections::HashMap}; 6 | use transmission_rpc::types::Id; 7 | 8 | use crate::tui::components::GenericTable; 9 | 10 | use super::rustmission_torrent::RustmissionTorrent; 11 | 12 | pub struct TableManager { 13 | pub table: GenericTable, 14 | pub widths: Vec, 15 | pub filter: Option, 16 | pub torrents_displaying_no: u16, 17 | pub sort_header: Option, 18 | pub sort_reverse: bool, 19 | pub sorting_is_being_selected: bool, 20 | pub selected_torrents_ids: Vec, 21 | } 22 | 23 | pub struct Filter { 24 | pub pattern: String, 25 | indexes: Vec, 26 | highlight_indices: Vec>, 27 | } 28 | 29 | impl TableManager { 30 | pub fn new() -> Self { 31 | let table = GenericTable::new(vec![]); 32 | let widths = Self::default_widths(&CONFIG.torrents_tab.headers); 33 | 34 | Self { 35 | table, 36 | widths, 37 | filter: None, 38 | torrents_displaying_no: 0, 39 | sort_header: None, 40 | sort_reverse: false, 41 | sorting_is_being_selected: false, 42 | selected_torrents_ids: vec![], 43 | } 44 | } 45 | 46 | pub fn enter_sorting_selection(&mut self) { 47 | self.sorting_is_being_selected = true; 48 | if self.sort_header.is_none() { 49 | self.sort_header = Some(0); 50 | self.sort(); 51 | } 52 | } 53 | 54 | pub fn reverse_sort(&mut self) { 55 | self.sort_reverse = !self.sort_reverse; 56 | self.sort(); 57 | } 58 | 59 | pub fn leave_sorting(&mut self) { 60 | self.sorting_is_being_selected = false; 61 | self.sort_header = None; 62 | self.sort(); 63 | } 64 | 65 | pub fn apply_sort(&mut self) { 66 | self.sorting_is_being_selected = false; 67 | } 68 | 69 | pub fn move_to_column_left(&mut self) { 70 | if let Some(selected) = self.sort_header { 71 | if selected == 0 { 72 | self.sort_header = Some(self.headers().len() - 1); 73 | self.sort(); 74 | } else { 75 | self.sort_header = Some(selected - 1); 76 | self.sort(); 77 | } 78 | } else { 79 | self.sort_header = Some(0); 80 | self.sort(); 81 | } 82 | } 83 | 84 | pub fn move_to_column_right(&mut self) { 85 | if let Some(selected) = self.sort_header { 86 | let headers_count = self.headers().len(); 87 | if selected < headers_count.saturating_sub(1) { 88 | self.sort_header = Some(selected + 1); 89 | self.sort(); 90 | } else { 91 | self.sort_header = Some(0); 92 | self.sort(); 93 | } 94 | } 95 | } 96 | 97 | pub fn sort(&mut self) { 98 | let sort_by = self 99 | .sort_header 100 | .map(|idx| CONFIG.torrents_tab.headers[idx]) 101 | .unwrap_or(CONFIG.torrents_tab.default_sort); 102 | 103 | match sort_by { 104 | Header::Id => todo!(), 105 | Header::Name => self.table.items.sort_by(|x, y| { 106 | x.torrent_name 107 | .to_lowercase() 108 | .cmp(&y.torrent_name.to_lowercase()) 109 | }), 110 | Header::SizeWhenDone => self 111 | .table 112 | .items 113 | .sort_by(|x, y| x.size_when_done.cmp(&y.size_when_done)), 114 | Header::Progress => self.table.items.sort_unstable_by(|x, y| { 115 | x.progress 116 | .partial_cmp(&y.progress) 117 | .unwrap_or(Ordering::Equal) 118 | }), 119 | Header::Eta => self.table.items.sort_by(|x, y| x.eta_secs.cmp(&y.eta_secs)), 120 | Header::DownloadRate => self 121 | .table 122 | .items 123 | .sort_by(|x, y| x.download_speed.cmp(&y.download_speed)), 124 | Header::UploadRate => self 125 | .table 126 | .items 127 | .sort_by(|x, y| x.upload_speed.cmp(&y.upload_speed)), 128 | Header::DownloadDir => self 129 | .table 130 | .items 131 | .sort_by(|x, y| x.download_dir.cmp(&y.download_dir)), 132 | Header::Padding => (), 133 | Header::UploadRatio => self 134 | .table 135 | .items 136 | .sort_by(|x, y| x.upload_ratio.cmp(&y.upload_ratio)), 137 | Header::UploadedEver => self 138 | .table 139 | .items 140 | .sort_by(|x, y| x.uploaded_ever.cmp(&y.uploaded_ever)), 141 | Header::ActivityDate => self 142 | .table 143 | .items 144 | .sort_by(|x, y| x.activity_date.cmp(&y.activity_date)), 145 | Header::AddedDate => self 146 | .table 147 | .items 148 | .sort_by(|x, y| x.added_date.cmp(&y.added_date)), 149 | Header::PeersConnected => self 150 | .table 151 | .items 152 | .sort_by(|x, y| x.peers_connected.cmp(&y.peers_connected)), 153 | Header::SmallStatus => (), 154 | Header::Category => self.table.items.sort_by(|x, y| { 155 | x.category 156 | .as_ref() 157 | .map(|cat| { 158 | cat.name().cmp( 159 | y.category 160 | .as_ref() 161 | .map(|cat| cat.name()) 162 | .unwrap_or_default(), 163 | ) 164 | }) 165 | .unwrap_or(Ordering::Less) 166 | }), 167 | Header::CategoryIcon => (), 168 | } 169 | if self.sort_reverse 170 | || (self.sort_header.is_none() && CONFIG.torrents_tab.default_sort_reverse) 171 | { 172 | self.table.items.reverse(); 173 | } 174 | } 175 | 176 | pub fn update_rows_number(&mut self) { 177 | if let Some(filter) = &self.filter { 178 | self.table.overwrite_len(filter.indexes.len()); 179 | } else { 180 | self.table.items.len(); 181 | } 182 | } 183 | 184 | pub fn select_current_torrent(&mut self) { 185 | let mut is_selected = true; 186 | if let Some(t) = self.current_torrent() { 187 | if let Id::Id(id) = t.id { 188 | match self.selected_torrents_ids.iter().position(|&x| x == id) { 189 | Some(idx) => { 190 | self.selected_torrents_ids.remove(idx); 191 | is_selected = false; 192 | } 193 | None => { 194 | self.selected_torrents_ids.push(id); 195 | } 196 | } 197 | } else { 198 | unreachable!(); 199 | } 200 | } 201 | 202 | if let Some(t) = self.current_torrent() { 203 | t.is_selected = is_selected; 204 | } 205 | } 206 | 207 | pub fn rows(&self) -> Vec> { 208 | if let Some(filter) = &self.filter { 209 | let highlight_style = Style::default().fg(CONFIG.general.accent_color); 210 | let headers = &CONFIG.torrents_tab.headers; 211 | let mut rows = vec![]; 212 | for (i, which_torrent) in filter.indexes.iter().enumerate() { 213 | let row = self.table.items[*which_torrent as usize].to_row_with_higlighted_indices( 214 | &filter.highlight_indices[i], 215 | highlight_style, 216 | headers, 217 | ); 218 | rows.push(row); 219 | } 220 | 221 | self.table.overwrite_len(rows.len()); 222 | rows 223 | } else { 224 | self.table 225 | .items 226 | .iter() 227 | .map(|t| t.to_row(&CONFIG.torrents_tab.headers)) 228 | .collect() 229 | } 230 | } 231 | 232 | pub fn headers(&self) -> &Vec
{ 233 | &CONFIG.torrents_tab.headers 234 | } 235 | 236 | pub fn current_torrent(&mut self) -> Option<&mut RustmissionTorrent> { 237 | let selected_idx = self.table.state.borrow().selected()?; 238 | 239 | if let Some(filter) = &self.filter { 240 | if filter.indexes.is_empty() { 241 | None 242 | } else { 243 | self.table 244 | .items 245 | .get_mut(filter.indexes[selected_idx] as usize) 246 | } 247 | } else { 248 | self.table.items.get_mut(selected_idx) 249 | } 250 | } 251 | 252 | pub fn set_new_rows(&mut self, mut rows: Vec) { 253 | if !self.selected_torrents_ids.is_empty() { 254 | let mut found_ids = vec![]; 255 | 256 | for row in &mut rows { 257 | if let Id::Id(id) = row.id { 258 | if self.selected_torrents_ids.contains(&id) { 259 | row.is_selected = true; 260 | found_ids.push(id); 261 | } 262 | } 263 | } 264 | 265 | let new_selected: Vec<_> = self 266 | .selected_torrents_ids 267 | .iter() 268 | .cloned() 269 | .filter(|id| found_ids.contains(id)) 270 | .collect(); 271 | 272 | self.selected_torrents_ids = new_selected; 273 | } 274 | 275 | self.table.set_items(rows); 276 | self.widths = self.header_widths(&self.table.items); 277 | self.update_rows_number(); 278 | self.sort(); 279 | 280 | let mut state = self.table.state.borrow_mut(); 281 | if state.selected().is_none() && !self.table.items.is_empty() { 282 | state.select(Some(0)); 283 | } 284 | } 285 | 286 | pub fn set_filter(&mut self, filter: String) { 287 | let matcher = SkimMatcherV2::default(); 288 | let mut indexes: Vec = vec![]; 289 | let mut highlight_indices = vec![]; 290 | for (i, torrent) in self.table.items.iter().enumerate() { 291 | if let Some((_, indices)) = matcher.fuzzy_indices(&torrent.torrent_name, &filter) { 292 | indexes.push(i as u16); 293 | highlight_indices.push(indices); 294 | } 295 | } 296 | 297 | let filter = Filter { 298 | pattern: filter, 299 | indexes, 300 | highlight_indices, 301 | }; 302 | 303 | self.filter = Some(filter); 304 | } 305 | 306 | fn default_widths(headers: &Vec
) -> Vec { 307 | let mut constraints = vec![]; 308 | 309 | for header in headers { 310 | if *header == Header::Category { 311 | constraints.push(Constraint::Length(u16::from( 312 | CONFIG.categories.max_name_len, 313 | ))) 314 | } else if *header == Header::CategoryIcon { 315 | constraints.push(Constraint::Length(u16::from( 316 | CONFIG.categories.max_icon_len, 317 | ))) 318 | } else { 319 | constraints.push(header.default_constraint()) 320 | } 321 | } 322 | constraints 323 | } 324 | 325 | fn header_widths(&self, rows: &[RustmissionTorrent]) -> Vec { 326 | let headers = &CONFIG.torrents_tab.headers; 327 | 328 | if !CONFIG.general.auto_hide { 329 | return Self::default_widths(headers); 330 | } 331 | 332 | let mut map = HashMap::new(); 333 | 334 | for header in headers { 335 | map.insert(header, header.default_constraint()); 336 | } 337 | 338 | let hidable_headers = [ 339 | Header::Progress, 340 | Header::UploadRate, 341 | Header::DownloadRate, 342 | Header::Eta, 343 | ]; 344 | 345 | for hidable_header in &hidable_headers { 346 | map.entry(hidable_header) 347 | .and_modify(|c| *c = Constraint::Length(0)); 348 | } 349 | 350 | for row in rows { 351 | if !row.download_speed().is_empty() { 352 | map.entry(&Header::DownloadRate) 353 | .and_modify(|c| *c = Header::DownloadRate.default_constraint()); 354 | } 355 | if !row.upload_speed.is_empty() { 356 | map.entry(&Header::UploadRate) 357 | .and_modify(|c| *c = Header::UploadRate.default_constraint()); 358 | } 359 | if !row.progress().is_empty() { 360 | map.entry(&Header::Progress) 361 | .and_modify(|c| *c = Header::Progress.default_constraint()); 362 | } 363 | 364 | if !row.eta_secs().is_empty() { 365 | map.entry(&Header::Eta) 366 | .and_modify(|c| *c = Header::Eta.default_constraint()); 367 | } 368 | } 369 | 370 | let mut constraints = vec![]; 371 | 372 | for header in headers { 373 | constraints.push(map.remove(header).expect("this header exists")) 374 | } 375 | 376 | constraints 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/task_manager.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::*; 2 | use throbber_widgets_tui::ThrobberState; 3 | use tokio::time::Instant; 4 | 5 | use rm_shared::{ 6 | action::{Action, UpdateAction}, 7 | status_task::StatusTask, 8 | }; 9 | use transmission_rpc::types::Id; 10 | 11 | use crate::tui::{ 12 | app::CTX, 13 | components::{Component, ComponentAction}, 14 | }; 15 | 16 | use super::tasks::{self, CurrentTaskState, TorrentSelection}; 17 | 18 | pub struct TaskManager { 19 | current_task: CurrentTask, 20 | // TODO: 21 | // Put Default task in a seperate field and merge it with Status task 22 | // and maybe with Selection task (or even Sort?). 23 | // This way there won't be any edge cases in torrents/mod.rs anymore 24 | // when dealing with TaskManager. 25 | // Default task would keep the state info whether there are any tasks 26 | // happening, whether the user is selecting torrents or is sorting them. 27 | } 28 | 29 | impl TaskManager { 30 | pub fn new() -> Self { 31 | Self { 32 | current_task: CurrentTask::Default(tasks::Default::new()), 33 | } 34 | } 35 | } 36 | 37 | pub enum CurrentTask { 38 | AddMagnet(tasks::AddMagnet), 39 | Delete(tasks::Delete), 40 | Filter(tasks::Filter), 41 | Move(tasks::Move), 42 | ChangeCategory(tasks::ChangeCategory), 43 | Default(tasks::Default), 44 | Status(tasks::Status), 45 | Sort(tasks::Sort), 46 | Selection(tasks::Selection), 47 | Rename(tasks::Rename), 48 | } 49 | 50 | impl CurrentTask { 51 | fn tick(&mut self) { 52 | if let Self::Status(status_bar) = self { 53 | status_bar.tick() 54 | } 55 | } 56 | } 57 | 58 | impl Component for TaskManager { 59 | #[must_use] 60 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 61 | match &mut self.current_task { 62 | CurrentTask::AddMagnet(magnet_bar) => { 63 | if magnet_bar.handle_actions(action).is_quit() { 64 | self.cancel_task() 65 | } 66 | } 67 | CurrentTask::Delete(delete_bar) => { 68 | if delete_bar.handle_actions(action).is_quit() { 69 | self.cancel_task() 70 | } 71 | } 72 | CurrentTask::Move(move_bar) => { 73 | if move_bar.handle_actions(action).is_quit() { 74 | self.cancel_task() 75 | } 76 | } 77 | CurrentTask::Filter(filter_bar) => { 78 | if filter_bar.handle_actions(action).is_quit() { 79 | self.cancel_task() 80 | } 81 | } 82 | CurrentTask::Status(status_bar) => { 83 | if status_bar.handle_actions(action).is_quit() { 84 | self.cancel_task() 85 | } 86 | } 87 | CurrentTask::ChangeCategory(category_bar) => { 88 | if category_bar.handle_actions(action).is_quit() { 89 | self.cancel_task() 90 | } 91 | } 92 | CurrentTask::Rename(rename_bar) => { 93 | if rename_bar.handle_actions(action).is_quit() { 94 | self.cancel_task() 95 | } 96 | } 97 | CurrentTask::Default(_) => (), 98 | CurrentTask::Sort(_) => (), 99 | CurrentTask::Selection(_) => (), 100 | }; 101 | ComponentAction::Nothing 102 | } 103 | 104 | fn handle_update_action(&mut self, action: UpdateAction) { 105 | match action { 106 | UpdateAction::StatusTaskClear => self.cancel_task(), 107 | UpdateAction::StatusTaskSet(task) => self.pending_task(task), 108 | UpdateAction::StatusTaskSetSuccess(task) => self.success_task(task), 109 | UpdateAction::StatusTaskSuccess => { 110 | if let CurrentTask::Status(status_bar) = &mut self.current_task { 111 | status_bar.set_success(); 112 | } 113 | } 114 | UpdateAction::StatusTaskFailure => { 115 | if let CurrentTask::Status(status_bar) = &mut self.current_task { 116 | status_bar.set_failure(); 117 | } 118 | } 119 | _ => (), 120 | } 121 | } 122 | 123 | fn render(&mut self, f: &mut Frame, rect: Rect) { 124 | match &mut self.current_task { 125 | CurrentTask::AddMagnet(magnet_bar) => magnet_bar.render(f, rect), 126 | CurrentTask::Delete(delete_bar) => delete_bar.render(f, rect), 127 | CurrentTask::Move(move_bar) => move_bar.render(f, rect), 128 | CurrentTask::Filter(filter_bar) => filter_bar.render(f, rect), 129 | CurrentTask::Default(default_bar) => default_bar.render(f, rect), 130 | CurrentTask::Status(status_bar) => status_bar.render(f, rect), 131 | CurrentTask::ChangeCategory(category_bar) => category_bar.render(f, rect), 132 | CurrentTask::Sort(sort_bar) => sort_bar.render(f, rect), 133 | CurrentTask::Selection(selection_bar) => selection_bar.render(f, rect), 134 | CurrentTask::Rename(rename_bar) => rename_bar.render(f, rect), 135 | } 136 | } 137 | 138 | fn tick(&mut self) { 139 | self.current_task.tick() 140 | } 141 | } 142 | 143 | impl TaskManager { 144 | pub fn add_magnet(&mut self) { 145 | self.current_task = CurrentTask::AddMagnet(tasks::AddMagnet::new()); 146 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 147 | } 148 | 149 | pub fn search(&mut self, current_pattern: &Option) { 150 | self.current_task = CurrentTask::Filter(tasks::Filter::new(current_pattern)); 151 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 152 | } 153 | 154 | pub fn rename(&mut self, id: Id, curr_name: String) { 155 | self.current_task = CurrentTask::Rename(tasks::Rename::new(id, curr_name)); 156 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 157 | } 158 | 159 | pub fn delete_torrents(&mut self, selection: TorrentSelection) { 160 | self.current_task = CurrentTask::Delete(tasks::Delete::new(selection)); 161 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 162 | } 163 | 164 | pub fn move_torrent(&mut self, selection: TorrentSelection, current_dir: String) { 165 | self.current_task = CurrentTask::Move(tasks::Move::new(selection, current_dir)); 166 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 167 | } 168 | 169 | pub fn change_category(&mut self, selection: TorrentSelection) { 170 | self.current_task = CurrentTask::ChangeCategory(tasks::ChangeCategory::new(selection)); 171 | CTX.send_update_action(UpdateAction::SwitchToInputMode); 172 | } 173 | 174 | pub fn default(&mut self) { 175 | self.current_task = CurrentTask::Default(tasks::Default::new()); 176 | } 177 | 178 | pub fn select(&mut self, amount: usize) { 179 | self.current_task = CurrentTask::Selection(tasks::Selection::new(amount)); 180 | } 181 | 182 | pub fn sort(&mut self) { 183 | self.current_task = CurrentTask::Sort(tasks::Sort::new()); 184 | } 185 | 186 | fn success_task(&mut self, task: StatusTask) { 187 | self.current_task = CurrentTask::Status(tasks::Status::new( 188 | task, 189 | CurrentTaskState::Success(Instant::now()), 190 | )) 191 | } 192 | 193 | fn pending_task(&mut self, task: StatusTask) { 194 | if matches!(self.current_task, CurrentTask::Status(_)) { 195 | return; 196 | } 197 | 198 | let state = ThrobberState::default(); 199 | self.current_task = 200 | CurrentTask::Status(tasks::Status::new(task, CurrentTaskState::Loading(state))); 201 | CTX.send_update_action(UpdateAction::SwitchToNormalMode); 202 | } 203 | 204 | fn cancel_task(&mut self) { 205 | if matches!(self.current_task, CurrentTask::Default(_)) { 206 | return; 207 | } 208 | 209 | CTX.send_update_action(UpdateAction::CancelTorrentTask); 210 | } 211 | 212 | pub fn is_status_task_in_progress(&self) -> bool { 213 | if let CurrentTask::Status(task) = &self.current_task { 214 | matches!(task.task_status, CurrentTaskState::Loading(_)) 215 | } else { 216 | false 217 | } 218 | } 219 | 220 | pub fn is_selection_task(&self) -> bool { 221 | matches!(self.current_task, CurrentTask::Selection(_)) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/add_magnet.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use ratatui::prelude::*; 3 | use rm_config::CONFIG; 4 | 5 | use crate::{ 6 | transmission::TorrentAction, 7 | tui::{ 8 | app::CTX, 9 | components::{Component, ComponentAction, InputManager}, 10 | tabs::torrents::SESSION_GET, 11 | }, 12 | }; 13 | use rm_shared::{ 14 | action::{Action, UpdateAction}, 15 | status_task::StatusTask, 16 | }; 17 | 18 | pub struct AddMagnet { 19 | input_magnet_mgr: InputManager, 20 | input_category_mgr: InputManager, 21 | input_location_mgr: InputManager, 22 | stage: Stage, 23 | } 24 | 25 | enum Stage { 26 | Magnet, 27 | Category, 28 | Location, 29 | } 30 | 31 | const MAGNET_PROMPT: &str = "Add magnet URI: "; 32 | const CATEGORY_PROMPT: &str = "Category (empty for default): "; 33 | const LOCATION_PROMPT: &str = "Directory: "; 34 | 35 | impl AddMagnet { 36 | pub fn new() -> Self { 37 | Self { 38 | input_magnet_mgr: InputManager::new(MAGNET_PROMPT.to_string()), 39 | input_category_mgr: InputManager::new(CATEGORY_PROMPT.to_string()) 40 | .autocompletions(CONFIG.categories.map.keys().cloned().collect()), 41 | input_location_mgr: InputManager::new_with_value( 42 | LOCATION_PROMPT.to_string(), 43 | SESSION_GET.get().unwrap().download_dir.clone(), 44 | ), 45 | stage: Stage::Magnet, 46 | } 47 | } 48 | 49 | pub fn magnet(mut self, magnet: impl Into) -> Self { 50 | self.input_magnet_mgr.set_text(magnet); 51 | if CONFIG.categories.is_empty() { 52 | self.stage = Stage::Location 53 | } else { 54 | self.stage = Stage::Category; 55 | } 56 | 57 | self 58 | } 59 | 60 | fn handle_input(&mut self, input: KeyEvent) -> ComponentAction { 61 | match self.stage { 62 | Stage::Magnet => self.handle_magnet_input(input), 63 | Stage::Category => self.handle_category_input(input), 64 | Stage::Location => self.handle_location_input(input), 65 | } 66 | } 67 | 68 | fn handle_magnet_input(&mut self, input: KeyEvent) -> ComponentAction { 69 | if input.code == KeyCode::Enter { 70 | if CONFIG.categories.is_empty() { 71 | self.stage = Stage::Location; 72 | } else { 73 | self.stage = Stage::Category; 74 | } 75 | CTX.send_action(Action::Render); 76 | return ComponentAction::Nothing; 77 | } 78 | 79 | if input.code == KeyCode::Esc { 80 | return ComponentAction::Quit; 81 | } 82 | 83 | if self.input_magnet_mgr.handle_key(input).is_some() { 84 | CTX.send_action(Action::Render); 85 | } 86 | 87 | ComponentAction::Nothing 88 | } 89 | 90 | fn handle_category_input(&mut self, input: KeyEvent) -> ComponentAction { 91 | if input.code == KeyCode::Enter { 92 | if self.input_category_mgr.text().is_empty() { 93 | self.stage = Stage::Location; 94 | CTX.send_action(Action::Render); 95 | return ComponentAction::Nothing; 96 | } else if let Some(category) = 97 | CONFIG.categories.map.get(&self.input_category_mgr.text()) 98 | { 99 | self.input_location_mgr = InputManager::new_with_value( 100 | LOCATION_PROMPT.to_string(), 101 | category.default_dir.clone().unwrap_or_else(|| { 102 | SESSION_GET 103 | .get() 104 | .as_ref() 105 | .expect("session_get was already initialized") 106 | .download_dir 107 | .clone() 108 | }), 109 | ); 110 | self.stage = Stage::Location; 111 | CTX.send_action(Action::Render); 112 | return ComponentAction::Nothing; 113 | } else { 114 | self.input_category_mgr.set_prompt(format!( 115 | "Category ({} not found): ", 116 | self.input_category_mgr.text() 117 | )); 118 | CTX.send_action(Action::Render); 119 | return ComponentAction::Nothing; 120 | }; 121 | } 122 | 123 | if input.code == KeyCode::Esc { 124 | return ComponentAction::Quit; 125 | } 126 | 127 | if self.input_category_mgr.handle_key(input).is_some() { 128 | CTX.send_action(Action::Render); 129 | } 130 | 131 | ComponentAction::Nothing 132 | } 133 | 134 | fn handle_location_input(&mut self, input: KeyEvent) -> ComponentAction { 135 | if input.code == KeyCode::Enter { 136 | let category = if self.input_category_mgr.text().is_empty() { 137 | None 138 | } else { 139 | Some(self.input_category_mgr.text()) 140 | }; 141 | 142 | let torrent_action = TorrentAction::Add( 143 | self.input_magnet_mgr.text(), 144 | Some(self.input_location_mgr.text()), 145 | category, 146 | ); 147 | CTX.send_torrent_action(torrent_action); 148 | 149 | let task = StatusTask::new_add(self.input_magnet_mgr.text()); 150 | CTX.send_update_action(UpdateAction::StatusTaskSet(task)); 151 | 152 | ComponentAction::Quit 153 | } else if input.code == KeyCode::Esc { 154 | ComponentAction::Quit 155 | } else if self.input_location_mgr.handle_key(input).is_some() { 156 | CTX.send_action(Action::Render); 157 | ComponentAction::Nothing 158 | } else { 159 | ComponentAction::Nothing 160 | } 161 | } 162 | } 163 | 164 | impl Component for AddMagnet { 165 | #[must_use] 166 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 167 | match action { 168 | Action::Input(input) => self.handle_input(input), 169 | _ => ComponentAction::Nothing, 170 | } 171 | } 172 | 173 | fn render(&mut self, f: &mut Frame, rect: Rect) { 174 | match self.stage { 175 | Stage::Magnet => self.input_magnet_mgr.render(f, rect), 176 | Stage::Category => self.input_category_mgr.render(f, rect), 177 | Stage::Location => self.input_location_mgr.render(f, rect), 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/change_category.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use ratatui::prelude::*; 3 | use rm_config::CONFIG; 4 | use rm_shared::{ 5 | action::{Action, UpdateAction}, 6 | status_task::StatusTask, 7 | }; 8 | 9 | use crate::{ 10 | transmission::TorrentAction, 11 | tui::{ 12 | app::CTX, 13 | components::{Component, ComponentAction, InputManager}, 14 | tabs::torrents::SESSION_GET, 15 | }, 16 | }; 17 | 18 | use super::TorrentSelection; 19 | 20 | pub struct ChangeCategory { 21 | selection: TorrentSelection, 22 | category_input_mgr: InputManager, 23 | directory_input_mgr: InputManager, 24 | default_dir: Option, 25 | stage: Stage, 26 | } 27 | 28 | enum Stage { 29 | Category, 30 | Directory, 31 | } 32 | 33 | impl ChangeCategory { 34 | pub fn new(selection: TorrentSelection) -> Self { 35 | let prompt = "New category: ".to_string(); 36 | 37 | Self { 38 | selection, 39 | category_input_mgr: InputManager::new(prompt) 40 | .autocompletions(CONFIG.categories.map.keys().cloned().collect()), 41 | directory_input_mgr: InputManager::new( 42 | "Move to category's default dir? (Y/n): ".into(), 43 | ), 44 | stage: Stage::Category, 45 | default_dir: None, 46 | } 47 | } 48 | fn send_status_task(&self) { 49 | let task = StatusTask::new_category(self.category_input_mgr.text()); 50 | CTX.send_update_action(UpdateAction::StatusTaskSet(task)); 51 | } 52 | 53 | fn set_stage_directory(&mut self, directory: String) { 54 | self.default_dir = Some(directory); 55 | self.stage = Stage::Directory; 56 | } 57 | 58 | fn handle_input(&mut self, input: KeyEvent) -> ComponentAction { 59 | match self.stage { 60 | Stage::Category => self.handle_category_input(input), 61 | Stage::Directory => self.handle_directory_input(input), 62 | } 63 | } 64 | 65 | fn handle_directory_input(&mut self, input: KeyEvent) -> ComponentAction { 66 | if input.code == KeyCode::Enter { 67 | if self.directory_input_mgr.text().to_lowercase() == "y" 68 | || self.directory_input_mgr.text().is_empty() 69 | { 70 | CTX.send_torrent_action(TorrentAction::ChangeCategory( 71 | self.selection.ids(), 72 | self.category_input_mgr.text(), 73 | )); 74 | 75 | CTX.send_torrent_action(TorrentAction::Move( 76 | self.selection.ids(), 77 | self.default_dir 78 | .take() 79 | .expect("it was set in the previous stage"), 80 | )); 81 | 82 | self.send_status_task(); 83 | 84 | return ComponentAction::Quit; 85 | } else if self.directory_input_mgr.text().to_lowercase() == "n" { 86 | CTX.send_torrent_action(TorrentAction::ChangeCategory( 87 | self.selection.ids(), 88 | self.category_input_mgr.text(), 89 | )); 90 | 91 | self.send_status_task(); 92 | 93 | return ComponentAction::Quit; 94 | } 95 | } 96 | 97 | if input.code == KeyCode::Esc { 98 | return ComponentAction::Quit; 99 | } 100 | 101 | if self.directory_input_mgr.handle_key(input).is_some() { 102 | CTX.send_action(Action::Render); 103 | } 104 | 105 | ComponentAction::Nothing 106 | } 107 | 108 | fn handle_category_input(&mut self, input: KeyEvent) -> ComponentAction { 109 | if input.code == KeyCode::Enter { 110 | let category = self.category_input_mgr.text(); 111 | 112 | if let Some(config_category) = CONFIG.categories.map.get(&category) { 113 | self.set_stage_directory(config_category.default_dir.clone().unwrap_or_else( 114 | || { 115 | SESSION_GET 116 | .get() 117 | .expect("session_get was not initialized yet") 118 | .download_dir 119 | .clone() 120 | }, 121 | )); 122 | CTX.send_action(Action::Render); 123 | return ComponentAction::Nothing; 124 | } else { 125 | CTX.send_torrent_action(TorrentAction::ChangeCategory( 126 | self.selection.ids(), 127 | category.clone(), 128 | )); 129 | self.send_status_task(); 130 | return ComponentAction::Quit; 131 | }; 132 | } 133 | 134 | if input.code == KeyCode::Esc { 135 | return ComponentAction::Quit; 136 | } 137 | 138 | if self.category_input_mgr.handle_key(input).is_some() { 139 | CTX.send_action(Action::Render); 140 | } 141 | 142 | ComponentAction::Nothing 143 | } 144 | } 145 | 146 | impl Component for ChangeCategory { 147 | #[must_use] 148 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 149 | match action { 150 | Action::Input(input) => self.handle_input(input), 151 | _ => ComponentAction::Nothing, 152 | } 153 | } 154 | 155 | fn render(&mut self, f: &mut Frame, rect: Rect) { 156 | match self.stage { 157 | Stage::Category => self.category_input_mgr.render(f, rect), 158 | Stage::Directory => self.directory_input_mgr.render(f, rect), 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/default.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::*; 2 | use rm_config::{keymap::GeneralAction, CONFIG}; 3 | 4 | use crate::tui::components::{keybinding_style, Component}; 5 | 6 | pub struct Default {} 7 | 8 | impl Default { 9 | pub const fn new() -> Self { 10 | Self {} 11 | } 12 | } 13 | 14 | impl Component for Default { 15 | fn render(&mut self, f: &mut Frame<'_>, rect: Rect) { 16 | let mut line = Line::default(); 17 | let mut line_is_empty = true; 18 | 19 | if CONFIG.general.beginner_mode { 20 | if let Some(keys) = CONFIG 21 | .keybindings 22 | .general 23 | .get_keys_for_action(GeneralAction::ShowHelp) 24 | { 25 | line.push_span(Span::raw(format!("{} ", CONFIG.icons.help))); 26 | line_is_empty = false; 27 | let keys_len = keys.len(); 28 | for (idx, key) in keys.into_iter().enumerate() { 29 | line.push_span(Span::styled(key, keybinding_style())); 30 | if idx != keys_len - 1 { 31 | line.push_span(Span::raw(" / ")); 32 | } 33 | } 34 | line.push_span(Span::raw(" - help")); 35 | } 36 | if let Some(keys) = CONFIG 37 | .keybindings 38 | .general 39 | .get_keys_for_action_joined(GeneralAction::Confirm) 40 | { 41 | if !line_is_empty { 42 | line.push_span(Span::raw(" | ")); 43 | } else { 44 | line.push_span(Span::raw(format!("{} ", CONFIG.icons.help))); 45 | } 46 | line.push_span(Span::styled(keys, keybinding_style())); 47 | line.push_span(Span::raw(" - view torrent")); 48 | } 49 | } 50 | f.render_widget(line, rect); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/delete_torrent.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | use ratatui::prelude::*; 3 | 4 | use crate::transmission::TorrentAction; 5 | use crate::tui::app::CTX; 6 | use crate::tui::components::{Component, ComponentAction, InputManager}; 7 | use rm_shared::action::{Action, UpdateAction}; 8 | use rm_shared::status_task::StatusTask; 9 | 10 | use super::TorrentSelection; 11 | 12 | pub struct Delete { 13 | delete_with_files: bool, 14 | torrents_to_delete: TorrentSelection, 15 | input_mgr: InputManager, 16 | } 17 | 18 | impl Delete { 19 | pub fn new(to_delete: TorrentSelection) -> Self { 20 | let prompt = String::from("Delete selected with files? (Y/n) "); 21 | 22 | Self { 23 | delete_with_files: false, 24 | torrents_to_delete: to_delete, 25 | input_mgr: InputManager::new(prompt), 26 | } 27 | } 28 | 29 | fn delete(&self) { 30 | if self.delete_with_files { 31 | CTX.send_torrent_action(TorrentAction::DelWithFiles(self.torrents_to_delete.ids())) 32 | } else { 33 | CTX.send_torrent_action(TorrentAction::DelWithoutFiles( 34 | self.torrents_to_delete.ids(), 35 | )) 36 | } 37 | 38 | let task = match &self.torrents_to_delete { 39 | TorrentSelection::Single(_, name) => StatusTask::new_del(name.clone()), 40 | TorrentSelection::Many(ids) => StatusTask::new_del(ids.len().to_string()), 41 | }; 42 | 43 | CTX.send_update_action(UpdateAction::StatusTaskSet(task)); 44 | } 45 | } 46 | 47 | impl Component for Delete { 48 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 49 | match action { 50 | Action::Input(input) => { 51 | if input.code == KeyCode::Esc { 52 | return ComponentAction::Quit; 53 | } else if input.code == KeyCode::Enter { 54 | let text = self.input_mgr.text().to_lowercase(); 55 | if text == "y" || text == "yes" || text.is_empty() { 56 | self.delete_with_files = true; 57 | self.delete(); 58 | return ComponentAction::Quit; 59 | } else if text == "n" || text == "no" { 60 | self.delete(); 61 | return ComponentAction::Quit; 62 | } 63 | } 64 | 65 | if self.input_mgr.handle_key(input).is_some() { 66 | CTX.send_action(Action::Render); 67 | } 68 | 69 | ComponentAction::Nothing 70 | } 71 | _ => ComponentAction::Nothing, 72 | } 73 | } 74 | 75 | fn render(&mut self, f: &mut Frame, rect: Rect) { 76 | self.input_mgr.render(f, rect) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/filter.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | use ratatui::prelude::*; 3 | 4 | use rm_shared::action::{Action, UpdateAction}; 5 | 6 | use crate::tui::{ 7 | app::CTX, 8 | components::{Component, ComponentAction, InputManager}, 9 | }; 10 | 11 | pub struct Filter { 12 | input: InputManager, 13 | } 14 | 15 | impl Filter { 16 | pub fn new(current_pattern: &Option) -> Self { 17 | let pattern = current_pattern.as_ref().cloned().unwrap_or_default(); 18 | let input = InputManager::new_with_value("Search: ".to_string(), pattern); 19 | Self { input } 20 | } 21 | } 22 | 23 | impl Component for Filter { 24 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 25 | match action { 26 | Action::Input(input) => { 27 | if matches!(input.code, KeyCode::Enter | KeyCode::Esc) { 28 | if self.input.text().is_empty() { 29 | CTX.send_update_action(UpdateAction::SearchFilterClear); 30 | } 31 | ComponentAction::Quit 32 | } else if self.input.handle_key(input).is_some() { 33 | CTX.send_update_action(UpdateAction::SearchFilterApply(self.input.text())); 34 | ComponentAction::Nothing 35 | } else { 36 | ComponentAction::Nothing 37 | } 38 | } 39 | _ => ComponentAction::Nothing, 40 | } 41 | } 42 | 43 | fn render(&mut self, f: &mut Frame, rect: Rect) { 44 | self.input.render(f, rect); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/mod.rs: -------------------------------------------------------------------------------- 1 | mod add_magnet; 2 | mod change_category; 3 | mod default; 4 | mod delete_torrent; 5 | mod filter; 6 | mod move_torrent; 7 | mod rename; 8 | mod selection; 9 | mod sort; 10 | mod status; 11 | 12 | pub use add_magnet::AddMagnet; 13 | pub use change_category::ChangeCategory; 14 | pub use default::Default; 15 | pub use delete_torrent::Delete; 16 | pub use filter::Filter; 17 | pub use move_torrent::Move; 18 | pub use rename::Rename; 19 | pub use selection::Selection; 20 | pub use sort::Sort; 21 | pub use status::{CurrentTaskState, Status}; 22 | use transmission_rpc::types::Id; 23 | 24 | pub enum TorrentSelection { 25 | Single(Id, String), 26 | Many(Vec), 27 | } 28 | 29 | impl TorrentSelection { 30 | pub fn ids(&self) -> Vec { 31 | match self { 32 | TorrentSelection::Single(id, _) => vec![id.clone()], 33 | TorrentSelection::Many(ids) => ids.clone(), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/move_torrent.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use ratatui::prelude::*; 3 | use rm_shared::{ 4 | action::{Action, UpdateAction}, 5 | status_task::StatusTask, 6 | }; 7 | 8 | use crate::{ 9 | transmission::TorrentAction, 10 | tui::{ 11 | app::{self, CTX}, 12 | components::{Component, ComponentAction, InputManager}, 13 | }, 14 | }; 15 | 16 | use super::TorrentSelection; 17 | 18 | pub struct Move { 19 | selection: TorrentSelection, 20 | input_mgr: InputManager, 21 | } 22 | 23 | impl Move { 24 | pub fn new(selection: TorrentSelection, existing_location: String) -> Self { 25 | let prompt = "New directory: ".to_string(); 26 | 27 | Self { 28 | selection, 29 | input_mgr: InputManager::new_with_value(prompt, existing_location), 30 | } 31 | } 32 | 33 | fn handle_input(&mut self, input: KeyEvent) -> ComponentAction { 34 | if input.code == KeyCode::Enter { 35 | let new_location = self.input_mgr.text(); 36 | 37 | let torrent_action = TorrentAction::Move(self.selection.ids(), new_location.clone()); 38 | CTX.send_torrent_action(torrent_action); 39 | 40 | let task = StatusTask::new_move(new_location); 41 | CTX.send_update_action(UpdateAction::StatusTaskSet(task)); 42 | 43 | ComponentAction::Quit 44 | } else if input.code == KeyCode::Esc { 45 | ComponentAction::Quit 46 | } else if self.input_mgr.handle_key(input).is_some() { 47 | CTX.send_action(Action::Render); 48 | ComponentAction::Nothing 49 | } else { 50 | ComponentAction::Nothing 51 | } 52 | } 53 | } 54 | 55 | impl Component for Move { 56 | fn handle_actions(&mut self, action: Action) -> ComponentAction { 57 | match action { 58 | Action::Input(input) => self.handle_input(input), 59 | _ => ComponentAction::Nothing, 60 | } 61 | } 62 | 63 | fn render(&mut self, f: &mut Frame, rect: Rect) { 64 | self.input_mgr.render(f, rect) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/rename.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | use ratatui::{prelude::*, Frame}; 3 | use rm_shared::{ 4 | action::{Action, UpdateAction}, 5 | status_task::StatusTask, 6 | }; 7 | use transmission_rpc::types::Id; 8 | 9 | use crate::{ 10 | transmission::TorrentAction, 11 | tui::{ 12 | app::CTX, 13 | components::{Component, ComponentAction, InputManager}, 14 | }, 15 | }; 16 | 17 | pub struct Rename { 18 | id: Id, 19 | curr_name: String, 20 | input_mgr: InputManager, 21 | } 22 | 23 | impl Rename { 24 | pub fn new(to_rename: Id, curr_name: String) -> Self { 25 | let prompt = String::from("New name: "); 26 | 27 | Self { 28 | id: to_rename, 29 | input_mgr: InputManager::new_with_value(prompt, curr_name.clone()), 30 | curr_name, 31 | } 32 | } 33 | 34 | fn rename(&self) { 35 | let new_name = self.input_mgr.text(); 36 | 37 | if self.curr_name == new_name { 38 | return; 39 | } 40 | 41 | let task = StatusTask::new_rename(self.curr_name.clone()); 42 | 43 | CTX.send_update_action(UpdateAction::StatusTaskSet(task)); 44 | CTX.send_torrent_action(TorrentAction::Rename( 45 | self.id.clone(), 46 | self.curr_name.clone(), 47 | self.input_mgr.text(), 48 | )) 49 | } 50 | } 51 | 52 | impl Component for Rename { 53 | fn handle_actions(&mut self, action: Action) -> crate::tui::components::ComponentAction { 54 | match action { 55 | Action::Input(input) => { 56 | if input.code == KeyCode::Esc { 57 | return ComponentAction::Quit; 58 | } else if input.code == KeyCode::Enter { 59 | self.rename(); 60 | return ComponentAction::Quit; 61 | } 62 | 63 | if self.input_mgr.handle_key(input).is_some() { 64 | CTX.send_action(Action::Render); 65 | } 66 | 67 | ComponentAction::Nothing 68 | } 69 | 70 | _ => ComponentAction::Nothing, 71 | } 72 | } 73 | 74 | fn render(&mut self, f: &mut Frame, rect: Rect) { 75 | self.input_mgr.render(f, rect) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/selection.rs: -------------------------------------------------------------------------------- 1 | use crate::tui::components::{keybinding_style, Component}; 2 | use rm_config::{keymap::GeneralAction, CONFIG}; 3 | 4 | use ratatui::{prelude::*, text::Span}; 5 | 6 | pub struct Selection { 7 | selection_amount: usize, 8 | } 9 | 10 | impl Selection { 11 | pub const fn new(selection_amount: usize) -> Self { 12 | Self { selection_amount } 13 | } 14 | } 15 | 16 | impl Component for Selection { 17 | fn render(&mut self, f: &mut Frame, rect: Rect) { 18 | let mut line = Line::default(); 19 | let mut line_is_empty = true; 20 | 21 | if let Some(keys) = CONFIG 22 | .keybindings 23 | .general 24 | .get_keys_for_action_joined(GeneralAction::Close) 25 | { 26 | line_is_empty = false; 27 | line.push_span(Span::styled(keys, keybinding_style())); 28 | line.push_span(Span::raw(" - clear selection")); 29 | } 30 | 31 | if !line_is_empty { 32 | line.push_span(Span::raw(" | ")); 33 | } 34 | 35 | line.push_span(format!("{} selected", self.selection_amount)); 36 | 37 | f.render_widget(line, rect); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/sort.rs: -------------------------------------------------------------------------------- 1 | use rm_config::{keymap::GeneralAction, CONFIG}; 2 | 3 | use ratatui::prelude::*; 4 | 5 | use crate::tui::components::{keybinding_style, Component}; 6 | 7 | pub struct Sort {} 8 | 9 | impl Sort { 10 | pub const fn new() -> Self { 11 | Self {} 12 | } 13 | } 14 | 15 | impl Component for Sort { 16 | fn render(&mut self, f: &mut Frame<'_>, rect: Rect) { 17 | let mut line = Line::default(); 18 | let mut line_is_empty = true; 19 | 20 | if let Some(keys) = CONFIG 21 | .keybindings 22 | .general 23 | .get_keys_for_action_joined(GeneralAction::Close) 24 | { 25 | line_is_empty = false; 26 | line.push_span(Span::styled(keys, keybinding_style())); 27 | line.push_span(Span::raw(" - reset & exit")); 28 | } 29 | 30 | if let Some(keys) = CONFIG 31 | .keybindings 32 | .general 33 | .get_keys_for_action_joined(GeneralAction::Confirm) 34 | { 35 | if !line_is_empty { 36 | line.push_span(Span::raw(" | ")); 37 | } 38 | line_is_empty = false; 39 | line.push_span(Span::styled(keys, keybinding_style())); 40 | line.push_span(Span::raw(" - apply")); 41 | } 42 | 43 | if let Some(keys) = CONFIG 44 | .keybindings 45 | .general 46 | .get_keys_for_action_joined(GeneralAction::Down) 47 | { 48 | if !line_is_empty { 49 | line.push_span(Span::raw(" | ")); 50 | } 51 | line.push_span(Span::styled(keys, keybinding_style())); 52 | line.push_span(Span::raw(" - reverse")); 53 | } 54 | 55 | f.render_widget(line, rect); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /rm-main/src/tui/tabs/torrents/tasks/status.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{prelude::*, style::Style}; 2 | use rm_shared::{ 3 | action::{Action, UpdateAction}, 4 | status_task::StatusTask, 5 | }; 6 | use throbber_widgets_tui::ThrobberState; 7 | use tokio::time::{self, Instant}; 8 | 9 | use crate::tui::{app::CTX, components::Component}; 10 | 11 | pub struct Status { 12 | task: StatusTask, 13 | pub task_status: CurrentTaskState, 14 | } 15 | 16 | #[derive(Clone)] 17 | pub enum CurrentTaskState { 18 | Loading(ThrobberState), 19 | Success(Instant), 20 | Failure(Instant), 21 | } 22 | 23 | impl Status { 24 | pub const fn new(task: StatusTask, task_status: CurrentTaskState) -> Self { 25 | Self { task, task_status } 26 | } 27 | 28 | pub fn set_failure(&mut self) { 29 | self.task_status = CurrentTaskState::Failure(Instant::now()); 30 | } 31 | 32 | pub fn set_success(&mut self) { 33 | self.task_status = CurrentTaskState::Success(Instant::now()); 34 | } 35 | } 36 | 37 | impl Component for Status { 38 | fn render(&mut self, f: &mut Frame, rect: Rect) { 39 | match &mut self.task_status { 40 | CurrentTaskState::Loading(ref mut state) => { 41 | let status_text = self.task.loading_str(); 42 | let default_throbber = throbber_widgets_tui::Throbber::default() 43 | .label(status_text) 44 | .style(Style::default().yellow()); 45 | f.render_stateful_widget(default_throbber.clone(), rect, state); 46 | } 47 | CurrentTaskState::Failure(_) => { 48 | let line = Line::from(vec![ 49 | Span::styled(" ", Style::default().red()), 50 | Span::raw(self.task.failure_str()), 51 | ]); 52 | f.render_widget(line, rect); 53 | } 54 | CurrentTaskState::Success(_) => { 55 | let line = Line::from(vec![ 56 | Span::styled(" ", Style::default().green()), 57 | Span::raw(self.task.success_str()), 58 | ]); 59 | f.render_widget(line, rect); 60 | } 61 | } 62 | } 63 | 64 | fn handle_update_action(&mut self, action: UpdateAction) { 65 | match action { 66 | UpdateAction::StatusTaskSuccess => { 67 | self.set_success(); 68 | CTX.send_action(Action::Render); 69 | } 70 | UpdateAction::Error(_) => { 71 | self.set_failure(); 72 | CTX.send_action(Action::Render); 73 | } 74 | _ => (), 75 | } 76 | } 77 | 78 | fn tick(&mut self) { 79 | match &mut self.task_status { 80 | CurrentTaskState::Loading(state) => { 81 | state.calc_next(); 82 | CTX.send_action(Action::Render); 83 | } 84 | CurrentTaskState::Success(start) => { 85 | let expiration_duration = time::Duration::from_secs(5); 86 | if start.elapsed() >= expiration_duration { 87 | CTX.send_update_action(UpdateAction::StatusTaskClear); 88 | } 89 | } 90 | CurrentTaskState::Failure(start) => { 91 | let expiration_duration = time::Duration::from_secs(5); 92 | if start.elapsed() >= expiration_duration { 93 | CTX.send_update_action(UpdateAction::StatusTaskClear); 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /rm-shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rm-shared" 3 | description = "shared things for rustmission" 4 | version.workspace = true 5 | edition.workspace = true 6 | authors.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | license.workspace = true 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | crossterm.workspace = true 15 | transmission-rpc.workspace = true 16 | magnetease.workspace = true 17 | ratatui.workspace = true 18 | chrono.workspace = true 19 | serde.workspace = true 20 | -------------------------------------------------------------------------------- /rm-shared/src/action.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, sync::Arc}; 2 | 3 | use crossterm::event::KeyEvent; 4 | use magnetease::{MagneteaseError, MagneteaseResult}; 5 | use transmission_rpc::types::{FreeSpace, SessionGet, SessionStats, Torrent}; 6 | 7 | use crate::status_task::StatusTask; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | pub enum Action { 11 | // General 12 | HardQuit, 13 | Quit, 14 | Close, 15 | Render, 16 | Up, 17 | Down, 18 | Left, 19 | Right, 20 | ScrollUpBy(u8), 21 | ScrollDownBy(u8), 22 | ScrollUpPage, 23 | ScrollDownPage, 24 | Home, 25 | End, 26 | Confirm, 27 | Select, 28 | ShowHelp, 29 | Search, 30 | ChangeFocus, 31 | ChangeTab(u8), 32 | XdgOpen, 33 | Input(KeyEvent), 34 | MoveToColumnLeft, 35 | MoveToColumnRight, 36 | // Torrents Tab 37 | ShowStats, 38 | ShowFiles, 39 | Pause, 40 | Delete, 41 | AddMagnet, 42 | MoveTorrent, 43 | ChangeCategory, 44 | Rename, 45 | // Search Tab 46 | ShowProvidersInfo, 47 | } 48 | 49 | pub enum UpdateAction { 50 | // General 51 | SwitchToInputMode, 52 | SwitchToNormalMode, 53 | Error(Box), 54 | // Torrents Tab 55 | SessionStats(Arc), 56 | SessionGet(Arc), 57 | FreeSpace(Arc), 58 | UpdateTorrents(Vec), 59 | UpdateCurrentTorrent(Box), 60 | SearchFilterApply(String), 61 | SearchFilterClear, 62 | CancelTorrentTask, 63 | // Search Tab 64 | SearchStarted, 65 | ProviderResult(MagneteaseResult), 66 | ProviderError(MagneteaseError), 67 | SearchFinished, 68 | // Task Manager's Status Task 69 | StatusTaskClear, 70 | StatusTaskSuccess, 71 | StatusTaskFailure, 72 | StatusTaskSet(StatusTask), 73 | StatusTaskSetSuccess(StatusTask), 74 | } 75 | 76 | #[derive(Debug, Clone, PartialEq, Eq)] 77 | pub struct ErrorMessage { 78 | pub title: String, 79 | pub description: String, 80 | pub source: String, 81 | } 82 | 83 | impl ErrorMessage { 84 | pub fn new( 85 | title: impl Into, 86 | message: impl Into, 87 | error: Box, 88 | ) -> Self { 89 | Self { 90 | title: title.into(), 91 | description: message.into(), 92 | source: error.to_string(), 93 | } 94 | } 95 | } 96 | 97 | impl Action { 98 | pub fn is_render(&self) -> bool { 99 | *self == Self::Render 100 | } 101 | 102 | pub fn is_hard_quit(&self) -> bool { 103 | *self == Self::HardQuit 104 | } 105 | 106 | pub fn is_quit(&self) -> bool { 107 | *self == Self::HardQuit || *self == Self::Quit 108 | } 109 | 110 | pub fn is_soft_quit(&self) -> bool { 111 | self.is_quit() || *self == Self::Close 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /rm-shared/src/header.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Hash, PartialEq, Eq, Clone, Copy)] 5 | pub enum Header { 6 | Id, 7 | Name, 8 | SizeWhenDone, 9 | Progress, 10 | Eta, 11 | DownloadRate, 12 | UploadRate, 13 | DownloadDir, 14 | Padding, 15 | UploadRatio, 16 | UploadedEver, 17 | ActivityDate, 18 | AddedDate, 19 | PeersConnected, 20 | SmallStatus, 21 | Category, 22 | CategoryIcon, 23 | } 24 | 25 | impl Header { 26 | pub fn default_constraint(&self) -> Constraint { 27 | match self { 28 | Self::Name => Constraint::Max(70), 29 | Self::SizeWhenDone => Constraint::Length(12), 30 | Self::Progress => Constraint::Length(12), 31 | Self::Eta => Constraint::Length(12), 32 | Self::DownloadRate => Constraint::Length(12), 33 | Self::UploadRate => Constraint::Length(12), 34 | Self::DownloadDir => Constraint::Max(70), 35 | Self::Padding => Constraint::Length(2), 36 | Self::UploadRatio => Constraint::Length(6), 37 | Self::UploadedEver => Constraint::Length(12), 38 | Self::Id => Constraint::Length(4), 39 | Self::ActivityDate => Constraint::Length(14), 40 | Self::AddedDate => Constraint::Length(12), 41 | Self::PeersConnected => Constraint::Length(6), 42 | Self::SmallStatus => Constraint::Length(1), 43 | Self::Category => Constraint::Max(15), 44 | Self::CategoryIcon => Constraint::Length(5), 45 | } 46 | } 47 | 48 | pub fn header_name(&self) -> &'static str { 49 | match *self { 50 | Self::Name => "Name", 51 | Self::SizeWhenDone => "Size", 52 | Self::Progress => "Progress", 53 | Self::Eta => "ETA", 54 | Self::DownloadRate => "Download", 55 | Self::UploadRate => "Upload", 56 | Self::DownloadDir => "Directory", 57 | Self::Padding => "", 58 | Self::UploadRatio => "Ratio", 59 | Self::UploadedEver => "Up Ever", 60 | Self::Id => "Id", 61 | Self::ActivityDate => "Last active", 62 | Self::AddedDate => "Added", 63 | Self::PeersConnected => "Peers", 64 | Self::SmallStatus => "", 65 | Self::Category => "Category", 66 | Self::CategoryIcon => "", 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /rm-shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod action; 2 | pub mod header; 3 | pub mod status_task; 4 | pub mod utils; 5 | -------------------------------------------------------------------------------- /rm-shared/src/status_task.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::truncated_str; 2 | 3 | pub struct StatusTask { 4 | task_type: TaskType, 5 | what: String, 6 | } 7 | 8 | #[derive(Clone, Copy)] 9 | enum TaskType { 10 | Add, 11 | Delete, 12 | Rename, 13 | Move, 14 | Open, 15 | ChangeCategory, 16 | } 17 | 18 | impl StatusTask { 19 | pub fn new_add(what: impl Into) -> Self { 20 | StatusTask { 21 | task_type: TaskType::Add, 22 | what: what.into(), 23 | } 24 | } 25 | 26 | pub fn new_rename(what: impl Into) -> Self { 27 | StatusTask { 28 | task_type: TaskType::Rename, 29 | what: what.into(), 30 | } 31 | } 32 | 33 | pub fn new_del(what: impl Into) -> Self { 34 | StatusTask { 35 | task_type: TaskType::Delete, 36 | what: what.into(), 37 | } 38 | } 39 | 40 | pub fn new_move(what: impl Into) -> Self { 41 | StatusTask { 42 | task_type: TaskType::Move, 43 | what: what.into(), 44 | } 45 | } 46 | 47 | pub fn new_category(what: impl Into) -> Self { 48 | StatusTask { 49 | task_type: TaskType::ChangeCategory, 50 | what: what.into(), 51 | } 52 | } 53 | 54 | pub fn new_open(what: impl Into) -> Self { 55 | StatusTask { 56 | task_type: TaskType::Open, 57 | what: what.into(), 58 | } 59 | } 60 | 61 | pub fn success_str(&self) -> String { 62 | let truncated = truncated_str(&self.what, 60); 63 | 64 | match self.task_type { 65 | TaskType::Add => format!(" Added {truncated}"), 66 | TaskType::Delete => format!(" Deleted {truncated}"), 67 | TaskType::Move => format!(" Moved {truncated}"), 68 | TaskType::Open => format!(" Opened {truncated}"), 69 | TaskType::ChangeCategory => { 70 | if truncated.is_empty() { 71 | " Categories cleared!".to_string() 72 | } else { 73 | format!(" Category set to {truncated}!") 74 | } 75 | } 76 | TaskType::Rename => format!("Renamed {truncated}"), 77 | } 78 | } 79 | 80 | pub fn failure_str(&self) -> String { 81 | let truncated = truncated_str(&self.what, 60); 82 | 83 | match self.task_type { 84 | TaskType::Add => format!(" Error adding {truncated}"), 85 | TaskType::Delete => format!(" Error deleting {truncated}"), 86 | TaskType::Move => format!(" Error moving to {truncated}"), 87 | TaskType::Open => format!(" Error opening {truncated}"), 88 | TaskType::ChangeCategory => format!(" Error changing category to {truncated}"), 89 | TaskType::Rename => format!(" Error renaming {truncated}"), 90 | } 91 | } 92 | 93 | pub fn loading_str(&self) -> String { 94 | let truncated = truncated_str(&self.what, 60); 95 | 96 | match self.task_type { 97 | TaskType::Add => format!(" Adding {truncated}"), 98 | TaskType::Delete => format!(" Deleting {truncated}"), 99 | TaskType::Move => format!(" Moving {truncated}"), 100 | TaskType::Open => format!(" Opening {truncated}"), 101 | TaskType::ChangeCategory => format!(" Changing category to {truncated}"), 102 | TaskType::Rename => format!(" Renaming {truncated}"), 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /rm-shared/src/utils.rs: -------------------------------------------------------------------------------- 1 | fn raw_bytes_to_human_format(bytes: i64, short: bool) -> String { 2 | const KB: f64 = 1024.0; 3 | const MB: f64 = KB * 1024.0; 4 | const GB: f64 = MB * 1024.0; 5 | const TB: f64 = GB * 1024.0; 6 | 7 | if bytes == 0 { 8 | if short { 9 | return "0B".to_string(); 10 | } else { 11 | return "0 B".to_string(); 12 | } 13 | } 14 | 15 | let (value, unit) = if bytes < (KB - 25f64) as i64 { 16 | (bytes as f64, "B") 17 | } else if bytes < (MB - 25f64) as i64 { 18 | (bytes as f64 / KB, "KB") 19 | } else if bytes < (GB - 25f64) as i64 { 20 | (bytes as f64 / MB, "MB") 21 | } else if bytes < (TB - 25f64) as i64 { 22 | (bytes as f64 / GB, "GB") 23 | } else { 24 | (bytes as f64 / TB, "TB") 25 | }; 26 | 27 | if short { 28 | format!("{value:.0}{unit}") 29 | } else { 30 | format!("{value:.1} {unit}") 31 | } 32 | } 33 | 34 | pub fn bytes_to_human_format(bytes: i64) -> String { 35 | raw_bytes_to_human_format(bytes, false) 36 | } 37 | 38 | pub fn bytes_to_short_human_format(bytes: i64) -> String { 39 | raw_bytes_to_human_format(bytes, true) 40 | } 41 | 42 | pub fn seconds_to_human_format(seconds: i64) -> String { 43 | const MINUTE: i64 = 60; 44 | const HOUR: i64 = MINUTE * 60; 45 | const DAY: i64 = HOUR * 24; 46 | 47 | if seconds == 0 { 48 | return "0s".to_string(); 49 | } 50 | 51 | let mut curr_string = String::new(); 52 | 53 | let mut rest = seconds; 54 | if seconds > DAY { 55 | let days = rest / DAY; 56 | rest %= DAY; 57 | 58 | curr_string = format!("{curr_string}{days}d"); 59 | } 60 | 61 | if seconds > HOUR { 62 | let hours = rest / HOUR; 63 | rest %= HOUR; 64 | curr_string = format!("{curr_string}{hours}h"); 65 | // skip minutes & seconds for multi-day durations 66 | if seconds > DAY { 67 | return curr_string; 68 | } 69 | } 70 | 71 | if seconds > MINUTE { 72 | let minutes = rest / MINUTE; 73 | rest %= MINUTE; 74 | curr_string = format!("{curr_string}{minutes}m"); 75 | } 76 | 77 | curr_string = format!("{curr_string}{rest}s"); 78 | curr_string 79 | } 80 | 81 | pub fn truncated_str(str: &str, max: usize) -> String { 82 | if str.chars().count() < max { 83 | str.to_string() 84 | } else { 85 | let truncated: String = str.chars().take(max).collect(); 86 | format!("\"{truncated}...\"") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | pkgs.mkShell { 3 | inputsFrom = with pkgs; [ 4 | openssl 5 | ]; 6 | 7 | buildInputs = with pkgs; [ 8 | openssl 9 | ]; 10 | 11 | packages = with pkgs; [ 12 | openssl 13 | pkg-config 14 | ]; 15 | } 16 | 17 | --------------------------------------------------------------------------------