Rewrite using Relm4
Yes, this is a monster commit but at this stage I'm the only one working on this project anyway. Further commits will follow Best Practises™ again.
This commit is contained in:
parent
d25fdd0a32
commit
4d4b7eb1c7
43 changed files with 2159 additions and 1248 deletions
330
Cargo.lock
generated
330
Cargo.lock
generated
|
|
@ -4,9 +4,9 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-sqlite"
|
name = "async-sqlite"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556a4163d701c7d3e28c89917294cea8d2a05092c92a50307c0f2d1742058ced"
|
checksum = "c0d7f95be4bae0f9c86c7550b9ea890fa3283a06d71aef9a99d7f21de07a93a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
|
|
@ -26,6 +26,12 @@ version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.19.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
version = "0.21.5"
|
version = "0.21.5"
|
||||||
|
|
@ -49,6 +55,16 @@ dependencies = [
|
||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "cfg-expr"
|
name = "cfg-expr"
|
||||||
version = "0.20.5"
|
version = "0.20.5"
|
||||||
|
|
@ -59,6 +75,12 @@ dependencies = [
|
||||||
"target-lexicon",
|
"target-lexicon",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
|
|
@ -103,10 +125,34 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.5"
|
version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
|
|
@ -254,6 +300,19 @@ dependencies = [
|
||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.21.5"
|
version = "0.21.5"
|
||||||
|
|
@ -447,26 +506,20 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -482,7 +535,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -524,14 +587,23 @@ checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.35.0"
|
version = "0.36.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
|
|
@ -547,6 +619,21 @@ dependencies = [
|
||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.21.5"
|
version = "0.21.5"
|
||||||
|
|
@ -617,10 +704,34 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relm4-macros"
|
name = "relm4"
|
||||||
version = "0.10.1"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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 = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -629,9 +740,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.38.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
|
|
@ -639,6 +750,7 @@ dependencies = [
|
||||||
"hashlink",
|
"hashlink",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"sqlite-wasm-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -650,6 +762,18 @@ dependencies = [
|
||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
|
|
@ -685,6 +809,12 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
|
|
@ -697,6 +827,28 @@ version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.112"
|
version = "2.0.112"
|
||||||
|
|
@ -727,6 +879,47 @@ version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.9.10+spec-1.1.0"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
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]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
|
|
@ -796,6 +1020,57 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -821,13 +1096,12 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zoodex"
|
name = "zoodex-scratchpad"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-sqlite",
|
"async-sqlite",
|
||||||
"fallible-iterator",
|
|
||||||
"futures",
|
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"relm4-macros",
|
"relm4",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
10
Cargo.toml
10
Cargo.toml
|
|
@ -1,5 +1,5 @@
|
||||||
[package]
|
[package]
|
||||||
name = "zoodex"
|
name = "zoodex-scratchpad"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
authors = ["Reinout Meliesie <zedfrigg@kernelmaft.com>"]
|
authors = ["Reinout Meliesie <zedfrigg@kernelmaft.com>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
@ -10,9 +10,9 @@ license = "GPL-3.0-or-later"
|
||||||
lto = true
|
lto = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-sqlite = { version = "0.5.3", default-features = false }
|
async-sqlite = { version = "0.5.4", default-features = false }
|
||||||
fallible-iterator = "0.3.0" # Must match version used by async-sqlite
|
|
||||||
futures = "0.3.31"
|
|
||||||
gtk4 = { version = "0.10.3", features = ["v4_20"] }
|
gtk4 = { version = "0.10.3", features = ["v4_20"] }
|
||||||
libadwaita = { version = "0.8.1", features = ["v1_8"] }
|
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"] }
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,8 @@
|
||||||
/* TODO: Switch out CSS dynamically on `gtk-application-prefer-dark-theme` property change */
|
.media-grid flowboxchild {
|
||||||
.collation-menu row:selected {
|
|
||||||
background-color: rgb(0 0 0 / 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collation-menu row:not(:selected) image {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collatable-container flowboxchild {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collection-item-button {
|
.media-grid-item {
|
||||||
font-weight: normal; /* No bold text by default for this kind of button */
|
/* No bold text by default for this kind of button */
|
||||||
}
|
font-weight: normal;
|
||||||
|
|
||||||
.collection-item-box {
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collection-item-image {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-modal {
|
|
||||||
padding: 100px;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Self> {
|
|
||||||
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<CollectionOverview> {
|
|
||||||
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<FilmDetails> {
|
|
||||||
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<FilmOverview>,
|
|
||||||
pub series: Vec<SeriesOverview>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait MediaOverview: Clone {
|
|
||||||
fn get_uuid(&self) -> String;
|
|
||||||
fn get_name(&self) -> String;
|
|
||||||
fn get_original_name(&self) -> Option<String>;
|
|
||||||
fn get_release_date(&self) -> String;
|
|
||||||
fn get_runtime_minutes(&self) -> Option<u32>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct FilmOverview {
|
|
||||||
pub uuid: String,
|
|
||||||
pub name: String,
|
|
||||||
pub original_name: Option<String>,
|
|
||||||
// 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<String>,
|
|
||||||
// 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<String> {
|
|
||||||
self.original_name.clone()
|
|
||||||
}
|
|
||||||
fn get_release_date(&self) -> String {
|
|
||||||
self.release_date.clone()
|
|
||||||
}
|
|
||||||
fn get_runtime_minutes(&self) -> Option<u32> {
|
|
||||||
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<String> {
|
|
||||||
self.original_name.clone()
|
|
||||||
}
|
|
||||||
fn get_release_date(&self) -> String {
|
|
||||||
self.first_release_date.clone()
|
|
||||||
}
|
|
||||||
fn get_runtime_minutes(&self) -> Option<u32> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_to_film_overview(row: &Row) -> rusqlite::Result<FilmOverview> {
|
|
||||||
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<SeriesOverview> {
|
|
||||||
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<String>,
|
|
||||||
pub release_date: String,
|
|
||||||
pub runtime_minutes: u32,
|
|
||||||
pub source: Option<SourceDetails>,
|
|
||||||
}
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SourceDetails {
|
|
||||||
pub bittorrent_hash: String,
|
|
||||||
pub file_path: PathBuf,
|
|
||||||
pub audio_track: Option<u32>,
|
|
||||||
pub subtitle_track: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_to_film_details(row: &Row) -> rusqlite::Result<FilmDetails> {
|
|
||||||
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<String>>(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<async_sqlite::Error> for ZoodexError {
|
|
||||||
fn from(error: async_sqlite::Error) -> Self {
|
|
||||||
match error {
|
|
||||||
async_sqlite::Error::Rusqlite(error) => ZoodexError::from(error),
|
|
||||||
_ => panic!("{}", error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<rusqlite::Error> 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
35
src/error.rs
35
src/error.rs
|
|
@ -1,35 +0,0 @@
|
||||||
use std::any::Any;
|
|
||||||
use std::result;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ZoodexError {
|
|
||||||
CollectionFileReadError,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Result<Success> = result::Result<Success, ZoodexError>;
|
|
||||||
|
|
||||||
impl From<Box<dyn Any + Send>> for ZoodexError {
|
|
||||||
fn from(error: Box<dyn Any + Send>) -> 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;
|
|
||||||
83
src/main.rs
83
src/main.rs
|
|
@ -1,71 +1,44 @@
|
||||||
mod data_manager;
|
#![allow(dead_code, private_interfaces, unused_assignments, unused_macros)]
|
||||||
mod error;
|
|
||||||
|
mod persist;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utility;
|
mod views;
|
||||||
|
|
||||||
use gtk4::gdk::Display;
|
use gtk4::gdk::Display;
|
||||||
use gtk4::glib::{ExitCode, spawn_future_local};
|
|
||||||
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
|
||||||
use gtk4::{
|
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::ui::App;
|
||||||
use crate::error::{ZoodexError, async_result_context};
|
|
||||||
use crate::ui::{UI, Window};
|
|
||||||
use crate::utility::leak;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() {
|
||||||
let application = Application::builder()
|
let app = RelmApp::new("com.kernelmaft.zoodex");
|
||||||
.application_id("com.kernelmaft.zoodex")
|
|
||||||
.build();
|
include_app_css();
|
||||||
application.connect_startup(add_style_provider);
|
|
||||||
application.connect_activate(show_window);
|
// TODO: Set this to nr of cores if using Relm commands heavily.
|
||||||
application.run()
|
// RELM_THREADS.set(4).unwrap();
|
||||||
|
|
||||||
|
app.run::<App>(());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_style_provider(_: &Application) {
|
fn include_app_css() {
|
||||||
let style_provider = CssProvider::new();
|
// We can't use relm4::set_global_css because we need access to the CSS provider
|
||||||
style_provider.load_from_string(include_str!("application.css"));
|
// to relay color scheme changes to it.
|
||||||
style_context_add_provider_for_display(
|
|
||||||
&Display::default().unwrap(),
|
|
||||||
&style_provider,
|
|
||||||
STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_window(application: &Application) {
|
let provider = CssProvider::new();
|
||||||
let window = leak(Window::new(application));
|
let display = Display::default().unwrap();
|
||||||
|
|
||||||
spawn_future_local(async move {
|
provider.load_from_string(include_str!("application.css"));
|
||||||
async_result_context!(
|
style_context_add_provider_for_display(&display, &provider, STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||||
async {
|
|
||||||
let data_manager = leak(DataManager::new().await?);
|
|
||||||
|
|
||||||
let ui = UI::new(
|
let settings = Settings::for_display(&display);
|
||||||
window,
|
provider.set_prefers_color_scheme(settings.gtk_interface_color_scheme());
|
||||||
async |film_uuid| {
|
settings.connect_gtk_interface_color_scheme_notify(move |settings| {
|
||||||
data_manager
|
provider.set_prefers_color_scheme(settings.gtk_interface_color_scheme());
|
||||||
.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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/persist/common.rs
Normal file
13
src/persist/common.rs
Normal file
|
|
@ -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;
|
||||||
82
src/persist/data_manager.rs
Normal file
82
src/persist/data_manager.rs
Normal file
|
|
@ -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<SqliteManager> = OnceLock::new();
|
||||||
|
static FILE_SYSTEM_MANAGER: OnceLock<FileSystemManager> = 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<Vec<FilmOverview>, DataManagerError> {
|
||||||
|
sqlite_manager!().films_overview().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn series_overview() -> Result<Vec<SeriesOverview>, DataManagerError> {
|
||||||
|
sqlite_manager!().series_overview().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poster(uuid: &str) -> Result<Option<Texture>, DataManagerError> {
|
||||||
|
fs_manager!().poster(uuid).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DataManagerError {
|
||||||
|
NoHomeDir,
|
||||||
|
CannotOpenSharedDB,
|
||||||
|
UnknownSharedDBError,
|
||||||
|
CannotOpenLocalDB,
|
||||||
|
UnknownLocalDBError,
|
||||||
|
UnknownTextureError,
|
||||||
|
}
|
||||||
44
src/persist/file_system_manager.rs
Normal file
44
src/persist/file_system_manager.rs
Normal file
|
|
@ -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<Option<Texture>, 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 }")
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/persist/mod.rs
Normal file
4
src/persist/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
mod common;
|
||||||
|
pub mod data_manager;
|
||||||
|
mod file_system_manager;
|
||||||
|
mod sqlite_manager;
|
||||||
196
src/persist/sqlite_manager.rs
Normal file
196
src/persist/sqlite_manager.rs
Normal file
|
|
@ -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<SqliteManager, DataManagerError> {
|
||||||
|
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<Vec<FilmOverview>, 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<Vec<SeriesOverview>, 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<Client, DataManagerError> {
|
||||||
|
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<FilmOverview> {
|
||||||
|
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<SeriesOverview> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
@ -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<A: MediaAdapter> {
|
|
||||||
media_widget_pairs: RefCell<Vec<(A::Overview, Button)>>,
|
|
||||||
grid_widget: FlowBox,
|
|
||||||
on_media_selected: &'static dyn Fn(A::Overview),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A: MediaAdapter> CollatedMediaGrid<A> {
|
|
||||||
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<A::Overview>, 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<A: MediaAdapter> Component for CollatedMediaGrid<A> {
|
|
||||||
fn get_widget(&self) -> &FlowBox {
|
|
||||||
&self.grid_widget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<A: MediaAdapter>(on_sort: impl Fn(A::Sorting) + 'static) -> Self {
|
|
||||||
let sort_button = MediaSortButton::<A>::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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<A: MediaAdapter> {
|
|
||||||
widget: SplitButton,
|
|
||||||
previous_sorting: &'static RefCell<A::Sorting>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A: MediaAdapter> MediaSortButton<A> {
|
|
||||||
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::<A>(
|
|
||||||
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<A: MediaAdapter> Component for MediaSortButton<A> {
|
|
||||||
fn get_widget(&self) -> &SplitButton {
|
|
||||||
&self.widget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_media_sort_activated<A: MediaAdapter>(
|
|
||||||
row: i32,
|
|
||||||
previous_sorting_mut: &RefCell<A::Sorting>,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<P: MediaProperty>: 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<FilmProperty> 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<SeriesProperty> 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<A: MediaAdapter> {
|
|
||||||
collated_grid: &'static CollatedMediaGrid<A>,
|
|
||||||
widget: Box,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<A: MediaAdapter> CollatableMediaContainer<A> {
|
|
||||||
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::<A>(|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<A::Overview>) {
|
|
||||||
self
|
|
||||||
.collated_grid
|
|
||||||
.set_media(media, A::Sorting::default())
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait MediaAdapter: 'static {
|
|
||||||
type Overview: MediaOverview;
|
|
||||||
type Sorting: MediaSorting<Self::Property>;
|
|
||||||
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<A: MediaAdapter> Component for CollatableMediaContainer<A> {
|
|
||||||
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"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
use gtk4::Widget;
|
|
||||||
use gtk4::prelude::IsA;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub trait Component {
|
|
||||||
fn get_widget(&self) -> &impl IsA<Widget>;
|
|
||||||
}
|
|
||||||
71
src/ui/components/app.rs
Normal file
71
src/ui/components/app.rs
Normal file
|
|
@ -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<Controller<MediaTypeSwitcher>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<App>) -> ComponentParts<App> {
|
||||||
|
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<App>,
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<FilmCollationMenu>,
|
||||||
|
film_grid: Controller<FilmGrid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<CollatableFilmGrid>,
|
||||||
|
) -> ComponentParts<CollatableFilmGrid> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SeriesCollationMenu>,
|
||||||
|
series_grid: Controller<SeriesGrid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<CollatableSeriesGrid>,
|
||||||
|
) -> ComponentParts<CollatableSeriesGrid> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/ui/components/collatable_media_grid/mod.rs
Normal file
5
src/ui/components/collatable_media_grid/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod collatable_film_grid;
|
||||||
|
mod collatable_series_grid;
|
||||||
|
|
||||||
|
pub use collatable_film_grid::*;
|
||||||
|
pub use collatable_series_grid::*;
|
||||||
140
src/ui/components/media_collation_menu/film_collation_menu.rs
Normal file
140
src/ui/components/media_collation_menu/film_collation_menu.rs
Normal file
|
|
@ -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<FilmCollationMenu>,
|
||||||
|
) -> ComponentParts<FilmCollationMenu> {
|
||||||
|
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<FilmCollationMenu>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/ui/components/media_collation_menu/mod.rs
Normal file
5
src/ui/components/media_collation_menu/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod film_collation_menu;
|
||||||
|
mod series_collation_menu;
|
||||||
|
|
||||||
|
pub use film_collation_menu::*;
|
||||||
|
pub use series_collation_menu::*;
|
||||||
142
src/ui/components/media_collation_menu/series_collation_menu.rs
Normal file
142
src/ui/components/media_collation_menu/series_collation_menu.rs
Normal file
|
|
@ -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<SeriesCollationMenu>,
|
||||||
|
) -> ComponentParts<SeriesCollationMenu> {
|
||||||
|
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<SeriesCollationMenu>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/ui/components/media_details/film_details.rs
Normal file
41
src/ui/components/media_details/film_details.rs
Normal file
|
|
@ -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<FilmDetails>,
|
||||||
|
) -> ComponentParts<FilmDetails> {
|
||||||
|
let model = FilmDetails { film_overview };
|
||||||
|
let widgets = view_output!();
|
||||||
|
ComponentParts { model, widgets }
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/ui/components/media_details/mod.rs
Normal file
5
src/ui/components/media_details/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod film_details;
|
||||||
|
mod series_details;
|
||||||
|
|
||||||
|
pub use film_details::*;
|
||||||
|
pub use series_details::*;
|
||||||
41
src/ui/components/media_details/series_details.rs
Normal file
41
src/ui/components/media_details/series_details.rs
Normal file
|
|
@ -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<SeriesDetails>,
|
||||||
|
) -> ComponentParts<SeriesDetails> {
|
||||||
|
let model = SeriesDetails { series_overview };
|
||||||
|
let widgets = view_output!();
|
||||||
|
ComponentParts { model, widgets }
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/ui/components/media_grid/film_grid.rs
Normal file
123
src/ui/components/media_grid/film_grid.rs
Normal file
|
|
@ -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<FilmGridItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FilmGridInput {
|
||||||
|
SortBy(FilmsSorting, SortingDirection),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FilmGridCmdOutput {
|
||||||
|
OverviewDataReady(Vec<FilmOverview>),
|
||||||
|
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<FilmGrid>,
|
||||||
|
) -> ComponentParts<FilmGrid> {
|
||||||
|
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<FilmGrid>,
|
||||||
|
_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<FilmGrid>,
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/ui/components/media_grid/mod.rs
Normal file
5
src/ui/components/media_grid/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod film_grid;
|
||||||
|
mod series_grid;
|
||||||
|
|
||||||
|
pub use film_grid::*;
|
||||||
|
pub use series_grid::*;
|
||||||
123
src/ui/components/media_grid/series_grid.rs
Normal file
123
src/ui/components/media_grid/series_grid.rs
Normal file
|
|
@ -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<SeriesGridItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SeriesGridInput {
|
||||||
|
SortBy(SeriesSorting, SortingDirection),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SeriesGridCmdOutput {
|
||||||
|
OverviewDataReady(Vec<SeriesOverview>),
|
||||||
|
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<SeriesGrid>,
|
||||||
|
) -> ComponentParts<SeriesGrid> {
|
||||||
|
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<SeriesGrid>,
|
||||||
|
_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<SeriesGrid>,
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/ui/components/media_grid_item/film_grid_item.rs
Normal file
178
src/ui/components/media_grid_item/film_grid_item.rs
Normal file
|
|
@ -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<Texture>,
|
||||||
|
details: Option<Controller<FilmDetails>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
) -> 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<FilmGridItem>) {
|
||||||
|
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<FilmGridItem>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/ui/components/media_grid_item/mod.rs
Normal file
5
src/ui/components/media_grid_item/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod film_grid_item;
|
||||||
|
mod series_grid_item;
|
||||||
|
|
||||||
|
pub use film_grid_item::*;
|
||||||
|
pub use series_grid_item::*;
|
||||||
172
src/ui/components/media_grid_item/series_grid_item.rs
Normal file
172
src/ui/components/media_grid_item/series_grid_item.rs
Normal file
|
|
@ -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<Texture>,
|
||||||
|
details: Option<Controller<SeriesDetails>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
) -> 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<SeriesGridItem>) {
|
||||||
|
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<SeriesGridItem>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/ui/components/media_type_switcher.rs
Normal file
55
src/ui/components/media_type_switcher.rs
Normal file
|
|
@ -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<CollatableFilmGrid>,
|
||||||
|
series_grid: Controller<CollatableSeriesGrid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<MediaTypeSwitcher>,
|
||||||
|
) -> ComponentParts<MediaTypeSwitcher> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ui/components/mod.rs
Normal file
8
src/ui/components/mod.rs
Normal file
|
|
@ -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;
|
||||||
18
src/ui/components/sorting_popover_entry.rs
Normal file
18
src/ui/components/sorting_popover_entry.rs
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
58
src/ui/factory_sorting.rs
Normal file
58
src/ui/factory_sorting.rs
Normal file
|
|
@ -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<F, I>(factory_vec: &mut FactoryVecDequeGuard<I>, compare: F)
|
||||||
|
where
|
||||||
|
F: Fn(&I, &I) -> Ordering,
|
||||||
|
I: FactoryComponent<Index = DynamicIndex>,
|
||||||
|
{
|
||||||
|
_sort_factory_vec(factory_vec, 0, factory_vec.len(), &compare);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _sort_factory_vec<F, I>(
|
||||||
|
factory_vec: &mut FactoryVecDequeGuard<I>,
|
||||||
|
start_index: usize,
|
||||||
|
end_index: usize,
|
||||||
|
compare: &F,
|
||||||
|
) where
|
||||||
|
F: Fn(&I, &I) -> Ordering,
|
||||||
|
I: FactoryComponent<Index = DynamicIndex>,
|
||||||
|
{
|
||||||
|
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<F, I>(
|
||||||
|
factory_vec: &mut FactoryVecDequeGuard<I>,
|
||||||
|
start_index: usize,
|
||||||
|
end_index: usize,
|
||||||
|
compare: &F,
|
||||||
|
) -> usize
|
||||||
|
where
|
||||||
|
F: Fn(&I, &I) -> Ordering,
|
||||||
|
I: FactoryComponent<Index = DynamicIndex>,
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
167
src/ui/mod.rs
167
src/ui/mod.rs
|
|
@ -1,163 +1,6 @@
|
||||||
mod collatable_container;
|
mod components;
|
||||||
mod component;
|
mod factory_sorting;
|
||||||
mod utility;
|
mod sorting;
|
||||||
|
mod widget_extensions;
|
||||||
|
|
||||||
use std::process::Command;
|
pub use components::app::App;
|
||||||
|
|
||||||
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<FilmsAdapter>,
|
|
||||||
series_component: CollatableMediaContainer<SeriesAdapter>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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::<FilmsAdapter>::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::<Vec<_>>();
|
|
||||||
|
|
||||||
// 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::<SeriesAdapter>::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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
85
src/ui/sorting.rs
Normal file
85
src/ui/sorting.rs
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 IsA<Widget>>);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OptChildExt for gtk4::Box {
|
|
||||||
fn append_opt(&self, child: &Option<impl IsA<Widget>>) {
|
|
||||||
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<Widget>) -> 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};
|
|
||||||
69
src/ui/widget_extensions.rs
Normal file
69
src/ui/widget_extensions.rs
Normal file
|
|
@ -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<gtk4::Box> {
|
||||||
|
// Unlike BoxExt::append the child is not a reference, so that using it with
|
||||||
|
// Option::map is more ergonomic.
|
||||||
|
fn cond_append<W: IsA<Widget>>(&self, child: Option<W>) {
|
||||||
|
if let Some(child) = child {
|
||||||
|
self.append(&child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: IsA<gtk4::Box>> 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<Dialog> {
|
||||||
|
// 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<W: IsA<Widget>>(&self, parent: Option<W>) {
|
||||||
|
match parent {
|
||||||
|
Some(parent) => self.present(Some(&parent)),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<D: IsA<Dialog>> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
1
src/views/mod.rs
Normal file
1
src/views/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod overview;
|
||||||
18
src/views/overview.rs
Normal file
18
src/views/overview.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FilmOverview {
|
||||||
|
pub uuid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub original_name: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
// Format: YYYY[-MM[-DD]].
|
||||||
|
pub first_release_date: String,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue