diff --git a/Cargo.lock b/Cargo.lock index 1f2b7f6..937d531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "async-sqlite" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556a4163d701c7d3e28c89917294cea8d2a05092c92a50307c0f2d1742058ced" +checksum = "c0d7f95be4bae0f9c86c7550b9ea890fa3283a06d71aef9a99d7f21de07a93a1" dependencies = [ "crossbeam-channel", "futures-channel", @@ -26,6 +26,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "cairo-rs" version = "0.21.5" @@ -49,6 +55,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-expr" version = "0.20.5" @@ -59,6 +75,12 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -103,10 +125,34 @@ dependencies = [ ] [[package]] -name = "foldhash" -version = "0.1.5" +name = "find-msvc-tools" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" [[package]] name = "futures" @@ -254,6 +300,19 @@ dependencies = [ "system-deps", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "gio" version = "0.21.5" @@ -447,26 +506,20 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown", ] [[package]] @@ -482,7 +535,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", ] [[package]] @@ -524,14 +587,23 @@ checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "pkg-config", "vcpkg", ] +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "memchr" version = "2.7.6" @@ -547,6 +619,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "pango" version = "0.21.5" @@ -617,10 +704,34 @@ dependencies = [ ] [[package]] -name = "relm4-macros" -version = "0.10.1" +name = "relm4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25edbb5b2e8126975f1dd8e85c48cd310afc150beed0dc97df22247b3243971e" +checksum = "6bae902de22fd92e62641f047975abf228573425b9b8de175e8ab5b6cda10379" +dependencies = [ + "flume", + "fragile", + "futures", + "gtk4", + "libadwaita", + "once_cell", + "relm4-css", + "relm4-macros", + "tokio", + "tracing", +] + +[[package]] +name = "relm4-css" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37dbe7a114855a22618f0e13595ce6b3f165478c13c2dfc4f4f99614da105797" + +[[package]] +name = "relm4-macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175fce497fc6f11dde7ea56daa30ff7ad29a534bbc209d59d766659c880ba5f1" dependencies = [ "proc-macro2", "quote", @@ -629,9 +740,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags", "fallible-iterator", @@ -639,6 +750,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -650,6 +762,18 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -685,6 +809,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.11" @@ -697,6 +827,28 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e98301bf8b0540c7de45ecd760539b9c62f5772aed172f08efba597c11cd5d" +dependencies = [ + "cc", + "hashbrown", + "js-sys", + "thiserror", + "wasm-bindgen", +] + [[package]] name = "syn" version = "2.0.112" @@ -727,6 +879,47 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.9.10+spec-1.1.0" @@ -778,6 +971,37 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -796,6 +1020,57 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -821,13 +1096,12 @@ dependencies = [ ] [[package]] -name = "zoodex" +name = "zoodex-scratchpad" version = "1.0.0" dependencies = [ "async-sqlite", - "fallible-iterator", - "futures", "gtk4", "libadwaita", - "relm4-macros", + "relm4", + "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 0872c82..fd8b2f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "zoodex" +name = "zoodex-scratchpad" version = "1.0.0" authors = ["Reinout Meliesie "] edition = "2024" @@ -10,9 +10,9 @@ license = "GPL-3.0-or-later" lto = true [dependencies] -async-sqlite = { version = "0.5.3", default-features = false } -fallible-iterator = "0.3.0" # Must match version used by async-sqlite -futures = "0.3.31" +async-sqlite = { version = "0.5.4", default-features = false } gtk4 = { version = "0.10.3", features = ["v4_20"] } libadwaita = { version = "0.8.1", features = ["v1_8"] } -relm4-macros = { version = "0.10.1", default-features = false } +relm4 = { version = "0.10.0", features = ["gnome_48"] } +# Keep version in sync with the one Relm4 uses +tokio = { version = "1.47.3", features = ["macros", "rt", "rt-multi-thread", "sync"] } diff --git a/src/application.css b/src/application.css index 1f47be8..a152f98 100644 --- a/src/application.css +++ b/src/application.css @@ -1,29 +1,8 @@ -/* TODO: Switch out CSS dynamically on `gtk-application-prefer-dark-theme` property change */ -.collation-menu row:selected { - background-color: rgb(0 0 0 / 0.08); -} - -.collation-menu row:not(:selected) image { - opacity: 0; -} - -.collatable-container flowboxchild { +.media-grid flowboxchild { padding: 0; } -.collection-item-button { - font-weight: normal; /* No bold text by default for this kind of button */ -} - -.collection-item-box { - margin-top: 20px; - margin-bottom: 20px; -} - -.collection-item-image { - margin-bottom: 20px; -} - -.media-modal { - padding: 100px; +.media-grid-item { + /* No bold text by default for this kind of button */ + font-weight: normal; } diff --git a/src/data_manager.rs b/src/data_manager.rs deleted file mode 100644 index 94e2958..0000000 --- a/src/data_manager.rs +++ /dev/null @@ -1,287 +0,0 @@ -use std::env::var_os; -use std::path::PathBuf; - -use async_sqlite::rusqlite::{OpenFlags, Row}; -use async_sqlite::{Client, ClientBuilder, rusqlite}; -use fallible_iterator::FallibleIterator; - -use crate::error::{Result, ZoodexError}; -use crate::utility::concat_os_str; - - - -pub struct DataManager { - sqlite_client_local: Client, - sqlite_client_shared: Client, -} - -impl DataManager { - pub async fn new() -> Result { - let home_directory = var_os("HOME").unwrap(); - let xdg_data_home = var_os("XDG_DATA_HOME"); - - let data_dir = match xdg_data_home { - Some(xdg_data_home) => concat_os_str!(xdg_data_home, "/zoodex"), - None => concat_os_str!(home_directory, "/.local/share/zoodex"), - }; - - let sqlite_client_shared = ClientBuilder::new() - .path(concat_os_str!(&data_dir, "/shared.sqlite")) - .flags(OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX) - .open() - .await?; - - let sqlite_client_local = ClientBuilder::new() - .path(concat_os_str!(&data_dir, "/local.sqlite")) - .flags(OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX) - .open() - .await?; - - Ok(Self { - sqlite_client_local, - sqlite_client_shared, - }) - } - - pub async fn get_collection_overview(&self) -> Result { - let collection_overview = self - .sqlite_client_shared - .conn(|sqlite_connection| { - let films = sqlite_connection - .prepare( - " - select uuid , name , original_name , release_date , runtime_minutes - from films - ", - )? - .query(())? - .map(row_to_film_overview) - .collect()?; - - let series = sqlite_connection - .prepare( - " - select series . uuid , series . name , series . original_name , - min ( episodes . release_date ) - from series , seasons , episodes - where series . uuid = seasons . series and seasons . uuid = episodes . season - group by series . uuid - ", - )? - .query(())? - .map(row_to_series_overview) - .collect()?; - - Ok(CollectionOverview { films, series }) - }) - .await?; - Ok(collection_overview) - } - - pub async fn get_film_details(&self, uuid: String) -> Result { - let film_details = self - .sqlite_client_shared - .conn(|sqlite_connection| { - let film_details = sqlite_connection - .prepare( - " - select - films . uuid , - films . name , - films . original_name , - films . release_date , - films . runtime_minutes , - sources . media_uuid , - sources . bittorrent_hash , - sources . file_path , - sources . audio_track , - sources . subtitle_track - from films left join sources - on films . uuid = sources . media_uuid - where films . uuid = (?1) - ", - )? - .query([uuid])? - .map(row_to_film_details) - .next()? - .unwrap(); - Ok(film_details) - }) - .await?; - Ok(film_details) - } -} - - - -pub struct CollectionOverview { - pub films: Vec, - pub series: Vec, -} - -pub trait MediaOverview: Clone { - fn get_uuid(&self) -> String; - fn get_name(&self) -> String; - fn get_original_name(&self) -> Option; - fn get_release_date(&self) -> String; - fn get_runtime_minutes(&self) -> Option; -} - -#[derive(Clone)] -pub struct FilmOverview { - pub uuid: String, - pub name: String, - pub original_name: Option, - // TODO: Switch to chrono types, I think rusqlite has crate option for it - pub release_date: String, - pub runtime_minutes: u32, -} -#[derive(Clone)] -pub struct SeriesOverview { - pub uuid: String, - pub name: String, - pub original_name: Option, - // TODO: Switch to chrono types, I think rusqlite has crate option for it - pub first_release_date: String, -} - -impl MediaOverview for FilmOverview { - fn get_uuid(&self) -> String { - self.uuid.clone() - } - fn get_name(&self) -> String { - self.name.clone() - } - fn get_original_name(&self) -> Option { - self.original_name.clone() - } - fn get_release_date(&self) -> String { - self.release_date.clone() - } - fn get_runtime_minutes(&self) -> Option { - Some(self.runtime_minutes) - } -} -impl MediaOverview for SeriesOverview { - fn get_uuid(&self) -> String { - self.uuid.clone() - } - fn get_name(&self) -> String { - self.name.clone() - } - fn get_original_name(&self) -> Option { - self.original_name.clone() - } - fn get_release_date(&self) -> String { - self.first_release_date.clone() - } - fn get_runtime_minutes(&self) -> Option { - None - } -} - -fn row_to_film_overview(row: &Row) -> rusqlite::Result { - let uuid = row.get(0)?; - let name = row.get(1)?; - let original_name = row.get(2)?; - let release_date = row.get(3)?; - let runtime_minutes = row.get(4)?; - - Ok(FilmOverview { - uuid, - name, - original_name, - release_date, - runtime_minutes, - }) -} -fn row_to_series_overview(row: &Row) -> rusqlite::Result { - let uuid = row.get(0)?; - let name = row.get(1)?; - let original_name = row.get(2)?; - let first_release_date = row.get(3)?; - - Ok(SeriesOverview { - uuid, - name, - original_name, - first_release_date, - }) -} - - - -#[derive(Clone)] -pub struct FilmDetails { - pub uuid: String, - pub name: String, - pub original_name: Option, - pub release_date: String, - pub runtime_minutes: u32, - pub source: Option, -} -#[derive(Clone)] -pub struct SourceDetails { - pub bittorrent_hash: String, - pub file_path: PathBuf, - pub audio_track: Option, - pub subtitle_track: Option, -} - -fn row_to_film_details(row: &Row) -> rusqlite::Result { - let uuid = row.get(0)?; - let name = row.get(1)?; - let original_name = row.get(2)?; - let release_date = row.get(3)?; - let runtime_minutes = row.get(4)?; - - let source_media_uuid = row.get::<_, Option>(5)?; - let source = match source_media_uuid { - Some(_) => { - let bittorrent_hash = row.get(6)?; - let file_path = PathBuf::from(row.get::<_, String>(7)?); - let audio_track = row.get(8)?; - let subtitle_track = row.get(9)?; - - Some(SourceDetails { - bittorrent_hash, - file_path, - audio_track, - subtitle_track, - }) - } - None => None, - }; - - Ok(FilmDetails { - uuid, - name, - original_name, - release_date, - runtime_minutes, - source, - }) -} - - - -impl From for ZoodexError { - fn from(error: async_sqlite::Error) -> Self { - match error { - async_sqlite::Error::Rusqlite(error) => ZoodexError::from(error), - _ => panic!("{}", error), - } - } -} - -impl From for ZoodexError { - fn from(error: rusqlite::Error) -> Self { - match error { - rusqlite::Error::SqliteFailure(error, _) => match error.code { - rusqlite::ffi::ErrorCode::CannotOpen => ZoodexError::CollectionFileReadError, - _ => panic!("{}", error), - }, - _ => panic!("{}", error), - } - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 8f43c4d..0000000 --- a/src/error.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::any::Any; -use std::result; - - - -#[derive(Debug)] -pub enum ZoodexError { - CollectionFileReadError, -} - -pub type Result = result::Result; - -impl From> for ZoodexError { - fn from(error: Box) -> Self { - *error.downcast().unwrap() - } -} - -macro_rules! async_result_context {( - $future: expr - $(, ok => $on_success: expr)? - $(, err => $on_failure: expr)?$(,)? -) => { - #[allow(unreachable_patterns)] - match $future.await { - $(Ok(value) => $on_success(value),)? - Ok(_) => {}, - $(Err(error) => $on_failure(error),)? - Err(_) => {}, - } -}} - - - -pub(crate) use async_result_context; diff --git a/src/main.rs b/src/main.rs index 86bab80..f0bd82c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,71 +1,44 @@ -mod data_manager; -mod error; +#![allow(dead_code, private_interfaces, unused_assignments, unused_macros)] + +mod persist; mod ui; -mod utility; +mod views; use gtk4::gdk::Display; -use gtk4::glib::{ExitCode, spawn_future_local}; -use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; use gtk4::{ - CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, style_context_add_provider_for_display, + CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, Settings, + style_context_add_provider_for_display, }; -use libadwaita::Application; +use relm4::RelmApp; -use crate::data_manager::DataManager; -use crate::error::{ZoodexError, async_result_context}; -use crate::ui::{UI, Window}; -use crate::utility::leak; +use crate::ui::App; -fn main() -> ExitCode { - let application = Application::builder() - .application_id("com.kernelmaft.zoodex") - .build(); - application.connect_startup(add_style_provider); - application.connect_activate(show_window); - application.run() +fn main() { + let app = RelmApp::new("com.kernelmaft.zoodex"); + + include_app_css(); + + // TODO: Set this to nr of cores if using Relm commands heavily. + // RELM_THREADS.set(4).unwrap(); + + app.run::(()); } -fn add_style_provider(_: &Application) { - let style_provider = CssProvider::new(); - style_provider.load_from_string(include_str!("application.css")); - style_context_add_provider_for_display( - &Display::default().unwrap(), - &style_provider, - STYLE_PROVIDER_PRIORITY_APPLICATION, - ); -} +fn include_app_css() { + // We can't use relm4::set_global_css because we need access to the CSS provider + // to relay color scheme changes to it. -fn show_window(application: &Application) { - let window = leak(Window::new(application)); + let provider = CssProvider::new(); + let display = Display::default().unwrap(); - spawn_future_local(async move { - async_result_context!( - async { - let data_manager = leak(DataManager::new().await?); + provider.load_from_string(include_str!("application.css")); + style_context_add_provider_for_display(&display, &provider, STYLE_PROVIDER_PRIORITY_APPLICATION); - let ui = UI::new( - window, - async |film_uuid| { - data_manager - .get_film_details(film_uuid) - .await - .expect("A film with the given UUID should exist") - }, - ); - window.show(); - - let collection = data_manager.get_collection_overview().await?; - ui.render_collection_overview(collection).await; - Ok(()) - }, - err => |error| { - match error { - ZoodexError::CollectionFileReadError => eprintln!("Could not read collection file"), - }; - window.close(); - }, - ); + let settings = Settings::for_display(&display); + provider.set_prefers_color_scheme(settings.gtk_interface_color_scheme()); + settings.connect_gtk_interface_color_scheme_notify(move |settings| { + provider.set_prefers_color_scheme(settings.gtk_interface_color_scheme()); }); } diff --git a/src/persist/common.rs b/src/persist/common.rs new file mode 100644 index 0000000..1ea4719 --- /dev/null +++ b/src/persist/common.rs @@ -0,0 +1,13 @@ +macro_rules! concat_os_str { + ($base: expr, $($suffix: expr),+) => { + { + let mut base = std::ffi::OsString::from($base); + $(base.push($suffix);)+ + base + } + } +} + + + +pub(crate) use concat_os_str; diff --git a/src/persist/data_manager.rs b/src/persist/data_manager.rs new file mode 100644 index 0000000..2e265a5 --- /dev/null +++ b/src/persist/data_manager.rs @@ -0,0 +1,82 @@ +use std::env::var_os; +use std::sync::OnceLock; + +use gtk4::gdk::Texture; + +use crate::persist::common::concat_os_str; +use crate::persist::file_system_manager::FileSystemManager; +use crate::persist::sqlite_manager::SqliteManager; +use crate::views::overview::{FilmOverview, SeriesOverview}; + + + +static SQLITE_MANAGER: OnceLock = OnceLock::new(); +static FILE_SYSTEM_MANAGER: OnceLock = OnceLock::new(); + +macro_rules! sqlite_manager { + () => { + SQLITE_MANAGER + .get() + .expect("SQLite manager should be initialized") + }; +} + +macro_rules! fs_manager { + () => { + FILE_SYSTEM_MANAGER + .get() + .expect("File system manager should be initialized") + }; +} + + + +pub struct DataManager {} + +impl DataManager { + pub async fn init() -> Result<(), DataManagerError> { + let data_dir = match var_os("XDG_DATA_HOME") { + Some(xdg_home_data_dir) => concat_os_str!(xdg_home_data_dir, "/zoodex"), + None => { + let home_dir = var_os("HOME").ok_or(DataManagerError::NoHomeDir)?; + concat_os_str!(home_dir, "/.local/share/zoodex") + } + }; + + let sqlite_manager = SqliteManager::new(data_dir.as_os_str()).await?; + SQLITE_MANAGER + .set(sqlite_manager) + .expect("SQLite manager should not already be initialized"); + + let fs_manager = FileSystemManager::new(data_dir.as_os_str()); + FILE_SYSTEM_MANAGER + .set(fs_manager) + .expect("File system manager should not already be initialized"); + + Ok(()) + } + + pub async fn films_overview() -> Result, DataManagerError> { + sqlite_manager!().films_overview().await + } + + pub async fn series_overview() -> Result, DataManagerError> { + sqlite_manager!().series_overview().await + } + + pub async fn poster(uuid: &str) -> Result, DataManagerError> { + fs_manager!().poster(uuid).await + } +} + + + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DataManagerError { + NoHomeDir, + CannotOpenSharedDB, + UnknownSharedDBError, + CannotOpenLocalDB, + UnknownLocalDBError, + UnknownTextureError, +} diff --git a/src/persist/file_system_manager.rs b/src/persist/file_system_manager.rs new file mode 100644 index 0000000..7239800 --- /dev/null +++ b/src/persist/file_system_manager.rs @@ -0,0 +1,44 @@ +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fmt::{Debug, Formatter}; + +use gtk4::gdk::Texture; +use gtk4::gio::IOErrorEnum; +use relm4::spawn_blocking; + +use crate::persist::common::concat_os_str; +use crate::persist::data_manager::DataManagerError; + + + +pub struct FileSystemManager { + data_dir: OsString, +} + +impl FileSystemManager { + pub fn new(data_dir: &OsStr) -> FileSystemManager { + let data_dir = data_dir.to_os_string(); + FileSystemManager { data_dir } + } + + pub async fn poster(&self, uuid: &str) -> Result, DataManagerError> { + let file_path = concat_os_str!(&self.data_dir, "/posters/", uuid); + let texture = spawn_blocking(move || Texture::from_filename(file_path)) + .await + .expect("Poster texture loading task should not panic"); + texture.map(Some).or_else(|glib_error| { + // It's okay for there not to be a poster. + if glib_error.matches(IOErrorEnum::NotFound) { + Ok(None) + } else { + Err(DataManagerError::UnknownTextureError) + } + }) + } +} + +impl Debug for FileSystemManager { + fn fmt(&self, formatter: &mut Formatter) -> fmt::Result { + formatter.write_str("FileSystemManager { OsString }") + } +} diff --git a/src/persist/mod.rs b/src/persist/mod.rs new file mode 100644 index 0000000..9bd64e5 --- /dev/null +++ b/src/persist/mod.rs @@ -0,0 +1,4 @@ +mod common; +pub mod data_manager; +mod file_system_manager; +mod sqlite_manager; diff --git a/src/persist/sqlite_manager.rs b/src/persist/sqlite_manager.rs new file mode 100644 index 0000000..2599f44 --- /dev/null +++ b/src/persist/sqlite_manager.rs @@ -0,0 +1,196 @@ +use std::ffi::OsStr; +use std::fmt; +use std::fmt::{Debug, Formatter}; + +use async_sqlite::rusqlite::fallible_iterator::FallibleIterator; +use async_sqlite::rusqlite::{OpenFlags, Row}; +use async_sqlite::{Client, ClientBuilder, rusqlite}; +use tokio::try_join; + +use crate::persist::common::concat_os_str; +use crate::persist::data_manager::DataManagerError; +use crate::views::overview::{FilmOverview, SeriesOverview}; + + + +pub struct SqliteManager { + client_shared: Client, + client_local: Client, +} + +impl SqliteManager { + pub async fn new(data_dir: &OsStr) -> Result { + let (client_shared, client_local) = try_join!( + create_client(data_dir, DBType::Shared), + create_client(data_dir, DBType::Local), + )?; + + Ok(SqliteManager { + client_shared, + client_local, + }) + } + + // The order of the items is undefined. + pub async fn films_overview(&self) -> Result, DataManagerError> { + let overview = self + .client_shared + .conn(|connection| { + let overview = connection + .prepare( + " + select uuid, name, original_name, release_date, runtime_minutes + from films + ", + ) + .expect("films overview statement should be valid SQL") + .query(()) + .expect("parameters in films overview query should match those in its statement") + .map(row_to_film_overview) + .collect()?; + Ok(overview) + }) + .await; + + overview.map_err(|async_sqlite_error| match async_sqlite_error { + async_sqlite::Error::Closed => panic!( + "shared database connection should remain open as long as the application is running" + ), + async_sqlite::Error::Rusqlite(rusqlite_error) => match rusqlite_error { + rusqlite::Error::InvalidColumnIndex(_) => { + panic!("column indices obtained from films overview query should exist") + } + rusqlite::Error::InvalidColumnName(_) => { + panic!("column names obtained from films overview query should exist") + } + rusqlite::Error::InvalidColumnType(..) => panic!( + "values obtained from films overview query should have a type matching their column" + ), + _ => DataManagerError::UnknownSharedDBError, + }, + _ => DataManagerError::UnknownSharedDBError, + }) + } + + // The order of the items is undefined. + pub async fn series_overview(&self) -> Result, DataManagerError> { + let overview = self + .client_shared + .conn(|connection| { + let overview = connection + .prepare( + " + select series.uuid, series.name, series.original_name, + min(episodes.release_date) as first_release_date + from series, seasons, episodes + where series.uuid = seasons.series and seasons.uuid = episodes.season + group by series.uuid + ", + ) + .expect("series overview statement should be valid SQL") + .query(()) + .expect("parameters in series overview query should match those in its statement") + .map(row_to_series_overview) + .collect()?; + Ok(overview) + }) + .await; + + overview.map_err(|async_sqlite_error| match async_sqlite_error { + async_sqlite::Error::Closed => panic!( + "shared database connection should remain open as long as the application is running" + ), + async_sqlite::Error::Rusqlite(rusqlite_error) => match rusqlite_error { + rusqlite::Error::InvalidColumnIndex(_) => { + panic!("column indices obtained from series overview query should exist") + } + rusqlite::Error::InvalidColumnName(_) => { + panic!("column names obtained from series overview query should exist") + } + rusqlite::Error::InvalidColumnType(..) => panic!( + "values obtained from series overview query should have a type matching their column" + ), + _ => DataManagerError::UnknownSharedDBError, + }, + _ => DataManagerError::UnknownSharedDBError, + }) + } +} + +impl Debug for SqliteManager { + fn fmt(&self, formatter: &mut Formatter) -> fmt::Result { + formatter.write_str("SqliteManager { Client, Client }") + } +} + +async fn create_client(data_dir: &OsStr, db_type: DBType) -> Result { + let open_mode = match db_type { + DBType::Shared => OpenFlags::SQLITE_OPEN_READ_ONLY, + DBType::Local => OpenFlags::SQLITE_OPEN_READ_WRITE, + }; + let filename = match db_type { + DBType::Shared => "/shared.sqlite", + DBType::Local => "/local.sqlite", + }; + let cannot_open_err = match db_type { + DBType::Shared => DataManagerError::CannotOpenSharedDB, + DBType::Local => DataManagerError::CannotOpenLocalDB, + }; + let unknown_err = match db_type { + DBType::Shared => DataManagerError::UnknownSharedDBError, + DBType::Local => DataManagerError::UnknownLocalDBError, + }; + + let client = ClientBuilder::new() + .path(concat_os_str!(data_dir, filename)) + .flags(open_mode | OpenFlags::SQLITE_OPEN_NO_MUTEX) + .open() + .await; + + client.map_err(|async_sqlite_error| match async_sqlite_error { + async_sqlite::Error::Rusqlite(rusqlite_error) => match rusqlite_error { + rusqlite::Error::SqliteFailure(sqlite_error, _) => match sqlite_error.code { + rusqlite::ffi::ErrorCode::CannotOpen => cannot_open_err, + _ => unknown_err, + }, + _ => unknown_err, + }, + _ => unknown_err, + }) +} + +fn row_to_film_overview(row: &Row) -> rusqlite::Result { + let uuid = row.get("uuid")?; + let name = row.get("name")?; + let original_name = row.get("original_name")?; + let release_date = row.get("release_date")?; + let runtime = row.get("runtime_minutes")?; + + Ok(FilmOverview { + uuid, + name, + original_name, + release_date, + runtime, + }) +} + +fn row_to_series_overview(row: &Row) -> rusqlite::Result { + let uuid = row.get("uuid")?; + let name = row.get("name")?; + let original_name = row.get("original_name")?; + let first_release_date = row.get("first_release_date")?; + + Ok(SeriesOverview { + uuid, + name, + original_name, + first_release_date, + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DBType { + Shared, + Local, +} diff --git a/src/ui/collatable_container/collated_grid.rs b/src/ui/collatable_container/collated_grid.rs deleted file mode 100644 index 2db7390..0000000 --- a/src/ui/collatable_container/collated_grid.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::cell::RefCell; -use std::env::var_os; -use std::iter::zip; - -use gtk4::gdk::Texture; -use gtk4::gio::{IOErrorEnum, spawn_blocking}; -use gtk4::glib::clone; -use gtk4::pango::{SCALE_LARGE, Weight}; -use gtk4::prelude::{BoxExt, ButtonExt, OrientableExt, WidgetExt}; -use gtk4::{Align, Button, FlowBox, Image, Justification, Label, Orientation, SelectionMode}; - -use crate::data_manager::MediaOverview; -use crate::ui::collatable_container::MediaAdapter; -use crate::ui::component::Component; -use crate::ui::utility::{OptChildExt, pango_attributes, view_expr}; -use crate::utility::{concat_os_str, leak}; - - - -pub struct CollatedMediaGrid { - media_widget_pairs: RefCell>, - grid_widget: FlowBox, - on_media_selected: &'static dyn Fn(A::Overview), -} - -impl CollatedMediaGrid { - pub fn new(on_media_selected: impl Fn(A::Overview) + 'static) -> Self { - let grid_widget = view_expr! { - FlowBox { - set_homogeneous: true, - set_selection_mode: SelectionMode::None, - set_css_classes: &["collatable-container"], - set_orientation: Orientation::Horizontal, - } - }; - let media_widget_pairs = RefCell::new(Vec::new()); - let on_media_selected = leak(on_media_selected); - - Self { - media_widget_pairs, - grid_widget, - on_media_selected, - } - } - - pub async fn set_media(&self, media: Vec, sorting: A::Sorting) { - // TODO: Check if we should use `MainContext::invoke_local` here - - let mut widgets = Vec::new(); - for media in media.as_slice() { - widgets.push(self.create_media_entry(media).await); - } - self - .media_widget_pairs - .replace(zip(media, widgets).collect()); - - for (_, widget) in self.sort_media_widget_pairs(sorting) { - self.grid_widget.append(&widget); - } - } - - async fn create_media_entry(&self, media: &A::Overview) -> Button { - view_expr! { - Button { - set_css_classes: &["flat", "collection-item-button"], - - connect_clicked: clone!( - #[strong] media, - #[strong(rename_to = on_media_selected)] self.on_media_selected, - move |_| on_media_selected(media.clone()), - ), - - set_child: Some(&view_expr! { - gtk4::Box { - set_css_classes: &["collection-item-box"], - set_valign: Align::Center, - set_orientation: Orientation::Vertical, - - // Poster - append_opt: &{ - let home_directory = var_os("HOME").unwrap(); - let xdg_data_home = var_os("XDG_DATA_HOME"); - - let data_dir = match xdg_data_home { - Some(xdg_data_home) => concat_os_str!(xdg_data_home, "/zoodex"), - None => concat_os_str!(home_directory, "/.local/share/zoodex"), - }; - - let poster_file_path = concat_os_str!(data_dir, "/posters/", media.get_uuid()); - - let poster_texture = spawn_blocking(move || Texture::from_filename(poster_file_path)) - .await - .unwrap(); - - match poster_texture { - Ok(poster_texture) => Some(view_expr! { - Image { - set_paintable: Some(&poster_texture), - set_pixel_size: 300, - set_css_classes: &["collection-item-image"], - } - }), - Err(error) => { - if error.matches(IOErrorEnum::NotFound) { - // The file not existing simply means there is no poster for this piece of media - None - } else { - // Any other error means something unexpected went wrong - panic!("{}", error) - } - }, - } - }, - - // Name - append: &view_expr! { - Label { - set_attributes: Some(&pango_attributes!(scale: SCALE_LARGE, weight: Weight::Bold)), - set_justify: Justification::Center, - // Not the actual limit, used instead to wrap more aggressively - set_max_width_chars: 1, - set_wrap: true, - set_label: media.get_name().as_str(), - } - }, - - // Original name - append_opt: &media.get_original_name().map(|original_name| view_expr! { - Label { - set_justify: Justification::Center, - set_max_width_chars: 1, - set_wrap: true, - set_label: original_name.as_str(), - } - }), - - // Details - append: &view_expr! { - gtk4::Box { - set_spacing: 20, - set_halign: Align::Center, - set_orientation: Orientation::Horizontal, - - // Release date - append: &view_expr! { - Label { set_label: media.get_release_date().split('-').next().unwrap() } - }, - - // Runtime - append_opt: &media.get_runtime_minutes().map(|runtime_minutes| view_expr! { - Label { set_label: format!("{}m", runtime_minutes).as_str() } - }), - } - }, - } - }), - } - } - } - - pub fn set_sorting(&self, sorting: A::Sorting) { - self.grid_widget.remove_all(); - - for (_, widget) in self.sort_media_widget_pairs(sorting) { - self.grid_widget.append(&widget); - } - } - - fn sort_media_widget_pairs(&self, sorting: A::Sorting) -> Vec<(A::Overview, Button)> { - let mut sorted = Vec::from(self.media_widget_pairs.borrow().as_slice()); - - sorted.sort_by(|(media_1, _), (media_2, _)| A::compare_by(media_1, media_2, sorting)); - - // See it, say it, ... - sorted - } -} - -impl Component for CollatedMediaGrid { - fn get_widget(&self) -> &FlowBox { - &self.grid_widget - } -} diff --git a/src/ui/collatable_container/collation_menu/mod.rs b/src/ui/collatable_container/collation_menu/mod.rs deleted file mode 100644 index 8a02d7a..0000000 --- a/src/ui/collatable_container/collation_menu/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -mod sort_button; - -use gtk4::prelude::{BoxExt, OrientableExt, WidgetExt}; -use gtk4::{Align, Box, Orientation}; -use relm4_macros::view; - -use crate::ui::collatable_container::MediaAdapter; -use crate::ui::collatable_container::collation_menu::sort_button::MediaSortButton; -use crate::ui::component::Component; - - - -pub struct MediaCollationMenu { - widget: Box, -} - -impl MediaCollationMenu { - pub fn new(on_sort: impl Fn(A::Sorting) + 'static) -> Self { - let sort_button = MediaSortButton::::new(on_sort); - - view! { - widget = gtk4::Box { - set_spacing: 20, - set_css_classes: &["toolbar", "collation-menu"], - set_halign: Align::Center, - set_orientation: Orientation::Horizontal, - - append: sort_button.get_widget(), - }, - } - - Self { widget } - } -} - -impl Component for MediaCollationMenu { - fn get_widget(&self) -> &Box { - &self.widget - } -} diff --git a/src/ui/collatable_container/collation_menu/sort_button.rs b/src/ui/collatable_container/collation_menu/sort_button.rs deleted file mode 100644 index 9cd472c..0000000 --- a/src/ui/collatable_container/collation_menu/sort_button.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::cell::RefCell; - -use gtk4::prelude::{BoxExt, ListBoxRowExt, OrientableExt, PopoverExt, WidgetExt}; -use gtk4::{Align, Image, Label, ListBox, Orientation, Popover}; -use libadwaita::SplitButton; -use relm4_macros::view; - -use crate::ui::collatable_container::{MediaAdapter, MediaSorting, SortingDirection}; -use crate::ui::component::Component; -use crate::ui::utility::view_expr; -use crate::utility::leak; - - - -pub struct MediaSortButton { - widget: SplitButton, - previous_sorting: &'static RefCell, -} - -impl MediaSortButton { - pub fn new(on_sort: impl Fn(A::Sorting) + 'static) -> Self { - let previous_sorting = leak(RefCell::new(A::Sorting::default())); - let property_descriptions = A::get_property_descriptions(); - - let sort_icons = { - let mut sort_icons = Vec::new(); - for _ in property_descriptions { - sort_icons.push(view_expr! { - Image { set_icon_name: Some("view-sort-ascending-symbolic") } - }); - } - Box::leak(sort_icons.into_boxed_slice()) as &'static _ - }; - - view! { - list_box = ListBox { - connect_row_activated: move |_, row| on_media_sort_activated::( - row.index(), - previous_sorting, - &on_sort, - sort_icons, - ), - }, - widget = SplitButton { - set_popover: Some(&view_expr! { - Popover { - set_css_classes: &["menu"], - set_child: Some(&list_box), - } - }), - set_child: Some(&view_expr! { - Label { set_label: "Sort" } - }), - }, - } - - for (index, (_, description)) in property_descriptions.iter().enumerate() { - list_box.append(&view_expr! { - gtk4::Box { - set_spacing: 20, - set_orientation: Orientation::Horizontal, - append: &view_expr! { - Label { - set_halign: Align::Start, - set_hexpand: true, - set_label: description, - } - }, - append: &sort_icons[index], - } - }); - } - - Self { - widget, - previous_sorting, - } - } -} - -impl Component for MediaSortButton { - fn get_widget(&self) -> &SplitButton { - &self.widget - } -} - -fn on_media_sort_activated( - row: i32, - previous_sorting_mut: &RefCell, - on_sort: &impl Fn(A::Sorting), - sort_icons: &[Image], -) { - let row = row as usize; - debug_assert!( - row <= A::get_property_descriptions().len(), - "Sorting menu has more rows than media adapter has property descriptions", - ); - let (sorting_property, _) = A::get_property_descriptions()[row].clone(); - - let previous_sorting = *previous_sorting_mut.borrow(); - if sorting_property == previous_sorting.get_property() { - match previous_sorting.get_direction() { - SortingDirection::Ascending => { - let new_sorting = A::Sorting::new(sorting_property, SortingDirection::Descending); - previous_sorting_mut.replace(new_sorting); - sort_icons[row].set_icon_name(Some("view-sort-descending-symbolic")); - on_sort(new_sorting); - } - SortingDirection::Descending => { - let new_sorting = A::Sorting::new(sorting_property, SortingDirection::Ascending); - previous_sorting_mut.replace(new_sorting); - sort_icons[row].set_icon_name(Some("view-sort-ascending-symbolic")); - on_sort(new_sorting); - } - } - } else { - let new_sorting = A::Sorting::new(sorting_property, SortingDirection::Ascending); - previous_sorting_mut.replace(new_sorting); - sort_icons[row].set_icon_name(Some("view-sort-ascending-symbolic")); - on_sort(new_sorting); - } -} diff --git a/src/ui/collatable_container/mod.rs b/src/ui/collatable_container/mod.rs deleted file mode 100644 index 7a5a2ae..0000000 --- a/src/ui/collatable_container/mod.rs +++ /dev/null @@ -1,202 +0,0 @@ -mod collated_grid; -mod collation_menu; - -use std::cmp::Ordering; -use std::fmt::Debug; - -use gtk4::prelude::{BoxExt, OrientableExt}; -use gtk4::{Box, Orientation, ScrolledWindow}; -use relm4_macros::view; - -use crate::data_manager::{FilmOverview, MediaOverview, SeriesOverview}; -use crate::ui::collatable_container::collated_grid::CollatedMediaGrid; -use crate::ui::collatable_container::collation_menu::MediaCollationMenu; -use crate::ui::component::Component; -use crate::ui::utility::{vertical_filler, view_expr}; -use crate::utility::leak; - - - -pub trait MediaSorting: Clone + Copy + Debug + Default { - fn new(property: P, direction: SortingDirection) -> Self; - fn get_property(&self) -> P; - fn get_direction(&self) -> SortingDirection; -} - -pub trait MediaProperty: Clone + Copy + Debug + PartialEq {} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum FilmProperty { - #[default] - Name, - ReleaseDate, - Runtime, -} -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum SeriesProperty { - #[default] - Name, - FirstReleaseDate, -} -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum SortingDirection { - #[default] - Ascending, - Descending, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct FilmsSorting { - property: FilmProperty, - direction: SortingDirection, -} -#[derive(Clone, Copy, Debug, Default)] -pub struct SeriesSorting { - property: SeriesProperty, - direction: SortingDirection, -} - -impl MediaSorting for FilmsSorting { - fn new(property: FilmProperty, direction: SortingDirection) -> Self { - Self { - property, - direction, - } - } - fn get_property(&self) -> FilmProperty { - self.property - } - fn get_direction(&self) -> SortingDirection { - self.direction - } -} -impl MediaSorting for SeriesSorting { - fn new(property: SeriesProperty, direction: SortingDirection) -> Self { - Self { - property, - direction, - } - } - fn get_property(&self) -> SeriesProperty { - self.property - } - fn get_direction(&self) -> SortingDirection { - self.direction - } -} - -impl MediaProperty for FilmProperty {} -impl MediaProperty for SeriesProperty {} - -pub struct CollatableMediaContainer { - collated_grid: &'static CollatedMediaGrid, - widget: Box, -} - -impl CollatableMediaContainer { - pub fn new(on_media_selected: impl Fn(A::Overview) + 'static) -> Self { - let collated_grid = leak(CollatedMediaGrid::new(on_media_selected)); - let collation_menu = MediaCollationMenu::new::(|sorting| collated_grid.set_sorting(sorting)); - - view! { - widget = gtk4::Box { - set_orientation: Orientation::Vertical, - append: collation_menu.get_widget(), - append: &view_expr! { - ScrolledWindow { - set_propagate_natural_height: true, - set_child: Some(&vertical_filler(collated_grid.get_widget())), - } - }, - } - } - - Self { - collated_grid, - widget, - } - } - - pub async fn set_media(&self, media: Vec) { - self - .collated_grid - .set_media(media, A::Sorting::default()) - .await; - } -} - -pub trait MediaAdapter: 'static { - type Overview: MediaOverview; - type Sorting: MediaSorting; - type Property: MediaProperty; - fn compare_by( - media_1: &Self::Overview, - media_2: &Self::Overview, - sorting: Self::Sorting, - ) -> Ordering; - fn get_property_descriptions() -> &'static [(Self::Property, &'static str)]; -} - -impl Component for CollatableMediaContainer { - fn get_widget(&self) -> &Box { - &self.widget - } -} - -pub struct FilmsAdapter {} -pub struct SeriesAdapter {} - -impl MediaAdapter for FilmsAdapter { - type Overview = FilmOverview; - type Sorting = FilmsSorting; - type Property = FilmProperty; - - fn compare_by(film_1: &FilmOverview, film_2: &FilmOverview, sorting: FilmsSorting) -> Ordering { - let ordering = match sorting.property { - FilmProperty::Name => film_1.name.cmp(&film_2.name), - FilmProperty::ReleaseDate => film_1.release_date.cmp(&film_2.release_date), - FilmProperty::Runtime => film_1.runtime_minutes.cmp(&film_2.runtime_minutes), - }; - match sorting.direction { - SortingDirection::Ascending => ordering, - SortingDirection::Descending => ordering.reverse(), - } - } - - fn get_property_descriptions() -> &'static [(FilmProperty, &'static str)] { - leak([ - (FilmProperty::Name, "Name"), - (FilmProperty::ReleaseDate, "Release date"), - (FilmProperty::Runtime, "Runtime"), - ]) - } -} -impl MediaAdapter for SeriesAdapter { - type Overview = SeriesOverview; - type Sorting = SeriesSorting; - type Property = SeriesProperty; - - fn compare_by( - series_1: &SeriesOverview, - series_2: &SeriesOverview, - sorting: SeriesSorting, - ) -> Ordering { - let ordering = match sorting.property { - SeriesProperty::Name => series_1.name.cmp(&series_2.name), - SeriesProperty::FirstReleaseDate => series_1 - .first_release_date - .cmp(&series_2.first_release_date), - }; - match sorting.direction { - SortingDirection::Ascending => ordering, - SortingDirection::Descending => ordering.reverse(), - } - } - - fn get_property_descriptions() -> &'static [(SeriesProperty, &'static str)] { - leak([ - (SeriesProperty::Name, "Name"), - (SeriesProperty::FirstReleaseDate, "First release date"), - ]) - } -} diff --git a/src/ui/component.rs b/src/ui/component.rs deleted file mode 100644 index 43efe73..0000000 --- a/src/ui/component.rs +++ /dev/null @@ -1,8 +0,0 @@ -use gtk4::Widget; -use gtk4::prelude::IsA; - - - -pub trait Component { - fn get_widget(&self) -> &impl IsA; -} diff --git a/src/ui/components/app.rs b/src/ui/components/app.rs new file mode 100644 index 0000000..085ee9a --- /dev/null +++ b/src/ui/components/app.rs @@ -0,0 +1,71 @@ +use gtk4::prelude::GtkWindowExt; +use libadwaita::ApplicationWindow; +use libadwaita::prelude::AdwApplicationWindowExt; +use relm4::{ + Component, ComponentController, ComponentParts, ComponentSender, Controller, component, +}; + +use crate::persist::data_manager::{DataManager, DataManagerError}; +use crate::ui::components::media_type_switcher::MediaTypeSwitcher; + + + +pub struct App { + media_type_switcher: Option>, +} + +#[derive(Debug)] +pub enum AppCmdOutput { + DataManagerReady, + DataManagerFailed(DataManagerError), +} + +#[component(pub)] +impl Component for App { + type Input = (); + type Output = (); + type Init = (); + type CommandOutput = AppCmdOutput; + + view! { + ApplicationWindow { + set_title: Some("Zoödex"), + + #[watch] + set_content: model.media_type_switcher.as_ref().map(Controller::widget), + } + } + + fn init(_init: (), root: ApplicationWindow, sender: ComponentSender) -> ComponentParts { + sender.oneshot_command(async { + match DataManager::init().await { + Ok(_) => AppCmdOutput::DataManagerReady, + Err(error) => AppCmdOutput::DataManagerFailed(error), + } + }); + + let model = App { + media_type_switcher: None, + }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } + + fn update_cmd( + &mut self, + message: AppCmdOutput, + _sender: ComponentSender, + _root: &ApplicationWindow, + ) { + match message { + AppCmdOutput::DataManagerReady => { + // Only launch child component after data manager is ready. + self.media_type_switcher = Some(MediaTypeSwitcher::builder().launch(()).detach()); + } + AppCmdOutput::DataManagerFailed(error) => { + // TODO: Show error in GUI. + println!("Error occurred in data manager initialization: {:?}", error); + } + } + } +} diff --git a/src/ui/components/collatable_media_grid/collatable_film_grid.rs b/src/ui/components/collatable_media_grid/collatable_film_grid.rs new file mode 100644 index 0000000..cfd174b --- /dev/null +++ b/src/ui/components/collatable_media_grid/collatable_film_grid.rs @@ -0,0 +1,54 @@ +use gtk4::Orientation; +use gtk4::prelude::OrientableExt; +use relm4::{ + Component, ComponentController, ComponentParts, ComponentSender, Controller, SimpleComponent, + component, +}; + +use crate::ui::components::media_collation_menu::{FilmCollationMenu, FilmCollationMenuOutput}; +use crate::ui::components::media_grid::{FilmGrid, FilmGridInput}; + + + +pub struct CollatableFilmGrid { + collation_menu: Controller, + film_grid: Controller, +} + +#[component(pub)] +impl SimpleComponent for CollatableFilmGrid { + type Input = (); + type Output = (); + type Init = (); + + view! { + gtk4::Box { + set_orientation: Orientation::Vertical, + model.collation_menu.widget(), + model.film_grid.widget(), + } + } + + fn init( + _init: (), + _root: gtk4::Box, + _sender: ComponentSender, + ) -> ComponentParts { + let film_grid = FilmGrid::builder().launch(()).detach(); + let collation_menu = FilmCollationMenu::builder().launch(()).forward( + film_grid.sender(), + |message| match message { + FilmCollationMenuOutput::SortBy(sorting, direction) => { + FilmGridInput::SortBy(sorting, direction) + } + }, + ); + + let model = CollatableFilmGrid { + collation_menu, + film_grid, + }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } +} diff --git a/src/ui/components/collatable_media_grid/collatable_series_grid.rs b/src/ui/components/collatable_media_grid/collatable_series_grid.rs new file mode 100644 index 0000000..61a2a07 --- /dev/null +++ b/src/ui/components/collatable_media_grid/collatable_series_grid.rs @@ -0,0 +1,54 @@ +use gtk4::Orientation; +use gtk4::prelude::OrientableExt; +use relm4::{ + Component, ComponentController, ComponentParts, ComponentSender, Controller, SimpleComponent, + component, +}; + +use crate::ui::components::media_collation_menu::{SeriesCollationMenu, SeriesCollationMenuOutput}; +use crate::ui::components::media_grid::{SeriesGrid, SeriesGridInput}; + + + +pub struct CollatableSeriesGrid { + collation_menu: Controller, + series_grid: Controller, +} + +#[component(pub)] +impl SimpleComponent for CollatableSeriesGrid { + type Input = (); + type Output = (); + type Init = (); + + view! { + gtk4::Box { + set_orientation: Orientation::Vertical, + model.collation_menu.widget(), + model.series_grid.widget(), + } + } + + fn init( + _init: (), + _root: gtk4::Box, + _sender: ComponentSender, + ) -> ComponentParts { + let series_grid = SeriesGrid::builder().launch(()).detach(); + let collation_menu = + SeriesCollationMenu::builder() + .launch(()) + .forward(series_grid.sender(), |message| match message { + SeriesCollationMenuOutput::SortBy(sorting, direction) => { + SeriesGridInput::SortBy(sorting, direction) + } + }); + + let model = CollatableSeriesGrid { + collation_menu, + series_grid, + }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } +} diff --git a/src/ui/components/collatable_media_grid/mod.rs b/src/ui/components/collatable_media_grid/mod.rs new file mode 100644 index 0000000..8c7d742 --- /dev/null +++ b/src/ui/components/collatable_media_grid/mod.rs @@ -0,0 +1,5 @@ +mod collatable_film_grid; +mod collatable_series_grid; + +pub use collatable_film_grid::*; +pub use collatable_series_grid::*; diff --git a/src/ui/components/media_collation_menu/film_collation_menu.rs b/src/ui/components/media_collation_menu/film_collation_menu.rs new file mode 100644 index 0000000..7537494 --- /dev/null +++ b/src/ui/components/media_collation_menu/film_collation_menu.rs @@ -0,0 +1,140 @@ +use gtk4::prelude::{ + BoxExt, ButtonExt, EntryExt, ListBoxRowExt, ObjectExt, OrientableExt, PopoverExt, WidgetExt, +}; +use gtk4::{Align, Button, Entry, Label, ListBox, MenuButton, Orientation, Popover}; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, component}; + +use crate::ui::components::sorting_popover_entry::sorting_popover_entry; +use crate::ui::sorting::{FilmsSorting, SortingDirection}; + + + +pub struct FilmCollationMenu { + sorted_by: FilmsSorting, + sort_direction: SortingDirection, +} + +#[derive(Debug)] +pub enum FilmCollationMenuInput { + SortBy(FilmsSorting), + ToggleSortOrder, +} + +#[derive(Debug)] +pub enum FilmCollationMenuOutput { + SortBy(FilmsSorting, SortingDirection), +} + +#[component(pub)] +impl SimpleComponent for FilmCollationMenu { + type Input = FilmCollationMenuInput; + type Output = FilmCollationMenuOutput; + type Init = (); + + view! { + gtk4::Box { + set_orientation: Orientation::Horizontal, + set_halign: Align::Center, + set_spacing: 20, + set_css_classes: &["toolbar", "collation-menu"], + + gtk4::Box { + set_orientation: Orientation::Horizontal, + set_css_classes: &["linked"], + + MenuButton { + set_always_show_arrow: true, + set_width_request: 200, + + #[wrap(Some)] + set_child = &Label { + set_halign: Align::Start, + set_margin_start: 3, + + #[watch] + set_label: match model.sorted_by { + FilmsSorting::Name => "Sort by name", + FilmsSorting::ReleaseDate => "Sort by release date", + FilmsSorting::Runtime => "Sort by runtime", + }, + }, + + #[name="popover"] + #[wrap(Some)] + set_popover = &Popover { + set_has_arrow: false, + set_css_classes: &["menu"], + ListBox { + connect_row_activated[popover, sender] => move |_, row| { + match row.index() { + 0 => sender.input(FilmCollationMenuInput::SortBy(FilmsSorting::Name)), + 1 => sender.input(FilmCollationMenuInput::SortBy(FilmsSorting::ReleaseDate)), + 2 => sender.input(FilmCollationMenuInput::SortBy(FilmsSorting::Runtime)), + _ => debug_assert!(false, "Invalid sort popover row index"), + } + popover.emit_by_name::<()>("closed", &[]); + }, + sorting_popover_entry("Name"), + sorting_popover_entry("Release date"), + sorting_popover_entry("Runtime"), + }, + }, + }, + Button { + connect_clicked => FilmCollationMenuInput::ToggleSortOrder, + #[watch] + set_icon_name: match model.sort_direction { + SortingDirection::Ascending => "view-sort-ascending-symbolic", + SortingDirection::Descending => "view-sort-descending-symbolic", + }, + }, + }, + Entry { + set_width_request: 230, + set_placeholder_text: Some("Search"), + set_secondary_icon_name: Some("system-search-symbolic"), + set_secondary_icon_sensitive: false, + }, + } + } + + fn init( + _init: (), + _root: gtk4::Box, + sender: ComponentSender, + ) -> ComponentParts { + let model = FilmCollationMenu { + sorted_by: FilmsSorting::Name, + sort_direction: SortingDirection::Ascending, + }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } + + fn update( + &mut self, + message: FilmCollationMenuInput, + sender: ComponentSender, + ) { + match message { + FilmCollationMenuInput::SortBy(sorting) => { + self.sorted_by = sorting; + self.sort_direction = SortingDirection::Ascending; + sender.output_sender().emit(FilmCollationMenuOutput::SortBy( + sorting, + SortingDirection::Ascending, + )); + } + FilmCollationMenuInput::ToggleSortOrder => { + self.sort_direction = match self.sort_direction { + SortingDirection::Ascending => SortingDirection::Descending, + SortingDirection::Descending => SortingDirection::Ascending, + }; + sender.output_sender().emit(FilmCollationMenuOutput::SortBy( + self.sorted_by, + self.sort_direction, + )); + } + } + } +} diff --git a/src/ui/components/media_collation_menu/mod.rs b/src/ui/components/media_collation_menu/mod.rs new file mode 100644 index 0000000..70d182a --- /dev/null +++ b/src/ui/components/media_collation_menu/mod.rs @@ -0,0 +1,5 @@ +mod film_collation_menu; +mod series_collation_menu; + +pub use film_collation_menu::*; +pub use series_collation_menu::*; diff --git a/src/ui/components/media_collation_menu/series_collation_menu.rs b/src/ui/components/media_collation_menu/series_collation_menu.rs new file mode 100644 index 0000000..7809066 --- /dev/null +++ b/src/ui/components/media_collation_menu/series_collation_menu.rs @@ -0,0 +1,142 @@ +use gtk4::prelude::{ + BoxExt, ButtonExt, EntryExt, ListBoxRowExt, ObjectExt, OrientableExt, PopoverExt, WidgetExt, +}; +use gtk4::{Align, Button, Entry, Label, ListBox, MenuButton, Orientation, Popover}; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, component}; + +use crate::ui::components::sorting_popover_entry::sorting_popover_entry; +use crate::ui::sorting::{SeriesSorting, SortingDirection}; + + + +pub struct SeriesCollationMenu { + sorted_by: SeriesSorting, + sort_direction: SortingDirection, +} + +#[derive(Debug)] +pub enum SeriesCollationMenuInput { + SortBy(SeriesSorting), + ToggleSortOrder, +} + +#[derive(Debug)] +pub enum SeriesCollationMenuOutput { + SortBy(SeriesSorting, SortingDirection), +} + +#[component(pub)] +impl SimpleComponent for SeriesCollationMenu { + type Input = SeriesCollationMenuInput; + type Output = SeriesCollationMenuOutput; + type Init = (); + + view! { + gtk4::Box { + set_orientation: Orientation::Horizontal, + set_halign: Align::Center, + set_spacing: 20, + set_css_classes: &["toolbar", "collation-menu"], + + gtk4::Box { + set_orientation: Orientation::Horizontal, + set_css_classes: &["linked"], + + MenuButton { + set_always_show_arrow: true, + set_width_request: 200, + + #[wrap(Some)] + set_child = &Label { + set_halign: Align::Start, + set_margin_start: 3, + + #[watch] + set_label: match model.sorted_by { + SeriesSorting::Name => "Sort by name", + SeriesSorting::FirstReleaseDate => "Sort by first release date", + }, + }, + + #[name="popover"] + #[wrap(Some)] + set_popover = &Popover { + set_has_arrow: false, + set_css_classes: &["menu"], + ListBox { + connect_row_activated[popover, sender] => move |_, row| { + match row.index() { + 0 => sender.input(SeriesCollationMenuInput::SortBy(SeriesSorting::Name)), + 1 => sender + .input(SeriesCollationMenuInput::SortBy(SeriesSorting::FirstReleaseDate)), + _ => debug_assert!(false, "Invalid sort popover row index"), + } + popover.emit_by_name::<()>("closed", &[]); + }, + sorting_popover_entry("Name"), + sorting_popover_entry("First release date"), + }, + }, + }, + Button { + connect_clicked => SeriesCollationMenuInput::ToggleSortOrder, + #[watch] + set_icon_name: match model.sort_direction { + SortingDirection::Ascending => "view-sort-ascending-symbolic", + SortingDirection::Descending => "view-sort-descending-symbolic", + }, + }, + }, + Entry { + set_width_request: 230, + set_placeholder_text: Some("Search"), + set_secondary_icon_name: Some("system-search-symbolic"), + set_secondary_icon_sensitive: false, + }, + } + } + + fn init( + _init: (), + _root: gtk4::Box, + sender: ComponentSender, + ) -> ComponentParts { + let model = SeriesCollationMenu { + sorted_by: SeriesSorting::Name, + sort_direction: SortingDirection::Ascending, + }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } + + fn update( + &mut self, + message: SeriesCollationMenuInput, + sender: ComponentSender, + ) { + match message { + SeriesCollationMenuInput::SortBy(sorting) => { + self.sorted_by = sorting; + self.sort_direction = SortingDirection::Ascending; + sender + .output_sender() + .emit(SeriesCollationMenuOutput::SortBy( + sorting, + SortingDirection::Ascending, + )); + } + SeriesCollationMenuInput::ToggleSortOrder => { + self.sort_direction = match self.sort_direction { + SortingDirection::Ascending => SortingDirection::Descending, + SortingDirection::Descending => SortingDirection::Ascending, + }; + sender + .output_sender() + .emit(SeriesCollationMenuOutput::SortBy( + self.sorted_by, + self.sort_direction, + )); + } + } + } +} diff --git a/src/ui/components/media_details/film_details.rs b/src/ui/components/media_details/film_details.rs new file mode 100644 index 0000000..a1e0e87 --- /dev/null +++ b/src/ui/components/media_details/film_details.rs @@ -0,0 +1,41 @@ +use gtk4::prelude::{BoxExt, OrientableExt, WidgetExt}; +use gtk4::{Label, Orientation}; +use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, component}; + +use crate::views::overview::FilmOverview; + + + +pub struct FilmDetails { + film_overview: FilmOverview, +} + +#[component(pub)] +impl SimpleComponent for FilmDetails { + type Init = FilmOverview; + type Input = (); + type Output = (); + + view! { + gtk4::Box { + set_orientation: Orientation::Vertical, + set_spacing: 40, + set_margin_all: 100, + + Label { + set_css_classes: &["title-1"], + set_label: model.film_overview.name.as_str(), + } + } + } + + fn init( + film_overview: FilmOverview, + _root: gtk4::Box, + _sender: ComponentSender, + ) -> ComponentParts { + let model = FilmDetails { film_overview }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } +} diff --git a/src/ui/components/media_details/mod.rs b/src/ui/components/media_details/mod.rs new file mode 100644 index 0000000..4816b75 --- /dev/null +++ b/src/ui/components/media_details/mod.rs @@ -0,0 +1,5 @@ +mod film_details; +mod series_details; + +pub use film_details::*; +pub use series_details::*; diff --git a/src/ui/components/media_details/series_details.rs b/src/ui/components/media_details/series_details.rs new file mode 100644 index 0000000..0c323f2 --- /dev/null +++ b/src/ui/components/media_details/series_details.rs @@ -0,0 +1,41 @@ +use gtk4::prelude::{BoxExt, OrientableExt, WidgetExt}; +use gtk4::{Label, Orientation}; +use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, component}; + +use crate::views::overview::SeriesOverview; + + + +pub struct SeriesDetails { + series_overview: SeriesOverview, +} + +#[component(pub)] +impl SimpleComponent for SeriesDetails { + type Init = SeriesOverview; + type Input = (); + type Output = (); + + view! { + gtk4::Box { + set_orientation: Orientation::Vertical, + set_spacing: 40, + set_margin_all: 100, + + Label { + set_css_classes: &["title-1"], + set_label: model.series_overview.name.as_str(), + } + } + } + + fn init( + series_overview: SeriesOverview, + _root: gtk4::Box, + _sender: ComponentSender, + ) -> ComponentParts { + let model = SeriesDetails { series_overview }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } +} diff --git a/src/ui/components/media_grid/film_grid.rs b/src/ui/components/media_grid/film_grid.rs new file mode 100644 index 0000000..de8d08b --- /dev/null +++ b/src/ui/components/media_grid/film_grid.rs @@ -0,0 +1,123 @@ +use gtk4::prelude::{OrientableExt, WidgetExt}; +use gtk4::{FlowBox, Orientation, ScrolledWindow, SelectionMode}; +use relm4::factory::FactoryVecDeque; +use relm4::{Component, ComponentParts, ComponentSender, component}; + +use crate::persist::data_manager::{DataManager, DataManagerError}; +use crate::ui::components::media_grid_item::FilmGridItem; +use crate::ui::factory_sorting::sort_factory_vec; +use crate::ui::sorting::{ + FilmsSorting, SortingDirection, cmp_films_by_name, cmp_films_by_rel_date, cmp_films_by_runtime, +}; +use crate::views::overview::FilmOverview; + + + +pub struct FilmGrid { + items: FactoryVecDeque, +} + +#[derive(Debug)] +pub enum FilmGridInput { + SortBy(FilmsSorting, SortingDirection), +} + +#[derive(Debug)] +pub enum FilmGridCmdOutput { + OverviewDataReady(Vec), + OverviewDataFailed(DataManagerError), +} + +#[component(pub)] +impl Component for FilmGrid { + type Input = FilmGridInput; + type Output = (); + type Init = (); + type CommandOutput = FilmGridCmdOutput; + + view! { + ScrolledWindow { + set_propagate_natural_height: true, + + #[name="grid"] + FlowBox { + set_orientation: Orientation::Horizontal, + set_homogeneous: true, + set_selection_mode: SelectionMode::None, + set_css_classes: &["media-grid"], + }, + } + } + + fn init( + _init: (), + _root: ScrolledWindow, + sender: ComponentSender, + ) -> ComponentParts { + let widgets = view_output!(); + let items = FactoryVecDeque::builder() + .launch(widgets.grid.clone()) + .detach(); + let model = FilmGrid { items }; + + sender.oneshot_command(async move { + let overview = DataManager::films_overview().await; + match overview { + Ok(overview) => FilmGridCmdOutput::OverviewDataReady(overview), + Err(error) => FilmGridCmdOutput::OverviewDataFailed(error), + } + }); + + ComponentParts { model, widgets } + } + + fn update( + &mut self, + message: FilmGridInput, + _sender: ComponentSender, + _root: &ScrolledWindow, + ) { + match message { + FilmGridInput::SortBy(sorting, direction) => { + let mut items = self.items.guard(); + let compare = match sorting { + FilmsSorting::Name => cmp_films_by_name, + FilmsSorting::ReleaseDate => cmp_films_by_rel_date, + FilmsSorting::Runtime => cmp_films_by_runtime, + }; + sort_factory_vec(&mut items, move |a, b| { + let ordering = compare(a.film(), b.film()); + match direction { + SortingDirection::Ascending => ordering, + SortingDirection::Descending => ordering.reverse(), + } + }); + } + } + } + + fn update_cmd( + &mut self, + message: FilmGridCmdOutput, + sender: ComponentSender, + _root: &ScrolledWindow, + ) { + match message { + FilmGridCmdOutput::OverviewDataReady(films) => { + let mut items = self.items.guard(); + items.clear(); + for film in films { + items.push_back(film); + } + + sender.input(FilmGridInput::SortBy( + FilmsSorting::Name, + SortingDirection::Ascending, + )); + } + FilmGridCmdOutput::OverviewDataFailed(error) => { + println!("Error occurred in loading films overview: {:?}", error); + } + } + } +} diff --git a/src/ui/components/media_grid/mod.rs b/src/ui/components/media_grid/mod.rs new file mode 100644 index 0000000..57b6914 --- /dev/null +++ b/src/ui/components/media_grid/mod.rs @@ -0,0 +1,5 @@ +mod film_grid; +mod series_grid; + +pub use film_grid::*; +pub use series_grid::*; diff --git a/src/ui/components/media_grid/series_grid.rs b/src/ui/components/media_grid/series_grid.rs new file mode 100644 index 0000000..6401ecc --- /dev/null +++ b/src/ui/components/media_grid/series_grid.rs @@ -0,0 +1,123 @@ +use gtk4::prelude::{OrientableExt, WidgetExt}; +use gtk4::{FlowBox, Orientation, ScrolledWindow, SelectionMode}; +use relm4::factory::FactoryVecDeque; +use relm4::{Component, ComponentParts, ComponentSender, component}; + +use crate::persist::data_manager::{DataManager, DataManagerError}; +use crate::ui::components::media_grid_item::SeriesGridItem; +use crate::ui::factory_sorting::sort_factory_vec; +use crate::ui::sorting::{ + SeriesSorting, SortingDirection, cmp_series_by_name, cmp_series_by_rel_date, +}; +use crate::views::overview::SeriesOverview; + + + +pub struct SeriesGrid { + items: FactoryVecDeque, +} + +#[derive(Debug)] +pub enum SeriesGridInput { + SortBy(SeriesSorting, SortingDirection), +} + +#[derive(Debug)] +pub enum SeriesGridCmdOutput { + OverviewDataReady(Vec), + OverviewDataFailed(DataManagerError), +} + +#[component(pub)] +impl Component for SeriesGrid { + type Input = SeriesGridInput; + type Output = (); + type Init = (); + type CommandOutput = SeriesGridCmdOutput; + + view! { + ScrolledWindow { + set_propagate_natural_height: true, + + #[name="grid"] + FlowBox { + set_orientation: Orientation::Horizontal, + set_homogeneous: true, + set_selection_mode: SelectionMode::None, + set_css_classes: &["media-grid"], + }, + } + } + + fn init( + _init: (), + _root: ScrolledWindow, + sender: ComponentSender, + ) -> ComponentParts { + let widgets = view_output!(); + let items = FactoryVecDeque::builder() + .launch(widgets.grid.clone()) + .detach(); + let model = SeriesGrid { items }; + + sender.oneshot_command(async move { + let overview = DataManager::series_overview().await; + match overview { + Ok(overview) => SeriesGridCmdOutput::OverviewDataReady(overview), + Err(error) => SeriesGridCmdOutput::OverviewDataFailed(error), + } + }); + + ComponentParts { model, widgets } + } + + fn update( + &mut self, + message: SeriesGridInput, + _sender: ComponentSender, + _root: &ScrolledWindow, + ) { + match message { + SeriesGridInput::SortBy(sorting, direction) => { + let mut items = self.items.guard(); + let compare = match sorting { + SeriesSorting::Name => cmp_series_by_name, + SeriesSorting::FirstReleaseDate => cmp_series_by_rel_date, + }; + sort_factory_vec(&mut items, move |a, b| { + let ordering = compare(a.series(), b.series()); + match direction { + SortingDirection::Ascending => ordering, + SortingDirection::Descending => ordering.reverse(), + } + }); + } + } + } + + fn update_cmd( + &mut self, + message: SeriesGridCmdOutput, + sender: ComponentSender, + _root: &ScrolledWindow, + ) { + match message { + SeriesGridCmdOutput::OverviewDataReady(series) => { + let mut items = self.items.guard(); + items.clear(); + // Look I can't help that this plural works this way either okay? + for series in series { + items.push_back(series); + } + + sender.input(SeriesGridInput::SortBy( + SeriesSorting::Name, + SortingDirection::Ascending, + )); + } + SeriesGridCmdOutput::OverviewDataFailed(error) => { + println!("Error occurred in loading series overview: {:?}", error); + } + } + } +} diff --git a/src/ui/components/media_grid_item/film_grid_item.rs b/src/ui/components/media_grid_item/film_grid_item.rs new file mode 100644 index 0000000..0aaf682 --- /dev/null +++ b/src/ui/components/media_grid_item/film_grid_item.rs @@ -0,0 +1,178 @@ +use gtk4::gdk::Texture; +use gtk4::glib::clone; +use gtk4::pango::{AttrList, Weight}; +use gtk4::prelude::{BoxExt, ButtonExt, OrientableExt, WidgetExt}; +use gtk4::{Align, Button, FlowBox, FlowBoxChild, Image, Justification, Label, Orientation, pango}; +use libadwaita::Dialog; +use libadwaita::prelude::AdwDialogExt; +use relm4::factory::{DynamicIndex, FactoryComponent}; +use relm4::{Component, ComponentController, Controller, FactorySender, RelmWidgetExt, factory}; + +use crate::persist::data_manager::{DataManager, DataManagerError}; +use crate::ui::components::media_details::FilmDetails; +use crate::ui::widget_extensions::{AttrListExt, CondDialogExt, CondLabelExt}; +use crate::views::overview::FilmOverview; + + + +pub struct FilmGridItem { + film: FilmOverview, + poster: Option, + details: Option>, +} + +impl FilmGridItem { + pub fn film(&self) -> &FilmOverview { + &self.film + } +} + +#[derive(Debug)] +pub enum FilmGridItemCmdOutput { + PosterReady(Texture), + NoPosterFound, + PosterFailed(DataManagerError), +} + +#[derive(Debug)] +pub enum FilmGridItemInput { + ItemClicked, + DetailsClosed, +} + +#[factory(pub)] +impl FactoryComponent for FilmGridItem { + type Init = FilmOverview; + type Input = FilmGridItemInput; + type Output = (); + type CommandOutput = FilmGridItemCmdOutput; + type ParentWidget = FlowBox; + + view! { + #[root] + root = FlowBoxChild { + set_focusable: false, + Button { + connect_clicked => FilmGridItemInput::ItemClicked, + set_css_classes: &["flat", "media-grid-item"], + + gtk4::Box { + set_orientation: Orientation::Vertical, + set_valign: Align::Center, + set_margin_vertical: 20, + + #[name="poster"] + Image { + set_pixel_size: 300, + set_margin_bottom: 20, + #[watch] + set_paintable: self.poster.as_ref(), + #[watch] + set_visible: self.poster.is_some(), + }, + + #[name="name"] + Label { + #[wrap(Some)] + set_attributes = &AttrList { + insert_scale: pango::SCALE_LARGE, + insert_font_weight: Weight::Bold, + }, + set_wrap: true, + // Not the actual limit, used instead to avoid the text width propagating to the + // requested widget width, behaving similarly to hexpand: false. + set_max_width_chars: 1, + // Keeps wrapped text centered. + set_justify: Justification::Center, + set_label: self.film.name.as_str(), + }, + + #[name="original_name"] + Label { + set_visible: self.film.original_name.is_some(), + set_cond_label: self.film.original_name.as_ref().map(String::as_str), + }, + + gtk4::Box { + set_spacing: 20, + set_halign: Align::Center, + set_orientation: Orientation::Horizontal, + + #[name="release_date"] + Label { + set_label: self.film.release_date.split('-').next() + .expect("film release date format should be YYYY[-MM[-DD]]"), + }, + + #[name="runtime"] + Label { + set_label: format!("{} mins", self.film.runtime).as_str(), + } + }, + }, + } + }, + + // TODO: Consider extracting this into its own component. + Dialog { + #[watch] + cond_present: self.details.as_ref().map(|_| { + root.toplevel_window().expect("root widget of FilmGridItem should be ancestor of a window") + }), + #[watch] + set_child: self.details.as_ref().map(|details| details.widget()), + connect_closed => FilmGridItemInput::DetailsClosed, + }, + } + + fn init_model( + film: FilmOverview, + _index: &DynamicIndex, + sender: FactorySender, + ) -> FilmGridItem { + sender.oneshot_command(clone!( + #[strong] + film, + async move { + match DataManager::poster(film.uuid.as_str()).await { + Ok(Some(texture)) => FilmGridItemCmdOutput::PosterReady(texture), + // The only reason this message variant exists is because we need to return something + // here. + Ok(None) => FilmGridItemCmdOutput::NoPosterFound, + Err(error) => FilmGridItemCmdOutput::PosterFailed(error), + } + }, + )); + + FilmGridItem { + film, + poster: None, + details: None, + } + } + + fn update(&mut self, message: FilmGridItemInput, _sender: FactorySender) { + match message { + FilmGridItemInput::ItemClicked => { + let details_controller = FilmDetails::builder().launch(self.film.clone()).detach(); + self.details = Some(details_controller); + } + FilmGridItemInput::DetailsClosed => { + self.details = None; + } + } + } + + fn update_cmd(&mut self, message: FilmGridItemCmdOutput, _sender: FactorySender) { + match message { + FilmGridItemCmdOutput::PosterReady(texture) => { + self.poster = Some(texture); + } + // If no poster is present we don't need to do anything. + FilmGridItemCmdOutput::NoPosterFound => {} + FilmGridItemCmdOutput::PosterFailed(error) => { + println!("Error occurred in loading film poster: {:?}", error); + } + } + } +} diff --git a/src/ui/components/media_grid_item/mod.rs b/src/ui/components/media_grid_item/mod.rs new file mode 100644 index 0000000..5ebd50a --- /dev/null +++ b/src/ui/components/media_grid_item/mod.rs @@ -0,0 +1,5 @@ +mod film_grid_item; +mod series_grid_item; + +pub use film_grid_item::*; +pub use series_grid_item::*; diff --git a/src/ui/components/media_grid_item/series_grid_item.rs b/src/ui/components/media_grid_item/series_grid_item.rs new file mode 100644 index 0000000..6193406 --- /dev/null +++ b/src/ui/components/media_grid_item/series_grid_item.rs @@ -0,0 +1,172 @@ +use gtk4::gdk::Texture; +use gtk4::glib::clone; +use gtk4::pango::{AttrList, Weight}; +use gtk4::prelude::{ButtonExt, OrientableExt, WidgetExt}; +use gtk4::{Align, Button, FlowBox, FlowBoxChild, Image, Justification, Label, Orientation, pango}; +use libadwaita::Dialog; +use libadwaita::prelude::AdwDialogExt; +use relm4::factory::{DynamicIndex, FactoryComponent}; +use relm4::{Component, ComponentController, Controller, FactorySender, RelmWidgetExt, factory}; + +use crate::persist::data_manager::{DataManager, DataManagerError}; +use crate::ui::components::media_details::SeriesDetails; +use crate::ui::widget_extensions::{AttrListExt, CondDialogExt, CondLabelExt}; +use crate::views::overview::SeriesOverview; + + + +pub struct SeriesGridItem { + series: SeriesOverview, + poster: Option, + details: Option>, +} + +impl SeriesGridItem { + pub fn series(&self) -> &SeriesOverview { + &self.series + } +} + +#[derive(Debug)] +pub enum SeriesGridItemCmdOutput { + PosterReady(Texture), + NoPosterFound, + PosterFailed(DataManagerError), +} + +#[derive(Debug)] +pub enum SeriesGridItemInput { + ItemClicked, + DetailsClosed, +} + +#[factory(pub)] +impl FactoryComponent for SeriesGridItem { + type Init = SeriesOverview; + type Input = SeriesGridItemInput; + type Output = (); + type CommandOutput = SeriesGridItemCmdOutput; + type ParentWidget = FlowBox; + + view! { + #[root] + root = FlowBoxChild { + set_focusable: false, + Button { + connect_clicked => SeriesGridItemInput::ItemClicked, + set_css_classes: &["flat", "media-grid-item"], + gtk4::Box { + set_orientation: Orientation::Vertical, + set_valign: Align::Center, + set_margin_vertical: 20, + + #[name="poster"] + Image { + set_pixel_size: 300, + set_margin_bottom: 20, + #[watch] + set_paintable: self.poster.as_ref(), + #[watch] + set_visible: self.poster.is_some(), + }, + + #[name="name"] + Label { + #[wrap(Some)] + set_attributes = &AttrList { + insert_scale: pango::SCALE_LARGE, + insert_font_weight: Weight::Bold, + }, + set_wrap: true, + // Not the actual limit, used instead to avoid the text width propagating to the + // requested widget width, behaving similarly to hexpand: false. + set_max_width_chars: 1, + // Keeps wrapped text centered. + set_justify: Justification::Center, + set_label: self.series.name.as_str(), + }, + + #[name="original_name"] + Label { + set_visible: self.series.original_name.is_some(), + set_cond_label: self.series.original_name.as_ref().map(String::as_str), + }, + + #[name="release_date"] + Label { + set_label: self.series.first_release_date.split('-').next() + .expect("series first release date format should be YYYY[-MM[-DD]]"), + }, + }, + } + }, + + // TODO: Consider extracting this into its own component. + Dialog { + #[watch] + cond_present: self.details.as_ref().map(|_| { + root.toplevel_window().expect("root widget of FilmGridItem should be ancestor of a window") + }), + #[watch] + set_child: self.details.as_ref().map(|details| details.widget()), + connect_closed => SeriesGridItemInput::DetailsClosed, + }, + } + + fn init_model( + series: SeriesOverview, + _index: &DynamicIndex, + sender: FactorySender, + ) -> SeriesGridItem { + sender.oneshot_command(clone!( + #[strong] + series, + async move { + match DataManager::poster(series.uuid.as_str()).await { + Ok(Some(texture)) => SeriesGridItemCmdOutput::PosterReady(texture), + // The only reason this message variant exists is because we need to return something + // here. + Ok(None) => SeriesGridItemCmdOutput::NoPosterFound, + Err(error) => SeriesGridItemCmdOutput::PosterFailed(error), + } + }, + )); + + SeriesGridItem { + series, + poster: None, + details: None, + } + } + + fn update(&mut self, message: SeriesGridItemInput, _sender: FactorySender) { + match message { + SeriesGridItemInput::ItemClicked => { + let details_controller = SeriesDetails::builder() + .launch(self.series.clone()) + .detach(); + self.details = Some(details_controller); + } + SeriesGridItemInput::DetailsClosed => { + self.details = None; + } + } + } + + fn update_cmd( + &mut self, + message: SeriesGridItemCmdOutput, + _sender: FactorySender, + ) { + match message { + SeriesGridItemCmdOutput::PosterReady(texture) => { + self.poster = Some(texture); + } + // If no poster is present we don't need to do anything. + SeriesGridItemCmdOutput::NoPosterFound => {} + SeriesGridItemCmdOutput::PosterFailed(error) => { + println!("Error occurred in loading series poster: {:?}", error); + } + } + } +} diff --git a/src/ui/components/media_type_switcher.rs b/src/ui/components/media_type_switcher.rs new file mode 100644 index 0000000..3cc650c --- /dev/null +++ b/src/ui/components/media_type_switcher.rs @@ -0,0 +1,55 @@ +use libadwaita::{HeaderBar, ToolbarView, ViewStack, ViewSwitcher, ViewSwitcherPolicy}; +use relm4::{ + Component, ComponentController, ComponentParts, ComponentSender, Controller, SimpleComponent, + component, +}; + +use crate::ui::components::collatable_media_grid::{CollatableFilmGrid, CollatableSeriesGrid}; + + + +pub struct MediaTypeSwitcher { + films_grid: Controller, + series_grid: Controller, +} + +#[component(pub)] +impl SimpleComponent for MediaTypeSwitcher { + type Input = (); + type Output = (); + type Init = (); + + view! { + ToolbarView { + add_top_bar = &HeaderBar { + #[wrap(Some)] + set_title_widget = &ViewSwitcher { + set_policy: ViewSwitcherPolicy::Wide, + set_stack: Some(&stack), + }, + }, + + #[wrap(Some)] + set_content: stack = &ViewStack { + add_titled_with_icon[None, "Films", "camera-video-symbolic"]: model.films_grid.widget(), + add_titled_with_icon[None, "Series", "video-display-symbolic"]: model.series_grid.widget(), + }, + } + } + + fn init( + _init: (), + _root: ToolbarView, + _sender: ComponentSender, + ) -> ComponentParts { + let films_grid = CollatableFilmGrid::builder().launch(()).detach(); + let series_grid = CollatableSeriesGrid::builder().launch(()).detach(); + + let model = MediaTypeSwitcher { + films_grid, + series_grid, + }; + let widgets = view_output!(); + ComponentParts { model, widgets } + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs new file mode 100644 index 0000000..eecfca0 --- /dev/null +++ b/src/ui/components/mod.rs @@ -0,0 +1,8 @@ +pub mod app; +pub mod collatable_media_grid; +pub mod media_collation_menu; +pub mod media_details; +pub mod media_grid; +pub mod media_grid_item; +pub mod media_type_switcher; +pub mod sorting_popover_entry; diff --git a/src/ui/components/sorting_popover_entry.rs b/src/ui/components/sorting_popover_entry.rs new file mode 100644 index 0000000..93e9645 --- /dev/null +++ b/src/ui/components/sorting_popover_entry.rs @@ -0,0 +1,18 @@ +use gtk4::prelude::WidgetExt; +use gtk4::{Align, Label, ListBoxRow}; +use relm4::view; + + + +pub fn sorting_popover_entry(label: &str) -> ListBoxRow { + view! { + root = ListBoxRow { + Label { + set_halign: Align::Start, + set_hexpand: true, + set_label: label, + } + } + } + root +} diff --git a/src/ui/factory_sorting.rs b/src/ui/factory_sorting.rs new file mode 100644 index 0000000..f3cb40f --- /dev/null +++ b/src/ui/factory_sorting.rs @@ -0,0 +1,58 @@ +use std::cmp::Ordering; + +use relm4::factory::{DynamicIndex, FactoryComponent, FactoryVecDequeGuard}; + + + +// Adapted from https://github.com/flakusha/sorting_rs/blob/1.2.10/src/quick_sort.rs under MIT +// license. + +pub fn sort_factory_vec(factory_vec: &mut FactoryVecDequeGuard, compare: F) +where + F: Fn(&I, &I) -> Ordering, + I: FactoryComponent, +{ + _sort_factory_vec(factory_vec, 0, factory_vec.len(), &compare); +} + +fn _sort_factory_vec( + factory_vec: &mut FactoryVecDequeGuard, + start_index: usize, + end_index: usize, + compare: &F, +) where + F: Fn(&I, &I) -> Ordering, + I: FactoryComponent, +{ + if end_index - start_index > 1 { + let pivot = lomuto_partition(factory_vec, start_index, end_index, compare); + _sort_factory_vec(factory_vec, start_index, pivot, compare); + _sort_factory_vec(factory_vec, pivot + 1, end_index, compare); + } +} + +fn lomuto_partition( + factory_vec: &mut FactoryVecDequeGuard, + start_index: usize, + end_index: usize, + compare: &F, +) -> usize +where + F: Fn(&I, &I) -> Ordering, + I: FactoryComponent, +{ + let pivot = end_index - 1; + let mut swap = start_index; + for i in start_index..pivot { + if compare(&factory_vec[i], &factory_vec[pivot]) == Ordering::Less { + if swap != i { + factory_vec.swap(swap, i); + } + swap += 1; + } + } + if swap != pivot { + factory_vec.swap(swap, pivot); + } + swap +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 76a1937..c92a42f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,163 +1,6 @@ -mod collatable_container; -mod component; -mod utility; +mod components; +mod factory_sorting; +mod sorting; +mod widget_extensions; -use std::process::Command; - -use futures::join; -use gtk4::glib::spawn_future_local; -use gtk4::prelude::{BoxExt, ButtonExt, GtkWindowExt, OrientableExt, WidgetExt}; -use gtk4::{Button, Image, Label, Orientation}; -use libadwaita::prelude::{AdwApplicationWindowExt, AdwDialogExt}; -use libadwaita::{ - Application, ApplicationWindow, Dialog, HeaderBar, ToolbarView, ViewStack, ViewSwitcher, - ViewSwitcherPolicy, -}; -use relm4_macros::view; - -use crate::data_manager::{CollectionOverview, FilmDetails}; -use crate::ui::collatable_container::{CollatableMediaContainer, FilmsAdapter, SeriesAdapter}; -use crate::ui::component::Component; -use crate::ui::utility::{OptChildExt, view_expr}; -use crate::utility::{concat_os_str, leak, to_os_string}; - - - -pub struct UI { - films_component: CollatableMediaContainer, - series_component: CollatableMediaContainer, -} - -impl UI { - pub fn new( - window: &'static Window, - get_film_details: impl AsyncFn(String) -> FilmDetails + 'static, - ) -> UI { - let get_film_details = leak(get_film_details); - - let films_component = CollatableMediaContainer::::new(|film| { - spawn_future_local(async { - let film_details = get_film_details(film.uuid).await; - - view! { - Dialog { - present: Some(&window.libadwaita_window), - set_child: Some(&view_expr! { - gtk4::Box { - set_spacing: 40, - set_css_classes: &["media-modal"], - set_orientation: Orientation::Vertical, - - append: &view_expr! { - Label { - set_css_classes: &["title-1"] , - set_label: film_details.name.as_str(), - } - }, - - append_opt: &film_details.original_name.map(|original_name| view_expr! { - Label { set_label: original_name.as_str() } - }), - - append: &view_expr! { - Label { set_label: &format!("Release date: {}", film_details.release_date) } - }, - - append_opt: &film_details.source.map(|source| view_expr! { - Button { - set_css_classes: &["suggested-action", "circular"], - - connect_clicked: move |_| { - let arguments = [ - Some(source.file_path.as_os_str().to_owned()), - source.audio_track.map( - |audio_track| concat_os_str!("--mpv-aid=", to_os_string(audio_track)), - ), - source.subtitle_track.map( - |subtitle_track| concat_os_str!("--mpv-sid=", to_os_string(subtitle_track)), - ), - ].iter().filter_map(Option::clone).collect::>(); - - // TODO: Better error handling for UI callbacks in general - Command::new("/usr/bin/celluloid").args(arguments).spawn().unwrap(); - }, - - set_child: Some(&view_expr! { - Image { set_icon_name: Some("media-playback-start-symbolic") } - }), - } - }), - } - }), - } - } - }); - }); - let series_component = CollatableMediaContainer::::new(|series| { - view_expr! { - Dialog { present: Some(&window.libadwaita_window) } - }; - }); - - view! { - switched = ViewStack { - add_titled_with_icon: (films_component.get_widget(), None, "Films", "camera-video-symbolic"), - add_titled_with_icon: (series_component.get_widget(), None, "Series", "video-display-symbolic"), - }, - header_bar = HeaderBar { - set_title_widget: Some(&view_expr! { - ViewSwitcher { - set_policy: ViewSwitcherPolicy::Wide, - set_stack: Some(&switched), - } - }), - }, - } - - window.libadwaita_window.set_content(Some(&view_expr! { - ToolbarView { - add_top_bar: &header_bar, - set_content: Some(&switched), - } - })); - - UI { - films_component, - series_component, - } - } - - pub async fn render_collection_overview(&self, collection: CollectionOverview) { - join!( - self.films_component.set_media(collection.films), - self.series_component.set_media(collection.series), - ); - } -} - - - -pub struct Window { - libadwaita_window: ApplicationWindow, -} - -impl Window { - pub fn new(application: &Application) -> Self { - let libadwaita_window = view_expr! { - ApplicationWindow { - set_application: Some(application), - set_title: Some("Zoödex"), - } - }; - - Self { libadwaita_window } - } - - pub fn show(&self) { - self.libadwaita_window.set_visible(true) - } - - pub fn close(&self) { - self.libadwaita_window.close() - } -} +pub use components::app::App; diff --git a/src/ui/sorting.rs b/src/ui/sorting.rs new file mode 100644 index 0000000..56697aa --- /dev/null +++ b/src/ui/sorting.rs @@ -0,0 +1,85 @@ +use std::cmp::Ordering; + +use crate::views::overview::{FilmOverview, SeriesOverview}; + + + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FilmsSorting { + Name, + ReleaseDate, + Runtime, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SeriesSorting { + Name, + FirstReleaseDate, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SortingDirection { + Ascending, + Descending, +} + + + +// TODO: Consider using wrapper structs implementing Ord. + +/// Compare two film overviews by name, falling back to release date and then +/// UUID if equal. +pub fn cmp_films_by_name(a: &FilmOverview, b: &FilmOverview) -> Ordering { + match a.name.cmp(&b.name) { + Ordering::Equal => match a.release_date.cmp(&b.release_date) { + Ordering::Equal => a.uuid.cmp(&b.uuid), + not_equal => not_equal, + }, + not_equal => not_equal, + } +} + +/// Compare two films by release date, falling back to name and then UUID if +/// equal. +pub fn cmp_films_by_rel_date(a: &FilmOverview, b: &FilmOverview) -> Ordering { + match a.release_date.cmp(&b.release_date) { + Ordering::Equal => match a.name.cmp(&b.name) { + Ordering::Equal => a.uuid.cmp(&b.uuid), + not_equal => not_equal, + }, + not_equal => not_equal, + } +} + +/// Compare two films by runtime, falling back to name, release date and then +/// UUID if equal. +pub fn cmp_films_by_runtime(a: &FilmOverview, b: &FilmOverview) -> Ordering { + match a.runtime.cmp(&b.runtime) { + Ordering::Equal => cmp_films_by_name(a, b), + not_equal => not_equal, + } +} + +/// Compare two series overviews by name, falling back to first release date and +/// then UUID if equal. +pub fn cmp_series_by_name(a: &SeriesOverview, b: &SeriesOverview) -> Ordering { + match a.name.cmp(&b.name) { + Ordering::Equal => match a.first_release_date.cmp(&b.first_release_date) { + Ordering::Equal => a.uuid.cmp(&b.uuid), + not_equal => not_equal, + }, + not_equal => not_equal, + } +} + +/// Compare two series by first release date, falling back to name and then UUID +/// if equal. +pub fn cmp_series_by_rel_date(a: &SeriesOverview, b: &SeriesOverview) -> Ordering { + match a.first_release_date.cmp(&b.first_release_date) { + Ordering::Equal => match a.name.cmp(&b.name) { + Ordering::Equal => a.uuid.cmp(&b.uuid), + not_equal => not_equal, + }, + not_equal => not_equal, + } +} diff --git a/src/ui/utility.rs b/src/ui/utility.rs deleted file mode 100644 index 8f8bc98..0000000 --- a/src/ui/utility.rs +++ /dev/null @@ -1,67 +0,0 @@ -use gtk4::prelude::{BoxExt, IsA, OrientableExt, WidgetExt}; -use gtk4::{Orientation, Widget}; -use libadwaita::Bin; - - - -// Convenience function to conditionally append child to a widget - -pub trait OptChildExt { - fn append_opt(&self, child: &Option>); -} - -impl OptChildExt for gtk4::Box { - fn append_opt(&self, child: &Option>) { - if let Some(child) = child { - self.append(child); - } - } -} - - - -// The `view` macro from Relm4 as an expression instead of a variable -// declaration - -macro_rules! view_expr {( - $($contents: tt)* -) => {{ - relm4_macros::view! { outer = $($contents)* } - outer -}}} - - - -pub fn vertical_filler(child: &impl IsA) -> gtk4::Box { - view_expr! { - gtk4::Box { - set_orientation: Orientation::Vertical, - append: child, - append: &view_expr! { - Bin { set_vexpand: true } - }, - } - } -} - - - -macro_rules! pango_attributes {( - $(scale: $scale: expr)? - $(, weight: $weight: expr $(,)?)? -) => {{ - let attributes = gtk4::pango::AttrList::new(); - #[allow(unused_mut)] - let mut font_description = gtk4::pango::FontDescription::new(); - - $(attributes.insert(gtk4::pango::AttrFloat::new_scale($scale));)? - $(font_description.set_weight($weight);)? - - attributes.insert(gtk4::pango::AttrFontDesc::new(&font_description)); - attributes -}}} - - - -#[allow(unused_imports)] -pub(crate) use {pango_attributes, view_expr}; diff --git a/src/ui/widget_extensions.rs b/src/ui/widget_extensions.rs new file mode 100644 index 0000000..295e78b --- /dev/null +++ b/src/ui/widget_extensions.rs @@ -0,0 +1,69 @@ +use gtk4::pango::{AttrFloat, AttrFontDesc, AttrList, FontDescription, Weight}; +use gtk4::prelude::{BoxExt, IsA}; +use gtk4::{Label, Widget}; +use libadwaita::Dialog; +use libadwaita::prelude::AdwDialogExt; + + + +pub trait CondBoxExt: IsA { + // Unlike BoxExt::append the child is not a reference, so that using it with + // Option::map is more ergonomic. + fn cond_append>(&self, child: Option) { + if let Some(child) = child { + self.append(&child); + } + } +} + +impl> CondBoxExt for B {} + + + +pub trait CondLabelExt { + fn set_cond_label(&self, label: Option<&str>); +} + +impl CondLabelExt for Label { + fn set_cond_label(&self, label: Option<&str>) { + if let Some(label) = label { + self.set_label(label); + } + } +} + + + +pub trait CondDialogExt: IsA { + // Deliberately does not expose the functionality to present a dialog without a + // parent window i.e. as its own window because we don't use it. + // Unlike AdwDialogExt::present the parent is not a reference, so that using it + // with Option::map is more ergonomic. + fn cond_present>(&self, parent: Option) { + match parent { + Some(parent) => self.present(Some(&parent)), + None => {} + } + } +} + +impl> CondDialogExt for D {} + + + +pub trait AttrListExt { + fn insert_scale(&self, scale: f64); + fn insert_font_weight(&self, weight: Weight); +} + +impl AttrListExt for AttrList { + fn insert_scale(&self, scale: f64) { + self.insert(AttrFloat::new_scale(scale)); + } + + fn insert_font_weight(&self, weight: Weight) { + let mut font_desc = FontDescription::new(); + font_desc.set_weight(weight); + self.insert(AttrFontDesc::new(&font_desc)); + } +} diff --git a/src/utility.rs b/src/utility.rs deleted file mode 100644 index e35f850..0000000 --- a/src/utility.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::ffi::OsString; -use std::fmt::Display; - - - -macro_rules! concat_os_str {( - $base: expr, $($suffix: expr),+ -) => {{ - let mut base = std::ffi::OsString::from($base); - $(base.push($suffix);)+ - base -}}} - -pub fn leak<'l, Type>(inner: Type) -> &'l Type { - Box::leak(Box::new(inner)) -} - -pub fn leak_mut<'l, Type>(inner: Type) -> &'l mut Type { - Box::leak(Box::new(inner)) -} - -pub fn to_os_string(value: impl Display + Sized) -> OsString { - OsString::from(ToString::to_string(&value)) -} - - - -#[allow(unused_imports)] -pub(crate) use concat_os_str; diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..20f3d75 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1 @@ +pub mod overview; diff --git a/src/views/overview.rs b/src/views/overview.rs new file mode 100644 index 0000000..118a12d --- /dev/null +++ b/src/views/overview.rs @@ -0,0 +1,18 @@ +#[derive(Clone, Debug)] +pub struct FilmOverview { + pub uuid: String, + pub name: String, + pub original_name: Option, + pub release_date: String, + // In minutes. + pub runtime: u32, +} + +#[derive(Clone, Debug)] +pub struct SeriesOverview { + pub uuid: String, + pub name: String, + pub original_name: Option, + // Format: YYYY[-MM[-DD]]. + pub first_release_date: String, +}