Compare commits

...

30 Commits

Author SHA1 Message Date
48ad17c6f1 style: Updated the contributing doc to also explain how to install commitizen 2024-11-06 16:48:27 -07:00
3cd15f34cd style: Test install for commitizen 2024-11-06 16:39:26 -07:00
53ca14e64d fix(handler): Fixed a bug in the movie details handler that would allow key events to be processed before the data was finished loading 2024-11-06 16:17:23 -07:00
0d8803d35d fix(ui): Fixed a bug that would freeze all user input while background network requests were running 2024-11-06 15:50:47 -07:00
8c90221a81 perf(network): Improved performance and reactiveness of the UI by speeding up network requests and clearing the channel whenever a request is cancelled/the UI is routing 2024-11-06 14:52:48 -07:00
a708f71d57 fix(radarr_ui): Fixed a race condition bug in the movie details UI that would panic if the user changes tabs too quickly 2024-11-06 11:29:49 -07:00
2a13f74a2b Updated the release workflows to use the correct flags and commands 2024-11-05 18:33:17 -07:00
2a97c49a8e Updated workflows to allow manual releases [skip ci] 2024-11-05 18:29:45 -07:00
8c155ce656 Updated release actions to only be executable by the repository owner [skip ci] 2024-11-05 18:25:26 -07:00
Alex Clarke
5245ba6d98 Merge pull request #14 from Dark-Alex-17/release-plz-2024-11-06T01-20-32Z
chore: release v0.2.1
2024-11-05 18:24:50 -07:00
github-actions[bot]
f9789ecc9b chore: release v0.2.1 2024-11-06 01:20:33 +00:00
9936ce1ab5 Removed the need for use_ssl to indicate SSL usage; instead just use the ssl_cert_path
Added the ability to specify either host/port, or uri for configuring Radarr
2024-11-05 18:16:01 -07:00
650c9783a6 Applied bug fix to the downloads tab as well as the context [skip ci] 2024-11-04 14:21:34 -07:00
b253a389eb Updated the README to not include the GitHub downloads badge since all binary releases are on crates.io [skip ci] 2024-11-04 10:12:45 -07:00
5023fbd3d1 Set all releases as manually triggered instead of automatic [skip ci] 2024-11-04 10:10:36 -07:00
fdb08fbd34 Added additional workflows for releasing minor and major releases, in addition to just patches so I can manually trigger them and update the Changelog dynamically. [skip ci] 2024-11-03 16:37:52 -07:00
b125d3341a Updated dockerfile to no longer use the --disable-terminal-size-checks flag [skip ci] 2024-11-03 16:20:32 -07:00
Alex Clarke
f73e3a4817 Merge pull request #11 from Dark-Alex-17/rc/v0.2.0-address-community-comments
Rc/v0.2.0 address community comments
2024-11-03 16:12:29 -07:00
1ff31b1bd9 Updated codecov config to also ignore the ui directory entirely, since it's not very conducive to tests as it is 2024-11-03 16:05:17 -07:00
ce4cbd8652 Updated codecov config to ignore test files 2024-11-03 16:01:49 -07:00
346d95f8ec Added Codecov config 2024-11-03 15:57:18 -07:00
85ea05e3c8 Incremented minor release since removing the --disable-terminal-size-checks flag is a breaking change, but the application is not yet ready for a 1.0 release. 2024-11-03 15:42:59 -07:00
93d78701ce fix:fixed divide by zero panic when download size is unknown 2024-11-03 15:36:26 -07:00
8d7cb63c7a Remove the terminal size checks since they've caused so many issues since their introduction 2024-11-03 15:33:08 -07:00
c8c7d00517 Added environment variables section to the README for added visibility into the feature 2024-11-03 15:06:24 -07:00
Alex Clarke
9402ad3f3b Merge pull request #9 from tangowithfoxtrot/add-env-var-config-options
Add environment variables for --config and terminal size check args
2024-11-03 14:43:50 -07:00
tangowithfoxtrot
ea9a9070ce Merge branch 'rc/v0.1.6-address-community-comments' into add-env-var-config-options 2024-11-03 13:40:53 -08:00
a0fe51c57b Added help that's always visible for modals with new shortcuts for accepting all modals, or closing all modals without the need of seeing the UI 2024-11-03 14:25:33 -07:00
tangowithfoxtrot
9326428141 feat: allow configuration via env vars 2024-11-03 11:20:15 -08:00
Alex Clarke
c1da8592b4 Merge pull request #7 from Dark-Alex-17/release-plz-2024-11-03T00-33-10Z
chore: release v0.1.5
2024-11-02 18:37:24 -06:00
73 changed files with 1449 additions and 376 deletions
+7
View File
@@ -0,0 +1,7 @@
[tool.commitizen]
name = "cz_conventional_commits"
tag_format = "v$version"
version_scheme = "semver"
version_provider = "cargo"
update_changelog_on_bump = true
major_version_zero = true
@@ -1,16 +1,14 @@
# Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml # Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml
# Thanks to joshka for permission to use this template! # Thanks to joshka for permission to use this template!
name: Create Release PR and Publish Release name: Create major release
permissions: permissions:
pull-requests: write pull-requests: write
contents: write contents: write
on: on:
push: workflow_dispatch:
branches:
- main
jobs: jobs:
release-plz: release-plz:
@@ -19,6 +17,12 @@ jobs:
name: Release-plz name: Release-plz
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -27,6 +31,8 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Run release-plz - name: Run release-plz
uses: MarcoIeni/release-plz-action@v0.5 uses: MarcoIeni/release-plz-action@v0.5
with:
command: release --bump-major
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+38
View File
@@ -0,0 +1,38 @@
# Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml
# Thanks to joshka for permission to use this template!
name: Create minor release
permissions:
pull-requests: write
contents: write
on:
workflow_dispatch:
jobs:
release-plz:
# see https://release-plz.ieni.dev/docs/github
# for more information
name: Release-plz
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: MarcoIeni/release-plz-action@v0.5
with:
command: release --bump-minor
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+38
View File
@@ -0,0 +1,38 @@
# Adapted from https://github.com/joshka/github-workflows/blob/main/.github/workflows/rust-release-plz.yml
# Thanks to joshka for permission to use this template!
name: Create patch release
permissions:
pull-requests: write
contents: write
on:
workflow_dispatch:
jobs:
release-plz:
# see https://release-plz.ieni.dev/docs/github
# for more information
name: Release-plz
runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: MarcoIeni/release-plz-action@v0.5
with:
command: release --bump-patch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+8
View File
@@ -0,0 +1,8 @@
repos:
- hooks:
- id: commitizen
- id: commitizen-branch
stages:
- pre-push
repo: https://github.com/commitizen-tools/commitizen
rev: v3.30.0
+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.1](https://github.com/Dark-Alex-17/managarr/compare/v0.2.0...v0.2.1) - 2024-11-06
### Other
- Removed the need for use_ssl to indicate SSL usage; instead just use the ssl_cert_path
- Applied bug fix to the downloads tab as well as the context [skip ci]
- Updated the README to not include the GitHub downloads badge since all binary releases are on crates.io [skip ci]
- Set all releases as manually triggered instead of automatic [skip ci]
- Updated dockerfile to no longer use the --disable-terminal-size-checks flag [skip ci]
## [0.1.5](https://github.com/Dark-Alex-17/managarr/compare/v0.1.4...v0.1.5) - 2024-11-03 ## [0.1.5](https://github.com/Dark-Alex-17/managarr/compare/v0.1.4...v0.1.5) - 2024-11-03
### Other ### Other
+32
View File
@@ -1,6 +1,7 @@
# Contributing # Contributing
Contributors are very welcome! **No contribution is too small and all contributions are valued.** Contributors are very welcome! **No contribution is too small and all contributions are valued.**
## Rust
You'll need to have the stable Rust toolchain installed in order to develop Managarr. You'll need to have the stable Rust toolchain installed in order to develop Managarr.
The Rust toolchain (stable) can be installed via rustup using the following command: The Rust toolchain (stable) can be installed via rustup using the following command:
@@ -11,6 +12,37 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This will install `rustup`, `rustc` and `cargo`. For more information, refer to the [official Rust installation documentation](https://www.rust-lang.org/tools/install). This will install `rustup`, `rustc` and `cargo`. For more information, refer to the [official Rust installation documentation](https://www.rust-lang.org/tools/install).
## Commitizen
[Commitizen](https://github.com/commitizen-tools/commitizen?tab=readme-ov-file) is a nifty tool that helps us write better commit messages. It ensures that our
commits have a consistent style and makes it easier to generate CHANGELOGS. Additionally,
Commitizen is used to run pre-commit checks to enforce style constraints.
To install `commitizen` and the `pre-commit` prerequisite, run the following command:
```shell
python3 -m pip install commitizen pre-commit
```
### Commitizen Quick Guide
To see an example commit to get an idea for the Commitizen style, run:
```shell
cz example
```
To see the allowed types of commits and their descriptions, run:
```shell
cz info
```
If you'd like to create a commit using Commitizen with an interactive prompt to help you get
comfortable with the style, use:
```shell
cz commit
```
## Setup workspace ## Setup workspace
1. Clone this repo 1. Clone this repo
Generated
+1 -1
View File
@@ -1148,7 +1148,7 @@ dependencies = [
[[package]] [[package]]
name = "managarr" name = "managarr"
version = "0.1.5" version = "0.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
+3 -3
View File
@@ -1,9 +1,9 @@
[package] [package]
name = "managarr" name = "managarr"
version = "0.1.5" version = "0.2.1"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs" description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "tui-rs", "dashboard", "servarr", "tui"] keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
documentation = "https://github.com/Dark-Alex-17/managarr" documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr" repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr" homepage = "https://github.com/Dark-Alex-17/managarr"
@@ -38,7 +38,7 @@ tokio = { version = "1.36.0", features = ["full"] }
tokio-util = "0.7.8" tokio-util = "0.7.8"
ratatui = { version = "0.28.0", features = ["all-widgets"] } ratatui = { version = "0.28.0", features = ["all-widgets"] }
urlencoding = "2.1.2" urlencoding = "2.1.2"
clap = { version = "4.5.20", features = ["derive", "cargo"] } clap = { version = "4.5.20", features = ["derive", "cargo", "env"] }
clap_complete = "4.5.33" clap_complete = "4.5.33"
itertools = "0.13.0" itertools = "0.13.0"
ctrlc = "3.4.5" ctrlc = "3.4.5"
+1 -1
View File
@@ -23,4 +23,4 @@ FROM debian:stable-slim
# Copy the compiled binary from the builder container # Copy the compiled binary from the builder container
COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin
ENTRYPOINT [ "/usr/local/bin/managarr", "--disable-terminal-size-checks" ] ENTRYPOINT [ "/usr/local/bin/managarr" ]
+18 -22
View File
@@ -2,13 +2,11 @@
![check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg) ![check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg) ![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/release.yml/badge.svg)
![License](https://img.shields.io/badge/license-MIT-blueviolet.svg) ![License](https://img.shields.io/badge/license-MIT-blueviolet.svg)
![LOC](https://tokei.rs/b1/github/Dark-Alex-17/managarr?category=code) ![LOC](https://tokei.rs/b1/github/Dark-Alex-17/managarr?category=code)
[![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr) [![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr)
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff) ![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff)
[![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr) [![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr)
[![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/managarr/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/managarr/releases)
![Crate.io downloads](https://img.shields.io/crates/d/managarr?label=Crate%20downloads) ![Crate.io downloads](https://img.shields.io/crates/d/managarr?label=Crate%20downloads)
Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust! Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust!
@@ -113,7 +111,7 @@ To see all available commands, simply run `managarr --help`:
```shell ```shell
$ managarr --help $ managarr --help
managarr 0.1.3 managarr 0.2.1
Alex Clarke <alex.j.tusa@gmail.com> Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs A TUI and CLI to manage your Servarrs
@@ -127,7 +125,6 @@ Commands:
Options: Options:
--config <CONFIG> The Managarr configuration file to use --config <CONFIG> The Managarr configuration file to use
--disable-terminal-size-checks Disable the terminal size checks
-h, --help Print help -h, --help Print help
-V, --version Print version -V, --version Print version
``` ```
@@ -159,7 +156,6 @@ Commands:
Options: Options:
--config <CONFIG> The Managarr configuration file to use --config <CONFIG> The Managarr configuration file to use
--disable-terminal-size-checks Disable the terminal size checks
-h, --help Print help -h, --help Print help
``` ```
@@ -203,47 +199,47 @@ managarr --config /path/to/config.yml
### Example Configuration: ### Example Configuration:
```yaml ```yaml
radarr: radarr:
host: 127.0.0.1 host: 192.168.0.78
port: 7878 port: 7878
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: true ssl_cert_path: /path/to/radarr.crt # Required to enable SSL
ssl_cert_path: /path/to/radarr.crt
sonarr: sonarr:
host: 127.0.0.1 uri: http://htpc.local/sonarr # Example of using the 'uri' key instead of 'host' and 'port'
port: 8989
api_token: someApiToken1234567890 api_token: someApiToken1234567890
readarr: readarr:
host: 127.0.0.1 host: 192.168.0.87
port: 8787 port: 8787
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: false
lidarr: lidarr:
host: 127.0.0.1 host: 192.168.0.86
port: 8686 port: 8686
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: false
whisparr: whisparr:
host: 127.0.0.1 host: 192.168.0.69
port: 6969 port: 6969
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: false ssl_cert_path: /path/to/whisparr.crt
bazarr: bazarr:
host: 127.0.0.1 host: 192.168.0.67
port: 6767 port: 6767
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: false
prowlarr: prowlarr:
host: 127.0.0.1 host: 192.168.0.96
port: 9696 port: 9696
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: false
tautulli: tautulli:
host: 127.0.0.1 host: 192.168.0.81
port: 8181 port: 8181
api_token: someApiToken1234567890 api_token: someApiToken1234567890
use_ssl: false
``` ```
## Environment Variables
Managarr supports using environment variables on startup so you don't have to always specify certain flags:
| Variable | Description | Equivalent Flag |
| --------------------------------------- | -------------------------------- | -------------------------------- |
| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` |
## Track My Progress for the Beta release (With Sonarr Support!) ## Track My Progress for the Beta release (With Sonarr Support!)
Progress for the beta release can be followed on my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr) Progress for the beta release can be followed on my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr)
with all items tagged `Beta`. with all items tagged `Beta`.
+6
View File
@@ -0,0 +1,6 @@
coverage:
range: "80..100"
ignore:
- "**/*_tests.rs"
- "src/ui"
+6
View File
@@ -0,0 +1,6 @@
{
"name": "managarr",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+1
View File
@@ -0,0 +1 @@
{}
+38 -8
View File
@@ -1,7 +1,7 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use anyhow::anyhow; use anyhow::anyhow;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::assert_eq;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
@@ -87,7 +87,11 @@ mod tests {
#[test] #[test]
fn test_reset_cancellation_token() { fn test_reset_cancellation_token() {
let mut app = App::default(); let mut app = App {
is_loading: true,
should_refresh: false,
..App::default()
};
app.cancellation_token.cancel(); app.cancellation_token.cancel();
assert!(app.cancellation_token.is_cancelled()); assert!(app.cancellation_token.is_cancelled());
@@ -96,6 +100,8 @@ mod tests {
assert!(!app.cancellation_token.is_cancelled()); assert!(!app.cancellation_token.is_cancelled());
assert!(!new_token.is_cancelled()); assert!(!new_token.is_cancelled());
assert!(!app.is_loading);
assert!(app.should_refresh);
} }
#[test] #[test]
@@ -145,6 +151,29 @@ mod tests {
assert_eq!(app.error.text, test_string); assert_eq!(app.error.text, test_string);
} }
#[tokio::test]
async fn test_dispatch_network_event() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App {
tick_until_poll: 2,
network_tx: Some(sync_network_tx),
..App::default()
};
assert_eq!(app.tick_count, 0);
app
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test] #[tokio::test]
async fn test_on_tick_first_render() { async fn test_on_tick_first_render() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500); let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
@@ -158,6 +187,7 @@ mod tests {
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
app.on_tick(true).await; app.on_tick(true).await;
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into() RadarrEvent::GetQualityProfiles.into()
@@ -170,6 +200,10 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into() RadarrEvent::GetRootFolders.into()
); );
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into() RadarrEvent::GetOverview.into()
@@ -182,10 +216,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetMovies.into() RadarrEvent::GetMovies.into()
); );
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(!app.is_routing); assert!(!app.is_routing);
assert!(!app.should_refresh); assert!(!app.should_refresh);
assert_eq!(app.tick_count, 1); assert_eq!(app.tick_count, 1);
@@ -221,10 +251,10 @@ mod tests {
fn test_radarr_config_default() { fn test_radarr_config_default() {
let radarr_config = RadarrConfig::default(); let radarr_config = RadarrConfig::default();
assert_str_eq!(radarr_config.host, "localhost"); assert_eq!(radarr_config.host, Some("localhost".to_string()));
assert_eq!(radarr_config.port, Some(7878)); assert_eq!(radarr_config.port, Some(7878));
assert_eq!(radarr_config.uri, None);
assert!(radarr_config.api_token.is_empty()); assert!(radarr_config.api_token.is_empty());
assert!(!radarr_config.use_ssl);
assert_eq!(radarr_config.ssl_cert_path, None); assert_eq!(radarr_config.ssl_cert_path, None);
} }
} }
+5
View File
@@ -33,6 +33,7 @@ generate_keybindings! {
tab, tab,
delete, delete,
submit, submit,
confirm,
quit, quit,
esc esc
} }
@@ -140,6 +141,10 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
key: Key::Enter, key: Key::Enter,
desc: "submit", desc: "submit",
}, },
confirm: KeyBinding {
key: Key::Ctrl('s'),
desc: "submit",
},
quit: KeyBinding { quit: KeyBinding {
key: Key::Char('q'), key: Key::Char('q'),
desc: "quit", desc: "quit",
+1
View File
@@ -31,6 +31,7 @@ mod test {
#[case(DEFAULT_KEYBINDINGS.tab, Key::Tab, "tab")] #[case(DEFAULT_KEYBINDINGS.tab, Key::Tab, "tab")]
#[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, "delete")] #[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, "delete")]
#[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, "submit")] #[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, "submit")]
#[case(DEFAULT_KEYBINDINGS.confirm, Key::Ctrl('s'), "submit")]
#[case(DEFAULT_KEYBINDINGS.quit, Key::Char('q'), "quit")] #[case(DEFAULT_KEYBINDINGS.quit, Key::Char('q'), "quit")]
#[case(DEFAULT_KEYBINDINGS.esc, Key::Esc, "close")] #[case(DEFAULT_KEYBINDINGS.esc, Key::Esc, "close")]
fn test_default_key_bindings_and_descriptions( fn test_default_key_bindings_and_descriptions(
+36 -5
View File
@@ -1,4 +1,7 @@
use std::process;
use anyhow::anyhow; use anyhow::anyhow;
use colored::Colorize;
use log::{debug, error}; use log::{debug, error};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
@@ -53,7 +56,10 @@ impl<'a> App<'a> {
pub async fn dispatch_network_event(&mut self, action: NetworkEvent) { pub async fn dispatch_network_event(&mut self, action: NetworkEvent) {
debug!("Dispatching network event: {action:?}"); debug!("Dispatching network event: {action:?}");
if !self.should_refresh {
self.is_loading = true; self.is_loading = true;
}
if let Some(network_tx) = &self.network_tx { if let Some(network_tx) = &self.network_tx {
if let Err(e) = network_tx.send(action).await { if let Err(e) = network_tx.send(action).await {
self.is_loading = false; self.is_loading = false;
@@ -110,6 +116,8 @@ impl<'a> App<'a> {
pub fn reset_cancellation_token(&mut self) -> CancellationToken { pub fn reset_cancellation_token(&mut self) -> CancellationToken {
self.cancellation_token = CancellationToken::new(); self.cancellation_token = CancellationToken::new();
self.should_refresh = true;
self.is_loading = false;
self.cancellation_token.clone() self.cancellation_token.clone()
} }
@@ -166,29 +174,52 @@ pub struct Data<'a> {
pub radarr_data: RadarrData<'a>, pub radarr_data: RadarrData<'a>,
} }
pub trait ServarrConfig {
fn validate(&self);
}
#[derive(Debug, Deserialize, Serialize, Default)] #[derive(Debug, Deserialize, Serialize, Default)]
pub struct AppConfig { pub struct AppConfig {
pub radarr: RadarrConfig, pub radarr: RadarrConfig,
} }
impl ServarrConfig for AppConfig {
fn validate(&self) {
self.radarr.validate();
}
}
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct RadarrConfig { pub struct RadarrConfig {
pub host: String, pub host: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
pub uri: Option<String>,
pub api_token: String, pub api_token: String,
#[serde(default)]
pub use_ssl: bool,
pub ssl_cert_path: Option<String>, pub ssl_cert_path: Option<String>,
} }
impl ServarrConfig for RadarrConfig {
fn validate(&self) {
if self.host.is_none() && self.uri.is_none() {
log_and_print_error("'host' or 'uri' is required for Radarr configuration".to_owned());
process::exit(1);
}
}
}
impl Default for RadarrConfig { impl Default for RadarrConfig {
fn default() -> Self { fn default() -> Self {
RadarrConfig { RadarrConfig {
host: "localhost".to_string(), host: Some("localhost".to_string()),
port: Some(7878), port: Some(7878),
uri: None,
api_token: "".to_string(), api_token: "".to_string(),
use_ssl: false,
ssl_cert_path: None, ssl_cert_path: None,
} }
} }
} }
pub fn log_and_print_error(error: String) {
error!("{}", error);
eprintln!("error: {}", error.red());
}
+11 -18
View File
@@ -142,36 +142,23 @@ impl<'a> App<'a> {
is_first_render: bool, is_first_render: bool,
) { ) {
if is_first_render { if is_first_render {
self self.refresh_metadata().await;
.dispatch_network_event(RadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetOverview.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
self.dispatch_by_radarr_block(&active_radarr_block).await; self.dispatch_by_radarr_block(&active_radarr_block).await;
} }
if self.should_refresh { if self.should_refresh {
self.dispatch_by_radarr_block(&active_radarr_block).await; self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await;
} }
if self.is_routing { if self.is_routing {
if self.is_loading && !self.should_refresh { if !self.should_refresh {
self.cancellation_token.cancel(); self.cancellation_token.cancel();
} } else {
self.dispatch_by_radarr_block(&active_radarr_block).await; self.dispatch_by_radarr_block(&active_radarr_block).await;
self.refresh_metadata().await; self.refresh_metadata().await;
} }
}
if self.tick_count % self.tick_until_poll == 0 { if self.tick_count % self.tick_until_poll == 0 {
self.refresh_metadata().await; self.refresh_metadata().await;
@@ -191,6 +178,12 @@ impl<'a> App<'a> {
self self
.dispatch_network_event(RadarrEvent::GetDownloads.into()) .dispatch_network_event(RadarrEvent::GetDownloads.into())
.await; .await;
self
.dispatch_network_event(RadarrEvent::GetOverview.into())
.await;
self
.dispatch_network_event(RadarrEvent::GetStatus.into())
.await;
} }
async fn populate_movie_collection_table(&mut self) { async fn populate_movie_collection_table(&mut self) {
+5
View File
@@ -120,6 +120,11 @@ pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.esc, "edit search"), (DEFAULT_KEYBINDINGS.esc, "edit search"),
]; ];
pub static CONFIRMATION_PROMPT_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.confirm, "submit"),
(DEFAULT_KEYBINDINGS.esc, "cancel"),
];
pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "start task"), (DEFAULT_KEYBINDINGS.submit, "start task"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
+18 -2
View File
@@ -5,8 +5,8 @@ mod tests {
use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::radarr::radarr_context_clues::{ use crate::app::radarr::radarr_context_clues::{
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES,
COLLECTION_DETAILS_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, COLLECTION_DETAILS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES,
LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES,
MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES,
SYSTEM_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES,
}; };
@@ -349,6 +349,22 @@ mod tests {
assert_eq!(add_movie_search_results_context_clues_iter.next(), None); assert_eq!(add_movie_search_results_context_clues_iter.next(), None);
} }
#[test]
fn test_confirmation_prompt_context_clues() {
let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter();
let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm);
assert_str_eq!(*description, "submit");
let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, "cancel");
assert_eq!(confirmation_prompt_context_clues_iter.next(), None);
}
#[test] #[test]
fn test_system_tasks_context_clues() { fn test_system_tasks_context_clues() {
let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter(); let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter();
+14 -30
View File
@@ -508,6 +508,14 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads.into()
); );
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into()
);
assert!(app.is_loading); assert!(app.is_loading);
} }
@@ -529,6 +537,10 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into() RadarrEvent::GetRootFolders.into()
); );
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetOverview.into() RadarrEvent::GetOverview.into()
@@ -537,10 +549,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetStatus.into() RadarrEvent::GetStatus.into()
); );
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading); assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
} }
@@ -549,6 +557,7 @@ mod tests {
async fn test_radarr_on_tick_routing() { async fn test_radarr_on_tick_routing() {
let (mut app, mut sync_network_rx) = construct_app_unit(); let (mut app, mut sync_network_rx) = construct_app_unit();
app.is_routing = true; app.is_routing = true;
app.should_refresh = true;
app app
.radarr_on_tick(ActiveRadarrBlock::Downloads, false) .radarr_on_tick(ActiveRadarrBlock::Downloads, false)
@@ -574,43 +583,19 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads.into()
); );
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
} }
#[tokio::test] #[tokio::test]
async fn test_radarr_on_tick_routing_while_long_request_is_running_should_cancel_request() { async fn test_radarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (mut app, mut sync_network_rx) = construct_app_unit(); let (mut app, _) = construct_app_unit();
app.is_routing = true; app.is_routing = true;
app.is_loading = true;
app.should_refresh = false; app.should_refresh = false;
app app
.radarr_on_tick(ActiveRadarrBlock::Downloads, false) .radarr_on_tick(ActiveRadarrBlock::Downloads, false)
.await; .await;
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetTags.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetRootFolders.into()
);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into()
);
assert!(app.is_loading);
assert!(!app.data.radarr_data.prompt_confirm);
assert!(app.cancellation_token.is_cancelled()); assert!(app.cancellation_token.is_cancelled());
} }
@@ -627,7 +612,6 @@ mod tests {
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads.into()
); );
assert!(app.is_loading);
assert!(app.should_refresh); assert!(app.should_refresh);
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
} }
@@ -584,6 +584,9 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::network::radarr_network::RadarrEvent;
use super::*; use super::*;
@@ -716,6 +719,43 @@ mod tests {
assert!(app.data.radarr_data.blocklist.sort.is_none()); assert!(app.data.radarr_data.blocklist.sort.is_none());
assert!(!app.data.radarr_data.blocklist.sort_asc); assert!(!app.data.radarr_data.blocklist.sort_asc);
} }
#[rstest]
#[case(
ActiveRadarrBlock::Blocklist,
ActiveRadarrBlock::DeleteBlocklistItemPrompt,
RadarrEvent::DeleteBlocklistItem(None)
)]
#[case(
ActiveRadarrBlock::Blocklist,
ActiveRadarrBlock::BlocklistClearAllItemsPrompt,
RadarrEvent::ClearBlocklist
)]
fn test_blocklist_prompt_confirm(
#[case] base_route: ActiveRadarrBlock,
#[case] prompt_block: ActiveRadarrBlock,
#[case] expected_action: RadarrEvent,
) {
let mut app = App::default();
app.data.radarr_data.blocklist.set_items(blocklist_vec());
app.push_navigation_stack(base_route.into());
app.push_navigation_stack(prompt_block.into());
BlocklistHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&prompt_block,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(expected_action)
);
assert_eq!(app.get_current_route(), &base_route.into());
}
} }
#[test] #[test]
+20 -2
View File
@@ -182,8 +182,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a,
fn handle_char_key_event(&mut self) { fn handle_char_key_event(&mut self) {
let key = self.key; let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::Blocklist { match self.active_radarr_block {
match self.key { ActiveRadarrBlock::Blocklist => match self.key {
_ if *key == DEFAULT_KEYBINDINGS.refresh.key => { _ if *key == DEFAULT_KEYBINDINGS.refresh.key => {
self.app.should_refresh = true; self.app.should_refresh = true;
} }
@@ -204,8 +204,26 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for BlocklistHandler<'a,
.push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into()); .push_navigation_stack(ActiveRadarrBlock::BlocklistSortPrompt.into());
} }
_ => (), _ => (),
},
ActiveRadarrBlock::DeleteBlocklistItemPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::DeleteBlocklistItem(None));
self.app.pop_navigation_stack();
} }
} }
ActiveRadarrBlock::BlocklistClearAllItemsPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::ClearBlocklist);
self.app.pop_navigation_stack();
}
}
_ => (),
}
} }
} }
@@ -1040,6 +1040,7 @@ mod tests {
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
RadarrData, EDIT_COLLECTION_SELECTION_BLOCKS, RadarrData, EDIT_COLLECTION_SELECTION_BLOCKS,
}; };
use crate::network::radarr_network::RadarrEvent;
use crate::test_edit_collection_key; use crate::test_edit_collection_key;
use super::*; use super::*;
@@ -1509,6 +1510,36 @@ mod tests {
assert!(app.data.radarr_data.collections.sort.is_none()); assert!(app.data.radarr_data.collections.sort.is_none());
assert!(!app.data.radarr_data.collections.sort_asc); assert!(!app.data.radarr_data.collections.sort_asc);
} }
#[test]
fn test_update_all_collections_prompt_confirm_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.collections
.set_items(vec![Collection::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Collections.into());
app.push_navigation_stack(ActiveRadarrBlock::UpdateAllCollectionsPrompt.into());
CollectionsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::UpdateAllCollectionsPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::UpdateCollections)
);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::Collections.into()
);
}
} }
#[rstest] #[rstest]
@@ -291,7 +291,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
fn handle_char_key_event(&mut self) { fn handle_char_key_event(&mut self) {
let key = self.key; let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::EditCollectionRootFolderPathInput { match self.active_radarr_block {
ActiveRadarrBlock::EditCollectionRootFolderPathInput => {
handle_text_box_keys!( handle_text_box_keys!(
self, self,
key, key,
@@ -305,5 +306,19 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditCollectionHandle
.path .path
) )
} }
ActiveRadarrBlock::EditCollectionPrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::EditCollectionConfirmPrompt
&& *key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditCollection(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (),
}
} }
} }
@@ -871,7 +871,15 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use super::*; use super::*;
use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::{
models::{
servarr_data::radarr::{
modals::EditCollectionModal, radarr_data::EDIT_COLLECTION_SELECTION_BLOCKS,
},
BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
#[test] #[test]
fn test_edit_collection_root_folder_path_input_backspace() { fn test_edit_collection_root_folder_path_input_backspace() {
@@ -927,6 +935,39 @@ mod tests {
"h" "h"
); );
} }
#[test]
fn test_edit_collection_confirm_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.data.radarr_data.edit_collection_modal = Some(EditCollectionModal::default());
app.push_navigation_stack(ActiveRadarrBlock::Collections.into());
app.push_navigation_stack(ActiveRadarrBlock::EditCollectionPrompt.into());
app.data.radarr_data.selected_block =
BlockSelectionState::new(&EDIT_COLLECTION_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1);
EditCollectionHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::EditCollectionPrompt,
&None,
)
.handle();
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::Collections.into()
);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditCollection(None))
);
assert!(app.should_refresh);
}
} }
#[test] #[test]
@@ -385,6 +385,14 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionsHandler<'
.unwrap() .unwrap()
) )
} }
ActiveRadarrBlock::UpdateAllCollectionsPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateCollections);
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -349,6 +349,9 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::network::radarr_network::RadarrEvent;
use super::*; use super::*;
@@ -450,6 +453,47 @@ mod tests {
); );
assert!(!app.should_refresh); assert!(!app.should_refresh);
} }
#[rstest]
#[case(
ActiveRadarrBlock::Downloads,
ActiveRadarrBlock::DeleteDownloadPrompt,
RadarrEvent::DeleteDownload(None)
)]
#[case(
ActiveRadarrBlock::Downloads,
ActiveRadarrBlock::UpdateDownloadsPrompt,
RadarrEvent::UpdateDownloads
)]
fn test_downloads_prompt_confirm_submit(
#[case] base_route: ActiveRadarrBlock,
#[case] prompt_block: ActiveRadarrBlock,
#[case] expected_action: RadarrEvent,
) {
let mut app = App::default();
app
.data
.radarr_data
.downloads
.set_items(vec![DownloadRecord::default()]);
app.push_navigation_stack(base_route.into());
app.push_navigation_stack(prompt_block.into());
DownloadsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&prompt_block,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(expected_action)
);
assert_eq!(app.get_current_route(), &base_route.into());
}
} }
#[test] #[test]
+19 -2
View File
@@ -119,8 +119,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a,
fn handle_char_key_event(&mut self) { fn handle_char_key_event(&mut self) {
let key = self.key; let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::Downloads { match self.active_radarr_block {
match self.key { ActiveRadarrBlock::Downloads => match self.key {
_ if *key == DEFAULT_KEYBINDINGS.update.key => { _ if *key == DEFAULT_KEYBINDINGS.update.key => {
self self
.app .app
@@ -130,7 +130,24 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DownloadsHandler<'a,
self.app.should_refresh = true; self.app.should_refresh = true;
} }
_ => (), _ => (),
},
ActiveRadarrBlock::DeleteDownloadPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteDownload(None));
self.app.pop_navigation_stack();
} }
} }
ActiveRadarrBlock::UpdateDownloadsPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateDownloads);
self.app.pop_navigation_stack();
}
}
_ => (),
}
} }
} }
@@ -429,6 +429,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditIndexerHandler<'
.tags .tags
); );
} }
ActiveRadarrBlock::EditIndexerPrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::EditIndexerConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditIndexer(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -1282,6 +1282,9 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use crate::app::App; use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditIndexerModal; use crate::models::servarr_data::radarr::modals::EditIndexerModal;
use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS;
use crate::models::BlockSelectionState;
use crate::network::radarr_network::RadarrEvent;
use pretty_assertions::assert_str_eq; use pretty_assertions::assert_str_eq;
use super::*; use super::*;
@@ -1560,6 +1563,37 @@ mod tests {
"h" "h"
); );
} }
#[test]
fn test_edit_indexer_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.push_navigation_stack(ActiveRadarrBlock::Indexers.into());
app.push_navigation_stack(ActiveRadarrBlock::EditIndexerPrompt.into());
app.data.radarr_data.selected_block =
BlockSelectionState::new(&EDIT_INDEXER_TORRENT_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1);
app.data.radarr_data.edit_indexer_modal = Some(EditIndexerModal::default());
EditIndexerHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::EditIndexerPrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
assert!(app.data.radarr_data.edit_indexer_modal.is_some());
assert!(app.should_refresh);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditIndexer(None))
);
}
} }
#[test] #[test]
@@ -241,7 +241,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
} }
fn handle_char_key_event(&mut self) { fn handle_char_key_event(&mut self) {
if self.active_radarr_block == &ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput { match self.active_radarr_block {
ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput => {
handle_text_box_keys!( handle_text_box_keys!(
self, self,
self.key, self.key,
@@ -255,5 +256,20 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexerSettingsHandl
.whitelisted_hardcoded_subs .whitelisted_hardcoded_subs
) )
} }
ActiveRadarrBlock::AllIndexerSettingsPrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::IndexerSettingsConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::EditAllIndexerSettings(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (),
}
} }
} }
@@ -851,7 +851,13 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use pretty_assertions::assert_str_eq; use pretty_assertions::assert_str_eq;
use crate::models::radarr_models::IndexerSettings; use crate::{
models::{
radarr_models::IndexerSettings,
servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
use super::*; use super::*;
@@ -909,6 +915,37 @@ mod tests {
"h" "h"
); );
} }
#[test]
fn test_edit_indexer_settings_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.push_navigation_stack(ActiveRadarrBlock::Indexers.into());
app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into());
app.data.radarr_data.selected_block =
BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1);
app.data.radarr_data.indexer_settings = Some(IndexerSettings::default());
IndexerSettingsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::AllIndexerSettingsPrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditAllIndexerSettings(None))
);
assert!(app.data.radarr_data.indexer_settings.is_some());
assert!(app.should_refresh);
}
} }
#[test] #[test]
@@ -464,7 +464,10 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use crate::models::servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; use crate::{
models::servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS,
network::radarr_network::RadarrEvent,
};
use super::*; use super::*;
@@ -696,6 +699,33 @@ mod tests {
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into()); assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
} }
#[test]
fn test_delete_indexer_prompt_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.indexers
.set_items(vec![Indexer::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Indexers.into());
app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into());
IndexersHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::DeleteIndexerPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::DeleteIndexer(None))
);
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Indexers.into());
}
} }
#[rstest] #[rstest]
+11 -2
View File
@@ -166,8 +166,8 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a,
fn handle_char_key_event(&mut self) { fn handle_char_key_event(&mut self) {
let key = self.key; let key = self.key;
if self.active_radarr_block == &ActiveRadarrBlock::Indexers { match self.active_radarr_block {
match self.key { ActiveRadarrBlock::Indexers => match self.key {
_ if *key == DEFAULT_KEYBINDINGS.add.key => { _ if *key == DEFAULT_KEYBINDINGS.add.key => {
self self
.app .app
@@ -194,7 +194,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for IndexersHandler<'a,
BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS); BlockSelectionState::new(&INDEXER_SETTINGS_SELECTION_BLOCKS);
} }
_ => (), _ => (),
},
ActiveRadarrBlock::DeleteIndexerPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteIndexer(None));
self.app.pop_navigation_stack();
} }
} }
_ => (),
}
} }
} }
@@ -461,6 +461,16 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for AddMovieHandler<'a,
.tags .tags
) )
} }
ActiveRadarrBlock::AddMoviePrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::AddMovieConfirmPrompt
&& *key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::AddMovie(None));
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -1494,7 +1494,13 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use super::*; use super::*;
use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::{
models::{
servarr_data::radarr::{modals::AddMovieModal, radarr_data::ADD_MOVIE_SELECTION_BLOCKS},
BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
#[test] #[test]
fn test_add_movie_search_input_backspace() { fn test_add_movie_search_input_backspace() {
@@ -1588,6 +1594,35 @@ mod tests {
"h" "h"
); );
} }
#[test]
fn test_add_movie_confirm_prompt_prompt_confirmation_confirm() {
let mut app = App::default();
app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default());
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::AddMoviePrompt.into());
app.data.radarr_data.selected_block = BlockSelectionState::new(&ADD_MOVIE_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(ADD_MOVIE_SELECTION_BLOCKS.len() - 1);
AddMovieHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::AddMoviePrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::AddMovie(None))
);
assert!(app.data.radarr_data.add_movie_modal.is_some());
}
} }
#[test] #[test]
@@ -1,3 +1,4 @@
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App; use crate::app::App;
use crate::event::Key; use crate::event::Key;
use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; use crate::handlers::{handle_prompt_toggle, KeyEventHandler};
@@ -100,5 +101,17 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for DeleteMovieHandler<'
} }
} }
fn handle_char_key_event(&mut self) {} fn handle_char_key_event(&mut self) {
if self.active_radarr_block == &ActiveRadarrBlock::DeleteMoviePrompt
&& self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::DeleteMovieConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::DeleteMovie(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
} }
@@ -250,6 +250,51 @@ mod tests {
} }
} }
mod test_handle_key_char {
use crate::{
models::{
servarr_data::radarr::radarr_data::DELETE_MOVIE_SELECTION_BLOCKS, BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
use super::*;
#[test]
fn test_delete_movie_confirm_prompt_prompt_confirm() {
let mut app = App::default();
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into());
app.data.radarr_data.delete_movie_files = true;
app.data.radarr_data.add_list_exclusion = true;
app.data.radarr_data.selected_block =
BlockSelectionState::new(&DELETE_MOVIE_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(DELETE_MOVIE_SELECTION_BLOCKS.len() - 1);
DeleteMovieHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::DeleteMoviePrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::DeleteMovie(None))
);
assert!(app.should_refresh);
assert!(app.data.radarr_data.prompt_confirm);
assert!(app.data.radarr_data.delete_movie_files);
assert!(app.data.radarr_data.add_list_exclusion);
}
}
#[test] #[test]
fn test_delete_movie_handler_accepts() { fn test_delete_movie_handler_accepts() {
ActiveRadarrBlock::iter().for_each(|active_radarr_block| { ActiveRadarrBlock::iter().for_each(|active_radarr_block| {
@@ -327,6 +327,18 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for EditMovieHandler<'a,
.tags .tags
) )
} }
ActiveRadarrBlock::EditMoviePrompt => {
if self.app.data.radarr_data.selected_block.get_active_block()
== &ActiveRadarrBlock::EditMovieConfirmPrompt
&& *key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::EditMovie(None));
self.app.should_refresh = true;
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -940,7 +940,16 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use super::*; use super::*;
use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::{
models::{
servarr_data::radarr::{
modals::EditMovieModal,
radarr_data::{EDIT_COLLECTION_SELECTION_BLOCKS, EDIT_MOVIE_SELECTION_BLOCKS},
},
BlockSelectionState,
},
network::radarr_network::RadarrEvent,
};
#[test] #[test]
fn test_edit_movie_path_input_backspace() { fn test_edit_movie_path_input_backspace() {
@@ -1051,6 +1060,36 @@ mod tests {
"h" "h"
); );
} }
#[test]
fn test_edit_movie_confirm_prompt_prompt_confirm() {
let mut app = App::default();
app.data.radarr_data.edit_movie_modal = Some(EditMovieModal::default());
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::EditMoviePrompt.into());
app.data.radarr_data.selected_block = BlockSelectionState::new(&EDIT_MOVIE_SELECTION_BLOCKS);
app
.data
.radarr_data
.selected_block
.set_index(EDIT_COLLECTION_SELECTION_BLOCKS.len() - 1);
EditMovieHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::EditMoviePrompt,
&None,
)
.handle();
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::EditMovie(None))
);
assert!(app.data.radarr_data.edit_movie_modal.is_some());
assert!(app.should_refresh);
}
} }
#[test] #[test]
@@ -996,6 +996,7 @@ mod tests {
RadarrData, EDIT_MOVIE_SELECTION_BLOCKS, RadarrData, EDIT_MOVIE_SELECTION_BLOCKS,
}; };
use crate::network::radarr_network::RadarrEvent;
use crate::test_edit_movie_key; use crate::test_edit_movie_key;
use super::*; use super::*;
@@ -1452,6 +1453,33 @@ mod tests {
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into()); assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
assert!(app.data.radarr_data.movies.sort.is_none()); assert!(app.data.radarr_data.movies.sort.is_none());
} }
#[test]
fn test_update_all_movies_prompt_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.movies
.set_items(vec![Movie::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
app.push_navigation_stack(ActiveRadarrBlock::UpdateAllMoviesPrompt.into());
LibraryHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::UpdateAllMoviesPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::UpdateAllMovies)
);
assert_eq!(app.get_current_route(), &ActiveRadarrBlock::Movies.into());
}
} }
#[rstest] #[rstest]
@@ -386,6 +386,14 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for LibraryHandler<'a, '
self.app.data.radarr_data.movies.filter.as_mut().unwrap() self.app.data.radarr_data.movies.filter.as_mut().unwrap()
) )
} }
ActiveRadarrBlock::UpdateAllMoviesPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAllMovies);
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -47,18 +47,28 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<
} }
fn is_ready(&self) -> bool { fn is_ready(&self) -> bool {
let movie_details_modal_is_ready =
if let Some(movie_details_modal) = &self.app.data.radarr_data.movie_details_modal { if let Some(movie_details_modal) = &self.app.data.radarr_data.movie_details_modal {
!movie_details_modal.movie_details.is_empty() match self.active_radarr_block {
|| !movie_details_modal.movie_history.is_empty() ActiveRadarrBlock::MovieDetails => {
|| !movie_details_modal.movie_cast.is_empty() !self.app.is_loading && !movie_details_modal.movie_details.is_empty()
|| !movie_details_modal.movie_crew.is_empty() }
|| !movie_details_modal.movie_releases.is_empty() ActiveRadarrBlock::MovieHistory => {
!self.app.is_loading && !movie_details_modal.movie_history.is_empty()
}
ActiveRadarrBlock::Cast => {
!self.app.is_loading && !movie_details_modal.movie_cast.is_empty()
}
ActiveRadarrBlock::Crew => {
!self.app.is_loading && !movie_details_modal.movie_crew.is_empty()
}
ActiveRadarrBlock::ManualSearch => {
!self.app.is_loading && !movie_details_modal.movie_releases.is_empty()
}
_ => !self.app.is_loading,
}
} else { } else {
false false
}; }
!self.app.is_loading && movie_details_modal_is_ready
} }
fn handle_scroll_up(&mut self) { fn handle_scroll_up(&mut self) {
@@ -464,6 +474,32 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler<
} }
_ => (), _ => (),
}, },
ActiveRadarrBlock::AutomaticallySearchMoviePrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::TriggerAutomaticSearch(None));
self.app.pop_navigation_stack();
}
}
ActiveRadarrBlock::UpdateAndScanPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::UpdateAndScan(None));
self.app.pop_navigation_stack();
}
}
ActiveRadarrBlock::ManualSearchConfirmPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::DownloadRelease(None));
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -3,6 +3,7 @@ mod tests {
use std::cmp::Ordering; use std::cmp::Ordering;
use pretty_assertions::assert_str_eq; use pretty_assertions::assert_str_eq;
use rstest::rstest;
use serde_json::Number; use serde_json::Number;
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
@@ -1245,10 +1246,12 @@ mod tests {
#[test] #[test]
fn test_manual_search_submit() { fn test_manual_search_submit() {
let mut app = App::default(); let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()), movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default() ..MovieDetailsModal::default()
}); };
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into());
MovieDetailsHandler::with( MovieDetailsHandler::with(
@@ -1468,6 +1471,7 @@ mod tests {
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
RadarrData, EDIT_MOVIE_SELECTION_BLOCKS, RadarrData, EDIT_MOVIE_SELECTION_BLOCKS,
}; };
use crate::network::radarr_network::RadarrEvent;
use crate::test_edit_movie_key; use crate::test_edit_movie_key;
use super::*; use super::*;
@@ -1485,10 +1489,17 @@ mod tests {
active_radarr_block: ActiveRadarrBlock, active_radarr_block: ActiveRadarrBlock,
) { ) {
let mut app = App::default(); let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()), movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default() ..MovieDetailsModal::default()
}); };
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with( MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.search.key, &DEFAULT_KEYBINDINGS.search.key,
@@ -1538,10 +1549,9 @@ mod tests {
#[test] #[test]
fn test_sort_key() { fn test_sort_key() {
let mut app = App::default(); let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { let mut modal = MovieDetailsModal::default();
movie_details: ScrollableText::with_string("test".to_owned()), modal.movie_releases.set_items(release_vec());
..MovieDetailsModal::default() app.data.radarr_data.movie_details_modal = Some(modal);
});
MovieDetailsHandler::with( MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.sort.key, &DEFAULT_KEYBINDINGS.sort.key,
@@ -1669,10 +1679,17 @@ mod tests {
active_radarr_block: ActiveRadarrBlock, active_radarr_block: ActiveRadarrBlock,
) { ) {
let mut app = App::default(); let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()), movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default() ..MovieDetailsModal::default()
}); };
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with( MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.update.key, &DEFAULT_KEYBINDINGS.update.key,
@@ -1732,10 +1749,17 @@ mod tests {
active_radarr_block: ActiveRadarrBlock, active_radarr_block: ActiveRadarrBlock,
) { ) {
let mut app = App::default(); let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal { let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()), movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default() ..MovieDetailsModal::default()
}); };
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
MovieDetailsHandler::with( MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.refresh.key, &DEFAULT_KEYBINDINGS.refresh.key,
@@ -1780,6 +1804,50 @@ mod tests {
assert_eq!(app.get_current_route(), &active_radarr_block.into()); assert_eq!(app.get_current_route(), &active_radarr_block.into());
assert!(app.is_routing); assert!(app.is_routing);
} }
#[rstest]
#[case(
ActiveRadarrBlock::AutomaticallySearchMoviePrompt,
RadarrEvent::TriggerAutomaticSearch(None)
)]
#[case(
ActiveRadarrBlock::UpdateAndScanPrompt,
RadarrEvent::UpdateAndScan(None)
)]
#[case(
ActiveRadarrBlock::ManualSearchConfirmPrompt,
RadarrEvent::DownloadRelease(None)
)]
fn test_movie_info_prompt_confirm(
#[case] prompt_block: ActiveRadarrBlock,
#[case] expected_action: RadarrEvent,
) {
let mut app = App::default();
app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal {
movie_details: ScrollableText::with_string("test".to_owned()),
..MovieDetailsModal::default()
});
app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into());
app.push_navigation_stack(prompt_block.into());
MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&prompt_block,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::MovieDetails.into()
);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(expected_action)
);
}
} }
#[test] #[test]
@@ -1949,15 +2017,37 @@ mod tests {
}); });
} }
#[test] #[rstest]
fn test_movie_details_handler_is_not_ready_when_loading() { fn test_movie_details_handler_is_not_ready_when_loading(
#[values(
ActiveRadarrBlock::MovieDetails,
ActiveRadarrBlock::MovieHistory,
ActiveRadarrBlock::FileInfo,
ActiveRadarrBlock::Cast,
ActiveRadarrBlock::Crew,
ActiveRadarrBlock::ManualSearch,
ActiveRadarrBlock::ManualSearch
)]
movie_details_block: ActiveRadarrBlock,
) {
let mut app = App::default(); let mut app = App::default();
app.is_loading = true; app.is_loading = true;
let mut modal = MovieDetailsModal {
movie_details: ScrollableText::with_string("Test".to_owned()),
..MovieDetailsModal::default()
};
modal
.movie_history
.set_items(vec![MovieHistoryItem::default()]);
modal.movie_cast.set_items(vec![Credit::default()]);
modal.movie_crew.set_items(vec![Credit::default()]);
modal.movie_releases.set_items(vec![Release::default()]);
app.data.radarr_data.movie_details_modal = Some(modal);
let handler = MovieDetailsHandler::with( let handler = MovieDetailsHandler::with(
&DEFAULT_KEYBINDINGS.esc.key, &DEFAULT_KEYBINDINGS.esc.key,
&mut app, &mut app,
&ActiveRadarrBlock::MovieDetails, &movie_details_block,
&None, &None,
); );
@@ -180,6 +180,15 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for RootFoldersHandler<'
self.app.data.radarr_data.edit_root_folder.as_mut().unwrap() self.app.data.radarr_data.edit_root_folder.as_mut().unwrap()
) )
} }
ActiveRadarrBlock::DeleteRootFolderPrompt => {
if *key == DEFAULT_KEYBINDINGS.confirm.key {
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action =
Some(RadarrEvent::DeleteRootFolder(None));
self.app.pop_navigation_stack();
}
}
_ => (), _ => (),
} }
} }
@@ -554,6 +554,8 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use crate::network::radarr_network::RadarrEvent;
use super::*; use super::*;
#[test] #[test]
@@ -706,6 +708,36 @@ mod tests {
"h" "h"
); );
} }
#[test]
fn test_delete_root_folder_prompt_confirm() {
let mut app = App::default();
app
.data
.radarr_data
.root_folders
.set_items(vec![RootFolder::default()]);
app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into());
app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into());
RootFoldersHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::DeleteRootFolderPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::DeleteRootFolder(None))
);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::RootFolders.into()
);
}
} }
#[test] #[test]
@@ -168,5 +168,13 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for SystemDetailsHandler
{ {
self.app.should_refresh = true; self.app.should_refresh = true;
} }
if self.active_radarr_block == &ActiveRadarrBlock::SystemTaskStartConfirmPrompt
&& *self.key == DEFAULT_KEYBINDINGS.confirm.key
{
self.app.data.radarr_data.prompt_confirm = true;
self.app.data.radarr_data.prompt_confirm_action = Some(RadarrEvent::StartTask(None));
self.app.pop_navigation_stack();
}
} }
} }
@@ -858,6 +858,8 @@ mod tests {
mod test_handle_key_char { mod test_handle_key_char {
use rstest::rstest; use rstest::rstest;
use crate::network::radarr_network::RadarrEvent;
use super::*; use super::*;
#[rstest] #[rstest]
@@ -912,6 +914,32 @@ mod tests {
assert_eq!(app.get_current_route(), &active_radarr_block.into()); assert_eq!(app.get_current_route(), &active_radarr_block.into());
assert!(!app.should_refresh); assert!(!app.should_refresh);
} }
#[test]
fn test_system_tasks_start_task_prompt_confirm() {
let mut app = App::default();
app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned());
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into());
SystemDetailsHandler::with(
&DEFAULT_KEYBINDINGS.confirm.key,
&mut app,
&ActiveRadarrBlock::SystemTaskStartConfirmPrompt,
&None,
)
.handle();
assert!(app.data.radarr_data.prompt_confirm);
assert_eq!(
app.data.radarr_data.prompt_confirm_action,
Some(RadarrEvent::StartTask(None))
);
assert_eq!(
app.get_current_route(),
&ActiveRadarrBlock::SystemTasks.into()
);
}
} }
#[test] #[test]
+39 -46
View File
@@ -6,11 +6,12 @@ use std::panic::PanicHookInfo;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use std::{io, panic, process}; use std::{io, panic, process};
use anyhow::anyhow; use anyhow::anyhow;
use anyhow::Result; use anyhow::Result;
use app::AppConfig; use app::{log_and_print_error, AppConfig, ServarrConfig};
use clap::{ use clap::{
command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser,
}; };
@@ -18,13 +19,14 @@ use clap_complete::generate;
use colored::Colorize; use colored::Colorize;
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, size, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
}; };
use log::error; use log::{error, warn};
use network::NetworkTrait; use network::NetworkTrait;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use reqwest::{Certificate, Client}; use reqwest::{Certificate, Client};
use tokio::select;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -46,9 +48,6 @@ mod network;
mod ui; mod ui;
mod utils; mod utils;
static MIN_TERM_WIDTH: u16 = 205;
static MIN_TERM_HEIGHT: u16 = 40;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
name = crate_name!(), name = crate_name!(),
@@ -71,11 +70,10 @@ struct Cli {
long, long,
global = true, global = true,
value_parser, value_parser,
env = "MANAGARR_CONFIG_FILE",
help = "The Managarr configuration file to use" help = "The Managarr configuration file to use"
)] )]
config: Option<PathBuf>, config: Option<PathBuf>,
#[arg(long, global = true, help = "Disable the terminal size checks")]
disable_terminal_size_checks: bool,
} }
#[tokio::main] #[tokio::main]
@@ -92,6 +90,7 @@ async fn main() -> Result<()> {
} else { } else {
confy::load("managarr", "config")? confy::load("managarr", "config")?
}; };
config.validate();
let reqwest_client = build_network_client(&config); let reqwest_client = build_network_client(&config);
let (sync_network_tx, sync_network_rx) = mpsc::channel(500); let (sync_network_tx, sync_network_rx) = mpsc::channel(500);
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
@@ -131,7 +130,7 @@ async fn main() -> Result<()> {
std::thread::spawn(move || { std::thread::spawn(move || {
start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client) start_networking(sync_network_rx, &app_nw, cancellation_token, reqwest_client)
}); });
start_ui(&app, !args.disable_terminal_size_checks).await?; start_ui(&app).await?;
} }
} }
@@ -147,27 +146,25 @@ async fn start_networking(
) { ) {
let mut network = Network::new(app, cancellation_token, client); let mut network = Network::new(app, cancellation_token, client);
while let Some(network_event) = network_rx.recv().await { loop {
select! {
Some(network_event) = network_rx.recv() => {
if let Err(e) = network.handle_network_event(network_event).await { if let Err(e) = network.handle_network_event(network_event).await {
error!("Encountered an error handling network event: {e:?}"); error!("Encountered an error handling network event: {e:?}");
} }
} }
_ = network.cancellation_token.cancelled() => {
warn!("Clearing network channel");
while network_rx.try_recv().is_ok() {
// Discard the message
}
network.reset_cancellation_token().await;
}
}
}
} }
async fn start_ui(app: &Arc<Mutex<App<'_>>>, check_terminal_size: bool) -> Result<()> { async fn start_ui(app: &Arc<Mutex<App<'_>>>) -> Result<()> {
if check_terminal_size {
let (width, height) = size()?;
if width < MIN_TERM_WIDTH || height < MIN_TERM_HEIGHT {
return Err(anyhow!(
"Terminal too small. Minimum size required: {}x{}; current terminal size: {}x{}",
MIN_TERM_WIDTH,
MIN_TERM_HEIGHT,
width,
height
));
}
}
let mut stdout = io::stdout(); let mut stdout = io::stdout();
enable_raw_mode()?; enable_raw_mode()?;
@@ -244,10 +241,13 @@ fn load_config(path: &str) -> Result<AppConfig> {
} }
fn build_network_client(config: &AppConfig) -> Client { fn build_network_client(config: &AppConfig) -> Client {
let mut client_builder = Client::builder(); let mut client_builder = Client::builder()
.pool_max_idle_per_host(10)
.http2_keep_alive_interval(Duration::from_secs(5))
.tcp_keepalive(Duration::from_secs(5));
if config.radarr.use_ssl { if let Some(ref cert_path) = config.radarr.ssl_cert_path {
let cert = add_cert_to_builder(config.radarr.ssl_cert_path.clone(), "Radarr"); let cert = create_cert(cert_path, "Radarr");
client_builder = client_builder.add_root_certificate(cert); client_builder = client_builder.add_root_certificate(cert);
} }
@@ -261,32 +261,25 @@ fn build_network_client(config: &AppConfig) -> Client {
} }
} }
fn add_cert_to_builder(cert_path: Option<String>, servarr_name: &str) -> Certificate { fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate {
let err = |error: String| { match fs::read(cert_path) {
error!("{}", error);
eprintln!("error: {}", error.red());
process::exit(1);
};
if cert_path.is_none() {
err(format!(
"A {} cert path is required when 'use_ssl' is 'true'",
servarr_name
));
}
match fs::read(cert_path.unwrap()) {
Ok(cert) => match Certificate::from_pem(&cert) { Ok(cert) => match Certificate::from_pem(&cert) {
Ok(certificate) => certificate, Ok(certificate) => certificate,
Err(_) => err(format!( Err(_) => {
log_and_print_error(format!(
"Unable to read the specified {} SSL certificate", "Unable to read the specified {} SSL certificate",
servarr_name servarr_name
)), ));
process::exit(1);
}
}, },
Err(_) => err(format!( Err(_) => {
log_and_print_error(format!(
"Unable to open specified {} SSL certificate", "Unable to open specified {} SSL certificate",
servarr_name servarr_name
)), ));
process::exit(1);
}
} }
} }
+5 -4
View File
@@ -40,7 +40,7 @@ pub trait NetworkTrait {
#[derive(Clone)] #[derive(Clone)]
pub struct Network<'a, 'b> { pub struct Network<'a, 'b> {
client: Client, client: Client,
cancellation_token: CancellationToken, pub cancellation_token: CancellationToken,
pub app: &'a Arc<Mutex<App<'b>>>, pub app: &'a Arc<Mutex<App<'b>>>,
} }
@@ -74,6 +74,10 @@ impl<'a, 'b> Network<'a, 'b> {
} }
} }
pub(super) async fn reset_cancellation_token(&mut self) {
self.cancellation_token = self.app.lock().await.reset_cancellation_token();
}
async fn handle_request<B, R>( async fn handle_request<B, R>(
&mut self, &mut self,
request_props: RequestProps<B>, request_props: RequestProps<B>,
@@ -89,9 +93,6 @@ impl<'a, 'b> Network<'a, 'b> {
select! { select! {
_ = self.cancellation_token.cancelled() => { _ = self.cancellation_token.cancelled() => {
warn!("Received Cancel request. Cancelling request to: {request_uri}"); warn!("Received Cancel request. Cancelling request to: {request_uri}");
let mut app = self.app.lock().await;
self.cancellation_token = app.reset_cancellation_token();
app.is_loading = false;
Ok(R::default()) Ok(R::default())
} }
resp = self.call_api(request_props).await.send() => { resp = self.call_api(request_props).await.send() => {
+23 -3
View File
@@ -26,7 +26,7 @@ mod tests {
.with_body("{}") .with_body("{}")
.create_async() .create_async()
.await; .await;
let host = server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned(); let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some( let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1] server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse() .parse()
@@ -38,8 +38,8 @@ mod tests {
host, host,
api_token: String::new(), api_token: String::new(),
port, port,
use_ssl: false,
ssl_cert_path: None, ssl_cert_path: None,
..RadarrConfig::default()
}; };
app.config.radarr = radarr_config; app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app)); let app_arc = Arc::new(Mutex::new(app));
@@ -181,11 +181,31 @@ mod tests {
assert!(!async_server.matched_async().await); assert!(!async_server.matched_async().await);
assert!(app_arc.lock().await.error.text.is_empty()); assert!(app_arc.lock().await.error.text.is_empty());
assert!(!network.cancellation_token.is_cancelled());
assert!(resp.is_ok()); assert!(resp.is_ok());
assert_eq!(resp.unwrap(), Test::default()); assert_eq!(resp.unwrap(), Test::default());
} }
#[tokio::test]
async fn test_reset_cancellation_token() {
let cancellation_token = CancellationToken::new();
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let app_arc = Arc::new(Mutex::new(App::new(
tx,
AppConfig::default(),
cancellation_token.clone(),
)));
app_arc.lock().await.should_refresh = false;
app_arc.lock().await.is_loading = true;
let mut network = Network::new(&app_arc, cancellation_token, Client::new());
network.cancellation_token.cancel();
network.reset_cancellation_token().await;
assert!(!network.cancellation_token.is_cancelled());
assert!(app_arc.lock().await.should_refresh);
assert!(!app_arc.lock().await.is_loading);
}
#[tokio::test] #[tokio::test]
async fn test_handle_request_get_invalid_body() { async fn test_handle_request_get_invalid_body() {
let mut server = Server::new_async().await; let mut server = Server::new_async().await;
+14 -5
View File
@@ -2261,15 +2261,24 @@ impl<'a, 'b> Network<'a, 'b> {
let RadarrConfig { let RadarrConfig {
host, host,
port, port,
uri,
api_token, api_token,
use_ssl, ssl_cert_path,
..
} = &app.config.radarr; } = &app.config.radarr;
let protocol = if *use_ssl { "https" } else { "http" }; let uri = if let Some(radarr_uri) = uri {
let uri = format!( format!("{radarr_uri}/api/v3{resource}")
} else {
let protocol = if ssl_cert_path.is_some() {
"https"
} else {
"http"
};
let host = host.as_ref().unwrap();
format!(
"{protocol}://{host}:{}/api/v3{resource}", "{protocol}://{host}:{}/api/v3{resource}",
port.unwrap_or(7878) port.unwrap_or(7878)
); )
};
RequestProps { RequestProps {
uri, uri,
+31 -12
View File
@@ -794,7 +794,7 @@ mod test {
.match_header("X-Api-Key", "test1234"); .match_header("X-Api-Key", "test1234");
async_server = async_server.expect_at_most(0).create_async().await; async_server = async_server.expect_at_most(0).create_async().await;
let host = server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned(); let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some( let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1] server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse() .parse()
@@ -805,8 +805,7 @@ mod test {
host, host,
port, port,
api_token: "test1234".to_owned(), api_token: "test1234".to_owned(),
use_ssl: false, ..RadarrConfig::default()
ssl_cert_path: None,
}; };
app.config.radarr = radarr_config; app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app)); let app_arc = Arc::new(Mutex::new(app));
@@ -4876,11 +4875,10 @@ mod test {
assert!(request_props.api_token.is_empty()); assert!(request_props.api_token.is_empty());
app_arc.lock().await.config.radarr = RadarrConfig { app_arc.lock().await.config.radarr = RadarrConfig {
host: "192.168.0.123".to_owned(), host: Some("192.168.0.123".to_owned()),
port: Some(8080), port: Some(8080),
api_token: "testToken1234".to_owned(), api_token: "testToken1234".to_owned(),
use_ssl: false, ..RadarrConfig::default()
ssl_cert_path: None,
}; };
} }
@@ -4889,11 +4887,33 @@ mod test {
let api_token = "testToken1234".to_owned(); let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::default()));
app_arc.lock().await.config.radarr = RadarrConfig { app_arc.lock().await.config.radarr = RadarrConfig {
host: "192.168.0.123".to_owned(), host: Some("192.168.0.123".to_owned()),
port: Some(8080), port: Some(8080),
api_token: api_token.clone(), api_token: api_token.clone(),
use_ssl: true, ssl_cert_path: Some("/test/cert.crt".to_owned()),
ssl_cert_path: None, ..RadarrConfig::default()
};
let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
let request_props = network
.radarr_request_props_from("/test", RequestMethod::Get, None::<()>)
.await;
assert_str_eq!(request_props.uri, "https://192.168.0.123:8080/api/v3/test");
assert_eq!(request_props.method, RequestMethod::Get);
assert_eq!(request_props.body, None);
assert_str_eq!(request_props.api_token, api_token);
}
#[tokio::test]
async fn test_radarr_request_props_from_custom_radarr_config_using_uri_instead_of_host_and_port()
{
let api_token = "testToken1234".to_owned();
let app_arc = Arc::new(Mutex::new(App::default()));
app_arc.lock().await.config.radarr = RadarrConfig {
uri: Some("https://192.168.0.123:8080".to_owned()),
api_token: api_token.clone(),
..RadarrConfig::default()
}; };
let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let network = Network::new(&app_arc, CancellationToken::new(), Client::new());
@@ -4986,7 +5006,7 @@ mod test {
async_server = async_server.create_async().await; async_server = async_server.create_async().await;
let host = server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned(); let host = Some(server.host_with_port().split(':').collect::<Vec<&str>>()[0].to_owned());
let port = Some( let port = Some(
server.host_with_port().split(':').collect::<Vec<&str>>()[1] server.host_with_port().split(':').collect::<Vec<&str>>()[1]
.parse() .parse()
@@ -4997,8 +5017,7 @@ mod test {
host, host,
port, port,
api_token: "test1234".to_owned(), api_token: "test1234".to_owned(),
use_ssl: false, ..RadarrConfig::default()
ssl_cert_path: None,
}; };
app.config.radarr = radarr_config; app.config.radarr = radarr_config;
let app_arc = Arc::new(Mutex::new(app)); let app_arc = Arc::new(Mutex::new(app));
+4 -4
View File
@@ -1,6 +1,6 @@
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Style, Stylize}; use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Text}; use ratatui::text::{Line, Text};
use ratatui::widgets::Clear; use ratatui::widgets::Clear;
@@ -83,7 +83,7 @@ fn draw_header_row(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.select(app.server_tabs.index); .select(app.server_tabs.index);
let help = Paragraph::new(help_text) let help = Paragraph::new(help_text)
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Right); .right_aligned();
f.render_widget(tabs, tabs_area); f.render_widget(tabs, tabs_area);
f.render_widget(help, help_area); f.render_widget(help, help_area);
@@ -170,7 +170,7 @@ fn draw_tabs(f: &mut Frame<'_>, area: Rect, title: &str, tab_state: &TabState) -
.select(tab_state.index); .select(tab_state.index);
let help = Paragraph::new(Text::from(tab_state.get_active_tab_help().help())) let help = Paragraph::new(Text::from(tab_state.get_active_tab_help().help()))
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Right); .right_aligned();
f.render_widget(tabs, tabs_area); f.render_widget(tabs, tabs_area);
f.render_widget(help, help_area); f.render_widget(help, help_area);
@@ -197,7 +197,7 @@ pub fn draw_input_box_popup(
let help = Paragraph::new("<esc> cancel") let help = Paragraph::new("<esc> cancel")
.help() .help()
.alignment(Alignment::Center) .centered()
.block(borderless_block()); .block(borderless_block());
f.render_widget(help, help_area); f.render_widget(help, help_area);
} }
@@ -259,7 +259,7 @@ fn draw_movie_overview(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
let help_paragraph = Paragraph::new(help_text) let help_paragraph = Paragraph::new(help_text)
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Center); .centered();
f.render_widget(paragraph, paragraph_area); f.render_widget(paragraph, paragraph_area);
f.render_widget(help_paragraph, help_area); f.render_widget(help_paragraph, help_area);
@@ -1,9 +1,12 @@
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::widgets::ListItem; use ratatui::text::Text;
use ratatui::widgets::{ListItem, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::context_clues::build_context_clue_string;
use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES;
use crate::app::App; use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::modals::EditCollectionModal;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
@@ -110,7 +113,7 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>
let selected_minimum_availability = minimum_availability_list.current_selection(); let selected_minimum_availability = minimum_availability_list.current_selection();
let selected_quality_profile = quality_profile_list.current_selection(); let selected_quality_profile = quality_profile_list.current_selection();
let [paragraph_area, monitored_area, min_availability_area, quality_profile_area, root_folder_area, search_on_add_area, _, buttons_area] = let [paragraph_area, monitored_area, min_availability_area, quality_profile_area, root_folder_area, search_on_add_area, _, buttons_area, help_area] =
Layout::vertical([ Layout::vertical([
Constraint::Length(6), Constraint::Length(6),
Constraint::Length(3), Constraint::Length(3),
@@ -118,8 +121,9 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1),
]) ])
.margin(1) .margin(1)
.areas(area); .areas(area);
@@ -127,6 +131,8 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(buttons_area); .areas(buttons_area);
let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
let prompt_paragraph = layout_paragraph_borderless(&collection_overview); let prompt_paragraph = layout_paragraph_borderless(&collection_overview);
let monitored_checkbox = Checkbox::new("Monitored") let monitored_checkbox = Checkbox::new("Monitored")
.highlighted(selected_block == &ActiveRadarrBlock::EditCollectionToggleMonitored) .highlighted(selected_block == &ActiveRadarrBlock::EditCollectionToggleMonitored)
@@ -169,6 +175,7 @@ fn draw_edit_collection_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>
f.render_widget(search_on_add_checkbox, search_on_add_area); f.render_widget(search_on_add_checkbox, search_on_add_area);
f.render_widget(save_button, save_area); f.render_widget(save_button, save_area);
f.render_widget(cancel_button, cancel_area); f.render_widget(cancel_button, cancel_area);
f.render_widget(help_paragraph, help_area);
} }
fn draw_edit_collection_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) { fn draw_edit_collection_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) {
+5 -1
View File
@@ -92,7 +92,11 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
); );
} }
let percent = 1f64 - (*sizeleft as f64 / *size as f64); let percent = if *size == 0 {
0.0
} else {
1f64 - (*sizeleft as f64 / *size as f64)
};
let file_size: f64 = convert_to_gb(*size); let file_size: f64 = convert_to_gb(*size);
Row::new(vec![ Row::new(vec![
+15 -6
View File
@@ -1,5 +1,7 @@
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use crate::app::context_clues::build_context_clue_string;
use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES;
use crate::app::App; use crate::app::App;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS};
use crate::models::Route; use crate::models::Route;
@@ -14,6 +16,8 @@ use crate::ui::widgets::loading_block::LoadingBlock;
use crate::ui::widgets::popup::Size; use crate::ui::widgets::popup::Size;
use crate::ui::{draw_popup_over, DrawUi}; use crate::ui::{draw_popup_over, DrawUi};
use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::Frame; use ratatui::Frame;
#[cfg(test)] #[cfg(test)]
@@ -50,32 +54,36 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let highlight_yes_no = selected_block == &ActiveRadarrBlock::EditIndexerConfirmPrompt; let highlight_yes_no = selected_block == &ActiveRadarrBlock::EditIndexerConfirmPrompt;
let edit_indexer_modal_option = &app.data.radarr_data.edit_indexer_modal; let edit_indexer_modal_option = &app.data.radarr_data.edit_indexer_modal;
let protocol = &app.data.radarr_data.indexers.current_selection().protocol; let protocol = &app.data.radarr_data.indexers.current_selection().protocol;
let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
if edit_indexer_modal_option.is_some() { if edit_indexer_modal_option.is_some() {
let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap();
let [settings_area, buttons_area] = let [settings_area, _, buttons_area, help_area] = Layout::vertical([
Layout::vertical([Constraint::Fill(0), Constraint::Length(3)]) Constraint::Length(15),
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(1),
])
.margin(1) .margin(1)
.areas(area); .areas(area);
let [left_side_area, right_side_area] = let [left_side_area, right_side_area] =
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.margin(1) .margin(1)
.areas(settings_area); .areas(settings_area);
let [name_area, rss_area, auto_search_area, interactive_search_area, _] = Layout::vertical([ let [name_area, rss_area, auto_search_area, interactive_search_area] = Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0),
]) ])
.areas(left_side_area); .areas(left_side_area);
let [url_area, api_key_area, seed_ratio_area, tags_area, _] = Layout::vertical([ let [url_area, api_key_area, seed_ratio_area, tags_area] = Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0),
]) ])
.areas(right_side_area); .areas(right_side_area);
@@ -161,6 +169,7 @@ fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
f.render_widget(interactive_search_checkbox, interactive_search_area); f.render_widget(interactive_search_checkbox, interactive_search_area);
f.render_widget(save_button, save_area); f.render_widget(save_button, save_area);
f.render_widget(cancel_button, cancel_area); f.render_widget(cancel_button, cancel_area);
f.render_widget(help_paragraph, help_area);
} }
} else { } else {
f.render_widget(LoadingBlock::new(app.is_loading, block), area); f.render_widget(LoadingBlock::new(app.is_loading, block), area);
@@ -1,8 +1,12 @@
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::Frame; use ratatui::Frame;
use crate::app::context_clues::build_context_clue_string;
use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES;
use crate::app::App; use crate::app::App;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS, ActiveRadarrBlock, INDEXER_SETTINGS_BLOCKS,
@@ -52,33 +56,37 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area:
let selected_block = app.data.radarr_data.selected_block.get_active_block(); let selected_block = app.data.radarr_data.selected_block.get_active_block();
let highlight_yes_no = selected_block == &ActiveRadarrBlock::IndexerSettingsConfirmPrompt; let highlight_yes_no = selected_block == &ActiveRadarrBlock::IndexerSettingsConfirmPrompt;
let indexer_settings_option = &app.data.radarr_data.indexer_settings; let indexer_settings_option = &app.data.radarr_data.indexer_settings;
let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
if indexer_settings_option.is_some() { if indexer_settings_option.is_some() {
let indexer_settings = indexer_settings_option.as_ref().unwrap(); let indexer_settings = indexer_settings_option.as_ref().unwrap();
let [settings_area, buttons_area] = let [settings_area, _, buttons_area, help_area] = Layout::vertical([
Layout::vertical([Constraint::Fill(0), Constraint::Length(3)]) Constraint::Length(15),
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(1),
])
.margin(1) .margin(1)
.areas(area); .areas(area);
let [left_side_area, right_side_area] = let [left_side_area, right_side_area] =
Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.margin(1) .margin(1)
.areas(settings_area); .areas(settings_area);
let [min_age_area, retention_area, max_size_area, prefer_flags_area, _] = Layout::vertical([ let [min_age_area, retention_area, max_size_area, prefer_flags_area] = Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0),
]) ])
.areas(left_side_area); .areas(left_side_area);
let [availability_delay_area, rss_sync_interval_area, whitelisted_sub_tags_area, allow_hardcoded_subs_area, _] = let [availability_delay_area, rss_sync_interval_area, whitelisted_sub_tags_area, allow_hardcoded_subs_area] =
Layout::vertical([ Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0),
]) ])
.areas(right_side_area); .areas(right_side_area);
@@ -162,6 +170,7 @@ fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area:
f.render_widget(allow_hardcoded_subs_checkbox, allow_hardcoded_subs_area); f.render_widget(allow_hardcoded_subs_checkbox, allow_hardcoded_subs_area);
f.render_widget(save_button, save_area); f.render_widget(save_button, save_area);
f.render_widget(cancel_button, cancel_area); f.render_widget(cancel_button, cancel_area);
f.render_widget(help_paragraph, help_area);
} else { } else {
f.render_widget(LoadingBlock::new(app.is_loading, block), area); f.render_widget(LoadingBlock::new(app.is_loading, block), area);
} }
+13 -7
View File
@@ -1,12 +1,14 @@
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::Text; use ratatui::text::Text;
use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; use ratatui::widgets::{Cell, ListItem, Paragraph, Row};
use ratatui::Frame; use ratatui::Frame;
use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES};
use crate::app::radarr::radarr_context_clues::ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES; use crate::app::radarr::radarr_context_clues::{
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
};
use crate::models::radarr_models::AddMovieSearchResult; use crate::models::radarr_models::AddMovieSearchResult;
use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::modals::AddMovieModal;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS};
@@ -209,7 +211,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help()); let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text) let help_paragraph = Paragraph::new(help_text)
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Center); .centered();
search_box.show_cursor(f, search_box_area); search_box.show_cursor(f, search_box_area);
f.render_widget(layout_block(), results_area); f.render_widget(layout_block(), results_area);
@@ -220,7 +222,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help()); let help_text = Text::from(build_context_clue_string(&BARE_POPUP_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text) let help_paragraph = Paragraph::new(help_text)
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Center); .centered();
let error_message = Message::new("No movies found matching your query!"); let error_message = Message::new("No movies found matching your query!");
let error_message_popup = Popup::new(error_message).size(Size::Message); let error_message_popup = Popup::new(error_message).size(Size::Message);
@@ -240,7 +242,7 @@ fn draw_add_movie_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Text::from(build_context_clue_string(&ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES).help()); Text::from(build_context_clue_string(&ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text) let help_paragraph = Paragraph::new(help_text)
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Center); .centered();
let search_results_table = ManagarrTable::new( let search_results_table = ManagarrTable::new(
app.data.radarr_data.add_searched_movies.as_mut(), app.data.radarr_data.add_searched_movies.as_mut(),
search_results_row_mapping, search_results_row_mapping,
@@ -369,7 +371,7 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
f.render_widget(title_block_centered(&title), area); f.render_widget(title_block_centered(&title), area);
let [paragraph_area, root_folder_area, monitor_area, min_availability_area, quality_profile_area, tags_area, _, buttons_area] = let [paragraph_area, root_folder_area, monitor_area, min_availability_area, quality_profile_area, tags_area, _, buttons_area, help_area] =
Layout::vertical([ Layout::vertical([
Constraint::Length(6), Constraint::Length(6),
Constraint::Length(3), Constraint::Length(3),
@@ -377,14 +379,18 @@ fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1),
]) ])
.margin(1) .margin(1)
.areas(area); .areas(area);
let prompt_paragraph = layout_paragraph_borderless(&prompt); let prompt_paragraph = layout_paragraph_borderless(&prompt);
let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
f.render_widget(prompt_paragraph, paragraph_area); f.render_widget(prompt_paragraph, paragraph_area);
f.render_widget(help_paragraph, help_area);
let [add_area, cancel_area] = let [add_area, cancel_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
+10 -3
View File
@@ -2,9 +2,12 @@ use std::sync::atomic::Ordering;
use ratatui::layout::{Constraint, Rect}; use ratatui::layout::{Constraint, Rect};
use ratatui::prelude::Layout; use ratatui::prelude::Layout;
use ratatui::widgets::ListItem; use ratatui::text::Text;
use ratatui::widgets::{ListItem, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::context_clues::build_context_clue_string;
use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES;
use crate::app::App; use crate::app::App;
use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::modals::EditMovieModal;
use crate::models::servarr_data::radarr::radarr_data::{ use crate::models::servarr_data::radarr::radarr_data::{
@@ -113,7 +116,7 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are
let selected_minimum_availability = minimum_availability_list.current_selection(); let selected_minimum_availability = minimum_availability_list.current_selection();
let selected_quality_profile = quality_profile_list.current_selection(); let selected_quality_profile = quality_profile_list.current_selection();
let [paragraph_area, monitored_area, min_availability_area, quality_profile_area, path_area, tags_area, _, buttons_area] = let [paragraph_area, monitored_area, min_availability_area, quality_profile_area, path_area, tags_area, _, buttons_area, help_area] =
Layout::vertical([ Layout::vertical([
Constraint::Length(6), Constraint::Length(6),
Constraint::Length(3), Constraint::Length(3),
@@ -121,8 +124,9 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(0), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1),
]) ])
.margin(1) .margin(1)
.areas(area); .areas(area);
@@ -130,6 +134,8 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(buttons_area); .areas(buttons_area);
let help_text = Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
let prompt_paragraph = layout_paragraph_borderless(&movie_overview); let prompt_paragraph = layout_paragraph_borderless(&movie_overview);
let monitored_checkbox = Checkbox::new("Monitored") let monitored_checkbox = Checkbox::new("Monitored")
.checked(monitored.unwrap_or_default()) .checked(monitored.unwrap_or_default())
@@ -181,6 +187,7 @@ fn draw_edit_movie_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, are
f.render_widget(quality_profile_drop_down_button, quality_profile_area); f.render_widget(quality_profile_drop_down_button, quality_profile_area);
f.render_widget(save_button, save_area); f.render_widget(save_button, save_area);
f.render_widget(cancel_button, cancel_area); f.render_widget(cancel_button, cancel_area);
f.render_widget(help_paragraph, help_area);
} }
fn draw_edit_movie_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) { fn draw_edit_movie_select_minimum_availability_popup(f: &mut Frame<'_>, app: &mut App<'_>) {
+29 -21
View File
@@ -1,6 +1,6 @@
use std::iter; use std::iter;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Style, Stylize}; use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text}; use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap};
@@ -166,6 +166,7 @@ fn draw_file_info(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
let block = layout_block_top_border(); let block = layout_block_top_border();
let unknown_download_status = "Status: Unknown".to_owned();
match app.data.radarr_data.movie_details_modal.as_ref() { match app.data.radarr_data.movie_details_modal.as_ref() {
Some(movie_details_modal) if !app.is_loading => { Some(movie_details_modal) if !app.is_loading => {
@@ -182,7 +183,7 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
.items .items
.iter() .iter()
.find(|&line| line.starts_with("Status: ")) .find(|&line| line.starts_with("Status: "))
.unwrap() .unwrap_or(&unknown_download_status)
.split(": ") .split(": ")
.collect::<Vec<&str>>()[1]; .collect::<Vec<&str>>()[1];
let text = Text::from( let text = Text::from(
@@ -285,6 +286,8 @@ fn draw_movie_history(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
} }
fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
match app.data.radarr_data.movie_details_modal.as_mut() {
Some(movie_details_modal) if !app.is_loading => {
let cast_row_mapping = |cast_member: &Credit| { let cast_row_mapping = |cast_member: &Credit| {
let Credit { let Credit {
person_name, person_name,
@@ -298,15 +301,7 @@ fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
]) ])
.success() .success()
}; };
let content = Some( let content = Some(&mut movie_details_modal.movie_cast);
&mut app
.data
.radarr_data
.movie_details_modal
.as_mut()
.unwrap()
.movie_cast,
);
let help_footer = app let help_footer = app
.data .data
.radarr_data .radarr_data
@@ -320,9 +315,20 @@ fn draw_movie_cast(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]); .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]);
f.render_widget(cast_table, area); f.render_widget(cast_table, area);
}
_ => f.render_widget(
LoadingBlock::new(
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
layout_block_top_border(),
),
area,
),
}
} }
fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
match app.data.radarr_data.movie_details_modal.as_mut() {
Some(movie_details_modal) if !app.is_loading => {
let crew_row_mapping = |crew_member: &Credit| { let crew_row_mapping = |crew_member: &Credit| {
let Credit { let Credit {
person_name, person_name,
@@ -338,15 +344,7 @@ fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
]) ])
.success() .success()
}; };
let content = Some( let content = Some(&mut movie_details_modal.movie_crew);
&mut app
.data
.radarr_data
.movie_details_modal
.as_mut()
.unwrap()
.movie_crew,
);
let help_footer = app let help_footer = app
.data .data
.radarr_data .radarr_data
@@ -360,6 +358,16 @@ fn draw_movie_crew(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.footer(help_footer); .footer(help_footer);
f.render_widget(crew_table, area); f.render_widget(crew_table, area);
}
_ => f.render_widget(
LoadingBlock::new(
app.is_loading || app.data.radarr_data.movie_details_modal.is_none(),
layout_block_top_border(),
),
area,
),
}
} }
fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
@@ -510,7 +518,7 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) {
let content_paragraph = Paragraph::new(lines_vec) let content_paragraph = Paragraph::new(lines_vec)
.block(borderless_block()) .block(borderless_block())
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.alignment(Alignment::Left); .left_aligned();
let confirmation_prompt = ConfirmationPrompt::new() let confirmation_prompt = ConfirmationPrompt::new()
.title(title) .title(title)
.prompt(&prompt) .prompt(&prompt)
+7 -3
View File
@@ -1,7 +1,7 @@
use std::iter; use std::iter;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::prelude::Stylize; use ratatui::prelude::Stylize;
use ratatui::text::Text; use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Row}; use ratatui::widgets::{Paragraph, Row};
@@ -192,7 +192,11 @@ fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) {
size, size,
.. ..
} = &downloads_vec[i]; } = &downloads_vec[i];
let percent = 1f64 - (*sizeleft as f64 / *size as f64); let percent = if *size == 0 {
0.0
} else {
1f64 - (*sizeleft as f64 / *size as f64)
};
let download_gauge = line_gauge_with_title(title, percent); let download_gauge = line_gauge_with_title(title, percent);
f.render_widget(download_gauge, download_item_areas[i]); f.render_widget(download_gauge, download_item_areas[i]);
@@ -244,6 +248,6 @@ fn draw_radarr_logo(f: &mut Frame<'_>, area: Rect) {
let logo = Paragraph::new(logo_text) let logo = Paragraph::new(logo_text)
.light_yellow() .light_yellow()
.block(layout_block().default()) .block(layout_block().default())
.alignment(Alignment::Center); .centered();
f.render_widget(logo, area); f.render_widget(logo, area);
} }
+2 -2
View File
@@ -1,7 +1,7 @@
use std::ops::Sub; use std::ops::Sub;
use chrono::Utc; use chrono::Utc;
use ratatui::layout::{Alignment, Layout}; use ratatui::layout::Layout;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::text::{Span, Text}; use ratatui::text::{Span, Text};
use ratatui::widgets::{Cell, Paragraph, Row}; use ratatui::widgets::{Cell, Paragraph, Row};
@@ -204,7 +204,7 @@ fn draw_help(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
); );
let help_paragraph = Paragraph::new(help_text) let help_paragraph = Paragraph::new(help_text)
.block(layout_block_top_border()) .block(layout_block_top_border())
.alignment(Alignment::Left); .left_aligned();
f.render_widget(help_paragraph, area); f.render_widget(help_paragraph, area);
} }
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn layout_paragraph_borderless(string: &str) -> Paragraph<'_> {
.primary() .primary()
.bold() .bold()
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.alignment(Alignment::Center) .centered()
} }
pub fn borderless_block<'a>() -> Block<'a> { pub fn borderless_block<'a>() -> Block<'a> {
+5 -5
View File
@@ -1,7 +1,7 @@
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{layout_block, style_block_highlight}; use crate::ui::utils::{layout_block, style_block_highlight};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::prelude::{Style, Text, Widget}; use ratatui::prelude::{Style, Text, Widget};
use ratatui::style::Styled; use ratatui::style::Styled;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
@@ -58,11 +58,11 @@ impl<'a> Button<'a> {
if let Some(icon) = self.icon { if let Some(icon) = self.icon {
layout_block().style(style).render(area, buf); layout_block().style(style).render(area, buf);
Paragraph::new(Text::from(self.title)) Paragraph::new(Text::from(self.title))
.alignment(Alignment::Left) .left_aligned()
.style(style) .style(style)
.render(title_area, buf); .render(title_area, buf);
Paragraph::new(Text::from(format!("{icon} "))) Paragraph::new(Text::from(format!("{icon} ")))
.alignment(Alignment::Right) .right_aligned()
.style(style) .style(style)
.render(icon_area, buf); .render(icon_area, buf);
} }
@@ -72,7 +72,7 @@ impl<'a> Button<'a> {
let [label_area, button_area] = let [label_area, button_area] =
Layout::horizontal([Constraint::Percentage(48), Constraint::Percentage(48)]).areas(area); Layout::horizontal([Constraint::Percentage(48), Constraint::Percentage(48)]).areas(area);
let label_paragraph = Paragraph::new(Text::from(format!("\n{}: ", self.label.unwrap()))) let label_paragraph = Paragraph::new(Text::from(format!("\n{}: ", self.label.unwrap())))
.alignment(Alignment::Right) .right_aligned()
.primary(); .primary();
if self.icon.is_some() { if self.icon.is_some() {
@@ -87,7 +87,7 @@ impl<'a> Button<'a> {
fn render_button(self, area: Rect, buf: &mut Buffer) { fn render_button(self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(self.title)) Paragraph::new(Text::from(self.title))
.block(layout_block()) .block(layout_block())
.alignment(Alignment::Center) .centered()
.style(style_block_highlight(self.is_selected)) .style(style_block_highlight(self.is_selected))
.render(area, buf); .render(area, buf);
} }
+3 -3
View File
@@ -1,7 +1,7 @@
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{borderless_block, layout_block, style_block_highlight}; use crate::ui::utils::{borderless_block, layout_block, style_block_highlight};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::prelude::Text; use ratatui::prelude::Text;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::widgets::{Paragraph, Widget}; use ratatui::widgets::{Paragraph, Widget};
@@ -47,13 +47,13 @@ impl<'a> Checkbox<'a> {
Paragraph::new(Text::from(format!("\n{}: ", self.label))) Paragraph::new(Text::from(format!("\n{}: ", self.label)))
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Right) .right_aligned()
.primary() .primary()
.render(label_area, buf); .render(label_area, buf);
Paragraph::new(Text::from(check)) Paragraph::new(Text::from(check))
.block(layout_block()) .block(layout_block())
.alignment(Alignment::Center) .centered()
.style(style_block_highlight(self.is_highlighted).bold()) .style(style_block_highlight(self.is_highlighted).bold())
.render(checkbox_box_area, buf); .render(checkbox_box_area, buf);
} }
+24 -5
View File
@@ -1,9 +1,12 @@
use crate::app::context_clues::build_context_clue_string;
use crate::app::radarr::radarr_context_clues::CONFIRMATION_PROMPT_CONTEXT_CLUES;
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{layout_paragraph_borderless, title_block_centered}; use crate::ui::utils::{layout_paragraph_borderless, title_block_centered};
use crate::ui::widgets::button::Button; use crate::ui::widgets::button::Button;
use crate::ui::widgets::checkbox::Checkbox; use crate::ui::widgets::checkbox::Checkbox;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Widget}; use ratatui::widgets::{Paragraph, Widget};
use std::iter; use std::iter;
@@ -64,12 +67,16 @@ impl<'a> ConfirmationPrompt<'a> {
fn render_confirmation_prompt_with_checkboxes(self, area: Rect, buf: &mut Buffer) { fn render_confirmation_prompt_with_checkboxes(self, area: Rect, buf: &mut Buffer) {
title_block_centered(self.title).render(area, buf); title_block_centered(self.title).render(area, buf);
let help_text =
Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
if let Some(checkboxes) = self.checkboxes { if let Some(checkboxes) = self.checkboxes {
let mut constraints = vec![ let mut constraints = vec![
Constraint::Length(4), Constraint::Length(4),
Constraint::Fill(0), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1),
]; ];
constraints.splice( constraints.splice(
1..1, 1..1,
@@ -81,6 +88,7 @@ impl<'a> ConfirmationPrompt<'a> {
.areas(chunks[checkboxes.len() + 2]); .areas(chunks[checkboxes.len() + 2]);
layout_paragraph_borderless(self.prompt).render(chunks[0], buf); layout_paragraph_borderless(self.prompt).render(chunks[0], buf);
help_paragraph.render(chunks[checkboxes.len() + 3], buf);
checkboxes checkboxes
.into_iter() .into_iter()
@@ -102,27 +110,38 @@ impl<'a> ConfirmationPrompt<'a> {
fn render_confirmation_prompt(self, area: Rect, buf: &mut Buffer) { fn render_confirmation_prompt(self, area: Rect, buf: &mut Buffer) {
title_block_centered(self.title).render(area, buf); title_block_centered(self.title).render(area, buf);
let help_text =
Text::from(build_context_clue_string(&CONFIRMATION_PROMPT_CONTEXT_CLUES).help());
let help_paragraph = Paragraph::new(help_text).centered();
let [prompt_area, buttons_area] = if let Some(content_paragraph) = self.content { let [prompt_area, buttons_area] = if let Some(content_paragraph) = self.content {
let [prompt_area, content_area, _, buttons_area] = Layout::vertical([ let [prompt_area, content_area, _, buttons_area, help_area] = Layout::vertical([
Constraint::Length(4), Constraint::Length(4),
Constraint::Length(7), Constraint::Length(7),
Constraint::Fill(0), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(1),
]) ])
.margin(1) .margin(1)
.areas(area); .areas(area);
content_paragraph.render(content_area, buf); content_paragraph.render(content_area, buf);
help_paragraph.render(help_area, buf);
[prompt_area, buttons_area] [prompt_area, buttons_area]
} else { } else {
let [prompt_area, buttons_area] = let [prompt_area, buttons_area, _, help_area] = Layout::vertical([
Layout::vertical([Constraint::Percentage(72), Constraint::Length(3)]) Constraint::Percentage(72),
Constraint::Length(3),
Constraint::Fill(0),
Constraint::Min(1),
])
.margin(1) .margin(1)
.flex(Flex::SpaceBetween) .flex(Flex::SpaceBetween)
.areas(area); .areas(area);
help_paragraph.render(help_area, buf);
[prompt_area, buttons_area] [prompt_area, buttons_area]
}; };
+2 -2
View File
@@ -1,5 +1,5 @@
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::prelude::Text; use ratatui::prelude::Text;
use ratatui::style::{Style, Styled, Stylize}; use ratatui::style::{Style, Styled, Stylize};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
@@ -114,7 +114,7 @@ impl<'a> InputBox<'a> {
Paragraph::new(Text::from(format!("\n{label}: "))) Paragraph::new(Text::from(format!("\n{label}: ")))
.block(borderless_block()) .block(borderless_block())
.alignment(Alignment::Right) .right_aligned()
.primary() .primary()
.render(label_area, buf); .render(label_area, buf);
input_box_paragraph.render(text_box_area, buf); input_box_paragraph.render(text_box_area, buf);
+3 -3
View File
@@ -1,7 +1,7 @@
use crate::ui::styles::ManagarrStyle; use crate::ui::styles::ManagarrStyle;
use crate::ui::utils::{background_block, centered_rect, layout_block_top_border}; use crate::ui::utils::{background_block, centered_rect, layout_block_top_border};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::prelude::Text; use ratatui::prelude::Text;
use ratatui::widgets::{Block, Clear, Paragraph, Widget}; use ratatui::widgets::{Block, Clear, Paragraph, Widget};
@@ -27,7 +27,7 @@ impl Size {
pub fn to_percent(&self) -> (u16, u16) { pub fn to_percent(&self) -> (u16, u16) {
match self { match self {
Size::SmallPrompt => (20, 20), Size::SmallPrompt => (20, 20),
Size::Prompt => (35, 35), Size::Prompt => (37, 37),
Size::LargePrompt => (70, 45), Size::LargePrompt => (70, 45),
Size::Message => (25, 8), Size::Message => (25, 8),
Size::NarrowMessage => (50, 20), Size::NarrowMessage => (50, 20),
@@ -100,7 +100,7 @@ impl<'a, T: Widget> Popup<'a, T> {
Paragraph::new(Text::from(format!(" {footer}").help())) Paragraph::new(Text::from(format!(" {footer}").help()))
.block(layout_block_top_border()) .block(layout_block_top_border())
.alignment(Alignment::Left) .left_aligned()
.render(help_footer_area, buf); .render(help_footer_area, buf);
content_area content_area
+1 -1
View File
@@ -7,7 +7,7 @@ mod tests {
#[test] #[test]
fn test_dimensions_to_percent() { fn test_dimensions_to_percent() {
assert_eq!(Size::SmallPrompt.to_percent(), (20, 20)); assert_eq!(Size::SmallPrompt.to_percent(), (20, 20));
assert_eq!(Size::Prompt.to_percent(), (35, 35)); assert_eq!(Size::Prompt.to_percent(), (37, 37));
assert_eq!(Size::LargePrompt.to_percent(), (70, 45)); assert_eq!(Size::LargePrompt.to_percent(), (70, 45));
assert_eq!(Size::Message.to_percent(), (25, 8)); assert_eq!(Size::Message.to_percent(), (25, 8));
assert_eq!(Size::NarrowMessage.to_percent(), (50, 20)); assert_eq!(Size::NarrowMessage.to_percent(), (50, 20));