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.
This commit is contained in:
@@ -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.
|
||||
+2
-783
@@ -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<ModalKind>,
|
||||
pub wanted: Vec<WantedEntry>,
|
||||
pub wanted_state: ListState,
|
||||
pub queue: Vec<QueueEntry>,
|
||||
pub queue_state: ListState,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
pub history_state: ListState,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub notifications: NotificationManager,
|
||||
pub notifications_open: bool,
|
||||
pub notifications_scroll: usize,
|
||||
pub notifications_expanded: Option<u64>,
|
||||
topbar_area: Rect,
|
||||
main_area: Rect,
|
||||
statusbar_area: Rect,
|
||||
tab_areas: Vec<Rect>,
|
||||
notifications_btn_area: Rect,
|
||||
notifications_dropdown_area: Rect,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
let artists: Vec<Artist> = Vec::new();
|
||||
let wanted: Vec<WantedEntry> = Vec::new();
|
||||
let queue: Vec<QueueEntry> = Vec::new();
|
||||
let history: Vec<HistoryEntry> = Vec::new();
|
||||
let calendar: Vec<CalendarEntry> = 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<Artist> = 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<Track> = 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<String> {
|
||||
self.library.needs_fetch()
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_artist(summary: ArtistSummary) -> Artist {
|
||||
let albums: Vec<Album> = 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;
|
||||
|
||||
@@ -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<ModalKind>,
|
||||
pub wanted: Vec<WantedEntry>,
|
||||
pub wanted_state: ListState,
|
||||
pub queue: Vec<QueueEntry>,
|
||||
pub queue_state: ListState,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
pub history_state: ListState,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub notifications: NotificationManager,
|
||||
pub notifications_open: bool,
|
||||
pub notifications_scroll: usize,
|
||||
pub notifications_expanded: Option<u64>,
|
||||
pub(crate) topbar_area: Rect,
|
||||
pub(crate) main_area: Rect,
|
||||
pub(crate) statusbar_area: Rect,
|
||||
pub(crate) tab_areas: Vec<Rect>,
|
||||
pub(crate) notifications_btn_area: Rect,
|
||||
pub(crate) notifications_dropdown_area: Rect,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
let artists: Vec<Artist> = Vec::new();
|
||||
let wanted: Vec<WantedEntry> = Vec::new();
|
||||
let queue: Vec<QueueEntry> = Vec::new();
|
||||
let history: Vec<HistoryEntry> = Vec::new();
|
||||
let calendar: Vec<CalendarEntry> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<Artist> = 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<Track> = 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<String> {
|
||||
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));
|
||||
}
|
||||
@@ -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<Artist>,
|
||||
pub tracks: Vec<Track>,
|
||||
pub focus: LibraryFocus,
|
||||
pub artist_state: ListState,
|
||||
pub album_state: ListState,
|
||||
pub track_state: ListState,
|
||||
tracks_cache: HashMap<String, Vec<Track>>,
|
||||
pending_album_id: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryState {
|
||||
pub fn new(artists: Vec<Artist>) -> 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<usize> {
|
||||
self.artist_state.selected()
|
||||
}
|
||||
|
||||
pub fn get_tracks(&self) -> &[Track] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
pub fn cache_tracks(&mut self, album_id: String, tracks: Vec<Track>) {
|
||||
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<Track>> {
|
||||
self.tracks_cache.get(album_id)
|
||||
}
|
||||
|
||||
pub fn needs_fetch(&mut self) -> Option<String> {
|
||||
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<String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod app_state;
|
||||
pub mod handlers;
|
||||
pub mod library_state;
|
||||
pub mod notification_state;
|
||||
@@ -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<String>,
|
||||
pub kind: NotifKind,
|
||||
pub icon: String,
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
pub struct NotificationManager {
|
||||
active: Vec<Notification>,
|
||||
history: Vec<Notification>,
|
||||
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<String>,
|
||||
detail: Option<String>,
|
||||
kind: NotifKind,
|
||||
icon: impl Into<String>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1
-26
@@ -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<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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::*;
|
||||
|
||||
+1
-5
@@ -1,5 +1 @@
|
||||
//! Data layer modules.
|
||||
|
||||
pub mod models;
|
||||
|
||||
pub use models::*;
|
||||
pub use crate::domain::models::*;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Album> = 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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod models;
|
||||
pub mod navigation;
|
||||
pub mod conversions;
|
||||
pub mod aggregates;
|
||||
@@ -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<String>,
|
||||
pub monitor_state: MonitorState,
|
||||
pub path: String,
|
||||
pub quality: String,
|
||||
pub size_gb: f64,
|
||||
pub albums: Vec<Album>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
+1
-97
@@ -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<ArtistSummary>),
|
||||
Album {
|
||||
album: AlbumDetail,
|
||||
tracks: Vec<TrackDetail>,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct GrpcClient {
|
||||
music: MusicAgregatorServiceClient<Channel>,
|
||||
}
|
||||
|
||||
impl GrpcClient {
|
||||
pub async fn connect(addr: &str) -> Result<Self, tonic::transport::Error> {
|
||||
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<Vec<ArtistSummary>, 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<TrackDetail>), 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<GrpcRequest>, mpsc::Receiver<GrpcResponse>) {
|
||||
let (req_tx, mut req_rx) = mpsc::channel::<GrpcRequest>(32);
|
||||
let (resp_tx, resp_rx) = mpsc::channel::<GrpcResponse>(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::*;
|
||||
|
||||
@@ -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<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ArtistSummary>),
|
||||
Album {
|
||||
album: AlbumDetail,
|
||||
tracks: Vec<TrackDetail>,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct GrpcClient {
|
||||
music: MusicAgregatorServiceClient<Channel>,
|
||||
}
|
||||
|
||||
impl GrpcClient {
|
||||
pub async fn connect(addr: &str) -> Result<Self, tonic::transport::Error> {
|
||||
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<Vec<ArtistSummary>, 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<TrackDetail>), 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<GrpcRequest>, mpsc::Receiver<GrpcResponse>) {
|
||||
let (req_tx, mut req_rx) = mpsc::channel::<GrpcRequest>(32);
|
||||
let (resp_tx, resp_rx) = mpsc::channel::<GrpcResponse>(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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod grpc;
|
||||
pub mod system;
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ListItem> = 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<ListItem> = 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<ListItem> = 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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}<motion>", "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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
@@ -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<Line<'a>>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)),
|
||||
])
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Rect>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<CalendarEntry>,
|
||||
}
|
||||
|
||||
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<CalendarCell> = 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<CalendarEntry> = calendar
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
if let Some(day_str) = e.date.split('-').nth(2)
|
||||
&& let Ok(day) = day_str.parse::<u8>()
|
||||
{
|
||||
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<Span> = 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<Line> = 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);
|
||||
}
|
||||
@@ -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<ListItem> = 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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Span<'static>> {
|
||||
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::<f64>().ok())
|
||||
.sum();
|
||||
let meta = format!(
|
||||
"{} active · {:.1}%",
|
||||
queue.len(),
|
||||
queue.iter().map(|q| q.progress * 100.0).sum::<f64>() / 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<ListItem> = 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);
|
||||
}
|
||||
@@ -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<Line> = 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<Line> = 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<Line> = 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);
|
||||
}
|
||||
@@ -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<ListItem> = 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);
|
||||
}
|
||||
+2
-661
@@ -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<Artist>,
|
||||
pub tracks: Vec<Track>,
|
||||
pub focus: LibraryFocus,
|
||||
pub artist_state: ListState,
|
||||
pub album_state: ListState,
|
||||
pub track_state: ListState,
|
||||
tracks_cache: HashMap<String, Vec<Track>>,
|
||||
pending_album_id: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryState {
|
||||
pub fn new(artists: Vec<Artist>) -> 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<usize> {
|
||||
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<ListItem> = 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<ListItem> = 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<ListItem> = 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<Track>) {
|
||||
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<Track>> {
|
||||
self.tracks_cache.get(album_id)
|
||||
}
|
||||
|
||||
pub fn needs_fetch(&mut self) -> Option<String> {
|
||||
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<String> {
|
||||
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;
|
||||
|
||||
+2
-12
@@ -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};
|
||||
|
||||
+2
-260
@@ -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<String>,
|
||||
pub kind: NotifKind,
|
||||
pub icon: String,
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
pub struct NotificationManager {
|
||||
active: Vec<Notification>,
|
||||
history: Vec<Notification>,
|
||||
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<String>,
|
||||
detail: Option<String>,
|
||||
kind: NotifKind,
|
||||
icon: impl Into<String>,
|
||||
) {
|
||||
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::<Vec<_>>()
|
||||
.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;
|
||||
|
||||
+1
-111
@@ -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<Line<'a>>,
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
+1
-39
@@ -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::*;
|
||||
|
||||
+1
-94
@@ -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::*;
|
||||
|
||||
+1
-147
@@ -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<Rect>,
|
||||
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::*;
|
||||
|
||||
+1
-13
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user