refactor: replace vim keybindings with mouse navigation, remove mock data
- Remove all sample/mock data (artists, albums, tracks, queue, history, calendar) - Delete input module (vim.rs, leader.rs) and related UI (which_key, cmdline) - Add mouse support: click tabs, click list items, scroll wheel navigation - Show real disk space via nix::statvfs instead of hardcoded value - Simplify topbar/statusbar by removing mode display and key hints - Hide album/track sections when no artist is selected
This commit is contained in:
Generated
+19
@@ -65,6 +65,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color-eyre"
|
name = "color-eyre"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -344,6 +350,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.37.3"
|
version = "0.37.3"
|
||||||
@@ -632,6 +650,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"nix",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ edition = "2024"
|
|||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
color-eyre = "0.6"
|
color-eyre = "0.6"
|
||||||
|
nix = { version = "0.29", features = ["fs"] }
|
||||||
|
|||||||
@@ -60,6 +60,8 @@
|
|||||||
rust-analyzer
|
rust-analyzer
|
||||||
clippy
|
clippy
|
||||||
rustfmt
|
rustfmt
|
||||||
|
|
||||||
|
opencode
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+198
-794
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
|||||||
//! Data layer modules.
|
//! Data layer modules.
|
||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod sample;
|
|
||||||
|
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use sample::*;
|
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
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() },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
//! 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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
//! 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;
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#![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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+30
-6
@@ -4,15 +4,17 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, Event, KeyEventKind},
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
ExecutableCommand,
|
ExecutableCommand,
|
||||||
|
event::{
|
||||||
|
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
|
||||||
|
MouseEventKind,
|
||||||
|
},
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod data;
|
mod data;
|
||||||
mod input;
|
|
||||||
mod theme;
|
mod theme;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
@@ -39,10 +41,12 @@ fn main() -> Result<()> {
|
|||||||
fn setup_terminal() -> Result<()> {
|
fn setup_terminal() -> Result<()> {
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
stdout().execute(EnterAlternateScreen)?;
|
stdout().execute(EnterAlternateScreen)?;
|
||||||
|
stdout().execute(EnableMouseCapture)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_terminal() -> Result<()> {
|
fn restore_terminal() -> Result<()> {
|
||||||
|
stdout().execute(DisableMouseCapture)?;
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
stdout().execute(LeaveAlternateScreen)?;
|
stdout().execute(LeaveAlternateScreen)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -56,11 +60,31 @@ fn run() -> Result<()> {
|
|||||||
terminal.draw(|frame| app.draw(frame))?;
|
terminal.draw(|frame| app.draw(frame))?;
|
||||||
|
|
||||||
if event::poll(TICK_RATE)? {
|
if event::poll(TICK_RATE)? {
|
||||||
if let Event::Key(key) = event::read()? {
|
match event::read()? {
|
||||||
if key.kind == KeyEventKind::Press {
|
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||||
app.handle_key(key);
|
// Only handle Ctrl+C and Escape for quit
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& key.code == KeyCode::Char('c')
|
||||||
|
{
|
||||||
|
app.running = false;
|
||||||
|
} else if key.code == KeyCode::Esc {
|
||||||
|
app.handle_escape();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Mouse(mouse) => match mouse.kind {
|
||||||
|
MouseEventKind::Down(button) => {
|
||||||
|
app.handle_click(mouse.column, mouse.row, button);
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollUp => {
|
||||||
|
app.handle_scroll(-1);
|
||||||
|
}
|
||||||
|
MouseEventKind::ScrollDown => {
|
||||||
|
app.handle_scroll(1);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
app.handle_tick();
|
app.handle_tick();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
+59
-32
@@ -1,16 +1,16 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{List, ListItem, ListState, Paragraph},
|
widgets::{List, ListItem, ListState, Paragraph},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::data::{Album, AlbumStatus, Artist, Track};
|
use crate::data::{Album, AlbumStatus, Artist, Track};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui::pane::{section_divider, Pane};
|
use crate::ui::pane::{Pane, section_divider};
|
||||||
use crate::ui::progress_bar::progress_bar;
|
use crate::ui::progress_bar::progress_bar;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -53,7 +53,9 @@ impl LibraryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn selected_artist(&self) -> Option<&Artist> {
|
pub fn selected_artist(&self) -> Option<&Artist> {
|
||||||
self.artist_state.selected().and_then(|i| self.artists.get(i))
|
self.artist_state
|
||||||
|
.selected()
|
||||||
|
.and_then(|i| self.artists.get(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selected_album(&self) -> Option<&Album> {
|
pub fn selected_album(&self) -> Option<&Album> {
|
||||||
@@ -101,7 +103,8 @@ impl LibraryState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LibraryFocus::Albums => {
|
LibraryFocus::Albums => {
|
||||||
let max = self.selected_artist()
|
let max = self
|
||||||
|
.selected_artist()
|
||||||
.map(|a| a.albums.len().saturating_sub(1))
|
.map(|a| a.albums.len().saturating_sub(1))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if let Some(i) = self.album_state.selected() {
|
if let Some(i) = self.album_state.selected() {
|
||||||
@@ -309,7 +312,13 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|||||||
let artist = state.selected_artist();
|
let artist = state.selected_artist();
|
||||||
|
|
||||||
let meta = artist
|
let meta = artist
|
||||||
.map(|a| format!("{} · {}", a.country, a.genres.first().map(|s| s.as_str()).unwrap_or("")))
|
.map(|a| {
|
||||||
|
format!(
|
||||||
|
"{} · {}",
|
||||||
|
a.country,
|
||||||
|
a.genres.first().map(|s| s.as_str()).unwrap_or("")
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let have_tracks: u16 = artist
|
let have_tracks: u16 = artist
|
||||||
@@ -319,21 +328,14 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|||||||
.map(|a| a.albums.iter().map(|al| al.total).sum())
|
.map(|a| a.albums.iter().map(|al| al.total).sum())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let footer = Line::from(vec![
|
let footer = if artist.is_some() {
|
||||||
Span::styled("[a]", Style::default().fg(theme::GRAY)),
|
Line::from(vec![Span::styled(
|
||||||
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),
|
format!("{}/{} tracks", have_tracks, total_tracks),
|
||||||
Style::default().fg(theme::GRAY),
|
Style::default().fg(theme::GRAY),
|
||||||
),
|
)])
|
||||||
]);
|
} else {
|
||||||
|
Line::from("")
|
||||||
|
};
|
||||||
|
|
||||||
let pane = Pane::new("Detail")
|
let pane = Pane::new("Detail")
|
||||||
.meta(&meta)
|
.meta(&meta)
|
||||||
@@ -344,6 +346,15 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let Some(artist) = artist else {
|
||||||
|
let msg = Paragraph::new(Span::styled(
|
||||||
|
"No artist selected",
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
));
|
||||||
|
frame.render_widget(msg, inner);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let chunks = Layout::vertical([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Length(6),
|
Constraint::Length(6),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -353,11 +364,9 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
if let Some(artist) = artist {
|
|
||||||
render_artist_header(frame, chunks[0], artist);
|
render_artist_header(frame, chunks[0], artist);
|
||||||
}
|
|
||||||
|
|
||||||
let albums_count = artist.map(|a| a.albums.len()).unwrap_or(0);
|
let albums_count = artist.albums.len();
|
||||||
let albums_label = format!("{} releases", albums_count);
|
let albums_label = format!("{} releases", albums_count);
|
||||||
let album_divider = section_divider("albums", Some(&albums_label));
|
let album_divider = section_divider("albums", Some(&albums_label));
|
||||||
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
|
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
|
||||||
@@ -407,7 +416,9 @@ fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
|
|||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
&artist.name,
|
&artist.name,
|
||||||
Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(theme::YELLOW)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
@@ -427,7 +438,10 @@ fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
|
|||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("albums ", Style::default().fg(theme::GRAY)),
|
Span::styled("albums ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled(artist.albums.len().to_string(), Style::default().fg(theme::FG1)),
|
Span::styled(
|
||||||
|
artist.albums.len().to_string(),
|
||||||
|
Style::default().fg(theme::FG1),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("tracks ", Style::default().fg(theme::GRAY)),
|
Span::styled("tracks ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled(have.to_string(), Style::default().fg(theme::FG1)),
|
Span::styled(have.to_string(), Style::default().fg(theme::FG1)),
|
||||||
@@ -463,7 +477,8 @@ fn render_albums_list(
|
|||||||
Style::default().fg(theme::AQUA)
|
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 title_width =
|
||||||
|
area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8;
|
||||||
let mut title = album.title.clone();
|
let mut title = album.title.clone();
|
||||||
if title.len() > title_width {
|
if title.len() > title_width {
|
||||||
title.truncate(title_width.saturating_sub(1));
|
title.truncate(title_width.saturating_sub(1));
|
||||||
@@ -560,20 +575,32 @@ impl LibraryState {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
||||||
if album.id == "bush" {
|
|
||||||
return crate::data::sample_tracks_bush_hall();
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks_for(album)
|
tracks_for(album)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tracks_for(album: &Album) -> Vec<Track> {
|
fn tracks_for(album: &Album) -> Vec<Track> {
|
||||||
let titles = [
|
let titles = [
|
||||||
"Opening", "Curtain Call", "Half-Light", "Polaroid", "Switchback",
|
"Opening",
|
||||||
"Slow Dancer", "The Inheritance", "Glassworks", "Interlude", "Aftermath",
|
"Curtain Call",
|
||||||
"Static", "Returner", "Dust Bowl", "Postcard", "Late Reply",
|
"Half-Light",
|
||||||
"Honeymoon", "Northern Lights", "Cold Open", "Coda", "Reprise",
|
"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)
|
(0..album.total)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod cmdline;
|
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
@@ -7,4 +6,3 @@ pub mod progress_bar;
|
|||||||
pub mod statusbar;
|
pub mod statusbar;
|
||||||
pub mod topbar;
|
pub mod topbar;
|
||||||
pub mod views;
|
pub mod views;
|
||||||
pub mod which_key;
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
@@ -18,9 +18,7 @@ fn keybind_row<'a>(key: &'a str, desc: &'a str) -> Line<'a> {
|
|||||||
fn section_header(title: &str) -> Line<'static> {
|
fn section_header(title: &str) -> Line<'static> {
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
title.to_string(),
|
title.to_string(),
|
||||||
Style::default()
|
Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD),
|
||||||
.fg(theme::FG1)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +61,10 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect) {
|
|||||||
render_col3(frame, cols[2]);
|
render_col3(frame, cols[2]);
|
||||||
|
|
||||||
let footer_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
let footer_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||||
let footer = Paragraph::new(Line::from(vec![
|
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||||
Span::styled(
|
|
||||||
"harmony · v0.4.2 · canonical evil-mode bindings (Vim/Doom Emacs)",
|
"harmony · v0.4.2 · canonical evil-mode bindings (Vim/Doom Emacs)",
|
||||||
Style::default().fg(theme::GRAY),
|
Style::default().fg(theme::GRAY),
|
||||||
),
|
)]));
|
||||||
]));
|
|
||||||
frame.render_widget(footer, footer_area);
|
frame.render_widget(footer, footer_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|||||||
+14
-6
@@ -3,11 +3,11 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
@@ -62,7 +62,13 @@ impl NotificationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push(&mut self, title: impl Into<String>, detail: Option<String>, kind: NotifKind, icon: impl Into<String>) {
|
pub fn push(
|
||||||
|
&mut self,
|
||||||
|
title: impl Into<String>,
|
||||||
|
detail: Option<String>,
|
||||||
|
kind: NotifKind,
|
||||||
|
icon: impl Into<String>,
|
||||||
|
) {
|
||||||
let notification = Notification {
|
let notification = Notification {
|
||||||
id: self.next_id,
|
id: self.next_id,
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
@@ -81,9 +87,8 @@ impl NotificationManager {
|
|||||||
|
|
||||||
pub fn tick(&mut self) {
|
pub fn tick(&mut self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
self.notifications.retain(|n| {
|
self.notifications
|
||||||
now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS
|
.retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||||
@@ -151,7 +156,10 @@ impl NotificationManager {
|
|||||||
d.truncate(max_len.saturating_sub(1));
|
d.truncate(max_len.saturating_sub(1));
|
||||||
d.push('…');
|
d.push('…');
|
||||||
}
|
}
|
||||||
lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY))));
|
lines.push(Line::from(Span::styled(
|
||||||
|
d,
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let para = Paragraph::new(lines);
|
let para = Paragraph::new(lines);
|
||||||
|
|||||||
+10
-2
@@ -43,8 +43,16 @@ impl<'a> Pane<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_block(&self) -> Block<'a> {
|
pub fn build_block(&self) -> Block<'a> {
|
||||||
let border_color = if self.focused { theme::YELLOW } else { theme::BG3 };
|
let border_color = if self.focused {
|
||||||
let title_color = if self.focused { theme::YELLOW } else { theme::GRAY };
|
theme::YELLOW
|
||||||
|
} else {
|
||||||
|
theme::BG3
|
||||||
|
};
|
||||||
|
let title_color = if self.focused {
|
||||||
|
theme::YELLOW
|
||||||
|
} else {
|
||||||
|
theme::GRAY
|
||||||
|
};
|
||||||
|
|
||||||
let mut title_spans = vec![
|
let mut title_spans = vec![
|
||||||
Span::styled("─[ ", Style::default().fg(border_color)),
|
Span::styled("─[ ", Style::default().fg(border_color)),
|
||||||
|
|||||||
+20
-46
@@ -1,69 +1,40 @@
|
|||||||
|
use nix::sys::statvfs::statvfs;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::Mode;
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
pub struct StatusHint {
|
fn get_free_space() -> String {
|
||||||
pub key: &'static str,
|
match statvfs("/") {
|
||||||
pub action: &'static str,
|
Ok(stat) => {
|
||||||
|
let free_bytes = stat.blocks_available() as u64 * stat.fragment_size() as u64;
|
||||||
|
let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||||
|
if free_gb >= 1000.0 {
|
||||||
|
format!("{:.1} TB free", free_gb / 1024.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB free", free_gb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => "-- GB free".to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_statusbar(
|
pub fn render_statusbar(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
mode: Mode,
|
|
||||||
hints: &[StatusHint],
|
|
||||||
position: Option<(usize, usize)>,
|
position: Option<(usize, usize)>,
|
||||||
queue_count: usize,
|
queue_count: usize,
|
||||||
wanted_count: usize,
|
wanted_count: usize,
|
||||||
status_message: Option<&str>,
|
|
||||||
) {
|
) {
|
||||||
let mut spans = Vec::new();
|
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)));
|
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 left_width: usize = spans.iter().map(|s| s.content.len()).sum();
|
||||||
|
|
||||||
let mut right_spans = Vec::new();
|
let mut right_spans = Vec::new();
|
||||||
@@ -96,7 +67,7 @@ pub fn render_statusbar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
right_spans.push(Span::styled(
|
right_spans.push(Span::styled(
|
||||||
" 47.3 GB free ",
|
format!(" {} ", get_free_space()),
|
||||||
Style::default().fg(theme::GRAY).bg(theme::BG2),
|
Style::default().fg(theme::GRAY).bg(theme::BG2),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -106,7 +77,10 @@ pub fn render_statusbar(
|
|||||||
));
|
));
|
||||||
|
|
||||||
let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum();
|
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;
|
let spacer_width = area
|
||||||
|
.width
|
||||||
|
.saturating_sub(left_width as u16)
|
||||||
|
.saturating_sub(right_width as u16) as usize;
|
||||||
|
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" ".repeat(spacer_width),
|
" ".repeat(spacer_width),
|
||||||
|
|||||||
+55
-40
@@ -1,46 +1,73 @@
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{Mode, Tab};
|
use crate::app::Tab;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
pub fn render_topbar(frame: &mut Frame, area: Rect, active_tab: Tab, mode: Mode, queue_count: usize, wanted_count: usize) {
|
pub fn render_topbar(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
active_tab: Tab,
|
||||||
|
queue_count: usize,
|
||||||
|
wanted_count: usize,
|
||||||
|
) -> Vec<Rect> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
let mut tab_areas = Vec::new();
|
||||||
|
let mut current_x = area.x;
|
||||||
|
|
||||||
|
let logo = " ▲ harmony ";
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" ▲ harmony ",
|
logo,
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme::BG0)
|
.fg(theme::BG0)
|
||||||
.bg(theme::ORANGE)
|
.bg(theme::ORANGE)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
|
current_x += logo.len() as u16;
|
||||||
|
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
|
current_x += 1;
|
||||||
|
|
||||||
let tabs = [
|
let tabs = [
|
||||||
(Tab::Library, "Library", None),
|
(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::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::History, "History", None),
|
||||||
(Tab::Calendar, "Calendar", None),
|
(Tab::Calendar, "Calendar", None),
|
||||||
(Tab::Settings, "Settings", None),
|
(Tab::Settings, "Settings", None),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (i, (tab, label, badge)) in tabs.iter().enumerate() {
|
for (tab, label, badge) in tabs.iter() {
|
||||||
let is_active = *tab == active_tab;
|
let is_active = *tab == active_tab;
|
||||||
let num = format!("{}", i + 1);
|
|
||||||
|
let tab_start = current_x;
|
||||||
|
let text = format!(" {} ", label);
|
||||||
|
let mut tab_width = text.len() as u16;
|
||||||
|
|
||||||
if is_active {
|
if is_active {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!(" {}", num),
|
text,
|
||||||
Style::default().fg(theme::ORANGE).bg(theme::BG0),
|
|
||||||
));
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" {} ", label),
|
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme::YELLOW)
|
.fg(theme::YELLOW)
|
||||||
.bg(theme::BG0)
|
.bg(theme::BG0)
|
||||||
@@ -48,52 +75,40 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, active_tab: Tab, mode: Mode,
|
|||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!(" {}", num),
|
text,
|
||||||
Style::default().fg(theme::GRAY).bg(theme::BG1),
|
|
||||||
));
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" {} ", label),
|
|
||||||
Style::default().fg(theme::FG3).bg(theme::BG1),
|
Style::default().fg(theme::FG3).bg(theme::BG1),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
current_x += tab_width;
|
||||||
|
|
||||||
if let Some(count) = badge {
|
if let Some(count) = badge {
|
||||||
|
let badge_text = format!(" {} ", count);
|
||||||
|
let badge_width = badge_text.len() as u16;
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!(" {} ", count),
|
badge_text,
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme::BG0)
|
.fg(theme::BG0)
|
||||||
.bg(theme::RED)
|
.bg(theme::RED)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
}
|
tab_width += badge_width;
|
||||||
|
current_x += badge_width;
|
||||||
}
|
}
|
||||||
|
|
||||||
let used_width: usize = spans.iter().map(|s| s.content.len()).sum();
|
tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1));
|
||||||
let remaining = area.width.saturating_sub(used_width as u16).saturating_sub(10) as usize;
|
}
|
||||||
|
|
||||||
|
let used_width = current_x - area.x;
|
||||||
|
let remaining = area.width.saturating_sub(used_width) as usize;
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" ".repeat(remaining),
|
" ".repeat(remaining),
|
||||||
Style::default().bg(theme::BG1),
|
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 line = Line::from(spans);
|
||||||
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1));
|
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1));
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
|
|
||||||
|
tab_areas
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Calendar view - upcoming releases.
|
//! Calendar view - upcoming releases.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::data::CalendarEntry;
|
use crate::data::CalendarEntry;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! History view - recent activity.
|
//! History view - recent activity.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{List, ListItem, ListState},
|
widgets::{List, ListItem, ListState},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::data::HistoryEntry;
|
use crate::data::HistoryEntry;
|
||||||
@@ -73,7 +73,10 @@ pub fn render_history(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(format!("{:<11}", entry.when), Style::default().fg(theme::GRAY)),
|
Span::styled(
|
||||||
|
format!("{:<11}", entry.when),
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
),
|
||||||
Span::styled(format!("{:<12}", label), style),
|
Span::styled(format!("{:<12}", label), style),
|
||||||
Span::styled(format!("{} ", icon), style),
|
Span::styled(format!("{} ", icon), style),
|
||||||
Span::styled(artist, Style::default().fg(theme::FG1)),
|
Span::styled(artist, Style::default().fg(theme::FG1)),
|
||||||
@@ -84,7 +87,6 @@ pub fn render_history(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
|
||||||
.highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
|
|
||||||
frame.render_stateful_widget(list, inner, state);
|
frame.render_stateful_widget(list, inner, state);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-10
@@ -1,11 +1,11 @@
|
|||||||
//! Queue view - active downloads.
|
//! Queue view - active downloads.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{List, ListItem, ListState},
|
widgets::{List, ListItem, ListState},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::data::QueueEntry;
|
use crate::data::QueueEntry;
|
||||||
@@ -22,12 +22,7 @@ fn progress_bar_aqua(progress: f64, width: usize) -> Vec<Span<'static>> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_queue(
|
pub fn render_queue(frame: &mut Frame, area: Rect, queue: &[QueueEntry], state: &mut ListState) {
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
queue: &[QueueEntry],
|
|
||||||
state: &mut ListState,
|
|
||||||
) {
|
|
||||||
let total_speed: f64 = queue
|
let total_speed: f64 = queue
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|q| q.speed.trim_end_matches(" MB/s").parse::<f64>().ok())
|
.filter_map(|q| q.speed.trim_end_matches(" MB/s").parse::<f64>().ok())
|
||||||
@@ -120,7 +115,10 @@ pub fn render_queue(
|
|||||||
Span::raw(" ".repeat(indexer_pad)),
|
Span::raw(" ".repeat(indexer_pad)),
|
||||||
];
|
];
|
||||||
spans.extend(progress_bar_aqua(entry.progress, 12));
|
spans.extend(progress_bar_aqua(entry.progress, 12));
|
||||||
spans.push(Span::styled(format!(" {:>3}%", pct), Style::default().fg(theme::GRAY)));
|
spans.push(Span::styled(
|
||||||
|
format!(" {:>3}%", pct),
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
));
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
format!("{:>8}", entry.speed),
|
format!("{:>8}", entry.speed),
|
||||||
@@ -135,7 +133,6 @@ pub fn render_queue(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
|
||||||
.highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
|
|
||||||
frame.render_stateful_widget(list, list_area, state);
|
frame.render_stateful_widget(list, list_area, state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Settings view - configuration display.
|
//! Settings view - configuration display.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
@@ -50,9 +50,12 @@ pub fn render_settings(frame: &mut Frame, area: Rect) {
|
|||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
// 2x2 grid layout
|
// 2x2 grid layout
|
||||||
let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
|
let rows =
|
||||||
let top_cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[0]);
|
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
|
||||||
let bot_cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
|
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_library_section(frame, top_cols[0]);
|
||||||
render_quality_section(frame, top_cols[1]);
|
render_quality_section(frame, top_cols[1]);
|
||||||
@@ -74,7 +77,10 @@ fn section_block(title: &str) -> Block<'_> {
|
|||||||
|
|
||||||
fn render_setting_row(row: &SettingRow) -> Line<'static> {
|
fn render_setting_row(row: &SettingRow) -> Line<'static> {
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(format!("{:<18}", row.label), Style::default().fg(theme::GRAY)),
|
Span::styled(
|
||||||
|
format!("{:<18}", row.label),
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
),
|
||||||
Span::styled(row.value.to_string(), row.value_style),
|
Span::styled(row.value.to_string(), row.value_style),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("[{}]", row.tag), row.tag_style),
|
Span::styled(format!("[{}]", row.tag), row.tag_style),
|
||||||
@@ -227,8 +233,14 @@ fn render_indexers_section(frame: &mut Frame, area: Rect) {
|
|||||||
};
|
};
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(format!("{:<18}", ix.name), name_style),
|
Span::styled(format!("{:<18}", ix.name), name_style),
|
||||||
Span::styled(format!("priority {} ", ix.priority), Style::default().fg(theme::AQUA)),
|
Span::styled(
|
||||||
Span::styled(format!("· {}", ix.formats), Style::default().fg(theme::GRAY)),
|
format!("priority {} ", ix.priority),
|
||||||
|
Style::default().fg(theme::AQUA),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("· {}", ix.formats),
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("[{}]", ix.state), ix.tag_style),
|
Span::styled(format!("[{}]", ix.state), ix.tag_style),
|
||||||
])
|
])
|
||||||
@@ -260,13 +272,19 @@ fn render_appearance_section(frame: &mut Frame, area: Rect) {
|
|||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("scanlines (CRT) ", Style::default().fg(theme::GRAY)),
|
Span::styled("scanlines (CRT) ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled("[ ] subtle scanline overlay", Style::default().fg(theme::GRAY)),
|
Span::styled(
|
||||||
|
"[ ] subtle scanline overlay",
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("[off]", Style::default().fg(theme::FG2)),
|
Span::styled("[off]", Style::default().fg(theme::FG2)),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("unicode glyphs ", Style::default().fg(theme::GRAY)),
|
Span::styled("unicode glyphs ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled("box-drawing · powerline · nerd", Style::default().fg(theme::FG1)),
|
Span::styled(
|
||||||
|
"box-drawing · powerline · nerd",
|
||||||
|
Style::default().fg(theme::FG1),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("[on]", Style::default().fg(theme::GREEN)),
|
Span::styled("[on]", Style::default().fg(theme::GREEN)),
|
||||||
]),
|
]),
|
||||||
|
|||||||
+15
-12
@@ -1,11 +1,11 @@
|
|||||||
//! Wanted view - missing albums and tracks.
|
//! Wanted view - missing albums and tracks.
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{List, ListItem, ListState},
|
widgets::{List, ListItem, ListState},
|
||||||
Frame,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::data::{AlbumStatus, WantedEntry};
|
use crate::data::{AlbumStatus, WantedEntry};
|
||||||
@@ -20,12 +20,7 @@ fn status_icon(status: AlbumStatus) -> (char, Style) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_wanted(
|
pub fn render_wanted(frame: &mut Frame, area: Rect, wanted: &[WantedEntry], state: &mut ListState) {
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
wanted: &[WantedEntry],
|
|
||||||
state: &mut ListState,
|
|
||||||
) {
|
|
||||||
let total_missing: u16 = wanted.iter().map(|w| w.missing).sum();
|
let total_missing: u16 = wanted.iter().map(|w| w.missing).sum();
|
||||||
let count_str = format!("{} missing or partial", wanted.len());
|
let count_str = format!("{} missing or partial", wanted.len());
|
||||||
|
|
||||||
@@ -57,7 +52,10 @@ pub fn render_wanted(
|
|||||||
Span::styled(" ", Style::default().fg(theme::GRAY)),
|
Span::styled(" ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled("ALBUM", Style::default().fg(theme::GRAY)),
|
Span::styled("ALBUM", Style::default().fg(theme::GRAY)),
|
||||||
Span::raw(" ".repeat(inner.width.saturating_sub(70) as usize)),
|
Span::raw(" ".repeat(inner.width.saturating_sub(70) as usize)),
|
||||||
Span::styled("ARTIST ", Style::default().fg(theme::GRAY)),
|
Span::styled(
|
||||||
|
"ARTIST ",
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
),
|
||||||
Span::styled("YEAR ", Style::default().fg(theme::GRAY)),
|
Span::styled("YEAR ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled("MISSING ", Style::default().fg(theme::GRAY)),
|
Span::styled("MISSING ", Style::default().fg(theme::GRAY)),
|
||||||
Span::styled("RELEASE DATE", Style::default().fg(theme::GRAY)),
|
Span::styled("RELEASE DATE", Style::default().fg(theme::GRAY)),
|
||||||
@@ -108,8 +106,14 @@ pub fn render_wanted(
|
|||||||
Span::raw(" ".repeat(album_pad)),
|
Span::raw(" ".repeat(album_pad)),
|
||||||
Span::styled(artist, Style::default().fg(theme::GRAY)),
|
Span::styled(artist, Style::default().fg(theme::GRAY)),
|
||||||
Span::raw(" ".repeat(artist_pad)),
|
Span::raw(" ".repeat(artist_pad)),
|
||||||
Span::styled(format!("{:<6}", entry.year), Style::default().fg(theme::GRAY)),
|
Span::styled(
|
||||||
Span::styled(format!("{:>7}", entry.missing), Style::default().fg(theme::RED)),
|
format!("{:<6}", entry.year),
|
||||||
|
Style::default().fg(theme::GRAY),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:>7}", entry.missing),
|
||||||
|
Style::default().fg(theme::RED),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(&entry.release_date, Style::default().fg(theme::GRAY)),
|
Span::styled(&entry.release_date, Style::default().fg(theme::GRAY)),
|
||||||
])
|
])
|
||||||
@@ -117,7 +121,6 @@ pub fn render_wanted(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items).highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
|
||||||
.highlight_style(Style::default().bg(theme::YELLOW).fg(theme::BG0));
|
|
||||||
frame.render_stateful_widget(list, list_area, state);
|
frame.render_stateful_widget(list, list_area, state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
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