feat: implement harmony TUI with vim/evil-mode navigation and SPC leader
Full Ratatui implementation of the harmony music library manager prototype: - 6 tab views (Library 3-pane, Wanted, Queue, History, Calendar, Settings) - Vim/evil-mode keybindings (hjkl, counts, gg/G, w/b/e, Ctrl-d/u, H/M/L, marks, operator-pending) - SPC leader key with which-key popup (Doom Emacs style) - Command mode (:q, :theme, :help) and / search filter - Help and quit confirmation modals - Toast notification system with auto-dismiss - Gruvbox dark theme throughout
This commit is contained in:
+1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
//! Data layer modules.
|
||||
|
||||
pub mod models;
|
||||
pub mod sample;
|
||||
|
||||
pub use models::*;
|
||||
pub use sample::*;
|
||||
@@ -0,0 +1,94 @@
|
||||
#![allow(dead_code)]
|
||||
//! Data models for harmony music library.
|
||||
|
||||
/// Album completion status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AlbumStatus {
|
||||
Complete,
|
||||
Partial,
|
||||
Wanted,
|
||||
Unmonitored,
|
||||
}
|
||||
|
||||
/// A music artist in the library.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Artist {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub country: String,
|
||||
pub genres: Vec<String>,
|
||||
pub monitored: bool,
|
||||
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,
|
||||
}
|
||||
|
||||
/// A track on an album.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Track {
|
||||
pub number: 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,262 @@
|
||||
use super::models::*;
|
||||
|
||||
pub fn sample_artists() -> Vec<Artist> {
|
||||
vec![
|
||||
Artist {
|
||||
id: "bcnr".into(),
|
||||
name: "Black Country, New Road".into(),
|
||||
country: "UK".into(),
|
||||
genres: vec!["Post-rock".into(), "Avant-prog".into(), "Chamber".into()],
|
||||
monitored: true,
|
||||
path: "/music/B/Black Country New Road".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 3.2,
|
||||
albums: vec![
|
||||
Album { id: "ftft".into(), title: "For the first time".into(), year: 2021, album_type: "Album".into(), monitored: true, total: 6, have: 6, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "afut".into(), title: "Ants From Up There".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "bush".into(), title: "Live at Bush Hall".into(), year: 2023, album_type: "Live".into(), monitored: true, total: 9, have: 5, quality: "FLAC".into(), status: AlbumStatus::Partial },
|
||||
Album { id: "fhwl".into(), title: "Forever Howlong".into(), year: 2025, album_type: "Album".into(), monitored: true, total: 12, have: 0, quality: "—".into(), status: AlbumStatus::Wanted },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "rad".into(),
|
||||
name: "Radiohead".into(),
|
||||
country: "UK".into(),
|
||||
genres: vec!["Alt-rock".into(), "Electronic".into(), "Art-rock".into()],
|
||||
monitored: true,
|
||||
path: "/music/R/Radiohead".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 8.7,
|
||||
albums: vec![
|
||||
Album { id: "okc".into(), title: "OK Computer".into(), year: 1997, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "kida".into(), title: "Kid A".into(), year: 2000, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "amne".into(), title: "Amnesiac".into(), year: 2001, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "htft".into(), title: "Hail to the Thief".into(), year: 2003, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "rain".into(), title: "In Rainbows".into(), year: 2007, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "kol".into(), title: "The King of Limbs".into(), year: 2011, album_type: "Album".into(), monitored: true, total: 8, have: 8, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "moon".into(), title: "A Moon Shaped Pool".into(), year: 2016, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "ken".into(),
|
||||
name: "Kendrick Lamar".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Hip-hop".into(), "Conscious".into()],
|
||||
monitored: true,
|
||||
path: "/music/K/Kendrick Lamar".into(),
|
||||
quality: "FLAC > MP3 320".into(),
|
||||
size_gb: 4.1,
|
||||
albums: vec![
|
||||
Album { id: "skd".into(), title: "Section.80".into(), year: 2011, album_type: "Album".into(), monitored: true, total: 16, have: 16, quality: "MP3".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "gkmc".into(), title: "good kid, m.A.A.d city".into(), year: 2012, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "tpab".into(), title: "To Pimp a Butterfly".into(), year: 2015, album_type: "Album".into(), monitored: true, total: 16, have: 16, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "damn".into(), title: "DAMN.".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "mmbs".into(), title: "Mr. Morale & The Big Steppers".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 18, have: 14, quality: "FLAC".into(), status: AlbumStatus::Partial },
|
||||
Album { id: "gnx".into(), title: "GNX".into(), year: 2024, album_type: "Album".into(), monitored: true, total: 12, have: 0, quality: "—".into(), status: AlbumStatus::Wanted },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "tame".into(),
|
||||
name: "Tame Impala".into(),
|
||||
country: "AU".into(),
|
||||
genres: vec!["Psychedelic".into(), "Synth-pop".into()],
|
||||
monitored: true,
|
||||
path: "/music/T/Tame Impala".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 2.9,
|
||||
albums: vec![
|
||||
Album { id: "inrn".into(), title: "Innerspeaker".into(), year: 2010, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "lonr".into(), title: "Lonerism".into(), year: 2012, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "curr".into(), title: "Currents".into(), year: 2015, album_type: "Album".into(), monitored: true, total: 13, have: 13, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "slow".into(), title: "The Slow Rush".into(), year: 2020, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "fka".into(),
|
||||
name: "FKA twigs".into(),
|
||||
country: "UK".into(),
|
||||
genres: vec!["Art-pop".into(), "R&B".into(), "Electronic".into()],
|
||||
monitored: true,
|
||||
path: "/music/F/FKA twigs".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 1.8,
|
||||
albums: vec![
|
||||
Album { id: "lp1".into(), title: "LP1".into(), year: 2014, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "magd".into(), title: "MAGDALENE".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 9, have: 9, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "caps".into(), title: "CAPRISONGS".into(), year: 2022, album_type: "Mixtape".into(), monitored: false, total: 17, have: 0, quality: "—".into(), status: AlbumStatus::Unmonitored },
|
||||
Album { id: "evt".into(), title: "EUSEXUA".into(), year: 2025, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "mit".into(),
|
||||
name: "Mitski".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Indie rock".into(), "Art-pop".into()],
|
||||
monitored: true,
|
||||
path: "/music/M/Mitski".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 2.4,
|
||||
albums: vec![
|
||||
Album { id: "bitc".into(), title: "Bury Me at Makeout Creek".into(), year: 2014, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "puby".into(), title: "Puberty 2".into(), year: 2016, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "bhdc".into(), title: "Be the Cowboy".into(), year: 2018, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "lhdh".into(), title: "Laurel Hell".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "tlid".into(), title: "The Land Is Inhospitable...".into(), year: 2023, album_type: "Album".into(), monitored: true, total: 11, have: 7, quality: "FLAC".into(), status: AlbumStatus::Partial },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "phbr".into(),
|
||||
name: "Phoebe Bridgers".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Indie folk".into(), "Singer-songwriter".into()],
|
||||
monitored: true,
|
||||
path: "/music/P/Phoebe Bridgers".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 1.1,
|
||||
albums: vec![
|
||||
Album { id: "stge".into(), title: "Stranger in the Alps".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "punr".into(), title: "Punisher".into(), year: 2020, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "vw".into(),
|
||||
name: "Vampire Weekend".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Indie rock".into(), "Baroque pop".into()],
|
||||
monitored: true,
|
||||
path: "/music/V/Vampire Weekend".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 2.0,
|
||||
albums: vec![
|
||||
Album { id: "vw1".into(), title: "Vampire Weekend".into(), year: 2008, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "cont".into(), title: "Contra".into(), year: 2010, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "moav".into(), title: "Modern Vampires...".into(), year: 2013, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "fofb".into(), title: "Father of the Bride".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 18, have: 18, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "ognc".into(), title: "Only God Was Above Us".into(), year: 2024, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "bigt".into(),
|
||||
name: "Big Thief".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Indie folk".into()],
|
||||
monitored: true,
|
||||
path: "/music/B/Big Thief".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 3.6,
|
||||
albums: vec![
|
||||
Album { id: "mskc".into(), title: "Masterpiece".into(), year: 2016, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "cap".into(), title: "Capacity".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "uffp".into(), title: "U.F.O.F.".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "twoh".into(), title: "Two Hands".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "ddiy".into(), title: "Dragon New Warm Mountain...".into(), year: 2022, album_type: "Album".into(), monitored: true, total: 20, have: 14, quality: "FLAC".into(), status: AlbumStatus::Partial },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "carp".into(),
|
||||
name: "Caroline Polachek".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Art-pop".into()],
|
||||
monitored: true,
|
||||
path: "/music/C/Caroline Polachek".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 0.9,
|
||||
albums: vec![
|
||||
Album { id: "panc".into(), title: "Pang".into(), year: 2019, album_type: "Album".into(), monitored: true, total: 14, have: 14, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "dsrt".into(), title: "Desire, I Want to Turn Into You".into(), year: 2023, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "nat".into(),
|
||||
name: "The National".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Indie rock".into()],
|
||||
monitored: false,
|
||||
path: "/music/T/The National".into(),
|
||||
quality: "MP3 320".into(),
|
||||
size_gb: 2.2,
|
||||
albums: vec![
|
||||
Album { id: "boxer".into(), title: "Boxer".into(), year: 2007, album_type: "Album".into(), monitored: true, total: 12, have: 12, quality: "MP3".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "hvcc".into(), title: "High Violet".into(), year: 2010, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "MP3".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "tbtb".into(), title: "Trouble Will Find Me".into(), year: 2013, album_type: "Album".into(), monitored: false, total: 13, have: 0, quality: "—".into(), status: AlbumStatus::Unmonitored },
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: "stv".into(),
|
||||
name: "St. Vincent".into(),
|
||||
country: "US".into(),
|
||||
genres: vec!["Art-rock".into()],
|
||||
monitored: true,
|
||||
path: "/music/S/St. Vincent".into(),
|
||||
quality: "FLAC".into(),
|
||||
size_gb: 2.7,
|
||||
albums: vec![
|
||||
Album { id: "marr".into(), title: "Marry Me".into(), year: 2007, album_type: "Album".into(), monitored: true, total: 13, have: 13, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "actr".into(), title: "Actor".into(), year: 2009, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "sssv".into(), title: "St. Vincent".into(), year: 2014, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "mads".into(), title: "MASSEDUCTION".into(), year: 2017, album_type: "Album".into(), monitored: true, total: 13, have: 13, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "dhft".into(), title: "Daddy's Home".into(), year: 2021, album_type: "Album".into(), monitored: true, total: 11, have: 11, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
Album { id: "alai".into(), title: "All Born Screaming".into(), year: 2024, album_type: "Album".into(), monitored: true, total: 10, have: 10, quality: "FLAC".into(), status: AlbumStatus::Complete },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn sample_tracks_bush_hall() -> Vec<Track> {
|
||||
vec![
|
||||
Track { number: 1, title: "Up Song".into(), duration: "4:24".into(), have: true, quality: "FLAC".into() },
|
||||
Track { number: 2, title: "The Boy".into(), duration: "3:01".into(), have: true, quality: "FLAC".into() },
|
||||
Track { number: 3, title: "I Won't Always Love You".into(), duration: "5:13".into(), have: false, quality: "—".into() },
|
||||
Track { number: 4, title: "Across the Pond Friend".into(), duration: "6:48".into(), have: true, quality: "FLAC".into() },
|
||||
Track { number: 5, title: "Laughing Song".into(), duration: "5:02".into(), have: false, quality: "—".into() },
|
||||
Track { number: 6, title: "The Wrong Trousers".into(), duration: "4:39".into(), have: true, quality: "FLAC".into() },
|
||||
Track { number: 7, title: "Turbines / Pigs".into(), duration: "9:22".into(), have: true, quality: "FLAC".into() },
|
||||
Track { number: 8, title: "Dancers".into(), duration: "5:55".into(), have: false, quality: "—".into() },
|
||||
Track { number: 9, title: "Up Song (Reprise)".into(), duration: "1:33".into(), have: false, quality: "—".into() },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn sample_wanted() -> Vec<WantedEntry> {
|
||||
vec![
|
||||
WantedEntry { id: "w1".into(), artist: "Black Country, New Road".into(), album: "Forever Howlong".into(), year: 2025, missing: 12, release_date: "2025-04-04".into(), status: AlbumStatus::Wanted },
|
||||
WantedEntry { id: "w2".into(), artist: "Kendrick Lamar".into(), album: "GNX".into(), year: 2024, missing: 12, release_date: "2024-11-22".into(), status: AlbumStatus::Wanted },
|
||||
WantedEntry { id: "w3".into(), artist: "Black Country, New Road".into(), album: "Live at Bush Hall".into(), year: 2023, missing: 4, release_date: "2023-03-31".into(), status: AlbumStatus::Partial },
|
||||
WantedEntry { id: "w4".into(), artist: "Kendrick Lamar".into(), album: "Mr. Morale...".into(), year: 2022, missing: 4, release_date: "2022-05-13".into(), status: AlbumStatus::Partial },
|
||||
WantedEntry { id: "w5".into(), artist: "Mitski".into(), album: "The Land Is...".into(), year: 2023, missing: 4, release_date: "2023-09-15".into(), status: AlbumStatus::Partial },
|
||||
WantedEntry { id: "w6".into(), artist: "Big Thief".into(), album: "Dragon New Warm...".into(), year: 2022, missing: 6, release_date: "2022-02-11".into(), status: AlbumStatus::Partial },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn sample_queue() -> Vec<QueueEntry> {
|
||||
vec![
|
||||
QueueEntry { id: "q1".into(), title: "Forever Howlong".into(), artist: "Black Country, New Road".into(), indexer: "redacted.ch".into(), size: "412 MB".into(), progress: 0.73, eta: "0:42".into(), speed: "8.1 MB/s".into(), client: "qbittorrent".into() },
|
||||
QueueEntry { id: "q2".into(), title: "GNX".into(), artist: "Kendrick Lamar".into(), indexer: "orpheus.network".into(), size: "284 MB".into(), progress: 0.41, eta: "1:18".into(), speed: "3.4 MB/s".into(), client: "qbittorrent".into() },
|
||||
QueueEntry { id: "q3".into(), title: "Bush Hall (4 trk)".into(), artist: "Black Country, New Road".into(), indexer: "redacted.ch".into(), size: "98 MB".into(), progress: 0.92, eta: "0:08".into(), speed: "11.2 MB/s".into(), client: "qbittorrent".into() },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn sample_history() -> Vec<HistoryEntry> {
|
||||
vec![
|
||||
HistoryEntry { when: "12:04".into(), event: "imported".into(), artist: "Vampire Weekend".into(), detail: "Only God Was Above Us - 10/10 FLAC".into() },
|
||||
HistoryEntry { when: "11:58".into(), event: "downloaded".into(), artist: "Vampire Weekend".into(), detail: "Only God Was Above Us - redacted.ch - 348 MB".into() },
|
||||
HistoryEntry { when: "11:42".into(), event: "grabbed".into(), artist: "Vampire Weekend".into(), detail: "Only God Was Above Us [WEB FLAC]".into() },
|
||||
HistoryEntry { when: "11:40".into(), event: "search".into(), artist: "Vampire Weekend".into(), detail: "manual search - 14 results".into() },
|
||||
HistoryEntry { when: "10:31".into(), event: "imported".into(), artist: "St. Vincent".into(), detail: "All Born Screaming - 10/10 FLAC".into() },
|
||||
HistoryEntry { when: "10:24".into(), event: "downloaded".into(), artist: "St. Vincent".into(), detail: "All Born Screaming - orpheus.network - 412 MB".into() },
|
||||
HistoryEntry { when: "09:12".into(), event: "grabbed".into(), artist: "Caroline Polachek".into(), detail: "Desire, I Want... [WEB FLAC]".into() },
|
||||
HistoryEntry { when: "yesterday".into(), event: "refreshed".into(), artist: "Library".into(), detail: "scanned 18 artists - 62 albums - 0 changes".into() },
|
||||
HistoryEntry { when: "yesterday".into(), event: "imported".into(), artist: "Tame Impala".into(), detail: "The Slow Rush - 12/12 FLAC".into() },
|
||||
HistoryEntry { when: "yesterday".into(), event: "failed".into(), artist: "Mitski".into(), detail: "release rejected - audio quality below cutoff".into() },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn sample_calendar() -> Vec<CalendarEntry> {
|
||||
vec![
|
||||
CalendarEntry { date: "2026-05-09".into(), artist: "Phoebe Bridgers".into(), album: "TBD".into(), status: "announced".into(), entry_type: "Album".into() },
|
||||
CalendarEntry { date: "2026-05-15".into(), artist: "Tame Impala".into(), album: "Deadbeat".into(), status: "monitored".into(), entry_type: "Album".into() },
|
||||
CalendarEntry { date: "2026-05-22".into(), artist: "FKA twigs".into(), album: "EUSEXUA Afterglow EP".into(), status: "monitored".into(), entry_type: "EP".into() },
|
||||
CalendarEntry { date: "2026-06-03".into(), artist: "Big Thief".into(), album: "(unannounced)".into(), status: "announced".into(), entry_type: "Album".into() },
|
||||
CalendarEntry { date: "2026-06-14".into(), artist: "Mitski".into(), album: "Live at the Met".into(), status: "monitored".into(), entry_type: "Live".into() },
|
||||
CalendarEntry { date: "2026-07-01".into(), artist: "Caroline Polachek".into(), album: "Pang Reissue".into(), status: "monitored".into(), entry_type: "Reissue".into() },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! SPC leader key tree (Doom Emacs / which-key style).
|
||||
//!
|
||||
//! Builds a tree of keybindings accessible via the SPC leader key.
|
||||
|
||||
use crate::app::{Mode, Tab};
|
||||
|
||||
/// Action to execute when a leader key sequence is completed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LeaderAction {
|
||||
/// Switch to a tab
|
||||
SetTab(Tab),
|
||||
/// Set input mode
|
||||
SetMode(Mode),
|
||||
/// Navigate to next/prev tab
|
||||
NextTab,
|
||||
PrevTab,
|
||||
/// Toggle monitor on selected item
|
||||
ToggleMonitor,
|
||||
/// Refresh/rescan library
|
||||
Refresh,
|
||||
/// Search for release
|
||||
SearchRelease,
|
||||
/// Cycle theme
|
||||
CycleTheme,
|
||||
/// Set specific theme
|
||||
SetThemeDark,
|
||||
SetThemeLight,
|
||||
/// Show notifications
|
||||
ShowNotifications,
|
||||
/// Dismiss all notifications
|
||||
DismissNotifications,
|
||||
/// Show help
|
||||
ShowHelp,
|
||||
/// Show add modal
|
||||
ShowAdd,
|
||||
/// Show quit confirm
|
||||
ShowQuit,
|
||||
/// Sync/save library
|
||||
SyncLibrary,
|
||||
}
|
||||
|
||||
/// A node in the leader key tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LeaderNode {
|
||||
/// Display name for this node
|
||||
pub name: String,
|
||||
/// Child nodes (None if this is a leaf action)
|
||||
pub children: Option<Vec<(char, LeaderNode)>>,
|
||||
/// Action to execute (None if this is a group)
|
||||
pub action: Option<LeaderAction>,
|
||||
}
|
||||
|
||||
impl LeaderNode {
|
||||
/// Create a new group node.
|
||||
pub fn group(name: impl Into<String>, children: Vec<(char, LeaderNode)>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
children: Some(children),
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new leaf action node.
|
||||
pub fn action(name: impl Into<String>, action: LeaderAction) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
children: None,
|
||||
action: Some(action),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a child node by key.
|
||||
pub fn get_child(&self, key: char) -> Option<&LeaderNode> {
|
||||
self.children
|
||||
.as_ref()
|
||||
.and_then(|children| children.iter().find(|(k, _)| *k == key).map(|(_, n)| n))
|
||||
}
|
||||
|
||||
/// Check if this is a group (has children).
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.children.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of navigating the leader tree.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LeaderResult {
|
||||
/// Navigation is pending (entered a group)
|
||||
Pending,
|
||||
/// An action was executed
|
||||
Executed(LeaderAction),
|
||||
/// Key was not bound
|
||||
NotBound,
|
||||
}
|
||||
|
||||
/// State for the leader key system.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LeaderState {
|
||||
/// Current path in the tree (e.g., ['b'] for SPC b ...)
|
||||
pub path: Vec<char>,
|
||||
/// Whether the leader menu is currently active
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl LeaderState {
|
||||
/// Create a new leader state.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Activate the leader menu.
|
||||
pub fn activate(&mut self) {
|
||||
self.active = true;
|
||||
self.path.clear();
|
||||
}
|
||||
|
||||
/// Deactivate the leader menu.
|
||||
pub fn deactivate(&mut self) {
|
||||
self.active = false;
|
||||
self.path.clear();
|
||||
}
|
||||
|
||||
/// Go back one level (Backspace behavior).
|
||||
pub fn go_back(&mut self) {
|
||||
if self.path.is_empty() {
|
||||
self.deactivate();
|
||||
} else {
|
||||
self.path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a key in the tree.
|
||||
pub fn navigate(&mut self, key: char, tree: &LeaderNode) -> LeaderResult {
|
||||
let current = self.current_node(tree);
|
||||
let Some(current) = current else {
|
||||
self.deactivate();
|
||||
return LeaderResult::NotBound;
|
||||
};
|
||||
|
||||
match current.get_child(key) {
|
||||
Some(child) => {
|
||||
if child.is_group() {
|
||||
self.path.push(key);
|
||||
LeaderResult::Pending
|
||||
} else if let Some(action) = &child.action {
|
||||
let action = action.clone();
|
||||
self.deactivate();
|
||||
LeaderResult::Executed(action)
|
||||
} else {
|
||||
self.deactivate();
|
||||
LeaderResult::NotBound
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.deactivate();
|
||||
LeaderResult::NotBound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current node based on the path.
|
||||
pub fn current_node<'a>(&self, tree: &'a LeaderNode) -> Option<&'a LeaderNode> {
|
||||
let mut node = tree;
|
||||
for &key in &self.path {
|
||||
node = node.get_child(key)?;
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Get the breadcrumb string for display (e.g., "SPC › b").
|
||||
pub fn breadcrumb(&self) -> String {
|
||||
if self.path.is_empty() {
|
||||
"SPC".to_string()
|
||||
} else {
|
||||
let path_str: String = self.path.iter().map(|c| format!(" › {}", c)).collect();
|
||||
format!("SPC{}", path_str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the leader key tree matching the app.jsx specification.
|
||||
pub fn build_leader_tree() -> LeaderNode {
|
||||
LeaderNode::group(
|
||||
"leader",
|
||||
vec![
|
||||
// SPC SPC / SPC : → command mode
|
||||
(' ', LeaderNode::action("M-x command", LeaderAction::SetMode(Mode::Command))),
|
||||
(':', LeaderNode::action("command", LeaderAction::SetMode(Mode::Command))),
|
||||
// SPC / → search mode
|
||||
('/', LeaderNode::action("filter", LeaderAction::SetMode(Mode::Search))),
|
||||
// SPC ? → help
|
||||
('?', LeaderNode::action("help", LeaderAction::ShowHelp)),
|
||||
// SPC b → +buffer (tabs)
|
||||
(
|
||||
'b',
|
||||
LeaderNode::group(
|
||||
"+buffer",
|
||||
vec![
|
||||
('l', LeaderNode::action("library", LeaderAction::SetTab(Tab::Library))),
|
||||
('w', LeaderNode::action("wanted", LeaderAction::SetTab(Tab::Wanted))),
|
||||
('q', LeaderNode::action("queue", LeaderAction::SetTab(Tab::Queue))),
|
||||
('h', LeaderNode::action("history", LeaderAction::SetTab(Tab::History))),
|
||||
('c', LeaderNode::action("calendar", LeaderAction::SetTab(Tab::Calendar))),
|
||||
('s', LeaderNode::action("settings", LeaderAction::SetTab(Tab::Settings))),
|
||||
('n', LeaderNode::action("next tab", LeaderAction::NextTab)),
|
||||
('p', LeaderNode::action("prev tab", LeaderAction::PrevTab)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Quick-jump shortcuts
|
||||
('l', LeaderNode::action("→ library", LeaderAction::SetTab(Tab::Library))),
|
||||
('w', LeaderNode::action("→ wanted", LeaderAction::SetTab(Tab::Wanted))),
|
||||
('h', LeaderNode::action("→ history", LeaderAction::SetTab(Tab::History))),
|
||||
('c', LeaderNode::action("→ calendar", LeaderAction::SetTab(Tab::Calendar))),
|
||||
// SPC a → +actions
|
||||
(
|
||||
'a',
|
||||
LeaderNode::group(
|
||||
"+actions / artist",
|
||||
vec![
|
||||
('a', LeaderNode::action("add artist", LeaderAction::ShowAdd)),
|
||||
('m', LeaderNode::action("toggle monitor", LeaderAction::ToggleMonitor)),
|
||||
('r', LeaderNode::action("refresh / rescan", LeaderAction::Refresh)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// SPC s → +search
|
||||
(
|
||||
's',
|
||||
LeaderNode::group(
|
||||
"+search",
|
||||
vec![
|
||||
('s', LeaderNode::action("release for selected", LeaderAction::SearchRelease)),
|
||||
('f', LeaderNode::action("filter library", LeaderAction::SetMode(Mode::Search))),
|
||||
('a', LeaderNode::action("add new artist", LeaderAction::ShowAdd)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// SPC t → +toggle/theme
|
||||
(
|
||||
't',
|
||||
LeaderNode::group(
|
||||
"+toggle / theme",
|
||||
vec![
|
||||
('t', LeaderNode::action("cycle theme", LeaderAction::CycleTheme)),
|
||||
('d', LeaderNode::action("dark theme", LeaderAction::SetThemeDark)),
|
||||
('l', LeaderNode::action("light theme", LeaderAction::SetThemeLight)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// SPC n → +notifications
|
||||
(
|
||||
'n',
|
||||
LeaderNode::group(
|
||||
"+notifications",
|
||||
vec![
|
||||
('n', LeaderNode::action("open center", LeaderAction::ShowNotifications)),
|
||||
('d', LeaderNode::action("dismiss all", LeaderAction::DismissNotifications)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// SPC f → +file
|
||||
(
|
||||
'f',
|
||||
LeaderNode::group(
|
||||
"+file / library",
|
||||
vec![
|
||||
('s', LeaderNode::action("save (sync)", LeaderAction::SyncLibrary)),
|
||||
('r', LeaderNode::action("refresh / rescan", LeaderAction::Refresh)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// SPC q → +quit
|
||||
(
|
||||
'q',
|
||||
LeaderNode::group(
|
||||
"+queue / quit",
|
||||
vec![
|
||||
('q', LeaderNode::action("queue tab", LeaderAction::SetTab(Tab::Queue))),
|
||||
('Q', LeaderNode::action("quit harmony", LeaderAction::ShowQuit)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Input handling modules: Vim state machine and SPC leader tree.
|
||||
|
||||
pub mod leader;
|
||||
pub mod vim;
|
||||
|
||||
pub use leader::{build_leader_tree, LeaderAction, LeaderNode, LeaderResult, LeaderState};
|
||||
pub use vim::VimState;
|
||||
@@ -0,0 +1,117 @@
|
||||
#![allow(dead_code)]
|
||||
//! Vim-style input state machine.
|
||||
//!
|
||||
//! Handles count prefix (5j), operator-pending sequences (g_, z_, m_, '_, `_, [_, ]_).
|
||||
|
||||
/// Vim state machine for count prefixes and operator-pending sequences.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct VimState {
|
||||
/// Accumulating count prefix (e.g., "12" for 12G)
|
||||
pub count: String,
|
||||
/// Operator-pending character: g, z, m, ', `, [, ]
|
||||
pub pending: Option<char>,
|
||||
}
|
||||
|
||||
impl VimState {
|
||||
/// Create a new VimState.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Accumulate a digit into the count. Returns true if the character was a digit
|
||||
/// and was accumulated (or if it was '0' but we already have a count started).
|
||||
/// Returns false if the char is not a digit, or if it's a bare '0' (which should
|
||||
/// be handled as line-start motion).
|
||||
pub fn accumulate_count(&mut self, c: char) -> bool {
|
||||
if !c.is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
// Bare '0' when no count started = line-start motion, not count
|
||||
if c == '0' && self.count.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.count.push(c);
|
||||
true
|
||||
}
|
||||
|
||||
/// Take the accumulated count, returning the parsed value (minimum 1).
|
||||
/// Clears the count buffer.
|
||||
pub fn take_count(&mut self) -> usize {
|
||||
let count = self.count.parse::<usize>().unwrap_or(1).max(1);
|
||||
self.count.clear();
|
||||
count
|
||||
}
|
||||
|
||||
/// Peek at the current count without consuming it.
|
||||
pub fn peek_count(&self) -> usize {
|
||||
self.count.parse::<usize>().unwrap_or(1).max(1)
|
||||
}
|
||||
|
||||
/// Check if there's a pending count being accumulated.
|
||||
pub fn has_count(&self) -> bool {
|
||||
!self.count.is_empty()
|
||||
}
|
||||
|
||||
/// Set the pending operator character.
|
||||
pub fn set_pending(&mut self, c: char) {
|
||||
self.pending = Some(c);
|
||||
}
|
||||
|
||||
/// Take the pending operator, returning it and clearing the pending state.
|
||||
pub fn take_pending(&mut self) -> Option<char> {
|
||||
self.pending.take()
|
||||
}
|
||||
|
||||
/// Check if there's a pending operator.
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
/// Clear all state (count and pending).
|
||||
pub fn clear(&mut self) {
|
||||
self.count.clear();
|
||||
self.pending = None;
|
||||
}
|
||||
|
||||
/// Check if this character starts an operator-pending sequence.
|
||||
pub fn is_operator_pending_starter(c: char) -> bool {
|
||||
matches!(c, 'g' | 'z' | 'm' | '\'' | '`' | '[' | ']')
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_accumulate_count() {
|
||||
let mut vim = VimState::new();
|
||||
assert!(vim.accumulate_count('5'));
|
||||
assert!(vim.accumulate_count('2'));
|
||||
assert_eq!(vim.take_count(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bare_zero() {
|
||||
let mut vim = VimState::new();
|
||||
assert!(!vim.accumulate_count('0')); // bare 0 is not accumulated
|
||||
assert_eq!(vim.take_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_in_count() {
|
||||
let mut vim = VimState::new();
|
||||
assert!(vim.accumulate_count('1'));
|
||||
assert!(vim.accumulate_count('0'));
|
||||
assert_eq!(vim.take_count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pending() {
|
||||
let mut vim = VimState::new();
|
||||
vim.set_pending('g');
|
||||
assert!(vim.has_pending());
|
||||
assert_eq!(vim.take_pending(), Some('g'));
|
||||
assert!(!vim.has_pending());
|
||||
}
|
||||
}
|
||||
+47
-33
@@ -1,56 +1,70 @@
|
||||
use std::io;
|
||||
use std::io::stdout;
|
||||
use std::panic;
|
||||
use std::time::Duration;
|
||||
|
||||
use color_eyre::Result;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEventKind},
|
||||
event::{self, Event, KeyEventKind},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use ratatui::{prelude::*, widgets::*};
|
||||
use ratatui::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod data;
|
||||
mod input;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
use app::App;
|
||||
|
||||
const TICK_RATE: Duration = Duration::from_millis(250);
|
||||
|
||||
fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
io::stdout().execute(EnterAlternateScreen)?;
|
||||
let hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
let _ = restore_terminal();
|
||||
hook(info);
|
||||
}));
|
||||
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
|
||||
|
||||
let result = run(&mut terminal);
|
||||
|
||||
disable_raw_mode()?;
|
||||
io::stdout().execute(LeaveAlternateScreen)?;
|
||||
setup_terminal()?;
|
||||
let result = run();
|
||||
restore_terminal()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(ui)?;
|
||||
fn setup_terminal() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_terminal() -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
stdout().execute(LeaveAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
let mut app = App::new();
|
||||
|
||||
while app.running {
|
||||
terminal.draw(|frame| app.draw(frame))?;
|
||||
|
||||
if event::poll(TICK_RATE)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return Ok(()),
|
||||
_ => {}
|
||||
if key.kind == KeyEventKind::Press {
|
||||
app.handle_key(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.handle_tick();
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" ui-agregator ")
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_type(BorderType::Rounded);
|
||||
|
||||
let text = Paragraph::new("press q to quit")
|
||||
.alignment(Alignment::Center)
|
||||
.block(block);
|
||||
|
||||
frame.render_widget(text, area);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#![allow(dead_code)]
|
||||
//! Gruvbox dark palette for harmony TUI.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
// Background shades (dark → light)
|
||||
pub const BG0: Color = Color::Rgb(40, 40, 40); // #282828
|
||||
pub const BG1: Color = Color::Rgb(50, 48, 47); // #32302f
|
||||
pub const BG2: Color = Color::Rgb(60, 56, 54); // #3c3836
|
||||
pub const BG3: Color = Color::Rgb(80, 73, 69); // #504945
|
||||
pub const BG4: Color = Color::Rgb(102, 92, 84); // #665c54
|
||||
|
||||
// Foreground shades (light → dark)
|
||||
pub const FG0: Color = Color::Rgb(251, 241, 199); // #fbf1c7
|
||||
pub const FG1: Color = Color::Rgb(235, 219, 178); // #ebdbb2
|
||||
pub const FG2: Color = Color::Rgb(213, 196, 161); // #d5c4a1
|
||||
pub const FG3: Color = Color::Rgb(189, 174, 147); // #bdae93
|
||||
|
||||
// Gray
|
||||
pub const GRAY: Color = Color::Rgb(146, 131, 116); // #928374
|
||||
|
||||
// Accent colors
|
||||
pub const RED: Color = Color::Rgb(251, 73, 52); // #fb4934
|
||||
pub const GREEN: Color = Color::Rgb(184, 187, 38); // #b8bb26
|
||||
pub const YELLOW: Color = Color::Rgb(250, 189, 47); // #fabd2f
|
||||
pub const BLUE: Color = Color::Rgb(131, 165, 152); // #83a598
|
||||
pub const PURPLE: Color = Color::Rgb(211, 134, 155); // #d3869b
|
||||
pub const AQUA: Color = Color::Rgb(142, 192, 124); // #8ec07c
|
||||
pub const ORANGE: Color = Color::Rgb(254, 128, 25); // #fe8019
|
||||
|
||||
// Selection and focus colors (from CSS)
|
||||
pub const SELECT_BG: Color = Color::Rgb(69, 64, 61); // #45403d
|
||||
pub const FOCUS_BG: Color = Color::Rgb(250, 189, 47); // #fabd2f (same as YELLOW)
|
||||
pub const FOCUS_FG: Color = Color::Rgb(40, 40, 40); // #282828 (same as BG0)
|
||||
@@ -0,0 +1,73 @@
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::Mode;
|
||||
use crate::theme;
|
||||
|
||||
pub struct CmdLineState {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Default for CmdLineState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CmdLineState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, c: char) {
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<char> {
|
||||
self.text.pop()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.text.clear();
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
pub fn take(&mut self) -> String {
|
||||
std::mem::take(&mut self.text)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_cmdline(frame: &mut Frame, area: Rect, mode: Mode, text: &str, hint: &str) {
|
||||
if mode == Mode::Normal {
|
||||
return;
|
||||
}
|
||||
|
||||
let (prompt, prompt_style) = match mode {
|
||||
Mode::Command => (":", Style::default().fg(theme::ORANGE)),
|
||||
Mode::Search => ("/", Style::default().fg(theme::BLUE)),
|
||||
Mode::Normal => return,
|
||||
};
|
||||
|
||||
let cursor = if text.is_empty() { "█" } else { "" };
|
||||
let hint_text = if text.is_empty() { hint } else { "" };
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(prompt, prompt_style),
|
||||
Span::styled(text.to_string(), Style::default().fg(theme::FG1)),
|
||||
Span::styled(cursor, Style::default().fg(theme::YELLOW)),
|
||||
Span::styled(hint_text, Style::default().fg(theme::GRAY)),
|
||||
]);
|
||||
|
||||
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::data::{Album, AlbumStatus, Artist, Track};
|
||||
use crate::theme;
|
||||
use crate::ui::pane::{section_divider, Pane};
|
||||
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 focus: LibraryFocus,
|
||||
pub artist_state: ListState,
|
||||
pub album_state: ListState,
|
||||
pub track_state: ListState,
|
||||
}
|
||||
|
||||
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,
|
||||
focus: LibraryFocus::Artists,
|
||||
artist_state,
|
||||
album_state,
|
||||
track_state,
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if i > 0 {
|
||||
self.artist_state.select(Some(i - 1));
|
||||
self.reset_album_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
if let Some(i) = self.album_state.selected() {
|
||||
if i > 0 {
|
||||
self.album_state.select(Some(i - 1));
|
||||
self.reset_track_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Tracks => {
|
||||
if let Some(i) = self.track_state.selected() {
|
||||
if 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() {
|
||||
if 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() {
|
||||
if 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() {
|
||||
if 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 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) = status_icon(status, artist.monitored);
|
||||
|
||||
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();
|
||||
if !artist.monitored {
|
||||
name_text.push_str(" ·unm");
|
||||
}
|
||||
|
||||
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 = Line::from(vec![
|
||||
Span::styled("[a]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" add ", Style::default().fg(theme::FG2)),
|
||||
Span::styled("[m]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" monitor ", Style::default().fg(theme::FG2)),
|
||||
Span::styled("[s]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" search ", Style::default().fg(theme::FG2)),
|
||||
Span::styled("[r]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" refresh", Style::default().fg(theme::FG2)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{}/{} tracks", have_tracks, total_tracks),
|
||||
Style::default().fg(theme::GRAY),
|
||||
),
|
||||
]);
|
||||
|
||||
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 chunks = Layout::vertical([
|
||||
Constraint::Length(6),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
if let Some(artist) = artist {
|
||||
render_artist_header(frame, chunks[0], artist);
|
||||
}
|
||||
|
||||
let albums_count = artist.map(|a| a.albums.len()).unwrap_or(0);
|
||||
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 {
|
||||
if 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) = if artist.monitored {
|
||||
(
|
||||
Span::styled("● ", Style::default().fg(theme::GREEN)),
|
||||
"Monitored",
|
||||
Style::default().fg(theme::FG2),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Span::styled("◌ ", Style::default().fg(theme::GRAY)),
|
||||
"Unmonitored",
|
||||
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;
|
||||
let tracks = state.get_tracks();
|
||||
|
||||
if 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> = 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, title_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.quality, 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) -> Vec<Track> {
|
||||
let Some(album) = self.selected_album() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if album.id == "bush" {
|
||||
return crate::data::sample_tracks_bush_hall();
|
||||
}
|
||||
|
||||
tracks_for(album)
|
||||
}
|
||||
}
|
||||
|
||||
fn tracks_for(album: &Album) -> Vec<Track> {
|
||||
let titles = [
|
||||
"Opening", "Curtain Call", "Half-Light", "Polaroid", "Switchback",
|
||||
"Slow Dancer", "The Inheritance", "Glassworks", "Interlude", "Aftermath",
|
||||
"Static", "Returner", "Dust Bowl", "Postcard", "Late Reply",
|
||||
"Honeymoon", "Northern Lights", "Cold Open", "Coda", "Reprise",
|
||||
];
|
||||
|
||||
(0..album.total)
|
||||
.map(|i| {
|
||||
let idx = i as usize;
|
||||
let m = 2 + ((idx * 7) % 5);
|
||||
let s = (idx * 13) % 60;
|
||||
let title_idx = idx % titles.len();
|
||||
let title = if idx >= titles.len() {
|
||||
format!("{} II", titles[title_idx])
|
||||
} else {
|
||||
titles[title_idx].to_string()
|
||||
};
|
||||
let have = i < album.have;
|
||||
let quality = if have {
|
||||
album.quality.clone()
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
|
||||
Track {
|
||||
number: i + 1,
|
||||
title,
|
||||
duration: format!("{}:{:02}", m, s),
|
||||
have,
|
||||
quality,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod cmdline;
|
||||
pub mod library;
|
||||
pub mod modals;
|
||||
pub mod notifications;
|
||||
pub mod pane;
|
||||
pub mod progress_bar;
|
||||
pub mod statusbar;
|
||||
pub mod topbar;
|
||||
pub mod views;
|
||||
pub mod which_key;
|
||||
@@ -0,0 +1,152 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
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,11 @@
|
||||
pub mod help;
|
||||
pub mod quit;
|
||||
|
||||
pub use help::render_help_modal;
|
||||
pub use quit::render_quit_modal;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModalKind {
|
||||
Help,
|
||||
Quit,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
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,169 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
const NOTIFICATION_TTL_SECS: u64 = 4;
|
||||
const MAX_VISIBLE: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotifKind {
|
||||
Info,
|
||||
Success,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl NotifKind {
|
||||
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 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 {
|
||||
notifications: Vec<Notification>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Default for NotificationManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
notifications: 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.notifications.push(notification);
|
||||
|
||||
while self.notifications.len() > MAX_VISIBLE * 2 {
|
||||
self.notifications.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.notifications.retain(|n| {
|
||||
now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS
|
||||
});
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let visible: Vec<&Notification> = self
|
||||
.notifications
|
||||
.iter()
|
||||
.rev()
|
||||
.take(MAX_VISIBLE)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
|
||||
if visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let notif_width = 36u16.min(area.width);
|
||||
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 mut d = detail.clone();
|
||||
let max_len = inner.width.saturating_sub(4) as usize;
|
||||
if d.len() > max_len {
|
||||
d.truncate(max_len.saturating_sub(1));
|
||||
d.push('…');
|
||||
}
|
||||
lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY))));
|
||||
}
|
||||
|
||||
let para = Paragraph::new(lines);
|
||||
frame.render_widget(para, inner);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.notifications.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.notifications.is_empty()
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
//! 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 {
|
||||
if 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,62 @@
|
||||
//! 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) + total as usize - 1) / 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)),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_progress_bar_full() {
|
||||
let line = progress_bar(10, 10, 10, AlbumStatus::Complete);
|
||||
assert_eq!(line.spans.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_bar_empty() {
|
||||
let line = progress_bar(0, 10, 10, AlbumStatus::Wanted);
|
||||
assert_eq!(line.spans.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_bar_partial() {
|
||||
let line = progress_bar(5, 10, 10, AlbumStatus::Partial);
|
||||
assert_eq!(line.spans.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::Mode;
|
||||
use crate::theme;
|
||||
|
||||
pub struct StatusHint {
|
||||
pub key: &'static str,
|
||||
pub action: &'static str,
|
||||
}
|
||||
|
||||
pub fn render_statusbar(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
mode: Mode,
|
||||
hints: &[StatusHint],
|
||||
position: Option<(usize, usize)>,
|
||||
queue_count: usize,
|
||||
wanted_count: usize,
|
||||
status_message: Option<&str>,
|
||||
) {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let mode_str = match mode {
|
||||
Mode::Normal => "NORMAL",
|
||||
Mode::Command => "COMMAND",
|
||||
Mode::Search => "SEARCH",
|
||||
};
|
||||
let mode_bg = match mode {
|
||||
Mode::Normal => theme::GREEN,
|
||||
Mode::Command => theme::ORANGE,
|
||||
Mode::Search => theme::BLUE,
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", mode_str),
|
||||
Style::default()
|
||||
.fg(theme::BG0)
|
||||
.bg(mode_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
spans.push(Span::styled(" ", Style::default().bg(theme::BG2)));
|
||||
|
||||
if let Some(msg) = status_message {
|
||||
spans.push(Span::styled(
|
||||
msg.to_string(),
|
||||
Style::default().fg(theme::FG1).bg(theme::BG2),
|
||||
));
|
||||
} else {
|
||||
for hint in hints {
|
||||
spans.push(Span::styled(
|
||||
format!("[{}]", hint.key),
|
||||
Style::default().fg(theme::ORANGE).bg(theme::BG2),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", hint.action),
|
||||
Style::default().fg(theme::GRAY).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(
|
||||
" 47.3 GB free ",
|
||||
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,99 @@
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{Mode, Tab};
|
||||
use crate::theme;
|
||||
|
||||
pub fn render_topbar(frame: &mut Frame, area: Rect, active_tab: Tab, mode: Mode, queue_count: usize, wanted_count: usize) {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
spans.push(Span::styled(
|
||||
" ▲ harmony ",
|
||||
Style::default()
|
||||
.fg(theme::BG0)
|
||||
.bg(theme::ORANGE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
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 (i, (tab, label, badge)) in tabs.iter().enumerate() {
|
||||
let is_active = *tab == active_tab;
|
||||
let num = format!("{}", i + 1);
|
||||
|
||||
if is_active {
|
||||
spans.push(Span::styled(
|
||||
format!(" {}", num),
|
||||
Style::default().fg(theme::ORANGE).bg(theme::BG0),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", label),
|
||||
Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.bg(theme::BG0)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
format!(" {}", num),
|
||||
Style::default().fg(theme::GRAY).bg(theme::BG1),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", label),
|
||||
Style::default().fg(theme::FG3).bg(theme::BG1),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(count) = badge {
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", count),
|
||||
Style::default()
|
||||
.fg(theme::BG0)
|
||||
.bg(theme::RED)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let used_width: usize = spans.iter().map(|s| s.content.len()).sum();
|
||||
let remaining = area.width.saturating_sub(used_width as u16).saturating_sub(10) as usize;
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(remaining),
|
||||
Style::default().bg(theme::BG1),
|
||||
));
|
||||
|
||||
let mode_str = match mode {
|
||||
Mode::Normal => "NORMAL",
|
||||
Mode::Command => "COMMAND",
|
||||
Mode::Search => "SEARCH",
|
||||
};
|
||||
let mode_bg = match mode {
|
||||
Mode::Normal => theme::GREEN,
|
||||
Mode::Command => theme::ORANGE,
|
||||
Mode::Search => theme::BLUE,
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", mode_str),
|
||||
Style::default()
|
||||
.fg(theme::BG0)
|
||||
.bg(mode_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Calendar view - upcoming releases.
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Build calendar grid for May 2026
|
||||
let today_day = 8u8; // May 8, 2026
|
||||
let year = 2026u16;
|
||||
let month = 5u8;
|
||||
|
||||
// May 2026 starts on Friday (day_of_week = 5)
|
||||
let start_dow = 5u8;
|
||||
let days_in_month = 31u8;
|
||||
let days_in_prev = 30u8; // April has 30 days
|
||||
|
||||
let mut cells: Vec<CalendarCell> = Vec::new();
|
||||
|
||||
// Previous month days
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
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) {
|
||||
if 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Next month days to fill 6 weeks (42 cells)
|
||||
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;
|
||||
}
|
||||
|
||||
// Layout: header + day-of-week + 6 rows of days
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Month header
|
||||
Constraint::Length(1), // Day of week labels
|
||||
Constraint::Fill(1), // Calendar grid
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Month header: "◀ May 2026 ▶"
|
||||
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],
|
||||
);
|
||||
|
||||
// Day of week labels
|
||||
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]);
|
||||
|
||||
// Calendar grid - 6 rows x 7 cols
|
||||
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();
|
||||
|
||||
// First line: day number with optional today marker
|
||||
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);
|
||||
|
||||
// Event lines
|
||||
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,90 @@
|
||||
//! History view - recent activity.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState},
|
||||
Frame,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Calculate widths
|
||||
let total_fixed = 11 + 12 + 2 + 22; // when + event_label + icon + artist
|
||||
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,141 @@
|
||||
//! Queue view - active downloads.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState},
|
||||
Frame,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Header row
|
||||
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);
|
||||
|
||||
// List 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| {
|
||||
// Calculate widths
|
||||
let total_fixed = 3 + 18 + 18 + 8 + 7; // icon + indexer + progress + speed + eta
|
||||
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,277 @@
|
||||
//! Settings view - configuration display.
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// 2x2 grid layout
|
||||
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::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState},
|
||||
Frame,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Header row
|
||||
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);
|
||||
|
||||
// List 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);
|
||||
|
||||
// Calculate widths
|
||||
let total_fixed = 3 + 28 + 6 + 7 + 12; // icon + artist + year + missing + date
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::input::{LeaderNode, LeaderState};
|
||||
use crate::theme;
|
||||
|
||||
const MIN_COLUMN_WIDTH: u16 = 28;
|
||||
|
||||
pub fn render_which_key(frame: &mut Frame, area: Rect, state: &LeaderState, tree: &LeaderNode) {
|
||||
let Some(current_node) = state.current_node(tree) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(children) = ¤t_node.children else {
|
||||
return;
|
||||
};
|
||||
|
||||
let popup_height = calculate_popup_height(children.len(), area.width);
|
||||
let popup_y = area.height.saturating_sub(popup_height + 1);
|
||||
let popup_area = Rect::new(0, popup_y, area.width, popup_height);
|
||||
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
let header = build_header(state, current_node);
|
||||
let block = Block::default()
|
||||
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
|
||||
.border_style(Style::default().fg(theme::ORANGE))
|
||||
.style(Style::default().bg(theme::BG1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
frame.render_widget(block, popup_area);
|
||||
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
frame.render_widget(header, chunks[0]);
|
||||
render_key_grid(frame, chunks[1], children);
|
||||
}
|
||||
|
||||
fn calculate_popup_height(child_count: usize, width: u16) -> u16 {
|
||||
let columns = (width / MIN_COLUMN_WIDTH).max(1) as usize;
|
||||
let rows = (child_count + columns - 1) / columns;
|
||||
(rows as u16 + 2).min(10)
|
||||
}
|
||||
|
||||
fn build_header(state: &LeaderState, node: &LeaderNode) -> Paragraph<'static> {
|
||||
let breadcrumb = state.breadcrumb();
|
||||
let description = node.name.clone();
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", breadcrumb),
|
||||
Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(description, Style::default().fg(theme::FG2)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Esc", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" cancel · ", Style::default().fg(theme::FG3)),
|
||||
Span::styled("Backspace", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" back", Style::default().fg(theme::FG3)),
|
||||
]);
|
||||
Paragraph::new(line)
|
||||
}
|
||||
|
||||
fn render_key_grid(frame: &mut Frame, area: Rect, children: &[(char, LeaderNode)]) {
|
||||
let columns = (area.width / MIN_COLUMN_WIDTH).max(1) as usize;
|
||||
let column_width = area.width / columns as u16;
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut current_row_spans: Vec<Span> = Vec::new();
|
||||
let mut col_idx = 0;
|
||||
|
||||
for (key, node) in children {
|
||||
let is_group = node.is_group();
|
||||
let key_style = Style::default().fg(theme::YELLOW);
|
||||
let name_style = if is_group {
|
||||
Style::default().fg(theme::ORANGE)
|
||||
} else {
|
||||
Style::default().fg(theme::FG2)
|
||||
};
|
||||
|
||||
let prefix = if is_group { "+" } else { "" };
|
||||
let entry = format!("{} → {}{}", key, prefix, node.name);
|
||||
|
||||
let key_span = Span::styled(format!("{}", key), key_style);
|
||||
let arrow_span = Span::styled(" → ", Style::default().fg(theme::GRAY));
|
||||
let name_span = Span::styled(format!("{}{}", prefix, node.name), name_style);
|
||||
let padding_len = column_width as usize - entry.len();
|
||||
let padding_span = Span::raw(" ".repeat(padding_len.min(20)));
|
||||
|
||||
current_row_spans.push(key_span);
|
||||
current_row_spans.push(arrow_span);
|
||||
current_row_spans.push(name_span);
|
||||
current_row_spans.push(padding_span);
|
||||
|
||||
col_idx += 1;
|
||||
if col_idx >= columns {
|
||||
lines.push(Line::from(std::mem::take(&mut current_row_spans)));
|
||||
col_idx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_row_spans.is_empty() {
|
||||
lines.push(Line::from(current_row_spans));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG1));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
Reference in New Issue
Block a user