From c1205e5fb0847c51397bbff6df4158c4182b9a75 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 9 May 2026 12:25:10 +0200 Subject: [PATCH] refactor: migrate to DDD layered architecture Split the monolithic app.rs (762 lines) into four DDD layers: - domain/: models, navigation enums (Tab, ModalKind), conversions, aggregates - application/: App state, LibraryState, NotificationManager, event handlers - infrastructure/: config, gRPC client, system utilities - presentation/: all render functions, widgets, views, modals Original modules (app, data, ui, config, grpc) preserved as thin re-export facades for backward compatibility. All 13 insta snapshot tests pass without modification. --- .sisyphus/plans/ddd-migration.md | 494 ++++++++++++++++ src/app.rs | 785 +------------------------- src/application/app_state.rs | 79 +++ src/application/handlers.rs | 317 +++++++++++ src/application/library_state.rs | 250 ++++++++ src/application/mod.rs | 4 + src/application/notification_state.rs | 128 +++++ src/config.rs | 27 +- src/data/mod.rs | 6 +- src/domain/aggregates.rs | 13 + src/domain/conversions.rs | 102 ++++ src/domain/mod.rs | 4 + src/domain/models.rs | 117 ++++ src/domain/navigation.rs | 50 ++ src/grpc/mod.rs | 98 +--- src/infrastructure/config.rs | 26 + src/infrastructure/grpc/mod.rs | 97 ++++ src/infrastructure/mod.rs | 3 + src/infrastructure/system.rs | 16 + src/lib.rs | 4 + src/presentation/app_renderer.rs | 263 +++++++++ src/presentation/library.rs | 404 +++++++++++++ src/presentation/mod.rs | 9 + src/presentation/modals/help.rs | 148 +++++ src/presentation/modals/mod.rs | 7 + src/presentation/modals/quit.rs | 59 ++ src/presentation/notifications.rs | 146 +++++ src/presentation/pane.rs | 111 ++++ src/presentation/progress_bar.rs | 39 ++ src/presentation/statusbar.rs | 79 +++ src/presentation/topbar.rs | 147 +++++ src/presentation/views/calendar.rs | 195 +++++++ src/presentation/views/history.rs | 91 +++ src/presentation/views/mod.rs | 13 + src/presentation/views/queue.rs | 135 +++++ src/presentation/views/settings.rs | 294 ++++++++++ src/presentation/views/wanted.rs | 123 ++++ src/ui/library.rs | 663 +--------------------- src/ui/modals/mod.rs | 14 +- src/ui/notifications.rs | 262 +-------- src/ui/pane.rs | 112 +--- src/ui/progress_bar.rs | 40 +- src/ui/statusbar.rs | 95 +--- src/ui/topbar.rs | 148 +---- src/ui/views/mod.rs | 14 +- 45 files changed, 3983 insertions(+), 2248 deletions(-) create mode 100644 .sisyphus/plans/ddd-migration.md create mode 100644 src/application/app_state.rs create mode 100644 src/application/handlers.rs create mode 100644 src/application/library_state.rs create mode 100644 src/application/mod.rs create mode 100644 src/application/notification_state.rs create mode 100644 src/domain/aggregates.rs create mode 100644 src/domain/conversions.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/models.rs create mode 100644 src/domain/navigation.rs create mode 100644 src/infrastructure/config.rs create mode 100644 src/infrastructure/grpc/mod.rs create mode 100644 src/infrastructure/mod.rs create mode 100644 src/infrastructure/system.rs create mode 100644 src/presentation/app_renderer.rs create mode 100644 src/presentation/library.rs create mode 100644 src/presentation/mod.rs create mode 100644 src/presentation/modals/help.rs create mode 100644 src/presentation/modals/mod.rs create mode 100644 src/presentation/modals/quit.rs create mode 100644 src/presentation/notifications.rs create mode 100644 src/presentation/pane.rs create mode 100644 src/presentation/progress_bar.rs create mode 100644 src/presentation/statusbar.rs create mode 100644 src/presentation/topbar.rs create mode 100644 src/presentation/views/calendar.rs create mode 100644 src/presentation/views/history.rs create mode 100644 src/presentation/views/mod.rs create mode 100644 src/presentation/views/queue.rs create mode 100644 src/presentation/views/settings.rs create mode 100644 src/presentation/views/wanted.rs diff --git a/.sisyphus/plans/ddd-migration.md b/.sisyphus/plans/ddd-migration.md new file mode 100644 index 0000000..47f364c --- /dev/null +++ b/.sisyphus/plans/ddd-migration.md @@ -0,0 +1,494 @@ +# DDD Migration Plan — ui-agregator + +## Hard Constraint + +Existing insta snapshot tests in `tests/ui_snapshots.rs` MUST pass without any changes to the test file or snapshot content. All current import paths must continue to resolve to the same types and functions producing identical rendering output. + +### Frozen Test Import Paths + +```rust +use ui_agregator::app::Tab; +use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState}; +use ui_agregator::ui::library::{LibraryFocus, LibraryState, render_library}; +use ui_agregator::ui::modals::render_help_modal; +use ui_agregator::ui::progress_bar::progress_bar; +use ui_agregator::ui::topbar::render_topbar; +``` + +## Strategy: Facade Re-exports + +Create new DDD directories (`domain/`, `application/`, `infrastructure/`, `presentation/`), move code there verbatim, then turn existing `app.rs`, `data/`, and `ui/` into thin re-export facades. Tests import from unchanged paths while actual code lives in proper DDD layers. + +--- + +## Target Architecture + +``` +src/ +├── lib.rs ← Module declarations + new layers +├── main.rs ← Entry point (unchanged) +│ +├── domain/ ← Pure business logic, zero UI deps +│ ├── mod.rs +│ ├── models.rs ← from data/models.rs +│ ├── navigation.rs ← Tab enum (from app.rs) — shared by application + presentation +│ ├── conversions.rs ← from app.rs (convert_artist, convert_album, convert_track) +│ └── aggregates.rs ← from ui/library.rs (artist_status) +│ +├── application/ ← State management, orchestration +│ ├── mod.rs +│ ├── app_state.rs ← App struct (from app.rs) — Tab moved to domain +│ ├── library_state.rs ← LibraryState + LibraryFocus (from ui/library.rs) +│ ├── notification_state.rs ← NotificationManager, NotifKind, Notification (from ui/notifications.rs) +│ └── handlers.rs ← All handle_* methods (from app.rs) +│ +├── infrastructure/ ← External concerns +│ ├── mod.rs +│ ├── config.rs ← from src/config.rs +│ ├── grpc/ ← from src/grpc/ +│ │ └── mod.rs +│ └── system.rs ← get_free_space() from statusbar.rs +│ +├── presentation/ ← UI rendering only +│ ├── mod.rs +│ ├── library.rs ← render_library + render helpers (from ui/library.rs) +│ ├── notifications.rs ← render + render_notification_item (from ui/notifications.rs) +│ ├── topbar.rs ← from ui/topbar.rs +│ ├── statusbar.rs ← render only, syscall moved out +│ ├── pane.rs ← from ui/pane.rs +│ ├── progress_bar.rs ← from ui/progress_bar.rs +│ ├── modals/ ← from ui/modals/ +│ └── views/ ← from ui/views/ +│ +├── theme.rs ← Unchanged (leaf node) +│ +├── app.rs ← FACADE: re-exports from application +├── data/ ← FACADE: re-exports from domain +│ └── mod.rs +├── config.rs ← FACADE: re-exports from infrastructure +├── grpc/ ← FACADE: re-exports from infrastructure +│ └── mod.rs +└── ui/ ← FACADE: re-exports from presentation + application + ├── mod.rs + ├── library.rs ← re-exports LibraryFocus, LibraryState, render_library + ├── modals/ + │ └── mod.rs ← re-exports from presentation/modals + ├── progress_bar.rs ← re-exports progress_bar + ├── topbar.rs ← re-exports render_topbar + ├── notifications.rs ← re-exports from application + presentation + ├── statusbar.rs ← re-exports from presentation + └── pane.rs ← re-exports from presentation +``` + +--- + +## Bounded Contexts (Lightweight) + +For a single TUI binary, use sub-domains within the domain layer: + +| Sub-domain | Entities | Value Objects | +|----------------|-----------------------------------|---------------------------| +| **Library** | Artist, Album, Track | AlbumStatus, MonitorState | +| **Acquisition**| WantedEntry, QueueEntry | — | +| **Activity** | HistoryEntry, CalendarEntry, Notification | NotifKind | + +All live in `domain/models.rs` for now — separate into sub-modules only if they grow significantly. + +--- + +## Phase 1: Infrastructure Extraction (Safest) + +**Goal:** Move external concerns. Zero test path impact. + +### Moves + +| Source | Destination | Items | +|--------|-------------|-------| +| `src/config.rs` | `src/infrastructure/config.rs` | Entire file (Config, ServerConfig) | +| `src/grpc/mod.rs` | `src/infrastructure/grpc/mod.rs` | Entire file (GrpcClient, GrpcRequest, GrpcResponse, spawn_grpc_worker) | +| `src/ui/statusbar.rs` line 12-25 | `src/infrastructure/system.rs` | `get_free_space()` only | + +### Facade Files + +```rust +// src/config.rs → facade +pub use crate::infrastructure::config::*; + +// src/grpc/mod.rs → facade +pub use crate::infrastructure::grpc::*; +``` + +### Risk: Minimal +No test paths affected. `config` and `grpc` are only used by `main.rs`. + +### Verification +`cargo build && cargo test` + +--- + +## Phase 2: Domain Layer Creation + +**Goal:** Extract pure domain models, shared navigation types, and business logic. + +### Moves + +| Source | Destination | Items | +|--------|-------------|-------| +| `src/data/models.rs` | `src/domain/models.rs` | All types: AlbumStatus, MonitorState, Artist, Album, Track, WantedEntry, QueueEntry, HistoryEntry, CalendarEntry | +| `src/app.rs` Tab enum + impls (lines 26-68) | `src/domain/navigation.rs` | `Tab` enum (see Risk Resolution 1) | +| `src/ui/modals/mod.rs` ModalKind enum (lines 7-11) | `src/domain/navigation.rs` | `ModalKind` enum (see Risk Resolution 6) | +| `src/app.rs` (convert_artist, convert_album, convert_track, parse_year, format_duration) | `src/domain/conversions.rs` | 5 functions (~90 lines) | +| `src/ui/library.rs` (artist_status fn) | `src/domain/aggregates.rs` | `artist_status()` (~12 lines) | + +### Facade Files + +```rust +// src/data/mod.rs → facade +pub use crate::domain::models::*; + +// src/app.rs must re-export Tab for test compat +pub use crate::domain::navigation::Tab; + +// src/domain/mod.rs +pub mod models; +pub mod navigation; +pub mod conversions; +pub mod aggregates; +``` + +### Risk: Low +`data::*` path preserved via re-export. `app::Tab` path preserved via re-export. Domain models are leaf nodes with zero internal deps. + +### Verification +`cargo test` — all `ui_agregator::data::*` and `ui_agregator::app::Tab` imports still resolve. + +--- + +## Phase 3: Application Layer — State Types + +**Goal:** Extract state management types and logic. + +### Moves + +| Source | Destination | Items | +|--------|-------------|-------| +| `src/app.rs` lines 70-131 | `src/application/app_state.rs` | `App` struct + Default impl + `App::new()` (Tab already moved to domain in Phase 2) | +| `src/ui/library.rs` lines 18-198 + 593-650 | `src/application/library_state.rs` | `LibraryFocus`, `LibraryState` (all fields + all methods: new, selected_artist, selected_album, move_up/down, focus_left/right, cycle_focus, cache_tracks, needs_fetch, clear_cache, etc.) | +| `src/ui/notifications.rs` lines 15-111 + 189-196 + 252-260 | `src/application/notification_state.rs` | `NotifKind`, `Notification`, `NotificationManager` (all fields + push, tick, history, active_count, history_count), `format_elapsed` | + +### Pre-requisite: Add `active()` getter to NotificationManager + +Before moving, add this public getter (see Risk Resolution 2): +```rust +pub fn active(&self) -> &[Notification] { + &self.active +} +``` + +This allows Phase 4 to convert `render()` to a free function without accessing private fields. + +### Critical Re-exports (preserve test paths) + +```rust +// src/app.rs → facade +pub use crate::domain::navigation::Tab; +pub use crate::application::app_state::App; + +// src/ui/library.rs → facade +pub use crate::application::library_state::{LibraryFocus, LibraryState}; +pub use crate::presentation::library::render_library; + +// src/ui/notifications.rs → facade +pub use crate::application::notification_state::*; +pub use crate::presentation::notifications::render_notification_item; +``` + +### Risk: Medium +Most refactoring happens here. LibraryState has private fields (tracks_cache, pending_album_id) — same crate so visibility is fine. + +### Mitigation +Move types first, verify tests, then move methods, verify again. + +### Verification +`cargo test` — snapshot tests must pass unchanged. + +--- + +## Phase 4: Presentation Layer — Render Functions + +**Goal:** Move all rendering code. + +### Moves + +| Source | Destination | Items | +|--------|-------------|-------| +| `src/ui/library.rs` lines 200-591 | `src/presentation/library.rs` | `render_library`, `render_artists_pane`, `render_detail_pane`, `render_artist_header`, `render_albums_list`, `render_tracks_list`, `status_icon`, `monitor_state_icon`, `track_icon`, `fmt_size` | +| `src/ui/notifications.rs` lines 113-250 | `src/presentation/notifications.rs` | `NotificationManager::render` (as free fn taking &NotificationManager), `render_notification_item` | +| `src/ui/topbar.rs` | `src/presentation/topbar.rs` | Entire file (render_topbar, TopbarAreas) | +| `src/ui/progress_bar.rs` | `src/presentation/progress_bar.rs` | Entire file (progress_bar fn) | +| `src/ui/pane.rs` | `src/presentation/pane.rs` | Entire file (Pane, section_divider) | +| `src/ui/statusbar.rs` | `src/presentation/statusbar.rs` | render_statusbar (with get_free_space imported from infrastructure) | +| `src/ui/modals/` | `src/presentation/modals/` | Entire directory (ModalKind, render_help_modal, render_quit_modal) | +| `src/ui/views/` | `src/presentation/views/` | Entire directory (render_calendar, render_history, render_queue, render_settings, render_wanted) | + +### Facade Files + +```rust +// src/ui/topbar.rs → facade +pub use crate::presentation::topbar::*; + +// src/ui/progress_bar.rs → facade +pub use crate::presentation::progress_bar::*; + +// src/ui/modals/mod.rs → facade +pub use crate::presentation::modals::*; + +// src/ui/pane.rs → facade +pub use crate::presentation::pane::*; + +// src/ui/statusbar.rs → facade +pub use crate::presentation::statusbar::*; +``` + +### Risk: Low-Medium +Mostly file moves. Rendering code moves verbatim — no logic changes means no snapshot changes. + +### Verification +`cargo test` — all 13 tests pass, snapshots unchanged. + +--- + +## Phase 5: Application Layer — Event Handlers + +**Goal:** Extract event handling from app.rs. + +### Moves + +| Source | Destination | Items | +|--------|-------------|-------| +| `src/app.rs` handle_escape, handle_click, handle_modal_click, handle_topbar_click, handle_notification_click, handle_main_click, handle_library_click, select_list_item, handle_scroll, scroll_library_list, handle_tick, set_error, handle_grpc_response, pending_album_fetch | `src/application/handlers.rs` | All event handler methods | +| `src/app.rs` scroll_list_state | `src/application/handlers.rs` | Free function | + +### Implementation + +Use split impl blocks (Rust allows impl blocks in multiple files within the same crate): + +```rust +// src/application/handlers.rs +use crate::application::app_state::App; + +impl App { + pub fn handle_escape(&mut self) { ... } + pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) { ... } + // ...all other handlers +} +``` + +### Risk: Medium +Must manage imports carefully. All handler methods access App fields directly. + +### Verification +`cargo test && cargo clippy`, manual TUI smoke test. + +--- + +## Phase 6: Final app.rs Cleanup + draw() Extraction + +**Goal:** Move rendering orchestration out of App, reduce app.rs to pure facade. + +### Moves + +| Source | Destination | Items | +|--------|-------------|-------| +| `src/app.rs` App::draw, render_main_content, render_notifications_dropdown, get_position | `src/presentation/app_renderer.rs` | All draw/render methods | + +### Final app.rs + +```rust +pub use crate::domain::navigation::Tab; +pub use crate::application::app_state::App; +``` + +### Final lib.rs + +```rust +pub mod domain; +pub mod application; +pub mod infrastructure; +pub mod presentation; +pub mod theme; + +// Facade modules (backward compat) +pub mod app; +pub mod config; +pub mod data; +pub mod grpc; +pub mod proto; +pub mod ui; +``` + +### Risk: Highest +Final wiring — all re-exports must be correct. + +### Verification +`cargo test`, `cargo clippy`, run the actual TUI end-to-end. + +--- + +--- + +## Risk Resolutions + +### Risk 1: Tab Circular Dependency — RESOLVED + +**Problem:** `presentation/topbar.rs` imports `Tab` from `app.rs`. If Tab lives in `application/`, then presentation depends on application, and application's `App::draw()` depends on presentation — creating a cycle. + +**Investigation:** Tab is used in exactly 3 locations: +- `src/app.rs` — definition + usage in App struct, event handlers, render routing +- `src/ui/topbar.rs` — imported as `use crate::app::Tab`, used to build tab list and compare active tab +- `tests/ui_snapshots.rs` — imported as `use ui_agregator::app::Tab`, used in test data + +Tab has no business logic dependencies. It's a pure enum with display helpers (`label()`, `index()`). No method on Tab calls into application or presentation code. + +**Solution:** Move Tab to `domain/navigation.rs`. Both application and presentation import from domain — dependency flows downward only: + +``` +domain/navigation.rs (defines Tab) + ↑ ↑ +application/app_state.rs presentation/topbar.rs + (App.tab field) (renders tabs) +``` + +Re-export chain for test compatibility: +```rust +// src/domain/navigation.rs — canonical location +pub enum Tab { Library, Wanted, Queue, History, Calendar, Settings } + +// src/app.rs — facade re-export +pub use crate::domain::navigation::Tab; + +// src/presentation/topbar.rs — imports from domain +use crate::domain::navigation::Tab; +``` + +Test path `ui_agregator::app::Tab` continues to work via the facade. + +--- + +### Risk 2: NotificationManager::render() Split — RESOLVED + +**Problem:** `NotificationManager::render()` is an `impl` method. When the type moves to `application/` and render moves to `presentation/`, the presentation layer can't add impl blocks to application types without creating a dependency cycle. + +**Investigation:** `render()` accesses exactly ONE private field: `self.active` (the Vec of active notifications). All `Notification` fields are already public. `render_notification_item()` is already a free function — good precedent. + +From app.rs, `NotificationManager` is used via these public methods only: +- `.history_count()` — already pub +- `.history()` — already pub, returns `&[Notification]` +- `.render(frame, area)` — the method to split +- `.tick()` — already pub +- `.push(...)` — already pub + +**Solution:** Add one pub getter, convert render to free function: + +```rust +// Step 1: Add getter to NotificationManager (in Phase 3, before the split) +pub fn active(&self) -> &[Notification] { + &self.active +} + +// Step 2: Convert render() to free function (in Phase 4) +// src/presentation/notifications.rs +pub fn render_notifications(frame: &mut Frame, area: Rect, active: &[Notification]) { + // Identical logic, `self.active` becomes `active` parameter +} + +// Step 3: Update call site in app.rs draw() (Phase 6) +// Before: self.notifications.render(frame, area); +// After: render_notifications(frame, area, self.notifications.active()); +``` + +No snapshot impact — render logic is identical, just parameterized differently. + +--- + +### Risk 3: Private Fields Across Layers — RESOLVED + +**Problem:** `LibraryState` has private fields (`tracks_cache`, `pending_album_id`). When type moves to `application/` and render code moves to `presentation/`, can presentation still work? + +**Investigation:** Verified that presentation/library.rs render functions access LibraryState through these paths only: +- `state.artists` — **pub** field +- `state.tracks` — **pub** field +- `state.focus` — **pub** field +- `state.artist_state` / `state.album_state` / `state.track_state` — **pub** fields (ListState for stateful widgets) +- `state.selected_artist()` — **pub** method +- `state.selected_album()` — **pub** method + +The private fields (`tracks_cache`, `pending_album_id`) are only accessed by `LibraryState`'s own methods (`cache_tracks`, `needs_fetch`, `load_tracks_from_cache`, `clear_cache`) — all of which stay with the type in `application/library_state.rs`. + +**Conclusion:** No new getters needed. The existing public API is sufficient for the presentation layer. + +--- + +### Risk 4: impl Block Splitting — RESOLVED + +**Problem:** After migration, `App` has impl blocks in 3 files: `application/app_state.rs`, `application/handlers.rs`, `presentation/app_renderer.rs`. Will Rust allow this? + +**Investigation:** Rust allows multiple impl blocks for a type anywhere within the same crate. The only requirement is that the type is accessible via `use`. Since all three files are in the same crate, this works: + +```rust +// application/app_state.rs — struct definition + new() +pub struct App { ... } +impl App { pub fn new() -> Self { ... } } + +// application/handlers.rs — event handlers +use crate::application::app_state::App; +impl App { pub fn handle_click(&mut self, ...) { ... } } + +// presentation/app_renderer.rs — draw methods +use crate::application::app_state::App; +impl App { pub fn draw(&mut self, frame: &mut Frame) { ... } } +``` + +**Caveat:** `presentation/app_renderer.rs` adds impl blocks to an `application` type. This means presentation depends on application — which is architecturally correct (presentation calls down to application, not up). But it does mean `draw()` can access private fields of `App`, which is acceptable since it needs `self.tab`, `self.modal`, `self.queue`, etc. + +**Conclusion:** No issue. This is standard Rust practice for organizing large impl blocks. + +--- + +### Risk 5: `use crate::` Path Updates — RESOLVED + +**Problem:** Every moved file has `use crate::data::*`, `use crate::ui::*` etc. These must be updated. + +**Solution:** Two options: + +**Option A (Recommended): Import from facade modules.** Moved files keep importing `use crate::data::*`, `use crate::ui::pane::*` etc. Since facades re-export everything, these paths still work. No import changes needed in moved files. + +**Option B: Import from canonical locations.** Update all imports to `use crate::domain::*`, `use crate::presentation::pane::*` etc. Cleaner but more churn. + +**Recommendation:** Use Option A during migration (zero import changes in moved files), then optionally do a cleanup pass later with Option B. This eliminates an entire class of errors during the migration. + +--- + +### Risk 6: ModalKind Placement — RESOLVED + +**Problem:** `ModalKind` enum is defined in `ui/modals/mod.rs` and used in `app.rs` (matching on `self.modal`). After migration, ModalKind moves to `presentation/modals/` but App (application layer) matches on it — application depends on presentation. + +**Investigation:** `ModalKind` is used in: +- `src/ui/modals/mod.rs` — definition +- `src/app.rs` line 180-184 — `match modal { ModalKind::Help => ..., ModalKind::Quit => ... }` + +**Solution:** Move `ModalKind` to `domain/navigation.rs` alongside `Tab` — both are simple navigation/state enums shared across layers: + +```rust +// src/domain/navigation.rs +pub enum Tab { Library, Wanted, Queue, History, Calendar, Settings } +pub enum ModalKind { Help, Quit } +``` + +Re-export from `ui/modals/mod.rs` facade for backward compatibility: +```rust +pub use crate::domain::navigation::ModalKind; +pub use crate::presentation::modals::{render_help_modal, render_quit_modal}; +``` + +Application layer imports `ModalKind` from domain. Presentation layer imports it from domain. No cross-layer dependency. diff --git a/src/app.rs b/src/app.rs index 944925f..07d8c76 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,783 +1,2 @@ -#![allow(dead_code)] - -use crossterm::event::MouseButton; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - widgets::{ListState, Paragraph}, -}; - -use crate::data::{ - Album, AlbumStatus, Artist, CalendarEntry, HistoryEntry, MonitorState, QueueEntry, Track, - WantedEntry, -}; -use crate::grpc::{AlbumDetail, ArtistSummary, GrpcResponse, TrackDetail}; -use crate::theme; -use crate::ui::library::{LibraryFocus, LibraryState, render_library}; -use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal}; -use crate::ui::notifications::{NotifKind, NotificationManager}; -use crate::ui::statusbar::render_statusbar; -use crate::ui::topbar::render_topbar; -use crate::ui::views::{ - render_calendar, render_history, render_queue, render_settings, render_wanted, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Tab { - #[default] - Library, - Wanted, - Queue, - History, - Calendar, - Settings, -} - -impl Tab { - pub const ALL: [Tab; 6] = [ - Tab::Library, - Tab::Wanted, - Tab::Queue, - Tab::History, - Tab::Calendar, - Tab::Settings, - ]; - - pub fn index(&self) -> usize { - match self { - Tab::Library => 0, - Tab::Wanted => 1, - Tab::Queue => 2, - Tab::History => 3, - Tab::Calendar => 4, - Tab::Settings => 5, - } - } - - pub fn label(&self) -> &'static str { - match self { - Tab::Library => "Library", - Tab::Wanted => "Wanted", - Tab::Queue => "Queue", - Tab::History => "History", - Tab::Calendar => "Calendar", - Tab::Settings => "Settings", - } - } -} - -pub struct App { - pub running: bool, - pub tab: Tab, - pub size: Rect, - pub library: LibraryState, - pub modal: Option, - pub wanted: Vec, - pub wanted_state: ListState, - pub queue: Vec, - pub queue_state: ListState, - pub history: Vec, - pub history_state: ListState, - pub calendar: Vec, - pub notifications: NotificationManager, - pub notifications_open: bool, - pub notifications_scroll: usize, - pub notifications_expanded: Option, - topbar_area: Rect, - main_area: Rect, - statusbar_area: Rect, - tab_areas: Vec, - notifications_btn_area: Rect, - notifications_dropdown_area: Rect, -} - -impl Default for App { - fn default() -> Self { - let artists: Vec = Vec::new(); - let wanted: Vec = Vec::new(); - let queue: Vec = Vec::new(); - let history: Vec = Vec::new(); - let calendar: Vec = Vec::new(); - - let wanted_state = ListState::default(); - let queue_state = ListState::default(); - let history_state = ListState::default(); - - Self { - running: true, - tab: Tab::Library, - size: Rect::default(), - library: LibraryState::new(artists), - modal: None, - wanted, - wanted_state, - queue, - queue_state, - history, - history_state, - calendar, - notifications: NotificationManager::new(), - notifications_open: false, - notifications_scroll: 0, - notifications_expanded: None, - topbar_area: Rect::default(), - main_area: Rect::default(), - statusbar_area: Rect::default(), - tab_areas: Vec::new(), - notifications_btn_area: Rect::default(), - notifications_dropdown_area: Rect::default(), - } - } -} - -impl App { - pub fn new() -> Self { - Self::default() - } - - pub fn draw(&mut self, frame: &mut Frame) { - self.size = frame.area(); - let area = frame.area(); - - frame.render_widget( - Paragraph::new("").style(Style::default().bg(theme::BG0)), - area, - ); - - let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(area); - - self.topbar_area = chunks[0]; - self.main_area = chunks[1]; - self.statusbar_area = chunks[2]; - - let queue_count = self.queue.len(); - let wanted_count = self.wanted.len(); - let notification_count = self.notifications.history_count(); - - let topbar = render_topbar( - frame, - chunks[0], - self.tab, - queue_count, - wanted_count, - notification_count, - self.notifications_open, - ); - self.tab_areas = topbar.tabs; - self.notifications_btn_area = topbar.notifications; - - self.render_main_content(frame, chunks[1]); - - let position = self.get_position(); - render_statusbar(frame, chunks[2], position, queue_count, wanted_count); - - if let Some(modal) = &self.modal { - match modal { - ModalKind::Help => render_help_modal(frame, area), - ModalKind::Quit => render_quit_modal(frame, area, self.queue.len()), - } - } - - self.notifications.render(frame, area); - - if self.notifications_open { - self.render_notifications_dropdown(frame); - } - } - - fn render_notifications_dropdown(&mut self, frame: &mut Frame) { - use crate::ui::notifications::render_notification_item; - use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; - - let history = self.notifications.history(); - if history.is_empty() { - return; - } - - let dropdown_width = 52u16; - let max_items = 8usize; - let item_height = 3u16; - - if let Some(expanded_id) = self.notifications_expanded { - if let Some(notif) = history.iter().find(|n| n.id == expanded_id) { - let detail_lines = notif - .detail - .as_ref() - .map(|d| d.lines().count()) - .unwrap_or(1); - let dropdown_height = (6 + detail_lines as u16).min(20); - - let x = self - .notifications_btn_area - .x - .saturating_sub(dropdown_width - self.notifications_btn_area.width); - let y = self.topbar_area.y + 1; - - let dropdown_area = Rect::new( - x.max(0), - y, - dropdown_width.min(self.size.width), - dropdown_height.min(self.size.height - y), - ); - self.notifications_dropdown_area = dropdown_area; - - frame.render_widget(Clear, dropdown_area); - - let border_color = notif.kind.color(); - let block = Block::default() - .borders(Borders::ALL) - .border_style(ratatui::style::Style::default().fg(border_color)) - .style(ratatui::style::Style::default().bg(theme::BG1)); - let inner = block.inner(dropdown_area); - frame.render_widget(block, dropdown_area); - - use ratatui::style::{Modifier, Style}; - use ratatui::text::{Line, Span}; - - let elapsed = notif.created_at.elapsed().as_secs(); - let time_str = if elapsed < 60 { - format!("{}s ago", elapsed) - } else if elapsed < 3600 { - format!("{}m ago", elapsed / 60) - } else { - format!("{}h ago", elapsed / 3600) - }; - - let mut lines = vec![ - Line::from(vec![ - Span::styled("← ", Style::default().fg(theme::GRAY)), - Span::styled(¬if.icon, Style::default().fg(border_color)), - Span::raw(" "), - Span::styled( - notif.kind.label(), - Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(time_str, Style::default().fg(theme::GRAY)), - ]), - Line::from(""), - Line::from(Span::styled(¬if.title, Style::default().fg(theme::FG1))), - ]; - - if let Some(detail) = ¬if.detail { - lines.push(Line::from("")); - for line in detail.lines() { - lines.push(Line::from(Span::styled( - line, - Style::default().fg(theme::FG2), - ))); - } - } - - let para = Paragraph::new(lines).wrap(Wrap { trim: false }); - frame.render_widget(para, inner); - return; - } - } - - let visible_count = history.len().min(max_items); - let dropdown_height = (visible_count as u16 * item_height) + 2; - - let x = self - .notifications_btn_area - .x - .saturating_sub(dropdown_width - self.notifications_btn_area.width); - let y = self.topbar_area.y + 1; - - let dropdown_area = Rect::new( - x.max(0), - y, - dropdown_width.min(self.size.width), - dropdown_height.min(self.size.height - y), - ); - self.notifications_dropdown_area = dropdown_area; - - frame.render_widget(Clear, dropdown_area); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(ratatui::style::Style::default().fg(theme::GRAY)) - .style(ratatui::style::Style::default().bg(theme::BG1)); - let inner = block.inner(dropdown_area); - frame.render_widget(block, dropdown_area); - - let start_idx = self.notifications_scroll; - let end_idx = (start_idx + max_items).min(history.len()); - - for (i, notif) in history - .iter() - .rev() - .skip(start_idx) - .take(end_idx - start_idx) - .enumerate() - { - let item_y = inner.y + (i as u16 * item_height); - let item_area = Rect::new(inner.x, item_y, inner.width, item_height); - render_notification_item(frame, item_area, notif); - } - } - - fn render_main_content(&mut self, frame: &mut Frame, area: Rect) { - match self.tab { - Tab::Library => { - render_library(frame, area, &mut self.library); - } - Tab::Wanted => { - render_wanted(frame, area, &self.wanted, &mut self.wanted_state); - } - Tab::Queue => { - render_queue(frame, area, &self.queue, &mut self.queue_state); - } - Tab::History => { - render_history(frame, area, &self.history, &mut self.history_state); - } - Tab::Calendar => { - render_calendar(frame, area, &self.calendar); - } - Tab::Settings => { - render_settings(frame, area); - } - } - } - - fn get_position(&self) -> Option<(usize, usize)> { - match self.tab { - Tab::Library => { - let idx = self.library.selected_artist_index().unwrap_or(0) + 1; - let total = self.library.artist_count(); - if total > 0 { Some((idx, total)) } else { None } - } - Tab::Wanted => { - if self.wanted.is_empty() { - return None; - } - let idx = self.wanted_state.selected().unwrap_or(0) + 1; - Some((idx, self.wanted.len())) - } - Tab::Queue => { - if self.queue.is_empty() { - return None; - } - let idx = self.queue_state.selected().unwrap_or(0) + 1; - Some((idx, self.queue.len())) - } - Tab::History => { - if self.history.is_empty() { - return None; - } - let idx = self.history_state.selected().unwrap_or(0) + 1; - Some((idx, self.history.len())) - } - Tab::Calendar | Tab::Settings => None, - } - } - - pub fn handle_escape(&mut self) { - if self.notifications_open { - self.notifications_open = false; - return; - } - if self.modal.is_some() { - self.modal = None; - } - } - - pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) { - if button != MouseButton::Left { - return; - } - - if self.modal.is_some() { - self.handle_modal_click(x, y); - return; - } - - if self.notifications_open { - let in_dropdown = x >= self.notifications_dropdown_area.x - && x < self.notifications_dropdown_area.x + self.notifications_dropdown_area.width - && y >= self.notifications_dropdown_area.y - && y < self.notifications_dropdown_area.y + self.notifications_dropdown_area.height; - let in_btn = x >= self.notifications_btn_area.x - && x < self.notifications_btn_area.x + self.notifications_btn_area.width - && y == self.notifications_btn_area.y; - - if in_btn { - self.notifications_open = false; - self.notifications_expanded = None; - } else if in_dropdown { - self.handle_notification_click(x, y); - } else { - self.notifications_open = false; - self.notifications_expanded = None; - } - return; - } - - if y == self.topbar_area.y { - self.handle_topbar_click(x); - return; - } - - if y >= self.main_area.y && y < self.main_area.y + self.main_area.height { - self.handle_main_click(x, y); - } - } - - fn handle_modal_click(&mut self, _x: u16, _y: u16) { - self.modal = None; - } - - fn handle_topbar_click(&mut self, x: u16) { - if x >= self.notifications_btn_area.x - && x < self.notifications_btn_area.x + self.notifications_btn_area.width - { - self.notifications_open = !self.notifications_open; - self.notifications_scroll = 0; - self.notifications_expanded = None; - return; - } - - for (i, area) in self.tab_areas.iter().enumerate() { - if x >= area.x && x < area.x + area.width { - if let Some(tab) = Tab::ALL.get(i) { - self.tab = *tab; - } - return; - } - } - } - - fn handle_notification_click(&mut self, _x: u16, y: u16) { - if self.notifications_expanded.is_some() { - self.notifications_expanded = None; - return; - } - - let inner_y = self.notifications_dropdown_area.y + 1; - let rel_y = y.saturating_sub(inner_y) as usize; - let item_height = 3usize; - let clicked_idx = rel_y / item_height; - - let history = self.notifications.history(); - let actual_idx = self.notifications_scroll + clicked_idx; - - if let Some(notif) = history.iter().rev().nth(actual_idx) { - self.notifications_expanded = Some(notif.id); - } - } - - fn handle_main_click(&mut self, x: u16, y: u16) { - let rel_y = y.saturating_sub(self.main_area.y) as usize; - - match self.tab { - Tab::Library => { - self.handle_library_click(x, rel_y); - } - Tab::Wanted => { - self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y); - if rel_y < self.wanted.len() { - self.wanted_state.select(Some(rel_y)); - } - } - Tab::Queue => { - if rel_y < self.queue.len() { - self.queue_state.select(Some(rel_y)); - } - } - Tab::History => { - if rel_y < self.history.len() { - self.history_state.select(Some(rel_y)); - } - } - Tab::Calendar | Tab::Settings => {} - } - } - - fn handle_library_click(&mut self, x: u16, rel_y: usize) { - const ARTISTS_PANE_WIDTH: u16 = 32; - const BORDER_TOP: usize = 1; - const HEADER_HEIGHT: usize = 6; - const DIVIDER_HEIGHT: usize = 1; - const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT; - - if x < ARTISTS_PANE_WIDTH { - if rel_y > 0 && rel_y <= self.library.artists.len() { - self.library.artist_state.select(Some(rel_y - 1)); - self.library.album_state.select(Some(0)); - self.library.track_state.select(Some(0)); - self.library.focus = LibraryFocus::Artists; - } - } else if rel_y >= ALBUMS_START_ROW { - let album_row = rel_y - ALBUMS_START_ROW; - let content_height = self.main_area.height.saturating_sub(10) as usize; - let albums_section_height = (content_height * 40) / 100; - let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT; - - if rel_y < tracks_start_row { - if let Some(artist) = self.library.selected_artist() - && album_row < artist.albums.len() - { - self.library.album_state.select(Some(album_row)); - self.library.track_state.select(Some(0)); - self.library.focus = LibraryFocus::Albums; - } - } else { - let track_row = rel_y - tracks_start_row; - if let Some(album) = self.library.selected_album() - && track_row < album.total as usize - { - self.library.track_state.select(Some(track_row)); - self.library.focus = LibraryFocus::Tracks; - } - } - } - } - - fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {} - - pub fn handle_scroll(&mut self, delta: i32) { - if self.modal.is_some() { - return; - } - - if self.notifications_open { - let max_scroll = self.notifications.history_count().saturating_sub(8); - if delta > 0 { - self.notifications_scroll = (self.notifications_scroll + 1).min(max_scroll); - } else { - self.notifications_scroll = self.notifications_scroll.saturating_sub(1); - } - return; - } - - match self.tab { - Tab::Library => { - self.scroll_library_list(delta); - } - Tab::Wanted => { - let len = self.wanted.len(); - scroll_list_state(&mut self.wanted_state, len, delta); - } - Tab::Queue => { - let len = self.queue.len(); - scroll_list_state(&mut self.queue_state, len, delta); - } - Tab::History => { - let len = self.history.len(); - scroll_list_state(&mut self.history_state, len, delta); - } - Tab::Calendar | Tab::Settings => {} - } - } - - fn scroll_library_list(&mut self, delta: i32) { - match self.library.focus { - LibraryFocus::Artists => { - let len = self.library.artists.len(); - if len == 0 { - return; - } - let current = self.library.artist_state.selected().unwrap_or(0); - let new_idx = if delta > 0 { - (current + 1).min(len - 1) - } else { - current.saturating_sub(1) - }; - if new_idx != current { - self.library.artist_state.select(Some(new_idx)); - self.library.album_state.select(Some(0)); - self.library.track_state.select(Some(0)); - } - } - LibraryFocus::Albums => { - if let Some(artist) = self.library.selected_artist() { - let len = artist.albums.len(); - if len == 0 { - return; - } - let current = self.library.album_state.selected().unwrap_or(0); - let new_idx = if delta > 0 { - (current + 1).min(len - 1) - } else { - current.saturating_sub(1) - }; - if new_idx != current { - self.library.album_state.select(Some(new_idx)); - self.library.track_state.select(Some(0)); - } - } - } - LibraryFocus::Tracks => { - if let Some(album) = self.library.selected_album() { - let len = album.total as usize; - if len == 0 { - return; - } - let current = self.library.track_state.selected().unwrap_or(0); - let new_idx = if delta > 0 { - (current + 1).min(len - 1) - } else { - current.saturating_sub(1) - }; - self.library.track_state.select(Some(new_idx)); - } - } - } - } - - pub fn handle_tick(&mut self) { - self.notifications.tick(); - } - - pub fn set_error(&mut self, msg: String) { - self.notifications - .push("Error", Some(msg), NotifKind::Error, "✗"); - } - - pub fn handle_grpc_response(&mut self, response: GrpcResponse) { - match response { - GrpcResponse::Artists(artists) => { - let converted: Vec = artists.into_iter().map(convert_artist).collect(); - let count = converted.len(); - self.library = LibraryState::new(converted); - self.notifications.push( - "Library loaded", - Some(format!("{} artists", count)), - NotifKind::Success, - "✓", - ); - } - GrpcResponse::Album { album, tracks } => { - let converted: Vec = tracks.into_iter().map(convert_track).collect(); - self.library.cache_tracks(album.id, converted); - } - GrpcResponse::Error(msg) => { - self.set_error(msg); - } - } - } - - pub fn pending_album_fetch(&mut self) -> Option { - self.library.needs_fetch() - } -} - -fn convert_artist(summary: ArtistSummary) -> Artist { - let albums: Vec = summary.albums.into_iter().map(convert_album).collect(); - Artist { - id: summary.id, - name: summary.name, - country: summary.country, - genres: summary.genres, - monitor_state: MonitorState::from_proto(summary.monitor_state), - path: String::new(), - quality: "FLAC".to_string(), - size_gb: 0.0, - albums, - } -} - -fn convert_album(detail: AlbumDetail) -> Album { - let year = parse_year(&detail.release_date); - let monitor_state = MonitorState::from_proto(detail.monitor_state); - let monitored = monitor_state.is_monitored(); - - let (have, status, quality) = if let Some(download) = detail.download { - let have = if download.state == "downloaded" { - detail.total_tracks as u16 - } else { - 0 - }; - let status = match download.state.as_str() { - "downloaded" => AlbumStatus::Complete, - "downloading" => AlbumStatus::Partial, - _ => { - if monitored { - AlbumStatus::Wanted - } else { - AlbumStatus::Unmonitored - } - } - }; - let quality = if download.quality.is_empty() { - "—".to_string() - } else { - download.quality - }; - (have, status, quality) - } else { - let status = if monitored { - AlbumStatus::Wanted - } else { - AlbumStatus::Unmonitored - }; - (0, status, "—".to_string()) - }; - - Album { - id: detail.id, - title: detail.title, - year, - album_type: detail.album_type, - monitored, - total: detail.total_tracks as u16, - have, - quality, - status, - } -} - -fn parse_year(date_str: &str) -> u16 { - date_str - .split('-') - .next() - .and_then(|y| y.parse().ok()) - .unwrap_or(0) -} - -fn convert_track(detail: TrackDetail) -> Track { - let have = detail.file.is_some(); - let quality = detail - .file - .as_ref() - .map(|f| f.format.clone()) - .unwrap_or_else(|| "—".to_string()); - let duration = format_duration(detail.duration_ms); - - Track { - id: detail.id, - number: detail.track_number as u16, - disc: detail.disc_number as u16, - title: detail.title, - duration, - have, - quality, - } -} - -fn format_duration(ms: i32) -> String { - let total_secs = ms / 1000; - let mins = total_secs / 60; - let secs = total_secs % 60; - format!("{}:{:02}", mins, secs) -} - -fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) { - if len == 0 { - return; - } - - let current = state.selected().unwrap_or(0); - let new_idx = if delta > 0 { - (current + 1).min(len - 1) - } else { - current.saturating_sub(1) - }; - state.select(Some(new_idx)); -} +pub use crate::application::app_state::App; +pub use crate::domain::navigation::Tab; diff --git a/src/application/app_state.rs b/src/application/app_state.rs new file mode 100644 index 0000000..44b551c --- /dev/null +++ b/src/application/app_state.rs @@ -0,0 +1,79 @@ +#![allow(dead_code)] + +use ratatui::layout::Rect; +use ratatui::widgets::ListState; + +use crate::application::library_state::LibraryState; +use crate::application::notification_state::NotificationManager; +use crate::data::{Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry}; +use crate::domain::navigation::{ModalKind, Tab}; + +pub struct App { + pub running: bool, + pub tab: Tab, + pub size: Rect, + pub library: LibraryState, + pub modal: Option, + pub wanted: Vec, + pub wanted_state: ListState, + pub queue: Vec, + pub queue_state: ListState, + pub history: Vec, + pub history_state: ListState, + pub calendar: Vec, + pub notifications: NotificationManager, + pub notifications_open: bool, + pub notifications_scroll: usize, + pub notifications_expanded: Option, + pub(crate) topbar_area: Rect, + pub(crate) main_area: Rect, + pub(crate) statusbar_area: Rect, + pub(crate) tab_areas: Vec, + pub(crate) notifications_btn_area: Rect, + pub(crate) notifications_dropdown_area: Rect, +} + +impl Default for App { + fn default() -> Self { + let artists: Vec = Vec::new(); + let wanted: Vec = Vec::new(); + let queue: Vec = Vec::new(); + let history: Vec = Vec::new(); + let calendar: Vec = Vec::new(); + + let wanted_state = ListState::default(); + let queue_state = ListState::default(); + let history_state = ListState::default(); + + Self { + running: true, + tab: Tab::Library, + size: Rect::default(), + library: LibraryState::new(artists), + modal: None, + wanted, + wanted_state, + queue, + queue_state, + history, + history_state, + calendar, + notifications: NotificationManager::new(), + notifications_open: false, + notifications_scroll: 0, + notifications_expanded: None, + topbar_area: Rect::default(), + main_area: Rect::default(), + statusbar_area: Rect::default(), + tab_areas: Vec::new(), + notifications_btn_area: Rect::default(), + notifications_dropdown_area: Rect::default(), + } + } +} + +impl App { + pub fn new() -> Self { + Self::default() + } +} diff --git a/src/application/handlers.rs b/src/application/handlers.rs new file mode 100644 index 0000000..43ade05 --- /dev/null +++ b/src/application/handlers.rs @@ -0,0 +1,317 @@ +#![allow(dead_code)] + +use crossterm::event::MouseButton; +use ratatui::widgets::ListState; + +use crate::application::app_state::App; +use crate::data::{Artist, Track}; +use crate::domain::conversions::{convert_artist, convert_track}; +use crate::domain::navigation::Tab; +use crate::grpc::GrpcResponse; +use crate::ui::library::{LibraryFocus, LibraryState}; +use crate::ui::notifications::NotifKind; + +impl App { + pub fn handle_escape(&mut self) { + if self.notifications_open { + self.notifications_open = false; + return; + } + if self.modal.is_some() { + self.modal = None; + } + } + + pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) { + if button != MouseButton::Left { + return; + } + + if self.modal.is_some() { + self.handle_modal_click(x, y); + return; + } + + if self.notifications_open { + let in_dropdown = x >= self.notifications_dropdown_area.x + && x < self.notifications_dropdown_area.x + self.notifications_dropdown_area.width + && y >= self.notifications_dropdown_area.y + && y < self.notifications_dropdown_area.y + self.notifications_dropdown_area.height; + let in_btn = x >= self.notifications_btn_area.x + && x < self.notifications_btn_area.x + self.notifications_btn_area.width + && y == self.notifications_btn_area.y; + + if in_btn { + self.notifications_open = false; + self.notifications_expanded = None; + } else if in_dropdown { + self.handle_notification_click(x, y); + } else { + self.notifications_open = false; + self.notifications_expanded = None; + } + return; + } + + if y == self.topbar_area.y { + self.handle_topbar_click(x); + return; + } + + if y >= self.main_area.y && y < self.main_area.y + self.main_area.height { + self.handle_main_click(x, y); + } + } + + fn handle_modal_click(&mut self, _x: u16, _y: u16) { + self.modal = None; + } + + fn handle_topbar_click(&mut self, x: u16) { + if x >= self.notifications_btn_area.x + && x < self.notifications_btn_area.x + self.notifications_btn_area.width + { + self.notifications_open = !self.notifications_open; + self.notifications_scroll = 0; + self.notifications_expanded = None; + return; + } + + for (i, area) in self.tab_areas.iter().enumerate() { + if x >= area.x && x < area.x + area.width { + if let Some(tab) = Tab::ALL.get(i) { + self.tab = *tab; + } + return; + } + } + } + + fn handle_notification_click(&mut self, _x: u16, y: u16) { + if self.notifications_expanded.is_some() { + self.notifications_expanded = None; + return; + } + + let inner_y = self.notifications_dropdown_area.y + 1; + let rel_y = y.saturating_sub(inner_y) as usize; + let item_height = 3usize; + let clicked_idx = rel_y / item_height; + + let history = self.notifications.history(); + let actual_idx = self.notifications_scroll + clicked_idx; + + if let Some(notif) = history.iter().rev().nth(actual_idx) { + self.notifications_expanded = Some(notif.id); + } + } + + fn handle_main_click(&mut self, x: u16, y: u16) { + let rel_y = y.saturating_sub(self.main_area.y) as usize; + + match self.tab { + Tab::Library => { + self.handle_library_click(x, rel_y); + } + Tab::Wanted => { + self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y); + if rel_y < self.wanted.len() { + self.wanted_state.select(Some(rel_y)); + } + } + Tab::Queue => { + if rel_y < self.queue.len() { + self.queue_state.select(Some(rel_y)); + } + } + Tab::History => { + if rel_y < self.history.len() { + self.history_state.select(Some(rel_y)); + } + } + Tab::Calendar | Tab::Settings => {} + } + } + + fn handle_library_click(&mut self, x: u16, rel_y: usize) { + const ARTISTS_PANE_WIDTH: u16 = 32; + const BORDER_TOP: usize = 1; + const HEADER_HEIGHT: usize = 6; + const DIVIDER_HEIGHT: usize = 1; + const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT; + + if x < ARTISTS_PANE_WIDTH { + if rel_y > 0 && rel_y <= self.library.artists.len() { + self.library.artist_state.select(Some(rel_y - 1)); + self.library.album_state.select(Some(0)); + self.library.track_state.select(Some(0)); + self.library.focus = LibraryFocus::Artists; + } + } else if rel_y >= ALBUMS_START_ROW { + let album_row = rel_y - ALBUMS_START_ROW; + let content_height = self.main_area.height.saturating_sub(10) as usize; + let albums_section_height = (content_height * 40) / 100; + let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT; + + if rel_y < tracks_start_row { + if let Some(artist) = self.library.selected_artist() + && album_row < artist.albums.len() + { + self.library.album_state.select(Some(album_row)); + self.library.track_state.select(Some(0)); + self.library.focus = LibraryFocus::Albums; + } + } else { + let track_row = rel_y - tracks_start_row; + if let Some(album) = self.library.selected_album() + && track_row < album.total as usize + { + self.library.track_state.select(Some(track_row)); + self.library.focus = LibraryFocus::Tracks; + } + } + } + } + + fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {} + + pub fn handle_scroll(&mut self, delta: i32) { + if self.modal.is_some() { + return; + } + + if self.notifications_open { + let max_scroll = self.notifications.history_count().saturating_sub(8); + if delta > 0 { + self.notifications_scroll = (self.notifications_scroll + 1).min(max_scroll); + } else { + self.notifications_scroll = self.notifications_scroll.saturating_sub(1); + } + return; + } + + match self.tab { + Tab::Library => { + self.scroll_library_list(delta); + } + Tab::Wanted => { + let len = self.wanted.len(); + scroll_list_state(&mut self.wanted_state, len, delta); + } + Tab::Queue => { + let len = self.queue.len(); + scroll_list_state(&mut self.queue_state, len, delta); + } + Tab::History => { + let len = self.history.len(); + scroll_list_state(&mut self.history_state, len, delta); + } + Tab::Calendar | Tab::Settings => {} + } + } + + fn scroll_library_list(&mut self, delta: i32) { + match self.library.focus { + LibraryFocus::Artists => { + let len = self.library.artists.len(); + if len == 0 { + return; + } + let current = self.library.artist_state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + if new_idx != current { + self.library.artist_state.select(Some(new_idx)); + self.library.album_state.select(Some(0)); + self.library.track_state.select(Some(0)); + } + } + LibraryFocus::Albums => { + if let Some(artist) = self.library.selected_artist() { + let len = artist.albums.len(); + if len == 0 { + return; + } + let current = self.library.album_state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + if new_idx != current { + self.library.album_state.select(Some(new_idx)); + self.library.track_state.select(Some(0)); + } + } + } + LibraryFocus::Tracks => { + if let Some(album) = self.library.selected_album() { + let len = album.total as usize; + if len == 0 { + return; + } + let current = self.library.track_state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + self.library.track_state.select(Some(new_idx)); + } + } + } + } + + pub fn handle_tick(&mut self) { + self.notifications.tick(); + } + + pub fn set_error(&mut self, msg: String) { + self.notifications + .push("Error", Some(msg), NotifKind::Error, "✗"); + } + + pub fn handle_grpc_response(&mut self, response: GrpcResponse) { + match response { + GrpcResponse::Artists(artists) => { + let converted: Vec = artists.into_iter().map(convert_artist).collect(); + let count = converted.len(); + self.library = LibraryState::new(converted); + self.notifications.push( + "Library loaded", + Some(format!("{} artists", count)), + NotifKind::Success, + "✓", + ); + } + GrpcResponse::Album { album, tracks } => { + let converted: Vec = tracks.into_iter().map(convert_track).collect(); + self.library.cache_tracks(album.id, converted); + } + GrpcResponse::Error(msg) => { + self.set_error(msg); + } + } + } + + pub fn pending_album_fetch(&mut self) -> Option { + self.library.needs_fetch() + } +} + +fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) { + if len == 0 { + return; + } + + let current = state.selected().unwrap_or(0); + let new_idx = if delta > 0 { + (current + 1).min(len - 1) + } else { + current.saturating_sub(1) + }; + state.select(Some(new_idx)); +} diff --git a/src/application/library_state.rs b/src/application/library_state.rs new file mode 100644 index 0000000..3737973 --- /dev/null +++ b/src/application/library_state.rs @@ -0,0 +1,250 @@ +#![allow(dead_code)] + +use std::collections::HashMap; + +use ratatui::widgets::ListState; + +use crate::data::{Album, Artist, Track}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LibraryFocus { + #[default] + Artists, + Albums, + Tracks, +} + +pub struct LibraryState { + pub artists: Vec, + pub tracks: Vec, + pub focus: LibraryFocus, + pub artist_state: ListState, + pub album_state: ListState, + pub track_state: ListState, + tracks_cache: HashMap>, + pending_album_id: Option, +} + +impl LibraryState { + pub fn new(artists: Vec) -> Self { + let mut artist_state = ListState::default(); + let mut album_state = ListState::default(); + let mut track_state = ListState::default(); + + if !artists.is_empty() { + artist_state.select(Some(0)); + if !artists[0].albums.is_empty() { + album_state.select(Some(0)); + track_state.select(Some(0)); + } + } + + Self { + artists, + tracks: Vec::new(), + focus: LibraryFocus::Artists, + artist_state, + album_state, + track_state, + tracks_cache: HashMap::new(), + pending_album_id: None, + } + } + + pub fn selected_artist(&self) -> Option<&Artist> { + self.artist_state + .selected() + .and_then(|i| self.artists.get(i)) + } + + pub fn selected_album(&self) -> Option<&Album> { + self.selected_artist() + .and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i))) + } + + pub fn move_up(&mut self) { + match self.focus { + LibraryFocus::Artists => { + if let Some(i) = self.artist_state.selected() + && i > 0 + { + self.artist_state.select(Some(i - 1)); + self.reset_album_selection(); + } + } + LibraryFocus::Albums => { + if let Some(i) = self.album_state.selected() + && i > 0 + { + self.album_state.select(Some(i - 1)); + self.reset_track_selection(); + } + } + LibraryFocus::Tracks => { + if let Some(i) = self.track_state.selected() + && i > 0 + { + self.track_state.select(Some(i - 1)); + } + } + } + } + + pub fn move_down(&mut self) { + match self.focus { + LibraryFocus::Artists => { + let max = self.artists.len().saturating_sub(1); + if let Some(i) = self.artist_state.selected() + && i < max + { + self.artist_state.select(Some(i + 1)); + self.reset_album_selection(); + } + } + LibraryFocus::Albums => { + let max = self + .selected_artist() + .map(|a| a.albums.len().saturating_sub(1)) + .unwrap_or(0); + if let Some(i) = self.album_state.selected() + && i < max + { + self.album_state.select(Some(i + 1)); + self.reset_track_selection(); + } + } + LibraryFocus::Tracks => { + let max = self.track_count().saturating_sub(1); + if let Some(i) = self.track_state.selected() + && i < max + { + self.track_state.select(Some(i + 1)); + } + } + } + } + + fn track_count(&self) -> usize { + self.selected_album().map(|a| a.total as usize).unwrap_or(0) + } + + pub fn focus_left(&mut self) { + match self.focus { + LibraryFocus::Artists => {} + LibraryFocus::Albums => self.focus = LibraryFocus::Artists, + LibraryFocus::Tracks => self.focus = LibraryFocus::Albums, + } + } + + pub fn focus_right(&mut self) { + match self.focus { + LibraryFocus::Artists => { + if self.selected_artist().is_some() { + self.focus = LibraryFocus::Albums; + } + } + LibraryFocus::Albums => { + if self.selected_album().is_some() { + self.focus = LibraryFocus::Tracks; + } + } + LibraryFocus::Tracks => {} + } + } + + pub fn cycle_focus(&mut self) { + self.focus = match self.focus { + LibraryFocus::Artists => LibraryFocus::Albums, + LibraryFocus::Albums => LibraryFocus::Tracks, + LibraryFocus::Tracks => LibraryFocus::Artists, + }; + } + + fn reset_album_selection(&mut self) { + if let Some(artist) = self.selected_artist() { + if !artist.albums.is_empty() { + self.album_state.select(Some(0)); + } else { + self.album_state.select(None); + } + } + self.reset_track_selection(); + } + + fn reset_track_selection(&mut self) { + if self.selected_album().is_some() { + self.track_state.select(Some(0)); + } else { + self.track_state.select(None); + } + } + + pub fn artist_count(&self) -> usize { + self.artists.len() + } + + pub fn selected_artist_index(&self) -> Option { + self.artist_state.selected() + } + + pub fn get_tracks(&self) -> &[Track] { + &self.tracks + } + + pub fn cache_tracks(&mut self, album_id: String, tracks: Vec) { + self.tracks_cache.insert(album_id.clone(), tracks); + + if self.pending_album_id.as_ref() == Some(&album_id) { + self.pending_album_id = None; + self.load_tracks_from_cache(&album_id); + } + } + + pub fn load_tracks_from_cache(&mut self, album_id: &str) { + if let Some(tracks) = self.tracks_cache.get(album_id) { + self.tracks = tracks.clone(); + self.track_state.select(if self.tracks.is_empty() { + None + } else { + Some(0) + }); + } + } + + pub fn get_cached_tracks(&self, album_id: &str) -> Option<&Vec> { + self.tracks_cache.get(album_id) + } + + pub fn needs_fetch(&mut self) -> Option { + let current_album_id = self.selected_album().map(|a| a.id.clone())?; + + if current_album_id.is_empty() { + return None; + } + + if self.tracks_cache.contains_key(¤t_album_id) { + if self.pending_album_id.as_ref() != Some(¤t_album_id) { + self.load_tracks_from_cache(¤t_album_id); + } + return None; + } + + if self.pending_album_id.as_ref() == Some(¤t_album_id) { + return None; + } + + self.pending_album_id = Some(current_album_id.clone()); + self.tracks.clear(); + Some(current_album_id) + } + + pub fn selected_album_id(&self) -> Option { + self.selected_album().map(|a| a.id.clone()) + } + + pub fn clear_cache(&mut self) { + self.tracks_cache.clear(); + self.pending_album_id = None; + self.tracks.clear(); + } +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..2d8c805 --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,4 @@ +pub mod app_state; +pub mod handlers; +pub mod library_state; +pub mod notification_state; diff --git a/src/application/notification_state.rs b/src/application/notification_state.rs new file mode 100644 index 0000000..57ff115 --- /dev/null +++ b/src/application/notification_state.rs @@ -0,0 +1,128 @@ +#![allow(dead_code)] + +use std::time::Instant; + +use ratatui::style::Color; + +use crate::theme; + +pub const NOTIFICATION_TTL_SECS: u64 = 6; +pub const MAX_VISIBLE: usize = 5; +pub const MAX_HISTORY: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotifKind { + Info, + Success, + Warn, + Error, +} + +impl NotifKind { + pub fn color(self) -> Color { + match self { + NotifKind::Info => theme::BLUE, + NotifKind::Success => theme::GREEN, + NotifKind::Warn => theme::YELLOW, + NotifKind::Error => theme::RED, + } + } + + pub fn label(self) -> &'static str { + match self { + NotifKind::Info => "Info", + NotifKind::Success => "Success", + NotifKind::Warn => "Warning", + NotifKind::Error => "Error", + } + } +} + +#[derive(Clone)] +pub struct Notification { + pub id: u64, + pub title: String, + pub detail: Option, + pub kind: NotifKind, + pub icon: String, + pub created_at: Instant, +} + +pub struct NotificationManager { + active: Vec, + history: Vec, + next_id: u64, +} + +impl Default for NotificationManager { + fn default() -> Self { + Self::new() + } +} + +impl NotificationManager { + pub fn new() -> Self { + Self { + active: Vec::new(), + history: Vec::new(), + next_id: 1, + } + } + + pub fn push( + &mut self, + title: impl Into, + detail: Option, + kind: NotifKind, + icon: impl Into, + ) { + let notification = Notification { + id: self.next_id, + title: title.into(), + detail, + kind, + icon: icon.into(), + created_at: Instant::now(), + }; + self.next_id += 1; + + self.history.push(notification.clone()); + if self.history.len() > MAX_HISTORY { + self.history.remove(0); + } + + self.active.push(notification); + } + + pub fn tick(&mut self) { + let now = Instant::now(); + self.active + .retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS); + } + + pub fn history(&self) -> &[Notification] { + &self.history + } + + pub fn active(&self) -> &[Notification] { + &self.active + } + + pub fn active_count(&self) -> usize { + self.active.len() + } + + pub fn history_count(&self) -> usize { + self.history.len() + } +} + +pub fn format_elapsed(secs: u64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m", secs / 60) + } else { + format!("{}h", secs / 3600) + } +} diff --git a/src/config.rs b/src/config.rs index a18f2a3..4f9f6d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,26 +1 @@ -use serde::Deserialize; -use std::fs; -use std::path::Path; - -#[derive(Debug, Deserialize)] -pub struct Config { - pub server: ServerConfig, -} - -#[derive(Debug, Deserialize)] -pub struct ServerConfig { - pub host: String, - pub port: u16, -} - -impl Config { - pub fn load>(path: P) -> Result> { - let contents = fs::read_to_string(path)?; - let config: Config = serde_yaml::from_str(&contents)?; - Ok(config) - } - - pub fn grpc_addr(&self) -> String { - format!("http://{}:{}", self.server.host, self.server.port) - } -} +pub use crate::infrastructure::config::*; diff --git a/src/data/mod.rs b/src/data/mod.rs index 85557ec..71614c5 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,5 +1 @@ -//! Data layer modules. - -pub mod models; - -pub use models::*; +pub use crate::domain::models::*; diff --git a/src/domain/aggregates.rs b/src/domain/aggregates.rs new file mode 100644 index 0000000..c3d9ab6 --- /dev/null +++ b/src/domain/aggregates.rs @@ -0,0 +1,13 @@ +use crate::domain::models::{AlbumStatus, Artist}; + +pub fn artist_status(artist: &Artist) -> AlbumStatus { + let total: u16 = artist.albums.iter().map(|a| a.total).sum(); + let have: u16 = artist.albums.iter().map(|a| a.have).sum(); + if have == total { + AlbumStatus::Complete + } else if have == 0 { + AlbumStatus::Wanted + } else { + AlbumStatus::Partial + } +} diff --git a/src/domain/conversions.rs b/src/domain/conversions.rs new file mode 100644 index 0000000..60f3f71 --- /dev/null +++ b/src/domain/conversions.rs @@ -0,0 +1,102 @@ +use crate::domain::models::{Album, AlbumStatus, Artist, MonitorState, Track}; +use crate::grpc::{AlbumDetail, ArtistSummary, TrackDetail}; + +pub fn convert_artist(summary: ArtistSummary) -> Artist { + let albums: Vec = summary.albums.into_iter().map(convert_album).collect(); + Artist { + id: summary.id, + name: summary.name, + country: summary.country, + genres: summary.genres, + monitor_state: MonitorState::from_proto(summary.monitor_state), + path: String::new(), + quality: "FLAC".to_string(), + size_gb: 0.0, + albums, + } +} + +pub fn convert_album(detail: AlbumDetail) -> Album { + let year = parse_year(&detail.release_date); + let monitor_state = MonitorState::from_proto(detail.monitor_state); + let monitored = monitor_state.is_monitored(); + + let (have, status, quality) = if let Some(download) = detail.download { + let have = if download.state == "downloaded" { + detail.total_tracks as u16 + } else { + 0 + }; + let status = match download.state.as_str() { + "downloaded" => AlbumStatus::Complete, + "downloading" => AlbumStatus::Partial, + _ => { + if monitored { + AlbumStatus::Wanted + } else { + AlbumStatus::Unmonitored + } + } + }; + let quality = if download.quality.is_empty() { + "—".to_string() + } else { + download.quality + }; + (have, status, quality) + } else { + let status = if monitored { + AlbumStatus::Wanted + } else { + AlbumStatus::Unmonitored + }; + (0, status, "—".to_string()) + }; + + Album { + id: detail.id, + title: detail.title, + year, + album_type: detail.album_type, + monitored, + total: detail.total_tracks as u16, + have, + quality, + status, + } +} + +pub fn parse_year(date_str: &str) -> u16 { + date_str + .split('-') + .next() + .and_then(|y| y.parse().ok()) + .unwrap_or(0) +} + +pub fn convert_track(detail: TrackDetail) -> Track { + let have = detail.file.is_some(); + let quality = detail + .file + .as_ref() + .map(|f| f.format.clone()) + .unwrap_or_else(|| "—".to_string()); + let duration = format_duration(detail.duration_ms); + + Track { + id: detail.id, + number: detail.track_number as u16, + disc: detail.disc_number as u16, + title: detail.title, + duration, + have, + quality, + } +} + +pub fn format_duration(ms: i32) -> String { + let total_secs = ms / 1000; + let mins = total_secs / 60; + let secs = total_secs % 60; + format!("{}:{:02}", mins, secs) +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..c2d144e --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,4 @@ +pub mod models; +pub mod navigation; +pub mod conversions; +pub mod aggregates; diff --git a/src/domain/models.rs b/src/domain/models.rs new file mode 100644 index 0000000..0eb8511 --- /dev/null +++ b/src/domain/models.rs @@ -0,0 +1,117 @@ +#![allow(dead_code)] + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AlbumStatus { + #[default] + Complete, + Partial, + Wanted, + Unmonitored, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MonitorState { + #[default] + Unspecified, + Monitored, + Unmonitored, + Excluded, +} + +impl MonitorState { + pub fn from_proto(value: i32) -> Self { + match value { + 1 => MonitorState::Monitored, + 2 => MonitorState::Unmonitored, + 3 => MonitorState::Excluded, + _ => MonitorState::Unspecified, + } + } + + pub fn is_monitored(&self) -> bool { + matches!(self, MonitorState::Monitored) + } +} + +#[derive(Debug, Clone)] +pub struct Artist { + pub id: String, + pub name: String, + pub country: String, + pub genres: Vec, + pub monitor_state: MonitorState, + pub path: String, + pub quality: String, + pub size_gb: f64, + pub albums: Vec, +} + +/// An album belonging to an artist. +#[derive(Debug, Clone)] +pub struct Album { + pub id: String, + pub title: String, + pub year: u16, + pub album_type: String, + pub monitored: bool, + pub total: u16, + pub have: u16, + pub quality: String, + pub status: AlbumStatus, +} + +#[derive(Debug, Clone)] +pub struct Track { + pub id: String, + pub number: u16, + pub disc: u16, + pub title: String, + pub duration: String, + pub have: bool, + pub quality: String, +} + +/// An entry in the wanted/missing queue. +#[derive(Debug, Clone)] +pub struct WantedEntry { + pub id: String, + pub artist: String, + pub album: String, + pub year: u16, + pub missing: u16, + pub release_date: String, + pub status: AlbumStatus, +} + +/// An active download in the queue. +#[derive(Debug, Clone)] +pub struct QueueEntry { + pub id: String, + pub title: String, + pub artist: String, + pub indexer: String, + pub size: String, + pub progress: f64, + pub eta: String, + pub speed: String, + pub client: String, +} + +/// A history log entry. +#[derive(Debug, Clone)] +pub struct HistoryEntry { + pub when: String, + pub event: String, + pub artist: String, + pub detail: String, +} + +/// A calendar entry for upcoming releases. +#[derive(Debug, Clone)] +pub struct CalendarEntry { + pub date: String, + pub artist: String, + pub album: String, + pub status: String, + pub entry_type: String, +} diff --git a/src/domain/navigation.rs b/src/domain/navigation.rs new file mode 100644 index 0000000..468b0b5 --- /dev/null +++ b/src/domain/navigation.rs @@ -0,0 +1,50 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Library, + Wanted, + Queue, + History, + Calendar, + Settings, +} + +impl Tab { + pub const ALL: [Tab; 6] = [ + Tab::Library, + Tab::Wanted, + Tab::Queue, + Tab::History, + Tab::Calendar, + Tab::Settings, + ]; + + pub fn index(&self) -> usize { + match self { + Tab::Library => 0, + Tab::Wanted => 1, + Tab::Queue => 2, + Tab::History => 3, + Tab::Calendar => 4, + Tab::Settings => 5, + } + } + + pub fn label(&self) -> &'static str { + match self { + Tab::Library => "Library", + Tab::Wanted => "Wanted", + Tab::Queue => "Queue", + Tab::History => "History", + Tab::Calendar => "Calendar", + Tab::Settings => "Settings", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum ModalKind { + Help, + Quit, +} diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs index 5a8a2d1..7989115 100644 --- a/src/grpc/mod.rs +++ b/src/grpc/mod.rs @@ -1,97 +1 @@ -use tokio::sync::mpsc; -use tonic::transport::Channel; - -use crate::proto::music_agregator_v1::music_agregator_service_client::MusicAgregatorServiceClient; - -pub use crate::proto::music_agregator_v1::{ - AlbumDetail, ArtistSummary, GetAlbumRequest, GetArtistsRequest, TrackDetail, -}; - -#[derive(Debug)] -pub enum GrpcRequest { - GetArtists, - GetAlbum { album_id: String }, -} - -#[derive(Debug)] -#[allow(dead_code, clippy::large_enum_variant)] -pub enum GrpcResponse { - Artists(Vec), - Album { - album: AlbumDetail, - tracks: Vec, - }, - Error(String), -} - -pub struct GrpcClient { - music: MusicAgregatorServiceClient, -} - -impl GrpcClient { - pub async fn connect(addr: &str) -> Result { - let channel = Channel::from_shared(addr.to_string()) - .expect("valid uri") - .connect() - .await?; - - Ok(Self { - music: MusicAgregatorServiceClient::new(channel), - }) - } - - pub async fn get_artists(&mut self) -> Result, tonic::Status> { - let response = self.music.get_artists(GetArtistsRequest {}).await?; - Ok(response.into_inner().artists) - } - - pub async fn get_album( - &mut self, - album_id: String, - ) -> Result<(AlbumDetail, Vec), tonic::Status> { - let response = self.music.get_album(GetAlbumRequest { album_id }).await?; - let inner = response.into_inner(); - let album = inner - .album - .ok_or_else(|| tonic::Status::not_found("Album not found in response"))?; - Ok((album, inner.tracks)) - } -} - -pub fn spawn_grpc_worker( - addr: String, -) -> (mpsc::Sender, mpsc::Receiver) { - let (req_tx, mut req_rx) = mpsc::channel::(32); - let (resp_tx, resp_rx) = mpsc::channel::(32); - - tokio::spawn(async move { - let client = match GrpcClient::connect(&addr).await { - Ok(c) => c, - Err(e) => { - let _ = resp_tx.send(GrpcResponse::Error(e.to_string())).await; - return; - } - }; - - let mut client = client; - - while let Some(request) = req_rx.recv().await { - let response = match request { - GrpcRequest::GetArtists => match client.get_artists().await { - Ok(artists) => GrpcResponse::Artists(artists), - Err(e) => GrpcResponse::Error(e.to_string()), - }, - GrpcRequest::GetAlbum { album_id } => match client.get_album(album_id).await { - Ok((album, tracks)) => GrpcResponse::Album { album, tracks }, - Err(e) => GrpcResponse::Error(e.to_string()), - }, - }; - - if resp_tx.send(response).await.is_err() { - break; - } - } - }); - - (req_tx, resp_rx) -} +pub use crate::infrastructure::grpc::*; diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs new file mode 100644 index 0000000..a18f2a3 --- /dev/null +++ b/src/infrastructure/config.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; +use std::fs; +use std::path::Path; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub server: ServerConfig, +} + +#[derive(Debug, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, +} + +impl Config { + pub fn load>(path: P) -> Result> { + let contents = fs::read_to_string(path)?; + let config: Config = serde_yaml::from_str(&contents)?; + Ok(config) + } + + pub fn grpc_addr(&self) -> String { + format!("http://{}:{}", self.server.host, self.server.port) + } +} diff --git a/src/infrastructure/grpc/mod.rs b/src/infrastructure/grpc/mod.rs new file mode 100644 index 0000000..5a8a2d1 --- /dev/null +++ b/src/infrastructure/grpc/mod.rs @@ -0,0 +1,97 @@ +use tokio::sync::mpsc; +use tonic::transport::Channel; + +use crate::proto::music_agregator_v1::music_agregator_service_client::MusicAgregatorServiceClient; + +pub use crate::proto::music_agregator_v1::{ + AlbumDetail, ArtistSummary, GetAlbumRequest, GetArtistsRequest, TrackDetail, +}; + +#[derive(Debug)] +pub enum GrpcRequest { + GetArtists, + GetAlbum { album_id: String }, +} + +#[derive(Debug)] +#[allow(dead_code, clippy::large_enum_variant)] +pub enum GrpcResponse { + Artists(Vec), + Album { + album: AlbumDetail, + tracks: Vec, + }, + Error(String), +} + +pub struct GrpcClient { + music: MusicAgregatorServiceClient, +} + +impl GrpcClient { + pub async fn connect(addr: &str) -> Result { + let channel = Channel::from_shared(addr.to_string()) + .expect("valid uri") + .connect() + .await?; + + Ok(Self { + music: MusicAgregatorServiceClient::new(channel), + }) + } + + pub async fn get_artists(&mut self) -> Result, tonic::Status> { + let response = self.music.get_artists(GetArtistsRequest {}).await?; + Ok(response.into_inner().artists) + } + + pub async fn get_album( + &mut self, + album_id: String, + ) -> Result<(AlbumDetail, Vec), tonic::Status> { + let response = self.music.get_album(GetAlbumRequest { album_id }).await?; + let inner = response.into_inner(); + let album = inner + .album + .ok_or_else(|| tonic::Status::not_found("Album not found in response"))?; + Ok((album, inner.tracks)) + } +} + +pub fn spawn_grpc_worker( + addr: String, +) -> (mpsc::Sender, mpsc::Receiver) { + let (req_tx, mut req_rx) = mpsc::channel::(32); + let (resp_tx, resp_rx) = mpsc::channel::(32); + + tokio::spawn(async move { + let client = match GrpcClient::connect(&addr).await { + Ok(c) => c, + Err(e) => { + let _ = resp_tx.send(GrpcResponse::Error(e.to_string())).await; + return; + } + }; + + let mut client = client; + + while let Some(request) = req_rx.recv().await { + let response = match request { + GrpcRequest::GetArtists => match client.get_artists().await { + Ok(artists) => GrpcResponse::Artists(artists), + Err(e) => GrpcResponse::Error(e.to_string()), + }, + GrpcRequest::GetAlbum { album_id } => match client.get_album(album_id).await { + Ok((album, tracks)) => GrpcResponse::Album { album, tracks }, + Err(e) => GrpcResponse::Error(e.to_string()), + }, + }; + + if resp_tx.send(response).await.is_err() { + break; + } + } + }); + + (req_tx, resp_rx) +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..ff38eb7 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod grpc; +pub mod system; diff --git a/src/infrastructure/system.rs b/src/infrastructure/system.rs new file mode 100644 index 0000000..efaf457 --- /dev/null +++ b/src/infrastructure/system.rs @@ -0,0 +1,16 @@ +use nix::sys::statvfs::statvfs; + +pub fn get_free_space() -> String { + match statvfs("/") { + Ok(stat) => { + let free_bytes = stat.blocks_available() * stat.fragment_size(); + let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + if free_gb >= 1000.0 { + format!("{:.1} TB free", free_gb / 1024.0) + } else { + format!("{:.1} GB free", free_gb) + } + } + Err(_) => "-- GB free".to_string(), + } +} diff --git a/src/lib.rs b/src/lib.rs index b363777..f7ee22e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,11 @@ pub mod app; +pub mod application; pub mod config; pub mod data; +pub mod domain; pub mod grpc; +pub mod infrastructure; +pub mod presentation; pub mod proto; pub mod theme; pub mod ui; diff --git a/src/presentation/app_renderer.rs b/src/presentation/app_renderer.rs new file mode 100644 index 0000000..3e733df --- /dev/null +++ b/src/presentation/app_renderer.rs @@ -0,0 +1,263 @@ +#![allow(dead_code)] + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Style, + widgets::Paragraph, +}; + +use crate::application::app_state::App; +use crate::domain::navigation::Tab; +use crate::theme; +use crate::ui::library::render_library; +use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal}; +use crate::ui::statusbar::render_statusbar; +use crate::ui::topbar::render_topbar; +use crate::ui::views::{ + render_calendar, render_history, render_queue, render_settings, render_wanted, +}; + +impl App { + pub fn draw(&mut self, frame: &mut Frame) { + self.size = frame.area(); + let area = frame.area(); + + frame.render_widget( + Paragraph::new("").style(Style::default().bg(theme::BG0)), + area, + ); + + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + self.topbar_area = chunks[0]; + self.main_area = chunks[1]; + self.statusbar_area = chunks[2]; + + let queue_count = self.queue.len(); + let wanted_count = self.wanted.len(); + let notification_count = self.notifications.history_count(); + + let topbar = render_topbar( + frame, + chunks[0], + self.tab, + queue_count, + wanted_count, + notification_count, + self.notifications_open, + ); + self.tab_areas = topbar.tabs; + self.notifications_btn_area = topbar.notifications; + + self.render_main_content(frame, chunks[1]); + + let position = self.get_position(); + render_statusbar(frame, chunks[2], position, queue_count, wanted_count); + + if let Some(modal) = &self.modal { + match modal { + ModalKind::Help => render_help_modal(frame, area), + ModalKind::Quit => render_quit_modal(frame, area, self.queue.len()), + } + } + + self.notifications.render(frame, area); + + if self.notifications_open { + self.render_notifications_dropdown(frame); + } + } + + fn render_notifications_dropdown(&mut self, frame: &mut Frame) { + use crate::ui::notifications::render_notification_item; + use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + + let history = self.notifications.history(); + if history.is_empty() { + return; + } + + let dropdown_width = 52u16; + let max_items = 8usize; + let item_height = 3u16; + + if let Some(expanded_id) = self.notifications_expanded { + if let Some(notif) = history.iter().find(|n| n.id == expanded_id) { + let detail_lines = notif + .detail + .as_ref() + .map(|d| d.lines().count()) + .unwrap_or(1); + let dropdown_height = (6 + detail_lines as u16).min(20); + + let x = self + .notifications_btn_area + .x + .saturating_sub(dropdown_width - self.notifications_btn_area.width); + let y = self.topbar_area.y + 1; + + let dropdown_area = Rect::new( + x.max(0), + y, + dropdown_width.min(self.size.width), + dropdown_height.min(self.size.height - y), + ); + self.notifications_dropdown_area = dropdown_area; + + frame.render_widget(Clear, dropdown_area); + + let border_color = notif.kind.color(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(ratatui::style::Style::default().fg(border_color)) + .style(ratatui::style::Style::default().bg(theme::BG1)); + let inner = block.inner(dropdown_area); + frame.render_widget(block, dropdown_area); + + use ratatui::style::{Modifier, Style}; + use ratatui::text::{Line, Span}; + + let elapsed = notif.created_at.elapsed().as_secs(); + let time_str = if elapsed < 60 { + format!("{}s ago", elapsed) + } else if elapsed < 3600 { + format!("{}m ago", elapsed / 60) + } else { + format!("{}h ago", elapsed / 3600) + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("← ", Style::default().fg(theme::GRAY)), + Span::styled(¬if.icon, Style::default().fg(border_color)), + Span::raw(" "), + Span::styled( + notif.kind.label(), + Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(time_str, Style::default().fg(theme::GRAY)), + ]), + Line::from(""), + Line::from(Span::styled(¬if.title, Style::default().fg(theme::FG1))), + ]; + + if let Some(detail) = ¬if.detail { + lines.push(Line::from("")); + for line in detail.lines() { + lines.push(Line::from(Span::styled( + line, + Style::default().fg(theme::FG2), + ))); + } + } + + let para = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(para, inner); + return; + } + } + + let visible_count = history.len().min(max_items); + let dropdown_height = (visible_count as u16 * item_height) + 2; + + let x = self + .notifications_btn_area + .x + .saturating_sub(dropdown_width - self.notifications_btn_area.width); + let y = self.topbar_area.y + 1; + + let dropdown_area = Rect::new( + x.max(0), + y, + dropdown_width.min(self.size.width), + dropdown_height.min(self.size.height - y), + ); + self.notifications_dropdown_area = dropdown_area; + + frame.render_widget(Clear, dropdown_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(ratatui::style::Style::default().fg(theme::GRAY)) + .style(ratatui::style::Style::default().bg(theme::BG1)); + let inner = block.inner(dropdown_area); + frame.render_widget(block, dropdown_area); + + let start_idx = self.notifications_scroll; + let end_idx = (start_idx + max_items).min(history.len()); + + for (i, notif) in history + .iter() + .rev() + .skip(start_idx) + .take(end_idx - start_idx) + .enumerate() + { + let item_y = inner.y + (i as u16 * item_height); + let item_area = Rect::new(inner.x, item_y, inner.width, item_height); + render_notification_item(frame, item_area, notif); + } + } + + fn render_main_content(&mut self, frame: &mut Frame, area: Rect) { + match self.tab { + Tab::Library => { + render_library(frame, area, &mut self.library); + } + Tab::Wanted => { + render_wanted(frame, area, &self.wanted, &mut self.wanted_state); + } + Tab::Queue => { + render_queue(frame, area, &self.queue, &mut self.queue_state); + } + Tab::History => { + render_history(frame, area, &self.history, &mut self.history_state); + } + Tab::Calendar => { + render_calendar(frame, area, &self.calendar); + } + Tab::Settings => { + render_settings(frame, area); + } + } + } + + fn get_position(&self) -> Option<(usize, usize)> { + match self.tab { + Tab::Library => { + let idx = self.library.selected_artist_index().unwrap_or(0) + 1; + let total = self.library.artist_count(); + if total > 0 { Some((idx, total)) } else { None } + } + Tab::Wanted => { + if self.wanted.is_empty() { + return None; + } + let idx = self.wanted_state.selected().unwrap_or(0) + 1; + Some((idx, self.wanted.len())) + } + Tab::Queue => { + if self.queue.is_empty() { + return None; + } + let idx = self.queue_state.selected().unwrap_or(0) + 1; + Some((idx, self.queue.len())) + } + Tab::History => { + if self.history.is_empty() { + return None; + } + let idx = self.history_state.selected().unwrap_or(0) + 1; + Some((idx, self.history.len())) + } + Tab::Calendar | Tab::Settings => None, + } + } +} diff --git a/src/presentation/library.rs b/src/presentation/library.rs new file mode 100644 index 0000000..69d2eb6 --- /dev/null +++ b/src/presentation/library.rs @@ -0,0 +1,404 @@ +#![allow(dead_code)] + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, ListState, Paragraph}, +}; + +use crate::application::library_state::{LibraryFocus, LibraryState}; +use crate::data::{Album, AlbumStatus, Artist, MonitorState}; +use crate::domain::aggregates::artist_status; +use crate::theme; +use crate::ui::pane::{Pane, section_divider}; +use crate::ui::progress_bar::progress_bar; + +fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) { + if !monitored { + return ('◌', Style::default().fg(theme::GRAY)); + } + match status { + AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)), + AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), + AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)), + AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)), + } +} + +fn monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) { + match state { + MonitorState::Monitored => match status { + AlbumStatus::Complete => ('✓', Style::default().fg(theme::GREEN)), + AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), + AlbumStatus::Wanted => ('!', Style::default().fg(theme::RED)), + AlbumStatus::Unmonitored => ('-', Style::default().fg(theme::GRAY)), + }, + MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)), + MonitorState::Excluded => ('x', Style::default().fg(theme::RED)), + MonitorState::Unspecified => ('?', Style::default().fg(theme::GRAY)), + } +} + +fn track_icon(have: bool) -> (char, Style) { + if have { + ('✓', Style::default().fg(theme::GREEN)) + } else { + ('✗', Style::default().fg(theme::RED)) + } +} + +fn fmt_size(gb: f64) -> String { + if gb >= 1.0 { + format!("{:.1} GB", gb) + } else { + format!("{} MB", (gb * 1024.0).round() as u32) + } +} + +pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area); + + render_artists_pane(frame, chunks[0], state); + render_detail_pane(frame, chunks[1], state); +} + +fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let focused = state.focus == LibraryFocus::Artists; + let artist_count = state.artists.len(); + + let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum(); + let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum(); + + let footer = Line::from(vec![ + Span::styled( + format!("{} artists · {} alb", artist_count, total_albums), + Style::default().fg(theme::GRAY), + ), + Span::raw(" "), + Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)), + ]); + + let artist_count_str = artist_count.to_string(); + let pane = Pane::new("Artists") + .meta(&artist_count_str) + .focused(focused) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let items: Vec = state + .artists + .iter() + .map(|artist| { + let status = artist_status(artist); + let (icon_char, icon_style) = monitor_state_icon(artist.monitor_state, status); + + let total: u16 = artist.albums.iter().map(|a| a.total).sum(); + let have: u16 = artist.albums.iter().map(|a| a.have).sum(); + + let mut name_text = artist.name.clone(); + + let count_str = format!("{}/{}", have, total); + let name_width = inner.width as usize - 2 - count_str.len() - 2; + if name_text.len() > name_width { + name_text.truncate(name_width.saturating_sub(1)); + name_text.push('…'); + } + + let padding = name_width.saturating_sub(name_text.len()); + + Line::from(vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(name_text, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(padding)), + Span::styled(count_str, Style::default().fg(theme::GRAY)), + ]) + .into() + }) + .collect(); + + let highlight_style = if focused { + Style::default().bg(theme::YELLOW).fg(theme::BG0) + } else { + Style::default().bg(theme::SELECT_BG).fg(theme::FG1) + }; + + let list = List::new(items).highlight_style(highlight_style); + frame.render_stateful_widget(list, inner, &mut state.artist_state); +} + +fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks; + + let artist = state.selected_artist(); + + let meta = artist + .map(|a| { + format!( + "{} · {}", + a.country, + a.genres.first().map(|s| s.as_str()).unwrap_or("") + ) + }) + .unwrap_or_default(); + + let have_tracks: u16 = artist + .map(|a| a.albums.iter().map(|al| al.have).sum()) + .unwrap_or(0); + let total_tracks: u16 = artist + .map(|a| a.albums.iter().map(|al| al.total).sum()) + .unwrap_or(0); + + let footer = if artist.is_some() { + Line::from(vec![Span::styled( + format!("{}/{} tracks", have_tracks, total_tracks), + Style::default().fg(theme::GRAY), + )]) + } else { + Line::from("") + }; + + let pane = Pane::new("Detail") + .meta(&meta) + .focused(focused) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let Some(artist) = artist else { + let msg = Paragraph::new(Span::styled( + "No artist selected", + Style::default().fg(theme::GRAY), + )); + frame.render_widget(msg, inner); + return; + }; + + let chunks = Layout::vertical([ + Constraint::Length(6), + Constraint::Length(1), + Constraint::Percentage(40), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + render_artist_header(frame, chunks[0], artist); + + let albums_count = artist.albums.len(); + let albums_label = format!("{} releases", albums_count); + let album_divider = section_divider("albums", Some(&albums_label)); + frame.render_widget(Paragraph::new(album_divider), chunks[1]); + + let selected_artist_idx = state.artist_state.selected(); + if let Some(idx) = selected_artist_idx + && let Some(artist) = state.artists.get(idx) + { + let albums = artist.albums.clone(); + let focus = state.focus; + render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state); + } + + let album_title = state + .selected_album() + .map(|a| a.title.clone()) + .unwrap_or_default(); + let track_counts = state + .selected_album() + .map(|a| format!("{}/{}", a.have, a.total)) + .unwrap_or_default(); + let track_label = format!("tracks · {}", album_title); + let track_divider = section_divider(&track_label, Some(&track_counts)); + frame.render_widget(Paragraph::new(track_divider), chunks[3]); + + render_tracks_list(frame, chunks[4], state); +} + +fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) { + let have: u16 = artist.albums.iter().map(|a| a.have).sum(); + let total: u16 = artist.albums.iter().map(|a| a.total).sum(); + + let (status_icon, status_text, status_style) = match artist.monitor_state { + MonitorState::Monitored => ( + Span::styled("● ", Style::default().fg(theme::GREEN)), + "Monitored", + Style::default().fg(theme::FG2), + ), + MonitorState::Unmonitored => ( + Span::styled("◌ ", Style::default().fg(theme::GRAY)), + "Unmonitored", + Style::default().fg(theme::GRAY), + ), + MonitorState::Excluded => ( + Span::styled("⊘ ", Style::default().fg(theme::RED)), + "Excluded", + Style::default().fg(theme::RED), + ), + MonitorState::Unspecified => ( + Span::styled("? ", Style::default().fg(theme::GRAY)), + "Unknown", + Style::default().fg(theme::GRAY), + ), + }; + + let lines = vec![ + Line::from(Span::styled( + &artist.name, + Style::default() + .fg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("status ", Style::default().fg(theme::GRAY)), + status_icon, + Span::styled(status_text, status_style), + Span::raw(" "), + Span::styled("path ", Style::default().fg(theme::GRAY)), + Span::styled(&artist.path, Style::default().fg(theme::AQUA)), + ]), + Line::from(vec![ + Span::styled("quality ", Style::default().fg(theme::GRAY)), + Span::styled(&artist.quality, Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled("size ", Style::default().fg(theme::GRAY)), + Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)), + ]), + Line::from(vec![ + Span::styled("albums ", Style::default().fg(theme::GRAY)), + Span::styled( + artist.albums.len().to_string(), + Style::default().fg(theme::FG1), + ), + Span::raw(" "), + Span::styled("tracks ", Style::default().fg(theme::GRAY)), + Span::styled(have.to_string(), Style::default().fg(theme::FG1)), + Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)), + ]), + ]; + + let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0)); + frame.render_widget(paragraph, area); +} + +fn render_albums_list( + frame: &mut Frame, + area: Rect, + albums: &[Album], + focus: LibraryFocus, + album_state: &mut ListState, +) { + let focused = focus == LibraryFocus::Albums; + + let items: Vec = albums + .iter() + .map(|album| { + let (icon_char, icon_style) = status_icon(album.status, album.monitored); + let type_str = format!("[{}]", album.album_type); + let year_str = album.year.to_string(); + let progress = progress_bar(album.have, album.total, 10, album.status); + let count_str = format!("{}/{}", album.have, album.total); + + let quality_style = if album.quality == "—" { + Style::default().fg(theme::GRAY) + } else { + Style::default().fg(theme::AQUA) + }; + + let title_width = + area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8; + let mut title = album.title.clone(); + if title.len() > title_width { + title.truncate(title_width.saturating_sub(1)); + title.push('…'); + } + + let mut spans = vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(title, Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled(type_str, Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(year_str, Style::default().fg(theme::GRAY)), + Span::raw(" "), + ]; + spans.extend(progress.spans); + spans.push(Span::raw(" ")); + spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY))); + spans.push(Span::raw(" ")); + spans.push(Span::styled(&album.quality, quality_style)); + + Line::from(spans).into() + }) + .collect(); + + let highlight_style = if focused { + Style::default().bg(theme::YELLOW).fg(theme::BG0) + } else { + Style::default().bg(theme::SELECT_BG).fg(theme::FG1) + }; + + let list = List::new(items).highlight_style(highlight_style); + frame.render_stateful_widget(list, area, album_state); +} + +fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) { + let focused = state.focus == LibraryFocus::Tracks; + + if state.tracks.is_empty() { + let msg = Paragraph::new(Span::styled( + "(no album selected)", + Style::default().fg(theme::GRAY), + )); + frame.render_widget(msg, area); + return; + } + + let items: Vec = state + .tracks + .iter() + .map(|track| { + let (icon_char, icon_style) = track_icon(track.have); + let num_str = format!("{:02}", track.number); + + let title_style = if track.have { + Style::default().fg(theme::FG1) + } else { + Style::default().fg(theme::GRAY) + }; + + let quality_style = if track.have { + Style::default().fg(theme::AQUA) + } else { + Style::default().fg(theme::RED) + }; + + Line::from(vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(num_str, Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(track.title.clone(), title_style), + Span::raw(" "), + Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled(track.quality.clone(), quality_style), + ]) + .into() + }) + .collect(); + + let highlight_style = if focused { + Style::default().bg(theme::YELLOW).fg(theme::BG0) + } else { + Style::default().bg(theme::SELECT_BG).fg(theme::FG1) + }; + + let list = List::new(items).highlight_style(highlight_style); + frame.render_stateful_widget(list, area, &mut state.track_state); +} diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs new file mode 100644 index 0000000..493a06f --- /dev/null +++ b/src/presentation/mod.rs @@ -0,0 +1,9 @@ +pub mod app_renderer; +pub mod library; +pub mod notifications; +pub mod topbar; +pub mod progress_bar; +pub mod pane; +pub mod statusbar; +pub mod modals; +pub mod views; diff --git a/src/presentation/modals/help.rs b/src/presentation/modals/help.rs new file mode 100644 index 0000000..ae68073 --- /dev/null +++ b/src/presentation/modals/help.rs @@ -0,0 +1,148 @@ +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::theme; + +fn keybind_row<'a>(key: &'a str, desc: &'a str) -> Line<'a> { + Line::from(vec![ + Span::styled(format!("{:<14}", key), Style::default().fg(theme::YELLOW)), + Span::styled(desc, Style::default().fg(theme::FG2)), + ]) +} + +fn section_header(title: &str) -> Line<'static> { + Line::from(Span::styled( + title.to_string(), + Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD), + )) +} + +pub fn render_help_modal(frame: &mut Frame, area: Rect) { + let modal_width = 96u16.min(area.width.saturating_sub(4)); + let modal_height = 28u16.min(area.height.saturating_sub(2)); + + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width, modal_height); + + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::YELLOW)) + .title(Line::from(vec![ + Span::styled("─ ", Style::default().fg(theme::YELLOW)), + Span::styled( + "Keybindings · evil-mode (Doom Emacs)", + Style::default().fg(theme::YELLOW), + ), + Span::styled(" ─", Style::default().fg(theme::YELLOW)), + ])) + .style(Style::default().bg(theme::BG0)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let cols = Layout::horizontal([ + Constraint::Percentage(33), + Constraint::Percentage(34), + Constraint::Percentage(33), + ]) + .split(inner); + + render_col1(frame, cols[0]); + render_col2(frame, cols[1]); + render_col3(frame, cols[2]); + + let footer_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + let footer = Paragraph::new(Line::from(vec![Span::styled( + "harmony · v0.4.2 · canonical evil-mode bindings (Vim/Doom Emacs)", + Style::default().fg(theme::GRAY), + )])); + frame.render_widget(footer, footer_area); +} + +fn render_col1(frame: &mut Frame, area: Rect) { + let lines = vec![ + section_header("Motion · char/line"), + keybind_row("h j k l", "left / down / up / right"), + keybind_row("w / W", "next word / WORD"), + keybind_row("b / B", "prev word / WORD"), + keybind_row("e / E", "end of word / WORD"), + keybind_row("ge / gE", "back to end of (W)ORD"), + keybind_row("0 / ^", "line start (focus left)"), + keybind_row("$", "line end (focus right)"), + keybind_row("{N}", "repeat motion N times"), + Line::from(""), + section_header("Motion · file/page"), + keybind_row("g g", "first line"), + keybind_row("G", "last line"), + keybind_row("{N} G", "go to line N"), + keybind_row("g t / g T", "next / prev tab"), + keybind_row("C-d / C-u", "½ page down/up"), + keybind_row("C-f / C-b", "page down/up"), + keybind_row("C-e / C-y", "scroll line down/up"), + keybind_row("H / M / L", "viewport top / mid / bot"), + keybind_row("{ / }", "paragraph back/fwd"), + keybind_row("[[ / ]]", "section back/fwd"), + keybind_row("[c / ]c", "prev / next change"), + ]; + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} + +fn render_col2(frame: &mut Frame, area: Rect) { + let lines = vec![ + section_header("Search & jumps"), + keybind_row("/ pat", "filter library"), + keybind_row("? pat", "search backward"), + keybind_row("n / N", "next / prev match"), + keybind_row("* / #", "search word fwd/back"), + keybind_row("C-o / C-i", "jumplist back / fwd"), + keybind_row("m{a-z}", "set mark"), + keybind_row("'{a-z}", "jump to mark line"), + keybind_row("`{a-z}", "jump to mark exact"), + keybind_row("''", "jump to last position"), + Line::from(""), + section_header("Center · z_"), + keybind_row("z z / z .", "center cursor"), + keybind_row("z t", "cursor → top"), + keybind_row("z b / z -", "cursor → bottom"), + ]; + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} + +fn render_col3(frame: &mut Frame, area: Rect) { + let lines = vec![ + section_header("SPC leader (Doom)"), + keybind_row("SPC SPC", "M-x command"), + keybind_row("SPC b", "+buffer (tabs)"), + keybind_row("SPC f", "+file / library"), + keybind_row("SPC s", "+search"), + keybind_row("SPC w", "+window / pane"), + keybind_row("SPC t", "+toggle / theme"), + keybind_row("SPC n", "+notifications"), + keybind_row("SPC a", "+actions / artist"), + keybind_row("SPC q", "+quit"), + keybind_row("SPC h", "+help"), + keybind_row("SPC l/w/h/c", "→ tab quick"), + Line::from(""), + section_header("Modes & ex commands"), + keybind_row(":w / :sync", "save library"), + keybind_row(":q", "quit"), + keybind_row(":theme", "dark | light"), + keybind_row("a · t · s · r", "add·toggle·search·refresh"), + keybind_row("1‥6", "switch tab"), + keybind_row("Enter / Esc", "open / back"), + keybind_row("?", "this help"), + ]; + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} diff --git a/src/presentation/modals/mod.rs b/src/presentation/modals/mod.rs new file mode 100644 index 0000000..d7b70de --- /dev/null +++ b/src/presentation/modals/mod.rs @@ -0,0 +1,7 @@ +pub mod help; +pub mod quit; + +pub use help::render_help_modal; +pub use quit::render_quit_modal; + +pub use crate::domain::navigation::ModalKind; diff --git a/src/presentation/modals/quit.rs b/src/presentation/modals/quit.rs new file mode 100644 index 0000000..8ef07ec --- /dev/null +++ b/src/presentation/modals/quit.rs @@ -0,0 +1,59 @@ +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::theme; + +pub fn render_quit_modal(frame: &mut Frame, area: Rect, downloads_in_progress: usize) { + let modal_width = 48u16.min(area.width.saturating_sub(4)); + let modal_height = 7u16.min(area.height.saturating_sub(2)); + + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width, modal_height); + + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::YELLOW)) + .title(Line::from(vec![ + Span::styled("─ ", Style::default().fg(theme::YELLOW)), + Span::styled("quit harmony?", Style::default().fg(theme::YELLOW)), + Span::styled(" ─", Style::default().fg(theme::YELLOW)), + ])) + .style(Style::default().bg(theme::BG0)); + + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let msg = if downloads_in_progress > 0 { + format!( + "{} downloads in progress will continue.", + downloads_in_progress + ) + } else { + "No downloads in progress.".to_string() + }; + + let lines = vec![ + Line::from(""), + Line::from(Span::styled(msg, Style::default().fg(theme::FG2))), + Line::from(""), + Line::from(vec![ + Span::styled("press ", Style::default().fg(theme::GRAY)), + Span::styled("y", Style::default().fg(theme::YELLOW)), + Span::styled(" to confirm, ", Style::default().fg(theme::GRAY)), + Span::styled("n", Style::default().fg(theme::YELLOW)), + Span::styled(" to cancel", Style::default().fg(theme::GRAY)), + ]), + ]; + + let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center); + frame.render_widget(para, inner); +} diff --git a/src/presentation/notifications.rs b/src/presentation/notifications.rs new file mode 100644 index 0000000..aaf70fd --- /dev/null +++ b/src/presentation/notifications.rs @@ -0,0 +1,146 @@ +#![allow(dead_code)] + +use std::time::Instant; + +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::application::notification_state::{format_elapsed, Notification, NotificationManager, MAX_VISIBLE}; +use crate::theme; + +impl NotificationManager { + pub fn render(&self, frame: &mut Frame, area: Rect) { + let visible: Vec<&Notification> = self + .active() + .iter() + .rev() + .take(MAX_VISIBLE) + .collect::>() + .into_iter() + .rev() + .collect(); + + if visible.is_empty() { + return; + } + + let notif_width = 50u16.min(area.width.saturating_sub(4)); + let notif_height = 3u16; + let spacing = 1u16; + let total_height = visible.len() as u16 * (notif_height + spacing); + + let start_y = area.y + area.height.saturating_sub(total_height + 1); + let start_x = area.x + area.width.saturating_sub(notif_width + 2); + + for (i, notif) in visible.iter().enumerate() { + let y = start_y + (i as u16) * (notif_height + spacing); + let notif_area = Rect::new(start_x, y, notif_width, notif_height); + + frame.render_widget(Clear, notif_area); + + let border_color = notif.kind.color(); + let block = Block::default() + .borders(Borders::LEFT) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme::BG1)); + + let inner = block.inner(notif_area); + frame.render_widget(block, notif_area); + + let elapsed = Instant::now().duration_since(notif.created_at).as_secs(); + let timestamp = if elapsed == 0 { + "now".to_string() + } else { + format!("{}s", elapsed) + }; + + let mut lines = vec![Line::from(vec![ + Span::styled(¬if.icon, Style::default().fg(border_color)), + Span::raw(" "), + Span::styled( + ¬if.title, + Style::default() + .fg(theme::FG1) + .add_modifier(ratatui::style::Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(timestamp, Style::default().fg(theme::GRAY)), + ])]; + + if let Some(detail) = ¬if.detail { + let max_len = inner.width.saturating_sub(2) as usize; + let d = if detail.len() > max_len { + format!("{}…", &detail[..max_len.saturating_sub(1)]) + } else { + detail.clone() + }; + lines.push(Line::from(Span::styled( + d, + Style::default().fg(theme::GRAY), + ))); + } + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); + } + } +} + +pub fn render_notification_item(frame: &mut Frame, area: Rect, notif: &Notification) { + let border_color = notif.kind.color(); + let block = Block::default() + .borders(Borders::LEFT) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme::BG1)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let elapsed = Instant::now().duration_since(notif.created_at).as_secs(); + let timestamp = format_elapsed(elapsed); + + let mut lines = vec![Line::from(vec![ + Span::styled(¬if.icon, Style::default().fg(border_color)), + Span::raw(" "), + Span::styled( + notif.kind.label(), + Style::default() + .fg(theme::FG1) + .add_modifier(ratatui::style::Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(timestamp, Style::default().fg(theme::GRAY)), + ])]; + + if let Some(detail) = ¬if.detail { + let max_len = inner.width.saturating_sub(2) as usize; + let d = if detail.len() > max_len { + format!("{}…", &detail[..max_len.saturating_sub(1)]) + } else { + detail.clone() + }; + lines.push(Line::from(Span::styled( + d, + Style::default().fg(theme::GRAY), + ))); + } else { + let max_len = inner.width.saturating_sub(2) as usize; + let title = if notif.title.len() > max_len { + format!("{}…", ¬if.title[..max_len.saturating_sub(1)]) + } else { + notif.title.clone() + }; + lines.push(Line::from(Span::styled( + title, + Style::default().fg(theme::GRAY), + ))); + } + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} diff --git a/src/presentation/pane.rs b/src/presentation/pane.rs new file mode 100644 index 0000000..ee83951 --- /dev/null +++ b/src/presentation/pane.rs @@ -0,0 +1,111 @@ +//! Reusable Pane widget with styled borders and title. + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Widget}, +}; + +use crate::theme; + +pub struct Pane<'a> { + title: &'a str, + meta: Option<&'a str>, + focused: bool, + footer: Option>, +} + +impl<'a> Pane<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + meta: None, + focused: false, + footer: None, + } + } + + pub fn meta(mut self, meta: &'a str) -> Self { + self.meta = Some(meta); + self + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn footer(mut self, footer: Line<'a>) -> Self { + self.footer = Some(footer); + self + } + + pub fn build_block(&self) -> Block<'a> { + let border_color = if self.focused { + theme::YELLOW + } else { + theme::BG3 + }; + let title_color = if self.focused { + theme::YELLOW + } else { + theme::GRAY + }; + + let mut title_spans = vec![ + Span::styled("─[ ", Style::default().fg(border_color)), + Span::styled(self.title, Style::default().fg(title_color)), + ]; + + if let Some(meta) = self.meta { + title_spans.push(Span::styled(" · ", Style::default().fg(theme::GRAY))); + title_spans.push(Span::styled(meta, Style::default().fg(theme::GRAY))); + } + + title_spans.push(Span::styled(" ]─", Style::default().fg(border_color))); + + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title(Line::from(title_spans)) + .style(Style::default().bg(theme::BG0)) + } +} + +impl Widget for Pane<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = self.build_block(); + block.render(area, buf); + + if let Some(footer) = self.footer + && area.height > 2 + { + let footer_y = area.y + area.height - 1; + let footer_x = area.x + 2; + let footer_width = area.width.saturating_sub(4); + + if footer_width > 0 { + buf.set_line(footer_x, footer_y, &footer, footer_width); + } + } + } +} + +pub fn section_divider<'a>(label: &'a str, right: Option<&'a str>) -> Line<'a> { + let mut spans = vec![ + Span::styled("─ ", Style::default().fg(theme::BG3)), + Span::styled(label, Style::default().fg(theme::GRAY)), + Span::styled(" ─", Style::default().fg(theme::BG3)), + ]; + + if let Some(r) = right { + spans.push(Span::styled( + format!(" {}", r), + Style::default().fg(theme::GRAY), + )); + } + + Line::from(spans) +} diff --git a/src/presentation/progress_bar.rs b/src/presentation/progress_bar.rs new file mode 100644 index 0000000..27864cf --- /dev/null +++ b/src/presentation/progress_bar.rs @@ -0,0 +1,39 @@ +//! Unicode progress bar widget. + +use ratatui::{ + style::Style, + text::{Line, Span}, +}; + +use crate::data::AlbumStatus; +use crate::theme; + +/// Renders a unicode progress bar using ▰ (filled) and ▱ (empty). +/// Returns a Line with colored spans based on status: +/// - Complete: green filled +/// - Partial: yellow filled +/// - Wanted: red filled +/// - Unmonitored: gray filled +pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) -> Line<'static> { + let filled_count = if total == 0 { + 0 + } else { + (have as usize * width).div_ceil(total as usize) + }; + let empty_count = width.saturating_sub(filled_count); + + let filled_color = match status { + AlbumStatus::Complete => theme::GREEN, + AlbumStatus::Partial => theme::YELLOW, + AlbumStatus::Wanted => theme::RED, + AlbumStatus::Unmonitored => theme::GRAY, + }; + + let filled_str: String = "▰".repeat(filled_count); + let empty_str: String = "▱".repeat(empty_count); + + Line::from(vec![ + Span::styled(filled_str, Style::default().fg(filled_color)), + Span::styled(empty_str, Style::default().fg(theme::BG3)), + ]) +} diff --git a/src/presentation/statusbar.rs b/src/presentation/statusbar.rs new file mode 100644 index 0000000..2da5fe7 --- /dev/null +++ b/src/presentation/statusbar.rs @@ -0,0 +1,79 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::infrastructure::system::get_free_space; +use crate::theme; + +pub fn render_statusbar( + frame: &mut Frame, + area: Rect, + position: Option<(usize, usize)>, + queue_count: usize, + wanted_count: usize, +) { + let mut spans = Vec::new(); + + spans.push(Span::styled(" ", Style::default().bg(theme::BG2))); + + let left_width: usize = spans.iter().map(|s| s.content.len()).sum(); + + let mut right_spans = Vec::new(); + + if let Some((current, total)) = position { + right_spans.push(Span::styled( + format!(" {}/{} ", current, total), + Style::default().fg(theme::FG2).bg(theme::BG2), + )); + } + + if queue_count > 0 { + right_spans.push(Span::styled( + format!(" {} {} ", '\u{2193}', queue_count), + Style::default() + .fg(theme::BG0) + .bg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + )); + } + + if wanted_count > 0 { + right_spans.push(Span::styled( + format!(" ! {} ", wanted_count), + Style::default() + .fg(theme::BG0) + .bg(theme::BLUE) + .add_modifier(Modifier::BOLD), + )); + } + + right_spans.push(Span::styled( + format!(" {} ", get_free_space()), + Style::default().fg(theme::GRAY).bg(theme::BG2), + )); + + right_spans.push(Span::styled( + " harmony 0.4.2 ", + Style::default().fg(theme::GRAY).bg(theme::BG2), + )); + + let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum(); + let spacer_width = area + .width + .saturating_sub(left_width as u16) + .saturating_sub(right_width as u16) as usize; + + spans.push(Span::styled( + " ".repeat(spacer_width), + Style::default().bg(theme::BG2), + )); + spans.extend(right_spans); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG2)); + frame.render_widget(paragraph, area); +} diff --git a/src/presentation/topbar.rs b/src/presentation/topbar.rs new file mode 100644 index 0000000..7625127 --- /dev/null +++ b/src/presentation/topbar.rs @@ -0,0 +1,147 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::app::Tab; +use crate::theme; + +pub struct TopbarAreas { + pub tabs: Vec, + pub notifications: Rect, +} + +pub fn render_topbar( + frame: &mut Frame, + area: Rect, + active_tab: Tab, + queue_count: usize, + wanted_count: usize, + notification_count: usize, + notifications_open: bool, +) -> TopbarAreas { + let mut spans = Vec::new(); + let mut tab_areas = Vec::new(); + let mut current_x = area.x; + + let logo = " ▲ harmony "; + spans.push(Span::styled( + logo, + Style::default() + .fg(theme::BG0) + .bg(theme::ORANGE) + .add_modifier(Modifier::BOLD), + )); + current_x += logo.len() as u16; + + spans.push(Span::raw(" ")); + current_x += 1; + + let tabs = [ + (Tab::Library, "Library", None), + ( + Tab::Wanted, + "Wanted", + if wanted_count > 0 { + Some(wanted_count) + } else { + None + }, + ), + ( + Tab::Queue, + "Queue", + if queue_count > 0 { + Some(queue_count) + } else { + None + }, + ), + (Tab::History, "History", None), + (Tab::Calendar, "Calendar", None), + (Tab::Settings, "Settings", None), + ]; + + for (tab, label, badge) in tabs.iter() { + let is_active = *tab == active_tab; + + let tab_start = current_x; + let text = format!(" {} ", label); + let mut tab_width = text.len() as u16; + + if is_active { + spans.push(Span::styled( + text, + Style::default() + .fg(theme::YELLOW) + .bg(theme::BG0) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + text, + Style::default().fg(theme::FG3).bg(theme::BG1), + )); + } + + current_x += tab_width; + + if let Some(count) = badge { + let badge_text = format!(" {} ", count); + let badge_width = badge_text.len() as u16; + spans.push(Span::styled( + badge_text, + Style::default() + .fg(theme::BG0) + .bg(theme::RED) + .add_modifier(Modifier::BOLD), + )); + tab_width += badge_width; + current_x += badge_width; + } + + tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1)); + } + + let notif_text = if notification_count > 0 { + format!(" ● Notifications ({}) ", notification_count) + } else { + " ● Notifications ".to_string() + }; + let notif_width = notif_text.len() as u16; + let notif_x = area.x + area.width - notif_width; + + let remaining = (notif_x - current_x) as usize; + if remaining > 0 { + spans.push(Span::styled( + " ".repeat(remaining), + Style::default().bg(theme::BG1), + )); + } + + let notif_style = if notifications_open { + Style::default() + .fg(theme::YELLOW) + .bg(theme::BG0) + .add_modifier(Modifier::BOLD) + } else if notification_count > 0 { + Style::default().fg(theme::YELLOW).bg(theme::BG1) + } else { + Style::default().fg(theme::FG3).bg(theme::BG1) + }; + spans.push(Span::styled(¬if_text, notif_style)); + + let notifications_area = Rect::new(notif_x, area.y, notif_width, 1); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1)); + frame.render_widget(paragraph, area); + + TopbarAreas { + tabs: tab_areas, + notifications: notifications_area, + } +} diff --git a/src/presentation/views/calendar.rs b/src/presentation/views/calendar.rs new file mode 100644 index 0000000..6139cf6 --- /dev/null +++ b/src/presentation/views/calendar.rs @@ -0,0 +1,195 @@ +//! Calendar view - upcoming releases. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::data::CalendarEntry; +use crate::theme; +use crate::ui::pane::Pane; + +struct CalendarCell { + day: u8, + dim: bool, + is_today: bool, + events: Vec, +} + +pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]) { + let meta = "upcoming releases · May 2026"; + + let footer = Line::from(vec![ + Span::styled("[h/l]", Style::default().fg(theme::GRAY)), + Span::styled(" month · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" details", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("{} upcoming", calendar.len()), + Style::default().fg(theme::GRAY), + ), + ]); + + let pane = Pane::new("Calendar") + .meta(meta) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let today_day = 8u8; + let year = 2026u16; + let month = 5u8; + + let start_dow = 5u8; + let days_in_month = 31u8; + let days_in_prev = 30u8; + + let mut cells: Vec = Vec::new(); + + for i in 0..start_dow { + cells.push(CalendarCell { + day: days_in_prev - start_dow + i + 1, + dim: true, + is_today: false, + events: Vec::new(), + }); + } + + for d in 1..=days_in_month { + let events: Vec = calendar + .iter() + .filter(|e| { + if let Some(day_str) = e.date.split('-').nth(2) + && let Ok(day) = day_str.parse::() + { + let month_match = e.date.contains(&format!("{:04}-{:02}", year, month)); + return month_match && day == d; + } + false + }) + .cloned() + .collect(); + + cells.push(CalendarCell { + day: d, + dim: false, + is_today: d == today_day, + events, + }); + } + + let mut next_day = 1u8; + while cells.len() < 42 { + cells.push(CalendarCell { + day: next_day, + dim: true, + is_today: false, + events: Vec::new(), + }); + next_day += 1; + } + + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + let header = Line::from(vec![ + Span::styled("◀ ", Style::default().fg(theme::GRAY)), + Span::styled( + "May 2026", + Style::default() + .fg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" ▶", Style::default().fg(theme::GRAY)), + ]); + frame.render_widget( + Paragraph::new(header).alignment(ratatui::layout::Alignment::Center), + chunks[0], + ); + + let dow_labels = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + let cell_width = chunks[2].width / 7; + let dow_spans: Vec = dow_labels + .iter() + .map(|&d| { + let pad = cell_width.saturating_sub(3) as usize; + Span::styled( + format!("{}{}", d, " ".repeat(pad)), + Style::default().fg(theme::GRAY), + ) + }) + .collect(); + frame.render_widget(Paragraph::new(Line::from(dow_spans)), chunks[1]); + + let row_height = chunks[2].height / 6; + let grid_rows = Layout::vertical(vec![Constraint::Length(row_height); 6]).split(chunks[2]); + + for (row_idx, row_area) in grid_rows.iter().enumerate() { + let col_areas = Layout::horizontal(vec![Constraint::Ratio(1, 7); 7]).split(*row_area); + + for (col_idx, col_area) in col_areas.iter().enumerate() { + let cell_idx = row_idx * 7 + col_idx; + if cell_idx < cells.len() { + render_calendar_cell(frame, *col_area, &cells[cell_idx]); + } + } + } +} + +fn render_calendar_cell(frame: &mut Frame, area: Rect, cell: &CalendarCell) { + if area.height == 0 || area.width == 0 { + return; + } + + let day_style = if cell.dim { + Style::default().fg(theme::BG4) + } else if cell.is_today { + Style::default().fg(theme::FG1) + } else { + Style::default().fg(theme::FG2) + }; + + let mut lines: Vec = Vec::new(); + + let day_line = if cell.is_today { + Line::from(vec![ + Span::styled(format!("{}", cell.day), day_style), + Span::styled(" ●", Style::default().fg(theme::ORANGE)), + ]) + } else { + Line::from(Span::styled(format!("{}", cell.day), day_style)) + }; + lines.push(day_line); + + let max_events = (area.height.saturating_sub(1)) as usize; + for event in cell.events.iter().take(max_events) { + let event_style = match event.status.as_str() { + "announced" => Style::default().fg(theme::YELLOW), + "monitored" => Style::default().fg(theme::GREEN), + _ => Style::default().fg(theme::FG2), + }; + + let mut display = event.artist.clone(); + let max_len = area.width.saturating_sub(1) as usize; + if display.len() > max_len { + display.truncate(max_len.saturating_sub(1)); + display.push('…'); + } + + lines.push(Line::from(Span::styled(display, event_style))); + } + + let para = Paragraph::new(lines); + frame.render_widget(para, area); +} diff --git a/src/presentation/views/history.rs b/src/presentation/views/history.rs new file mode 100644 index 0000000..648d655 --- /dev/null +++ b/src/presentation/views/history.rs @@ -0,0 +1,91 @@ +//! History view - recent activity. + +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, +}; + +use crate::data::HistoryEntry; +use crate::theme; +use crate::ui::pane::Pane; + +fn event_style(event: &str) -> (char, &'static str, Style) { + match event { + "imported" => ('✓', "imported", Style::default().fg(theme::GREEN)), + "downloaded" => ('↓', "downloaded", Style::default().fg(theme::AQUA)), + "grabbed" => ('⤓', "grabbed", Style::default().fg(theme::BLUE)), + "search" => ('?', "search", Style::default().fg(theme::YELLOW)), + "refreshed" => ('↻', "refreshed", Style::default().fg(theme::PURPLE)), + "failed" => ('✗', "failed", Style::default().fg(theme::RED)), + _ => ('·', "unknown", Style::default().fg(theme::GRAY)), + } +} + +pub fn render_history( + frame: &mut Frame, + area: Rect, + history: &[HistoryEntry], + state: &mut ListState, +) { + let meta = format!("{} events", history.len()); + + let footer = Line::from(vec![ + Span::styled("[d]", Style::default().fg(theme::GRAY)), + Span::styled(" clear · ", Style::default().fg(theme::FG2)), + Span::styled("[r]", Style::default().fg(theme::GRAY)), + Span::styled(" retry failed", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled("last sync 12:04", Style::default().fg(theme::GRAY)), + ]); + + let pane = Pane::new("History") + .meta(&meta) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let items: Vec = history + .iter() + .map(|entry| { + let (icon, label, style) = event_style(&entry.event); + + let total_fixed = 11 + 12 + 2 + 22; + let detail_width = (inner.width as usize).saturating_sub(total_fixed); + + let mut artist = entry.artist.clone(); + if artist.len() > 20 { + artist.truncate(19); + artist.push('…'); + } + let artist_pad = 22_usize.saturating_sub(artist.len()); + + let mut detail = entry.detail.clone(); + if detail.len() > detail_width { + detail.truncate(detail_width.saturating_sub(1)); + detail.push('…'); + } + + Line::from(vec![ + Span::styled( + format!("{:<11}", entry.when), + Style::default().fg(theme::GRAY), + ), + Span::styled(format!("{:<12}", label), style), + Span::styled(format!("{} ", icon), style), + Span::styled(artist, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(artist_pad)), + Span::styled(detail, Style::default().fg(theme::GRAY)), + ]) + .into() + }) + .collect(); + + let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + frame.render_stateful_widget(list, inner, state); +} diff --git a/src/presentation/views/mod.rs b/src/presentation/views/mod.rs new file mode 100644 index 0000000..201cc90 --- /dev/null +++ b/src/presentation/views/mod.rs @@ -0,0 +1,13 @@ +//! Tab view modules. + +pub mod calendar; +pub mod history; +pub mod queue; +pub mod settings; +pub mod wanted; + +pub use calendar::render_calendar; +pub use history::render_history; +pub use queue::render_queue; +pub use settings::render_settings; +pub use wanted::render_wanted; diff --git a/src/presentation/views/queue.rs b/src/presentation/views/queue.rs new file mode 100644 index 0000000..0dbcb4d --- /dev/null +++ b/src/presentation/views/queue.rs @@ -0,0 +1,135 @@ +//! Queue view - active downloads. + +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, +}; + +use crate::data::QueueEntry; +use crate::theme; +use crate::ui::pane::Pane; + +fn progress_bar_aqua(progress: f64, width: usize) -> Vec> { + let filled = ((progress * width as f64).round() as usize).min(width); + let empty = width.saturating_sub(filled); + + vec![ + Span::styled("▰".repeat(filled), Style::default().fg(theme::AQUA)), + Span::styled("▱".repeat(empty), Style::default().fg(theme::BG3)), + ] +} + +pub fn render_queue(frame: &mut Frame, area: Rect, queue: &[QueueEntry], state: &mut ListState) { + let total_speed: f64 = queue + .iter() + .filter_map(|q| q.speed.trim_end_matches(" MB/s").parse::().ok()) + .sum(); + let meta = format!( + "{} active · {:.1}%", + queue.len(), + queue.iter().map(|q| q.progress * 100.0).sum::() / queue.len().max(1) as f64 + ); + + let footer = Line::from(vec![ + Span::styled("[x]", Style::default().fg(theme::GRAY)), + Span::styled(" remove · ", Style::default().fg(theme::FG2)), + Span::styled("[p]", Style::default().fg(theme::GRAY)), + Span::styled(" pause · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" details", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("↓ {:.1} MB/s", total_speed), + Style::default().fg(theme::AQUA), + ), + ]); + + let pane = Pane::new("Download queue") + .meta(&meta) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let header = Line::from(vec![ + Span::styled(" ", Style::default().fg(theme::GRAY)), + Span::styled("RELEASE", Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(inner.width.saturating_sub(65) as usize)), + Span::styled("INDEXER ", Style::default().fg(theme::GRAY)), + Span::styled("PROGRESS ", Style::default().fg(theme::GRAY)), + Span::styled("SPEED ", Style::default().fg(theme::GRAY)), + Span::styled("ETA", Style::default().fg(theme::GRAY)), + ]); + + let header_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + frame.render_widget(ratatui::widgets::Paragraph::new(header), header_area); + + let list_area = Rect { + x: inner.x, + y: inner.y + 1, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + + let items: Vec = queue + .iter() + .map(|entry| { + let total_fixed = 3 + 18 + 18 + 8 + 7; + let title_width = (inner.width as usize).saturating_sub(total_fixed); + + let title_artist = format!("{} · {}", entry.title, entry.artist); + let mut display_title = title_artist.clone(); + if display_title.len() > title_width { + display_title.truncate(title_width.saturating_sub(1)); + display_title.push('…'); + } + let title_pad = title_width.saturating_sub(display_title.len()); + + let mut indexer = entry.indexer.clone(); + if indexer.len() > 16 { + indexer.truncate(15); + indexer.push('…'); + } + let indexer_pad = 18_usize.saturating_sub(indexer.len()); + + let pct = (entry.progress * 100.0).round() as u8; + + let mut spans = vec![ + Span::styled("↓ ", Style::default().fg(theme::AQUA)), + Span::styled(display_title, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(title_pad)), + Span::styled(indexer, Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(indexer_pad)), + ]; + spans.extend(progress_bar_aqua(entry.progress, 12)); + spans.push(Span::styled( + format!(" {:>3}%", pct), + Style::default().fg(theme::GRAY), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{:>8}", entry.speed), + Style::default().fg(theme::GREEN), + )); + spans.push(Span::styled( + format!("{:>7}", entry.eta), + Style::default().fg(theme::YELLOW), + )); + + Line::from(spans).into() + }) + .collect(); + + let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + frame.render_stateful_widget(list, list_area, state); +} diff --git a/src/presentation/views/settings.rs b/src/presentation/views/settings.rs new file mode 100644 index 0000000..248cb87 --- /dev/null +++ b/src/presentation/views/settings.rs @@ -0,0 +1,294 @@ +//! Settings view - configuration display. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::theme; +use crate::ui::pane::Pane; + +struct SettingRow<'a> { + label: &'a str, + value: &'a str, + value_style: Style, + tag: &'a str, + tag_style: Style, +} + +struct Indexer<'a> { + name: &'a str, + priority: u8, + formats: &'a str, + state: &'a str, + tag_style: Style, + enabled: bool, +} + +pub fn render_settings(frame: &mut Frame, area: Rect) { + let footer = Line::from(vec![ + Span::styled("[Tab]", Style::default().fg(theme::GRAY)), + Span::styled(" next section · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" edit · ", Style::default().fg(theme::FG2)), + Span::styled("[:w]", Style::default().fg(theme::GRAY)), + Span::styled(" save", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled("config saved 12:04", Style::default().fg(theme::GRAY)), + ]); + + let pane = Pane::new("Settings") + .meta("config · /etc/harmony.toml") + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner); + let top_cols = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[0]); + let bot_cols = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]); + + render_library_section(frame, top_cols[0]); + render_quality_section(frame, top_cols[1]); + render_indexers_section(frame, bot_cols[0]); + render_appearance_section(frame, bot_cols[1]); +} + +fn section_block(title: &str) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BG3)) + .title(Line::from(vec![ + Span::styled("─[ ", Style::default().fg(theme::BG3)), + Span::styled(title, Style::default().fg(theme::GRAY)), + Span::styled(" ]─", Style::default().fg(theme::BG3)), + ])) + .style(Style::default().bg(theme::BG0)) +} + +fn render_setting_row(row: &SettingRow) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{:<18}", row.label), + Style::default().fg(theme::GRAY), + ), + Span::styled(row.value.to_string(), row.value_style), + Span::raw(" "), + Span::styled(format!("[{}]", row.tag), row.tag_style), + ]) +} + +fn render_library_section(frame: &mut Frame, area: Rect) { + let block = section_block("Library"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = [ + SettingRow { + label: "root folder", + value: "/home/usr/music", + value_style: Style::default().fg(theme::AQUA), + tag: "edit", + tag_style: Style::default().fg(theme::GREEN), + }, + SettingRow { + label: "naming", + value: "{artist}/{year} - {album}/…", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "cover art", + value: "embed + folder.jpg", + value_style: Style::default().fg(theme::FG1), + tag: "on", + tag_style: Style::default().fg(theme::GREEN), + }, + SettingRow { + label: "file types", + value: "flac, mp3, m4a, ogg, opus", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "replace existing", + value: "if higher quality", + value_style: Style::default().fg(theme::FG1), + tag: "cycle", + tag_style: Style::default().fg(theme::YELLOW), + }, + ]; + + let lines: Vec = rows.iter().map(render_setting_row).collect(); + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} + +fn render_quality_section(frame: &mut Frame, area: Rect) { + let block = section_block("Quality Profile"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = [ + SettingRow { + label: "profile", + value: "FLAC ▸ MP3 320 ▸ MP3 V0", + value_style: Style::default().fg(theme::YELLOW), + tag: "cycle", + tag_style: Style::default().fg(theme::GREEN), + }, + SettingRow { + label: "cutoff", + value: "FLAC 16-44", + value_style: Style::default().fg(theme::FG1), + tag: "cycle", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "min size", + value: "12 MB / track", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "max size", + value: "300 MB / track", + value_style: Style::default().fg(theme::FG1), + tag: "edit", + tag_style: Style::default().fg(theme::FG2), + }, + SettingRow { + label: "prefer single album", + value: "true", + value_style: Style::default().fg(theme::FG1), + tag: "on", + tag_style: Style::default().fg(theme::GREEN), + }, + ]; + + let lines: Vec = rows.iter().map(render_setting_row).collect(); + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} + +fn render_indexers_section(frame: &mut Frame, area: Rect) { + let block = section_block("Indexers"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let indexers = [ + Indexer { + name: "redacted.ch", + priority: 25, + formats: "FLAC, MP3", + state: "ok", + tag_style: Style::default().fg(theme::GREEN), + enabled: true, + }, + Indexer { + name: "orpheus.network", + priority: 25, + formats: "FLAC", + state: "ok", + tag_style: Style::default().fg(theme::GREEN), + enabled: true, + }, + Indexer { + name: "rutracker", + priority: 10, + formats: "FLAC, MP3", + state: "slow", + tag_style: Style::default().fg(theme::YELLOW), + enabled: true, + }, + Indexer { + name: "nzbgeek", + priority: 5, + formats: "usenet", + state: "off", + tag_style: Style::default().fg(theme::RED), + enabled: false, + }, + ]; + + let lines: Vec = indexers + .iter() + .map(|ix| { + let name_style = if ix.enabled { + Style::default().fg(theme::FG1) + } else { + Style::default().fg(theme::GRAY) + }; + Line::from(vec![ + Span::styled(format!("{:<18}", ix.name), name_style), + Span::styled( + format!("priority {} ", ix.priority), + Style::default().fg(theme::AQUA), + ), + Span::styled( + format!("· {}", ix.formats), + Style::default().fg(theme::GRAY), + ), + Span::raw(" "), + Span::styled(format!("[{}]", ix.state), ix.tag_style), + ]) + }) + .collect(); + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} + +fn render_appearance_section(frame: &mut Frame, area: Rect) { + let block = section_block("Appearance"); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled("theme ", Style::default().fg(theme::GRAY)), + Span::styled("[x] gruvbox dark", Style::default().fg(theme::YELLOW)), + Span::styled(" [ ] gruvbox light", Style::default().fg(theme::GRAY)), + Span::raw(" "), + Span::styled("[cycle]", Style::default().fg(theme::GREEN)), + ]), + Line::from(vec![ + Span::styled("font size ", Style::default().fg(theme::GRAY)), + Span::styled("JetBrains Mono · 14px", Style::default().fg(theme::FG1)), + Span::raw(" "), + Span::styled("[edit]", Style::default().fg(theme::FG2)), + ]), + Line::from(vec![ + Span::styled("scanlines (CRT) ", Style::default().fg(theme::GRAY)), + Span::styled( + "[ ] subtle scanline overlay", + Style::default().fg(theme::GRAY), + ), + Span::raw(" "), + Span::styled("[off]", Style::default().fg(theme::FG2)), + ]), + Line::from(vec![ + Span::styled("unicode glyphs ", Style::default().fg(theme::GRAY)), + Span::styled( + "box-drawing · powerline · nerd", + Style::default().fg(theme::FG1), + ), + Span::raw(" "), + Span::styled("[on]", Style::default().fg(theme::GREEN)), + ]), + ]; + + let para = Paragraph::new(lines); + frame.render_widget(para, inner); +} diff --git a/src/presentation/views/wanted.rs b/src/presentation/views/wanted.rs new file mode 100644 index 0000000..9d83f3f --- /dev/null +++ b/src/presentation/views/wanted.rs @@ -0,0 +1,123 @@ +//! Wanted view - missing albums and tracks. + +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, +}; + +use crate::data::{AlbumStatus, WantedEntry}; +use crate::theme; +use crate::ui::pane::Pane; + +fn status_icon(status: AlbumStatus) -> (char, Style) { + match status { + AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)), + AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), + _ => ('●', Style::default().fg(theme::GREEN)), + } +} + +pub fn render_wanted(frame: &mut Frame, area: Rect, wanted: &[WantedEntry], state: &mut ListState) { + let total_missing: u16 = wanted.iter().map(|w| w.missing).sum(); + let count_str = format!("{} missing or partial", wanted.len()); + + let footer = Line::from(vec![ + Span::styled("[s]", Style::default().fg(theme::GRAY)), + Span::styled(" search · ", Style::default().fg(theme::FG2)), + Span::styled("[m]", Style::default().fg(theme::GRAY)), + Span::styled(" unmonitor · ", Style::default().fg(theme::FG2)), + Span::styled("[Enter]", Style::default().fg(theme::GRAY)), + Span::styled(" open", Style::default().fg(theme::FG2)), + Span::raw(" "), + Span::styled( + format!("{} missing tracks", total_missing), + Style::default().fg(theme::GRAY), + ), + ]); + + let pane = Pane::new("Wanted") + .meta(&count_str) + .focused(true) + .footer(footer); + + let block = pane.build_block(); + let inner = block.inner(area); + frame.render_widget(block, area); + + let header = Line::from(vec![ + Span::styled(" ", Style::default().fg(theme::GRAY)), + Span::styled("ALBUM", Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(inner.width.saturating_sub(70) as usize)), + Span::styled( + "ARTIST ", + Style::default().fg(theme::GRAY), + ), + Span::styled("YEAR ", Style::default().fg(theme::GRAY)), + Span::styled("MISSING ", Style::default().fg(theme::GRAY)), + Span::styled("RELEASE DATE", Style::default().fg(theme::GRAY)), + ]); + + let header_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + frame.render_widget(ratatui::widgets::Paragraph::new(header), header_area); + + let list_area = Rect { + x: inner.x, + y: inner.y + 1, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + + let items: Vec = wanted + .iter() + .map(|entry| { + let (icon_char, icon_style) = status_icon(entry.status); + + let total_fixed = 3 + 28 + 6 + 7 + 12; + let album_width = (inner.width as usize).saturating_sub(total_fixed); + + let mut album = entry.album.clone(); + if album.len() > album_width { + album.truncate(album_width.saturating_sub(1)); + album.push('…'); + } + let album_pad = album_width.saturating_sub(album.len()); + + let mut artist = entry.artist.clone(); + if artist.len() > 26 { + artist.truncate(25); + artist.push('…'); + } + let artist_pad = 28_usize.saturating_sub(artist.len()); + + Line::from(vec![ + Span::styled(format!("{} ", icon_char), icon_style), + Span::styled(album, Style::default().fg(theme::FG1)), + Span::raw(" ".repeat(album_pad)), + Span::styled(artist, Style::default().fg(theme::GRAY)), + Span::raw(" ".repeat(artist_pad)), + Span::styled( + format!("{:<6}", entry.year), + Style::default().fg(theme::GRAY), + ), + Span::styled( + format!("{:>7}", entry.missing), + Style::default().fg(theme::RED), + ), + Span::raw(" "), + Span::styled(&entry.release_date, Style::default().fg(theme::GRAY)), + ]) + .into() + }) + .collect(); + + let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0)); + frame.render_stateful_widget(list, list_area, state); +} diff --git a/src/ui/library.rs b/src/ui/library.rs index a7ded71..b01adf9 100644 --- a/src/ui/library.rs +++ b/src/ui/library.rs @@ -1,661 +1,2 @@ -#![allow(dead_code)] - -use std::collections::HashMap; - -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{List, ListItem, ListState, Paragraph}, -}; - -use crate::data::{Album, AlbumStatus, Artist, MonitorState, Track}; -use crate::theme; -use crate::ui::pane::{Pane, section_divider}; -use crate::ui::progress_bar::progress_bar; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LibraryFocus { - #[default] - Artists, - Albums, - Tracks, -} - -pub struct LibraryState { - pub artists: Vec, - pub tracks: Vec, - pub focus: LibraryFocus, - pub artist_state: ListState, - pub album_state: ListState, - pub track_state: ListState, - tracks_cache: HashMap>, - pending_album_id: Option, -} - -impl LibraryState { - pub fn new(artists: Vec) -> Self { - let mut artist_state = ListState::default(); - let mut album_state = ListState::default(); - let mut track_state = ListState::default(); - - if !artists.is_empty() { - artist_state.select(Some(0)); - if !artists[0].albums.is_empty() { - album_state.select(Some(0)); - track_state.select(Some(0)); - } - } - - Self { - artists, - tracks: Vec::new(), - focus: LibraryFocus::Artists, - artist_state, - album_state, - track_state, - tracks_cache: HashMap::new(), - pending_album_id: None, - } - } - - pub fn selected_artist(&self) -> Option<&Artist> { - self.artist_state - .selected() - .and_then(|i| self.artists.get(i)) - } - - pub fn selected_album(&self) -> Option<&Album> { - self.selected_artist() - .and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i))) - } - - pub fn move_up(&mut self) { - match self.focus { - LibraryFocus::Artists => { - if let Some(i) = self.artist_state.selected() - && i > 0 - { - self.artist_state.select(Some(i - 1)); - self.reset_album_selection(); - } - } - LibraryFocus::Albums => { - if let Some(i) = self.album_state.selected() - && i > 0 - { - self.album_state.select(Some(i - 1)); - self.reset_track_selection(); - } - } - LibraryFocus::Tracks => { - if let Some(i) = self.track_state.selected() - && i > 0 - { - self.track_state.select(Some(i - 1)); - } - } - } - } - - pub fn move_down(&mut self) { - match self.focus { - LibraryFocus::Artists => { - let max = self.artists.len().saturating_sub(1); - if let Some(i) = self.artist_state.selected() - && i < max - { - self.artist_state.select(Some(i + 1)); - self.reset_album_selection(); - } - } - LibraryFocus::Albums => { - let max = self - .selected_artist() - .map(|a| a.albums.len().saturating_sub(1)) - .unwrap_or(0); - if let Some(i) = self.album_state.selected() - && i < max - { - self.album_state.select(Some(i + 1)); - self.reset_track_selection(); - } - } - LibraryFocus::Tracks => { - let max = self.track_count().saturating_sub(1); - if let Some(i) = self.track_state.selected() - && i < max - { - self.track_state.select(Some(i + 1)); - } - } - } - } - - fn track_count(&self) -> usize { - self.selected_album().map(|a| a.total as usize).unwrap_or(0) - } - - pub fn focus_left(&mut self) { - match self.focus { - LibraryFocus::Artists => {} - LibraryFocus::Albums => self.focus = LibraryFocus::Artists, - LibraryFocus::Tracks => self.focus = LibraryFocus::Albums, - } - } - - pub fn focus_right(&mut self) { - match self.focus { - LibraryFocus::Artists => { - if self.selected_artist().is_some() { - self.focus = LibraryFocus::Albums; - } - } - LibraryFocus::Albums => { - if self.selected_album().is_some() { - self.focus = LibraryFocus::Tracks; - } - } - LibraryFocus::Tracks => {} - } - } - - pub fn cycle_focus(&mut self) { - self.focus = match self.focus { - LibraryFocus::Artists => LibraryFocus::Albums, - LibraryFocus::Albums => LibraryFocus::Tracks, - LibraryFocus::Tracks => LibraryFocus::Artists, - }; - } - - fn reset_album_selection(&mut self) { - if let Some(artist) = self.selected_artist() { - if !artist.albums.is_empty() { - self.album_state.select(Some(0)); - } else { - self.album_state.select(None); - } - } - self.reset_track_selection(); - } - - fn reset_track_selection(&mut self) { - if self.selected_album().is_some() { - self.track_state.select(Some(0)); - } else { - self.track_state.select(None); - } - } - - pub fn artist_count(&self) -> usize { - self.artists.len() - } - - pub fn selected_artist_index(&self) -> Option { - self.artist_state.selected() - } -} - -fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) { - if !monitored { - return ('◌', Style::default().fg(theme::GRAY)); - } - match status { - AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)), - AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), - AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)), - AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)), - } -} - -fn monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) { - match state { - MonitorState::Monitored => match status { - AlbumStatus::Complete => ('✓', Style::default().fg(theme::GREEN)), - AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), - AlbumStatus::Wanted => ('!', Style::default().fg(theme::RED)), - AlbumStatus::Unmonitored => ('-', Style::default().fg(theme::GRAY)), - }, - MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)), - MonitorState::Excluded => ('x', Style::default().fg(theme::RED)), - MonitorState::Unspecified => ('?', Style::default().fg(theme::GRAY)), - } -} - -fn track_icon(have: bool) -> (char, Style) { - if have { - ('✓', Style::default().fg(theme::GREEN)) - } else { - ('✗', Style::default().fg(theme::RED)) - } -} - -fn artist_status(artist: &Artist) -> AlbumStatus { - let total: u16 = artist.albums.iter().map(|a| a.total).sum(); - let have: u16 = artist.albums.iter().map(|a| a.have).sum(); - if have == total { - AlbumStatus::Complete - } else if have == 0 { - AlbumStatus::Wanted - } else { - AlbumStatus::Partial - } -} - -fn fmt_size(gb: f64) -> String { - if gb >= 1.0 { - format!("{:.1} GB", gb) - } else { - format!("{} MB", (gb * 1024.0).round() as u32) - } -} - -pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) { - let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area); - - render_artists_pane(frame, chunks[0], state); - render_detail_pane(frame, chunks[1], state); -} - -fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { - let focused = state.focus == LibraryFocus::Artists; - let artist_count = state.artists.len(); - - let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum(); - let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum(); - - let footer = Line::from(vec![ - Span::styled( - format!("{} artists · {} alb", artist_count, total_albums), - Style::default().fg(theme::GRAY), - ), - Span::raw(" "), - Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)), - ]); - - let artist_count_str = artist_count.to_string(); - let pane = Pane::new("Artists") - .meta(&artist_count_str) - .focused(focused) - .footer(footer); - - let block = pane.build_block(); - let inner = block.inner(area); - frame.render_widget(block, area); - - let items: Vec = state - .artists - .iter() - .map(|artist| { - let status = artist_status(artist); - let (icon_char, icon_style) = monitor_state_icon(artist.monitor_state, status); - - let total: u16 = artist.albums.iter().map(|a| a.total).sum(); - let have: u16 = artist.albums.iter().map(|a| a.have).sum(); - - let mut name_text = artist.name.clone(); - - let count_str = format!("{}/{}", have, total); - let name_width = inner.width as usize - 2 - count_str.len() - 2; - if name_text.len() > name_width { - name_text.truncate(name_width.saturating_sub(1)); - name_text.push('…'); - } - - let padding = name_width.saturating_sub(name_text.len()); - - Line::from(vec![ - Span::styled(format!("{} ", icon_char), icon_style), - Span::styled(name_text, Style::default().fg(theme::FG1)), - Span::raw(" ".repeat(padding)), - Span::styled(count_str, Style::default().fg(theme::GRAY)), - ]) - .into() - }) - .collect(); - - let highlight_style = if focused { - Style::default().bg(theme::YELLOW).fg(theme::BG0) - } else { - Style::default().bg(theme::SELECT_BG).fg(theme::FG1) - }; - - let list = List::new(items).highlight_style(highlight_style); - frame.render_stateful_widget(list, inner, &mut state.artist_state); -} - -fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { - let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks; - - let artist = state.selected_artist(); - - let meta = artist - .map(|a| { - format!( - "{} · {}", - a.country, - a.genres.first().map(|s| s.as_str()).unwrap_or("") - ) - }) - .unwrap_or_default(); - - let have_tracks: u16 = artist - .map(|a| a.albums.iter().map(|al| al.have).sum()) - .unwrap_or(0); - let total_tracks: u16 = artist - .map(|a| a.albums.iter().map(|al| al.total).sum()) - .unwrap_or(0); - - let footer = if artist.is_some() { - Line::from(vec![Span::styled( - format!("{}/{} tracks", have_tracks, total_tracks), - Style::default().fg(theme::GRAY), - )]) - } else { - Line::from("") - }; - - let pane = Pane::new("Detail") - .meta(&meta) - .focused(focused) - .footer(footer); - - let block = pane.build_block(); - let inner = block.inner(area); - frame.render_widget(block, area); - - let Some(artist) = artist else { - let msg = Paragraph::new(Span::styled( - "No artist selected", - Style::default().fg(theme::GRAY), - )); - frame.render_widget(msg, inner); - return; - }; - - let chunks = Layout::vertical([ - Constraint::Length(6), - Constraint::Length(1), - Constraint::Percentage(40), - Constraint::Length(1), - Constraint::Fill(1), - ]) - .split(inner); - - render_artist_header(frame, chunks[0], artist); - - let albums_count = artist.albums.len(); - let albums_label = format!("{} releases", albums_count); - let album_divider = section_divider("albums", Some(&albums_label)); - frame.render_widget(Paragraph::new(album_divider), chunks[1]); - - let selected_artist_idx = state.artist_state.selected(); - if let Some(idx) = selected_artist_idx - && let Some(artist) = state.artists.get(idx) - { - let albums = artist.albums.clone(); - let focus = state.focus; - render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state); - } - - let album_title = state - .selected_album() - .map(|a| a.title.clone()) - .unwrap_or_default(); - let track_counts = state - .selected_album() - .map(|a| format!("{}/{}", a.have, a.total)) - .unwrap_or_default(); - let track_label = format!("tracks · {}", album_title); - let track_divider = section_divider(&track_label, Some(&track_counts)); - frame.render_widget(Paragraph::new(track_divider), chunks[3]); - - render_tracks_list(frame, chunks[4], state); -} - -fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) { - let have: u16 = artist.albums.iter().map(|a| a.have).sum(); - let total: u16 = artist.albums.iter().map(|a| a.total).sum(); - - let (status_icon, status_text, status_style) = match artist.monitor_state { - MonitorState::Monitored => ( - Span::styled("● ", Style::default().fg(theme::GREEN)), - "Monitored", - Style::default().fg(theme::FG2), - ), - MonitorState::Unmonitored => ( - Span::styled("◌ ", Style::default().fg(theme::GRAY)), - "Unmonitored", - Style::default().fg(theme::GRAY), - ), - MonitorState::Excluded => ( - Span::styled("⊘ ", Style::default().fg(theme::RED)), - "Excluded", - Style::default().fg(theme::RED), - ), - MonitorState::Unspecified => ( - Span::styled("? ", Style::default().fg(theme::GRAY)), - "Unknown", - Style::default().fg(theme::GRAY), - ), - }; - - let lines = vec![ - Line::from(Span::styled( - &artist.name, - Style::default() - .fg(theme::YELLOW) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(vec![ - Span::styled("status ", Style::default().fg(theme::GRAY)), - status_icon, - Span::styled(status_text, status_style), - Span::raw(" "), - Span::styled("path ", Style::default().fg(theme::GRAY)), - Span::styled(&artist.path, Style::default().fg(theme::AQUA)), - ]), - Line::from(vec![ - Span::styled("quality ", Style::default().fg(theme::GRAY)), - Span::styled(&artist.quality, Style::default().fg(theme::FG1)), - Span::raw(" "), - Span::styled("size ", Style::default().fg(theme::GRAY)), - Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)), - ]), - Line::from(vec![ - Span::styled("albums ", Style::default().fg(theme::GRAY)), - Span::styled( - artist.albums.len().to_string(), - Style::default().fg(theme::FG1), - ), - Span::raw(" "), - Span::styled("tracks ", Style::default().fg(theme::GRAY)), - Span::styled(have.to_string(), Style::default().fg(theme::FG1)), - Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)), - ]), - ]; - - let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0)); - frame.render_widget(paragraph, area); -} - -fn render_albums_list( - frame: &mut Frame, - area: Rect, - albums: &[Album], - focus: LibraryFocus, - album_state: &mut ListState, -) { - let focused = focus == LibraryFocus::Albums; - - let items: Vec = albums - .iter() - .map(|album| { - let (icon_char, icon_style) = status_icon(album.status, album.monitored); - let type_str = format!("[{}]", album.album_type); - let year_str = album.year.to_string(); - let progress = progress_bar(album.have, album.total, 10, album.status); - let count_str = format!("{}/{}", album.have, album.total); - - let quality_style = if album.quality == "—" { - Style::default().fg(theme::GRAY) - } else { - Style::default().fg(theme::AQUA) - }; - - let title_width = - area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8; - let mut title = album.title.clone(); - if title.len() > title_width { - title.truncate(title_width.saturating_sub(1)); - title.push('…'); - } - - let mut spans = vec![ - Span::styled(format!("{} ", icon_char), icon_style), - Span::styled(title, Style::default().fg(theme::FG1)), - Span::raw(" "), - Span::styled(type_str, Style::default().fg(theme::GRAY)), - Span::raw(" "), - Span::styled(year_str, Style::default().fg(theme::GRAY)), - Span::raw(" "), - ]; - spans.extend(progress.spans); - spans.push(Span::raw(" ")); - spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY))); - spans.push(Span::raw(" ")); - spans.push(Span::styled(&album.quality, quality_style)); - - Line::from(spans).into() - }) - .collect(); - - let highlight_style = if focused { - Style::default().bg(theme::YELLOW).fg(theme::BG0) - } else { - Style::default().bg(theme::SELECT_BG).fg(theme::FG1) - }; - - let list = List::new(items).highlight_style(highlight_style); - frame.render_stateful_widget(list, area, album_state); -} - -fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) { - let focused = state.focus == LibraryFocus::Tracks; - - if state.tracks.is_empty() { - let msg = Paragraph::new(Span::styled( - "(no album selected)", - Style::default().fg(theme::GRAY), - )); - frame.render_widget(msg, area); - return; - } - - let items: Vec = state - .tracks - .iter() - .map(|track| { - let (icon_char, icon_style) = track_icon(track.have); - let num_str = format!("{:02}", track.number); - - let title_style = if track.have { - Style::default().fg(theme::FG1) - } else { - Style::default().fg(theme::GRAY) - }; - - let quality_style = if track.have { - Style::default().fg(theme::AQUA) - } else { - Style::default().fg(theme::RED) - }; - - Line::from(vec![ - Span::styled(format!("{} ", icon_char), icon_style), - Span::styled(num_str, Style::default().fg(theme::GRAY)), - Span::raw(" "), - Span::styled(track.title.clone(), title_style), - Span::raw(" "), - Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)), - Span::raw(" "), - Span::styled(track.quality.clone(), quality_style), - ]) - .into() - }) - .collect(); - - let highlight_style = if focused { - Style::default().bg(theme::YELLOW).fg(theme::BG0) - } else { - Style::default().bg(theme::SELECT_BG).fg(theme::FG1) - }; - - let list = List::new(items).highlight_style(highlight_style); - frame.render_stateful_widget(list, area, &mut state.track_state); -} - -impl LibraryState { - pub fn get_tracks(&self) -> &[Track] { - &self.tracks - } - - pub fn cache_tracks(&mut self, album_id: String, tracks: Vec) { - self.tracks_cache.insert(album_id.clone(), tracks); - - if self.pending_album_id.as_ref() == Some(&album_id) { - self.pending_album_id = None; - self.load_tracks_from_cache(&album_id); - } - } - - pub fn load_tracks_from_cache(&mut self, album_id: &str) { - if let Some(tracks) = self.tracks_cache.get(album_id) { - self.tracks = tracks.clone(); - self.track_state.select(if self.tracks.is_empty() { - None - } else { - Some(0) - }); - } - } - - pub fn get_cached_tracks(&self, album_id: &str) -> Option<&Vec> { - self.tracks_cache.get(album_id) - } - - pub fn needs_fetch(&mut self) -> Option { - let current_album_id = self.selected_album().map(|a| a.id.clone())?; - - if current_album_id.is_empty() { - return None; - } - - if self.tracks_cache.contains_key(¤t_album_id) { - if self.pending_album_id.as_ref() != Some(¤t_album_id) { - self.load_tracks_from_cache(¤t_album_id); - } - return None; - } - - if self.pending_album_id.as_ref() == Some(¤t_album_id) { - return None; - } - - self.pending_album_id = Some(current_album_id.clone()); - self.tracks.clear(); - Some(current_album_id) - } - - pub fn selected_album_id(&self) -> Option { - self.selected_album().map(|a| a.id.clone()) - } - - pub fn clear_cache(&mut self) { - self.tracks_cache.clear(); - self.pending_album_id = None; - self.tracks.clear(); - } -} +pub use crate::application::library_state::{LibraryFocus, LibraryState}; +pub use crate::presentation::library::render_library; diff --git a/src/ui/modals/mod.rs b/src/ui/modals/mod.rs index 39ce87a..30a345d 100644 --- a/src/ui/modals/mod.rs +++ b/src/ui/modals/mod.rs @@ -1,12 +1,2 @@ -pub mod help; -pub mod quit; - -pub use help::render_help_modal; -pub use quit::render_quit_modal; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum ModalKind { - Help, - Quit, -} +pub use crate::domain::navigation::ModalKind; +pub use crate::presentation::modals::{render_help_modal, render_quit_modal}; diff --git a/src/ui/notifications.rs b/src/ui/notifications.rs index fca0f91..6e46c68 100644 --- a/src/ui/notifications.rs +++ b/src/ui/notifications.rs @@ -1,260 +1,2 @@ -#![allow(dead_code)] - -use std::time::Instant; - -use ratatui::{ - Frame, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::theme; - -const NOTIFICATION_TTL_SECS: u64 = 6; -const MAX_VISIBLE: usize = 5; -const MAX_HISTORY: usize = 100; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotifKind { - Info, - Success, - Warn, - Error, -} - -impl NotifKind { - pub fn color(self) -> ratatui::style::Color { - match self { - NotifKind::Info => theme::BLUE, - NotifKind::Success => theme::GREEN, - NotifKind::Warn => theme::YELLOW, - NotifKind::Error => theme::RED, - } - } - - pub fn label(self) -> &'static str { - match self { - NotifKind::Info => "Info", - NotifKind::Success => "Success", - NotifKind::Warn => "Warning", - NotifKind::Error => "Error", - } - } -} - -#[derive(Clone)] -pub struct Notification { - pub id: u64, - pub title: String, - pub detail: Option, - pub kind: NotifKind, - pub icon: String, - pub created_at: Instant, -} - -pub struct NotificationManager { - active: Vec, - history: Vec, - next_id: u64, -} - -impl Default for NotificationManager { - fn default() -> Self { - Self::new() - } -} - -impl NotificationManager { - pub fn new() -> Self { - Self { - active: Vec::new(), - history: Vec::new(), - next_id: 1, - } - } - - pub fn push( - &mut self, - title: impl Into, - detail: Option, - kind: NotifKind, - icon: impl Into, - ) { - let notification = Notification { - id: self.next_id, - title: title.into(), - detail, - kind, - icon: icon.into(), - created_at: Instant::now(), - }; - self.next_id += 1; - - self.history.push(notification.clone()); - if self.history.len() > MAX_HISTORY { - self.history.remove(0); - } - - self.active.push(notification); - } - - pub fn tick(&mut self) { - let now = Instant::now(); - self.active - .retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS); - } - - pub fn history(&self) -> &[Notification] { - &self.history - } - - pub fn render(&self, frame: &mut Frame, area: Rect) { - let visible: Vec<&Notification> = self - .active - .iter() - .rev() - .take(MAX_VISIBLE) - .collect::>() - .into_iter() - .rev() - .collect(); - - if visible.is_empty() { - return; - } - - let notif_width = 50u16.min(area.width.saturating_sub(4)); - let notif_height = 3u16; - let spacing = 1u16; - let total_height = visible.len() as u16 * (notif_height + spacing); - - let start_y = area.y + area.height.saturating_sub(total_height + 1); - let start_x = area.x + area.width.saturating_sub(notif_width + 2); - - for (i, notif) in visible.iter().enumerate() { - let y = start_y + (i as u16) * (notif_height + spacing); - let notif_area = Rect::new(start_x, y, notif_width, notif_height); - - frame.render_widget(Clear, notif_area); - - let border_color = notif.kind.color(); - let block = Block::default() - .borders(Borders::LEFT) - .border_style(Style::default().fg(border_color)) - .style(Style::default().bg(theme::BG1)); - - let inner = block.inner(notif_area); - frame.render_widget(block, notif_area); - - let elapsed = Instant::now().duration_since(notif.created_at).as_secs(); - let timestamp = if elapsed == 0 { - "now".to_string() - } else { - format!("{}s", elapsed) - }; - - let mut lines = vec![Line::from(vec![ - Span::styled(¬if.icon, Style::default().fg(border_color)), - Span::raw(" "), - Span::styled( - ¬if.title, - Style::default() - .fg(theme::FG1) - .add_modifier(ratatui::style::Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(timestamp, Style::default().fg(theme::GRAY)), - ])]; - - if let Some(detail) = ¬if.detail { - let max_len = inner.width.saturating_sub(2) as usize; - let d = if detail.len() > max_len { - format!("{}…", &detail[..max_len.saturating_sub(1)]) - } else { - detail.clone() - }; - lines.push(Line::from(Span::styled( - d, - Style::default().fg(theme::GRAY), - ))); - } - - let para = Paragraph::new(lines); - frame.render_widget(para, inner); - } - } - - pub fn active_count(&self) -> usize { - self.active.len() - } - - pub fn history_count(&self) -> usize { - self.history.len() - } -} - -pub fn render_notification_item(frame: &mut Frame, area: Rect, notif: &Notification) { - let border_color = notif.kind.color(); - let block = Block::default() - .borders(Borders::LEFT) - .border_style(Style::default().fg(border_color)) - .style(Style::default().bg(theme::BG1)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let elapsed = Instant::now().duration_since(notif.created_at).as_secs(); - let timestamp = format_elapsed(elapsed); - - let mut lines = vec![Line::from(vec![ - Span::styled(¬if.icon, Style::default().fg(border_color)), - Span::raw(" "), - Span::styled( - notif.kind.label(), - Style::default() - .fg(theme::FG1) - .add_modifier(ratatui::style::Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(timestamp, Style::default().fg(theme::GRAY)), - ])]; - - if let Some(detail) = ¬if.detail { - let max_len = inner.width.saturating_sub(2) as usize; - let d = if detail.len() > max_len { - format!("{}…", &detail[..max_len.saturating_sub(1)]) - } else { - detail.clone() - }; - lines.push(Line::from(Span::styled( - d, - Style::default().fg(theme::GRAY), - ))); - } else { - let max_len = inner.width.saturating_sub(2) as usize; - let title = if notif.title.len() > max_len { - format!("{}…", ¬if.title[..max_len.saturating_sub(1)]) - } else { - notif.title.clone() - }; - lines.push(Line::from(Span::styled( - title, - Style::default().fg(theme::GRAY), - ))); - } - - let para = Paragraph::new(lines); - frame.render_widget(para, inner); -} - -fn format_elapsed(secs: u64) -> String { - if secs < 60 { - format!("{}s", secs) - } else if secs < 3600 { - format!("{}m", secs / 60) - } else { - format!("{}h", secs / 3600) - } -} +pub use crate::application::notification_state::*; +pub use crate::presentation::notifications::render_notification_item; diff --git a/src/ui/pane.rs b/src/ui/pane.rs index ee83951..b09c4c2 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -1,111 +1 @@ -//! Reusable Pane widget with styled borders and title. - -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Widget}, -}; - -use crate::theme; - -pub struct Pane<'a> { - title: &'a str, - meta: Option<&'a str>, - focused: bool, - footer: Option>, -} - -impl<'a> Pane<'a> { - pub fn new(title: &'a str) -> Self { - Self { - title, - meta: None, - focused: false, - footer: None, - } - } - - pub fn meta(mut self, meta: &'a str) -> Self { - self.meta = Some(meta); - self - } - - pub fn focused(mut self, focused: bool) -> Self { - self.focused = focused; - self - } - - pub fn footer(mut self, footer: Line<'a>) -> Self { - self.footer = Some(footer); - self - } - - pub fn build_block(&self) -> Block<'a> { - let border_color = if self.focused { - theme::YELLOW - } else { - theme::BG3 - }; - let title_color = if self.focused { - theme::YELLOW - } else { - theme::GRAY - }; - - let mut title_spans = vec![ - Span::styled("─[ ", Style::default().fg(border_color)), - Span::styled(self.title, Style::default().fg(title_color)), - ]; - - if let Some(meta) = self.meta { - title_spans.push(Span::styled(" · ", Style::default().fg(theme::GRAY))); - title_spans.push(Span::styled(meta, Style::default().fg(theme::GRAY))); - } - - title_spans.push(Span::styled(" ]─", Style::default().fg(border_color))); - - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .title(Line::from(title_spans)) - .style(Style::default().bg(theme::BG0)) - } -} - -impl Widget for Pane<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - let block = self.build_block(); - block.render(area, buf); - - if let Some(footer) = self.footer - && area.height > 2 - { - let footer_y = area.y + area.height - 1; - let footer_x = area.x + 2; - let footer_width = area.width.saturating_sub(4); - - if footer_width > 0 { - buf.set_line(footer_x, footer_y, &footer, footer_width); - } - } - } -} - -pub fn section_divider<'a>(label: &'a str, right: Option<&'a str>) -> Line<'a> { - let mut spans = vec![ - Span::styled("─ ", Style::default().fg(theme::BG3)), - Span::styled(label, Style::default().fg(theme::GRAY)), - Span::styled(" ─", Style::default().fg(theme::BG3)), - ]; - - if let Some(r) = right { - spans.push(Span::styled( - format!(" {}", r), - Style::default().fg(theme::GRAY), - )); - } - - Line::from(spans) -} +pub use crate::presentation::pane::*; diff --git a/src/ui/progress_bar.rs b/src/ui/progress_bar.rs index 27864cf..b607808 100644 --- a/src/ui/progress_bar.rs +++ b/src/ui/progress_bar.rs @@ -1,39 +1 @@ -//! Unicode progress bar widget. - -use ratatui::{ - style::Style, - text::{Line, Span}, -}; - -use crate::data::AlbumStatus; -use crate::theme; - -/// Renders a unicode progress bar using ▰ (filled) and ▱ (empty). -/// Returns a Line with colored spans based on status: -/// - Complete: green filled -/// - Partial: yellow filled -/// - Wanted: red filled -/// - Unmonitored: gray filled -pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) -> Line<'static> { - let filled_count = if total == 0 { - 0 - } else { - (have as usize * width).div_ceil(total as usize) - }; - let empty_count = width.saturating_sub(filled_count); - - let filled_color = match status { - AlbumStatus::Complete => theme::GREEN, - AlbumStatus::Partial => theme::YELLOW, - AlbumStatus::Wanted => theme::RED, - AlbumStatus::Unmonitored => theme::GRAY, - }; - - let filled_str: String = "▰".repeat(filled_count); - let empty_str: String = "▱".repeat(empty_count); - - Line::from(vec![ - Span::styled(filled_str, Style::default().fg(filled_color)), - Span::styled(empty_str, Style::default().fg(theme::BG3)), - ]) -} +pub use crate::presentation::progress_bar::*; diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 76728cb..f68932d 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -1,94 +1 @@ -use nix::sys::statvfs::statvfs; -use ratatui::{ - Frame, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, -}; - -use crate::theme; - -fn get_free_space() -> String { - match statvfs("/") { - Ok(stat) => { - let free_bytes = stat.blocks_available() * stat.fragment_size(); - let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0); - if free_gb >= 1000.0 { - format!("{:.1} TB free", free_gb / 1024.0) - } else { - format!("{:.1} GB free", free_gb) - } - } - Err(_) => "-- GB free".to_string(), - } -} - -pub fn render_statusbar( - frame: &mut Frame, - area: Rect, - position: Option<(usize, usize)>, - queue_count: usize, - wanted_count: usize, -) { - let mut spans = Vec::new(); - - spans.push(Span::styled(" ", Style::default().bg(theme::BG2))); - - let left_width: usize = spans.iter().map(|s| s.content.len()).sum(); - - let mut right_spans = Vec::new(); - - if let Some((current, total)) = position { - right_spans.push(Span::styled( - format!(" {}/{} ", current, total), - Style::default().fg(theme::FG2).bg(theme::BG2), - )); - } - - if queue_count > 0 { - right_spans.push(Span::styled( - format!(" {} {} ", '\u{2193}', queue_count), - Style::default() - .fg(theme::BG0) - .bg(theme::YELLOW) - .add_modifier(Modifier::BOLD), - )); - } - - if wanted_count > 0 { - right_spans.push(Span::styled( - format!(" ! {} ", wanted_count), - Style::default() - .fg(theme::BG0) - .bg(theme::BLUE) - .add_modifier(Modifier::BOLD), - )); - } - - right_spans.push(Span::styled( - format!(" {} ", get_free_space()), - Style::default().fg(theme::GRAY).bg(theme::BG2), - )); - - right_spans.push(Span::styled( - " harmony 0.4.2 ", - Style::default().fg(theme::GRAY).bg(theme::BG2), - )); - - let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum(); - let spacer_width = area - .width - .saturating_sub(left_width as u16) - .saturating_sub(right_width as u16) as usize; - - spans.push(Span::styled( - " ".repeat(spacer_width), - Style::default().bg(theme::BG2), - )); - spans.extend(right_spans); - - let line = Line::from(spans); - let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG2)); - frame.render_widget(paragraph, area); -} +pub use crate::presentation::statusbar::*; diff --git a/src/ui/topbar.rs b/src/ui/topbar.rs index 7625127..e87d2cb 100644 --- a/src/ui/topbar.rs +++ b/src/ui/topbar.rs @@ -1,147 +1 @@ -use ratatui::{ - Frame, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, -}; - -use crate::app::Tab; -use crate::theme; - -pub struct TopbarAreas { - pub tabs: Vec, - pub notifications: Rect, -} - -pub fn render_topbar( - frame: &mut Frame, - area: Rect, - active_tab: Tab, - queue_count: usize, - wanted_count: usize, - notification_count: usize, - notifications_open: bool, -) -> TopbarAreas { - let mut spans = Vec::new(); - let mut tab_areas = Vec::new(); - let mut current_x = area.x; - - let logo = " ▲ harmony "; - spans.push(Span::styled( - logo, - Style::default() - .fg(theme::BG0) - .bg(theme::ORANGE) - .add_modifier(Modifier::BOLD), - )); - current_x += logo.len() as u16; - - spans.push(Span::raw(" ")); - current_x += 1; - - let tabs = [ - (Tab::Library, "Library", None), - ( - Tab::Wanted, - "Wanted", - if wanted_count > 0 { - Some(wanted_count) - } else { - None - }, - ), - ( - Tab::Queue, - "Queue", - if queue_count > 0 { - Some(queue_count) - } else { - None - }, - ), - (Tab::History, "History", None), - (Tab::Calendar, "Calendar", None), - (Tab::Settings, "Settings", None), - ]; - - for (tab, label, badge) in tabs.iter() { - let is_active = *tab == active_tab; - - let tab_start = current_x; - let text = format!(" {} ", label); - let mut tab_width = text.len() as u16; - - if is_active { - spans.push(Span::styled( - text, - Style::default() - .fg(theme::YELLOW) - .bg(theme::BG0) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled( - text, - Style::default().fg(theme::FG3).bg(theme::BG1), - )); - } - - current_x += tab_width; - - if let Some(count) = badge { - let badge_text = format!(" {} ", count); - let badge_width = badge_text.len() as u16; - spans.push(Span::styled( - badge_text, - Style::default() - .fg(theme::BG0) - .bg(theme::RED) - .add_modifier(Modifier::BOLD), - )); - tab_width += badge_width; - current_x += badge_width; - } - - tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1)); - } - - let notif_text = if notification_count > 0 { - format!(" ● Notifications ({}) ", notification_count) - } else { - " ● Notifications ".to_string() - }; - let notif_width = notif_text.len() as u16; - let notif_x = area.x + area.width - notif_width; - - let remaining = (notif_x - current_x) as usize; - if remaining > 0 { - spans.push(Span::styled( - " ".repeat(remaining), - Style::default().bg(theme::BG1), - )); - } - - let notif_style = if notifications_open { - Style::default() - .fg(theme::YELLOW) - .bg(theme::BG0) - .add_modifier(Modifier::BOLD) - } else if notification_count > 0 { - Style::default().fg(theme::YELLOW).bg(theme::BG1) - } else { - Style::default().fg(theme::FG3).bg(theme::BG1) - }; - spans.push(Span::styled(¬if_text, notif_style)); - - let notifications_area = Rect::new(notif_x, area.y, notif_width, 1); - - let line = Line::from(spans); - let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1)); - frame.render_widget(paragraph, area); - - TopbarAreas { - tabs: tab_areas, - notifications: notifications_area, - } -} +pub use crate::presentation::topbar::*; diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs index 201cc90..352c3f9 100644 --- a/src/ui/views/mod.rs +++ b/src/ui/views/mod.rs @@ -1,13 +1 @@ -//! Tab view modules. - -pub mod calendar; -pub mod history; -pub mod queue; -pub mod settings; -pub mod wanted; - -pub use calendar::render_calendar; -pub use history::render_history; -pub use queue::render_queue; -pub use settings::render_settings; -pub use wanted::render_wanted; +pub use crate::presentation::views::*;