From 0d71766cf95121d5aeb36bb81e95b95ea29f80e4 Mon Sep 17 00:00:00 2001 From: Samuel Hitz Date: Thu, 9 Apr 2026 07:32:06 +0200 Subject: [PATCH 1/4] refactor theme --- AGENTS.md | 10 +- openspec/specs/add-torrent/spec.md | 5 +- openspec/specs/connection-screen/spec.md | 2 +- openspec/specs/file-selection/spec.md | 15 +- openspec/specs/m3-components/spec.md | 4 +- openspec/specs/material-theme/spec.md | 4 +- openspec/specs/rpc-client/spec.md | 3 +- src/auth.rs | 52 +- src/screens/main_screen.rs | 2 +- src/screens/settings/view.rs | 43 +- src/screens/torrent_list/add_dialog.rs | 7 +- src/screens/torrent_list/view.rs | 51 +- src/theme.rs | 866 +---------------------- src/theme/buttons.rs | 392 ++++++++++ src/theme/containers.rs | 211 ++++++ src/theme/inputs.rs | 97 +++ src/theme/progress.rs | 70 ++ src/theme/segmented.rs | 119 ++++ src/theme/selection.rs | 95 +++ system_architecture.md | 3 +- 20 files changed, 1051 insertions(+), 1000 deletions(-) create mode 100644 src/theme/buttons.rs create mode 100644 src/theme/containers.rs create mode 100644 src/theme/inputs.rs create mode 100644 src/theme/progress.rs create mode 100644 src/theme/segmented.rs create mode 100644 src/theme/selection.rs diff --git a/AGENTS.md b/AGENTS.md index db8aa7b..5d3a05c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,10 +63,11 @@ Never create a new file without this header. --- -## Theme & UI Elements (`src/theme.rs`) +## Theme & UI Elements (`crate::theme`) -All visual styling lives in `src/theme.rs`. Import only from there — never hard-code -colors, fonts, or sizes inline. +All visual styling lives in the public `crate::theme` module, implemented by +`src/theme.rs` plus private `src/theme/*` helpers. Import only from `crate::theme` +— never hard-code colors, fonts, or sizes inline. ### When to use each helper @@ -123,7 +124,8 @@ src/ ├── auth.rs # Passphrase setup and unlock flows ├── crypto.rs # Argon2id KDF + ChaCha20-Poly1305 AEAD ├── profile.rs # TOML config persistence -├── theme.rs # All styling (colors, fonts, widget helpers) +├── theme.rs # Public theme facade (colors, fonts, widget helpers) +├── theme/ # Private theme submodules backing crate::theme └── format.rs # Human-readable formatting (sizes, speeds, ETA) ``` diff --git a/openspec/specs/add-torrent/spec.md b/openspec/specs/add-torrent/spec.md index 426e86e..b3180f2 100644 --- a/openspec/specs/add-torrent/spec.md +++ b/openspec/specs/add-torrent/spec.md @@ -3,7 +3,9 @@ ## Purpose TBD - created by archiving change add-torrents. Update Purpose after archive. + ## Requirements + ### Requirement: Add Torrent toolbar entry points The main-screen toolbar SHALL provide two buttons: **Add Torrent** and **Add Link**. These are @@ -197,7 +199,7 @@ in the `torrent-add` RPC call so the daemon never starts downloading those files ### Requirement: Select All tri-state header in file add dialog The file add dialog SHALL display a **tri-state checkbox** header row above the file list, -using the `m3_tristate_checkbox` helper from `src/theme.rs`. Its state SHALL be derived +using the `m3_tristate_checkbox` helper from the public `crate::theme` module. Its state SHALL be derived from the `selected: Vec` state: - All selected \u2192 **Checked** @@ -231,4 +233,3 @@ Checked emits `AddDialogDeselectAll`. - **WHEN** the header is Checked and the user clicks it - **THEN** all file checkboxes are set to unchecked - diff --git a/openspec/specs/connection-screen/spec.md b/openspec/specs/connection-screen/spec.md index f6b42d1..1da790d 100644 --- a/openspec/specs/connection-screen/spec.md +++ b/openspec/specs/connection-screen/spec.md @@ -113,7 +113,7 @@ All connection errors SHALL be logged to stdout/stderr in addition to being show ### Requirement: Saved Profiles and Quick Connect tab navigation -The connection screen SHALL present two modes — Saved Profiles and Quick Connect — as a segmented control component (M3 style) using the `segmented_control` helper from `src/theme.rs`. The segmented control SHALL be centered in a fixed-width (400 px) container. +The connection screen SHALL present two modes — Saved Profiles and Quick Connect — as a segmented control component (M3 style) using the `segmented_control` helper from the public `crate::theme` module. The segmented control SHALL be centered in a fixed-width (400 px) container. #### Scenario: Segmented control shown on connection screen diff --git a/openspec/specs/file-selection/spec.md b/openspec/specs/file-selection/spec.md index 7b1f262..a914f3b 100644 --- a/openspec/specs/file-selection/spec.md +++ b/openspec/specs/file-selection/spec.md @@ -1,8 +1,11 @@ # file-selection Specification ## Purpose + TBD - created by archiving change selective-file-download. Update Purpose after archive. + ## Requirements + ### Requirement: Inspector Files tab shows per-file wanted checkboxes The inspector Files tab SHALL display a checkbox at the start of each file row. The displayed @@ -91,7 +94,8 @@ effective wanted values (incorporating `pending_wanted` overrides): - No files effectively wanted → **Unchecked** - Some but not all files effectively wanted → **Mixed** (indeterminate) -The tri-state checkbox SHALL use the `m3_tristate_checkbox` helper from `src/theme.rs` +The tri-state checkbox SHALL use the `m3_tristate_checkbox` helper from the public +`crate::theme` module (see Design D5). Clicking the header when Mixed or Unchecked emits `AllFilesWantedToggled { wanted: true }`; clicking when Checked emits `AllFilesWantedToggled { wanted: false }`. @@ -129,9 +133,9 @@ and enqueue **exactly one** `RpcWork::SetFileWanted` with the full index range ### Requirement: CheckState enum and m3_tristate_checkbox theme helper -A `CheckState` enum with variants `Checked`, `Unchecked`, and `Mixed` SHALL be defined in -`src/theme.rs`. A `m3_tristate_checkbox(state, label, on_toggle)` helper function SHALL -also live in `src/theme.rs`. It SHALL render a borderless button composed of a Material +A `CheckState` enum with variants `Checked`, `Unchecked`, and `Mixed` SHALL be exposed from +the public `crate::theme` module. A `m3_tristate_checkbox(state, label, on_toggle)` helper +function SHALL also be exposed from `crate::theme`. It SHALL render a borderless button composed of a Material Icons glyph and a text label: | `CheckState` | Icon constant | Next state on click | @@ -142,7 +146,7 @@ Icons glyph and a text label: The icon SHALL be coloured with `palette.primary.base.color` for `Checked` and `Mixed` states, and `palette.background.base.text` for `Unchecked`, using the same dark/light -detection pattern already used throughout `theme.rs`. +detection pattern already used throughout the theme module. #### Scenario: Checked icon renders with primary colour @@ -202,4 +206,3 @@ when absent (e.g. older Transmission versions or incomplete responses). - **WHEN** `torrent-get` returns `fileStats` without a `wanted` key - **THEN** `TorrentFileStats::wanted` defaults to `true` - diff --git a/openspec/specs/m3-components/spec.md b/openspec/specs/m3-components/spec.md index 7c80eae..affa2a3 100644 --- a/openspec/specs/m3-components/spec.md +++ b/openspec/specs/m3-components/spec.md @@ -2,7 +2,7 @@ ### Requirement: Primary filled button helper -The application SHALL provide an `m3_primary_button` style function in `src/theme.rs`. The style SHALL produce a solid primary-color background with white (on-primary) text and a fully rounded pill shape (radius 100.0). Padding SHALL be at least `[10, 24]`. This style is used for all primary CTAs (Connect, Save, Add). +The application SHALL provide an `m3_primary_button` style function from the public `crate::theme` module. The style SHALL produce a solid primary-color background with white (on-primary) text and a fully rounded pill shape (radius 100.0). Padding SHALL be at least `[10, 24]`. This style is used for all primary CTAs (Connect, Save, Add). #### Scenario: Primary button renders with solid brand color @@ -16,7 +16,7 @@ The application SHALL provide an `m3_primary_button` style function in `src/them ### Requirement: Tonal (secondary) button helper -The application SHALL provide an `m3_tonal_button` style function in `src/theme.rs`. The style SHALL produce a 15 % alpha primary color wash background with primary-color text and a fully rounded pill shape (radius 100.0). This style is used for all secondary/cancel actions (Cancel, Manage Profiles, Test Connection, Undo). +The application SHALL provide an `m3_tonal_button` style function from the public `crate::theme` module. The style SHALL produce a 15 % alpha primary color wash background with primary-color text and a fully rounded pill shape (radius 100.0). This style is used for all secondary/cancel actions (Cancel, Manage Profiles, Test Connection, Undo). #### Scenario: Tonal button renders with brand wash background diff --git a/openspec/specs/material-theme/spec.md b/openspec/specs/material-theme/spec.md index 21f906b..e06f1d7 100644 --- a/openspec/specs/material-theme/spec.md +++ b/openspec/specs/material-theme/spec.md @@ -2,7 +2,7 @@ ### Requirement: Material Design 3 theme -The application SHALL support two built-in themes — Light and Dark — both derived from a Material Design 3 palette seeded from the Clutch brand color Magnetic Blue (`#2A64A7`). Each theme SHALL be implemented as a custom `iced::Theme` via a single `clutch_theme(is_dark: bool) -> Theme` function. The dark palette SHALL use a lightened primary (`#5B9FD4`) for sufficient contrast on dark surfaces. The following named color constants SHALL be defined in `src/theme.rs`: +The application SHALL support two built-in themes — Light and Dark — both derived from a Material Design 3 palette seeded from the Clutch brand color Magnetic Blue (`#2A64A7`). Each theme SHALL be implemented as a custom `iced::Theme` via a single `clutch_theme(is_dark: bool) -> Theme` function. The dark palette SHALL use a lightened primary (`#5B9FD4`) for sufficient contrast on dark surfaces. The following named color constants SHALL be available from the public `crate::theme` module: - `MAGNETIC_BLUE`: `Color::from_rgb(0.16, 0.39, 0.65)` — base brand blue - `MAGNETIC_BLUE_LIGHT`: `Color::from_rgb(0.36, 0.62, 0.83)` — lightened primary for dark mode @@ -44,7 +44,7 @@ The active theme SHALL apply to all standard iced widgets automatically via the #### Scenario: Named constants are the sole source of brand colors -- **WHEN** a developer inspects `src/theme.rs` +- **WHEN** a developer inspects the public `crate::theme` module - **THEN** all brand colors are defined as named `const Color` values at the top of the file and nowhere else ### Requirement: Material Icons font diff --git a/openspec/specs/rpc-client/spec.md b/openspec/specs/rpc-client/spec.md index 25c69a1..443de01 100644 --- a/openspec/specs/rpc-client/spec.md +++ b/openspec/specs/rpc-client/spec.md @@ -3,7 +3,9 @@ Provide a safe, async Transmission JSON-RPC client that serializes all daemon calls through a single MPSC worker, guaranteeing at most one in-flight HTTP connection at any time while remaining fully non-blocking on the iced UI thread. + ## Requirements + ### Requirement: Session-Id lifecycle handling The RPC client SHALL implement the Transmission `X-Transmission-Session-Id` handshake. On receiving a 409 response, the client SHALL extract the new session ID from the response header and retry the request exactly once with the updated ID. @@ -267,4 +269,3 @@ The `torrent-get` fields list SHALL include `"error"`, `"errorString"`, and `"do - **WHEN** the daemon omits `"error"` and `"errorString"` fields - **THEN** `TorrentData::error == 0` and `TorrentData::error_string` is empty - diff --git a/src/auth.rs b/src/auth.rs index 7e333bc..4d723c6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -462,36 +462,14 @@ fn view_setup_passphrase<'a>( ) .padding(28) .max_width(420) - .style(|t: &iced::Theme| { - let is_dark = t.extended_palette().background.base.color.r < 0.5; - iced::widget::container::Style { - background: Some(iced::Background::Color(if is_dark { - crate::theme::CARD_SURFACE_DARK - } else { - crate::theme::CARD_SURFACE_LIGHT - })), - border: iced::Border { - radius: 12.0.into(), - ..Default::default() - }, - ..Default::default() - } - }); + .style(crate::theme::auth_dialog_card); container(card) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) - .style(|_: &iced::Theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.5, - })), - ..Default::default() - }) + .style(crate::theme::dialog_scrim(0.5)) .into() } @@ -551,36 +529,14 @@ fn view_unlock<'a>( ) .padding(28) .max_width(420) - .style(|t: &iced::Theme| { - let is_dark = t.extended_palette().background.base.color.r < 0.5; - iced::widget::container::Style { - background: Some(iced::Background::Color(if is_dark { - crate::theme::CARD_SURFACE_DARK - } else { - crate::theme::CARD_SURFACE_LIGHT - })), - border: iced::Border { - radius: 12.0.into(), - ..Default::default() - }, - ..Default::default() - } - }); + .style(crate::theme::auth_dialog_card); container(card) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) - .style(|_: &iced::Theme| iced::widget::container::Style { - background: Some(iced::Background::Color(iced::Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.5, - })), - ..Default::default() - }) + .style(crate::theme::dialog_scrim(0.5)) .into() } diff --git a/src/screens/main_screen.rs b/src/screens/main_screen.rs index e45e3bd..988c18b 100644 --- a/src/screens/main_screen.rs +++ b/src/screens/main_screen.rs @@ -485,7 +485,7 @@ impl MainScreen { #[cfg(test)] mod tests { use super::*; - use crate::rpc::{RpcWork, TorrentBandwidthArgs, TorrentData}; + use crate::rpc::{RpcWork, TorrentData}; use crate::screens::inspector::ActiveTab; use crate::screens::torrent_list::Message as TLMsg; use tokio::sync::mpsc; diff --git a/src/screens/settings/view.rs b/src/screens/settings/view.rs index e9c2a5b..4cb4a63 100644 --- a/src/screens/settings/view.rs +++ b/src/screens/settings/view.rs @@ -687,23 +687,7 @@ impl SettingsScreen { .on_press(msg) .padding([10, 24]) .style(if primary { - |t: &iced::Theme, s| { - let p = t.extended_palette(); - let (bg, hover) = (p.danger.base.color, p.danger.strong.color); - let bg = match s { - button::Status::Hovered | button::Status::Pressed => hover, - _ => bg, - }; - button::Style { - background: Some(iced::Background::Color(bg)), - text_color: p.danger.base.text, - border: iced::Border { - radius: 100.0.into(), - ..Default::default() - }, - ..Default::default() - } - } + crate::theme::danger_pill_button } else { crate::theme::m3_tonal_button }) @@ -724,35 +708,14 @@ impl SettingsScreen { ) .padding(28) .max_width(360.0) - .style(|t: &iced::Theme| { - let p = t.extended_palette(); - container::Style { - background: Some(iced::Background::Color(p.background.base.color)), - border: iced::Border { - radius: 12.0.into(), - width: 1.0, - color: p.background.strong.color, - }, - shadow: iced::Shadow { - color: iced::Color::from_rgba8(0, 0, 0, 0.35), - offset: iced::Vector::new(0.0, 4.0), - blur_radius: 16.0, - }, - ..Default::default() - } - }); + .style(crate::theme::dialog_card); container(card) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) - .style(|_: &iced::Theme| container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba8( - 0, 0, 0, 0.70, - ))), - ..Default::default() - }) + .style(crate::theme::dialog_scrim(0.70)) .into() } diff --git a/src/screens/torrent_list/add_dialog.rs b/src/screens/torrent_list/add_dialog.rs index f1a1792..19081f6 100644 --- a/src/screens/torrent_list/add_dialog.rs +++ b/src/screens/torrent_list/add_dialog.rs @@ -211,12 +211,7 @@ pub fn view_add_dialog(state: &AddDialogState) -> Element<'_, Message> { }); container(dialog) - .style(|_theme: &iced::Theme| container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba( - 0.0, 0.0, 0.0, 0.45, - ))), - ..Default::default() - }) + .style(crate::theme::dialog_scrim(0.45)) .width(Length::Fill) .height(Length::Fill) .center(Length::Fill) diff --git a/src/screens/torrent_list/view.rs b/src/screens/torrent_list/view.rs index 5ce648d..105ea0a 100644 --- a/src/screens/torrent_list/view.rs +++ b/src/screens/torrent_list/view.rs @@ -500,24 +500,7 @@ fn view_delete_dialog(name: &str, del_local: bool) -> Element<'_, Message> { button("Confirm Delete") .on_press(Message::DeleteConfirmed) .padding([10, 24]) - .style(|t: &iced::Theme, s| { - let p = t.extended_palette(); - let bg = match s { - button::Status::Hovered | button::Status::Pressed => { - p.danger.strong.color - } - _ => p.danger.base.color, - }; - button::Style { - background: Some(iced::Background::Color(bg)), - text_color: p.danger.base.text, - border: iced::Border { - radius: 100.0.into(), - ..Default::default() - }, - ..Default::default() - } - }), + .style(crate::theme::danger_pill_button), ] .spacing(8) .width(Length::Fill), @@ -526,35 +509,14 @@ fn view_delete_dialog(name: &str, del_local: bool) -> Element<'_, Message> { ) .padding(28) .max_width(400.0) - .style(|t: &iced::Theme| { - let p = t.extended_palette(); - container::Style { - background: Some(iced::Background::Color(p.background.base.color)), - border: iced::Border { - radius: 12.0.into(), - width: 1.0, - color: p.background.strong.color, - }, - shadow: iced::Shadow { - color: iced::Color::from_rgba8(0, 0, 0, 0.35), - offset: iced::Vector::new(0.0, 4.0), - blur_radius: 16.0, - }, - ..Default::default() - } - }); + .style(crate::theme::dialog_card); container(card) .width(Length::Fill) .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) - .style(|_: &iced::Theme| container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba8( - 0, 0, 0, 0.70, - ))), - ..Default::default() - }) + .style(crate::theme::dialog_scrim(0.70)) .into() } @@ -597,12 +559,7 @@ fn view_set_location_dialog(dlg: &SetLocationDialog) -> Element<'_, Message> { .height(Length::Fill) .center_x(Length::Fill) .center_y(Length::Fill) - .style(|_: &iced::Theme| container::Style { - background: Some(iced::Background::Color(iced::Color::from_rgba8( - 0, 0, 0, 0.70, - ))), - ..Default::default() - }) + .style(crate::theme::dialog_scrim(0.70)) .into() } diff --git a/src/theme.rs b/src/theme.rs index 89c6fc5..6b1254a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -12,8 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! All styling concerns are centralised here so future theme changes require -//! edits in exactly one file. +//! All styling concerns are centralised in `crate::theme`. +//! +//! This file is the public facade for the theme layer. Private implementation +//! details live under `src/theme/`, but callers continue to import everything +//! from `crate::theme` so the styling API stays stable as the internals evolve. //! //! # Usage //! @@ -25,17 +28,32 @@ //! //! Apply styles: //! ```ignore -//! container(content).style(theme::elevated_surface) +//! container(content).style(theme::m3_card) //! progress_bar(...).style(theme::progress_bar_style(torrent.status)) //! icon('\u{E034}') // pause glyph //! ``` -use iced::widget::button; -use iced::widget::container; -use iced::widget::progress_bar; -use iced::widget::row; -use iced::widget::text_input; -use iced::{Border, Color, Element, Font, Length, Shadow, Theme, Vector}; +use iced::{Color, Font, Theme}; + +mod buttons; +mod containers; +mod inputs; +mod progress; +mod segmented; +mod selection; + +pub use self::buttons::{ + active_icon_button, danger_pill_button, icon_button, m3_filter_chip, m3_menu_item, + m3_menu_item_disabled, m3_primary_button, m3_tonal_button, +}; +pub use self::containers::{ + auth_dialog_card, dialog_card, dialog_scrim, inspector_surface, m3_card, m3_menu_card, + m3_tooltip, selected_row, +}; +pub use self::inputs::m3_text_input; +pub use self::progress::progress_bar_style; +pub use self::segmented::segmented_control; +pub use self::selection::{CheckState, m3_checkbox, m3_tristate_checkbox}; // ── Material Icons font ─────────────────────────────────────────────────────── @@ -82,93 +100,6 @@ pub const ICON_CB_CHECKED: char = '\u{e834}'; // check_box pub const ICON_CB_UNCHECKED: char = '\u{e835}'; // check_box_outline_blank pub const ICON_CB_MIXED: char = '\u{e909}'; // indeterminate_check_box -// ── Tri-state checkbox ──────────────────────────────────────────────────────── - -/// Three-valued selection state used by the file-list header checkbox. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CheckState { - Checked, - Unchecked, - Mixed, -} - -/// Shared rendering core for both `m3_checkbox` and `m3_tristate_checkbox`. -/// -/// Produces a `mouse_area`-wrapped row of icon + optional label. -/// `is_active` controls the icon colour: primary when `true`, muted text when `false`. -fn checkbox_widget<'a, Message: Clone + 'a>( - codepoint: char, - is_active: bool, - label: &'a str, - on_press: Message, -) -> Element<'a, Message> { - let icon_widget = iced::widget::text(String::from(codepoint)) - .font(MATERIAL_ICONS) - .size(20) - .width(20) - .align_x(iced::alignment::Horizontal::Center) - .style(move |theme: &iced::Theme| { - let palette = theme.extended_palette(); - iced::widget::text::Style { - color: Some(if is_active { - palette.primary.base.color - } else { - palette.background.base.text - }), - } - }); - - let content: Element<'a, Message> = if label.is_empty() { - icon_widget.into() - } else { - row![icon_widget, iced::widget::text(label).size(14)] - .spacing(8) - .align_y(iced::alignment::Vertical::Center) - .into() - }; - - iced::widget::mouse_area(content).on_press(on_press).into() -} - -/// Render a two-state checkbox using Material Icons, matching the visual style -/// of [`m3_tristate_checkbox`]. -/// -/// `on_toggle` receives the new `bool` value after the click. -pub fn m3_checkbox<'a, Message: Clone + 'a>( - checked: bool, - label: &'a str, - on_toggle: impl Fn(bool) -> Message + 'a, -) -> Element<'a, Message> { - let codepoint = if checked { - ICON_CB_CHECKED - } else { - ICON_CB_UNCHECKED - }; - checkbox_widget(codepoint, checked, label, on_toggle(!checked)) -} - -/// Render a tri-state "Select All" header checkbox using Material Icons. -/// -/// Clicking `Mixed` or `Unchecked` produces `CheckState::Checked`; -/// clicking `Checked` produces `CheckState::Unchecked`. -pub fn m3_tristate_checkbox<'a, Message: Clone + 'a>( - state: CheckState, - label: &'a str, - on_toggle: impl Fn(CheckState) -> Message + 'a, -) -> Element<'a, Message> { - let (codepoint, next_state) = match state { - CheckState::Mixed => (ICON_CB_MIXED, CheckState::Checked), - CheckState::Checked => (ICON_CB_CHECKED, CheckState::Unchecked), - CheckState::Unchecked => (ICON_CB_UNCHECKED, CheckState::Checked), - }; - checkbox_widget( - codepoint, - state != CheckState::Unchecked, - label, - on_toggle(next_state), - ) -} - /// Render a single Material icon glyph at 24 px. pub fn icon(codepoint: char) -> iced::widget::Text<'static> { iced::widget::text(String::from(codepoint)) @@ -293,746 +224,3 @@ pub fn clutch_theme(is_dark: bool) -> Theme { ) } } - -// ── Container styles ────────────────────────────────────────────────────────── - -/// Inspector panel background: slightly elevated, rounded top corners. -pub fn inspector_surface(theme: &Theme) -> container::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = if is_dark { - INSPECTOR_SURFACE_DARK - } else { - INSPECTOR_SURFACE_LIGHT - }; - container::Style { - text_color: None, - background: Some(iced::Background::Color(bg)), - border: Border { - radius: iced::border::Radius { - top_left: 12.0, - top_right: 12.0, - bottom_left: 0.0, - bottom_right: 0.0, - }, - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow { - color: Color::from_rgba8(0, 0, 0, 0.0), - offset: Vector::new(0.0, -2.0), - blur_radius: 4.0, - }, - snap: false, - } -} - -// ── Progress bar styles ─────────────────────────────────────────────────────── - -/// Return a progress bar style closure based on the torrent's Transmission status code. -/// -/// - Status 4 (Downloading) → green bar -/// - Status 6 (Seeding) → blue bar -/// - All other states → gray bar (paused, checking, queued) -pub fn progress_bar_style(status: i32) -> impl Fn(&Theme) -> progress_bar::Style { - move |theme: &Theme| { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let (bg, bar) = match status { - 4 => ( - if is_dark { - PROGRESS_DOWNLOAD_TRACK_DARK - } else { - PROGRESS_DOWNLOAD_TRACK_LIGHT - }, - PROGRESS_DOWNLOAD_BAR, - ), - 6 => ( - if is_dark { - PROGRESS_SEED_TRACK_DARK - } else { - PROGRESS_SEED_TRACK_LIGHT - }, - PROGRESS_SEED_BAR, - ), - _ => ( - if is_dark { - PROGRESS_PAUSED_TRACK_DARK - } else { - PROGRESS_PAUSED_TRACK_LIGHT - }, - if is_dark { - PROGRESS_PAUSED_BAR_DARK - } else { - PROGRESS_PAUSED_BAR_LIGHT - }, - ), - }; - progress_bar::Style { - background: iced::Background::Color(bg), - bar: iced::Background::Color(bar), - border: Border { - radius: 100.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - } - } -} - -// ── Button styles ───────────────────────────────────────────────────────────── - -/// Selected-row highlight: flat color, no border, no shadow. -pub fn selected_row(theme: &Theme) -> container::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = if is_dark { - // Subtle tint of brand blue at ~18 % opacity blended over the surface - Color { - r: MAGNETIC_BLUE_LIGHT.r, - g: MAGNETIC_BLUE_LIGHT.g, - b: MAGNETIC_BLUE_LIGHT.b, - a: 0.18, - } - } else { - Color { - r: MAGNETIC_BLUE.r, - g: MAGNETIC_BLUE.g, - b: MAGNETIC_BLUE.b, - a: 0.12, - } - }; - container::Style { - text_color: None, // inherit from theme - background: Some(iced::Background::Color(bg)), - border: Border { - radius: 6.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow::default(), - snap: false, - } -} - -/// Icon-only toolbar button: transparent background with a circular primary -/// tint on hover/press. -/// -/// The caller must attach `.on_press(message)` to make the button active. -pub fn icon_button<'a, Message: Clone + 'a>( - content: impl Into>, -) -> iced::widget::Button<'a, Message> { - iced::widget::button( - iced::widget::container(content) - .width(Length::Fill) - .height(Length::Fill) - .center(Length::Fill), - ) - .padding(0) - .width(Length::Fixed(36.0)) - .height(Length::Fixed(36.0)) - .style(|theme: &Theme, status| { - let primary = theme.palette().primary; - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = match status { - button::Status::Hovered => Some(iced::Background::Color(Color { - r: primary.r, - g: primary.g, - b: primary.b, - a: 0.12, - })), - button::Status::Pressed => Some(iced::Background::Color(Color { - r: primary.r, - g: primary.g, - b: primary.b, - a: 0.20, - })), - _ => None, - }; - let text_color = match status { - button::Status::Disabled => { - if is_dark { - DISABLED_DARK - } else { - DISABLED_LIGHT - } - } - _ => theme.palette().text, - }; - button::Style { - background: bg, - text_color, - border: Border { - radius: 100.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow::default(), - snap: false, - } - }) -} - -/// Like [`icon_button`] but highlights with the primary colour when `active` is `true`. -/// -/// Use this for toggle buttons whose pressed state should be persistently -/// visible (e.g. the Turtle Mode speed-limiter toggle in the toolbar). -pub fn active_icon_button<'a, Message: Clone + 'a>( - content: impl Into>, - active: bool, -) -> iced::widget::Button<'a, Message> { - iced::widget::button( - iced::widget::container(content) - .width(Length::Fill) - .height(Length::Fill) - .center(Length::Fill), - ) - .padding(0) - .width(Length::Fixed(36.0)) - .height(Length::Fixed(36.0)) - .style(move |theme: &Theme, status| { - let primary = theme.palette().primary; - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = if active { - Some(iced::Background::Color(Color { - r: primary.r, - g: primary.g, - b: primary.b, - a: match status { - button::Status::Pressed => 0.32, - _ => 0.20, - }, - })) - } else { - match status { - button::Status::Hovered => Some(iced::Background::Color(Color { - r: primary.r, - g: primary.g, - b: primary.b, - a: 0.12, - })), - button::Status::Pressed => Some(iced::Background::Color(Color { - r: primary.r, - g: primary.g, - b: primary.b, - a: 0.20, - })), - _ => None, - } - }; - let text_color = if active { - primary - } else { - match status { - button::Status::Disabled => { - if is_dark { - DISABLED_DARK - } else { - DISABLED_LIGHT - } - } - _ => theme.palette().text, - } - }; - button::Style { - background: bg, - text_color, - border: Border { - radius: 100.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow::default(), - snap: false, - } - }) -} - -/// M3 segmented control: a connected row of buttons acting as a single toggle. -/// -/// `options` is a slice of `(label, value)` pairs. -/// `active` is the currently selected value. -/// `on_select` maps a selected value to a `Message`. -/// `equal_width` — when `true` each segment gets `Length::FillPortion(1)` so -/// all segments are equal width; when `false` segments shrink to their label. -/// `compact` — when `true` uses reduced vertical padding for space-constrained contexts. -pub fn segmented_control<'a, Message: Clone + 'a, T: PartialEq + Copy>( - options: &[(&'a str, T)], - active: T, - on_select: impl Fn(T) -> Message + 'a, - equal_width: bool, - compact: bool, -) -> Element<'a, Message> { - let count = options.len(); - let mut buttons: Vec> = Vec::with_capacity(count); - - for (i, (label, value)) in options.iter().enumerate() { - let is_active = *value == active; - let msg = on_select(*value); - - const ROUNDNESS: f32 = 16.0; - let radius = if count <= 1 { - iced::border::Radius::from(ROUNDNESS) - } else if i == 0 { - iced::border::Radius { - top_left: ROUNDNESS, - bottom_left: ROUNDNESS, - top_right: 0.0, - bottom_right: 0.0, - } - } else if i == count - 1 { - iced::border::Radius { - top_left: 0.0, - bottom_left: 0.0, - top_right: ROUNDNESS, - bottom_right: ROUNDNESS, - } - } else { - iced::border::Radius::from(0.0) - }; - - let btn = iced::widget::button(iced::widget::text(*label).align_x(iced::Alignment::Center)) - .on_press(msg) - .padding(if compact { [5, 10] } else { [8, 16] }) - .width(if equal_width { - Length::FillPortion(1) - } else { - Length::Shrink - }) - .style(move |theme: &Theme, status| { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let primary = theme.palette().primary; - let is_hovered = - matches!(status, button::Status::Hovered | button::Status::Pressed); - - let (bg, text_color) = if is_active { - ( - Some(iced::Background::Color(Color { a: 0.18, ..primary })), - primary, - ) - } else if is_hovered { - ( - Some(iced::Background::Color(Color { - r: primary.r, - g: primary.g, - b: primary.b, - a: 0.10, - })), - theme.palette().text, - ) - } else { - let surface = if is_dark { - SEGCTL_SURFACE_DARK - } else { - SEGCTL_SURFACE_LIGHT - }; - ( - Some(iced::Background::Color(surface)), - theme.palette().text.scale_alpha(0.65), - ) - }; - - button::Style { - background: bg, - text_color, - border: Border { - radius, - width: 1.0, - color: if is_dark { - SEGCTL_BORDER_DARK - } else { - SEGCTL_BORDER_LIGHT - }, - }, - shadow: Shadow::default(), - snap: false, - } - }) - .into(); - - buttons.push(btn); - } - - row(buttons).spacing(0).into() -} - -/// M3 card surface container style: uniform 16 px radius, tonal elevation, -/// subtle drop shadow. -/// -/// Use with `container(content).style(theme::m3_card)`. -pub fn m3_card(theme: &Theme) -> container::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = if is_dark { - CARD_SURFACE_DARK - } else { - CARD_SURFACE_LIGHT - }; - container::Style { - text_color: None, - background: Some(iced::Background::Color(bg)), - border: Border { - radius: 16.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow { - color: Color::from_rgba8(0, 0, 0, 0.18), - offset: Vector::new(0.0, 2.0), - blur_radius: 6.0, - }, - snap: false, - } -} - -/// M3 Plain Tooltip: dark elevated surface, rounded corners, no harsh border. -pub fn m3_tooltip(theme: &Theme) -> container::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - // Use a slightly darker/lighter contrasting surface regardless of mode. - let bg = if is_dark { - Color::from_rgb8(46, 50, 58) - } else { - Color::from_rgb8(42, 46, 54) - }; - container::Style { - background: Some(iced::Background::Color(bg)), - text_color: Some(Color::from_rgb8(220, 224, 232)), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 6.0.into(), - }, - shadow: Shadow { - color: Color::from_rgba8(0, 0, 0, 0.28), - offset: Vector::new(0.0, 2.0), - blur_radius: 8.0, - }, - snap: false, - } -} - -// ── Text input styles ───────────────────────────────────────────────────────── - -/// M3 Outlined text-field style. -/// -/// Inactive: subtle 1px border. Hovered: slightly brighter border. -/// Focused: primary-colour 2px border. -pub fn m3_text_input(theme: &Theme, status: text_input::Status) -> text_input::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = if is_dark { - iced::Background::Color(Color::from_rgb8(26, 29, 34)) - } else { - iced::Background::Color(Color::from_rgb8(255, 255, 255)) - }; - let text_color = theme.palette().text; - let placeholder_color = theme.palette().text.scale_alpha(0.40); - let selection_color = theme.extended_palette().primary.weak.color; - let radius = 8.0.into(); - - match status { - text_input::Status::Active => text_input::Style { - background: bg, - border: Border { - color: if is_dark { - Color::from_rgb8(55, 60, 70) - } else { - Color::from_rgb8(190, 195, 205) - }, - width: 1.0, - radius, - }, - icon: text_color, - value: text_color, - placeholder: placeholder_color, - selection: selection_color, - }, - text_input::Status::Hovered => text_input::Style { - background: bg, - border: Border { - color: if is_dark { - Color::from_rgb8(85, 92, 105) - } else { - Color::from_rgb8(140, 148, 165) - }, - width: 1.0, - radius, - }, - icon: text_color, - value: text_color, - placeholder: placeholder_color, - selection: selection_color, - }, - text_input::Status::Focused { .. } => text_input::Style { - background: bg, - border: Border { - color: theme.palette().primary, - width: 2.0, - radius, - }, - icon: text_color, - value: text_color, - placeholder: placeholder_color, - selection: selection_color, - }, - text_input::Status::Disabled => text_input::Style { - background: if is_dark { - iced::Background::Color(Color::from_rgb8(20, 22, 26)) - } else { - iced::Background::Color(Color::from_rgb8(240, 240, 243)) - }, - border: Border { - color: if is_dark { - Color::from_rgb8(40, 43, 50) - } else { - Color::from_rgb8(210, 213, 220) - }, - width: 1.0, - radius, - }, - icon: placeholder_color, - value: placeholder_color, - placeholder: placeholder_color, - selection: selection_color, - }, - } -} - -// ── Additional button styles ────────────────────────────────────────────────── - -/// M3 "filled tonal" button — primary brand wash background, solid primary text. -/// -/// Use for secondary actions that are important but not the primary CTA -/// (e.g. "Test Connection"). -pub fn m3_tonal_button(theme: &Theme, status: button::Status) -> button::Style { - let primary = theme.palette().primary; - match status { - button::Status::Active => button::Style { - background: Some(iced::Background::Color(Color { a: 0.15, ..primary })), - text_color: primary, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 100.0.into(), - }, - shadow: Shadow::default(), - snap: false, - }, - button::Status::Hovered => button::Style { - background: Some(iced::Background::Color(Color { a: 0.25, ..primary })), - text_color: primary, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 100.0.into(), - }, - shadow: Shadow::default(), - snap: false, - }, - button::Status::Pressed => button::Style { - background: Some(iced::Background::Color(Color { a: 0.35, ..primary })), - text_color: primary, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 100.0.into(), - }, - shadow: Shadow::default(), - snap: false, - }, - button::Status::Disabled => button::Style { - background: None, - text_color: theme.palette().text.scale_alpha(0.45), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 100.0.into(), - }, - shadow: Shadow::default(), - snap: false, - }, - } -} - -/// M3 "filled" primary button — solid brand-primary background, white text. -/// -/// Use for the primary CTA (e.g. "Save"). -pub fn m3_primary_button(theme: &Theme, status: button::Status) -> button::Style { - let p = theme.extended_palette(); - let radius = 100.0.into(); - match status { - button::Status::Active => button::Style { - background: Some(iced::Background::Color(p.primary.base.color)), - text_color: p.primary.base.text, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - }, - button::Status::Hovered => button::Style { - background: Some(iced::Background::Color(p.primary.strong.color)), - text_color: p.primary.base.text, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - }, - button::Status::Pressed => button::Style { - background: Some(iced::Background::Color(p.primary.strong.color)), - text_color: p.primary.base.text, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - }, - button::Status::Disabled => button::Style { - background: Some(iced::Background::Color(Color { - a: 0.38, - ..p.primary.base.color - })), - text_color: p.primary.base.text.scale_alpha(0.38), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - }, - } -} - -/// M3 Filter Chip button style. -/// -/// Pass `is_selected = true` for the selected state (soft primary wash, no border). -/// Pass `is_selected = false` for the unselected state (transparent with a subtle -/// outline; faint fill on hover). -pub fn m3_filter_chip(theme: &Theme, status: button::Status, is_selected: bool) -> button::Style { - let palette = theme.extended_palette(); - let radius = 8.0.into(); - - if is_selected { - button::Style { - background: Some(iced::Background::Color(Color { - a: 0.15, - ..palette.primary.base.color - })), - text_color: palette.primary.base.color, - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - } - } else { - let outline = Color { - a: 0.30, - ..palette.background.base.text - }; - match status { - button::Status::Hovered | button::Status::Pressed => button::Style { - background: Some(iced::Background::Color(Color { - a: 0.05, - ..palette.background.base.text - })), - text_color: palette.background.base.text, - border: Border { - color: outline, - width: 1.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - }, - _ => button::Style { - background: None, - text_color: palette.background.base.text, - border: Border { - color: outline, - width: 1.0, - radius, - }, - shadow: Shadow::default(), - snap: false, - }, - } - } -} - -// ── Context menu styles ─────────────────────────────────────────────────────── - -/// Floating context-menu card: matches the M3 menu surface spec. -/// -/// Tighter corner radius than `m3_card` (4 dp) and a more prominent shadow to -/// lift the menu above all other content. -pub fn m3_menu_card(theme: &Theme) -> container::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let bg = if is_dark { - CARD_SURFACE_DARK - } else { - CARD_SURFACE_LIGHT - }; - container::Style { - text_color: None, - background: Some(iced::Background::Color(bg)), - border: Border { - radius: 4.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow { - color: Color::from_rgba8(0, 0, 0, 0.30), - offset: Vector::new(0.0, 4.0), - blur_radius: 14.0, - }, - snap: false, - } -} - -/// M3 context-menu item button: transparent background with an 8 % state layer -/// on hover. Zero border radius so the highlight spans edge to edge. -pub fn m3_menu_item(theme: &Theme, status: button::Status) -> button::Style { - let text_color = theme.palette().text; - match status { - button::Status::Hovered | button::Status::Pressed => button::Style { - background: Some(iced::Background::Color(Color { - a: 0.08, - ..text_color - })), - text_color, - border: Border::default(), - shadow: Shadow::default(), - snap: false, - }, - _ => button::Style { - background: None, - text_color, - border: Border::default(), - shadow: Shadow::default(), - snap: false, - }, - } -} - -/// M3 context-menu item button — disabled state: dimmed text, no hover effect. -pub fn m3_menu_item_disabled(theme: &Theme, _status: button::Status) -> button::Style { - let is_dark = theme.extended_palette().background.base.color.r < 0.5; - let text_color = if is_dark { - DISABLED_DARK - } else { - DISABLED_LIGHT - }; - button::Style { - background: None, - text_color, - border: Border::default(), - shadow: Shadow::default(), - snap: false, - } -} diff --git a/src/theme/buttons.rs b/src/theme/buttons.rs new file mode 100644 index 0000000..5c14403 --- /dev/null +++ b/src/theme/buttons.rs @@ -0,0 +1,392 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::{button, container}; +use iced::{Border, Color, Element, Length, Shadow, Theme}; + +use super::{DISABLED_DARK, DISABLED_LIGHT}; + +/// Icon-only toolbar button: transparent background with a circular primary +/// tint on hover/press. +/// +/// The caller must attach `.on_press(message)` to make the button active. +pub fn icon_button<'a, Message: Clone + 'a>( + content: impl Into>, +) -> iced::widget::Button<'a, Message> { + iced::widget::button( + container(content) + .width(Length::Fill) + .height(Length::Fill) + .center(Length::Fill), + ) + .padding(0) + .width(Length::Fixed(36.0)) + .height(Length::Fixed(36.0)) + .style(|theme: &Theme, status| { + let primary = theme.palette().primary; + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = match status { + button::Status::Hovered => Some(iced::Background::Color(Color { + r: primary.r, + g: primary.g, + b: primary.b, + a: 0.12, + })), + button::Status::Pressed => Some(iced::Background::Color(Color { + r: primary.r, + g: primary.g, + b: primary.b, + a: 0.20, + })), + _ => None, + }; + let text_color = match status { + button::Status::Disabled => { + if is_dark { + DISABLED_DARK + } else { + DISABLED_LIGHT + } + } + _ => theme.palette().text, + }; + + button::Style { + background: bg, + text_color, + border: Border { + radius: 100.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Shadow::default(), + snap: false, + } + }) +} + +/// Like [`icon_button`] but highlights with the primary colour when `active` is `true`. +/// +/// Use this for toggle buttons whose pressed state should be persistently +/// visible (e.g. the Turtle Mode speed-limiter toggle in the toolbar). +pub fn active_icon_button<'a, Message: Clone + 'a>( + content: impl Into>, + active: bool, +) -> iced::widget::Button<'a, Message> { + iced::widget::button( + container(content) + .width(Length::Fill) + .height(Length::Fill) + .center(Length::Fill), + ) + .padding(0) + .width(Length::Fixed(36.0)) + .height(Length::Fixed(36.0)) + .style(move |theme: &Theme, status| { + let primary = theme.palette().primary; + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if active { + Some(iced::Background::Color(Color { + r: primary.r, + g: primary.g, + b: primary.b, + a: match status { + button::Status::Pressed => 0.32, + _ => 0.20, + }, + })) + } else { + match status { + button::Status::Hovered => Some(iced::Background::Color(Color { + r: primary.r, + g: primary.g, + b: primary.b, + a: 0.12, + })), + button::Status::Pressed => Some(iced::Background::Color(Color { + r: primary.r, + g: primary.g, + b: primary.b, + a: 0.20, + })), + _ => None, + } + }; + let text_color = if active { + primary + } else { + match status { + button::Status::Disabled => { + if is_dark { + DISABLED_DARK + } else { + DISABLED_LIGHT + } + } + _ => theme.palette().text, + } + }; + + button::Style { + background: bg, + text_color, + border: Border { + radius: 100.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Shadow::default(), + snap: false, + } + }) +} + +/// M3 "filled tonal" button — primary brand wash background, solid primary text. +/// +/// Use for secondary actions that are important but not the primary CTA. +pub fn m3_tonal_button(theme: &Theme, status: button::Status) -> button::Style { + let primary = theme.palette().primary; + match status { + button::Status::Active => button::Style { + background: Some(iced::Background::Color(Color { a: 0.15, ..primary })), + text_color: primary, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 100.0.into(), + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Hovered => button::Style { + background: Some(iced::Background::Color(Color { a: 0.25, ..primary })), + text_color: primary, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 100.0.into(), + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Pressed => button::Style { + background: Some(iced::Background::Color(Color { a: 0.35, ..primary })), + text_color: primary, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 100.0.into(), + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Disabled => button::Style { + background: None, + text_color: theme.palette().text.scale_alpha(0.45), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 100.0.into(), + }, + shadow: Shadow::default(), + snap: false, + }, + } +} + +/// M3 "filled" primary button — solid brand-primary background, white text. +pub fn m3_primary_button(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let radius = 100.0.into(); + match status { + button::Status::Active => button::Style { + background: Some(iced::Background::Color(palette.primary.base.color)), + text_color: palette.primary.base.text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Hovered | button::Status::Pressed => button::Style { + background: Some(iced::Background::Color(palette.primary.strong.color)), + text_color: palette.primary.base.text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Disabled => button::Style { + background: Some(iced::Background::Color(Color { + a: 0.38, + ..palette.primary.base.color + })), + text_color: palette.primary.base.text.scale_alpha(0.38), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + } +} + +/// Destructive pill button used by confirmation dialogs. +pub fn danger_pill_button(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let radius = 100.0.into(); + match status { + button::Status::Active => button::Style { + background: Some(iced::Background::Color(palette.danger.base.color)), + text_color: palette.danger.base.text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Hovered | button::Status::Pressed => button::Style { + background: Some(iced::Background::Color(palette.danger.strong.color)), + text_color: palette.danger.base.text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + button::Status::Disabled => button::Style { + background: Some(iced::Background::Color(Color { + a: 0.38, + ..palette.danger.base.color + })), + text_color: palette.danger.base.text.scale_alpha(0.38), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + } +} + +/// M3 Filter Chip button style. +pub fn m3_filter_chip(theme: &Theme, status: button::Status, is_selected: bool) -> button::Style { + let palette = theme.extended_palette(); + let radius = 8.0.into(); + + if is_selected { + button::Style { + background: Some(iced::Background::Color(Color { + a: 0.15, + ..palette.primary.base.color + })), + text_color: palette.primary.base.color, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + } + } else { + let outline = Color { + a: 0.30, + ..palette.background.base.text + }; + match status { + button::Status::Hovered | button::Status::Pressed => button::Style { + background: Some(iced::Background::Color(Color { + a: 0.05, + ..palette.background.base.text + })), + text_color: palette.background.base.text, + border: Border { + color: outline, + width: 1.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + _ => button::Style { + background: None, + text_color: palette.background.base.text, + border: Border { + color: outline, + width: 1.0, + radius, + }, + shadow: Shadow::default(), + snap: false, + }, + } + } +} + +/// M3 context-menu item button: transparent background with an 8 % state layer +/// on hover. Zero border radius so the highlight spans edge to edge. +pub fn m3_menu_item(theme: &Theme, status: button::Status) -> button::Style { + let text_color = theme.palette().text; + match status { + button::Status::Hovered | button::Status::Pressed => button::Style { + background: Some(iced::Background::Color(Color { + a: 0.08, + ..text_color + })), + text_color, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + }, + _ => button::Style { + background: None, + text_color, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + }, + } +} + +/// M3 context-menu item button — disabled state: dimmed text, no hover effect. +pub fn m3_menu_item_disabled(theme: &Theme, _status: button::Status) -> button::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let text_color = if is_dark { + DISABLED_DARK + } else { + DISABLED_LIGHT + }; + + button::Style { + background: None, + text_color, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + } +} diff --git a/src/theme/containers.rs b/src/theme/containers.rs new file mode 100644 index 0000000..5a71b94 --- /dev/null +++ b/src/theme/containers.rs @@ -0,0 +1,211 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::container; +use iced::{Border, Color, Shadow, Theme, Vector}; + +use super::{ + CARD_SURFACE_DARK, CARD_SURFACE_LIGHT, INSPECTOR_SURFACE_DARK, INSPECTOR_SURFACE_LIGHT, + MAGNETIC_BLUE, MAGNETIC_BLUE_LIGHT, +}; + +/// Inspector panel background: slightly elevated, rounded top corners. +pub fn inspector_surface(theme: &Theme) -> container::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + INSPECTOR_SURFACE_DARK + } else { + INSPECTOR_SURFACE_LIGHT + }; + + container::Style { + text_color: None, + background: Some(iced::Background::Color(bg)), + border: Border { + radius: iced::border::Radius { + top_left: 12.0, + top_right: 12.0, + bottom_left: 0.0, + bottom_right: 0.0, + }, + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.0), + offset: Vector::new(0.0, -2.0), + blur_radius: 4.0, + }, + snap: false, + } +} + +/// Selected-row highlight: flat color, no border, no shadow. +pub fn selected_row(theme: &Theme) -> container::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + Color { + r: MAGNETIC_BLUE_LIGHT.r, + g: MAGNETIC_BLUE_LIGHT.g, + b: MAGNETIC_BLUE_LIGHT.b, + a: 0.18, + } + } else { + Color { + r: MAGNETIC_BLUE.r, + g: MAGNETIC_BLUE.g, + b: MAGNETIC_BLUE.b, + a: 0.12, + } + }; + + container::Style { + text_color: None, + background: Some(iced::Background::Color(bg)), + border: Border { + radius: 6.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Shadow::default(), + snap: false, + } +} + +/// M3 card surface container style: uniform 16 px radius, tonal elevation, +/// subtle drop shadow. +pub fn m3_card(theme: &Theme) -> container::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + CARD_SURFACE_DARK + } else { + CARD_SURFACE_LIGHT + }; + + container::Style { + text_color: None, + background: Some(iced::Background::Color(bg)), + border: Border { + radius: 16.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.18), + offset: Vector::new(0.0, 2.0), + blur_radius: 6.0, + }, + snap: false, + } +} + +/// Auth overlay card: simple rounded surface without extra elevation. +pub fn auth_dialog_card(theme: &Theme) -> container::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + CARD_SURFACE_DARK + } else { + CARD_SURFACE_LIGHT + }; + + container::Style { + background: Some(iced::Background::Color(bg)), + border: Border { + radius: 12.0.into(), + ..Default::default() + }, + ..Default::default() + } +} + +/// Elevated dialog card used by destructive confirmations and settings overlays. +pub fn dialog_card(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + container::Style { + background: Some(iced::Background::Color(palette.background.base.color)), + border: Border { + radius: 12.0.into(), + width: 1.0, + color: palette.background.strong.color, + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.35), + offset: Vector::new(0.0, 4.0), + blur_radius: 16.0, + }, + ..Default::default() + } +} + +/// Backdrop scrim used behind modal overlays. +pub fn dialog_scrim(alpha: f32) -> impl Fn(&Theme) -> container::Style { + move |_| container::Style { + background: Some(iced::Background::Color(Color::from_rgba( + 0.0, 0.0, 0.0, alpha, + ))), + ..Default::default() + } +} + +/// M3 Plain Tooltip: dark elevated surface, rounded corners, no harsh border. +pub fn m3_tooltip(theme: &Theme) -> container::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + Color::from_rgb8(46, 50, 58) + } else { + Color::from_rgb8(42, 46, 54) + }; + + container::Style { + background: Some(iced::Background::Color(bg)), + text_color: Some(Color::from_rgb8(220, 224, 232)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.28), + offset: Vector::new(0.0, 2.0), + blur_radius: 8.0, + }, + snap: false, + } +} + +/// Floating context-menu card: matches the M3 menu surface spec. +pub fn m3_menu_card(theme: &Theme) -> container::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + CARD_SURFACE_DARK + } else { + CARD_SURFACE_LIGHT + }; + + container::Style { + text_color: None, + background: Some(iced::Background::Color(bg)), + border: Border { + radius: 4.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.30), + offset: Vector::new(0.0, 4.0), + blur_radius: 14.0, + }, + snap: false, + } +} diff --git a/src/theme/inputs.rs b/src/theme/inputs.rs new file mode 100644 index 0000000..dfd429a --- /dev/null +++ b/src/theme/inputs.rs @@ -0,0 +1,97 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::text_input; +use iced::{Border, Color, Theme}; + +/// M3 Outlined text-field style. +pub fn m3_text_input(theme: &Theme, status: text_input::Status) -> text_input::Style { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let bg = if is_dark { + iced::Background::Color(Color::from_rgb8(26, 29, 34)) + } else { + iced::Background::Color(Color::from_rgb8(255, 255, 255)) + }; + let text_color = theme.palette().text; + let placeholder_color = theme.palette().text.scale_alpha(0.40); + let selection_color = theme.extended_palette().primary.weak.color; + let radius = 8.0.into(); + + match status { + text_input::Status::Active => text_input::Style { + background: bg, + border: Border { + color: if is_dark { + Color::from_rgb8(55, 60, 70) + } else { + Color::from_rgb8(190, 195, 205) + }, + width: 1.0, + radius, + }, + icon: text_color, + value: text_color, + placeholder: placeholder_color, + selection: selection_color, + }, + text_input::Status::Hovered => text_input::Style { + background: bg, + border: Border { + color: if is_dark { + Color::from_rgb8(85, 92, 105) + } else { + Color::from_rgb8(140, 148, 165) + }, + width: 1.0, + radius, + }, + icon: text_color, + value: text_color, + placeholder: placeholder_color, + selection: selection_color, + }, + text_input::Status::Focused { .. } => text_input::Style { + background: bg, + border: Border { + color: theme.palette().primary, + width: 2.0, + radius, + }, + icon: text_color, + value: text_color, + placeholder: placeholder_color, + selection: selection_color, + }, + text_input::Status::Disabled => text_input::Style { + background: if is_dark { + iced::Background::Color(Color::from_rgb8(20, 22, 26)) + } else { + iced::Background::Color(Color::from_rgb8(240, 240, 243)) + }, + border: Border { + color: if is_dark { + Color::from_rgb8(40, 43, 50) + } else { + Color::from_rgb8(210, 213, 220) + }, + width: 1.0, + radius, + }, + icon: placeholder_color, + value: placeholder_color, + placeholder: placeholder_color, + selection: selection_color, + }, + } +} diff --git a/src/theme/progress.rs b/src/theme/progress.rs new file mode 100644 index 0000000..c5ee3ca --- /dev/null +++ b/src/theme/progress.rs @@ -0,0 +1,70 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::progress_bar; +use iced::{Border, Color, Theme}; + +use super::{ + PROGRESS_DOWNLOAD_BAR, PROGRESS_DOWNLOAD_TRACK_DARK, PROGRESS_DOWNLOAD_TRACK_LIGHT, + PROGRESS_PAUSED_BAR_DARK, PROGRESS_PAUSED_BAR_LIGHT, PROGRESS_PAUSED_TRACK_DARK, + PROGRESS_PAUSED_TRACK_LIGHT, PROGRESS_SEED_BAR, PROGRESS_SEED_TRACK_DARK, + PROGRESS_SEED_TRACK_LIGHT, +}; + +/// Return a progress bar style closure based on the torrent's Transmission status code. +pub fn progress_bar_style(status: i32) -> impl Fn(&Theme) -> progress_bar::Style { + move |theme: &Theme| { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let (bg, bar) = match status { + 4 => ( + if is_dark { + PROGRESS_DOWNLOAD_TRACK_DARK + } else { + PROGRESS_DOWNLOAD_TRACK_LIGHT + }, + PROGRESS_DOWNLOAD_BAR, + ), + 6 => ( + if is_dark { + PROGRESS_SEED_TRACK_DARK + } else { + PROGRESS_SEED_TRACK_LIGHT + }, + PROGRESS_SEED_BAR, + ), + _ => ( + if is_dark { + PROGRESS_PAUSED_TRACK_DARK + } else { + PROGRESS_PAUSED_TRACK_LIGHT + }, + if is_dark { + PROGRESS_PAUSED_BAR_DARK + } else { + PROGRESS_PAUSED_BAR_LIGHT + }, + ), + }; + + progress_bar::Style { + background: iced::Background::Color(bg), + bar: iced::Background::Color(bar), + border: Border { + radius: 100.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + } + } +} diff --git a/src/theme/segmented.rs b/src/theme/segmented.rs new file mode 100644 index 0000000..ad0594f --- /dev/null +++ b/src/theme/segmented.rs @@ -0,0 +1,119 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::{button, row}; +use iced::{Border, Color, Element, Length, Shadow, Theme}; + +use super::{SEGCTL_BORDER_DARK, SEGCTL_BORDER_LIGHT, SEGCTL_SURFACE_DARK, SEGCTL_SURFACE_LIGHT}; + +/// M3 segmented control: a connected row of buttons acting as a single toggle. +pub fn segmented_control<'a, Message: Clone + 'a, T: PartialEq + Copy>( + options: &[(&'a str, T)], + active: T, + on_select: impl Fn(T) -> Message + 'a, + equal_width: bool, + compact: bool, +) -> Element<'a, Message> { + let count = options.len(); + let mut buttons: Vec> = Vec::with_capacity(count); + + for (index, (label, value)) in options.iter().enumerate() { + let is_active = *value == active; + let msg = on_select(*value); + + const ROUNDNESS: f32 = 16.0; + let radius = if count <= 1 { + iced::border::Radius::from(ROUNDNESS) + } else if index == 0 { + iced::border::Radius { + top_left: ROUNDNESS, + bottom_left: ROUNDNESS, + top_right: 0.0, + bottom_right: 0.0, + } + } else if index == count - 1 { + iced::border::Radius { + top_left: 0.0, + bottom_left: 0.0, + top_right: ROUNDNESS, + bottom_right: ROUNDNESS, + } + } else { + iced::border::Radius::from(0.0) + }; + + let btn = iced::widget::button(iced::widget::text(*label).align_x(iced::Alignment::Center)) + .on_press(msg) + .padding(if compact { [5, 10] } else { [8, 16] }) + .width(if equal_width { + Length::FillPortion(1) + } else { + Length::Shrink + }) + .style(move |theme: &Theme, status| { + let is_dark = theme.extended_palette().background.base.color.r < 0.5; + let primary = theme.palette().primary; + let is_hovered = + matches!(status, button::Status::Hovered | button::Status::Pressed); + + let (bg, text_color) = if is_active { + ( + Some(iced::Background::Color(Color { a: 0.18, ..primary })), + primary, + ) + } else if is_hovered { + ( + Some(iced::Background::Color(Color { + r: primary.r, + g: primary.g, + b: primary.b, + a: 0.10, + })), + theme.palette().text, + ) + } else { + let surface = if is_dark { + SEGCTL_SURFACE_DARK + } else { + SEGCTL_SURFACE_LIGHT + }; + ( + Some(iced::Background::Color(surface)), + theme.palette().text.scale_alpha(0.65), + ) + }; + + button::Style { + background: bg, + text_color, + border: Border { + radius, + width: 1.0, + color: if is_dark { + SEGCTL_BORDER_DARK + } else { + SEGCTL_BORDER_LIGHT + }, + }, + shadow: Shadow::default(), + snap: false, + } + }) + .into(); + + buttons.push(btn); + } + + row(buttons).spacing(0).into() +} diff --git a/src/theme/selection.rs b/src/theme/selection.rs new file mode 100644 index 0000000..d359fd6 --- /dev/null +++ b/src/theme/selection.rs @@ -0,0 +1,95 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::Element; +use iced::widget::row; + +use super::{ICON_CB_CHECKED, ICON_CB_MIXED, ICON_CB_UNCHECKED, MATERIAL_ICONS}; + +/// Three-valued selection state used by the file-list header checkbox. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckState { + Checked, + Unchecked, + Mixed, +} + +/// Shared rendering core for both `m3_checkbox` and `m3_tristate_checkbox`. +fn checkbox_widget<'a, Message: Clone + 'a>( + codepoint: char, + is_active: bool, + label: &'a str, + on_press: Message, +) -> Element<'a, Message> { + let icon_widget = iced::widget::text(String::from(codepoint)) + .font(MATERIAL_ICONS) + .size(20) + .width(20) + .align_x(iced::alignment::Horizontal::Center) + .style(move |theme: &iced::Theme| { + let palette = theme.extended_palette(); + iced::widget::text::Style { + color: Some(if is_active { + palette.primary.base.color + } else { + palette.background.base.text + }), + } + }); + + let content: Element<'a, Message> = if label.is_empty() { + icon_widget.into() + } else { + row![icon_widget, iced::widget::text(label).size(14)] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() + }; + + iced::widget::mouse_area(content).on_press(on_press).into() +} + +/// Render a two-state checkbox using Material Icons. +pub fn m3_checkbox<'a, Message: Clone + 'a>( + checked: bool, + label: &'a str, + on_toggle: impl Fn(bool) -> Message + 'a, +) -> Element<'a, Message> { + let codepoint = if checked { + ICON_CB_CHECKED + } else { + ICON_CB_UNCHECKED + }; + checkbox_widget(codepoint, checked, label, on_toggle(!checked)) +} + +/// Render a tri-state "Select All" header checkbox using Material Icons. +pub fn m3_tristate_checkbox<'a, Message: Clone + 'a>( + state: CheckState, + label: &'a str, + on_toggle: impl Fn(CheckState) -> Message + 'a, +) -> Element<'a, Message> { + let (codepoint, next_state) = match state { + CheckState::Mixed => (ICON_CB_MIXED, CheckState::Checked), + CheckState::Checked => (ICON_CB_CHECKED, CheckState::Unchecked), + CheckState::Unchecked => (ICON_CB_UNCHECKED, CheckState::Checked), + }; + + checkbox_widget( + codepoint, + state != CheckState::Unchecked, + label, + on_toggle(next_state), + ) +} diff --git a/system_architecture.md b/system_architecture.md index f5ea1c1..32ac181 100644 --- a/system_architecture.md +++ b/system_architecture.md @@ -83,6 +83,7 @@ src/ ├── auth.rs # Passphrase setup and unlock dialogs ├── crypto.rs # Argon2id / ChaCha20 wrappers ├── profile.rs # TOML storage and runtime configuration -├── theme.rs # Material Design 3 palettes and widget styling +├── theme.rs # Public Material Design 3 theme facade +├── theme/ # Private widget/style helpers backing crate::theme └── format.rs # String formatting for ETA, bytes, speeds ``` From 33613091270a3f64c75a2a65521e9990e22f99c6 Mon Sep 17 00:00:00 2001 From: Samuel Hitz Date: Thu, 9 Apr 2026 08:28:15 +0200 Subject: [PATCH 2/4] refactor app and torrent-list --- AGENTS.md | 5 +- src/app.rs | 619 ++------------------------- src/app/keyboard.rs | 92 ++++ src/app/routing.rs | 333 +++++++++++++++ src/app/settings_bridge.rs | 238 +++++++++++ src/screens/torrent_list/columns.rs | 275 ++++++++++++ src/screens/torrent_list/dialogs.rs | 208 +++++++++ src/screens/torrent_list/filters.rs | 149 +++++++ src/screens/torrent_list/mod.rs | 34 +- src/screens/torrent_list/toolbar.rs | 131 ++++++ src/screens/torrent_list/view.rs | 630 ++-------------------------- system_architecture.md | 7 +- 12 files changed, 1495 insertions(+), 1226 deletions(-) create mode 100644 src/app/keyboard.rs create mode 100644 src/app/routing.rs create mode 100644 src/app/settings_bridge.rs create mode 100644 src/screens/torrent_list/columns.rs create mode 100644 src/screens/torrent_list/dialogs.rs create mode 100644 src/screens/torrent_list/filters.rs create mode 100644 src/screens/torrent_list/toolbar.rs diff --git a/AGENTS.md b/AGENTS.md index 5d3a05c..80c8d86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,13 +113,14 @@ Do **not** keep a separate `bool` flag for this purpose. ```text src/ ├── main.rs # Entry point only — window setup, font registration, tracing init -├── app.rs # AppState, Screen router, top-level update/view/subscription +├── app.rs # AppState, Screen router, top-level facade for update/view/subscription +├── app/ # Private routing, settings-bridge, and keyboard helpers backing crate::app ├── rpc/ # Transmission RPC: api, models, transport, worker (serialized queue) ├── screens/ │ ├── connection.rs # Profile selection / quick-connect │ ├── main_screen.rs # Root layout: torrent list + inspector split │ ├── inspector.rs # Detail inspector panel -│ └── torrent_list/ # List, sort, add-torrent dialog (split into view/update/worker) +│ └── torrent_list/ # List screen: view orchestration, toolbar/header/dialog helpers, sort, add-torrent, update, worker │ └── settings/ # Profile editing (split into state/draft/update/view) ├── auth.rs # Passphrase setup and unlock flows ├── crypto.rs # Argon2id KDF + ChaCha20-Poly1305 AEAD diff --git a/src/app.rs b/src/app.rs index 26e8a54..a78a4d3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -31,17 +31,25 @@ //! is executed by the tokio runtime on a background thread; the result arrives //! back as a new `Message`. Violating this invariant will freeze the UI. +//! Split into private helpers: +//! - [`routing`] — startup flow, global intercepts, and connection bridging +//! - [`settings_bridge`] — reconciliation of settings results back into `AppState` +//! - [`keyboard`] — screen-specific keyboard subscriptions + +mod keyboard; +mod routing; +mod settings_bridge; + use uuid::Uuid; use iced::{Element, Subscription, Task, Theme}; -use secrecy::{ExposeSecret, SecretString}; +use secrecy::SecretString; -use crate::auth::{AuthDialog, PendingAction}; -use crate::crypto; +use crate::auth::AuthDialog; use crate::profile::{ProfileStore, resolve_theme_config}; use crate::screens::connection::{self, ConnectionScreen}; use crate::screens::main_screen::{self, MainScreen}; -use crate::screens::settings::{self, SettingsScreen, SettingsTab}; +use crate::screens::settings::{self, SettingsScreen}; // ── Screen ──────────────────────────────────────────────────────────────────── @@ -184,219 +192,19 @@ impl AppState { // ── Elm functions ───────────────────────────────────────────────────────────── -/// If the connected profile has any configured bandwidth or seeding settings, push them -/// to the daemon via `session-set` on connect. Returns `None` when all values are default. -fn make_push_bandwidth_task( - url: &str, - creds: &crate::rpc::TransmissionCredentials, - session_id: &str, - profile: &crate::profile::ConnectionProfile, -) -> Option> { - let has_anything = profile.speed_limit_down_enabled - || profile.speed_limit_down != 0 - || profile.speed_limit_up_enabled - || profile.speed_limit_up != 0 - || profile.alt_speed_down != 0 - || profile.alt_speed_up != 0 - || profile.ratio_limit_enabled - || profile.ratio_limit != 0.0; - if !has_anything { - return None; - } - let url = url.to_owned(); - let creds = creds.clone(); - let sid = session_id.to_owned(); - let args = crate::rpc::SessionSetArgs { - speed_limit_down_enabled: Some(profile.speed_limit_down_enabled), - speed_limit_down: Some(profile.speed_limit_down), - speed_limit_up_enabled: Some(profile.speed_limit_up_enabled), - speed_limit_up: Some(profile.speed_limit_up), - alt_speed_down: if profile.alt_speed_down != 0 { - Some(profile.alt_speed_down) - } else { - None - }, - alt_speed_up: if profile.alt_speed_up != 0 { - Some(profile.alt_speed_up) - } else { - None - }, - seed_ratio_limited: Some(profile.ratio_limit_enabled), - seed_ratio_limit: Some(profile.ratio_limit), - ..Default::default() - }; - Some(Task::perform( - async move { crate::rpc::session_set(&url, &creds, &sid, &args).await }, - |_| Message::Noop, - )) -} - -/// Stash the current main screen (if any) and switch to Settings. -fn open_settings(state: &mut AppState, tab: SettingsTab) { - if let Screen::Main(_) = &state.screen { - if let Screen::Main(m) = std::mem::replace( - &mut state.screen, - Screen::Settings(SettingsScreen::new( - &state.profiles, - state.active_profile, - tab, - )), - ) { - state.stashed_main = Some(m); - } - } else { - state.screen = Screen::Settings(SettingsScreen::new( - &state.profiles, - state.active_profile, - tab, - )); - } -} - pub fn update(state: &mut AppState, message: Message) -> Task { - // ── Startup ─────────────────────────────────────────────────────────────── - if let Message::Noop = message { return Task::none(); } - if let Message::ProfilesLoaded(store) = &message { - let store = store.clone(); - state.theme = resolve_theme_config(store.general.theme); - state.profiles = store; - - // Only rebuild the connection launchpad when we are still sitting on - // the connection screen. If we are already on Main (e.g. a background - // save fired ProfilesLoaded after a successful connect) we must not - // navigate away. - if matches!(state.screen, Screen::Connection(_)) { - let conn = ConnectionScreen::new_launchpad(&state.profiles.profiles); - let focus = conn.initial_focus_task().map(Message::Connection); - state.screen = Screen::Connection(conn); - return focus; - } - return Task::none(); - } - - if let Message::AutoConnectResult(result) = &message { - match result { - Ok(info) => { - let id = state.profiles.last_connected.expect("set before probe"); - let profile = state.profiles.get(id).expect("profile must exist"); - let creds = profile.credentials( - state - .unlocked_passphrase - .as_ref() - .map(|s| s.expose_secret().as_str()), - ); - let sid = info.session_id.clone(); - let profile_name = profile.name.clone(); - tracing::info!(profile = %profile_name, "Auto-connect succeeded"); - state.alt_speed_enabled = info.alt_speed_enabled; - // Push the profile's stored bandwidth settings to the daemon. - let push_task = make_push_bandwidth_task(&creds.rpc_url(), &creds, &sid, profile); - state.screen = Screen::Main(MainScreen::new_with_label( - creds, - sid, - Some(profile_name), - Some(id), - state.profiles.general.refresh_interval, - )); - if let Some(t) = push_task { - return t; - } - } - Err(err) => { - tracing::warn!(error = %err, "Auto-connect failed; showing connection launchpad"); - let conn = ConnectionScreen::new_launchpad(&state.profiles.profiles); - let focus = conn.initial_focus_task().map(Message::Connection); - state.screen = Screen::Connection(conn); - return focus; - } - } - return Task::none(); + if let Some(task) = routing::handle_startup_message(state, &message) { + return task; } - // ── Global intercepts ───────────────────────────────────────────────────── - - match &message { - Message::Main(main_screen::Message::Disconnect) => { - tracing::info!("Disconnecting; returning to connection launchpad"); - state.active_profile = None; - let conn = ConnectionScreen::new_launchpad(&state.profiles.profiles); - let focus = conn.initial_focus_task().map(Message::Connection); - state.screen = Screen::Connection(conn); - return focus; - } - Message::Main(main_screen::Message::OpenSettingsClicked) => { - open_settings(state, SettingsTab::General); - return Task::none(); - } - Message::Main(main_screen::Message::TurtleModeToggled) => { - let new_val = !state.alt_speed_enabled; - state.alt_speed_enabled = new_val; - if let Screen::Main(main) = &state.screen { - let params = main.list.params.clone(); - let args = crate::rpc::SessionSetArgs { - alt_speed_enabled: Some(new_val), - ..Default::default() - }; - return Task::perform( - async move { - crate::rpc::session_set( - ¶ms.url, - ¶ms.credentials, - ¶ms.session_id, - &args, - ) - .await - }, - |_| Message::Noop, - ); - } - return Task::none(); - } - Message::Main(main_screen::Message::SessionDataLoaded(data)) => { - state.alt_speed_enabled = data.alt_speed_enabled; - return Task::none(); - } - Message::Connection(connection::Message::ManageProfilesClicked) => { - open_settings(state, SettingsTab::Connections); - return Task::none(); - } - Message::Connection(connection::Message::ConnectProfile(id)) => { - let id = *id; - let Some(profile) = state.profiles.get(id) else { - return Task::none(); - }; - // If the profile has an encrypted password and the passphrase is not - // yet unlocked, show the unlock dialog before connecting. - if profile.encrypted_password.is_some() && state.unlocked_passphrase.is_none() { - state.active_dialog = Some(AuthDialog::Unlock { - pending_action: PendingAction::ConnectToProfile(id), - passphrase_input: String::new(), - error: None, - is_processing: false, - }); - return iced::widget::operation::focus(crate::auth::unlock_input_id()); - } - // Passphrase available (or no password required) — build creds and connect. - let creds = profile.credentials( - state - .unlocked_passphrase - .as_ref() - .map(|s| s.expose_secret().as_str()), - ); - return Task::done(Message::Connection(connection::Message::ConnectWithCreds { - profile_id: id, - creds, - })); - } - _ => {} + if let Some(task) = routing::handle_global_message(state, &message) { + return task; } - // ── Auth dialog messages ────────────────────────────────────────────────── - if let Some(task) = crate::auth::handle_message(state, &message) { // Keep the settings screen's cached hash in sync (e.g. after first-time // passphrase setup while the settings screen is open). @@ -406,318 +214,17 @@ pub fn update(state: &mut AppState, message: Message) -> Task { return task; } - // ── Per-screen dispatch ─────────────────────────────────────────────────── - - match &mut state.screen { - Screen::Connection(conn) => { - match message { - Message::Connection(msg) => { - let (task, opt_success) = conn.update(msg); - if let Some(success) = opt_success { - state.alt_speed_enabled = success.alt_speed_enabled; - if let Some(id) = success.profile_id { - // Saved profile: persist last_connected. - state.active_profile = Some(id); - state.profiles.last_connected = Some(id); - let profile_name = state.profiles.get(id).map(|p| p.name.clone()); - let push_task = state.profiles.get(id).and_then(|p| { - make_push_bandwidth_task( - &success.creds.rpc_url(), - &success.creds, - &success.session_id, - p, - ) - }); - let profiles_snap = state.profiles.clone(); - let save_task = - Task::perform(async move { profiles_snap.save().await }, |_| { - Message::Noop - }); - state.screen = Screen::Main(MainScreen::new_with_label( - success.creds, - success.session_id, - profile_name, - Some(id), - state.profiles.general.refresh_interval, - )); - tracing::info!(profile_id = %id, "Connected via saved profile"); - let base = save_task.chain(task.map(Message::Connection)); - return if let Some(t) = push_task { - base.chain(t) - } else { - base - }; - } else { - // Ephemeral quick connect: nothing is persisted. - state.active_profile = None; - state.screen = Screen::Main(MainScreen::new_with_label( - success.creds, - success.session_id, - None, - None, - state.profiles.general.refresh_interval, - )); - tracing::info!("Connected via quick connect (ephemeral)"); - } - } - task.map(Message::Connection) - } - _ => Task::none(), - } - } + if matches!(&state.screen, Screen::Connection(_)) { + return routing::handle_connection_message(state, message); + } - Screen::Main(main) => match message { - Message::Main(msg) => main.update(msg).map(Message::Main), - _ => Task::none(), - }, + if matches!(&state.screen, Screen::Settings(_)) { + return settings_bridge::handle_message(state, message); + } - Screen::Settings(settings) => { - match message { - Message::Settings(msg) => { - let (task, opt_result) = settings.update(msg); - if let Some(result) = opt_result { - match result { - settings::SettingsResult::GeneralSettingsSaved { - theme_config, - mut store, - } => { - store.adopt_from(&state.profiles); - state.profiles = store; - state.theme = resolve_theme_config(theme_config); - // Propagate new interval to stashed main (if settings were - // opened while main was active). - if let Some(m) = &mut state.stashed_main { - m.refresh_interval = state.profiles.general.refresh_interval; - } - // Re-save so last_connected is not lost (the settings - // screen's own save omits it). - let snap = state.profiles.clone(); - return task.map(Message::Settings).chain(Task::perform( - async move { snap.save().await }, - |_| Message::Noop, - )); - } - settings::SettingsResult::StoreUpdated(mut store) => { - store.adopt_from(&state.profiles); - state.profiles = store; - // Re-save so last_connected is not lost. - let snap = state.profiles.clone(); - return task.map(Message::Settings).chain(Task::perform( - async move { snap.save().await }, - |_| Message::Noop, - )); - } - settings::SettingsResult::ActiveProfileSaved { - profile_id, - mut store, - } => { - // Re-probe using AutoConnectResult so the main screen - // loads fresh session info with updated credentials. - store.adopt_from(&state.profiles); - store.last_connected = Some(profile_id); - state.profiles = store; - state.active_profile = Some(profile_id); - let profile = state.profiles.get(profile_id).expect("just saved"); - let creds = profile.credentials( - state - .unlocked_passphrase - .as_ref() - .map(|s| s.expose_secret().as_str()), - ); - let url = creds.rpc_url(); - let probe = Task::perform( - async move { - crate::rpc::session_get(&url, &creds, "") - .await - .map_err(|e| e.to_string()) - }, - Message::AutoConnectResult, - ); - return task.map(Message::Settings).chain(probe); - } - settings::SettingsResult::ActiveProfileBandwidthSaved { - profile_id, - mut store, - } => { - // Only bandwidth/seeding settings changed — no reconnect. - // Just update the store and push new limits to the daemon. - store.adopt_from(&state.profiles); - state.profiles = store; - let push_task = state.profiles.get(profile_id).and_then(|p| { - state.stashed_main.as_ref().map(|m| { - make_push_bandwidth_task( - &m.list.params.url, - &m.list.params.credentials, - &m.list.params.session_id, - p, - ) - }) - }); - let snap = state.profiles.clone(); - let save = Task::perform(async move { snap.save().await }, |_| { - Message::Noop - }); - let base = task.map(Message::Settings).chain(save); - return if let Some(Some(push)) = push_task { - base.chain(push) - } else { - base - }; - } - settings::SettingsResult::Closed(mut store) => { - store.adopt_from(&state.profiles); - state.profiles = store; - // Restore the stashed main screen if available — - // this avoids a reconnect/refetch. - if let Some(main) = state.stashed_main.take() { - state.screen = Screen::Main(main); - return Task::none(); - } - // Fallback: re-open main from active profile. - if let Some(id) = state.active_profile - && let Some(profile) = state.profiles.get(id) - { - let creds = profile.credentials( - state - .unlocked_passphrase - .as_ref() - .map(|s| s.expose_secret().as_str()), - ); - state.screen = Screen::Main(MainScreen::new_with_label( - creds, - String::new(), - state.profiles.get(id).map(|p| p.name.clone()), - Some(id), - state.profiles.general.refresh_interval, - )); - return Task::none(); - } - state.active_profile = None; - let conn = - ConnectionScreen::new_launchpad(&state.profiles.profiles); - let focus = conn.initial_focus_task().map(Message::Connection); - state.screen = Screen::Connection(conn); - return focus; - } - settings::SettingsResult::SaveWithPassword { - profile_id, - password, - mut store, - } => { - store.adopt_from(&state.profiles); - state.profiles = store; - match &state.profiles.master_passphrase_hash { - None => { - state.active_dialog = Some(AuthDialog::SetupPassphrase { - pending_profile_id: profile_id, - pending_password: password, - passphrase_input: String::new(), - confirm_input: String::new(), - error: None, - is_processing: false, - }); - return task.map(Message::Settings).chain( - iced::widget::operation::focus( - crate::auth::setup_passphrase_id(), - ), - ); - } - Some(_) if state.unlocked_passphrase.is_none() => { - state.active_dialog = Some(AuthDialog::Unlock { - pending_action: PendingAction::SavePassword { - profile_id, - password, - }, - passphrase_input: String::new(), - error: None, - is_processing: false, - }); - return task.map(Message::Settings).chain( - iced::widget::operation::focus( - crate::auth::unlock_input_id(), - ), - ); - } - Some(_) => { - // Passphrase already unlocked — encrypt immediately. - let passphrase = state - .unlocked_passphrase - .as_ref() - .unwrap() - .expose_secret() - .to_owned(); - let encrypt_task = Task::perform( - async move { - let creds = - tokio::task::spawn_blocking(move || { - crypto::encrypt_password( - &passphrase, - &password, - ) - }) - .await - .expect("encrypt task panicked"); - (profile_id, creds) - }, - |(pid, ep)| Message::EncryptPasswordReady { - profile_id: pid, - encrypted_password: ep, - }, - ); - return task.map(Message::Settings).chain(encrypt_task); - } - } - } - settings::SettingsResult::TestConnectionWithId { profile_id } => { - // If the passphrase is locked, prompt for it before testing. - if let Some(profile) = state.profiles.get(profile_id) - && profile.encrypted_password.is_some() - && state.unlocked_passphrase.is_none() - { - state.active_dialog = Some(AuthDialog::Unlock { - pending_action: PendingAction::TestConnectionFromSettings { - profile_id, - }, - passphrase_input: String::new(), - error: None, - is_processing: false, - }); - return task.map(Message::Settings).chain( - iced::widget::operation::focus( - crate::auth::unlock_input_id(), - ), - ); - } - // Passphrase available (or no password required). - let passphrase = state - .unlocked_passphrase - .as_ref() - .map(|s| s.expose_secret().as_str()); - if let Some(profile) = state.profiles.get(profile_id) { - let creds = profile.credentials(passphrase); - let url = creds.rpc_url(); - let probe = Task::perform( - async move { - crate::rpc::session_get(&url, &creds, "") - .await - .map_err(|e| e.to_string()) - }, - |r| { - Message::Settings( - settings::Message::TestConnectionResult(r), - ) - }, - ); - return task.map(Message::Settings).chain(probe); - } - } - } - } - task.map(Message::Settings) - } - _ => Task::none(), - } - } + match (&mut state.screen, message) { + (Screen::Main(main), Message::Main(msg)) => main.update(msg).map(Message::Main), + _ => Task::none(), } } @@ -735,79 +242,5 @@ pub fn view(state: &AppState) -> Element<'_, Message> { /// Return active subscriptions. pub fn subscription(state: &AppState) -> Subscription { - let dialog_active = state.active_dialog.is_some(); - - match &state.screen { - Screen::Connection(_) => { - iced::keyboard::listen() - .with(dialog_active) - .filter_map(|(dialog_active, event)| { - use iced::keyboard::{Event, Key, key::Named}; - if let Event::KeyPressed { key, modifiers, .. } = event { - match key.as_ref() { - Key::Named(Named::Tab) if dialog_active => { - Some(Message::AuthTabKeyPressed { - shift: modifiers.shift(), - }) - } - Key::Named(Named::Enter) - if dialog_active && !modifiers.control() && !modifiers.alt() => - { - Some(Message::AuthEnterPressed) - } - Key::Named(Named::Tab) => { - Some(Message::Connection(connection::Message::TabKeyPressed { - shift: modifiers.shift(), - })) - } - Key::Named(Named::Enter) - if !modifiers.control() && !modifiers.alt() => - { - Some(Message::Connection(connection::Message::EnterPressed)) - } - _ => None, - } - } else { - None - } - }) - } - - Screen::Settings(_) => { - iced::keyboard::listen() - .with(dialog_active) - .filter_map(|(dialog_active, event)| { - use iced::keyboard::{Event, Key, key::Named}; - if let Event::KeyPressed { key, modifiers, .. } = event { - match key.as_ref() { - Key::Named(Named::Tab) if dialog_active => { - Some(Message::AuthTabKeyPressed { - shift: modifiers.shift(), - }) - } - Key::Named(Named::Enter) - if dialog_active && !modifiers.control() && !modifiers.alt() => - { - Some(Message::AuthEnterPressed) - } - Key::Named(Named::Tab) => { - Some(Message::Settings(settings::Message::TabKeyPressed { - shift: modifiers.shift(), - })) - } - Key::Named(Named::Enter) - if !modifiers.control() && !modifiers.alt() => - { - Some(Message::Settings(settings::Message::EnterPressed)) - } - _ => None, - } - } else { - None - } - }) - } - - Screen::Main(main) => main.subscription().map(Message::Main), - } + keyboard::subscription(state) } diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs new file mode 100644 index 0000000..ad978e2 --- /dev/null +++ b/src/app/keyboard.rs @@ -0,0 +1,92 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::Subscription; +use iced::keyboard::{Event, Key, key::Named}; + +use crate::screens::{connection, settings}; + +use super::{AppState, Message, Screen}; + +#[derive(Clone, Copy)] +enum FormScreen { + Connection, + Settings, +} + +pub(super) fn subscription(state: &AppState) -> Subscription { + let dialog_active = state.active_dialog.is_some(); + + match &state.screen { + Screen::Connection(_) => connection_subscription(dialog_active), + Screen::Settings(_) => settings_subscription(dialog_active), + Screen::Main(main) => main.subscription().map(Message::Main), + } +} + +fn connection_subscription(dialog_active: bool) -> Subscription { + iced::keyboard::listen() + .with(dialog_active) + .filter_map(connection_key_event) +} + +fn settings_subscription(dialog_active: bool) -> Subscription { + iced::keyboard::listen() + .with(dialog_active) + .filter_map(settings_key_event) +} + +fn connection_key_event((dialog_active, event): (bool, Event)) -> Option { + map_key_event(FormScreen::Connection, dialog_active, event) +} + +fn settings_key_event((dialog_active, event): (bool, Event)) -> Option { + map_key_event(FormScreen::Settings, dialog_active, event) +} + +fn map_key_event(screen: FormScreen, dialog_active: bool, event: Event) -> Option { + if let Event::KeyPressed { key, modifiers, .. } = event { + match key.as_ref() { + Key::Named(Named::Tab) if dialog_active => Some(Message::AuthTabKeyPressed { + shift: modifiers.shift(), + }), + Key::Named(Named::Enter) + if dialog_active && !modifiers.control() && !modifiers.alt() => + { + Some(Message::AuthEnterPressed) + } + Key::Named(Named::Tab) => Some(screen_tab_message(screen, modifiers.shift())), + Key::Named(Named::Enter) if !modifiers.control() && !modifiers.alt() => { + Some(screen_enter_message(screen)) + } + _ => None, + } + } else { + None + } +} + +fn screen_tab_message(screen: FormScreen, shift: bool) -> Message { + match screen { + FormScreen::Connection => Message::Connection(connection::Message::TabKeyPressed { shift }), + FormScreen::Settings => Message::Settings(settings::Message::TabKeyPressed { shift }), + } +} + +fn screen_enter_message(screen: FormScreen) -> Message { + match screen { + FormScreen::Connection => Message::Connection(connection::Message::EnterPressed), + FormScreen::Settings => Message::Settings(settings::Message::EnterPressed), + } +} diff --git a/src/app/routing.rs b/src/app/routing.rs new file mode 100644 index 0000000..7721d7a --- /dev/null +++ b/src/app/routing.rs @@ -0,0 +1,333 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use secrecy::ExposeSecret; +use uuid::Uuid; + +use iced::Task; + +use crate::auth::{AuthDialog, PendingAction}; +use crate::profile::{ConnectionProfile, ProfileStore, resolve_theme_config}; +use crate::rpc::{self, SessionData, SessionSetArgs, TransmissionCredentials}; +use crate::screens::connection::{self, ConnectionScreen}; +use crate::screens::main_screen::{self, MainScreen}; +use crate::screens::settings::{SettingsScreen, SettingsTab}; + +use super::{AppState, Message, Screen}; + +pub(super) fn handle_startup_message( + state: &mut AppState, + message: &Message, +) -> Option> { + match message { + Message::ProfilesLoaded(store) => Some(apply_loaded_profiles(state, store.clone())), + Message::AutoConnectResult(result) => Some(apply_auto_connect_result(state, result)), + _ => None, + } +} + +pub(super) fn handle_global_message( + state: &mut AppState, + message: &Message, +) -> Option> { + match message { + Message::Main(main_screen::Message::Disconnect) => { + tracing::info!("Disconnecting; returning to connection launchpad"); + state.active_profile = None; + Some(show_connection_launchpad(state)) + } + Message::Main(main_screen::Message::OpenSettingsClicked) => { + open_settings(state, SettingsTab::General); + Some(Task::none()) + } + Message::Main(main_screen::Message::TurtleModeToggled) => Some(toggle_turtle_mode(state)), + Message::Main(main_screen::Message::SessionDataLoaded(data)) => { + state.alt_speed_enabled = data.alt_speed_enabled; + Some(Task::none()) + } + Message::Connection(connection::Message::ManageProfilesClicked) => { + open_settings(state, SettingsTab::Connections); + Some(Task::none()) + } + Message::Connection(connection::Message::ConnectProfile(profile_id)) => { + Some(connect_saved_profile(state, *profile_id)) + } + _ => None, + } +} + +pub(super) fn handle_connection_message(state: &mut AppState, message: Message) -> Task { + let (task, opt_success) = match (&mut state.screen, message) { + (Screen::Connection(connection), Message::Connection(msg)) => connection.update(msg), + _ => return Task::none(), + }; + + let Some(success) = opt_success else { + return task.map(Message::Connection); + }; + + state.alt_speed_enabled = success.alt_speed_enabled; + + if let Some(profile_id) = success.profile_id { + state.active_profile = Some(profile_id); + state.profiles.last_connected = Some(profile_id); + + let profile_name = state + .profiles + .get(profile_id) + .map(|profile| profile.name.clone()); + let push_task = state.profiles.get(profile_id).and_then(|profile| { + make_push_bandwidth_task( + &success.creds.rpc_url(), + &success.creds, + &success.session_id, + profile, + ) + }); + let profiles_snapshot = state.profiles.clone(); + + state.screen = Screen::Main(MainScreen::new_with_label( + success.creds, + success.session_id, + profile_name, + Some(profile_id), + state.profiles.general.refresh_interval, + )); + tracing::info!(profile_id = %profile_id, "Connected via saved profile"); + + let save_task = Task::perform(async move { profiles_snapshot.save().await }, |_| { + Message::Noop + }); + let base = save_task.chain(task.map(Message::Connection)); + return if let Some(push_task) = push_task { + base.chain(push_task) + } else { + base + }; + } + + state.active_profile = None; + state.screen = Screen::Main(MainScreen::new_with_label( + success.creds, + success.session_id, + None, + None, + state.profiles.general.refresh_interval, + )); + tracing::info!("Connected via quick connect (ephemeral)"); + task.map(Message::Connection) +} + +pub(super) fn make_push_bandwidth_task( + url: &str, + credentials: &TransmissionCredentials, + session_id: &str, + profile: &ConnectionProfile, +) -> Option> { + let has_anything = profile.speed_limit_down_enabled + || profile.speed_limit_down != 0 + || profile.speed_limit_up_enabled + || profile.speed_limit_up != 0 + || profile.alt_speed_down != 0 + || profile.alt_speed_up != 0 + || profile.ratio_limit_enabled + || profile.ratio_limit != 0.0; + if !has_anything { + return None; + } + + let url = url.to_owned(); + let credentials = credentials.clone(); + let session_id = session_id.to_owned(); + let args = SessionSetArgs { + speed_limit_down_enabled: Some(profile.speed_limit_down_enabled), + speed_limit_down: Some(profile.speed_limit_down), + speed_limit_up_enabled: Some(profile.speed_limit_up_enabled), + speed_limit_up: Some(profile.speed_limit_up), + alt_speed_down: if profile.alt_speed_down != 0 { + Some(profile.alt_speed_down) + } else { + None + }, + alt_speed_up: if profile.alt_speed_up != 0 { + Some(profile.alt_speed_up) + } else { + None + }, + seed_ratio_limited: Some(profile.ratio_limit_enabled), + seed_ratio_limit: Some(profile.ratio_limit), + ..Default::default() + }; + + Some(Task::perform( + async move { rpc::session_set(&url, &credentials, &session_id, &args).await }, + |_| Message::Noop, + )) +} + +pub(super) fn profile_credentials( + state: &AppState, + profile: &ConnectionProfile, +) -> TransmissionCredentials { + profile.credentials( + state + .unlocked_passphrase + .as_ref() + .map(|passphrase| passphrase.expose_secret().as_str()), + ) +} + +pub(super) fn probe_profile(state: &AppState, profile_id: Uuid) -> Task { + let Some(profile) = state.profiles.get(profile_id) else { + return Task::none(); + }; + + let credentials = profile_credentials(state, profile); + let url = credentials.rpc_url(); + Task::perform( + async move { + rpc::session_get(&url, &credentials, "") + .await + .map_err(|error| error.to_string()) + }, + Message::AutoConnectResult, + ) +} + +pub(super) fn show_connection_launchpad(state: &mut AppState) -> Task { + let connection = ConnectionScreen::new_launchpad(&state.profiles.profiles); + let focus = connection.initial_focus_task().map(Message::Connection); + state.screen = Screen::Connection(connection); + focus +} + +pub(super) fn open_settings(state: &mut AppState, tab: SettingsTab) { + if let Screen::Main(_) = &state.screen { + if let Screen::Main(main) = std::mem::replace( + &mut state.screen, + Screen::Settings(SettingsScreen::new( + &state.profiles, + state.active_profile, + tab, + )), + ) { + state.stashed_main = Some(main); + } + } else { + state.screen = Screen::Settings(SettingsScreen::new( + &state.profiles, + state.active_profile, + tab, + )); + } +} + +fn apply_loaded_profiles(state: &mut AppState, store: ProfileStore) -> Task { + state.theme = resolve_theme_config(store.general.theme); + state.profiles = store; + + if matches!(state.screen, Screen::Connection(_)) { + return show_connection_launchpad(state); + } + + Task::none() +} + +fn apply_auto_connect_result( + state: &mut AppState, + result: &Result, +) -> Task { + match result { + Ok(info) => { + let profile_id = state.profiles.last_connected.expect("set before probe"); + let profile = state.profiles.get(profile_id).expect("profile must exist"); + let credentials = profile_credentials(state, profile); + let session_id = info.session_id.clone(); + let profile_name = profile.name.clone(); + tracing::info!(profile = %profile_name, "Auto-connect succeeded"); + state.alt_speed_enabled = info.alt_speed_enabled; + + let push_task = make_push_bandwidth_task( + &credentials.rpc_url(), + &credentials, + &session_id, + profile, + ); + state.screen = Screen::Main(MainScreen::new_with_label( + credentials, + session_id, + Some(profile_name), + Some(profile_id), + state.profiles.general.refresh_interval, + )); + + if let Some(push_task) = push_task { + push_task + } else { + Task::none() + } + } + Err(error) => { + tracing::warn!(error = %error, "Auto-connect failed; showing connection launchpad"); + show_connection_launchpad(state) + } + } +} + +fn connect_saved_profile(state: &mut AppState, profile_id: Uuid) -> Task { + let Some(profile) = state.profiles.get(profile_id) else { + return Task::none(); + }; + let has_encrypted_password = profile.encrypted_password.is_some(); + + if has_encrypted_password && state.unlocked_passphrase.is_none() { + state.active_dialog = Some(AuthDialog::Unlock { + pending_action: PendingAction::ConnectToProfile(profile_id), + passphrase_input: String::new(), + error: None, + is_processing: false, + }); + return iced::widget::operation::focus(crate::auth::unlock_input_id()); + } + + let Some(profile) = state.profiles.get(profile_id) else { + return Task::none(); + }; + let credentials = profile_credentials(state, profile); + Task::done(Message::Connection(connection::Message::ConnectWithCreds { + profile_id, + creds: credentials, + })) +} + +fn toggle_turtle_mode(state: &mut AppState) -> Task { + let new_value = !state.alt_speed_enabled; + state.alt_speed_enabled = new_value; + + if let Screen::Main(main) = &state.screen { + let params = main.list.params.clone(); + let args = SessionSetArgs { + alt_speed_enabled: Some(new_value), + ..Default::default() + }; + return Task::perform( + async move { + rpc::session_set(¶ms.url, ¶ms.credentials, ¶ms.session_id, &args).await + }, + |_| Message::Noop, + ); + } + + Task::none() +} diff --git a/src/app/settings_bridge.rs b/src/app/settings_bridge.rs new file mode 100644 index 0000000..c50cc96 --- /dev/null +++ b/src/app/settings_bridge.rs @@ -0,0 +1,238 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use secrecy::ExposeSecret; + +use iced::Task; + +use crate::auth::{AuthDialog, PendingAction}; +use crate::crypto; +use crate::profile::{ProfileStore, resolve_theme_config}; +use crate::screens::main_screen::MainScreen; +use crate::screens::settings::{self, SettingsResult}; + +use super::routing::{ + make_push_bandwidth_task, probe_profile, profile_credentials, show_connection_launchpad, +}; +use super::{AppState, Message, Screen}; + +pub(super) fn handle_message(state: &mut AppState, message: Message) -> Task { + let (task, opt_result) = match (&mut state.screen, message) { + (Screen::Settings(settings), Message::Settings(msg)) => settings.update(msg), + _ => return Task::none(), + }; + + let Some(result) = opt_result else { + return task.map(Message::Settings); + }; + + handle_result(state, task, result) +} + +fn handle_result( + state: &mut AppState, + task: Task, + result: SettingsResult, +) -> Task { + match result { + SettingsResult::GeneralSettingsSaved { + theme_config, + mut store, + } => { + store.adopt_from(&state.profiles); + state.profiles = store; + state.theme = resolve_theme_config(theme_config); + if let Some(main) = &mut state.stashed_main { + main.refresh_interval = state.profiles.general.refresh_interval; + } + save_profiles_task(task, state.profiles.clone()) + } + SettingsResult::StoreUpdated(mut store) => { + store.adopt_from(&state.profiles); + state.profiles = store; + save_profiles_task(task, state.profiles.clone()) + } + SettingsResult::ActiveProfileSaved { + profile_id, + mut store, + } => { + store.adopt_from(&state.profiles); + store.last_connected = Some(profile_id); + state.profiles = store; + state.active_profile = Some(profile_id); + + task.map(Message::Settings) + .chain(probe_profile(state, profile_id)) + } + SettingsResult::ActiveProfileBandwidthSaved { + profile_id, + mut store, + } => { + store.adopt_from(&state.profiles); + state.profiles = store; + + let push_task = state.profiles.get(profile_id).and_then(|profile| { + state.stashed_main.as_ref().and_then(|main| { + make_push_bandwidth_task( + &main.list.params.url, + &main.list.params.credentials, + &main.list.params.session_id, + profile, + ) + }) + }); + + let base = save_profiles_task(task, state.profiles.clone()); + if let Some(push_task) = push_task { + base.chain(push_task) + } else { + base + } + } + SettingsResult::Closed(mut store) => { + store.adopt_from(&state.profiles); + state.profiles = store; + + if let Some(main) = state.stashed_main.take() { + state.screen = Screen::Main(main); + return Task::none(); + } + + if let Some(profile_id) = state.active_profile + && let Some(profile) = state.profiles.get(profile_id) + { + let profile_name = profile.name.clone(); + let credentials = profile_credentials(state, profile); + state.screen = Screen::Main(MainScreen::new_with_label( + credentials, + String::new(), + Some(profile_name), + Some(profile_id), + state.profiles.general.refresh_interval, + )); + return Task::none(); + } + + state.active_profile = None; + show_connection_launchpad(state) + } + SettingsResult::SaveWithPassword { + profile_id, + password, + mut store, + } => { + store.adopt_from(&state.profiles); + state.profiles = store; + + match &state.profiles.master_passphrase_hash { + None => { + state.active_dialog = Some(AuthDialog::SetupPassphrase { + pending_profile_id: profile_id, + pending_password: password, + passphrase_input: String::new(), + confirm_input: String::new(), + error: None, + is_processing: false, + }); + focus_setup_input(task) + } + Some(_) if state.unlocked_passphrase.is_none() => { + state.active_dialog = Some(AuthDialog::Unlock { + pending_action: PendingAction::SavePassword { + profile_id, + password, + }, + passphrase_input: String::new(), + error: None, + is_processing: false, + }); + focus_unlock_input(task) + } + Some(_) => { + let passphrase = state + .unlocked_passphrase + .as_ref() + .expect("unlock guard handled earlier") + .expose_secret() + .to_owned(); + let encrypt_task = Task::perform( + async move { + let encrypted_password = tokio::task::spawn_blocking(move || { + crypto::encrypt_password(&passphrase, &password) + }) + .await + .expect("encrypt task panicked"); + (profile_id, encrypted_password) + }, + |(profile_id, encrypted_password)| Message::EncryptPasswordReady { + profile_id, + encrypted_password, + }, + ); + task.map(Message::Settings).chain(encrypt_task) + } + } + } + SettingsResult::TestConnectionWithId { profile_id } => { + if let Some(profile) = state.profiles.get(profile_id) + && profile.encrypted_password.is_some() + && state.unlocked_passphrase.is_none() + { + state.active_dialog = Some(AuthDialog::Unlock { + pending_action: PendingAction::TestConnectionFromSettings { profile_id }, + passphrase_input: String::new(), + error: None, + is_processing: false, + }); + return focus_unlock_input(task); + } + + let Some(profile) = state.profiles.get(profile_id) else { + return task.map(Message::Settings); + }; + let credentials = profile_credentials(state, profile); + let url = credentials.rpc_url(); + let probe = Task::perform( + async move { + crate::rpc::session_get(&url, &credentials, "") + .await + .map_err(|error| error.to_string()) + }, + |result| Message::Settings(settings::Message::TestConnectionResult(result)), + ); + task.map(Message::Settings).chain(probe) + } + } +} + +fn save_profiles_task(task: Task, snapshot: ProfileStore) -> Task { + task.map(Message::Settings) + .chain(Task::perform(async move { snapshot.save().await }, |_| { + Message::Noop + })) +} + +fn focus_setup_input(task: Task) -> Task { + task.map(Message::Settings) + .chain(iced::widget::operation::focus( + crate::auth::setup_passphrase_id(), + )) +} + +fn focus_unlock_input(task: Task) -> Task { + task.map(Message::Settings) + .chain(iced::widget::operation::focus( + crate::auth::unlock_input_id(), + )) +} diff --git a/src/screens/torrent_list/columns.rs b/src/screens/torrent_list/columns.rs new file mode 100644 index 0000000..145dc05 --- /dev/null +++ b/src/screens/torrent_list/columns.rs @@ -0,0 +1,275 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::tooltip; +use iced::widget::{button, container, row, text}; +use iced::{Alignment, Element, Length}; + +use crate::theme::{ICON_DOWNLOAD, ICON_UPLOAD, MATERIAL_ICONS}; + +use super::sort::{SortColumn, SortDir}; +use super::{Message, TorrentListScreen}; + +pub(crate) const W_STATUS: f32 = 90.0; +pub(crate) const W_SIZE: f32 = 80.0; +pub(crate) const W_SPEED_DOWN: f32 = 90.0; +pub(crate) const W_SPEED_UP: f32 = 90.0; +pub(crate) const W_ETA: f32 = 80.0; +pub(crate) const W_RATIO: f32 = 64.0; +pub(crate) const W_PROGRESS: f32 = 130.0; +pub(crate) const SCROLLBAR_WIDTH: f32 = 14.0; + +pub(crate) fn view_column_header(state: &TorrentListScreen) -> Element<'_, Message> { + let header_row = row![ + container( + tooltip( + col_header_btn( + "NAME", + SortColumn::Name, + &state.sort_column, + state.sort_dir, + Alignment::Start + ) + .width(Length::Fill), + text("Sort by name"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fill), + container( + tooltip( + col_header_btn( + "STATUS", + SortColumn::Status, + &state.sort_column, + state.sort_dir, + Alignment::Start + ) + .width(Length::Fixed(W_STATUS)), + text("Sort by status"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_STATUS)), + container( + tooltip( + col_header_btn( + "SIZE", + SortColumn::Size, + &state.sort_column, + state.sort_dir, + Alignment::End + ) + .width(Length::Fixed(W_SIZE)), + text("Total size"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_SIZE)), + container( + tooltip( + col_header_icon_btn( + ICON_DOWNLOAD, + SortColumn::SpeedDown, + &state.sort_column, + state.sort_dir, + Alignment::End + ) + .width(Length::Fixed(W_SPEED_DOWN)), + text("Download speed"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_SPEED_DOWN)), + container( + tooltip( + col_header_icon_btn( + ICON_UPLOAD, + SortColumn::SpeedUp, + &state.sort_column, + state.sort_dir, + Alignment::End + ) + .width(Length::Fixed(W_SPEED_UP)), + text("Upload speed"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_SPEED_UP)), + container( + tooltip( + col_header_btn( + "ETA", + SortColumn::Eta, + &state.sort_column, + state.sort_dir, + Alignment::End + ) + .width(Length::Fixed(W_ETA)), + text("Estimated time remaining"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_ETA)), + container( + tooltip( + col_header_btn( + "RATIO", + SortColumn::Ratio, + &state.sort_column, + state.sort_dir, + Alignment::End + ) + .width(Length::Fixed(W_RATIO)), + text("Upload/download ratio"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_RATIO)), + container( + tooltip( + col_header_btn( + "PROGRESS", + SortColumn::Progress, + &state.sort_column, + state.sort_dir, + Alignment::End + ) + .width(Length::Fixed(W_PROGRESS)), + text("Percent complete"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ) + .width(Length::Fixed(W_PROGRESS)), + ] + .spacing(16); + + container(header_row) + .padding(iced::Padding { + top: 0.0, + bottom: 0.0, + left: 0.0, + right: SCROLLBAR_WIDTH + 2.0, + }) + .into() +} + +fn col_header_btn( + label: &'static str, + col: SortColumn, + active: &Option, + dir: SortDir, + alignment: Alignment, +) -> iced::widget::Button<'static, Message> { + let chevron = chevron_indicator(col, active, dir); + let label_elem = text(label) + .size(11) + .width(Length::Fill) + .align_x(alignment) + .style(|theme: &iced::Theme| iced::widget::text::Style { + color: Some(theme.palette().text.scale_alpha(0.55)), + }); + let chevron_elem = text(chevron) + .size(14) + .width(Length::Fixed(14.0)) + .align_x(Alignment::Center) + .style(|theme: &iced::Theme| iced::widget::text::Style { + color: Some(theme.palette().text.scale_alpha(0.55)), + }); + + let content: Element<'static, Message> = if alignment == Alignment::End { + row![chevron_elem, label_elem] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() + } else { + row![label_elem, chevron_elem] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() + }; + + button(content) + .on_press(Message::ColumnHeaderClicked(col)) + .style(iced::widget::button::text) + .padding([2, 0]) +} + +fn col_header_icon_btn( + glyph: char, + col: SortColumn, + active: &Option, + dir: SortDir, + alignment: Alignment, +) -> iced::widget::Button<'static, Message> { + let chevron = chevron_indicator(col, active, dir); + let icon_elem = text(String::from(glyph)) + .font(MATERIAL_ICONS) + .size(14) + .width(Length::Fill) + .align_x(alignment) + .style(|theme: &iced::Theme| iced::widget::text::Style { + color: Some(theme.palette().text.scale_alpha(0.55)), + }); + let chevron_elem = text(chevron) + .size(14) + .width(Length::Fixed(14.0)) + .align_x(Alignment::Center) + .style(|theme: &iced::Theme| iced::widget::text::Style { + color: Some(theme.palette().text.scale_alpha(0.55)), + }); + + let content: Element<'static, Message> = if alignment == Alignment::End { + row![chevron_elem, icon_elem] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() + } else { + row![icon_elem, chevron_elem] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() + }; + + button(content) + .on_press(Message::ColumnHeaderClicked(col)) + .style(iced::widget::button::text) + .padding([2, 0]) +} + +fn chevron_indicator(col: SortColumn, active: &Option, dir: SortDir) -> &'static str { + match active { + Some(current) if *current == col => match dir { + SortDir::Asc => "▴", + SortDir::Desc => "▾", + }, + _ => "", + } +} diff --git a/src/screens/torrent_list/dialogs.rs b/src/screens/torrent_list/dialogs.rs new file mode 100644 index 0000000..66a6ad3 --- /dev/null +++ b/src/screens/torrent_list/dialogs.rs @@ -0,0 +1,208 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::{Space, button, checkbox, column, container, mouse_area, row, stack, text}; +use iced::{Alignment, Element, Length}; + +use crate::theme::{ICON_DELETE, ICON_FOLDER, ICON_PAUSE, ICON_PLAY, MATERIAL_ICONS}; + +use super::{Message, SetLocationDialog, TorrentListScreen}; + +/// Menu width in logical pixels — used for right-edge mitigation. +const MENU_WIDTH: f32 = 220.0; + +/// Builds a single M3 context-menu row with a fixed-width icon container and +/// an edge-to-edge hover highlight. Pass `on_press = None` to render disabled. +fn menu_item<'a>( + icon_glyph: char, + label: &'a str, + on_press: Option, +) -> Element<'a, Message> { + let icon_widget = text(icon_glyph) + .font(MATERIAL_ICONS) + .size(18) + .width(Length::Fixed(24.0)) + .align_x(Alignment::Center); + + let content = row![icon_widget, text(label).size(14)] + .spacing(12) + .align_y(Alignment::Center); + + let button = button(content).width(Length::Fill).padding([8, 16]); + + if let Some(msg) = on_press { + button + .on_press(msg) + .style(crate::theme::m3_menu_item) + .into() + } else { + button.style(crate::theme::m3_menu_item_disabled).into() + } +} + +/// Builds the context menu overlay element when a context menu is open. +pub fn view_context_menu_overlay(state: &TorrentListScreen) -> Option> { + let (torrent_id, point) = state.context_menu?; + + let effective_y = if point.y > state.window_height - 150.0 { + point.y - 150.0 + } else { + point.y + }; + let effective_x = if point.x + MENU_WIDTH > state.window_width { + (point.x - MENU_WIDTH).max(0.0) + } else { + point.x + }; + + let torrent_opt = state + .torrents + .iter() + .find(|torrent| torrent.id == torrent_id); + let can_start = torrent_opt.is_some_and(|torrent| !matches!(torrent.status, 3..=6)); + let can_pause = torrent_opt.is_some_and(|torrent| matches!(torrent.status, 3..=6)); + + let menu_card = container( + column![ + menu_item( + ICON_PLAY, + "Start", + can_start.then_some(Message::ContextMenuStart(torrent_id)) + ), + menu_item( + ICON_PAUSE, + "Pause", + can_pause.then_some(Message::ContextMenuPause(torrent_id)) + ), + menu_item( + ICON_DELETE, + "Delete", + Some(Message::ContextMenuDelete(torrent_id)) + ), + menu_item( + ICON_FOLDER, + "Set Data Location", + Some(Message::OpenSetLocation(torrent_id)) + ), + ] + .spacing(0), + ) + .padding(iced::Padding { + top: 8.0, + bottom: 8.0, + left: 0.0, + right: 0.0, + }) + .width(Length::Fixed(MENU_WIDTH)) + .style(crate::theme::m3_menu_card); + + let positioned = container(menu_card) + .padding(iced::Padding { + top: effective_y, + left: effective_x, + right: 0.0, + bottom: 0.0, + }) + .width(Length::Fill) + .height(Length::Fill); + + let click_away = mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill), + ) + .on_press(Message::DismissContextMenu); + + Some(stack![click_away, positioned].into()) +} + +pub(crate) fn view_delete_dialog(name: &str, del_local: bool) -> Element<'_, Message> { + let card = container( + column![ + text(format!("Delete \"{}\"?", name)).size(18), + text("This cannot be undone.").size(13), + checkbox(del_local) + .label("Also delete local data") + .on_toggle(Message::DeleteLocalDataToggled), + row![ + Space::new().width(Length::Fill), + button("Cancel") + .on_press(Message::DeleteCancelled) + .padding([10, 24]) + .style(crate::theme::m3_tonal_button), + button("Confirm Delete") + .on_press(Message::DeleteConfirmed) + .padding([10, 24]) + .style(crate::theme::danger_pill_button), + ] + .spacing(8) + .width(Length::Fill), + ] + .spacing(16), + ) + .padding(28) + .max_width(400.0) + .style(crate::theme::dialog_card); + + container(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(crate::theme::dialog_scrim(0.70)) + .into() +} + +pub(crate) fn view_set_location_dialog(dlg: &SetLocationDialog) -> Element<'_, Message> { + use iced::widget::text_input; + + let card = container( + column![ + text("Set Data Location").size(18), + text("Destination path on the daemon's filesystem"), + text_input("/path/to/data", &dlg.path) + .on_input(Message::SetLocationPathChanged) + .padding([12, 16]) + .style(crate::theme::m3_text_input), + checkbox(dlg.move_data) + .label("Move data to new location") + .on_toggle(|_| Message::SetLocationMoveToggled), + row![ + Space::new().width(Length::Fill), + button("Cancel") + .on_press(Message::SetLocationCancel) + .padding([10, 24]) + .style(crate::theme::m3_tonal_button), + button("Apply") + .on_press(Message::SetLocationApply) + .padding([10, 24]) + .style(crate::theme::m3_primary_button), + ] + .spacing(8) + .width(Length::Fill), + ] + .spacing(16), + ) + .padding(28) + .max_width(440.0) + .style(crate::theme::m3_card); + + container(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(crate::theme::dialog_scrim(0.70)) + .into() +} diff --git a/src/screens/torrent_list/filters.rs b/src/screens/torrent_list/filters.rs new file mode 100644 index 0000000..e300119 --- /dev/null +++ b/src/screens/torrent_list/filters.rs @@ -0,0 +1,149 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::HashSet; + +use crate::rpc::TorrentData; + +use super::StatusFilter; +use super::sort::{SortColumn, SortDir, sort_torrents}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct FilterCounts { + pub downloading: u32, + pub seeding: u32, + pub paused: u32, + pub active: u32, + pub error: u32, +} + +/// Returns all [`StatusFilter`] buckets that apply to `t`. +/// +/// A torrent may match more than one bucket simultaneously; for example, a +/// torrent actively downloading at 500 KB/s will be in both `Downloading` and +/// `Active`. The caller should use `.iter().any(|f| set.contains(f))` to +/// decide visibility. +pub fn matching_filters(t: &TorrentData) -> Vec { + let mut out = Vec::with_capacity(2); + match t.status { + 3 | 4 => out.push(StatusFilter::Downloading), + 5 | 6 => out.push(StatusFilter::Seeding), + 0 => out.push(StatusFilter::Paused), + 1 | 2 => out.push(StatusFilter::Error), + _ => {} + } + if t.rate_download > 0 || t.rate_upload > 0 { + out.push(StatusFilter::Active); + } + out +} + +pub(crate) fn count_filters(torrents: &[TorrentData]) -> FilterCounts { + let mut counts = FilterCounts::default(); + + for torrent in torrents { + for filter in matching_filters(torrent) { + match filter { + StatusFilter::Downloading => counts.downloading += 1, + StatusFilter::Seeding => counts.seeding += 1, + StatusFilter::Paused => counts.paused += 1, + StatusFilter::Active => counts.active += 1, + StatusFilter::Error => counts.error += 1, + } + } + } + + counts +} + +pub(crate) fn torrent_matches_active_filters( + torrent: &TorrentData, + active_filters: &HashSet, +) -> bool { + matching_filters(torrent) + .iter() + .any(|filter| active_filters.contains(filter)) +} + +pub(crate) fn display_torrents<'a>( + torrents: &'a [TorrentData], + sort_column: Option, + sort_dir: SortDir, + active_filters: &HashSet, +) -> Vec<&'a TorrentData> { + let sorted: Vec<&TorrentData> = match sort_column { + Some(column) => sort_torrents(torrents, column, sort_dir), + None => torrents.iter().collect(), + }; + + sorted + .into_iter() + .filter(|torrent| torrent_matches_active_filters(torrent, active_filters)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_torrent( + id: i64, + name: &str, + status: i32, + rate_download: i64, + rate_upload: i64, + ) -> TorrentData { + TorrentData { + id, + name: name.to_owned(), + status, + rate_download, + rate_upload, + ..Default::default() + } + } + + #[test] + fn count_filters_tallies_multi_bucket_membership() { + let torrents = vec![ + make_torrent(1, "alpha", 4, 1024, 0), + make_torrent(2, "beta", 0, 0, 0), + make_torrent(3, "gamma", 1, 0, 0), + ]; + + let counts = count_filters(&torrents); + + assert_eq!(counts.downloading, 1); + assert_eq!(counts.active, 1); + assert_eq!(counts.paused, 1); + assert_eq!(counts.error, 1); + assert_eq!(counts.seeding, 0); + } + + #[test] + fn display_torrents_applies_sort_before_filtering() { + let torrents = vec![ + make_torrent(1, "zeta", 4, 1, 0), + make_torrent(2, "alpha", 0, 0, 0), + make_torrent(3, "beta", 4, 1, 0), + ]; + let filters = HashSet::from([StatusFilter::Downloading]); + + let display = display_torrents(&torrents, Some(SortColumn::Name), SortDir::Asc, &filters); + + assert_eq!(display.len(), 2); + assert_eq!(display[0].name, "beta"); + assert_eq!(display[1].name, "zeta"); + } +} diff --git a/src/screens/torrent_list/mod.rs b/src/screens/torrent_list/mod.rs index 342de66..4e0129b 100644 --- a/src/screens/torrent_list/mod.rs +++ b/src/screens/torrent_list/mod.rs @@ -17,12 +17,20 @@ //! //! - [`sort`] — Pure column-sort logic (no UI dependencies) //! - [`add_dialog`] — Add-torrent modal dialog state and view -//! - [`view`] — Widget-tree rendering (toolbar, header, rows) +//! - [`filters`] — Pure status-bucket counting and visibility helpers +//! - [`toolbar`] — Torrent-list toolbar rendering +//! - [`columns`] — Sticky header and sort-indicator rendering +//! - [`dialogs`] — Context-menu and modal overlay rendering +//! - [`view`] — Widget-tree rendering orchestration and row rendering //! - [`update`] — Elm update function //! - [`worker`] — Serialized RPC worker subscription stream pub mod add_dialog; +mod columns; +mod dialogs; +mod filters; pub mod sort; +mod toolbar; mod update; pub mod view; pub mod worker; @@ -38,9 +46,10 @@ use sort::{SortColumn, SortDir}; // Re-export for parent modules. pub use add_dialog::FileReadResult; +pub use dialogs::view_context_menu_overlay; +pub use filters::matching_filters; pub use update::update; pub use view::view; -pub use view::view_context_menu_overlay; pub use worker::rpc_worker_stream; // ── SetLocationDialog ─────────────────────────────────────────────────────────────── @@ -89,27 +98,6 @@ impl StatusFilter { } } -/// Returns all [`StatusFilter`] buckets that apply to `t`. -/// -/// A torrent may match more than one bucket simultaneously; for example, a -/// torrent actively downloading at 500 KB/s will be in both `Downloading` and -/// `Active`. The caller should use `.iter().any(|f| set.contains(f))` to -/// decide visibility. -pub fn matching_filters(t: &TorrentData) -> Vec { - let mut out = Vec::with_capacity(2); - match t.status { - 3 | 4 => out.push(StatusFilter::Downloading), - 5 | 6 => out.push(StatusFilter::Seeding), - 0 => out.push(StatusFilter::Paused), - 1 | 2 => out.push(StatusFilter::Error), - _ => {} - } - if t.rate_download > 0 || t.rate_upload > 0 { - out.push(StatusFilter::Active); - } - out -} - use iced::Subscription; // ── Message ─────────────────────────────────────────────────────────────────── diff --git a/src/screens/torrent_list/toolbar.rs b/src/screens/torrent_list/toolbar.rs new file mode 100644 index 0000000..2667632 --- /dev/null +++ b/src/screens/torrent_list/toolbar.rs @@ -0,0 +1,131 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use iced::widget::tooltip; +use iced::widget::{Space, row, text}; +use iced::{Alignment, Element, Length}; + +use crate::theme::{ + ICON_ADD, ICON_DELETE, ICON_LINK, ICON_LOGOUT, ICON_PAUSE, ICON_PLAY, ICON_SETTINGS, + ICON_SPEED, active_icon_button, icon, icon_button, +}; + +use super::{Message, TorrentListScreen}; + +pub(crate) fn view_normal_toolbar( + state: &TorrentListScreen, + alt_speed_enabled: bool, +) -> Element<'_, Message> { + let selected = state + .selected_id + .and_then(|id| state.torrents.iter().find(|torrent| torrent.id == id)); + let can_pause = selected.is_some_and(|torrent| matches!(torrent.status, 3..=6)); + let can_resume = selected.is_some_and(|torrent| torrent.status == 0); + let can_delete = state.selected_id.is_some(); + + let group1: Element = row![ + tooltip( + icon_button(icon(ICON_ADD)).on_press(Message::AddTorrentClicked), + text("Add torrent from file"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + tooltip( + icon_button(icon(ICON_LINK)).on_press(Message::AddLinkClicked), + text("Add torrent from magnet link"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ] + .spacing(4) + .into(); + + let pause_btn = { + let button = icon_button(icon(ICON_PAUSE)); + if can_pause { + button.on_press(Message::PauseClicked) + } else { + button + } + }; + let resume_btn = { + let button = icon_button(icon(ICON_PLAY)); + if can_resume { + button.on_press(Message::ResumeClicked) + } else { + button + } + }; + let delete_btn = { + let button = icon_button(icon(ICON_DELETE)); + if can_delete { + button.on_press(Message::DeleteClicked) + } else { + button + } + }; + let group2: Element = row![ + tooltip(pause_btn, text("Pause"), tooltip::Position::Bottom) + .gap(6) + .style(crate::theme::m3_tooltip), + tooltip(resume_btn, text("Resume"), tooltip::Position::Bottom) + .gap(6) + .style(crate::theme::m3_tooltip), + tooltip(delete_btn, text("Delete"), tooltip::Position::Bottom) + .gap(6) + .style(crate::theme::m3_tooltip), + ] + .spacing(4) + .into(); + + let group3: Element = row![ + tooltip( + active_icon_button(icon(ICON_SPEED), alt_speed_enabled) + .on_press(Message::TurtleModeToggled), + text("Turtle Mode"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + tooltip( + icon_button(icon(ICON_SETTINGS)).on_press(Message::OpenSettingsClicked), + text("Settings"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + tooltip( + icon_button(icon(ICON_LOGOUT)).on_press(Message::Disconnect), + text("Disconnect"), + tooltip::Position::Bottom, + ) + .gap(6) + .style(crate::theme::m3_tooltip), + ] + .spacing(4) + .into(); + + row![ + group1, + Space::new().width(16), + group2, + Space::new().width(Length::Fill), + group3 + ] + .align_y(Alignment::Center) + .spacing(0) + .into() +} diff --git a/src/screens/torrent_list/view.rs b/src/screens/torrent_list/view.rs index 105ea0a..be0dd9e 100644 --- a/src/screens/torrent_list/view.rs +++ b/src/screens/torrent_list/view.rs @@ -13,34 +13,24 @@ // limitations under the License. use iced::widget::rule; -use iced::widget::tooltip; use iced::widget::{ - Space, button, checkbox, column, container, mouse_area, opaque, progress_bar, row, scrollable, - stack, text, + Space, button, column, container, mouse_area, opaque, progress_bar, row, scrollable, stack, + text, }; use iced::{Alignment, Element, Length}; use crate::format::{format_eta, format_size, format_speed}; -use crate::rpc::TorrentData; -use crate::theme::{ - ICON_ADD, ICON_DELETE, ICON_DOWNLOAD, ICON_FOLDER, ICON_LINK, ICON_LOGOUT, ICON_PAUSE, - ICON_PLAY, ICON_SETTINGS, ICON_SPEED, ICON_UPLOAD, MATERIAL_ICONS, icon, m3_filter_chip, - progress_bar_style, -}; +use crate::theme::{MATERIAL_ICONS, m3_filter_chip, progress_bar_style}; use super::add_dialog::{AddDialogState, view_add_dialog}; -use super::sort::{SortColumn, SortDir, sort_torrents}; -use super::{Message, SetLocationDialog, StatusFilter, TorrentListScreen, matching_filters}; - -// Fixed pixel widths for narrow numeric columns. -const W_STATUS: f32 = 90.0; -const W_SIZE: f32 = 80.0; -const W_SPEED_DOWN: f32 = 90.0; -const W_SPEED_UP: f32 = 90.0; -const W_ETA: f32 = 80.0; -const W_RATIO: f32 = 64.0; -const W_PROGRESS: f32 = 130.0; -const SCROLLBAR_WIDTH: f32 = 14.0; +use super::columns::{ + SCROLLBAR_WIDTH, W_ETA, W_PROGRESS, W_RATIO, W_SIZE, W_SPEED_DOWN, W_SPEED_UP, W_STATUS, + view_column_header, +}; +use super::dialogs::{view_delete_dialog, view_set_location_dialog}; +use super::filters::{count_filters, display_torrents}; +use super::toolbar::view_normal_toolbar; +use super::{Message, StatusFilter, TorrentListScreen}; fn status_label(status: i32) -> &'static str { match status { @@ -97,11 +87,11 @@ fn filter_chip( pub fn view( state: &TorrentListScreen, - theme_mode: crate::app::ThemeMode, + _theme_mode: crate::app::ThemeMode, alt_speed_enabled: bool, ) -> Element<'_, Message> { // ── Toolbar ─────────────────────────────────────────────────────────────── - let toolbar = view_normal_toolbar(state, theme_mode, alt_speed_enabled); + let toolbar = view_normal_toolbar(state, alt_speed_enabled); // ── Inline error banner ─────────────────────────────────────────────────── let error_row: Element = if let Some(err) = &state.error { @@ -110,23 +100,7 @@ pub fn view( Space::new().into() }; - // ── Count pass: tally per-bucket counts over the full torrent list ──────── - let mut count_downloading: u32 = 0; - let mut count_seeding: u32 = 0; - let mut count_paused: u32 = 0; - let mut count_active: u32 = 0; - let mut count_error: u32 = 0; - for t in &state.torrents { - for f in matching_filters(t) { - match f { - StatusFilter::Downloading => count_downloading += 1, - StatusFilter::Seeding => count_seeding += 1, - StatusFilter::Paused => count_paused += 1, - StatusFilter::Active => count_active += 1, - StatusFilter::Error => count_error += 1, - } - } - } + let counts = count_filters(&state.torrents); // ── Filter chips row ────────────────────────────────────────────────────── let is_all_selected = state.filters.len() == StatusFilter::all().len(); @@ -140,31 +114,31 @@ pub fn view( Space::new().width(Length::Fixed(4.0)), filter_chip( "Downloading", - count_downloading, + counts.downloading, state.filters.contains(&StatusFilter::Downloading), Message::FilterToggled(StatusFilter::Downloading), ), filter_chip( "Seeding", - count_seeding, + counts.seeding, state.filters.contains(&StatusFilter::Seeding), Message::FilterToggled(StatusFilter::Seeding), ), filter_chip( "Paused", - count_paused, + counts.paused, state.filters.contains(&StatusFilter::Paused), Message::FilterToggled(StatusFilter::Paused), ), filter_chip( "Active", - count_active, + counts.active, state.filters.contains(&StatusFilter::Active), Message::FilterToggled(StatusFilter::Active), ), filter_chip( "Error", - count_error, + counts.error, state.filters.contains(&StatusFilter::Error), Message::FilterToggled(StatusFilter::Error), ), @@ -174,20 +148,12 @@ pub fn view( // ── Sticky header ───────────────────────────────────────────────────────── let header = view_column_header(state); - - // ── Data rows (sorted, then filtered against active chips) ──────────────── - let sorted: Vec<&TorrentData> = match state.sort_column { - Some(col) => sort_torrents(&state.torrents, col, state.sort_dir), - None => state.torrents.iter().collect(), - }; - let display: Vec<&TorrentData> = sorted - .into_iter() - .filter(|t| { - matching_filters(t) - .iter() - .any(|f| state.filters.contains(f)) - }) - .collect(); + let display = display_torrents( + &state.torrents, + state.sort_column, + state.sort_dir, + &state.filters, + ); let is_list_empty = state.initial_load_done && state.torrents.is_empty(); let is_filter_empty = @@ -368,549 +334,3 @@ pub fn view( // can draw over the inspector panel. Nothing to do here. after_set_location } - -/// Menu width in logical pixels — used for right-edge mitigation. -const MENU_WIDTH: f32 = 220.0; - -/// Builds a single M3 context-menu row with a fixed-width icon container and -/// an edge-to-edge hover highlight. Pass `on_press = None` to render disabled. -fn menu_item<'a>( - icon_glyph: char, - label: &'a str, - on_press: Option, -) -> Element<'a, Message> { - // Force every icon glyph into an identical 24-px bounding box so that - // narrow glyphs (play triangle) align with wide ones (trash square). - let icon_widget = text(icon_glyph) - .font(MATERIAL_ICONS) - .size(18) - .width(Length::Fixed(24.0)) - .align_x(Alignment::Center); - - let content = row![icon_widget, text(label).size(14)] - .spacing(12) - .align_y(Alignment::Center); - - let btn = button(content).width(Length::Fill).padding([8, 16]); - - if let Some(msg) = on_press { - btn.on_press(msg).style(crate::theme::m3_menu_item).into() - } else { - btn.style(crate::theme::m3_menu_item_disabled).into() - } -} - -/// Builds the context menu overlay element when a context menu is open. -/// -/// Returns `None` when no context menu is active. The returned element must be -/// placed on top of the **full** application content (including the inspector) -/// so the menu is not clipped by the torrent-list container. -pub fn view_context_menu_overlay(state: &TorrentListScreen) -> Option> { - let (torrent_id, point) = state.context_menu?; - - // Bottom-edge mitigation: shift menu up if it would overflow. - let effective_y = if point.y > state.window_height - 150.0 { - point.y - 150.0 - } else { - point.y - }; - - // Right-edge mitigation: anchor to the left of the cursor when there is - // not enough horizontal space to the right. - let effective_x = if point.x + MENU_WIDTH > state.window_width { - (point.x - MENU_WIDTH).max(0.0) - } else { - point.x - }; - - let torrent_opt = state.torrents.iter().find(|t| t.id == torrent_id); - let can_start = torrent_opt.is_some_and(|t| !matches!(t.status, 3..=6)); - let can_pause = torrent_opt.is_some_and(|t| matches!(t.status, 3..=6)); - - // M3 menu card: 8px vertical padding, zero horizontal padding so buttons - // span edge-to-edge with their own horizontal padding inside. - let menu_card = container( - column![ - menu_item( - ICON_PLAY, - "Start", - can_start.then_some(Message::ContextMenuStart(torrent_id)) - ), - menu_item( - ICON_PAUSE, - "Pause", - can_pause.then_some(Message::ContextMenuPause(torrent_id)) - ), - menu_item( - ICON_DELETE, - "Delete", - Some(Message::ContextMenuDelete(torrent_id)) - ), - menu_item( - ICON_FOLDER, - "Set Data Location", - Some(Message::OpenSetLocation(torrent_id)) - ), - ] - .spacing(0), - ) - .padding(iced::Padding { - top: 8.0, - bottom: 8.0, - left: 0.0, - right: 0.0, - }) - .width(Length::Fixed(MENU_WIDTH)) - .style(crate::theme::m3_menu_card); - - let positioned = container(menu_card) - .padding(iced::Padding { - top: effective_y, - left: effective_x, - right: 0.0, - bottom: 0.0, - }) - .width(Length::Fill) - .height(Length::Fill); - - let click_away = mouse_area( - container(Space::new()) - .width(Length::Fill) - .height(Length::Fill), - ) - .on_press(Message::DismissContextMenu); - - Some(stack![click_away, positioned].into()) -} - -fn view_delete_dialog(name: &str, del_local: bool) -> Element<'_, Message> { - let card = container( - column![ - text(format!("Delete \"{}\"?", name)).size(18), - text("This cannot be undone.").size(13), - checkbox(del_local) - .label("Also delete local data") - .on_toggle(Message::DeleteLocalDataToggled), - row![ - Space::new().width(Length::Fill), - button("Cancel") - .on_press(Message::DeleteCancelled) - .padding([10, 24]) - .style(crate::theme::m3_tonal_button), - button("Confirm Delete") - .on_press(Message::DeleteConfirmed) - .padding([10, 24]) - .style(crate::theme::danger_pill_button), - ] - .spacing(8) - .width(Length::Fill), - ] - .spacing(16), - ) - .padding(28) - .max_width(400.0) - .style(crate::theme::dialog_card); - - container(card) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(crate::theme::dialog_scrim(0.70)) - .into() -} - -fn view_set_location_dialog(dlg: &SetLocationDialog) -> Element<'_, Message> { - use iced::widget::text_input; - - let card = container( - column![ - text("Set Data Location").size(18), - text("Destination path on the daemon's filesystem"), - text_input("/path/to/data", &dlg.path) - .on_input(Message::SetLocationPathChanged) - .padding([12, 16]) - .style(crate::theme::m3_text_input), - checkbox(dlg.move_data) - .label("Move data to new location") - .on_toggle(|_| Message::SetLocationMoveToggled), - row![ - Space::new().width(Length::Fill), - button("Cancel") - .on_press(Message::SetLocationCancel) - .padding([10, 24]) - .style(crate::theme::m3_tonal_button), - button("Apply") - .on_press(Message::SetLocationApply) - .padding([10, 24]) - .style(crate::theme::m3_primary_button), - ] - .spacing(8) - .width(Length::Fill), - ] - .spacing(16), - ) - .padding(28) - .max_width(440.0) - .style(crate::theme::m3_card); - - container(card) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(crate::theme::dialog_scrim(0.70)) - .into() -} - -fn view_normal_toolbar( - state: &TorrentListScreen, - _theme_mode: crate::app::ThemeMode, - alt_speed_enabled: bool, -) -> Element<'_, Message> { - let selected = state - .selected_id - .and_then(|id| state.torrents.iter().find(|t| t.id == id)); - let can_pause = selected.is_some_and(|t| matches!(t.status, 3..=6)); - let can_resume = selected.is_some_and(|t| t.status == 0); - let can_delete = state.selected_id.is_some(); - - let group1: Element = row![ - tooltip( - crate::theme::icon_button(icon(ICON_ADD)).on_press(Message::AddTorrentClicked), - text("Add torrent from file"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - tooltip( - crate::theme::icon_button(icon(ICON_LINK)).on_press(Message::AddLinkClicked), - text("Add torrent from magnet link"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ] - .spacing(4) - .into(); - - let pause_btn = { - let b = crate::theme::icon_button(icon(ICON_PAUSE)); - if can_pause { - b.on_press(Message::PauseClicked) - } else { - b - } - }; - let resume_btn = { - let b = crate::theme::icon_button(icon(ICON_PLAY)); - if can_resume { - b.on_press(Message::ResumeClicked) - } else { - b - } - }; - let delete_btn = { - let b = crate::theme::icon_button(icon(ICON_DELETE)); - if can_delete { - b.on_press(Message::DeleteClicked) - } else { - b - } - }; - let group2: Element = row![ - tooltip(pause_btn, text("Pause"), tooltip::Position::Bottom) - .gap(6) - .style(crate::theme::m3_tooltip), - tooltip(resume_btn, text("Resume"), tooltip::Position::Bottom) - .gap(6) - .style(crate::theme::m3_tooltip), - tooltip(delete_btn, text("Delete"), tooltip::Position::Bottom) - .gap(6) - .style(crate::theme::m3_tooltip), - ] - .spacing(4) - .into(); - - let group3: Element = row![ - tooltip( - crate::theme::active_icon_button(icon(ICON_SPEED), alt_speed_enabled) - .on_press(Message::TurtleModeToggled), - text("Turtle Mode"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - tooltip( - crate::theme::icon_button(icon(ICON_SETTINGS)).on_press(Message::OpenSettingsClicked), - text("Settings"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - tooltip( - crate::theme::icon_button(icon(ICON_LOGOUT)).on_press(Message::Disconnect), - text("Disconnect"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ] - .spacing(4) - .into(); - - row![ - group1, - Space::new().width(16), - group2, - Space::new().width(Length::Fill), - group3 - ] - .align_y(Alignment::Center) - .spacing(0) - .into() -} - -fn view_column_header(state: &TorrentListScreen) -> Element<'_, Message> { - let header_row = row![ - container( - tooltip( - col_header_btn( - "NAME", - SortColumn::Name, - &state.sort_column, - state.sort_dir, - Alignment::Start - ) - .width(Length::Fill), - text("Sort by name"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fill), - container( - tooltip( - col_header_btn( - "STATUS", - SortColumn::Status, - &state.sort_column, - state.sort_dir, - Alignment::Start - ) - .width(Length::Fixed(W_STATUS)), - text("Sort by status"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_STATUS)), - container( - tooltip( - col_header_btn( - "SIZE", - SortColumn::Size, - &state.sort_column, - state.sort_dir, - Alignment::End - ) - .width(Length::Fixed(W_SIZE)), - text("Total size"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_SIZE)), - container( - tooltip( - col_header_icon_btn( - ICON_DOWNLOAD, - SortColumn::SpeedDown, - &state.sort_column, - state.sort_dir, - Alignment::End - ) - .width(Length::Fixed(W_SPEED_DOWN)), - text("Download speed"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_SPEED_DOWN)), - container( - tooltip( - col_header_icon_btn( - ICON_UPLOAD, - SortColumn::SpeedUp, - &state.sort_column, - state.sort_dir, - Alignment::End - ) - .width(Length::Fixed(W_SPEED_UP)), - text("Upload speed"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_SPEED_UP)), - container( - tooltip( - col_header_btn( - "ETA", - SortColumn::Eta, - &state.sort_column, - state.sort_dir, - Alignment::End - ) - .width(Length::Fixed(W_ETA)), - text("Estimated time remaining"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_ETA)), - container( - tooltip( - col_header_btn( - "RATIO", - SortColumn::Ratio, - &state.sort_column, - state.sort_dir, - Alignment::End - ) - .width(Length::Fixed(W_RATIO)), - text("Upload/download ratio"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_RATIO)), - container( - tooltip( - col_header_btn( - "PROGRESS", - SortColumn::Progress, - &state.sort_column, - state.sort_dir, - Alignment::End - ) - .width(Length::Fixed(W_PROGRESS)), - text("Percent complete"), - tooltip::Position::Bottom, - ) - .gap(6) - .style(crate::theme::m3_tooltip), - ) - .width(Length::Fixed(W_PROGRESS)), - ] - .spacing(16); - - container(header_row) - .padding(iced::Padding { - top: 0.0, - bottom: 0.0, - left: 0.0, - right: SCROLLBAR_WIDTH + 2.0, - }) - .into() -} - -fn col_header_btn( - label: &'static str, - col: SortColumn, - active: &Option, - dir: SortDir, - alignment: Alignment, -) -> iced::widget::Button<'static, Message> { - let chevron = chevron_indicator(col, active, dir); - let label_elem = text(label) - .size(11) - .width(Length::Fill) - .align_x(alignment) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.palette().text.scale_alpha(0.55)), - }); - let chevron_elem = text(chevron) - .size(14) - .width(Length::Fixed(14.0)) - .align_x(Alignment::Center) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.palette().text.scale_alpha(0.55)), - }); - - let content: Element<'static, Message> = if alignment == Alignment::End { - row![chevron_elem, label_elem] - .width(Length::Fill) - .align_y(Alignment::Center) - .into() - } else { - row![label_elem, chevron_elem] - .width(Length::Fill) - .align_y(Alignment::Center) - .into() - }; - - button(content) - .on_press(Message::ColumnHeaderClicked(col)) - .style(iced::widget::button::text) - .padding([2, 0]) -} - -fn col_header_icon_btn( - glyph: char, - col: SortColumn, - active: &Option, - dir: SortDir, - alignment: Alignment, -) -> iced::widget::Button<'static, Message> { - let chevron = chevron_indicator(col, active, dir); - let icon_elem = text(String::from(glyph)) - .font(crate::theme::MATERIAL_ICONS) - .size(14) - .width(Length::Fill) - .align_x(alignment) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.palette().text.scale_alpha(0.55)), - }); - let chevron_elem = text(chevron) - .size(14) - .width(Length::Fixed(14.0)) - .align_x(Alignment::Center) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.palette().text.scale_alpha(0.55)), - }); - - let content: Element<'static, Message> = if alignment == Alignment::End { - row![chevron_elem, icon_elem] - .width(Length::Fill) - .align_y(Alignment::Center) - .into() - } else { - row![icon_elem, chevron_elem] - .width(Length::Fill) - .align_y(Alignment::Center) - .into() - }; - - button(content) - .on_press(Message::ColumnHeaderClicked(col)) - .style(iced::widget::button::text) - .padding([2, 0]) -} - -fn chevron_indicator(col: SortColumn, active: &Option, dir: SortDir) -> &'static str { - match active { - Some(c) if *c == col => match dir { - SortDir::Asc => "▴", - SortDir::Desc => "▾", - }, - _ => "", - } -} diff --git a/system_architecture.md b/system_architecture.md index 32ac181..d7845e7 100644 --- a/system_architecture.md +++ b/system_architecture.md @@ -29,8 +29,8 @@ pub enum Screen { ``` **Message Dispatch:** Each screen has its own Message enum. The top-level `app::Message` wraps these -and routes them to the active screen's `update()` function, handling global intercepts (like opening -settings or locking the app) at the top level. +and routes them through `app.rs`, which delegates startup/global flow, settings-result reconciliation, +and screen-specific keyboard subscriptions to private `src/app/` helpers. ## The RPC Layer & Data Flow @@ -74,7 +74,8 @@ A quick map of where to find things: ```text src/ ├── main.rs # Entry point, window constraints, tracing setup -├── app.rs # AppState, Screen router, top-level update/view +├── app.rs # AppState, Screen router, top-level facade for update/view +├── app/ # Private routing, settings bridge, and keyboard helpers ├── rpc/ # Transmission API, types, and the serialized worker ├── screens/ # Individual screen states and views │ ├── connection/ # Profile selection and quick-connect From fd59c38c58eda62ea549a7de63396a03125d21aa Mon Sep 17 00:00:00 2001 From: Samuel Hitz Date: Thu, 9 Apr 2026 08:35:26 +0200 Subject: [PATCH 3/4] cleanup --- src/app/keyboard.rs | 3 ++ src/app/routing.rs | 10 ++++ src/app/settings_bridge.rs | 3 ++ src/lib.rs | 6 +++ src/main.rs | 3 ++ src/profile.rs | 6 +-- src/rpc/api.rs | 6 +-- src/rpc/error.rs | 2 + src/rpc/transport.rs | 2 +- src/screens/inspector.rs | 14 +++--- src/screens/mod.rs | 2 + src/screens/settings/draft.rs | 5 ++ src/screens/settings/mod.rs | 2 +- src/screens/settings/state.rs | 8 ++++ src/screens/settings/update.rs | 2 + src/screens/settings/view.rs | 3 ++ src/screens/torrent_list/add_dialog.rs | 3 ++ src/screens/torrent_list/columns.rs | 3 ++ src/screens/torrent_list/dialogs.rs | 4 ++ src/screens/torrent_list/filters.rs | 6 +++ src/screens/torrent_list/mod.rs | 66 +++++++++++++------------- src/screens/torrent_list/sort.rs | 26 +++++----- src/screens/torrent_list/toolbar.rs | 3 ++ src/screens/torrent_list/update.rs | 3 ++ src/screens/torrent_list/view.rs | 6 +-- src/theme/buttons.rs | 2 + src/theme/containers.rs | 2 + src/theme/inputs.rs | 2 + src/theme/progress.rs | 2 + src/theme/segmented.rs | 2 + src/theme/selection.rs | 2 + 31 files changed, 145 insertions(+), 64 deletions(-) diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index ad978e2..3e8f2e1 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Keyboard subscriptions for the connection and settings form screens. + use iced::Subscription; use iced::keyboard::{Event, Key, key::Named}; @@ -25,6 +27,7 @@ enum FormScreen { Settings, } +/// Build the active keyboard subscription for the current top-level screen. pub(super) fn subscription(state: &AppState) -> Subscription { let dialog_active = state.active_dialog.is_some(); diff --git a/src/app/routing.rs b/src/app/routing.rs index 7721d7a..06b2ec0 100644 --- a/src/app/routing.rs +++ b/src/app/routing.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Startup flow and app-level routing helpers for the root application state. + use secrecy::ExposeSecret; use uuid::Uuid; @@ -26,6 +28,7 @@ use crate::screens::settings::{SettingsScreen, SettingsTab}; use super::{AppState, Message, Screen}; +/// Handle startup-only messages before normal screen dispatch begins. pub(super) fn handle_startup_message( state: &mut AppState, message: &Message, @@ -37,6 +40,7 @@ pub(super) fn handle_startup_message( } } +/// Handle top-level messages that reroute screens or trigger app-wide side effects. pub(super) fn handle_global_message( state: &mut AppState, message: &Message, @@ -67,6 +71,7 @@ pub(super) fn handle_global_message( } } +/// Forward connection-screen messages and reconcile successful connects into `AppState`. pub(super) fn handle_connection_message(state: &mut AppState, message: Message) -> Task { let (task, opt_success) = match (&mut state.screen, message) { (Screen::Connection(connection), Message::Connection(msg)) => connection.update(msg), @@ -129,6 +134,7 @@ pub(super) fn handle_connection_message(state: &mut AppState, message: Message) task.map(Message::Connection) } +/// Build a `session-set` task when a profile stores non-default bandwidth settings. pub(super) fn make_push_bandwidth_task( url: &str, credentials: &TransmissionCredentials, @@ -176,6 +182,7 @@ pub(super) fn make_push_bandwidth_task( )) } +/// Resolve a profile into runtime credentials using the unlocked passphrase, if present. pub(super) fn profile_credentials( state: &AppState, profile: &ConnectionProfile, @@ -188,6 +195,7 @@ pub(super) fn profile_credentials( ) } +/// Probe a saved profile by issuing a background `session-get` request. pub(super) fn probe_profile(state: &AppState, profile_id: Uuid) -> Task { let Some(profile) = state.profiles.get(profile_id) else { return Task::none(); @@ -205,6 +213,7 @@ pub(super) fn probe_profile(state: &AppState, profile_id: Uuid) -> Task ) } +/// Rebuild the connection launchpad and return any initial focus task it requires. pub(super) fn show_connection_launchpad(state: &mut AppState) -> Task { let connection = ConnectionScreen::new_launchpad(&state.profiles.profiles); let focus = connection.initial_focus_task().map(Message::Connection); @@ -212,6 +221,7 @@ pub(super) fn show_connection_launchpad(state: &mut AppState) -> Task { focus } +/// Stash the current main screen, if any, and switch to the settings screen. pub(super) fn open_settings(state: &mut AppState, tab: SettingsTab) { if let Screen::Main(_) = &state.screen { if let Screen::Main(main) = std::mem::replace( diff --git a/src/app/settings_bridge.rs b/src/app/settings_bridge.rs index c50cc96..1011f4f 100644 --- a/src/app/settings_bridge.rs +++ b/src/app/settings_bridge.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Reconcile `SettingsScreen` results back into the root `AppState`. + use secrecy::ExposeSecret; use iced::Task; @@ -27,6 +29,7 @@ use super::routing::{ }; use super::{AppState, Message, Screen}; +/// Forward settings messages and apply any returned `SettingsResult` to `AppState`. pub(super) fn handle_message(state: &mut AppState, message: Message) -> Task { let (task, opt_result) = match (&mut state.screen, message) { (Screen::Settings(settings), Message::Settings(msg)) => settings.update(msg), diff --git a/src/lib.rs b/src/lib.rs index 9252bcb..e251837 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Library crate for Clutch. +//! +//! This crate exposes the app shell, screen modules, RPC client, profile +//! persistence, formatting helpers, and shared theme layer used by the desktop +//! binary. + pub mod app; pub mod auth; pub mod crypto; diff --git a/src/main.rs b/src/main.rs index f446f46..8e74221 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +//! Desktop application entry point. + use clutch::{app, theme}; +/// Configure the window, register fonts, and run the iced application. fn main() -> iced::Result { tracing_subscriber::fmt::init(); diff --git a/src/profile.rs b/src/profile.rs index f8a4f3e..cef15ec 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -295,7 +295,7 @@ port = 9091 "# } - /// 2.6a – Valid TOML parses correctly. + /// Valid TOML parses correctly. #[test] fn load_parses_valid_toml() { let store: ProfileStore = toml::from_str(sample_toml()).unwrap(); @@ -306,7 +306,7 @@ port = 9091 assert!(store.last_connected.is_some()); } - /// 2.6b – Missing file yields empty defaults (simulated by parsing empty string). + /// Missing file input yields empty defaults when parsed from an empty string. #[test] fn load_empty_toml_gives_defaults() { let store: ProfileStore = toml::from_str("").unwrap(); @@ -316,7 +316,7 @@ port = 9091 assert!(store.last_connected.is_none()); } - /// 2.6c – Corrupt TOML fails to parse. + /// Corrupt TOML fails to parse. #[test] fn load_corrupt_toml_fails() { let result: Result = toml::from_str("[[not valid toml{{"); diff --git a/src/rpc/api.rs b/src/rpc/api.rs index f21b2fc..006c93e 100644 --- a/src/rpc/api.rs +++ b/src/rpc/api.rs @@ -939,7 +939,7 @@ mod tests { // ── torrent_set_file_wanted ─────────────────────────────────────────────── - /// 5.1 – `torrent_set_file_wanted` with `wanted = true` sends `"files-wanted"`. + /// `torrent_set_file_wanted` with `wanted = true` sends `"files-wanted"`. #[tokio::test] async fn torrent_set_file_wanted_sends_files_wanted_field() { let server = MockServer::start().await; @@ -971,7 +971,7 @@ mod tests { ); } - /// 5.2 – `torrent_set_file_wanted` with `wanted = false` sends `"files-unwanted"`. + /// `torrent_set_file_wanted` with `wanted = false` sends `"files-unwanted"`. #[tokio::test] async fn torrent_set_file_wanted_sends_files_unwanted_field() { let server = MockServer::start().await; @@ -1003,7 +1003,7 @@ mod tests { ); } - /// 5.3 – `torrent_add` with `files_unwanted` populated includes the array in the body. + /// `torrent_add` includes `files_unwanted` in the request body when provided. #[tokio::test] async fn torrent_add_includes_files_unwanted_in_body() { let server = MockServer::start().await; diff --git a/src/rpc/error.rs b/src/rpc/error.rs index a5124a4..c1828aa 100644 --- a/src/rpc/error.rs +++ b/src/rpc/error.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Error types returned by the Transmission RPC layer. + /// Errors that can occur when communicating with the Transmission daemon. #[derive(Debug, Clone)] pub enum RpcError { diff --git a/src/rpc/transport.rs b/src/rpc/transport.rs index 5dd4db5..caa7f41 100644 --- a/src/rpc/transport.rs +++ b/src/rpc/transport.rs @@ -128,7 +128,7 @@ mod tests { } } - /// 7.1 – A 409 response must yield SessionRotated with the new session id. + /// A 409 response yields `SessionRotated` with the new session ID. #[tokio::test] async fn post_rpc_409_returns_session_rotated() { let server = MockServer::start().await; diff --git a/src/screens/inspector.rs b/src/screens/inspector.rs index 3b86ed8..b6f6ad4 100644 --- a/src/screens/inspector.rs +++ b/src/screens/inspector.rs @@ -655,7 +655,7 @@ fn view_options(opts: &InspectorOptionsState) -> Element<'_, Message> { mod tests { use super::*; - /// 12.2 – TabSelected updates active_tab. + /// `TabSelected` updates `active_tab`. #[test] fn tab_selected_updates_active() { let mut screen = InspectorScreen::new(); @@ -674,9 +674,9 @@ mod tests { assert_eq!(screen.active_tab, ActiveTab::General); } - // ── 5.5-5.8 – Selective file download (inspector) ──────────────────────── + // ── Selective file download (inspector) ───────────────────────────────── - /// 5.5 – FileWantedToggled inserts the toggled index into `pending_wanted`. + /// `FileWantedToggled` inserts the toggled index into `pending_wanted`. #[test] fn file_wanted_toggled_updates_pending() { let mut screen = InspectorScreen::new(); @@ -692,7 +692,7 @@ mod tests { assert!(!screen.pending_wanted.contains_key(&0)); } - /// 5.6 – FileWantedSetSuccess removes only the specified indices, leaving others intact. + /// `FileWantedSetSuccess` removes only the specified indices. #[test] fn file_wanted_set_success_clears_only_specified_indices() { let mut screen = InspectorScreen::new(); @@ -723,7 +723,7 @@ mod tests { ); } - /// 5.7 – AllFilesWantedToggled inserts all indices into `pending_wanted`. + /// `AllFilesWantedToggled` inserts all indices into `pending_wanted`. #[test] fn all_files_wanted_toggled_populates_all_indices() { let mut screen = InspectorScreen::new(); @@ -744,8 +744,8 @@ mod tests { } } - /// 5.8 – The inspector `Message` enum has no variant that would clear - /// `pending_wanted` on a background poll. Verified structurally: calling + /// The inspector `Message` enum has no variant that would clear + /// `pending_wanted` on a background poll. Calling /// `TabSelected` (a non-file message) leaves `pending_wanted` unchanged. #[test] fn poll_does_not_clear_pending_wanted() { diff --git a/src/screens/mod.rs b/src/screens/mod.rs index 581fc7f..be5bc87 100644 --- a/src/screens/mod.rs +++ b/src/screens/mod.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Top-level screen modules for the Clutch application shell. + pub mod connection; pub mod inspector; pub mod main_screen; diff --git a/src/screens/settings/draft.rs b/src/screens/settings/draft.rs index 5e3ff13..e003681 100644 --- a/src/screens/settings/draft.rs +++ b/src/screens/settings/draft.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Editable draft types used by the settings connections tab. + use uuid::Uuid; use crate::profile::ConnectionProfile; @@ -61,6 +63,7 @@ pub enum TestResult { } impl ProfileDraft { + /// Build a draft from an existing saved profile. pub fn from_profile(profile: &ConnectionProfile) -> Self { Self { id: profile.id, @@ -104,6 +107,7 @@ impl ProfileDraft { } } + /// Build a draft for a newly created blank profile. pub fn from_blank(profile: &ConnectionProfile) -> Self { Self { id: profile.id, @@ -127,6 +131,7 @@ impl ProfileDraft { } } + /// Convert the current draft into runtime credentials when the port is valid. pub fn to_credentials(&self) -> Option { let port: u16 = self.port.parse().ok()?; Some(TransmissionCredentials { diff --git a/src/screens/settings/mod.rs b/src/screens/settings/mod.rs index debe1cf..98dbbc2 100644 --- a/src/screens/settings/mod.rs +++ b/src/screens/settings/mod.rs @@ -243,7 +243,7 @@ mod tests { assert!(result.is_none(), "save should be blocked"); } - /// R13: GuardDiscard + Close must return SettingsResult::Closed. + /// `GuardDiscard` with a pending close returns `SettingsResult::Closed`. #[test] fn guard_discard_close_returns_closed() { let (mut s, _) = screen_with_selected_profile(); diff --git a/src/screens/settings/state.rs b/src/screens/settings/state.rs index ba4aa22..c0546b4 100644 --- a/src/screens/settings/state.rs +++ b/src/screens/settings/state.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Settings screen state and state-local helper methods. + use uuid::Uuid; use crate::profile::{ConnectionProfile, GeneralSettings, ProfileStore, ThemeConfig}; @@ -27,6 +29,7 @@ pub enum PendingNavigation { Close, } +/// State for the full-screen settings editor. #[derive(Debug)] pub struct SettingsScreen { // ── Tab ────────────────────────────────────────────────────────────────── @@ -59,6 +62,7 @@ pub struct SettingsScreen { } impl SettingsScreen { + /// Construct the settings screen from the current persisted profile store. pub fn new( store: &ProfileStore, active_profile_id: Option, @@ -84,6 +88,7 @@ impl SettingsScreen { } } + /// Build a store snapshot from the current settings drafts. pub fn build_store_snapshot(&self) -> ProfileStore { ProfileStore { last_connected: None, @@ -100,6 +105,7 @@ impl SettingsScreen { } } + /// Validate the refresh-interval draft and store any user-facing error message. pub fn validate_refresh_interval(&mut self) { match self.refresh_interval_draft.parse::() { Ok(v) if (1..=30).contains(&v) => self.general_validation_error = None, @@ -114,6 +120,7 @@ impl SettingsScreen { } } + /// Return whether the current profile draft can be saved. pub fn draft_is_saveable(&self) -> bool { let Some(d) = &self.draft else { return false; @@ -121,6 +128,7 @@ impl SettingsScreen { !d.name.is_empty() && d.port.parse::().is_ok() } + /// Apply a deferred navigation action after the discard guard is resolved. pub fn execute_pending_nav(&mut self, nav: PendingNavigation) { match nav { PendingNavigation::SwitchTab(tab) => { diff --git a/src/screens/settings/update.rs b/src/screens/settings/update.rs index 2a1a20a..a50c59f 100644 --- a/src/screens/settings/update.rs +++ b/src/screens/settings/update.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Message handling for the settings screen. + use iced::Task; use crate::profile::ConnectionProfile; diff --git a/src/screens/settings/view.rs b/src/screens/settings/view.rs index 4cb4a63..f0cda40 100644 --- a/src/screens/settings/view.rs +++ b/src/screens/settings/view.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! View rendering for the settings screen. + use iced::widget::{ Space, button, column, container, opaque, row, scrollable, stack, text, text_input, toggler, tooltip, @@ -25,6 +27,7 @@ use super::state::SettingsScreen; use super::{Message, SettingsTab, prof_ring_id}; impl SettingsScreen { + /// Render the settings screen and any active modal overlays. pub fn view(&self) -> Element<'_, Message> { let main_content = column![ self.view_header(), diff --git a/src/screens/torrent_list/add_dialog.rs b/src/screens/torrent_list/add_dialog.rs index 19081f6..15a3606 100644 --- a/src/screens/torrent_list/add_dialog.rs +++ b/src/screens/torrent_list/add_dialog.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Add-torrent dialog state and rendering helpers. + use iced::widget::{button, column, container, row, scrollable, text, text_input}; use iced::{Element, Length}; @@ -79,6 +81,7 @@ fn format_file_size(bytes: u64) -> String { } } +/// Render the add-torrent dialog for the current `AddDialogState`. pub fn view_add_dialog(state: &AddDialogState) -> Element<'_, Message> { let (title_str, input_area): (&str, Element<'_, Message>) = match state { AddDialogState::AddLink { diff --git a/src/screens/torrent_list/columns.rs b/src/screens/torrent_list/columns.rs index 145dc05..882f923 100644 --- a/src/screens/torrent_list/columns.rs +++ b/src/screens/torrent_list/columns.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Column-header rendering and shared width constants for the torrent list. + use iced::widget::tooltip; use iced::widget::{button, container, row, text}; use iced::{Alignment, Element, Length}; @@ -30,6 +32,7 @@ pub(crate) const W_RATIO: f32 = 64.0; pub(crate) const W_PROGRESS: f32 = 130.0; pub(crate) const SCROLLBAR_WIDTH: f32 = 14.0; +/// Render the sticky column header row and sort controls. pub(crate) fn view_column_header(state: &TorrentListScreen) -> Element<'_, Message> { let header_row = row![ container( diff --git a/src/screens/torrent_list/dialogs.rs b/src/screens/torrent_list/dialogs.rs index 66a6ad3..5a05da9 100644 --- a/src/screens/torrent_list/dialogs.rs +++ b/src/screens/torrent_list/dialogs.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Context menu and modal overlays owned by the torrent list screen. + use iced::widget::{Space, button, checkbox, column, container, mouse_area, row, stack, text}; use iced::{Alignment, Element, Length}; @@ -127,6 +129,7 @@ pub fn view_context_menu_overlay(state: &TorrentListScreen) -> Option Element<'_, Message> { let card = container( column![ @@ -164,6 +167,7 @@ pub(crate) fn view_delete_dialog(name: &str, del_local: bool) -> Element<'_, Mes .into() } +/// Render the set-location dialog for the selected torrent. pub(crate) fn view_set_location_dialog(dlg: &SetLocationDialog) -> Element<'_, Message> { use iced::widget::text_input; diff --git a/src/screens/torrent_list/filters.rs b/src/screens/torrent_list/filters.rs index e300119..3dd42c2 100644 --- a/src/screens/torrent_list/filters.rs +++ b/src/screens/torrent_list/filters.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Filtering and count helpers for torrent-list status chips. + use std::collections::HashSet; use crate::rpc::TorrentData; @@ -19,6 +21,7 @@ use crate::rpc::TorrentData; use super::StatusFilter; use super::sort::{SortColumn, SortDir, sort_torrents}; +/// Aggregate counts for the torrent-list status filter chips. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) struct FilterCounts { pub downloading: u32, @@ -49,6 +52,7 @@ pub fn matching_filters(t: &TorrentData) -> Vec { out } +/// Count how many torrents belong to each status-filter bucket. pub(crate) fn count_filters(torrents: &[TorrentData]) -> FilterCounts { let mut counts = FilterCounts::default(); @@ -67,6 +71,7 @@ pub(crate) fn count_filters(torrents: &[TorrentData]) -> FilterCounts { counts } +/// Return whether a torrent should remain visible under the current filter set. pub(crate) fn torrent_matches_active_filters( torrent: &TorrentData, active_filters: &HashSet, @@ -76,6 +81,7 @@ pub(crate) fn torrent_matches_active_filters( .any(|filter| active_filters.contains(filter)) } +/// Sort the list, then retain only the torrents allowed by the active filters. pub(crate) fn display_torrents<'a>( torrents: &'a [TorrentData], sort_column: Option, diff --git a/src/screens/torrent_list/mod.rs b/src/screens/torrent_list/mod.rs index 4e0129b..87782b6 100644 --- a/src/screens/torrent_list/mod.rs +++ b/src/screens/torrent_list/mod.rs @@ -336,7 +336,7 @@ mod tests { } } - /// 6.1 – Tick when is_loading=true: no command, state unchanged. + /// Tick is ignored when `is_loading` is already true. #[test] fn tick_ignored_when_loading() { let mut screen = make_screen(); @@ -345,7 +345,7 @@ mod tests { assert!(screen.is_loading); } - /// 6.2 – Tick when is_loading=false: is_loading becomes true. + /// Tick starts a load when the screen is idle. #[test] fn tick_fires_when_not_loading() { let mut screen = make_screen(); @@ -354,7 +354,7 @@ mod tests { assert!(screen.is_loading); } - /// 6.3 – TorrentsUpdated(Ok) replaces torrents and clears is_loading. + /// `TorrentsUpdated(Ok)` replaces torrents and clears `is_loading`. #[test] fn torrents_updated_ok_replaces_and_clears_loading() { let mut screen = make_screen(); @@ -366,7 +366,7 @@ mod tests { assert_eq!(screen.torrents[0].name, "Ubuntu ISO"); } - /// 6.4 – TorrentsUpdated(Err) clears is_loading and sets error. + /// `TorrentsUpdated(Err)` clears `is_loading` and stores the error. #[test] fn torrents_updated_err_clears_loading_and_sets_error() { let mut screen = make_screen(); @@ -379,7 +379,7 @@ mod tests { assert_eq!(screen.error.as_deref(), Some("timeout")); } - /// 6.5 – SessionIdRotated updates the stored session id. + /// `SessionIdRotated` updates the stored session ID. #[test] fn session_id_rotated_updates_id() { let mut screen = make_screen(); @@ -390,7 +390,7 @@ mod tests { assert_eq!(screen.params.session_id, "new-id-xyz"); } - /// 8.1 – TorrentSelected toggles selected_id correctly. + /// `TorrentSelected` toggles `selected_id` correctly. #[test] fn torrent_selected_toggles() { let mut screen = make_screen(); @@ -406,7 +406,7 @@ mod tests { assert_eq!(screen.selected_id, None); } - /// 12.1 – selected_torrent() returns the matching TorrentData when selected. + /// `selected_torrent()` returns the matching torrent when selected. #[test] fn selected_torrent_returns_correct_entry() { let mut screen = make_screen(); @@ -417,14 +417,14 @@ mod tests { assert_eq!(t.name, "Beta"); } - /// 12.1b – selected_torrent() returns None when nothing is selected. + /// `selected_torrent()` returns `None` when nothing is selected. #[test] fn selected_torrent_none_when_no_selection() { let screen = make_screen(); assert!(screen.selected_torrent().is_none()); } - /// 8.3 – DeleteClicked sets confirming_delete. + /// `DeleteClicked` arms the delete-confirmation state. #[test] fn delete_clicked_sets_confirming() { let mut screen = make_screen(); @@ -434,7 +434,7 @@ mod tests { assert_eq!(screen.confirming_delete, Some((5, false))); } - /// 8.3 – DeleteClicked when nothing is selected is a no-op. + /// `DeleteClicked` is a no-op when nothing is selected. #[test] fn delete_clicked_no_selection_is_noop() { let mut screen = make_screen(); @@ -442,7 +442,7 @@ mod tests { assert_eq!(screen.confirming_delete, None); } - /// 8.4 – DeleteCancelled clears confirming_delete. + /// `DeleteCancelled` clears the delete-confirmation state. #[test] fn delete_cancelled_clears_confirming() { let mut screen = make_screen(); @@ -451,7 +451,7 @@ mod tests { assert_eq!(screen.confirming_delete, None); } - /// 8.5 – DeleteConfirmed fires a task and clears confirming_delete. + /// `DeleteConfirmed` clears the dialog state and starts a delete action. #[test] fn delete_confirmed_clears_confirming_and_loads() { let mut screen = make_screen(); @@ -461,7 +461,7 @@ mod tests { assert!(screen.is_loading); } - /// 8.5 – DeleteConfirmed when confirming_delete is None is a no-op. + /// `DeleteConfirmed` is a no-op when no delete confirmation is active. #[test] fn delete_confirmed_no_state_is_noop() { let mut screen = make_screen(); @@ -469,7 +469,7 @@ mod tests { assert!(!screen.is_loading); } - /// 8.6 – DeleteLocalDataToggled updates checkbox state. + /// `DeleteLocalDataToggled` updates the checkbox state. #[test] fn delete_local_data_toggled_updates_state() { let mut screen = make_screen(); @@ -480,7 +480,7 @@ mod tests { assert_eq!(screen.confirming_delete, Some((9, false))); } - /// 8.7 – ActionCompleted(Ok) keeps is_loading=true and fires a poll task. + /// `ActionCompleted(Ok)` keeps loading active and triggers a refresh. #[test] fn action_completed_ok_fires_refresh() { let mut screen = make_screen(); @@ -489,7 +489,7 @@ mod tests { assert!(screen.is_loading); } - /// 8.8 – ActionCompleted(Err) clears is_loading and stores error. + /// `ActionCompleted(Err)` clears loading and stores the error. #[test] fn action_completed_err_clears_and_stores_error() { let mut screen = make_screen(); @@ -502,7 +502,7 @@ mod tests { assert_eq!(screen.error.as_deref(), Some("daemon refused")); } - /// 8.9 – Poll tick is ignored while is_loading is true. + /// Poll tick is ignored while an action is still in flight. #[test] fn tick_ignored_while_action_in_flight() { let mut screen = make_screen(); @@ -512,9 +512,9 @@ mod tests { assert!(screen.is_loading); } - // ── 9.x add-torrent dialog tests ───────────────────────────────────────── + // ── Add-torrent dialog tests ───────────────────────────────────────────── - /// 9.1 – AddLinkClicked transitions add_dialog to AddLink. + /// `AddLinkClicked` opens the add-link dialog. #[test] fn add_link_clicked_opens_add_link_dialog() { let mut screen = make_screen(); @@ -522,7 +522,7 @@ mod tests { assert!(matches!(screen.add_dialog, AddDialogState::AddLink { .. })); } - /// 9.2 – AddConfirmed with empty magnet is a no-op. + /// `AddConfirmed` is a no-op when the magnet input is empty. #[test] fn add_confirmed_empty_magnet_is_noop() { let mut screen = make_screen(); @@ -535,7 +535,7 @@ mod tests { assert!(matches!(screen.add_dialog, AddDialogState::AddLink { .. })); } - /// 9.3 – AddConfirmed with a valid magnet sets is_loading=true. + /// `AddConfirmed` with a valid magnet starts loading. #[test] fn add_confirmed_valid_magnet_emits_task() { let mut screen = make_screen(); @@ -548,7 +548,7 @@ mod tests { assert!(screen.is_loading); } - /// 9.4 – AddCancelled resets add_dialog to Hidden. + /// `AddCancelled` closes the add dialog. #[test] fn add_cancelled_closes_dialog() { let mut screen = make_screen(); @@ -561,7 +561,7 @@ mod tests { assert!(matches!(screen.add_dialog, AddDialogState::Hidden)); } - /// 9.5 – TorrentFileRead(Ok) opens AddFile dialog with the correct file list. + /// `TorrentFileRead(Ok)` opens the add-file dialog with the parsed files. #[test] fn torrent_file_read_ok_opens_add_file_dialog() { let mut screen = make_screen(); @@ -582,7 +582,7 @@ mod tests { } } - /// 9.6 – AddCompleted(Ok) clears the dialog and fires an immediate torrent-get. + /// `AddCompleted(Ok)` clears the dialog and triggers an immediate refresh. #[test] fn add_completed_ok_clears_dialog_and_polls() { let mut screen = make_screen(); @@ -596,7 +596,7 @@ mod tests { assert!(screen.is_loading); } - /// 9.7 – AddCompleted(Err) stores the error inside the dialog without closing it. + /// `AddCompleted(Err)` stores the error inside the dialog without closing it. #[test] fn add_completed_err_stores_error_in_dialog() { let mut screen = make_screen(); @@ -617,7 +617,7 @@ mod tests { } } - /// 11.1 – ColumnHeaderClicked cycles Unsorted → Asc → Desc → Unsorted. + /// `ColumnHeaderClicked` cycles unsorted, ascending, descending, then unsorted. #[test] fn column_header_clicked_cycles_sort() { let mut screen = make_screen(); @@ -635,7 +635,7 @@ mod tests { assert_eq!(screen.sort_column, None); } - /// 11.2 – Clicking a different column starts Asc on the new column. + /// Clicking a different column starts ascending order on the new column. #[test] fn column_header_clicked_different_column_resets() { let mut screen = make_screen(); @@ -771,7 +771,7 @@ mod tests { assert!(!screen.is_loading); } - // ── 5.4 – Selective file selection ─────────────────────────────────────── + // ── Selective file selection ───────────────────────────────────────────── fn make_add_file_screen(n: usize) -> TorrentListScreen { let mut screen = make_screen(); @@ -791,13 +791,13 @@ mod tests { screen } - /// 5.4a – AddDialogSelectAll sets every entry in `selected` to `true`. + /// `AddDialogSelectAll` sets every entry in `selected` to `true`. #[test] fn add_dialog_select_all_sets_all_true() { let mut screen = make_add_file_screen(3); - // Deselect one first. + // Deselect one entry first. let _ = update(&mut screen, Message::AddDialogFileToggled(1)); - // Select all. + // Then select all. let _ = update(&mut screen, Message::AddDialogSelectAll); if let AddDialogState::AddFile { selected, .. } = &screen.add_dialog { assert_eq!(selected, &vec![true, true, true]); @@ -806,7 +806,7 @@ mod tests { } } - /// 5.4b – AddDialogDeselectAll sets every entry in `selected` to `false`. + /// `AddDialogDeselectAll` sets every entry in `selected` to `false`. #[test] fn add_dialog_deselect_all_sets_all_false() { let mut screen = make_add_file_screen(3); @@ -818,7 +818,7 @@ mod tests { } } - /// 5.4c – AddDialogFileToggled flips only the targeted index. + /// `AddDialogFileToggled` flips only the targeted index. #[test] fn add_dialog_file_toggled_flips_single_index() { let mut screen = make_add_file_screen(3); diff --git a/src/screens/torrent_list/sort.rs b/src/screens/torrent_list/sort.rs index 94eccfd..b442c8f 100644 --- a/src/screens/torrent_list/sort.rs +++ b/src/screens/torrent_list/sort.rs @@ -133,14 +133,14 @@ mod tests { ] } - /// 10.1 – Empty list returns empty vec for any sort. + /// Empty list returns an empty vec for any sort. #[test] fn sort_empty_list() { let torrents: Vec = vec![]; assert!(sort_torrents(&torrents, SortColumn::Name, SortDir::Asc).is_empty()); } - /// 10.2 – Single-element list is a no-op. + /// Single-element list is a no-op. #[test] fn sort_single_element() { let torrents = vec![make_torrent(1, "only")]; @@ -149,7 +149,7 @@ mod tests { assert_eq!(result[0].id, 1); } - /// 10.3 – Ascending sort by Name. + /// Ascending sort by name. #[test] fn sort_by_name_asc() { let list = make_list(); @@ -158,7 +158,7 @@ mod tests { assert_eq!(names, ["alpha", "bravo", "charlie"]); } - /// 10.4 – Descending sort by Name. + /// Descending sort by name. #[test] fn sort_by_name_desc() { let list = make_list(); @@ -167,7 +167,7 @@ mod tests { assert_eq!(names, ["charlie", "bravo", "alpha"]); } - /// 10.5 – Ascending sort by Status. + /// Ascending sort by status. #[test] fn sort_by_status_asc() { let list = make_list(); @@ -177,7 +177,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.6 – Ascending sort by Size. + /// Ascending sort by size. #[test] fn sort_by_size_asc() { let list = make_list(); @@ -186,7 +186,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.7 – Ascending sort by SpeedDown. + /// Ascending sort by download speed. #[test] fn sort_by_speed_down_asc() { let list = make_list(); @@ -195,7 +195,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.8 – Ascending sort by SpeedUp. + /// Ascending sort by upload speed. #[test] fn sort_by_speed_up_asc() { let list = make_list(); @@ -204,7 +204,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.9 – Ascending sort by ETA. + /// Ascending sort by ETA. #[test] fn sort_by_eta_asc() { let list = make_list(); @@ -213,7 +213,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.10 – Unknown ETA (-1) sorts to end. + /// Unknown ETA (-1) sorts to the end. #[test] fn sort_by_eta_unknown_last() { let mut list = make_list(); @@ -222,7 +222,7 @@ mod tests { assert_eq!(result.last().unwrap().id, 1); } - /// 10.11 – Ascending sort by Ratio. + /// Ascending sort by ratio. #[test] fn sort_by_ratio_asc() { let list = make_list(); @@ -231,7 +231,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.12 – Ascending sort by Progress. + /// Ascending sort by progress. #[test] fn sort_by_progress_asc() { let list = make_list(); @@ -240,7 +240,7 @@ mod tests { assert_eq!(ids, [2, 3, 1]); } - /// 10.13 – Descending sort reverses any column (spot-check with Size). + /// Descending sort reverses any column (spot-check with size). #[test] fn sort_by_size_desc() { let list = make_list(); diff --git a/src/screens/torrent_list/toolbar.rs b/src/screens/torrent_list/toolbar.rs index 2667632..2583438 100644 --- a/src/screens/torrent_list/toolbar.rs +++ b/src/screens/torrent_list/toolbar.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Toolbar rendering for the torrent list screen. + use iced::widget::tooltip; use iced::widget::{Space, row, text}; use iced::{Alignment, Element, Length}; @@ -23,6 +25,7 @@ use crate::theme::{ use super::{Message, TorrentListScreen}; +/// Render the primary torrent-list toolbar and its action groups. pub(crate) fn view_normal_toolbar( state: &TorrentListScreen, alt_speed_enabled: bool, diff --git a/src/screens/torrent_list/update.rs b/src/screens/torrent_list/update.rs index 5684dcf..37285b7 100644 --- a/src/screens/torrent_list/update.rs +++ b/src/screens/torrent_list/update.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Elm update logic for the torrent list screen. + use base64::Engine as _; use iced::Task; @@ -21,6 +23,7 @@ use super::add_dialog::{self, AddDialogState, FileReadResult, TorrentFileInfo}; use super::sort::SortDir; use super::{Message, SetLocationDialog, StatusFilter, TorrentListScreen}; +/// Apply a single torrent-list message to the screen state and return follow-up work. pub fn update(state: &mut TorrentListScreen, msg: Message) -> Task { match msg { // ── Polling ─────────────────────────────────────────────────────────── diff --git a/src/screens/torrent_list/view.rs b/src/screens/torrent_list/view.rs index be0dd9e..18eb932 100644 --- a/src/screens/torrent_list/view.rs +++ b/src/screens/torrent_list/view.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Widget-tree orchestration and row rendering for the torrent list screen. + use iced::widget::rule; use iced::widget::{ Space, button, column, container, mouse_area, opaque, progress_bar, row, scrollable, stack, @@ -85,6 +87,7 @@ fn filter_chip( .into() } +/// Render the torrent list screen, including overlays owned by the list itself. pub fn view( state: &TorrentListScreen, _theme_mode: crate::app::ThemeMode, @@ -329,8 +332,5 @@ pub fn view( after_delete }; - // ── Context menu overlay ────────────────────────────────────────────────── - // Rendered at the main_screen level via `view_context_menu_overlay` so it - // can draw over the inspector panel. Nothing to do here. after_set_location } diff --git a/src/theme/buttons.rs b/src/theme/buttons.rs index 5c14403..c1f8bb2 100644 --- a/src/theme/buttons.rs +++ b/src/theme/buttons.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Button styles and icon-button helpers for the shared theme layer. + use iced::widget::{button, container}; use iced::{Border, Color, Element, Length, Shadow, Theme}; diff --git a/src/theme/containers.rs b/src/theme/containers.rs index 5a71b94..fd4ffa8 100644 --- a/src/theme/containers.rs +++ b/src/theme/containers.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Container and surface styles used across Clutch screens and dialogs. + use iced::widget::container; use iced::{Border, Color, Shadow, Theme, Vector}; diff --git a/src/theme/inputs.rs b/src/theme/inputs.rs index dfd429a..4a016e4 100644 --- a/src/theme/inputs.rs +++ b/src/theme/inputs.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Shared text-input styles for the theme layer. + use iced::widget::text_input; use iced::{Border, Color, Theme}; diff --git a/src/theme/progress.rs b/src/theme/progress.rs index c5ee3ca..f85949d 100644 --- a/src/theme/progress.rs +++ b/src/theme/progress.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Progress-bar styles keyed by torrent activity state. + use iced::widget::progress_bar; use iced::{Border, Color, Theme}; diff --git a/src/theme/segmented.rs b/src/theme/segmented.rs index ad0594f..6a2dd03 100644 --- a/src/theme/segmented.rs +++ b/src/theme/segmented.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Shared segmented-control rendering for mutually exclusive option groups. + use iced::widget::{button, row}; use iced::{Border, Color, Element, Length, Shadow, Theme}; diff --git a/src/theme/selection.rs b/src/theme/selection.rs index d359fd6..19bed83 100644 --- a/src/theme/selection.rs +++ b/src/theme/selection.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Checkbox-like selection widgets built from Material Icons glyphs. + use iced::Element; use iced::widget::row; From dc1c34aff39129cc95529275b15699622ce69800 Mon Sep 17 00:00:00 2001 From: Samuel Hitz Date: Thu, 9 Apr 2026 10:58:11 +0200 Subject: [PATCH 4/4] refactor auth and inspector --- AGENTS.md | 41 +- CHANGELOG.md | 6 + src/{auth.rs => auth/mod.rs} | 551 ++---------------- src/auth/update.rs | 304 ++++++++++ src/auth/view.rs | 202 +++++++ src/screens/inspector/mod.rs | 193 ++++++ src/screens/inspector/state.rs | 70 +++ src/screens/inspector/update.rs | 91 +++ .../{inspector.rs => inspector/view.rs} | 299 +--------- src/screens/torrent_list/filters.rs | 34 +- system_architecture.md | 19 +- 11 files changed, 988 insertions(+), 822 deletions(-) rename src/{auth.rs => auth/mod.rs} (52%) create mode 100644 src/auth/update.rs create mode 100644 src/auth/view.rs create mode 100644 src/screens/inspector/mod.rs create mode 100644 src/screens/inspector/state.rs create mode 100644 src/screens/inspector/update.rs rename src/screens/{inspector.rs => inspector/view.rs} (59%) diff --git a/AGENTS.md b/AGENTS.md index 80c8d86..9ae400a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,10 +119,10 @@ src/ ├── screens/ │ ├── connection.rs # Profile selection / quick-connect │ ├── main_screen.rs # Root layout: torrent list + inspector split -│ ├── inspector.rs # Detail inspector panel -│ └── torrent_list/ # List screen: view orchestration, toolbar/header/dialog helpers, sort, add-torrent, update, worker +│ ├── inspector/ # Detail inspector panel (state / update / view) +│ ├── torrent_list/ # List screen: view orchestration, toolbar/header/dialog helpers, sort, add-torrent, update, worker │ └── settings/ # Profile editing (split into state/draft/update/view) -├── auth.rs # Passphrase setup and unlock flows +├── auth/ # Passphrase setup and unlock flows (mod / update / view) ├── crypto.rs # Argon2id KDF + ChaCha20-Poly1305 AEAD ├── profile.rs # TOML config persistence ├── theme.rs # Public theme facade (colors, fonts, widget helpers) @@ -140,16 +140,18 @@ When adding a new screen: ## Commit Messages -Follow the Go commit style with a component prefix: +Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit +messages: ```text -component: short imperative summary +(): short imperative summary Optional body explaining *why*, not *what* (the diff shows that). ``` -Component examples: `clutch/theme`, `clutch/rpc`, `clutch/torrent-list`, `clutch/settings`, -`clutch/ci`, `clutch/packaging`. +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`. Scope is optional but +encouraged for larger projects; use the name of the module or feature being changed (e.g. +`torrent-list`, `rpc`, `theme`). --- @@ -185,8 +187,29 @@ Component examples: `clutch/theme`, `clutch/rpc`, `clutch/torrent-list`, `clutch Keep the system_architecture.md and README.md up to date with any architectural changes or new features. The README should be a user-friendly overview, while system_architecture.md can go into -technical depth for future maintainers. Change logs must be recorded in CHANGELOG.md following the -[Keep a Changelog format](https://keepachangelog.com/en/1.0.0/). +technical depth for future maintainers. + +## Changelog + +Change logs must be recorded in CHANGELOG.md following the [Keep a Changelog +format](https://keepachangelog.com/en/1.0.0/). + +### Guiding Principles + +- Changelog are for humans, not machines. +- There should be an entry for every single version. +- The same types of changes should be grouped. +- The latest version comes first. +- The release date of each version is displayed. + +### Types of changes + +- `Added` for new features. +- `Changed` for changes in existing functionality. +- `Deprecated` for soon-to-be removed features. +- `Removed` for now removed features. +- `Fixed` for any bug fixes. +- `Security` for vulnerabilities. ## Markdown formatting diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c389d..3589ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Refactored some large monolithic modules into smaller submodules for better organization and + maintainability. +- Eliminated per-torrent heap allocation from the render hot path. + ## [0.12.0] - 2026-04-08 ### Added diff --git a/src/auth.rs b/src/auth/mod.rs similarity index 52% rename from src/auth.rs rename to src/auth/mod.rs index 4d723c6..f368423 100644 --- a/src/auth.rs +++ b/src/auth/mod.rs @@ -20,15 +20,13 @@ //! 2. **Unlock** — session unlock: verifies the entered passphrase against the //! stored PHC hash, then executes the deferred [`PendingAction`]. -use uuid::Uuid; +mod update; +mod view; -use iced::widget::{Space, button, column, container, row, stack, text, text_input}; -use iced::{Element, Length, Task}; -use secrecy::SecretString; +pub use update::handle_message; +pub use view::view_overlay; -use crate::app::{AppState, Message}; -use crate::crypto; -use crate::screens::connection; +use uuid::Uuid; // ── Stable widget IDs for auth dialog inputs ──────────────────────────────────── @@ -76,470 +74,6 @@ pub enum AuthDialog { }, } -// ── State machine ───────────────────────────────────────────────────────────── - -/// Handle an auth dialog message. -/// -/// Returns `Some(task)` if the message was consumed by the auth state machine, -/// or `None` if it should be forwarded to the per-screen update handler. -pub fn handle_message(state: &mut AppState, message: &Message) -> Option> { - match message { - Message::AuthTabKeyPressed { shift } => { - match &state.active_dialog { - Some(AuthDialog::SetupPassphrase { .. }) => { - // Two-input ring: passphrase ↔ confirm. - // We don't track which is focused, so we always use focus_next / - // focus_previous which cycles naturally within the opaque overlay. - let task = if *shift { - iced::widget::operation::focus_previous() - } else { - iced::widget::operation::focus_next() - }; - Some(task) - } - // Unlock dialog has one input — Tab is a no-op. - Some(AuthDialog::Unlock { .. }) => Some(Task::none()), - None => None, - } - } - Message::AuthEnterPressed => match &state.active_dialog { - Some(AuthDialog::Unlock { is_processing, .. }) if !*is_processing => { - Some(Task::done(Message::SubmitUnlockPassphrase)) - } - Some(AuthDialog::SetupPassphrase { is_processing, .. }) if !*is_processing => { - Some(Task::done(Message::SubmitSetupPassphrase)) - } - Some(_) => Some(Task::none()), - None => None, - }, - Message::DismissAuthDialog => { - state.active_dialog = None; - Some(Task::none()) - } - Message::AuthSetupPassphraseChanged(v) => { - if let Some(AuthDialog::SetupPassphrase { - passphrase_input, - error, - .. - }) = &mut state.active_dialog - { - *passphrase_input = v.clone(); - *error = None; - } - Some(Task::none()) - } - Message::AuthSetupConfirmChanged(v) => { - if let Some(AuthDialog::SetupPassphrase { - confirm_input, - error, - .. - }) = &mut state.active_dialog - { - *confirm_input = v.clone(); - *error = None; - } - Some(Task::none()) - } - Message::AuthUnlockPassphraseChanged(v) => { - if let Some(AuthDialog::Unlock { - passphrase_input, - error, - .. - }) = &mut state.active_dialog - { - *passphrase_input = v.clone(); - *error = None; - } - Some(Task::none()) - } - Message::SubmitSetupPassphrase => { - let Some(AuthDialog::SetupPassphrase { - pending_profile_id, - pending_password, - passphrase_input, - confirm_input, - error, - is_processing, - }) = &mut state.active_dialog - else { - return Some(Task::none()); - }; - if *is_processing { - return Some(Task::none()); - } - if passphrase_input != confirm_input { - *error = Some("Passphrases do not match".to_owned()); - return Some(Task::none()); - } - if passphrase_input.is_empty() { - *error = Some("Passphrase cannot be empty".to_owned()); - return Some(Task::none()); - } - let profile_id = *pending_profile_id; - let password = pending_password.clone(); - let passphrase = passphrase_input.clone(); - *is_processing = true; - let task = Task::perform( - async move { - let passphrase_c = passphrase.clone(); - let (hash, encrypted) = tokio::task::spawn_blocking(move || { - let hash = crypto::hash_passphrase(&passphrase_c); - let encrypted = crypto::encrypt_password(&passphrase_c, &password); - (hash, encrypted) - }) - .await - .expect("passphrase setup task panicked"); - (passphrase, hash, profile_id, encrypted) - }, - |(passphrase, hash, pid, ep)| Message::SetupPassphraseComplete { - passphrase, - hash, - profile_id: pid, - encrypted_password: ep, - }, - ); - Some(task) - } - Message::SetupPassphraseComplete { - passphrase, - hash, - profile_id, - encrypted_password, - } => { - state.active_dialog = None; - state.unlocked_passphrase = Some(SecretString::new(passphrase.clone())); - state.profiles.master_passphrase_hash = Some(hash.clone()); - if let Some(p) = state - .profiles - .profiles - .iter_mut() - .find(|p| p.id == *profile_id) - { - p.encrypted_password = Some(encrypted_password.clone()); - } - let snap = state.profiles.clone(); - Some(Task::perform(async move { snap.save().await }, |_| { - Message::Noop - })) - } - Message::SubmitUnlockPassphrase => { - let Some(AuthDialog::Unlock { - passphrase_input, - is_processing, - .. - }) = &state.active_dialog - else { - return Some(Task::none()); - }; - if *is_processing { - return Some(Task::none()); - } - if passphrase_input.is_empty() { - if let Some(AuthDialog::Unlock { error, .. }) = &mut state.active_dialog { - *error = Some("Passphrase cannot be empty".to_owned()); - } - return Some(Task::none()); - } - let passphrase = passphrase_input.clone(); - let hash = state - .profiles - .master_passphrase_hash - .clone() - .unwrap_or_default(); - if let Some(AuthDialog::Unlock { is_processing, .. }) = &mut state.active_dialog { - *is_processing = true; - } - let task = Task::perform( - async move { - let passphrase_c = passphrase.clone(); - let valid = tokio::task::spawn_blocking(move || { - crypto::verify_passphrase(&passphrase_c, &hash) - }) - .await - .expect("passphrase verify task panicked"); - (passphrase, valid) - }, - |(passphrase, valid)| Message::UnlockPassphraseResult { passphrase, valid }, - ); - Some(task) - } - Message::UnlockPassphraseResult { passphrase, valid } => { - if !valid { - if let Some(AuthDialog::Unlock { - error, - is_processing, - .. - }) = &mut state.active_dialog - { - *error = Some("Incorrect passphrase".to_owned()); - *is_processing = false; - } - return Some(Task::none()); - } - state.unlocked_passphrase = Some(SecretString::new(passphrase.clone())); - let pending = match state.active_dialog.take() { - Some(AuthDialog::Unlock { pending_action, .. }) => pending_action, - _ => return Some(Task::none()), - }; - match pending { - PendingAction::ConnectToProfile(id) => { - let Some(profile) = state.profiles.get(id) else { - return Some(Task::none()); - }; - let creds = profile.credentials(Some(passphrase.as_str())); - Some(Task::done(Message::Connection( - connection::Message::ConnectWithCreds { - profile_id: id, - creds, - }, - ))) - } - PendingAction::SavePassword { - profile_id, - password, - } => { - let pw = passphrase.clone(); - let task = Task::perform( - async move { - let encrypted = tokio::task::spawn_blocking(move || { - crypto::encrypt_password(&pw, &password) - }) - .await - .expect("encrypt task panicked"); - (profile_id, encrypted) - }, - |(pid, ep)| Message::EncryptPasswordReady { - profile_id: pid, - encrypted_password: ep, - }, - ); - Some(task) - } - PendingAction::TestConnectionFromSettings { profile_id } => { - let Some(profile) = state.profiles.get(profile_id) else { - return Some(Task::none()); - }; - let creds = profile.credentials(Some(passphrase.as_str())); - let url = creds.rpc_url(); - let probe = Task::perform( - async move { - crate::rpc::session_get(&url, &creds, "") - .await - .map_err(|e| e.to_string()) - }, - |r| { - Message::Settings( - crate::screens::settings::Message::TestConnectionResult(r), - ) - }, - ); - Some(probe) - } - } - } - Message::EncryptPasswordReady { - profile_id, - encrypted_password, - } => { - if let Some(p) = state - .profiles - .profiles - .iter_mut() - .find(|p| p.id == *profile_id) - { - p.encrypted_password = Some(encrypted_password.clone()); - } - let snap = state.profiles.clone(); - Some(Task::perform(async move { snap.save().await }, |_| { - Message::Noop - })) - } - _ => None, - } -} - -// ── View helpers ────────────────────────────────────────────────────────────── - -/// Wrap `base` in the active auth dialog modal overlay, or return `base` unchanged. -pub fn view_overlay<'a>( - dialog: Option<&'a AuthDialog>, - base: Element<'a, Message>, -) -> Element<'a, Message> { - match dialog { - Some(AuthDialog::SetupPassphrase { - passphrase_input, - confirm_input, - error, - is_processing, - .. - }) => stack![ - base, - view_setup_passphrase( - passphrase_input, - confirm_input, - error.as_deref(), - *is_processing - ) - ] - .into(), - Some(AuthDialog::Unlock { - passphrase_input, - error, - is_processing, - .. - }) => stack![ - base, - view_unlock(passphrase_input, error.as_deref(), *is_processing) - ] - .into(), - None => base, - } -} - -fn view_setup_passphrase<'a>( - passphrase_input: &'a str, - confirm_input: &'a str, - error: Option<&'a str>, - is_processing: bool, -) -> Element<'a, Message> { - let error_row: Element<'_, Message> = if let Some(err) = error { - text(err) - .size(13) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.extended_palette().danger.base.color), - }) - .into() - } else { - Space::new().into() - }; - - let card = container( - column![ - text("Create master passphrase").size(18), - text( - "This passphrase protects your saved passwords. \ - You will enter it once per app session." - ) - .size(13) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.palette().text.scale_alpha(0.7)), - }), - text_input("Enter passphrase", passphrase_input) - .id(setup_passphrase_id()) - .on_input_maybe((!is_processing).then_some(Message::AuthSetupPassphraseChanged)) - .secure(true) - .padding([12, 16]) - .style(crate::theme::m3_text_input), - text_input("Confirm passphrase", confirm_input) - .id(setup_confirm_id()) - .on_input_maybe((!is_processing).then_some(Message::AuthSetupConfirmChanged)) - .secure(true) - .padding([12, 16]) - .style(crate::theme::m3_text_input), - error_row, - row![ - button(text("Cancel").size(14)) - .on_press_maybe((!is_processing).then_some(Message::DismissAuthDialog)) - .padding([10, 20]) - .style(crate::theme::m3_tonal_button), - Space::new().width(Length::Fill), - button( - text(if is_processing { - "Creating…" - } else { - "Create" - }) - .size(14) - ) - .on_press_maybe((!is_processing).then_some(Message::SubmitSetupPassphrase)) - .padding([10, 20]) - .style(crate::theme::m3_primary_button), - ] - .width(Length::Fill) - .align_y(iced::Alignment::Center), - ] - .spacing(12), - ) - .padding(28) - .max_width(420) - .style(crate::theme::auth_dialog_card); - - container(card) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(crate::theme::dialog_scrim(0.5)) - .into() -} - -fn view_unlock<'a>( - passphrase_input: &'a str, - error: Option<&'a str>, - is_processing: bool, -) -> Element<'a, Message> { - let error_row: Element<'_, Message> = if let Some(err) = error { - text(err) - .size(13) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.extended_palette().danger.base.color), - }) - .into() - } else { - Space::new().into() - }; - - let card = container( - column![ - text("Enter master passphrase").size(18), - text("Enter your master passphrase to unlock your saved credentials.") - .size(13) - .style(|t: &iced::Theme| iced::widget::text::Style { - color: Some(t.palette().text.scale_alpha(0.7)), - }), - text_input("Master passphrase", passphrase_input) - .id(unlock_input_id()) - .on_input_maybe((!is_processing).then_some(Message::AuthUnlockPassphraseChanged)) - .secure(true) - .padding([12, 16]) - .style(crate::theme::m3_text_input), - error_row, - row![ - button(text("Cancel").size(14)) - .on_press_maybe((!is_processing).then_some(Message::DismissAuthDialog)) - .padding([10, 20]) - .style(crate::theme::m3_tonal_button), - Space::new().width(Length::Fill), - button( - text(if is_processing { - "Verifying…" - } else { - "Unlock" - }) - .size(14) - ) - .on_press_maybe((!is_processing).then_some(Message::SubmitUnlockPassphrase)) - .padding([10, 20]) - .style(crate::theme::m3_primary_button), - ] - .width(Length::Fill) - .align_y(iced::Alignment::Center), - ] - .spacing(12), - ) - .padding(28) - .max_width(420) - .style(crate::theme::auth_dialog_card); - - container(card) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(crate::theme::dialog_scrim(0.5)) - .into() -} - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -579,7 +113,7 @@ mod tests { fn dismiss_clears_setup_dialog() { let mut state = make_state(); state.active_dialog = Some(setup_dialog(Uuid::new_v4())); - let result = handle_message(&mut state, &Message::DismissAuthDialog); + let result = handle_message(&mut state, &crate::app::Message::DismissAuthDialog); assert!(result.is_some(), "message should be consumed"); assert!(state.active_dialog.is_none()); } @@ -588,14 +122,14 @@ mod tests { fn dismiss_clears_unlock_dialog() { let mut state = make_state(); state.active_dialog = Some(unlock_dialog()); - handle_message(&mut state, &Message::DismissAuthDialog); + handle_message(&mut state, &crate::app::Message::DismissAuthDialog); assert!(state.active_dialog.is_none()); } #[test] fn dismiss_when_no_dialog_still_consumed() { let mut state = make_state(); - let result = handle_message(&mut state, &Message::DismissAuthDialog); + let result = handle_message(&mut state, &crate::app::Message::DismissAuthDialog); assert!(result.is_some(), "DismissAuthDialog is always consumed"); } @@ -607,7 +141,7 @@ mod tests { state.active_dialog = Some(setup_dialog(Uuid::new_v4())); handle_message( &mut state, - &Message::AuthSetupPassphraseChanged("abc".to_owned()), + &crate::app::Message::AuthSetupPassphraseChanged("abc".to_owned()), ); let Some(AuthDialog::SetupPassphrase { passphrase_input, .. @@ -631,7 +165,7 @@ mod tests { }); handle_message( &mut state, - &Message::AuthSetupPassphraseChanged("new".to_owned()), + &crate::app::Message::AuthSetupPassphraseChanged("new".to_owned()), ); let Some(AuthDialog::SetupPassphrase { error, .. }) = &state.active_dialog else { panic!(); @@ -647,7 +181,7 @@ mod tests { state.active_dialog = Some(setup_dialog(Uuid::new_v4())); handle_message( &mut state, - &Message::AuthSetupConfirmChanged("xyz".to_owned()), + &crate::app::Message::AuthSetupConfirmChanged("xyz".to_owned()), ); let Some(AuthDialog::SetupPassphrase { confirm_input, .. }) = &state.active_dialog else { panic!(); @@ -668,7 +202,7 @@ mod tests { }); handle_message( &mut state, - &Message::AuthSetupConfirmChanged("x".to_owned()), + &crate::app::Message::AuthSetupConfirmChanged("x".to_owned()), ); let Some(AuthDialog::SetupPassphrase { error, .. }) = &state.active_dialog else { panic!(); @@ -684,7 +218,7 @@ mod tests { state.active_dialog = Some(unlock_dialog()); handle_message( &mut state, - &Message::AuthUnlockPassphraseChanged("secret".to_owned()), + &crate::app::Message::AuthUnlockPassphraseChanged("secret".to_owned()), ); let Some(AuthDialog::Unlock { passphrase_input, .. @@ -706,7 +240,7 @@ mod tests { }); handle_message( &mut state, - &Message::AuthUnlockPassphraseChanged("x".to_owned()), + &crate::app::Message::AuthUnlockPassphraseChanged("x".to_owned()), ); let Some(AuthDialog::Unlock { error, .. }) = &state.active_dialog else { panic!(); @@ -727,7 +261,7 @@ mod tests { error: None, is_processing: false, }); - handle_message(&mut state, &Message::SubmitSetupPassphrase); + handle_message(&mut state, &crate::app::Message::SubmitSetupPassphrase); let Some(AuthDialog::SetupPassphrase { error, .. }) = &state.active_dialog else { panic!(); }; @@ -745,7 +279,7 @@ mod tests { error: None, is_processing: false, }); - handle_message(&mut state, &Message::SubmitSetupPassphrase); + handle_message(&mut state, &crate::app::Message::SubmitSetupPassphrase); let Some(AuthDialog::SetupPassphrase { error, .. }) = &state.active_dialog else { panic!(); }; @@ -763,7 +297,7 @@ mod tests { error: None, is_processing: false, }); - let result = handle_message(&mut state, &Message::SubmitSetupPassphrase); + let result = handle_message(&mut state, &crate::app::Message::SubmitSetupPassphrase); assert!(result.is_some(), "should return an async task"); // Dialog not yet cleared — cleared only on SetupPassphraseComplete. assert!(state.active_dialog.is_some()); @@ -775,7 +309,7 @@ mod tests { fn submit_unlock_empty_passphrase_sets_error() { let mut state = make_state(); state.active_dialog = Some(unlock_dialog()); - handle_message(&mut state, &Message::SubmitUnlockPassphrase); + handle_message(&mut state, &crate::app::Message::SubmitUnlockPassphrase); let Some(AuthDialog::Unlock { error, .. }) = &state.active_dialog else { panic!(); }; @@ -792,7 +326,7 @@ mod tests { error: None, is_processing: false, }); - let result = handle_message(&mut state, &Message::SubmitUnlockPassphrase); + let result = handle_message(&mut state, &crate::app::Message::SubmitUnlockPassphrase); assert!(result.is_some(), "should dispatch async verify task"); } @@ -804,7 +338,7 @@ mod tests { state.active_dialog = Some(unlock_dialog()); handle_message( &mut state, - &Message::UnlockPassphraseResult { + &crate::app::Message::UnlockPassphraseResult { passphrase: "wrong".to_owned(), valid: false, }, @@ -821,7 +355,7 @@ mod tests { state.active_dialog = Some(unlock_dialog()); handle_message( &mut state, - &Message::UnlockPassphraseResult { + &crate::app::Message::UnlockPassphraseResult { passphrase: "correct".to_owned(), valid: true, }, @@ -866,7 +400,7 @@ mod tests { let result = handle_message( &mut state, - &Message::SetupPassphraseComplete { + &crate::app::Message::SetupPassphraseComplete { passphrase: "my_passphrase".to_owned(), hash: "$argon2id$fakehash".to_owned(), profile_id, @@ -926,7 +460,7 @@ mod tests { handle_message( &mut state, - &Message::EncryptPasswordReady { + &crate::app::Message::EncryptPasswordReady { profile_id, encrypted_password: "salt$nonce$ct".to_owned(), }, @@ -947,7 +481,7 @@ mod tests { // No profiles — should not panic. let result = handle_message( &mut state, - &Message::EncryptPasswordReady { + &crate::app::Message::EncryptPasswordReady { profile_id: Uuid::new_v4(), encrypted_password: "x$y$z".to_owned(), }, @@ -960,7 +494,7 @@ mod tests { #[test] fn non_auth_message_returns_none() { let mut state = make_state(); - let result = handle_message(&mut state, &Message::Noop); + let result = handle_message(&mut state, &crate::app::Message::Noop); assert!(result.is_none(), "non-auth messages must not be consumed"); } @@ -970,7 +504,10 @@ mod tests { #[test] fn auth_tab_not_consumed_when_no_dialog() { let mut state = make_state(); - let result = handle_message(&mut state, &Message::AuthTabKeyPressed { shift: false }); + let result = handle_message( + &mut state, + &crate::app::Message::AuthTabKeyPressed { shift: false }, + ); assert!(result.is_none(), "no dialog → message must not be consumed"); } @@ -979,7 +516,10 @@ mod tests { fn auth_tab_noop_in_unlock_dialog() { let mut state = make_state(); state.active_dialog = Some(unlock_dialog()); - let result = handle_message(&mut state, &Message::AuthTabKeyPressed { shift: false }); + let result = handle_message( + &mut state, + &crate::app::Message::AuthTabKeyPressed { shift: false }, + ); assert!(result.is_some(), "Unlock → must be consumed"); // Dialog itself must be untouched. assert!( @@ -993,7 +533,10 @@ mod tests { fn auth_shift_tab_noop_in_unlock_dialog() { let mut state = make_state(); state.active_dialog = Some(unlock_dialog()); - let result = handle_message(&mut state, &Message::AuthTabKeyPressed { shift: true }); + let result = handle_message( + &mut state, + &crate::app::Message::AuthTabKeyPressed { shift: true }, + ); assert!(result.is_some()); } @@ -1002,7 +545,10 @@ mod tests { fn auth_tab_active_in_setup_dialog() { let mut state = make_state(); state.active_dialog = Some(setup_dialog(Uuid::new_v4())); - let result = handle_message(&mut state, &Message::AuthTabKeyPressed { shift: false }); + let result = handle_message( + &mut state, + &crate::app::Message::AuthTabKeyPressed { shift: false }, + ); assert!(result.is_some(), "Setup → must be consumed"); // Dialog must still be open. assert!( @@ -1019,7 +565,10 @@ mod tests { fn auth_shift_tab_active_in_setup_dialog() { let mut state = make_state(); state.active_dialog = Some(setup_dialog(Uuid::new_v4())); - let result = handle_message(&mut state, &Message::AuthTabKeyPressed { shift: true }); + let result = handle_message( + &mut state, + &crate::app::Message::AuthTabKeyPressed { shift: true }, + ); assert!(result.is_some()); } @@ -1029,7 +578,7 @@ mod tests { #[test] fn auth_enter_not_consumed_when_no_dialog() { let mut state = make_state(); - let result = handle_message(&mut state, &Message::AuthEnterPressed); + let result = handle_message(&mut state, &crate::app::Message::AuthEnterPressed); assert!(result.is_none()); } @@ -1038,7 +587,7 @@ mod tests { fn auth_enter_dispatches_unlock_submit_when_ready() { let mut state = make_state(); state.active_dialog = Some(unlock_dialog()); - let result = handle_message(&mut state, &Message::AuthEnterPressed); + let result = handle_message(&mut state, &crate::app::Message::AuthEnterPressed); assert!(result.is_some(), "must be consumed"); // Not yet processing — the submit task is returned, no state mutation yet. assert!( @@ -1063,7 +612,7 @@ mod tests { error: None, is_processing: true, }); - let result = handle_message(&mut state, &Message::AuthEnterPressed); + let result = handle_message(&mut state, &crate::app::Message::AuthEnterPressed); assert!(result.is_some(), "still consumed"); assert!( matches!( @@ -1082,7 +631,7 @@ mod tests { fn auth_enter_dispatches_setup_submit_when_ready() { let mut state = make_state(); state.active_dialog = Some(setup_dialog(Uuid::new_v4())); - let result = handle_message(&mut state, &Message::AuthEnterPressed); + let result = handle_message(&mut state, &crate::app::Message::AuthEnterPressed); assert!(result.is_some()); assert!( matches!( @@ -1105,7 +654,7 @@ mod tests { error: None, is_processing: true, }); - let result = handle_message(&mut state, &Message::AuthEnterPressed); + let result = handle_message(&mut state, &crate::app::Message::AuthEnterPressed); assert!(result.is_some()); assert!( matches!( diff --git a/src/auth/update.rs b/src/auth/update.rs new file mode 100644 index 0000000..ccbd2bc --- /dev/null +++ b/src/auth/update.rs @@ -0,0 +1,304 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! State-machine message handler for the auth dialog. + +use iced::Task; +use secrecy::SecretString; + +use crate::app::{AppState, Message}; +use crate::crypto; +use crate::screens::connection; + +use super::{AuthDialog, PendingAction}; + +/// Handle an auth dialog message. +/// +/// Returns `Some(task)` if the message was consumed by the auth state machine, +/// or `None` if it should be forwarded to the per-screen update handler. +pub fn handle_message(state: &mut AppState, message: &Message) -> Option> { + match message { + Message::AuthTabKeyPressed { shift } => { + match &state.active_dialog { + Some(AuthDialog::SetupPassphrase { .. }) => { + // Two-input ring: passphrase ↔ confirm. + // We don't track which is focused, so we always use focus_next / + // focus_previous which cycles naturally within the opaque overlay. + let task = if *shift { + iced::widget::operation::focus_previous() + } else { + iced::widget::operation::focus_next() + }; + Some(task) + } + // Unlock dialog has one input — Tab is a no-op. + Some(AuthDialog::Unlock { .. }) => Some(Task::none()), + None => None, + } + } + Message::AuthEnterPressed => match &state.active_dialog { + Some(AuthDialog::Unlock { is_processing, .. }) if !*is_processing => { + Some(Task::done(Message::SubmitUnlockPassphrase)) + } + Some(AuthDialog::SetupPassphrase { is_processing, .. }) if !*is_processing => { + Some(Task::done(Message::SubmitSetupPassphrase)) + } + Some(_) => Some(Task::none()), + None => None, + }, + Message::DismissAuthDialog => { + state.active_dialog = None; + Some(Task::none()) + } + Message::AuthSetupPassphraseChanged(v) => { + if let Some(AuthDialog::SetupPassphrase { + passphrase_input, + error, + .. + }) = &mut state.active_dialog + { + *passphrase_input = v.clone(); + *error = None; + } + Some(Task::none()) + } + Message::AuthSetupConfirmChanged(v) => { + if let Some(AuthDialog::SetupPassphrase { + confirm_input, + error, + .. + }) = &mut state.active_dialog + { + *confirm_input = v.clone(); + *error = None; + } + Some(Task::none()) + } + Message::AuthUnlockPassphraseChanged(v) => { + if let Some(AuthDialog::Unlock { + passphrase_input, + error, + .. + }) = &mut state.active_dialog + { + *passphrase_input = v.clone(); + *error = None; + } + Some(Task::none()) + } + Message::SubmitSetupPassphrase => { + let Some(AuthDialog::SetupPassphrase { + pending_profile_id, + pending_password, + passphrase_input, + confirm_input, + error, + is_processing, + }) = &mut state.active_dialog + else { + return Some(Task::none()); + }; + if *is_processing { + return Some(Task::none()); + } + if passphrase_input != confirm_input { + *error = Some("Passphrases do not match".to_owned()); + return Some(Task::none()); + } + if passphrase_input.is_empty() { + *error = Some("Passphrase cannot be empty".to_owned()); + return Some(Task::none()); + } + let profile_id = *pending_profile_id; + let password = pending_password.clone(); + let passphrase = passphrase_input.clone(); + *is_processing = true; + let task = Task::perform( + async move { + let passphrase_c = passphrase.clone(); + let (hash, encrypted) = tokio::task::spawn_blocking(move || { + let hash = crypto::hash_passphrase(&passphrase_c); + let encrypted = crypto::encrypt_password(&passphrase_c, &password); + (hash, encrypted) + }) + .await + .expect("passphrase setup task panicked"); + (passphrase, hash, profile_id, encrypted) + }, + |(passphrase, hash, pid, ep)| Message::SetupPassphraseComplete { + passphrase, + hash, + profile_id: pid, + encrypted_password: ep, + }, + ); + Some(task) + } + Message::SetupPassphraseComplete { + passphrase, + hash, + profile_id, + encrypted_password, + } => { + state.active_dialog = None; + state.unlocked_passphrase = Some(SecretString::new(passphrase.clone())); + state.profiles.master_passphrase_hash = Some(hash.clone()); + if let Some(p) = state + .profiles + .profiles + .iter_mut() + .find(|p| p.id == *profile_id) + { + p.encrypted_password = Some(encrypted_password.clone()); + } + let snap = state.profiles.clone(); + Some(Task::perform(async move { snap.save().await }, |_| { + Message::Noop + })) + } + Message::SubmitUnlockPassphrase => { + let Some(AuthDialog::Unlock { + passphrase_input, + is_processing, + .. + }) = &state.active_dialog + else { + return Some(Task::none()); + }; + if *is_processing { + return Some(Task::none()); + } + if passphrase_input.is_empty() { + if let Some(AuthDialog::Unlock { error, .. }) = &mut state.active_dialog { + *error = Some("Passphrase cannot be empty".to_owned()); + } + return Some(Task::none()); + } + let passphrase = passphrase_input.clone(); + let hash = state + .profiles + .master_passphrase_hash + .clone() + .unwrap_or_default(); + if let Some(AuthDialog::Unlock { is_processing, .. }) = &mut state.active_dialog { + *is_processing = true; + } + let task = Task::perform( + async move { + let passphrase_c = passphrase.clone(); + let valid = tokio::task::spawn_blocking(move || { + crypto::verify_passphrase(&passphrase_c, &hash) + }) + .await + .expect("passphrase verify task panicked"); + (passphrase, valid) + }, + |(passphrase, valid)| Message::UnlockPassphraseResult { passphrase, valid }, + ); + Some(task) + } + Message::UnlockPassphraseResult { passphrase, valid } => { + if !valid { + if let Some(AuthDialog::Unlock { + error, + is_processing, + .. + }) = &mut state.active_dialog + { + *error = Some("Incorrect passphrase".to_owned()); + *is_processing = false; + } + return Some(Task::none()); + } + state.unlocked_passphrase = Some(SecretString::new(passphrase.clone())); + let pending = match state.active_dialog.take() { + Some(AuthDialog::Unlock { pending_action, .. }) => pending_action, + _ => return Some(Task::none()), + }; + match pending { + PendingAction::ConnectToProfile(id) => { + let Some(profile) = state.profiles.get(id) else { + return Some(Task::none()); + }; + let creds = profile.credentials(Some(passphrase.as_str())); + Some(Task::done(Message::Connection( + connection::Message::ConnectWithCreds { + profile_id: id, + creds, + }, + ))) + } + PendingAction::SavePassword { + profile_id, + password, + } => { + let pw = passphrase.clone(); + let task = Task::perform( + async move { + let encrypted = tokio::task::spawn_blocking(move || { + crypto::encrypt_password(&pw, &password) + }) + .await + .expect("encrypt task panicked"); + (profile_id, encrypted) + }, + |(pid, ep)| Message::EncryptPasswordReady { + profile_id: pid, + encrypted_password: ep, + }, + ); + Some(task) + } + PendingAction::TestConnectionFromSettings { profile_id } => { + let Some(profile) = state.profiles.get(profile_id) else { + return Some(Task::none()); + }; + let creds = profile.credentials(Some(passphrase.as_str())); + let url = creds.rpc_url(); + let probe = Task::perform( + async move { + crate::rpc::session_get(&url, &creds, "") + .await + .map_err(|e| e.to_string()) + }, + |r| { + Message::Settings( + crate::screens::settings::Message::TestConnectionResult(r), + ) + }, + ); + Some(probe) + } + } + } + Message::EncryptPasswordReady { + profile_id, + encrypted_password, + } => { + if let Some(p) = state + .profiles + .profiles + .iter_mut() + .find(|p| p.id == *profile_id) + { + p.encrypted_password = Some(encrypted_password.clone()); + } + let snap = state.profiles.clone(); + Some(Task::perform(async move { snap.save().await }, |_| { + Message::Noop + })) + } + _ => None, + } +} diff --git a/src/auth/view.rs b/src/auth/view.rs new file mode 100644 index 0000000..5857923 --- /dev/null +++ b/src/auth/view.rs @@ -0,0 +1,202 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Modal overlay rendering for the auth dialog. + +use iced::widget::{Space, button, column, container, row, stack, text, text_input}; +use iced::{Element, Length}; + +use crate::app::Message; + +use super::{AuthDialog, setup_confirm_id, setup_passphrase_id, unlock_input_id}; + +/// Wrap `base` in the active auth dialog modal overlay, or return `base` unchanged. +pub fn view_overlay<'a>( + dialog: Option<&'a AuthDialog>, + base: Element<'a, Message>, +) -> Element<'a, Message> { + match dialog { + Some(AuthDialog::SetupPassphrase { + passphrase_input, + confirm_input, + error, + is_processing, + .. + }) => stack![ + base, + view_setup_passphrase( + passphrase_input, + confirm_input, + error.as_deref(), + *is_processing + ) + ] + .into(), + Some(AuthDialog::Unlock { + passphrase_input, + error, + is_processing, + .. + }) => stack![ + base, + view_unlock(passphrase_input, error.as_deref(), *is_processing) + ] + .into(), + None => base, + } +} + +fn view_setup_passphrase<'a>( + passphrase_input: &'a str, + confirm_input: &'a str, + error: Option<&'a str>, + is_processing: bool, +) -> Element<'a, Message> { + let error_row: Element<'_, Message> = if let Some(err) = error { + text(err) + .size(13) + .style(|t: &iced::Theme| iced::widget::text::Style { + color: Some(t.extended_palette().danger.base.color), + }) + .into() + } else { + Space::new().into() + }; + + let card = container( + column![ + text("Create master passphrase").size(18), + text( + "This passphrase protects your saved passwords. \ + You will enter it once per app session." + ) + .size(13) + .style(|t: &iced::Theme| iced::widget::text::Style { + color: Some(t.palette().text.scale_alpha(0.7)), + }), + text_input("Enter passphrase", passphrase_input) + .id(setup_passphrase_id()) + .on_input_maybe((!is_processing).then_some(Message::AuthSetupPassphraseChanged)) + .secure(true) + .padding([12, 16]) + .style(crate::theme::m3_text_input), + text_input("Confirm passphrase", confirm_input) + .id(setup_confirm_id()) + .on_input_maybe((!is_processing).then_some(Message::AuthSetupConfirmChanged)) + .secure(true) + .padding([12, 16]) + .style(crate::theme::m3_text_input), + error_row, + row![ + button(text("Cancel").size(14)) + .on_press_maybe((!is_processing).then_some(Message::DismissAuthDialog)) + .padding([10, 20]) + .style(crate::theme::m3_tonal_button), + Space::new().width(Length::Fill), + button( + text(if is_processing { + "Creating…" + } else { + "Create" + }) + .size(14) + ) + .on_press_maybe((!is_processing).then_some(Message::SubmitSetupPassphrase)) + .padding([10, 20]) + .style(crate::theme::m3_primary_button), + ] + .width(Length::Fill) + .align_y(iced::Alignment::Center), + ] + .spacing(12), + ) + .padding(28) + .max_width(420) + .style(crate::theme::auth_dialog_card); + + container(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(crate::theme::dialog_scrim(0.5)) + .into() +} + +fn view_unlock<'a>( + passphrase_input: &'a str, + error: Option<&'a str>, + is_processing: bool, +) -> Element<'a, Message> { + let error_row: Element<'_, Message> = if let Some(err) = error { + text(err) + .size(13) + .style(|t: &iced::Theme| iced::widget::text::Style { + color: Some(t.extended_palette().danger.base.color), + }) + .into() + } else { + Space::new().into() + }; + + let card = container( + column![ + text("Enter master passphrase").size(18), + text("Enter your master passphrase to unlock your saved credentials.") + .size(13) + .style(|t: &iced::Theme| iced::widget::text::Style { + color: Some(t.palette().text.scale_alpha(0.7)), + }), + text_input("Master passphrase", passphrase_input) + .id(unlock_input_id()) + .on_input_maybe((!is_processing).then_some(Message::AuthUnlockPassphraseChanged)) + .secure(true) + .padding([12, 16]) + .style(crate::theme::m3_text_input), + error_row, + row![ + button(text("Cancel").size(14)) + .on_press_maybe((!is_processing).then_some(Message::DismissAuthDialog)) + .padding([10, 20]) + .style(crate::theme::m3_tonal_button), + Space::new().width(Length::Fill), + button( + text(if is_processing { + "Verifying…" + } else { + "Unlock" + }) + .size(14) + ) + .on_press_maybe((!is_processing).then_some(Message::SubmitUnlockPassphrase)) + .padding([10, 20]) + .style(crate::theme::m3_primary_button), + ] + .width(Length::Fill) + .align_y(iced::Alignment::Center), + ] + .spacing(12), + ) + .padding(28) + .max_width(420) + .style(crate::theme::auth_dialog_card); + + container(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(crate::theme::dialog_scrim(0.5)) + .into() +} diff --git a/src/screens/inspector/mod.rs b/src/screens/inspector/mod.rs new file mode 100644 index 0000000..335414b --- /dev/null +++ b/src/screens/inspector/mod.rs @@ -0,0 +1,193 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Displays per-torrent detail in a tabbed panel. +//! +//! `view()` accepts an immutable reference to the currently selected +//! `TorrentData` — all data arrives via the polling subscription; the +//! inspector owns no RPC state. +//! +//! # Architecture +//! +//! This module is a self-contained Elm component: +//! - [`InspectorScreen`] — state (active tab only) +//! - [`Message`] — messages that can be dispatched to this component +//! - [`update`] — pure state transition +//! - [`view`] — renders the panel for the given torrent + +mod state; +mod update; +mod view; + +pub use state::{InspectorOptionsState, InspectorScreen}; +pub use update::update; +pub use view::view; + +// ── ActiveTab ───────────────────────────────────────────────────────────────── + +/// The currently visible inspector tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ActiveTab { + #[default] + General, + Files, + Trackers, + Peers, + Options, +} + +// ── Message ─────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum Message { + TabSelected(ActiveTab), + FileWantedToggled { + torrent_id: i64, + file_index: usize, + wanted: bool, + }, + AllFilesWantedToggled { + torrent_id: i64, + file_count: usize, + wanted: bool, + }, + /// Emitted when the SetFileWanted RPC completes (success or failure). + /// Removes the given indices from `pending_wanted`. + FileWantedSetSuccess { + indices: Vec, + }, + // ── Options tab messages ────────────────────────────────────────────── + OptionsDownloadLimitToggled(bool), + OptionsDownloadLimitChanged(String), + OptionsDownloadLimitSubmitted, + OptionsUploadLimitToggled(bool), + OptionsUploadLimitChanged(String), + OptionsUploadLimitSubmitted, + OptionsRatioModeChanged(u8), + OptionsRatioLimitChanged(String), + OptionsRatioLimitSubmitted, + OptionsHonorGlobalToggled(bool), +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `TabSelected` updates `active_tab`. + #[test] + fn tab_selected_updates_active() { + let mut screen = InspectorScreen::new(); + assert_eq!(screen.active_tab, ActiveTab::General); + + let _ = update(&mut screen, Message::TabSelected(ActiveTab::Files)); + assert_eq!(screen.active_tab, ActiveTab::Files); + + let _ = update(&mut screen, Message::TabSelected(ActiveTab::Trackers)); + assert_eq!(screen.active_tab, ActiveTab::Trackers); + + let _ = update(&mut screen, Message::TabSelected(ActiveTab::Peers)); + assert_eq!(screen.active_tab, ActiveTab::Peers); + + let _ = update(&mut screen, Message::TabSelected(ActiveTab::General)); + assert_eq!(screen.active_tab, ActiveTab::General); + } + + // ── Selective file download (inspector) ───────────────────────────────── + + /// `FileWantedToggled` inserts the toggled index into `pending_wanted`. + #[test] + fn file_wanted_toggled_updates_pending() { + let mut screen = InspectorScreen::new(); + let _ = update( + &mut screen, + Message::FileWantedToggled { + torrent_id: 1, + file_index: 2, + wanted: false, + }, + ); + assert_eq!(screen.pending_wanted.get(&2), Some(&false)); + assert!(!screen.pending_wanted.contains_key(&0)); + } + + /// `FileWantedSetSuccess` removes only the specified indices. + #[test] + fn file_wanted_set_success_clears_only_specified_indices() { + let mut screen = InspectorScreen::new(); + // Seed several pending entries. + screen.pending_wanted.insert(0, true); + screen.pending_wanted.insert(1, false); + screen.pending_wanted.insert(2, true); + + let _ = update( + &mut screen, + Message::FileWantedSetSuccess { + indices: vec![0, 2], + }, + ); + + assert!( + !screen.pending_wanted.contains_key(&0), + "index 0 should be cleared" + ); + assert!( + !screen.pending_wanted.contains_key(&2), + "index 2 should be cleared" + ); + assert_eq!( + screen.pending_wanted.get(&1), + Some(&false), + "index 1 must remain untouched" + ); + } + + /// `AllFilesWantedToggled` inserts all indices into `pending_wanted`. + #[test] + fn all_files_wanted_toggled_populates_all_indices() { + let mut screen = InspectorScreen::new(); + let _ = update( + &mut screen, + Message::AllFilesWantedToggled { + torrent_id: 7, + file_count: 4, + wanted: false, + }, + ); + for i in 0..4 { + assert_eq!( + screen.pending_wanted.get(&i), + Some(&false), + "index {i} should be set to false" + ); + } + } + + /// The inspector `Message` enum has no variant that would clear + /// `pending_wanted` on a background poll. Calling + /// `TabSelected` (a non-file message) leaves `pending_wanted` unchanged. + #[test] + fn poll_does_not_clear_pending_wanted() { + let mut screen = InspectorScreen::new(); + screen.pending_wanted.insert(5, true); + + // Simulate any non-file-wanted message arriving (e.g. from a background poll path). + let _ = update(&mut screen, Message::TabSelected(ActiveTab::Files)); + + assert_eq!( + screen.pending_wanted.get(&5), + Some(&true), + "pending_wanted must not be cleared by unrelated messages" + ); + } +} diff --git a/src/screens/inspector/state.rs b/src/screens/inspector/state.rs new file mode 100644 index 0000000..c9d898a --- /dev/null +++ b/src/screens/inspector/state.rs @@ -0,0 +1,70 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Inspector screen state types. + +use std::collections::HashMap; + +use crate::rpc::TorrentData; + +// ── InspectorOptionsState ──────────────────────────────────────────────────── + +/// Local draft for the per-torrent Options tab. +/// Reset whenever a new torrent is selected. +#[derive(Debug, Default, Clone)] +pub struct InspectorOptionsState { + pub download_limited: bool, + pub download_limit_val: String, + pub upload_limited: bool, + pub upload_limit_val: String, + /// 0 = Global, 1 = Custom, 2 = Unlimited + pub ratio_mode: u8, + pub ratio_limit_val: String, + pub honors_session_limits: bool, +} + +impl InspectorOptionsState { + /// Populate from fresh torrent data. + pub fn from_torrent(t: &TorrentData) -> Self { + Self { + download_limited: t.download_limited, + download_limit_val: t.download_limit.to_string(), + upload_limited: t.upload_limited, + upload_limit_val: t.upload_limit.to_string(), + ratio_mode: t.seed_ratio_mode, + ratio_limit_val: format!("{:.2}", t.seed_ratio_limit), + honors_session_limits: t.honors_session_limits, + } + } +} + +// ── InspectorScreen ─────────────────────────────────────────────────────────── + +/// State for the inspector detail panel. +#[derive(Debug, Default)] +pub struct InspectorScreen { + pub active_tab: super::ActiveTab, + /// Optimistic file-wanted overrides keyed by file index. + /// Entries are inserted when the user toggles a checkbox and removed + /// when the corresponding `torrent-set` RPC completes (or fails). + pub pending_wanted: HashMap, + /// Draft state for the Options tab. + pub options: InspectorOptionsState, +} + +impl InspectorScreen { + pub fn new() -> Self { + Self::default() + } +} diff --git a/src/screens/inspector/update.rs b/src/screens/inspector/update.rs new file mode 100644 index 0000000..1d3a279 --- /dev/null +++ b/src/screens/inspector/update.rs @@ -0,0 +1,91 @@ +// Copyright 2026 The clutch authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Message handling for the inspector panel. + +use iced::Task; + +use super::{InspectorScreen, Message}; + +/// Apply an inspector message to the screen state and return any follow-up work. +pub fn update(state: &mut InspectorScreen, msg: Message) -> Task { + match msg { + Message::TabSelected(tab) => { + state.active_tab = tab; + Task::none() + } + Message::FileWantedToggled { + file_index, wanted, .. + } => { + state.pending_wanted.insert(file_index, wanted); + Task::none() + } + Message::AllFilesWantedToggled { + file_count, wanted, .. + } => { + for i in 0..file_count { + state.pending_wanted.insert(i, wanted); + } + Task::none() + } + Message::FileWantedSetSuccess { indices } => { + for i in &indices { + state.pending_wanted.remove(i); + } + Task::none() + } + // ── Options tab ─────────────────────────────────────────────────────── + Message::OptionsDownloadLimitToggled(v) => { + state.options.download_limited = v; + Task::none() + } + Message::OptionsDownloadLimitChanged(v) => { + if v.is_empty() || v.chars().all(|c| c.is_ascii_digit()) { + state.options.download_limit_val = v; + } + Task::none() + } + Message::OptionsUploadLimitToggled(v) => { + state.options.upload_limited = v; + Task::none() + } + Message::OptionsUploadLimitChanged(v) => { + if v.is_empty() || v.chars().all(|c| c.is_ascii_digit()) { + state.options.upload_limit_val = v; + } + Task::none() + } + Message::OptionsRatioModeChanged(v) => { + state.options.ratio_mode = v; + Task::none() + } + Message::OptionsRatioLimitChanged(v) => { + // Ratio allows digits and at most one decimal point. + let dot_count = v.chars().filter(|c| *c == '.').count(); + if v.is_empty() || (v.chars().all(|c| c.is_ascii_digit() || c == '.') && dot_count <= 1) + { + state.options.ratio_limit_val = v; + } + Task::none() + } + Message::OptionsHonorGlobalToggled(v) => { + state.options.honors_session_limits = v; + Task::none() + } + // Submit messages are intercepted by main_screen; nothing to update here. + Message::OptionsDownloadLimitSubmitted + | Message::OptionsUploadLimitSubmitted + | Message::OptionsRatioLimitSubmitted => Task::none(), + } +} diff --git a/src/screens/inspector.rs b/src/screens/inspector/view.rs similarity index 59% rename from src/screens/inspector.rs rename to src/screens/inspector/view.rs index b6f6ad4..83747da 100644 --- a/src/screens/inspector.rs +++ b/src/screens/inspector/view.rs @@ -12,194 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Displays per-torrent detail in a tabbed panel using `iced_aw::Tabs`. -//! `view()` accepts an immutable reference to the currently selected -//! `TorrentData` — all data arrives via the polling subscription; the -//! inspector owns no RPC state. -//! -//! # Architecture -//! -//! This module is a self-contained Elm component: -//! - [`InspectorScreen`] — state (active tab only) -//! - [`Message`] — messages that can be dispatched to this component -//! - [`update`] — pure state transition -//! - [`view`] — renders the panel for the given torrent +//! View rendering for the inspector panel tabs. use iced::widget::{Space, column, container, progress_bar, row, scrollable, text}; -use iced::{Element, Length, Task}; +use iced::{Element, Length}; use crate::format::{format_ago, format_eta, format_size, format_speed}; use crate::rpc::TorrentData; -// ── ActiveTab ───────────────────────────────────────────────────────────────── - -/// The currently visible inspector tab. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum ActiveTab { - #[default] - General, - Files, - Trackers, - Peers, - Options, -} - -// ── Message ─────────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone)] -pub enum Message { - TabSelected(ActiveTab), - FileWantedToggled { - torrent_id: i64, - file_index: usize, - wanted: bool, - }, - AllFilesWantedToggled { - torrent_id: i64, - file_count: usize, - wanted: bool, - }, - /// Emitted when the SetFileWanted RPC completes (success or failure). - /// Removes the given indices from `pending_wanted`. - FileWantedSetSuccess { - indices: Vec, - }, - // ── Options tab messages ────────────────────────────────────────────── - OptionsDownloadLimitToggled(bool), - OptionsDownloadLimitChanged(String), - OptionsDownloadLimitSubmitted, - OptionsUploadLimitToggled(bool), - OptionsUploadLimitChanged(String), - OptionsUploadLimitSubmitted, - OptionsRatioModeChanged(u8), - OptionsRatioLimitChanged(String), - OptionsRatioLimitSubmitted, - OptionsHonorGlobalToggled(bool), -} - -// ── InspectorOptionsState ──────────────────────────────────────────────────── - -/// Local draft for the per-torrent Options tab. -/// Reset whenever a new torrent is selected. -#[derive(Debug, Default, Clone)] -pub struct InspectorOptionsState { - pub download_limited: bool, - pub download_limit_val: String, - pub upload_limited: bool, - pub upload_limit_val: String, - /// 0 = Global, 1 = Custom, 2 = Unlimited - pub ratio_mode: u8, - pub ratio_limit_val: String, - pub honors_session_limits: bool, -} - -impl InspectorOptionsState { - /// Populate from fresh torrent data. - pub fn from_torrent(t: &TorrentData) -> Self { - Self { - download_limited: t.download_limited, - download_limit_val: t.download_limit.to_string(), - upload_limited: t.upload_limited, - upload_limit_val: t.upload_limit.to_string(), - ratio_mode: t.seed_ratio_mode, - ratio_limit_val: format!("{:.2}", t.seed_ratio_limit), - honors_session_limits: t.honors_session_limits, - } - } -} - -// ── State ───────────────────────────────────────────────────────────────────── - -#[derive(Debug, Default)] -pub struct InspectorScreen { - pub active_tab: ActiveTab, - /// Optimistic file-wanted overrides keyed by file index. - /// Entries are inserted when the user toggles a checkbox and removed - /// when the corresponding `torrent-set` RPC completes (or fails). - pub pending_wanted: std::collections::HashMap, - /// Draft state for the Options tab. - pub options: InspectorOptionsState, -} - -impl InspectorScreen { - pub fn new() -> Self { - Self::default() - } -} - -// ── Elm functions ───────────────────────────────────────────────────────────── - -pub fn update(state: &mut InspectorScreen, msg: Message) -> Task { - match msg { - Message::TabSelected(tab) => { - state.active_tab = tab; - Task::none() - } - Message::FileWantedToggled { - file_index, wanted, .. - } => { - state.pending_wanted.insert(file_index, wanted); - Task::none() - } - Message::AllFilesWantedToggled { - file_count, wanted, .. - } => { - for i in 0..file_count { - state.pending_wanted.insert(i, wanted); - } - Task::none() - } - Message::FileWantedSetSuccess { indices } => { - for i in &indices { - state.pending_wanted.remove(i); - } - Task::none() - } - // ── Options tab ─────────────────────────────────────────────────────── - Message::OptionsDownloadLimitToggled(v) => { - state.options.download_limited = v; - Task::none() - } - Message::OptionsDownloadLimitChanged(v) => { - if v.is_empty() || v.chars().all(|c| c.is_ascii_digit()) { - state.options.download_limit_val = v; - } - Task::none() - } - Message::OptionsUploadLimitToggled(v) => { - state.options.upload_limited = v; - Task::none() - } - Message::OptionsUploadLimitChanged(v) => { - if v.is_empty() || v.chars().all(|c| c.is_ascii_digit()) { - state.options.upload_limit_val = v; - } - Task::none() - } - Message::OptionsRatioModeChanged(v) => { - state.options.ratio_mode = v; - Task::none() - } - Message::OptionsRatioLimitChanged(v) => { - // Ratio allows digits and at most one decimal point. - let dot_count = v.chars().filter(|c| *c == '.').count(); - if v.is_empty() || (v.chars().all(|c| c.is_ascii_digit() || c == '.') && dot_count <= 1) - { - state.options.ratio_limit_val = v; - } - Task::none() - } - Message::OptionsHonorGlobalToggled(v) => { - state.options.honors_session_limits = v; - Task::none() - } - // Submit messages are intercepted by main_screen; nothing to update here. - Message::OptionsDownloadLimitSubmitted - | Message::OptionsUploadLimitSubmitted - | Message::OptionsRatioLimitSubmitted => Task::none(), - } -} +use super::{ActiveTab, InspectorOptionsState, InspectorScreen, Message}; +/// Render the inspector panel for the given torrent. pub fn view<'a>(state: &'a InspectorScreen, torrent: &'a TorrentData) -> Element<'a, Message> { // ── Material tab bar ────────────────────────────────────────────────────── // Each tab is a plain button (transparent background). The active tab gets @@ -544,6 +367,8 @@ fn view_peers(torrent: &TorrentData) -> Element<'_, Message> { ) } +// ── Options tab ─────────────────────────────────────────────────────────────── + fn view_options(opts: &InspectorOptionsState) -> Element<'_, Message> { use iced::widget::{text_input, toggler}; @@ -650,115 +475,3 @@ fn view_options(opts: &InspectorOptionsState) -> Element<'_, Message> { .height(Length::Fill) .into() } - -#[cfg(test)] -mod tests { - use super::*; - - /// `TabSelected` updates `active_tab`. - #[test] - fn tab_selected_updates_active() { - let mut screen = InspectorScreen::new(); - assert_eq!(screen.active_tab, ActiveTab::General); - - let _ = update(&mut screen, Message::TabSelected(ActiveTab::Files)); - assert_eq!(screen.active_tab, ActiveTab::Files); - - let _ = update(&mut screen, Message::TabSelected(ActiveTab::Trackers)); - assert_eq!(screen.active_tab, ActiveTab::Trackers); - - let _ = update(&mut screen, Message::TabSelected(ActiveTab::Peers)); - assert_eq!(screen.active_tab, ActiveTab::Peers); - - let _ = update(&mut screen, Message::TabSelected(ActiveTab::General)); - assert_eq!(screen.active_tab, ActiveTab::General); - } - - // ── Selective file download (inspector) ───────────────────────────────── - - /// `FileWantedToggled` inserts the toggled index into `pending_wanted`. - #[test] - fn file_wanted_toggled_updates_pending() { - let mut screen = InspectorScreen::new(); - let _ = update( - &mut screen, - Message::FileWantedToggled { - torrent_id: 1, - file_index: 2, - wanted: false, - }, - ); - assert_eq!(screen.pending_wanted.get(&2), Some(&false)); - assert!(!screen.pending_wanted.contains_key(&0)); - } - - /// `FileWantedSetSuccess` removes only the specified indices. - #[test] - fn file_wanted_set_success_clears_only_specified_indices() { - let mut screen = InspectorScreen::new(); - // Seed several pending entries. - screen.pending_wanted.insert(0, true); - screen.pending_wanted.insert(1, false); - screen.pending_wanted.insert(2, true); - - let _ = update( - &mut screen, - Message::FileWantedSetSuccess { - indices: vec![0, 2], - }, - ); - - assert!( - !screen.pending_wanted.contains_key(&0), - "index 0 should be cleared" - ); - assert!( - !screen.pending_wanted.contains_key(&2), - "index 2 should be cleared" - ); - assert_eq!( - screen.pending_wanted.get(&1), - Some(&false), - "index 1 must remain untouched" - ); - } - - /// `AllFilesWantedToggled` inserts all indices into `pending_wanted`. - #[test] - fn all_files_wanted_toggled_populates_all_indices() { - let mut screen = InspectorScreen::new(); - let _ = update( - &mut screen, - Message::AllFilesWantedToggled { - torrent_id: 7, - file_count: 4, - wanted: false, - }, - ); - for i in 0..4 { - assert_eq!( - screen.pending_wanted.get(&i), - Some(&false), - "index {i} should be set to false" - ); - } - } - - /// The inspector `Message` enum has no variant that would clear - /// `pending_wanted` on a background poll. Calling - /// `TabSelected` (a non-file message) leaves `pending_wanted` unchanged. - #[test] - fn poll_does_not_clear_pending_wanted() { - let mut screen = InspectorScreen::new(); - screen.pending_wanted.insert(5, true); - - // Simulate any non-file-wanted message arriving (e.g. from a background poll path). - let _ = update(&mut screen, Message::TabSelected(ActiveTab::Files)); - - assert_eq!( - screen.pending_wanted.get(&5), - Some(&true), - "pending_wanted must not be cleared by unrelated messages" - ); - } -} diff --git a/src/screens/torrent_list/filters.rs b/src/screens/torrent_list/filters.rs index 3dd42c2..00cd9a8 100644 --- a/src/screens/torrent_list/filters.rs +++ b/src/screens/torrent_list/filters.rs @@ -56,15 +56,17 @@ pub fn matching_filters(t: &TorrentData) -> Vec { pub(crate) fn count_filters(torrents: &[TorrentData]) -> FilterCounts { let mut counts = FilterCounts::default(); - for torrent in torrents { - for filter in matching_filters(torrent) { - match filter { - StatusFilter::Downloading => counts.downloading += 1, - StatusFilter::Seeding => counts.seeding += 1, - StatusFilter::Paused => counts.paused += 1, - StatusFilter::Active => counts.active += 1, - StatusFilter::Error => counts.error += 1, - } + for t in torrents { + // Inline the matching logic to avoid per-torrent Vec allocation. + match t.status { + 3 | 4 => counts.downloading += 1, + 5 | 6 => counts.seeding += 1, + 0 => counts.paused += 1, + 1 | 2 => counts.error += 1, + _ => {} + } + if t.rate_download > 0 || t.rate_upload > 0 { + counts.active += 1; } } @@ -76,9 +78,17 @@ pub(crate) fn torrent_matches_active_filters( torrent: &TorrentData, active_filters: &HashSet, ) -> bool { - matching_filters(torrent) - .iter() - .any(|filter| active_filters.contains(filter)) + // Inline the matching logic to avoid per-torrent Vec allocation. + let status_match = match torrent.status { + 3 | 4 => active_filters.contains(&StatusFilter::Downloading), + 5 | 6 => active_filters.contains(&StatusFilter::Seeding), + 0 => active_filters.contains(&StatusFilter::Paused), + 1 | 2 => active_filters.contains(&StatusFilter::Error), + _ => false, + }; + let active_match = (torrent.rate_download > 0 || torrent.rate_upload > 0) + && active_filters.contains(&StatusFilter::Active); + status_match || active_match } /// Sort the list, then retain only the torrents allowed by the active filters. diff --git a/system_architecture.md b/system_architecture.md index d7845e7..9b0cbf0 100644 --- a/system_architecture.md +++ b/system_architecture.md @@ -61,8 +61,11 @@ The torrent list supports multi-select status filtering entirely in-process. A 1. A **count pass** over the full `Vec` tallies how many torrents belong to each of the five semantic buckets (Downloading, Seeding, Paused, Active, Error). -2. A **filter pass** retains only torrents whose `matching_filters()` set intersects the active - `HashSet` before the rows are rendered. +2. A **filter pass** retains only torrents that match the active `HashSet` before the rows are + rendered. + +Both passes inline the status-matching logic directly rather than allocating intermediate +collections, keeping the render hot path allocation-free on the per-torrent level. No additional RPC calls are needed; the daemon continues to return the full torrent list on every poll tick. @@ -78,11 +81,13 @@ src/ ├── app/ # Private routing, settings bridge, and keyboard helpers ├── rpc/ # Transmission API, types, and the serialized worker ├── screens/ # Individual screen states and views -│ ├── connection/ # Profile selection and quick-connect -│ ├── main_screen/ # The core app: torrent list and detail inspector -│ └── settings/ # Profile editing and app preferences -├── auth.rs # Passphrase setup and unlock dialogs -├── crypto.rs # Argon2id / ChaCha20 wrappers +│ ├── connection.rs # Profile selection and quick-connect +│ ├── main_screen.rs # Root layout: torrent list + inspector split +│ ├── inspector/ # Detail inspector panel (state / update / view) +│ ├── torrent_list/ # Torrent list (filters, toolbar, columns, dialogs, sort, add-torrent, update, worker) +│ └── settings/ # Profile editing (state / draft / update / view) +├── auth/ # Passphrase setup and unlock flows (update / view) +├── crypto.rs # Argon2id / ChaCha20-Poly1305 wrappers ├── profile.rs # TOML storage and runtime configuration ├── theme.rs # Public Material Design 3 theme facade ├── theme/ # Private widget/style helpers backing crate::theme