diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4c0b122..9dbcf4e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -99,8 +99,8 @@ jobs: # new packager release should be adopted deliberately, not picked up by surprise. run: cargo install cargo-packager --locked --version 0.11.8 - # One echo package carrying both frontends: the desktop app plus the `spotify` TUI. The - # MSI also puts its install dir on PATH; the macOS bundle holds both in Contents/MacOS. + # The MSI also puts its install dir on PATH. The stub is built for the upgrade archive + # only; the installers never carry it. - name: Build and Package shell: bash env: @@ -110,9 +110,8 @@ jobs: run: | if [ -n "${{ matrix.formats }}" ]; then cargo packager --release -p echo-desktop --formats ${{ matrix.formats }} - else - cargo build --release -p echo-desktop -p spotify-tui fi + cargo build --release -p echo-desktop -p spotify-stub # A gpui_platform missing its wayland/x11 features still compiles — it just yields a # desktop binary that panics instead of opening a window, which no build step notices. @@ -122,10 +121,13 @@ jobs: if: runner.os == 'Linux' run: ldd target/release/echo-desktop | grep -q libxkbcommon - # The portable archive: what `spotify upgrade` and the desktop's update button pull on - # every platform, and what install.sh lays down on Linux. It carries themes/ because - # themes are loaded from the install directory — binaries alone would leave a release's - # theme changes behind — and the hicolor icons install.sh files into the menu entry. + # The portable archive: what the desktop's updater pulls on every platform, and what + # install.sh lays down on Linux. It carries themes/ because themes are loaded from the + # install directory — the binary alone would leave a release's theme changes behind — + # and the hicolor icons install.sh files into the menu entry. + # + # `spotify` is the stub from crates/spotify-stub: updaters in v0.6.7 and earlier refuse + # an archive missing a binary they found installed. Drop it once those are gone. - name: Build upgrade archive shell: bash run: | diff --git a/Cargo.lock b/Cargo.lock index 72468bc..78b4167 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf 0.13.1", + "phf", "serde", "zvariant", ] @@ -508,15 +508,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -652,16 +643,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - [[package]] name = "base64ct" version = "1.8.3" @@ -688,30 +669,15 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec 0.9.1", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.9.1" @@ -748,18 +714,6 @@ dependencies = [ "no_std_io2", ] -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "block" version = "0.1.6" @@ -837,12 +791,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - [[package]] name = "bytemuck" version = "1.25.0" @@ -940,15 +888,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -1192,20 +1131,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "compression-codecs" version = "0.4.38" @@ -1551,33 +1476,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix 1.1.4", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crunchy" version = "0.2.4" @@ -1594,26 +1492,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf 0.11.3", -] - -[[package]] -name = "ctor" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - [[package]] name = "ctor" version = "1.0.12" @@ -1624,12 +1502,6 @@ dependencies = [ "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "ctr" version = "0.9.2" @@ -1645,18 +1517,8 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] @@ -1673,37 +1535,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1746,12 +1584,6 @@ dependencies = [ "dbus", ] -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - [[package]] name = "der" version = "0.7.10" @@ -1787,7 +1619,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -1928,21 +1760,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - [[package]] name = "dunce" version = "1.0.5" @@ -2205,16 +2022,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set 0.5.3", - "regex", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -2276,18 +2083,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fixedbitset" version = "0.5.7" @@ -2470,12 +2265,6 @@ dependencies = [ "libc", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.32" @@ -2507,7 +2296,7 @@ version = "7.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" dependencies = [ - "fixedbitset 0.5.7", + "fixedbitset", "futures-core", "futures-lite", "pin-project", @@ -2935,7 +2724,7 @@ dependencies = [ "core-graphics 0.24.0", "core-text", "core-video", - "ctor 1.0.12", + "ctor", "derive_more", "embed-resource", "etagere", @@ -3066,7 +2855,7 @@ dependencies = [ "core-graphics 0.24.0", "core-text", "core-video", - "ctor 1.0.12", + "ctor", "derive_more", "dispatch2", "etagere", @@ -3735,16 +3524,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icy_sixel" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" -dependencies = [ - "quantette", - "thiserror 2.0.18", -] - [[package]] name = "id-arena" version = "2.3.0" @@ -3846,15 +3625,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inotify" version = "0.11.2" @@ -3885,19 +3655,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "interpolate_name" version = "0.2.4" @@ -4075,17 +3832,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - [[package]] name = "keyboard-types" version = "0.7.0" @@ -4164,12 +3910,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - [[package]] name = "lazy_static" version = "1.5.0" @@ -4518,15 +4258,6 @@ dependencies = [ "x11", ] -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4623,15 +4354,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -4704,16 +4426,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - [[package]] name = "mach2" version = "0.4.3" @@ -4781,7 +4493,7 @@ dependencies = [ "bindgen", "core-foundation 0.10.1", "core-video", - "ctor 1.0.12", + "ctor", "foreign-types 0.5.0", "metal", "objc", @@ -4802,12 +4514,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - [[package]] name = "memoffset" version = "0.9.1" @@ -4910,7 +4616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set", "bitflags 2.11.1", "cfg-if", "cfg_aliases", @@ -4981,19 +4687,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "no_std_io2" version = "0.9.4" @@ -5694,15 +5387,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "ordered-float" version = "5.3.0" @@ -5722,45 +5406,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "palette" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" -dependencies = [ - "bytemuck", - "libm", - "palette_derive", - "palette_math", -] - -[[package]] -name = "palette_derive" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "palette_math" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" -dependencies = [ - "libm", -] - [[package]] name = "pango" version = "0.18.3" @@ -5894,89 +5539,16 @@ dependencies = [ ] [[package]] -name = "pest" -version = "2.8.6" +name = "phf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", -] - [[package]] name = "phf_generator" version = "0.13.1" @@ -5984,20 +5556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -6006,22 +5565,13 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "phf_shared" version = "0.13.1" @@ -6183,7 +5733,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ - "atomic 0.5.3", + "atomic", "crossbeam-queue", "futures", "log", @@ -6443,26 +5993,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "quantette" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174" -dependencies = [ - "bitvec", - "bytemuck", - "image", - "libm", - "num-traits", - "ordered-float 5.3.0", - "palette", - "rand 0.9.4", - "rand_xoshiro", - "rayon", - "ref-cast", - "wide", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -6574,12 +6104,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.6" @@ -6649,15 +6173,6 @@ dependencies = [ "rand 0.9.4", ] -[[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" -dependencies = [ - "rand_core 0.9.5", -] - [[package]] name = "range-alloc" version = "0.1.5" @@ -6670,108 +6185,6 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.1", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools 0.14.0", - "kasuari", - "lru", - "strum", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-image" -version = "11.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad0eed12f8df9b9245aa1fc71a8cfcbba36ae3b6cb1e7896a01379027da2563" -dependencies = [ - "base64-simd", - "icy_sixel", - "image", - "rand 0.8.6", - "ratatui", - "rustix 0.38.44", - "self_cell", - "thiserror 1.0.69", - "windows 0.58.0", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools 0.14.0", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "rav1e" version = "0.8.1" @@ -7362,15 +6775,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safe_arch" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd" -dependencies = [ - "bytemuck", -] - [[package]] name = "same-file" version = "1.0.6" @@ -7700,27 +7104,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7930,20 +7313,8 @@ dependencies = [ ] [[package]] -name = "spotify-tui" +name = "spotify-stub" version = "0.1.0" -dependencies = [ - "anyhow", - "crossterm", - "ctor 0.4.3", - "echo-core", - "image", - "ratatui", - "ratatui-image", - "tokio", - "unicode-segmentation", - "unicode-width", -] [[package]] name = "stable_deref_trait" @@ -8311,7 +7682,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -8452,12 +7822,6 @@ dependencies = [ "objc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tar" version = "0.4.46" @@ -8517,69 +7881,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom 7.1.3", - "phf 0.11.3", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.1", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset 0.4.2", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float 4.6.0", - "pest", - "pest_derive", - "phf 0.11.3", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -9115,12 +8416,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "uds_windows" version = "1.2.1" @@ -9180,17 +8475,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools 0.14.0", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "unicode-vo" version = "0.1.0" @@ -9273,12 +8557,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "util_macros" version = "0.1.0" @@ -9295,7 +8573,6 @@ version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ - "atomic 0.6.1", "getrandom 0.4.2", "js-sys", "serde_core", @@ -9412,12 +8689,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "vswhom" version = "0.1.0" @@ -9438,15 +8709,6 @@ dependencies = [ "libc", ] -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "waker-fn" version = "1.2.0" @@ -9745,78 +9007,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float 4.6.0", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "wgpu" version = "29.0.4" @@ -9854,8 +9044,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set 0.9.1", - "bit-vec 0.9.1", + "bit-set", + "bit-vec", "bitflags 2.11.1", "bytemuck", "cfg_aliases", @@ -9926,7 +9116,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.9.1", + "bit-set", "bitflags 2.11.1", "block2 0.6.2", "bytemuck", @@ -9950,7 +9140,7 @@ dependencies = [ "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", "once_cell", - "ordered-float 5.3.0", + "ordered-float", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -10019,16 +9209,6 @@ dependencies = [ "winsafe", ] -[[package]] -name = "wide" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "winapi" version = "0.3.9" @@ -10777,15 +9957,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x11" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index c5b9cbc..39f06c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [workspace] resolver = "3" members = ["crates/*"] -# Bare `cargo run` / `cargo build` keep working on the TUI. -default-members = ["crates/spotify-tui"] +# Bare `cargo run` / `cargo build` target the desktop app. +default-members = ["crates/echo-desktop"] [workspace.package] version = "0.1.0" @@ -13,7 +13,6 @@ echo-core = { path = "crates/echo-core" } anyhow = "1.0.102" chrono = "0.4.44" -crossterm = "0.29.0" dirs = "6.0.0" flate2 = "1.1.9" futures-util = "0.3.32" @@ -29,8 +28,6 @@ librespot-playback = { git = "https://github.com/librespot-org/librespot", rev = lofty = "0.24.0" notify = "8.2.0" parking_lot = "0.12.5" -ratatui = "0.30.0" -ratatui-image = { version = "11.0.2", default-features = false, features = ["image-defaults", "crossterm"] } reqwest = { version = "0.13.4", features = ["json"] } rodio = "0.21.1" rspotify = { version = "0.16.1", features = ["cli"] } @@ -40,13 +37,11 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.10.9" souvlaki = "0.8.3" -# Release archives are .tar.gz on every platform, Windows included, so `spotify upgrade` -# has one extraction path instead of a second zip branch. +# Release archives are .tar.gz on every platform, Windows included, so the updater has one +# extraction path instead of a second zip branch. tar = "0.4.44" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } toml = "1.1.2" -unicode-segmentation = "1.13.2" -unicode-width = "0.2.2" urlencoding = "2.1.3" webbrowser = "1.2.1" diff --git a/README.md b/README.md index ffb0cf2..9a3da2a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 简体中文

-echo is a native desktop music player and Spotify client written in Rust. echo brings your entire Spotify library — liked songs, playlists, albums, and the artists you follow — plus your local music files into one fast, keyboard-friendly app, with full playback control, synced lyrics, and dynamic theming. A companion terminal client (`spotify`) ships in the same install for when you'd rather live in the terminal. +echo is a native desktop music player and Spotify client written in Rust. echo brings your entire Spotify library — liked songs, playlists, albums, and the artists you follow — plus your local music files into one fast, keyboard-friendly app, with full playback control, synced lyrics, and dynamic theming. ![echo desktop app](assets/echo-desktop.png) @@ -27,7 +27,6 @@ echo is a native desktop music player and Spotify client written in Rust. echo b - **Local music support**: Scan a local music folder, play local files, and create local playlists that can also reference Spotify tracks. - **Search**: Fast global search (`ctrl-k`) across the Spotify catalog and your scanned local tracks. - **Dynamic theming**: Ship-with themes plus live theme editing — see [Themes](#themes). -- **Terminal client**: The same install also puts a full-featured `spotify` TUI on your `PATH` — see [Terminal client (TUI)](#terminal-client-tui). ## Setup @@ -40,8 +39,6 @@ echo is a native desktop music player and Spotify client written in Rust. echo b ### Installation -One command, one install: the desktop app **and** the `spotify` terminal command. - **Linux and macOS** ```bash @@ -54,13 +51,13 @@ curl -fsSL https://github.com/and2049/echo/releases/latest/download/install.sh | irm https://github.com/and2049/echo/releases/latest/download/install.ps1 | iex ``` -Neither needs administrator rights. Both put `spotify` on your `PATH` — open a new terminal afterwards — and add the desktop app to your Start menu, Launchpad, or applications menu. +Neither needs administrator rights. Both add echo to your Start menu, Launchpad, or applications menu. | Platform | Where it lands | | --- | --- | | Windows | `%LOCALAPPDATA%\Programs\echo` (installed from the release MSI, x64) | -| macOS | `/Applications/echo.app`, with `spotify` linked into `~/.local/bin` (Apple Silicon) | -| Linux | `~/.local/share/echo`, with both commands linked into `~/.local/bin` (x86_64) | +| macOS | `/Applications/echo.app` (Apple Silicon) | +| Linux | `~/.local/share/echo`, with `echo-desktop` linked into `~/.local/bin` (x86_64) | To pin a version or remove echo: @@ -88,17 +85,9 @@ OpenGL, so `libvulkan1` plus your GPU's driver is worth having but is not requir #### Updating -Desktop releases install automatically in the background; turn this off in Settings or with `:autoupdate off`. - -After the first install, echo updates itself — no reinstall, no administrator rights: +After the first install, echo updates itself — no reinstall, no administrator rights. New releases install automatically in the background; turn this off in Settings or with `:autoupdate off`, and check by hand from **Settings → Updates → Check for updates**. An update swaps the app and its bundled themes in place and asks you to restart. -```bash -spotify upgrade # upgrade to the latest release -spotify upgrade --check # only report whether one is available -spotify upgrade 0.4.6 # move to a specific version -``` - -The desktop app does the same from **Settings → Updates → Check for updates**. Both swap the binaries and bundled themes in place and ask you to restart. +> Earlier releases also shipped a `spotify` terminal client. It has been removed; updating from one of those releases deletes it along with its `~/.local/bin` link. ### Build from Source @@ -115,101 +104,71 @@ sudo apt-get install -y --no-install-recommends \ ```bash git clone https://github.com/and2049/echo.git cd echo -cargo build --release -p echo-desktop -p spotify-tui +cargo run --release ``` -Run the desktop app or the terminal client: - -```bash -./target/release/echo-desktop # desktop app -./target/release/spotify # terminal client -``` - -A bare `cargo build --release` (or `cargo run`) builds only the `spotify` terminal client — the workspace's default member — so name `-p echo-desktop` to build the desktop app. - -> The terminal command is `spotify` — the previous name collided with the shell builtin `echo`. Configuration and caches still live in `~/.config/echo/`, so existing setups keep working after the rename. On Windows, if the official Spotify client's directory happens to be on your `PATH`, make sure `%USERPROFILE%\.cargo\bin` (or wherever you installed this binary) comes first. +The binary lands at `./target/release/echo-desktop`. On first run, echo will prompt you to enter your `Client ID` and `Client Secret`, then open your browser to authenticate with Spotify. -## Desktop App - -The desktop app works with the mouse — click a playlist, artist, or track to open it, use the controls in the now-playing bar, and drag tracks to reorder your own playlists. It's also fully keyboard-driven. Press `?` at any time for the in-app shortcut overlay, `ctrl-,` for settings, and `t` to switch themes. The keybindings mirror the terminal client below. - -**Navigation**: `j` / `k` / `↓` / `↑` to move, `gg` / `G` to jump to first / last, `ctrl-b` / `ctrl-f` to page, `ctrl-u` / `ctrl-d` for half a page, `enter` / `z` to open, `h` / `esc` to go back, `←` / `→` to move focus between panes, `tab` to switch tabs, `gc` to jump to the playing track. - -**Playback**: `space` play/pause, `[` / `]` (or `ctrl-←` / `ctrl-→`) previous / next, `,` / `.` seek, `-` / `=` volume, `shift-M` mute, `s` / `r` shuffle / repeat, `shift-D` device menu, `shift-L` full-screen lyrics, `ctrl-shift-L` lyrics in the player bar, `shift-F` immersive view. +## Usage -**Library**: `l` like/unlike, `a` add to playlist, `shift-A` track actions, `q` / `shift-Q` queue, `shift-J` / `shift-K` move a track within your own playlist, `dd` delete, `v` select a range, `m` pin, `c` / `e` create / rename. - -**Finding things**: `ctrl-k` global search, `/` filter the current list, `n` / `shift-N` next / previous match, `:` command bar, `t` themes, `?` help, `ctrl-q` quit. +echo works with the mouse — click a playlist, artist, or track to open it, use the controls in the now-playing bar, and drag tracks to reorder your own playlists. It's also fully keyboard-driven. Press `?` at any time for the in-app shortcut overlay, `ctrl-,` for settings, and `t` to switch themes. Closing the window keeps echo playing: on Windows and Linux it hides to a tray icon (click it to bring the window back, or pick Quit), on macOS it stays in the Dock. Turn that off with `:tray off` or Settings → Window; `ctrl-q` always quits. Launching echo again brings the running one forward instead of starting a second copy. -The `:` command bar accepts the same commands as the terminal client — see [Commands](#commands). - -## Terminal client (TUI) - -The same install also ships `spotify`, a full-featured terminal client with native in-terminal image rendering. It shares echo's cache, config, playback engine, and commands with the desktop app. - -![echo terminal client](assets/echo-tui.png) - -- **Terminal image support**: Renders high-quality album art and playlist covers directly in your terminal (supports Kitty, Sixel, and half-block fallbacks). -- **Everything the desktop app does**: the same library, search, local music, playback, and `:` commands, driven entirely from the keyboard. - -echo is heavily keyboard-driven. - -### Global Navigation -- `j` / `k` or `Down` / `Up`: Move down / up +### Navigation +- `j` / `k` or `↓` / `↑`: Move down / up - `gg` / `G`: Jump to the first / last item -- `Ctrl-b` / `Ctrl-f` or `Page Up` / `Page Down`: Move one page -- `Ctrl-u` / `Ctrl-d`: Move half a page -- `Ctrl-l`: Clear and fully redraw the TUI -- `gc`: Jump to the currently playing track or its available context -- `Enter` or `z`: Select item / Open playlist / Play track -- `h` / `q` / `Esc` / `Backspace`: Go back / Close modal / Clear search -- `Tab`: Switch tabs (e.g., Playlists ↔ Albums, Search Tracks ↔ Search Albums, artist discography All ↔ Albums ↔ Singles & EPs ↔ Appears On) -- `:`: Enter Command Mode -- `/`: Search within tracklist -- `f`: Global search -- `n` / `N`: Jump to next / previous search match within a list - -### Playback Controls -- `Space`: Play / Pause -- `]` / `>`: Next Track -- `[` / `<`: Previous Track -- `,` / `.`: Seek backward / forward by 5 seconds -- `0`: Seek to the start of the current track -- `M` (Shift + m): Mute / restore the previous volume -- `s`: Toggle Shuffle -- `r`: Toggle Repeat Mode (Off → Track → Context) -- `=` / `-`: Volume Up / Down (by 1%) -- `+` / `_`: Volume Up / Down (by 5%) -- `D` (Shift + d): Open Device Selection menu -- `L` (Shift + l): Toggle synced lyrics (a panel docked on the right in the desktop app, a full-screen modal in the terminal) -- `Ctrl + Shift + L`: Toggle condensed Synced Lyrics view - -### Track & Library Actions -- `l`: Like / Unlike the selected track -- `A` (Shift + a): Open action menu for hovered track (or currently playing if not focused in track page) -- `p`: paste a cut playlist into a folder -- `a`: Add selected track to playlist / Add selected album to library -- `q`: Add currently hovered track to Queue -- `Q` (Shift + q): Open Queue view (the desktop app's playback-bar queue button docks the queue on the right instead) -- `m`: Pin / Unpin a playlist -- `T` (Shift + t): Toggle library thumbnails (cover art next to playlist / album names) -- `c`: Quick shortcut to create a new playlist -- `e`: Quick shortcut to rename a playlist or folder -- `v`: Enter Visual mode for multi-selection -- `d` (double press): Delete playlist/folder, or remove a track from your custom playlist -- `x`: Cut playlist (to move into a folder) -- `J` / `K` (Shift + j / k, desktop): Move the selected track down / up within one of your own playlists (drag-and-drop works too); requires the original sort order -- `R` (Shift + r): Force refresh -- `F` (Shift + f, terminal): Follow / unfollow the artist whose page is open (the desktop app has a Follow button in the header) +- `ctrl-b` / `ctrl-f` or `Page Up` / `Page Down`: Move one page +- `ctrl-u` / `ctrl-d`: Move half a page +- `gc`: Jump to the currently playing track or its context +- `enter` or `z`: Open the selected item / play the selected track +- `h` / `esc`: Go back / close a panel +- `←` / `→`: Move focus between the sidebar and the main pane; `backspace` also returns to the sidebar +- `alt-←` / `alt-→`: History back / forward +- `tab`: Switch tabs (e.g. search results, artist discography) +- `ctrl-h` (`ctrl-shift-h` on macOS): Home +- `ctrl-\`: Show / hide the sidebar + +### Playback +- `space`: Play / pause +- `]` / `[` (or `ctrl-→` / `ctrl-←`): Next / previous track +- `.` / `,` (or `shift-→` / `shift-←`): Seek forward / backward 5 seconds +- `0`: Seek to the start of the track +- `=` / `-`: Volume up / down 1%; `+` / `_` for 5% +- `shift-M`: Mute / restore the previous volume +- `s`: Toggle shuffle +- `r`: Cycle repeat (off → track → context) +- `shift-D`: Device menu +- `shift-L`: Synced lyrics panel +- `ctrl-shift-L`: Condensed lyrics in the player bar +- `shift-F`: Immersive view + +### Library +- `l`: Like / unlike the selected track +- `a`: Add the selected track to a playlist, or the selected album to your library +- `shift-A`: Action menu for the selected (or currently playing) track +- `q`: Add the selected track to the queue +- `shift-Q`: Open the queue +- `m`: Pin / unpin a playlist +- `c` / `e`: Create / rename a playlist or folder +- `v`: Visual mode for selecting a range +- `dd`: Delete a playlist or folder, or remove a track from your own playlist +- `shift-J` / `shift-K`: Move the selected track down / up within your own playlist (drag-and-drop works too); requires the original sort order +- `shift-R`: Force refresh + +### Finding things +- `ctrl-k`: Global search +- `f`: Search from the command bar +- `/`: Filter the current list +- `n` / `shift-N`: Next / previous match +- `:`: Command bar — see [Commands](#commands) The track action menu adapts to the source. Spotify tracks support link copying, liking, and album library actions. Local tracks support copying their absolute path and revealing the file in the platform file manager. Both sources retain album/artist navigation, playlist insertion, and queue actions where applicable. ## Commands -While in Command Mode (`:`), you can use the following: +The `:` command bar accepts the following: - `:search `: Search for tracks or albums. - `:newplaylist `: Create a new playlist. - `:newlocalplaylist `: Create a local playlist stored on this machine. @@ -225,41 +184,28 @@ While in Command Mode (`:`), you can use the following: - `:mute`: Mute playback or restore the previous volume. - `:open [spotify-url-or-uri]`: Open a Spotify track, album, artist, or playlist. With no argument, read it from the clipboard. - `:relative `: Configure Vim-style relative line numbers in track lists. -- `:redraw`: Clear and fully redraw the TUI after unexpected terminal output. - `:theme `: Switch application theme. - `:lang `: Switch language. - `:album`: Jump to the album of the currently selected track. - `:queue`: Open the Queue view. - `:clearqueue`: Clear the manually queued tracks (only while playing on this device). +- `:clearhistory`: Forget the local play history. +- `:range `: Time range for Top Tracks and Top Artists. +- `:spotifylogin`: Re-authenticate with Spotify. - `:vis`: Toggle the audio visualizer. - `:visbins `: Set the number of audio visualizer frequency bins (5-32). - `:pixelate `: Enable retro 8-bit aesthetic on album covers. Set to 0 to disable, or e.g., 16 for a pixelated look. -- `:backdrop `: Pick the moving picture behind the desktop app's immersive view (also in Settings). -- `:tray [on|off]`: Whether the desktop app's close button hides it to the tray instead of quitting (also in Settings). -- `:thumbs [on|off]`: Toggle cover-art thumbnails in the library sidebar. Covers are cached in `~/.config/echo/thumbs/` so they load instantly on later launches. +- `:backdrop `: Pick the moving picture behind the immersive view (also in Settings). +- `:tray [on|off]`: Whether the close button hides echo to the tray instead of quitting (also in Settings). +- `:autoupdate [on|off]`: Install new releases automatically (also in Settings). - `:index `: Set track index base (1-indexed vs 0-indexed). - `:quit`, `:q`, `:qa`, `:wq`: Exit the application. -## Custom Keybindings - -Add a `keybindings` table under `[library]` in `~/.config/echo/config.toml` to override or add semantic mappings. Single keys, modifier keys such as `ctrl-f`, and two-key sequences are supported. Unmapped keys keep echo's defaults. - -```toml -[library.keybindings] -"s d" = "sort_duration" -"s a" = "sort_artist" -"ctrl-j" = "half_page_down" -"ctrl-k" = "half_page_up" -";" = "seek_forward" -``` - -Available actions are `first`, `last`, `page_up`, `page_down`, `half_page_up`, `half_page_down`, `current_context`, `play_pause`, `next`, `previous`, `shuffle`, `repeat`, `seek_backward`, `seek_forward`, `seek_start`, `mute`, `sort_original`, `sort_title`, `sort_artist`, `sort_album`, `sort_duration`, `sort_added`, `reverse_tracks`, `redraw`, and `toggle_thumbnails`. - Track sorting and navigation operate on already-loaded data. They do not issue Spotify requests. Navigation history retains up to 20 in-memory views so returning to a previous track list normally does not refetch it. ## Themes -Themes live in `themes/*.toml` as a flat list: nine base colors followed by the twelve derived colors the desktop app paints, every one explicit with a comment saying what it drives. Edit values freely, or change base colors and run `python themes/generate_desktop.py` to recompute the derived ones. Derived keys are optional — a missing key is computed with the formula named in its comment, and a `[desktop]` table is also accepted for overrides. To iterate visually, `python tools/theme-preview/serve.py` opens a live mock of the desktop window in the browser that repaints on every save — no rebuild needed. Colors can be edited in either direction: change the toml in your editor, or click any color in the preview's legend to adjust it with a picker that writes straight back to the file (its "recompute derived" button re-runs the generator for the current theme). +Themes live in `themes/*.toml` as a flat list: nine base colors followed by the twelve derived colors the app paints, every one explicit with a comment saying what it drives. Edit values freely, or change base colors and run `python themes/generate_desktop.py` to recompute the derived ones. Derived keys are optional — a missing key is computed with the formula named in its comment, and a `[desktop]` table is also accepted for overrides. To iterate visually, `python tools/theme-preview/serve.py` opens a live mock of the desktop window in the browser that repaints on every save — no rebuild needed. Colors can be edited in either direction: change the toml in your editor, or click any color in the preview's legend to adjust it with a picker that writes straight back to the file (its "recompute derived" button re-runs the generator for the current theme). ## Audio Quality @@ -293,8 +239,6 @@ Local playlists are stored locally and are not Spotify playlists. They can conta Embedded artwork is used when available. If a track has no embedded artwork, echo looks for folder artwork such as `cover.jpg`, `folder.jpg`, or `front.png`. ## Troubleshooting -- **Theme color rendering issues (Windows)**: Disable "Adjust indistinguishable text" in the Appearance settings of the Defaults profile. -- **Images not rendering**: Cover art is drawn with half-block cells and needs nothing from the terminal beyond truecolor support, which every modern terminal has. - **Cache desync**: Likes from other devices show up on startup or when you open Liked Songs (checked at most every 15 minutes). Songs unliked elsewhere are caught at the same point and trigger a background re-read of the library; a rate-limited re-read picks up where it stopped. Deleting `~/.config/echo/liked_songs.json` while echo is closed forces a full re-read. - **Local file missing**: If a file was deleted or moved after scanning, run `:rescanlocal` to refresh the local library. - **Audio sounds mono or muffled (Bluetooth headsets)**: Windows exposes a Bluetooth headset as two output devices — a stereo "Headphones" (A2DP) endpoint, and a mono "Hands-Free" (HFP) endpoint capped at 16 kHz. Windows switches to Hands-Free whenever an application opens the microphone. Check `echo-debug-audio-spotify.log`: if it reports `channels=1`, quit whatever is holding the mic and select the stereo endpoint as your default output device. diff --git a/README.zh.md b/README.zh.md index a4ebf32..921d9e6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -10,7 +10,7 @@ 简体中文

-echo 是一款用 Rust 编写的原生桌面音乐播放器和 Spotify 客户端。echo 将您的整个 Spotify 库——喜欢的歌曲、播放列表、专辑以及关注的艺术家——加上本地音乐文件汇集到一个快速、键盘友好的应用中,提供完整的播放控制、同步歌词和动态主题。同一安装包还附带一个终端客户端(`spotify`),在您想待在终端里时随时可用。 +echo 是一款用 Rust 编写的原生桌面音乐播放器和 Spotify 客户端。echo 将您的整个 Spotify 库——喜欢的歌曲、播放列表、专辑以及关注的艺术家——加上本地音乐文件汇集到一个快速、键盘友好的应用中,提供完整的播放控制、同步歌词和动态主题。 ![echo 桌面应用](assets/echo-desktop.png) @@ -26,7 +26,6 @@ echo 是一款用 Rust 编写的原生桌面音乐播放器和 Spotify 客户端 - **本地音乐支持**:扫描本地音乐文件夹,播放本地文件,创建也可引用 Spotify 曲目的本地播放列表。 - **搜索**:快速全局搜索(`ctrl-k`),覆盖 Spotify 目录和已扫描的本地曲目。 - **动态主题**:内置多套主题,并支持实时主题编辑——参见[主题](#主题)。 -- **终端客户端**:同一安装也会将功能完备的 `spotify` TUI 加入您的 `PATH`——参见[终端客户端 (TUI)](#终端客户端-tui)。 ## 设置 @@ -39,8 +38,6 @@ echo 是一款用 Rust 编写的原生桌面音乐播放器和 Spotify 客户端 ### 安装 -一条命令即可完成安装:桌面应用**与** `spotify` 终端命令。 - **Linux 与 macOS** ```bash @@ -53,13 +50,13 @@ curl -fsSL https://github.com/and2049/echo/releases/latest/download/install.sh | irm https://github.com/and2049/echo/releases/latest/download/install.ps1 | iex ``` -两者均无需管理员权限,都会将 `spotify` 加入 `PATH`(安装后请打开新终端),并把桌面应用添加到开始菜单、启动台或应用程序菜单中。 +两者均无需管理员权限,都会把 echo 添加到开始菜单、启动台或应用程序菜单中。 | 平台 | 安装位置 | | --- | --- | | Windows | `%LOCALAPPDATA%\Programs\echo`(通过发布的 MSI 安装,x64) | -| macOS | `/Applications/echo.app`,并将 `spotify` 链接到 `~/.local/bin`(Apple Silicon) | -| Linux | `~/.local/share/echo`,并将两个命令链接到 `~/.local/bin`(x86_64) | +| macOS | `/Applications/echo.app`(Apple Silicon) | +| Linux | `~/.local/share/echo`,并将 `echo-desktop` 链接到 `~/.local/bin`(x86_64) | 指定版本或卸载: @@ -87,15 +84,9 @@ sudo apt-get install libasound2 libdbus-1-3 libssl3 \ #### 更新 -首次安装之后,echo 可自我更新——无需重装,也无需管理员权限: - -```bash -spotify upgrade # 更新到最新版本 -spotify upgrade --check # 仅检查是否有可用更新 -spotify upgrade 0.4.6 # 更新到指定版本 -``` +首次安装之后,echo 可自我更新——无需重装,也无需管理员权限。新版本会在后台自动安装;可在设置中或通过 `:autoupdate off` 关闭,也可在**设置 → 更新 → 检查更新**手动检查。更新会就地替换应用与内置主题,并提示重启。 -桌面应用也可通过**设置 → 更新 → 检查更新**完成同样操作。两者都会就地替换二进制文件与内置主题,并提示重启。 +> 早期版本还附带一个 `spotify` 终端客户端,现已移除;从这些版本更新时会一并删除它及其在 `~/.local/bin` 中的链接。 ### 从源码构建 @@ -112,101 +103,72 @@ sudo apt-get install -y --no-install-recommends \ ```bash git clone https://github.com/and2049/echo.git cd echo -cargo build --release -p echo-desktop -p spotify-tui +cargo run --release ``` -运行桌面应用或终端客户端: - -```bash -./target/release/echo-desktop # 桌面应用 -./target/release/spotify # 终端客户端 -``` - -裸的 `cargo build --release`(或 `cargo run`)只构建 `spotify` 终端客户端——它是工作区的默认成员——因此要构建桌面应用请额外指定 `-p echo-desktop`。 - -> 终端命令名为 `spotify`——之前的名字与 shell 内建命令 `echo` 冲突。配置和缓存仍位于 `~/.config/echo/`,因此现有环境在重命名后可继续使用。在 Windows 上,如果官方 Spotify 客户端的目录恰好在您的 `PATH` 中,请确保 `%USERPROFILE%\.cargo\bin`(或您安装此二进制文件的位置)排在前面。 +生成的二进制文件位于 `./target/release/echo-desktop`。 首次运行时,echo 会提示您输入 `Client ID` 和 `Client Secret`,然后打开浏览器以通过 Spotify 进行身份验证。 -## 桌面应用 - -桌面应用支持鼠标操作——点击播放列表、艺术家或曲目即可打开,使用正在播放栏中的控件,还可拖动曲目来重排自己的播放列表。它也完全可以用键盘驱动。任何时候按 `?` 可查看应用内快捷键面板,按 `ctrl-,` 打开设置,按 `t` 切换主题。快捷键与下方的终端客户端一致。 - -**导航**:`j` / `k` / `↓` / `↑` 移动,`gg` / `G` 跳到开头 / 末尾,`ctrl-b` / `ctrl-f` 翻页,`ctrl-u` / `ctrl-d` 翻半页,`enter` / `z` 打开,`h` / `esc` 返回,`←` / `→` 在窗格间移动焦点,`tab` 切换标签页,`gc` 跳转到正在播放的曲目。 - -**播放**:`space` 播放/暂停,`[` / `]`(或 `ctrl-←` / `ctrl-→`)上一曲 / 下一曲,`,` / `.` 跳转进度,`-` / `=` 音量,`shift-M` 静音,`s` / `r` 随机播放 / 重复,`shift-D` 设备菜单,`shift-L` 全屏歌词,`ctrl-shift-L` 播放栏内歌词,`shift-F` 沉浸视图。 - -**库操作**:`l` 喜欢/取消喜欢,`a` 添加到播放列表,`shift-A` 曲目操作,`q` / `shift-Q` 队列,`shift-J` / `shift-K` 在自己的播放列表中移动曲目,`dd` 删除,`v` 选择范围,`m` 固定,`c` / `e` 创建 / 重命名。 +## 使用 -**查找内容**:`ctrl-k` 全局搜索,`/` 过滤当前列表,`n` / `shift-N` 下一个 / 上一个匹配项,`:` 命令栏,`t` 主题,`?` 帮助,`ctrl-q` 退出。 +echo 支持鼠标操作——点击播放列表、艺术家或曲目即可打开,使用正在播放栏中的控件,还可拖动曲目来重排自己的播放列表。它也完全可以用键盘驱动。任何时候按 `?` 可查看应用内快捷键面板,按 `ctrl-,` 打开设置,按 `t` 切换主题。 关闭窗口时 echo 会继续播放:在 Windows 和 Linux 上隐藏到托盘图标(点击图标恢复窗口,或选择退出),在 macOS 上保留在 Dock 中。可用 `:tray off` 或「设置 → 窗口」关闭此行为;`ctrl-q` 始终退出。再次启动 echo 会把正在运行的实例带到前台,而不是打开第二个。 -`:` 命令栏接受与终端客户端相同的命令——参见[命令](#命令)。 - -## 终端客户端 (TUI) - -同一安装还附带 `spotify`,一个功能完备的终端客户端,支持在终端中直接渲染图像。它与桌面应用共享 echo 的缓存、配置、播放引擎和命令。 - -![echo 终端客户端](assets/echo-tui.png) - -- **终端图像支持**:直接在终端中呈现高品质专辑封面和播放列表封面(支持 Kitty、Sixel 以及块状回退方案)。 -- **桌面应用的全部功能**:相同的资料库、搜索、本地音乐、播放控制和 `:` 命令,完全由键盘驱动。 - -echo 主要由键盘驱动。 - -### 全局导航 -- `j` / `k` 或 `Down` / `Up`:向下 / 向上移动 +### 导航 +- `j` / `k` 或 `↓` / `↑`:向下 / 向上移动 - `gg` / `G`:跳到第一个 / 最后一个项目 -- `Ctrl-b` / `Ctrl-f` 或 `Page Up` / `Page Down`:移动一页 -- `Ctrl-u` / `Ctrl-d`:移动半页 -- `Ctrl-l`:清除并完整重绘 TUI -- `gc`:跳转到正在播放的曲目或其可用上下文 -- `Enter` 或 `z`:选择项目 / 打开播放列表 / 播放曲目 -- `h` / `q` / `Esc` / `Backspace`:返回 / 关闭模态框 / 清除搜索 -- `Tab`:切换标签页(例如,播放列表 ↔ 专辑,搜索曲目 ↔ 搜索专辑) -- `:`:进入命令模式 -- `/`:在曲目列表中搜索 -- `f`:全局搜索 -- `n` / `N`:跳转到列表中的下一个 / 上一个搜索结果 - -### 播放控制 -- `Space`:播放 / 暂停 -- `]` / `>`:下一曲 -- `[` / `<`:上一曲 -- `,` / `.`:向后 / 向前快进 5 秒 -- `0`:跳到当前曲目开头 -- `M`(Shift + m):静音 / 恢复之前的音量 +- `ctrl-b` / `ctrl-f` 或 `Page Up` / `Page Down`:移动一页 +- `ctrl-u` / `ctrl-d`:移动半页 +- `gc`:跳转到正在播放的曲目或其上下文 +- `enter` 或 `z`:打开所选项目 / 播放所选曲目 +- `h` / `esc`:返回 / 关闭面板 +- `←` / `→`:在侧栏与主窗格之间移动焦点;`backspace` 也可回到侧栏 +- `alt-←` / `alt-→`:历史后退 / 前进 +- `tab`:切换标签页(例如搜索结果、艺术家作品) +- `ctrl-h`(macOS 上为 `ctrl-shift-h`):首页 +- `ctrl-\`:显示 / 隐藏侧栏 + +### 播放 +- `space`:播放 / 暂停 +- `]` / `[`(或 `ctrl-→` / `ctrl-←`):下一曲 / 上一曲 +- `.` / `,`(或 `shift-→` / `shift-←`):向前 / 向后跳转 5 秒 +- `0`:跳到曲目开头 +- `=` / `-`:音量增大 / 减小 1%;`+` / `_` 为 5% +- `shift-M`:静音 / 恢复之前的音量 - `s`:切换随机播放 - `r`:切换重复模式(关闭 → 单曲循环 → 列表循环) -- `=` / `-`:音量增大 / 减小(1%) -- `+` / `_`:音量增大 / 减小(5%) -- `D`(Shift + d):打开设备选择菜单 -- `L`(Shift + l):切换全屏同步歌词界面 -- `Ctrl + Shift + L`:切换精简同步歌词视图 +- `shift-D`:设备菜单 +- `shift-L`:同步歌词面板 +- `ctrl-shift-L`:播放栏内的精简歌词 +- `shift-F`:沉浸视图 -### 曲目与库操作 +### 库操作 - `l`:喜欢 / 取消喜欢所选曲目 -- `A`(Shift + a):打开悬停曲目的操作菜单(若未聚焦在曲目页面则打开当前播放曲目的菜单) -- `p`:将剪切的播放列表粘贴到文件夹中 -- `a`:将所选曲目添加到播放列表 / 将所选专辑添加到库中 -- `q`:将当前悬停的曲目添加到队列 -- `Q`(Shift + q):打开队列视图 +- `a`:将所选曲目添加到播放列表,或将所选专辑添加到库中 +- `shift-A`:所选(或正在播放)曲目的操作菜单 +- `q`:将所选曲目加入队列 +- `shift-Q`:打开队列 - `m`:固定 / 取消固定播放列表 -- `T`(Shift + t):切换资料库封面缩略图(在播放列表 / 专辑名称旁显示封面) -- `c`:快速创建新播放列表 -- `e`:快速重命名播放列表或文件夹 -- `v`:进入可视模式以进行多选 -- `d`(双击):删除播放列表/文件夹,或从自定义播放列表中移除曲目 -- `x`:剪切播放列表(以便移动到文件夹中) -- `J` / `K`(Shift + j / k,桌面端):在自己播放列表中将所选曲目下移 / 上移(也支持拖拽);需要原始排序方式 -- `R`(Shift + r):强制刷新 +- `c` / `e`:创建 / 重命名播放列表或文件夹 +- `v`:可视模式,用于选择范围 +- `dd`:删除播放列表或文件夹,或从自己的播放列表中移除曲目 +- `shift-J` / `shift-K`:在自己的播放列表中将所选曲目下移 / 上移(也支持拖拽);需要原始排序方式 +- `shift-R`:强制刷新 + +### 查找内容 +- `ctrl-k`:全局搜索 +- `f`:从命令栏搜索 +- `/`:过滤当前列表 +- `n` / `shift-N`:下一个 / 上一个匹配项 +- `:`:命令栏——参见[命令](#命令) 曲目操作菜单会根据来源自动调整。Spotify 曲目支持复制链接、喜欢和专辑入库等操作。本地曲目支持复制绝对路径和在系统文件管理器中显示文件。两种来源都保留专辑/艺术家导航、插入播放列表和加入队列等操作(如适用)。 ## 命令 -在命令模式(`:`)下,您可以使用以下命令: +`:` 命令栏接受以下命令: - `:search `:搜索曲目或专辑。 - `:newplaylist `:创建新播放列表。 - `:newlocalplaylist `:创建存储在本机的本地播放列表。 @@ -222,41 +184,28 @@ echo 主要由键盘驱动。 - `:mute`:静音播放或恢复之前的音量。 - `:open [spotify-url-or-uri]`:打开 Spotify 曲目、专辑、艺术家或播放列表。不带参数时从剪贴板读取。 - `:relative `:配置曲目列表中 Vim 风格的相对行号。 -- `:redraw`:在终端输出异常后清除并完整重绘 TUI。 - `:theme `:切换应用主题。 - `:lang `:切换语言。 - `:album`:跳转到当前选中曲目所属的专辑。 - `:queue`:打开队列视图。 - `:clearqueue`:清空手动加入队列的歌曲(仅在此设备上播放时可用)。 +- `:clearhistory`:清除本地播放记录。 +- `:range `:热门曲目与热门艺术家的时间范围。 +- `:spotifylogin`:重新登录 Spotify。 - `:vis`:切换音频可视化器。 - `:visbins `:设置音频可视化器频率条数量(5-32)。 - `:pixelate `:在专辑封面上启用复古 8 位像素风格。设置为 0 可禁用,或例如 16 以获得像素化效果。 -- `:backdrop `:选择桌面应用沉浸视图背后的动态画面(设置中也可选择)。 -- `:tray [on|off]`:桌面应用的关闭按钮是隐藏到托盘还是直接退出(设置中也可选择)。 -- `:thumbs [on|off]`:切换资料库侧栏中的封面缩略图。封面缓存于 `~/.config/echo/thumbs/`,之后启动时即时加载。 +- `:backdrop `:选择沉浸视图背后的动态画面(设置中也可选择)。 +- `:tray [on|off]`:关闭按钮是将 echo 隐藏到托盘还是直接退出(设置中也可选择)。 +- `:autoupdate [on|off]`:自动安装新版本(设置中也可选择)。 - `:index `:设置曲目索引基数(从 1 开始或从 0 开始)。 - `:quit`、`:q`、`:qa`、`:wq`:退出应用。 -## 自定义快捷键 - -在 `~/.config/echo/config.toml` 的 `[library]` 下添加 `keybindings` 表,可以覆盖或添加语义化映射。支持单键、修饰键组合(如 `ctrl-f`)以及两键序列。未映射的按键保留 echo 的默认行为。 - -```toml -[library.keybindings] -"s d" = "sort_duration" -"s a" = "sort_artist" -"ctrl-j" = "half_page_down" -"ctrl-k" = "half_page_up" -";" = "seek_forward" -``` - -可用动作为 `first`、`last`、`page_up`、`page_down`、`half_page_up`、`half_page_down`、`current_context`、`play_pause`、`next`、`previous`、`shuffle`、`repeat`、`seek_backward`、`seek_forward`、`seek_start`、`mute`、`sort_original`、`sort_title`、`sort_artist`、`sort_album`、`sort_duration`、`sort_added`、`reverse_tracks`、`redraw` 和 `toggle_thumbnails`。 - 曲目排序与导航仅作用于已加载的数据,不会向 Spotify 发送请求。导航历史最多保留 20 个内存中的视图,因此返回之前的曲目列表通常无需重新获取。 ## 主题 -主题位于 `themes/*.toml`,采用扁平列表格式:九个基础颜色之后是桌面应用使用的十二个派生颜色,每一项都有明确的注释说明其用途。您可以随意修改数值,或修改基础颜色后运行 `python themes/generate_desktop.py` 重新计算派生颜色。派生键是可选的——缺失的键会按其注释中命名的公式计算,同时也可以接受 `[desktop]` 表用于覆盖。要进行可视化迭代,可运行 `python tools/theme-preview/serve.py` 在浏览器中打开桌面窗口的实时模拟,每次保存都会重新着色——无需重新构建。颜色可以从任一方向编辑:在编辑器中修改 toml 文件,或在预览图例中点击任意颜色,用取色器调整并直接写回文件(其中的“重新计算派生色”按钮会为当前主题重新运行生成器)。 +主题位于 `themes/*.toml`,采用扁平列表格式:九个基础颜色之后是应用使用的十二个派生颜色,每一项都有明确的注释说明其用途。您可以随意修改数值,或修改基础颜色后运行 `python themes/generate_desktop.py` 重新计算派生颜色。派生键是可选的——缺失的键会按其注释中命名的公式计算,同时也可以接受 `[desktop]` 表用于覆盖。要进行可视化迭代,可运行 `python tools/theme-preview/serve.py` 在浏览器中打开桌面窗口的实时模拟,每次保存都会重新着色——无需重新构建。颜色可以从任一方向编辑:在编辑器中修改 toml 文件,或在预览图例中点击任意颜色,用取色器调整并直接写回文件(其中的“重新计算派生色”按钮会为当前主题重新运行生成器)。 ## 音频质量 @@ -291,8 +240,6 @@ device=Headphones (WH-1000XM5) channels=2 sample_rate=48000 format=F32 ## 故障排除 -- **主题颜色渲染问题 (Windows)**:在“默认值”配置文件的“外观”设置中,禁用“调整难以区分的文本”。 -- **图像无法渲染**:封面使用半格单元格绘制,只需要终端支持真彩色——所有现代终端都已具备。 - **缓存不同步**:其他设备上喜欢的歌曲会在启动时或打开喜欢的歌曲时出现(最多每 15 分钟检查一次)。在其他设备上取消喜欢的歌曲也会在同一时机被发现,并触发后台重读整个库;被限流的重读会从中断处继续。在 echo 关闭时删除 `~/.config/echo/liked_songs.json` 可强制完整重读。 - **本地文件丢失**:如果文件在扫描后被删除或移动,运行 `:rescanlocal` 以刷新本地库。 - **音频听起来单声道或沉闷(蓝牙耳机)**:Windows 将蓝牙耳机暴露为两个输出设备——立体声的“耳机”(A2DP)端点,以及限制在 16 kHz 的单声道“免提”(HFP)端点。每当有应用程序打开麦克风时,Windows 就会切换到免提模式。检查 `echo-debug-audio-spotify.log`:如果报告 `channels=1`,请退出占用麦克风的程序,并将立体声端点设为默认输出设备。 diff --git a/assets/echo-tui.png b/assets/echo-tui.png deleted file mode 100644 index 269c6a1..0000000 Binary files a/assets/echo-tui.png and /dev/null differ diff --git a/assets/install.ps1 b/assets/install.ps1 index 55b2f71..f9b57bf 100644 --- a/assets/install.ps1 +++ b/assets/install.ps1 @@ -1,12 +1,12 @@ #Requires -Version 5.0 <# .SYNOPSIS - Install echo — the desktop app plus the `spotify` terminal command — on Windows. + Install the echo desktop app on Windows. .DESCRIPTION - Downloads the release MSI and installs it silently for the current user. The MSI puts both - frontends in %LOCALAPPDATA%\Programs\echo and adds that directory to the user PATH, which is - what lets `spotify upgrade` replace them later without elevation or another installer run. + Downloads the release MSI and installs it silently for the current user. The MSI puts echo + in %LOCALAPPDATA%\Programs\echo and adds that directory to the user PATH; the per-user + location is what lets the app update itself later without elevation or another installer run. .EXAMPLE irm https://github.com/and2049/echo/releases/latest/download/install.ps1 | iex @@ -30,7 +30,7 @@ function Write-Failure { param([string]$Message) Write-Host $Message -Foreground if ($Help) { Write-Host @" -Install echo - the desktop app plus the 'spotify' terminal command. +Install the echo desktop app. Usage: install.ps1 [options] @@ -39,7 +39,7 @@ Options: -Uninstall Remove echo (leaves your settings in %APPDATA%\echo alone) -Help Show this message -After installing, upgrade with 'spotify upgrade' - no need to re-run this script. +After installing, echo updates itself from its Settings - no need to re-run this script. "@ exit 0 } @@ -97,7 +97,7 @@ if ($Version) { $installed = Get-EchoRegistration if ($installed -and $installed.DisplayVersion -eq $Version) { - Write-Info "echo $Version is already installed. Run 'spotify upgrade' to move to a newer release." + Write-Info "echo $Version is already installed. Update from echo's Settings to move to a newer release." exit 0 } @@ -105,9 +105,10 @@ if ($installed -and $installed.DisplayVersion -eq $Version) { # The MSI cannot rewrite files a running echo holds open; msiexec would otherwise schedule the # replacement for the next reboot and appear to have done nothing. +# `spotify` is the terminal client older installs carried; the upgrade removes it. $running = Get-Process -Name "spotify", "echo-desktop" -ErrorAction SilentlyContinue if ($running) { - Write-Failure "echo is running. Close it (and any 'spotify' terminals) and try again." + Write-Failure "echo is running. Close it (and any 'spotify' terminals left from older versions) and try again." exit 1 } @@ -159,9 +160,7 @@ if ($env:GITHUB_ACTIONS -eq "true" -and $env:GITHUB_PATH) { Write-Host "" Write-Info "echo $Version is installed." -Write-Info " App: $installDir\echo-desktop.exe (also in your Start menu)" -Write-Info " Terminal: $installDir\spotify.exe" +Write-Info " App: $installDir\echo-desktop.exe (also in your Start menu)" Write-Host "" -Write-Info "Open a new terminal, then run 'spotify' to start." -Write-Info "Later on, 'spotify upgrade' updates both the terminal client and the app." +Write-Info "Later on, echo updates itself from its Settings." Write-Host "" diff --git a/assets/install.sh b/assets/install.sh index 4932370..4051f04 100755 --- a/assets/install.sh +++ b/assets/install.sh @@ -1,17 +1,16 @@ #!/bin/sh -# install.sh — install echo (the desktop app and the `spotify` terminal command) on Linux and macOS. +# install.sh — install the echo desktop app on Linux and macOS. # # curl -fsSL https://github.com/and2049/echo/releases/latest/download/install.sh | sh # curl -fsSL .../install.sh | sh -s -- --version 0.4.6 # curl -fsSL .../install.sh | sh -s -- --uninstall # -# Both frontends come from one release artifact and land in one directory, which is what makes -# `spotify upgrade` able to replace them afterwards without reinstalling. See -# crates/echo-core/src/update.rs. +# The app lands in a directory the user owns, which is what lets its updater replace it +# afterwards without reinstalling. See crates/echo-core/src/update.rs. # -# Linux takes the portable `echo-linux-x64.tar.gz`; macOS takes the DMG, because a Mac desktop -# app has to be an .app bundle to appear in Launchpad. Either way `spotify` ends up in -# ~/.local/bin, on PATH. +# Linux takes the portable `echo-linux-x64.tar.gz` and links `echo-desktop` into ~/.local/bin; +# macOS takes the DMG, because a Mac desktop app has to be an .app bundle to appear in +# Launchpad. set -eu @@ -31,7 +30,7 @@ TMP_DIR="" usage() { cat < { - state.ui.action_menu_context = None; state.ui.operation_register = vec![ctx.track_id]; state.ui.playlist_add_modal_open = true; state.ui.playlist_add_filter.clear(); @@ -197,7 +196,7 @@ pub fn playlist_add_choices(state: &AppState) -> Vec { /// Confirm the add-to-playlist picker at `choice_index`. Tracks come from the operation /// register when set (the action-menu path), else from the current selection. Out-of-range -/// index is a no-op and leaves the modal open, matching the TUI's behavior. +/// index is a no-op and leaves the modal open. pub fn commit_playlist_add(state: &mut AppState, choice_index: usize) -> Option { let playlists = playlist_add_choices(state); let playlist = playlists.get(choice_index)?; diff --git a/crates/echo-core/src/app.rs b/crates/echo-core/src/app.rs index af5d132..35715e4 100644 --- a/crates/echo-core/src/app.rs +++ b/crates/echo-core/src/app.rs @@ -1,7 +1,7 @@ use crate::config::{LibraryConfig, Theme}; use crate::models::{ - ActionMenuContext, ArtistPageData, BrowseNode, DiscographyFilter, LocalLibrary, LocalPlaylists, - Playlist, SearchResults, Track, TrackListContext, TrackSource, + ArtistPageData, BrowseNode, DiscographyFilter, LocalLibrary, LocalPlaylists, Playlist, + SearchResults, Track, TrackListContext, TrackSource, }; use crate::theme::ResolvedTheme; use std::collections::HashMap; @@ -18,7 +18,6 @@ pub struct UIState { pub forward_history: Vec, pub active_library_tab: LibraryTab, pub active_search_tab: SearchTab, - pub active_browse_node: BrowseNode, /// Which list the [`ActiveView::ArtistList`] view renders. pub artist_list_source: ArtistListSource, /// A generated-tracklist browse node (Top Tracks / Recently Played) the user opened @@ -38,7 +37,6 @@ pub struct UIState { pub selected_device_index: usize, pub artist_page_album_index: usize, pub artist_discography_filter: DiscographyFilter, - pub selected_action_index: usize, pub selected_playlist_modal_index: usize, // Prompts / modals pub folder_delete_prompt: Option, @@ -51,12 +49,9 @@ pub struct UIState { pub playlist_edit: Option, pub duplicate_prompt: Option, pub device_modal_open: bool, - pub lyrics_modal_open: bool, /// The desktop's docked queue panel is showing, so the queue is kept fresh the way the /// full queue view is. pub queue_panel_open: bool, - pub action_menu_open: bool, - pub action_menu_context: Option, pub visual_selection_start: Option, pub picked_rows: std::collections::BTreeSet, pub pending_d_press: bool, @@ -72,17 +67,14 @@ pub struct UIState { pub status_message: Option, pub status_message_expiry: Option, pub audio_output_error: Option, - pub recent_queue_count: usize, // Theme / display pub themes: HashMap, pub active_theme: ResolvedTheme, - pub needs_terminal_clear: bool, pub vis_bins: usize, pub condensed_lyrics_enabled: bool, // Setup pub setup_client_id: String, pub setup_client_secret: String, - pub setup_focus_secret: bool, // Library config (mutable user settings) pub library_config: LibraryConfig, // Image rendering @@ -92,7 +84,6 @@ pub struct UIState { pub operation_register: Vec, pub track_sort: TrackSort, pub track_sort_ascending: bool, - pub pending_key_sequence: Option<(String, std::time::Instant)>, } impl UIState { @@ -112,7 +103,6 @@ impl UIState { forward_history: Vec::new(), active_library_tab: LibraryTab::Playlists, active_search_tab: SearchTab::Tracks, - active_browse_node: BrowseNode::TopTracks, artist_list_source: ArtistListSource::Followed, pending_browse_open: None, selected_playlist_index: 0, @@ -126,7 +116,6 @@ impl UIState { selected_device_index: 0, artist_page_album_index: 0, artist_discography_filter: DiscographyFilter::All, - selected_action_index: 0, selected_playlist_modal_index: 0, folder_delete_prompt: None, playlist_delete_prompt: None, @@ -138,10 +127,7 @@ impl UIState { playlist_edit: None, duplicate_prompt: None, device_modal_open: false, - lyrics_modal_open: false, queue_panel_open: false, - action_menu_open: false, - action_menu_context: None, visual_selection_start: None, picked_rows: std::collections::BTreeSet::new(), pending_d_press: false, @@ -155,22 +141,18 @@ impl UIState { status_message: None, status_message_expiry: None, audio_output_error: None, - recent_queue_count: 0, themes, active_theme: ResolvedTheme::from_theme(&active_theme_config), - needs_terminal_clear: false, vis_bins, condensed_lyrics_enabled, setup_client_id: String::new(), setup_client_secret: String::new(), - setup_focus_secret: false, library_config, active_library_header_image: None, thumbnails: crate::thumbnails::ThumbnailCache::default(), operation_register: vec![], track_sort: TrackSort::Original, track_sort_ascending: true, - pending_key_sequence: None, } } } @@ -520,13 +502,11 @@ pub enum AppMode { Visual, } -/// Sidebar tabs. `Browse` is TUI-only (the desktop shows browse links permanently); -/// `Artists` is desktop-only (the TUI reaches followed artists through Browse). +/// Library sidebar tabs. #[derive(PartialEq, Clone, Copy, Debug)] pub enum LibraryTab { Playlists, Albums, - Browse, Artists, } @@ -906,21 +886,6 @@ impl AppState { let _ = config.save(); } - pub fn set_library_thumbnails(&mut self, enabled: bool) { - self.ui.library_config.library_thumbnails = enabled; - self.save_library_config(); - // Half-block rendering needs nothing from the terminal beyond colour, so there is no - // longer a "no image support" case to warn about. - let message = if enabled { - "Library thumbnails on".to_string() - } else { - "Library thumbnails off".to_string() - }; - self.ui.status_message = Some(message); - self.ui.status_message_expiry = - Some(std::time::Instant::now() + std::time::Duration::from_secs(3)); - } - pub fn save_volume(&self) { let mut config = crate::config::AppConfig::load(); config.library.volume = self.playback.volume; @@ -1080,9 +1045,8 @@ impl AppState { } } -/// The number shown in a list's index column, shared by both frontends: `base` shifts the -/// count (0- or 1-based), `relative` shows vim-style distances from the selected row (which -/// keeps its absolute number). +/// The number shown in a list's index column: `base` shifts the count (0- or 1-based), while +/// `relative` shows vim-style distances from the selected row (which keeps its absolute number). pub fn displayed_track_number(index: usize, selected: usize, base: isize, relative: bool) -> isize { if relative && index != selected { index.abs_diff(selected) as isize diff --git a/crates/echo-core/src/apply_worker_event/artist.rs b/crates/echo-core/src/apply_worker_event/artist.rs index 85c5868..5af1789 100644 --- a/crates/echo-core/src/apply_worker_event/artist.rs +++ b/crates/echo-core/src/apply_worker_event/artist.rs @@ -63,9 +63,9 @@ pub fn handle_albums_loaded(state: &mut AppState, artist_id: String, albums: Vec } } -/// Rebuilds the visible album rows from the discography under the active filter and keeps -/// the cursor on a row. The TUI cursor is album-relative; the desktop's counts the Popular -/// rows first, so both clamp against the same combined length. +/// Rebuilds the visible album rows from the discography under the active filter and keeps the +/// cursor on a row. The desktop cursor counts the Popular rows first, so it clamps against the +/// combined length. pub fn apply_discography_filter(state: &mut AppState) { let Some(data) = state.data.artist_page_data.as_mut() else { return; diff --git a/crates/echo-core/src/apply_worker_event/data.rs b/crates/echo-core/src/apply_worker_event/data.rs index 74be333..d0525ed 100644 --- a/crates/echo-core/src/apply_worker_event/data.rs +++ b/crates/echo-core/src/apply_worker_event/data.rs @@ -165,7 +165,6 @@ pub fn reconcile_manual_queue(manual: &mut Vec, queue: &[Track]) { pub fn handle_tracks_queued(state: &mut AppState, track_ids: Vec) { let count = track_ids.len(); state.data.manual_queue.extend(track_ids); - state.ui.recent_queue_count += count; set_timed_status( state, i18n::t("messages.added_to_queue", &state.ui.library_config.language) @@ -440,7 +439,6 @@ mod tests { crate::i18n::init(); let mut state = AppState::new(); handle_tracks_queued(&mut state, ids(&["a", "b"])); - assert_eq!(state.ui.recent_queue_count, 2); handle_queue_loaded(&mut state, vec![sample_track("b"), sample_track("ctx")]); assert_eq!(state.data.manual_queue, ids(&["b"])); assert_eq!(state.data.queue.len(), 2); diff --git a/crates/echo-core/src/apply_worker_event/misc.rs b/crates/echo-core/src/apply_worker_event/misc.rs index 5b145ca..839c2c3 100644 --- a/crates/echo-core/src/apply_worker_event/misc.rs +++ b/crates/echo-core/src/apply_worker_event/misc.rs @@ -11,10 +11,6 @@ pub fn set_timed_status(state: &mut AppState, message: String, seconds: u64) { Some(std::time::Instant::now() + std::time::Duration::from_secs(seconds)); } -pub fn handle_force_redraw(state: &mut AppState) { - state.ui.needs_terminal_clear = true; -} - pub fn handle_force_context_refresh(state: &AppState, app_tx: &mpsc::UnboundedSender) { if state.ui.active_view == app::ActiveView::TrackList && let Some(context) = state.data.active_tracklist_context.clone() @@ -44,7 +40,6 @@ pub fn handle_audio_output_unavailable(state: &mut AppState, message: String) { state.ui.audio_output_error = Some(format!( "Audio output disconnected: {message}. Reconnect a device and press Space to resume." )); - state.ui.needs_terminal_clear = true; } pub fn handle_audio_output_recovered(state: &mut AppState) { @@ -68,7 +63,7 @@ mod tests { use super::*; #[test] - fn audio_output_error_is_persistent_and_forces_redraw() { + fn audio_output_error_is_persistent() { let mut state = AppState::new(); state.playback.is_playing = true; state.playback.progress_ms = 1_000; @@ -84,7 +79,6 @@ mod tests { state.playback.display_progress_ms(), state.playback.progress_ms ); - assert!(state.ui.needs_terminal_clear); assert!(state.ui.status_message_expiry.is_none()); assert!( state diff --git a/crates/echo-core/src/apply_worker_event/mod.rs b/crates/echo-core/src/apply_worker_event/mod.rs index 0757c5a..6d53dd1 100644 --- a/crates/echo-core/src/apply_worker_event/mod.rs +++ b/crates/echo-core/src/apply_worker_event/mod.rs @@ -68,7 +68,6 @@ pub fn apply_worker_event( WorkerEvent::ApiRequestFailed { label, message } => { misc::handle_api_request_failed(state, label, message) } - WorkerEvent::ForceRedraw => misc::handle_force_redraw(state), WorkerEvent::AudioOutputUnavailable { message } => { misc::handle_audio_output_unavailable(state, message) } diff --git a/crates/echo-core/src/artwork.rs b/crates/echo-core/src/artwork.rs index 73f6a2c..2b3aba8 100644 --- a/crates/echo-core/src/artwork.rs +++ b/crates/echo-core/src/artwork.rs @@ -1,75 +1,18 @@ -//! Decoded cover art, as raw pixels, and the half-block sampler that renders it. -//! -//! Replaces `ratatui_image::protocol::StatefulProtocol`. The old type was stateful and expensive -//! to re-render — every draw re-emitted terminal escapes — which is why the views cached an -//! off-screen `Buffer` beside each one and tracked a `header_image_dirty` flag. Raw pixels are -//! stateless and cheap to resample, so all of that bookkeeping is gone: the views just draw. -//! -//! The sampler lives here rather than in the TUI because it is pure pixel math with no terminal -//! dependency: each cell renders `▀` with the foreground carrying the upper sample and the -//! background the lower one, giving two vertical samples per cell — the same technique as -//! `ratatui-image`'s halfblocks backend, but with the cell box under our control. +//! Decoded cover art stored as raw pixels for frontend rendering. use std::sync::Arc; /// The longest edge a cover is kept at. /// -/// Spotify serves covers at 640px, and frontends with real pixel output (a terminal speaking -/// kitty/sixel, or the desktop app) want all of it — capping lower is a visible resolution loss -/// there. The half-block sampler throws away the excess at draw time either way, so the cap only -/// bounds what the caches hold: a handful of covers at ~1.6 MB each. +/// Spotify serves covers at 640px, and the desktop app wants all of it. The cap bounds what the +/// caches hold to a handful of covers at about 1.6 MB each. pub const MAX_COVER_EDGE: u32 = 640; /// The edge length a library thumbnail is kept at. /// -/// Thumbnails render into a 6x3 cell box — 12 samples across — and there can be hundreds cached. +/// There can be hundreds of thumbnails cached, so they use a smaller decoded size. pub const THUMB_EDGE: u32 = 64; -/// Upper half block: foreground paints the top sample, background the bottom. -const HALF_BLOCK: char = '▀'; - -/// Vertical samples resolved per character cell. -pub const SAMPLES_PER_ROW: u32 = 2; - -/// Cell aspect ratio (height / width) assumed when deriving a square box. -/// -/// Very nearly universal for terminal fonts. It must not require the terminal's cooperation — -/// artwork has to render in terminals that never answer a pixel-resolution query. -pub const CELL_ASPECT: u32 = 2; - -/// Cell box that displays a 1:1 image squarely, given a height in rows. -/// -/// ```text -/// square_box(3) -> (6, 3) thumbnails -/// square_box(5) -> (10, 5) list headers -/// square_box(10) -> (20, 10) now-playing cover -/// ``` -pub const fn square_box(rows: u32) -> (u32, u32) { - (rows * CELL_ASPECT, rows) -} - -/// An RGBA color sample. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Rgba { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -impl Rgba { - pub const TRANSPARENT: Self = Self { - r: 0, - g: 0, - b: 0, - a: 0, - }; - - pub const fn from_rgba(r: u8, g: u8, b: u8, a: u8) -> Self { - Self { r, g, b, a } - } -} - /// An RGBA8 image, tightly packed, `width * height * 4` bytes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Artwork { @@ -114,93 +57,11 @@ impl Artwork { pixels: rgba.into_raw(), }) } - - /// Bytes held, for reasoning about cache size. - pub fn byte_len(&self) -> usize { - self.pixels.len() - } - - fn sample_box(&self, x0: u32, y0: u32, x1: u32, y1: u32) -> Rgba { - // Area-average over the source region for this sample. Nearest-neighbour is visibly - // noisy when a 640px cover collapses to 20 samples. - let x1 = x1.max(x0 + 1).min(self.width); - let y1 = y1.max(y0 + 1).min(self.height); - let (mut r, mut g, mut b, mut a, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32); - for y in y0..y1 { - for x in x0..x1 { - let i = ((y * self.width + x) * 4) as usize; - if i + 3 >= self.pixels.len() { - continue; - } - r += self.pixels[i] as u32; - g += self.pixels[i + 1] as u32; - b += self.pixels[i + 2] as u32; - a += self.pixels[i + 3] as u32; - n += 1; - } - } - if n == 0 { - return Rgba::TRANSPARENT; - } - Rgba::from_rgba((r / n) as u8, (g / n) as u8, (b / n) as u8, (a / n) as u8) - } } /// Shared so a cover can sit in the cache and in playback state without being copied. pub type SharedArtwork = Arc; -/// One cell's two vertical samples: the upper half and the lower half. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Cell { - pub top: Rgba, - pub bottom: Rgba, -} - -impl Cell { - /// The glyph that renders this cell. - /// - /// A uniform cell needs no half-block: drawing a blank with matching colors keeps the frame - /// diff smaller and avoids a seam on terminals that render `▀` a pixel off. - pub fn glyph(&self) -> char { - if self.top == self.bottom { - ' ' - } else { - HALF_BLOCK - } - } -} - -/// Resolves `artwork` into a `cols` x `rows` grid of half-block cells, row-major. -/// -/// The image is fitted to the box by area-averaging; it is not letterboxed, so pass a box whose -/// aspect already matches (see [`square_box`]) or the image will stretch. -pub fn sample_cells(artwork: &Artwork, cols: u32, rows: u32) -> Vec { - if cols == 0 || rows == 0 || artwork.width == 0 || artwork.height == 0 { - return Vec::new(); - } - let sample_rows = rows * SAMPLES_PER_ROW; - let mut cells = Vec::with_capacity((cols * rows) as usize); - - for cy in 0..rows { - for cx in 0..cols { - let sx0 = cx * artwork.width / cols; - let sx1 = (cx + 1) * artwork.width / cols; - - let top_row = cy * SAMPLES_PER_ROW; - let ty0 = top_row * artwork.height / sample_rows; - let ty1 = (top_row + 1) * artwork.height / sample_rows; - let by0 = ty1; - let by1 = (top_row + 2) * artwork.height / sample_rows; - - cells.push(Cell { - top: artwork.sample_box(sx0, ty0, sx1, ty1), - bottom: artwork.sample_box(sx0, by0, sx1, by1), - }); - } - } - cells -} - #[cfg(test)] mod tests { use super::*; @@ -215,14 +76,6 @@ mod tests { buffer.into_inner() } - fn solid(w: u32, h: u32, c: [u8; 4]) -> Artwork { - Artwork { - width: w, - height: h, - pixels: (0..w * h).flat_map(|_| c).collect(), - } - } - #[test] fn decoding_yields_tightly_packed_rgba() { let art = Artwork::decode(&red_png(8), 0, MAX_COVER_EDGE).expect("decode"); @@ -250,7 +103,7 @@ mod tests { let art = Artwork::decode(&red_png(640), 0, THUMB_EDGE).expect("decode"); assert_eq!(art.width.max(art.height), THUMB_EDGE); // Three hundred of these is a few megabytes, not a few hundred. - assert!(art.byte_len() <= (THUMB_EDGE * THUMB_EDGE * 4) as usize); + assert!(art.pixels.len() <= (THUMB_EDGE * THUMB_EDGE * 4) as usize); } #[test] @@ -268,95 +121,4 @@ mod tests { assert!(Artwork::decode(b"not an image", 0, MAX_COVER_EDGE).is_none()); assert!(Artwork::decode(&[], 0, MAX_COVER_EDGE).is_none()); } - - #[test] - fn square_box_matches_echos_existing_cover_sizes() { - assert_eq!(square_box(3), (6, 3)); - assert_eq!(square_box(5), (10, 5)); - assert_eq!(square_box(10), (20, 10)); - } - - #[test] - fn the_sampler_accepts_what_decode_produces() { - let art = Artwork::decode(&red_png(64), 0, MAX_COVER_EDGE).expect("decode"); - let cells = sample_cells(&art, 6, 3); - assert_eq!(cells.len(), 18); - // A solid red source resolves to solid red cells. - assert_eq!(cells[0].top.r, 255); - assert_eq!(cells[0].top, cells[0].bottom); - } - - #[test] - fn sample_box_averages_the_region() { - // Left half black, right half white. - let (w, h) = (4u32, 1u32); - let mut px = Vec::new(); - for x in 0..w { - let v = if x < 2 { 0 } else { 255 }; - px.extend_from_slice(&[v, v, v, 255]); - } - let img = Artwork { - width: w, - height: h, - pixels: px, - }; - assert_eq!(img.sample_box(0, 0, 2, 1).r, 0); - assert_eq!(img.sample_box(2, 0, 4, 1).r, 255); - // Averaging across the seam gives the midpoint. - assert_eq!(img.sample_box(0, 0, 4, 1).r, 127); - } - - #[test] - fn sample_box_clamps_to_image_bounds() { - let img = solid(2, 2, [10, 20, 30, 255]); - let c = img.sample_box(0, 0, 99, 99); - assert_eq!((c.r, c.g, c.b), (10, 20, 30)); - } - - #[test] - fn sample_box_of_an_empty_region_is_transparent() { - let img = solid(2, 2, [255, 255, 255, 255]); - assert_eq!(img.sample_box(5, 5, 5, 5), Rgba::TRANSPARENT); - } - - #[test] - fn vertical_detail_resolves_to_two_samples_per_cell() { - // Top half red, bottom half blue; one row of cells means the split must land on the - // cell's top/bottom pair rather than averaging into one colour. - let (w, h) = (8u32, 8u32); - let mut px = Vec::new(); - for y in 0..h { - for _ in 0..w { - if y < h / 2 { - px.extend_from_slice(&[255, 0, 0, 255]); - } else { - px.extend_from_slice(&[0, 0, 255, 255]); - } - } - } - let img = Artwork { - width: w, - height: h, - pixels: px, - }; - let cells = sample_cells(&img, 4, 1); - assert_eq!(cells[0].top.r, 255); - assert_eq!(cells[0].bottom.b, 255); - assert_eq!(cells[0].glyph(), HALF_BLOCK); - } - - #[test] - fn uniform_cells_avoid_the_half_block_glyph() { - let img = solid(16, 16, [200, 100, 50, 255]); - for cell in sample_cells(&img, 4, 2) { - assert_eq!(cell.glyph(), ' '); - } - } - - #[test] - fn zero_sized_requests_are_ignored() { - let img = solid(4, 4, [255, 255, 255, 255]); - assert!(sample_cells(&img, 0, 2).is_empty()); - assert!(sample_cells(&img, 2, 0).is_empty()); - } } diff --git a/crates/echo-core/src/bootstrap.rs b/crates/echo-core/src/bootstrap.rs index e3d836b..207bbf0 100644 --- a/crates/echo-core/src/bootstrap.rs +++ b/crates/echo-core/src/bootstrap.rs @@ -1,8 +1,8 @@ //! Shared startup: channels, worker, config and initial state. //! -//! Both frontends begin the same way — spawn the [`Worker`](crate::worker::Worker) on the -//! current tokio runtime, load config and caches into an [`AppState`], and pick the starting -//! mode. Only the render loop that follows differs, so everything up to that point lives here. +//! Desktop startup spawns the [`Worker`](crate::worker::Worker) on the current tokio runtime, +//! loads config and caches into an [`AppState`], and picks the starting mode. Everything before +//! rendering begins lives here. use tokio::sync::mpsc; diff --git a/crates/echo-core/src/commands.rs b/crates/echo-core/src/commands.rs index d293246..6be43bc 100644 --- a/crates/echo-core/src/commands.rs +++ b/crates/echo-core/src/commands.rs @@ -1,9 +1,8 @@ -//! The `:` command registry shared by both frontends. +//! The `:` command registry used by the desktop command bar. //! -//! The TUI's command mode and the desktop's command bar both edit -//! `state.ui.command_buffer` and call [`submit`] on Enter; Tab completion goes through -//! [`cycle_suggestion`]. Frontends own only the key handling — everything the commands *do* -//! lives here so `:sort`, `:open`, `:theme` and friends behave identically everywhere. +//! The command bar edits `state.ui.command_buffer` and calls [`submit`] on Enter; Tab completion +//! goes through [`cycle_suggestion`]. The UI owns only the key handling — everything the commands +//! *do* lives here. use crate::app::{AppMode, AppState}; use crate::events::AppEvent; @@ -42,7 +41,6 @@ pub const COMMANDS: &[(&str, &str)] = &[ ("rename ", "Rename the selected playlist or folder"), ("pixelate ", "Retro pixelation on cover art; 0 disables"), ("backdrop ", "Immersive view backdrop (desktop)"), - ("thumbs [on|off]", "Cover thumbnails in the sidebar"), ( "autoupdate [on|off]", "Install new releases automatically (desktop)", @@ -63,7 +61,6 @@ pub const COMMANDS: &[(&str, &str)] = &[ "Vim-style relative line numbers", ), ("range ", "Top tracks/artists time range"), - ("redraw", "Clear and redraw (TUI only)"), ]; /// Just the command names, for completion matching. @@ -187,7 +184,6 @@ pub fn submit(state: &mut AppState) -> Option { let cmd = state.ui.command_buffer.clone(); state.ui.command_buffer.clear(); state.ui.mode = AppMode::Normal; - state.ui.needs_terminal_clear = true; execute(state, &cmd) } @@ -441,9 +437,6 @@ fn execute(state: &mut AppState, cmd: &str) -> Option { None => set_status(state, format!("Usage: backdrop <{names}>")), } } - "redraw" => { - state.ui.needs_terminal_clear = true; - } "newfolder" => { let name = args.collect::>().join(" "); if !name.is_empty() { @@ -612,21 +605,6 @@ fn execute(state: &mut AppState, cmd: &str) -> Option { state.ui.status_message_expiry = Some(std::time::Instant::now() + std::time::Duration::from_secs(3)); } - "thumbs" => { - let enabled = match args.next() { - Some("on") => true, - Some("off") => false, - Some(other) => { - state.ui.status_message = - Some(format!("Usage: thumbs [on|off], got '{}'", other)); - state.ui.status_message_expiry = - Some(std::time::Instant::now() + std::time::Duration::from_secs(3)); - return None; - } - None => !state.ui.library_config.library_thumbnails, - }; - state.set_library_thumbnails(enabled); - } "autoupdate" => { state.ui.library_config.auto_update = match args.next() { Some("on") => true, @@ -1097,14 +1075,6 @@ mod tests { } } - #[test] - fn redraw_command_requests_terminal_clear() { - let mut state = AppState::new(); - - assert!(submit_command(&mut state, "redraw").is_none()); - assert!(state.ui.needs_terminal_clear); - } - #[test] fn submitting_returns_to_normal_mode_and_clears_the_buffer() { let mut state = AppState::new(); diff --git a/crates/echo-core/src/config.rs b/crates/echo-core/src/config.rs index 4720326..ad363e8 100644 --- a/crates/echo-core/src/config.rs +++ b/crates/echo-core/src/config.rs @@ -411,22 +411,18 @@ pub struct LibraryConfig { #[serde(default = "default_volume")] pub volume: u32, #[serde(default)] - pub keybindings: std::collections::HashMap, - #[serde(default)] pub relative_line_numbers: bool, - #[serde(default)] - pub library_thumbnails: bool, #[serde(default = "default_bitrate")] pub bitrate: u32, #[serde(default = "default_normalisation")] pub normalisation: bool, #[serde(default = "default_normalisation_pregain")] pub normalisation_pregain: f64, - /// The picture behind the desktop app's immersive view. Ignored by the TUI. + /// The picture behind the desktop app's immersive view. #[serde(default)] pub immersive_backdrop: BackdropMode, - /// Desktop window geometry, so a resized window comes back the same size. Ignored by the - /// TUI. `None` until the desktop app has been run and closed at least once. + /// Desktop window geometry, so a resized window comes back the same size. `None` until the + /// desktop app has been run and closed at least once. #[serde(default)] pub sidebar_width: Option, #[serde(default)] @@ -436,8 +432,7 @@ pub struct LibraryConfig { pub right_panel: Option, #[serde(default)] pub window_bounds: Option, - /// The desktop close button hides the window to the tray and keeps playing. Ignored by - /// the TUI. + /// The desktop close button hides the window to the tray and keeps playing. #[serde(default = "default_close_to_tray")] pub close_to_tray: bool, /// The `time_range` window used by the Top Tracks / Top Artists browse lists. @@ -521,9 +516,7 @@ impl Default for LibraryConfig { enable_visualizer: false, local_music_dir: None, volume: 100, - keybindings: std::collections::HashMap::new(), relative_line_numbers: false, - library_thumbnails: false, bitrate: 320, normalisation: true, normalisation_pregain: default_normalisation_pregain(), @@ -554,7 +547,7 @@ pub enum SortMode { Creator, } -/// What the desktop app docks to the right of the main area. Ignored by the TUI. +/// What the desktop app docks to the right of the main area. #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RightPanel { diff --git a/crates/echo-core/src/events.rs b/crates/echo-core/src/events.rs index dc47a2d..9af154a 100644 --- a/crates/echo-core/src/events.rs +++ b/crates/echo-core/src/events.rs @@ -181,7 +181,6 @@ pub enum WorkerEvent { item: Option, context: Option, }, - ForceRedraw, ForceContextRefresh, TrackMetadataLoaded { track_id: String, diff --git a/crates/echo-core/src/image_tasks.rs b/crates/echo-core/src/image_tasks.rs index 196d09a..069a6c9 100644 --- a/crates/echo-core/src/image_tasks.rs +++ b/crates/echo-core/src/image_tasks.rs @@ -1,7 +1,6 @@ //! Fetching and decoding cover art off the render thread. //! -//! Decoding hands back raw pixels ([`Artwork`]) rather than a terminal protocol object, so no -//! `Picker` is needed and the same payload serves any frontend. +//! Decoding hands back raw pixels ([`Artwork`]) so rendering can consume the payload directly. use std::sync::Arc; use tokio::sync::mpsc; diff --git a/crates/echo-core/src/intent.rs b/crates/echo-core/src/intent.rs index 794cbae..d81cb43 100644 --- a/crates/echo-core/src/intent.rs +++ b/crates/echo-core/src/intent.rs @@ -1,9 +1,9 @@ //! Frontend-neutral user intents. //! -//! "The user activated library row N" or "asked to play track N" means the same thing whether it -//! arrived as an Enter keypress in the TUI or a click in the desktop app: mutate selection state -//! and return the event the worker should receive. The frontends translate their input idioms -//! into these calls and send whatever comes back over `app_tx`. +//! "The user activated library row N" or "asked to play track N" has the same effect whether it +//! arrived from a keyboard shortcut or a click: mutate selection state and return the event the +//! worker should receive. The desktop translates its input idioms into these calls and sends +//! whatever comes back over `app_tx`. use crate::app::{ActiveView, AppMode, AppState, SearchTab}; use crate::events::AppEvent; @@ -726,22 +726,7 @@ fn search_track_play_event(state: &AppState, track: &SearchTrack) -> Option Option { - state.push_view_history(); - state.ui.active_view = ActiveView::ArtistList; - state.ui.artist_list_source = crate::app::ArtistListSource::Followed; - state.ui.selected_artist_index = 0; - state - .data - .followed_artists - .is_empty() - .then_some(AppEvent::FetchFollowedArtists) -} - -/// Opens the user's top artists as an artist list, fetching in the background when -/// empty — same fill-in-place behavior as [`open_artist_list`]. +/// Opens the user's top artists as an artist list, fetching in the background when empty. pub fn open_top_artists(state: &mut AppState) -> Option { state.push_view_history(); state.ui.active_view = ActiveView::ArtistList; @@ -756,8 +741,7 @@ pub fn open_top_artists(state: &mut AppState) -> Option { }) } -/// Opens the What's New feed (recent releases from followed artists), fetching in the -/// background when empty — same fill-in-place behavior as [`open_artist_list`]. +/// Opens the What's New feed, fetching recent releases from followed artists when empty. pub fn open_whats_new(state: &mut AppState) -> Option { state.push_view_history(); state.ui.active_view = ActiveView::WhatsNew; @@ -814,15 +798,7 @@ fn open_artist(state: &mut AppState, artist: Artist) -> Option { }) } -/// Opens album `index` of the current artist page as a track list. -pub fn open_artist_album(state: &mut AppState, index: usize) -> Option { - state.ui.artist_page_album_index = index; - open_album_from_artist_page(state, index) -} - -/// The album-opening body shared by [`open_artist_album`] (TUI, album-relative cursor) and -/// [`activate_artist_page_row`] (desktop, combined cursor): deliberately does not touch the -/// cursor, so each caller's own index space survives the history snapshot. +/// Opens an artist-page album without changing the combined Popular/albums cursor. fn open_album_from_artist_page(state: &mut AppState, album_index: usize) -> Option { let data = state.data.artist_page_data.clone()?; let album = data.albums.get(album_index)?; @@ -895,11 +871,6 @@ pub fn set_discography_filter(state: &mut AppState, filter: DiscographyFilter) { crate::apply_worker_event::artist::apply_discography_filter(state); } -/// Tab on the artist page: the next discography chip, wrapping. -pub fn cycle_discography_filter(state: &mut AppState) { - set_discography_filter(state, state.ui.artist_discography_filter.next()); -} - /// Follows or unfollows the artist of the open artist page, flipping the followed list at /// once; the worker reloads that list after the write, which also undoes a failed flip. pub fn toggle_follow_artist(state: &mut AppState) -> Option { @@ -921,13 +892,6 @@ pub fn toggle_follow_artist(state: &mut AppState) -> Option { Some(AppEvent::FollowArtist(artist_id)) } -/// Backs out of an artist page to the artist list, cancelling any in-flight page load. -pub fn back_to_artist_list(state: &mut AppState) -> AppEvent { - state.ui.active_view = ActiveView::ArtistList; - state.clear_pending_artist_page(); - AppEvent::CancelArtistPageLoad -} - /// Saves the pasted Spotify developer credentials and starts authentication. `None` while /// either field is still empty. pub fn submit_setup_credentials(state: &mut AppState) -> Option { @@ -952,7 +916,6 @@ pub fn apply_theme(state: &mut AppState, name: &str) -> bool { }; state.ui.active_theme = crate::theme::ResolvedTheme::from_theme(theme); state.ui.library_config.active_theme = Some(name.to_string()); - state.ui.needs_terminal_clear = true; state.save_library_config(); true } @@ -1025,7 +988,7 @@ pub fn set_top_items_range( None } -// Vim-style motions and toggles shared by both frontends. +// Vim-style motions and toggles. /// `g c`: jump the selection (or the whole view) to whatever is currently playing. pub fn jump_to_current_context(state: &mut AppState) -> Option { @@ -1133,7 +1096,7 @@ pub fn adjust_volume(state: &mut AppState, delta: i32) -> AppEvent { set_volume(state, next) } -/// Ctrl-L: toggle the inline lyric line in the playback bar, persisted like the TUI does. +/// Ctrl-L: toggle and persist the inline lyric line in the playback bar. /// `library_config` must be kept in sync too: later whole-section saves (window bounds on /// close, sidebar width) write it back verbatim and would otherwise revert the toggle. pub fn toggle_condensed_lyrics(state: &mut AppState) { @@ -1451,9 +1414,8 @@ pub fn remove_playlist_from_folders(state: &mut AppState, id: &str) -> bool { removed } -// Destructive-action prompts. A frontend sets one of the `*_prompt` fields, shows its own -// confirm UI, and resolves it through these — the TUI with y/other keys, the desktop with -// modal buttons. +// Destructive-action prompts. The desktop sets one of the `*_prompt` fields, shows its confirm +// UI, and resolves it through these from modal buttons or keyboard shortcuts. // Visual mode: a contiguous range anchored where `v` was pressed, extended by moving the // selection. `AppState::get_visual_selection_range` resolves the anchor and the live selection @@ -2249,18 +2211,6 @@ mod tests { use crate::models::TrackListContextKind; use std::path::PathBuf; - #[test] - fn backing_out_cancels_pending_artist_without_clearing_page_data() { - let mut state = AppState::new(); - state.begin_artist_page_load("artist".to_string(), "Artist".to_string(), None); - - let event = back_to_artist_list(&mut state); - - assert!(matches!(event, AppEvent::CancelArtistPageLoad)); - assert!(state.ui.active_view == ActiveView::ArtistList); - assert!(state.data.pending_artist_page_id.is_none()); - } - fn library_playlist(id: &str) -> crate::models::Playlist { crate::models::Playlist { description: None, @@ -2367,9 +2317,6 @@ mod tests { assert_eq!(names(&state), ["album-0", "album-2"]); set_discography_filter(&mut state, DiscographyFilter::AppearsOn); assert_eq!(names(&state), ["album-3"]); - cycle_discography_filter(&mut state); - assert_eq!(state.ui.artist_discography_filter, DiscographyFilter::All); - assert_eq!(names(&state), ["album-0", "album-1", "album-2"]); } #[test] @@ -2942,23 +2889,6 @@ mod tests { assert_eq!(selected_index, 1); } - #[test] - fn opening_the_artist_list_fetches_only_when_empty() { - let mut state = AppState::new(); - assert!(matches!( - open_artist_list(&mut state), - Some(AppEvent::FetchFollowedArtists) - )); - - state.data.followed_artists.push(Artist { - id: "artist".to_string(), - name: "Artist".to_string(), - image_url: None, - }); - assert!(open_artist_list(&mut state).is_none()); - assert!(state.ui.active_view == ActiveView::ArtistList); - } - #[test] fn generated_context_playback_never_masquerades_as_playlist() { let track = spotify_track("track"); diff --git a/crates/echo-core/src/lib.rs b/crates/echo-core/src/lib.rs index ae77d55..da74cdf 100644 --- a/crates/echo-core/src/lib.rs +++ b/crates/echo-core/src/lib.rs @@ -1,9 +1,9 @@ //! Frontend-agnostic core of echo: Spotify + local-library worker, domain models, config, //! app state and the events that tie a frontend to the worker. //! -//! Frontends (the ratatui `spotify` binary, the GPUI `echo` desktop app) depend on this crate, -//! spawn [`worker::Worker`] on a tokio runtime and talk to it over the two event channels in -//! [`events`]. Nothing in here may depend on a rendering or input library. +//! The GPUI desktop app depends on this crate, spawns [`worker::Worker`] on a tokio runtime and +//! talks to it over the two event channels in [`events`]. Nothing in here may depend on a +//! rendering or input library. pub mod action_menu; pub mod app; diff --git a/crates/echo-core/src/theme.rs b/crates/echo-core/src/theme.rs index 2b4b8eb..369fad6 100644 --- a/crates/echo-core/src/theme.rs +++ b/crates/echo-core/src/theme.rs @@ -1,9 +1,8 @@ //! Frontend-neutral theme colors. //! //! Theme files store colors as strings (`config::Theme`); this module resolves them into -//! [`ThemeColor`] values that carry no rendering dependency, so the same resolved theme serves -//! any frontend. The ratatui views convert with an extension trait in `tui`; a GPUI frontend -//! maps named colors through its own palette. +//! [`ThemeColor`] values that carry no rendering dependency. The desktop maps named colors +//! through its own GPUI palette. use crate::config::Theme; use std::str::FromStr; @@ -43,8 +42,8 @@ pub enum ThemeColor { impl FromStr for ThemeColor { type Err = (); - /// Mirrors ratatui's `Color::from_str`: named colors (case- and separator-insensitive), - /// `#rrggbb` hex, or a bare palette index. + /// Accepts named colors (case- and separator-insensitive), `#rrggbb` hex, or a bare palette + /// index. fn from_str(s: &str) -> Result { let normalized: String = s .chars() @@ -90,7 +89,7 @@ impl FromStr for ThemeColor { } } -/// A theme with every color parsed, falling back per-slot like the old ratatui-typed version. +/// A theme with every color parsed and a fallback for each slot. #[derive(Clone)] pub struct ResolvedTheme { pub primary: ThemeColor, diff --git a/crates/echo-core/src/thumbnails.rs b/crates/echo-core/src/thumbnails.rs index af8ed48..49c926b 100644 --- a/crates/echo-core/src/thumbnails.rs +++ b/crates/echo-core/src/thumbnails.rs @@ -6,13 +6,6 @@ use tokio::sync::mpsc; use crate::app::AppState; use crate::events::WorkerEvent; -/// Width of the cover image in terminal cells. -pub const THUMB_W: u16 = 6; -/// Height of the cover image in terminal cells. -pub const THUMB_H: u16 = 3; -/// Height of one library row in thumbnail mode. -pub const ROW_H: u16 = 3; - const MAX_IN_FLIGHT: usize = 4; const MAX_MEMORY_ENTRIES: usize = 300; const MAX_DISK_FILES: usize = 500; diff --git a/crates/echo-core/src/update.rs b/crates/echo-core/src/update.rs index d349209..401e66c 100644 --- a/crates/echo-core/src/update.rs +++ b/crates/echo-core/src/update.rs @@ -1,18 +1,17 @@ //! Self-upgrade: replacing an installed echo with a newer GitHub release. //! -//! There is exactly one install shape — `echo-desktop` and `spotify` side by side in a directory -//! the user owns, with `themes/` beside them — so there is exactly one thing to swap: the -//! `echo--.tar.gz` archive of that directory. The installers (`assets/install.sh`, -//! `assets/install.ps1`) lay that shape down; every upgrade after it happens here, with no -//! installer UI involved. +//! There is exactly one install shape — `echo-desktop` in a directory the user owns, with +//! `themes/` beside it — so there is exactly one thing to swap: the `echo--.tar.gz` +//! archive of that directory. The installers (`assets/install.sh`, `assets/install.ps1`) lay that +//! shape down; every upgrade after it happens here, with no installer UI involved. //! -//! Two details drive the design: +//! Themes are loaded from `/themes` (see [`crate::config`]), so an archive of the bare +//! binary would leave a release's theme changes behind. The archives carry `themes/` too and the +//! directory is swapped along with the binary. //! -//! - Both frontends live in the *same* directory on every platform (see -//! `echo-desktop/Cargo.toml`), so one archive updates both at once. -//! - Themes are loaded from `/themes` (see [`crate::config`]), so an archive of bare -//! binaries would leave a release's theme changes behind. The archives carry `themes/` too and -//! the directory is swapped along with the binaries. +//! Installs from v0.6.7 and earlier also hold the retired `spotify` terminal client. Their +//! updaters insist on replacing it, so the archive still carries a stub under that name +//! (`crates/spotify-stub`); [`remove_retired`] deletes it once a newer desktop app starts. use std::path::{Component, Path, PathBuf}; @@ -81,7 +80,7 @@ pub const DEV_VERSION: &str = "0.1.0"; /// GitHub rejects API requests that arrive without one. const USER_AGENT: &str = concat!("echo/", env!("CARGO_PKG_VERSION")); -/// Network calls are bounded so a black-holed connection cannot hang `spotify upgrade`. +/// Network calls are bounded so a black-holed connection cannot hang an upgrade. const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); @@ -280,17 +279,13 @@ fn bin_name(stem: &str) -> String { fn resolve(exe: &Path, exists: impl Fn(&Path) -> bool) -> Result { let dir = parent_of(exe)?; - // An install normally holds both binaries, but only what is actually on disk gets replaced: - // extracting `echo-desktop` over a directory that never had it would leave a stray copy + // Extracting `echo-desktop` over a directory that never had it would leave a stray copy // outside whatever the user's launcher points at. - let targets: Vec = ["spotify", "echo-desktop"] - .into_iter() - .map(|stem| dir.join(bin_name(stem))) - .filter(|path| exists(path)) - .collect(); - if targets.is_empty() { + let desktop = dir.join(bin_name("echo-desktop")); + if !exists(&desktop) { return Err(UpdateError::UnknownInstall(dir)); } + let targets = vec![desktop]; // Mirrors `config::app_theme_dirs`: themes sit beside the binary, except in a macOS // bundle where they live in Contents/Resources. @@ -316,7 +311,7 @@ fn resolve(exe: &Path, exists: impl Fn(&Path) -> bool) -> Result Result { - // Resolves symlinks, so the `~/.local/bin/spotify` link the installers leave behind lands + // Resolves symlinks, so the `~/.local/bin/echo-desktop` link install.sh leaves behind lands // on the real install directory rather than on a directory holding nothing but links. let exe = std::env::current_exe().map_err(io)?; let plan = resolve(&exe, |path| path.exists())?; @@ -352,7 +347,7 @@ fn probe_writable(dir: &Path) -> Result<(), UpdateError> { /// Delete `*.old` files left behind by a previous upgrade. /// /// Windows cannot unlink the image of a running process, so [`apply`] leaves its backups in -/// place. Both frontends call this at startup, when nothing holds them open any more. +/// place. The desktop app calls this at startup, when nothing holds them open any more. pub fn sweep_backups() { let Ok(exe) = std::env::current_exe() else { return; @@ -360,13 +355,66 @@ pub fn sweep_backups() { let Some(dir) = exe.parent() else { return; }; - for stem in ["spotify", "echo-desktop"] { + for stem in std::iter::once("echo-desktop").chain(RETIRED) { let mut name = std::ffi::OsString::from(bin_name(stem)); name.push(".old"); let _ = std::fs::remove_file(dir.join(name)); } } +/// Binaries older installs carry that echo no longer ships. +const RETIRED: [&str; 1] = ["spotify"]; + +/// Delete retired binaries from this install, along with the `~/.local/bin` links install.sh +/// made for them. Called at startup; anything still in use (a `spotify.exe` running in a +/// terminal on Windows) is left for the next launch. +pub fn remove_retired() { + // A working tree's target/ is not an install, and cargo builds the stub there too. + if is_dev_build() { + return; + } + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(dir) = exe.parent() else { + return; + }; + let bin_home = dirs::home_dir().map(|home| home.join(".local").join("bin")); + if remove_retired_from(dir, bin_home.as_deref()) { + // Removing a file from Contents/MacOS breaks a bundle seal just like swapping one. + resign_if_bundled(dir, &[]); + } +} + +/// Returns whether any retired binary was actually deleted. +fn remove_retired_from(dir: &Path, bin_home: Option<&Path>) -> bool { + let mut removed = false; + for stem in RETIRED { + let binary = dir.join(bin_name(stem)); + // Links first: once the binary is gone there is nothing left to match them against. + if let Some(link) = bin_home.map(|bin| bin.join(stem)) + && links_to(&link, &binary) + { + let _ = std::fs::remove_file(&link); + } + removed |= std::fs::remove_file(&binary).is_ok(); + } + removed +} + +/// Whether `link` is a symlink resolving to `target`. A same-named file that is not ours, or a +/// link pointing anywhere else, is left alone. +fn links_to(link: &Path, target: &Path) -> bool { + let is_link = std::fs::symlink_metadata(link).is_ok_and(|meta| meta.file_type().is_symlink()); + if !is_link { + return false; + } + match (std::fs::canonicalize(link), std::fs::canonicalize(target)) { + (Ok(resolved), Ok(target)) => resolved == target, + _ => false, + } +} + // --- Checking ------------------------------------------------------------- pub enum Check { @@ -845,22 +893,23 @@ mod tests { #[test] fn backups_keep_the_original_extension() { - // `with_extension` would turn spotify.exe into spotify.old and orphan the real binary. + // `with_extension` would turn echo-desktop.exe into echo-desktop.old and orphan the + // real binary. assert_eq!( - backup_path(Path::new("/opt/echo/spotify.exe")), - PathBuf::from("/opt/echo/spotify.exe.old") + backup_path(Path::new("/opt/echo/echo-desktop.exe")), + PathBuf::from("/opt/echo/echo-desktop.exe.old") ); assert_eq!( - backup_path(Path::new("/opt/echo/spotify")), - PathBuf::from("/opt/echo/spotify.old") + backup_path(Path::new("/opt/echo/echo-desktop")), + PathBuf::from("/opt/echo/echo-desktop.old") ); } #[test] fn archive_paths_that_escape_are_rejected() { assert_eq!( - safe_relative(Path::new("./spotify")).as_deref(), - Some(Path::new("spotify")) + safe_relative(Path::new("./echo-desktop")).as_deref(), + Some(Path::new("echo-desktop")) ); assert_eq!( safe_relative(Path::new("themes/echo.toml")).as_deref(), @@ -879,39 +928,74 @@ mod tests { } #[test] - fn both_binaries_side_by_side_are_both_replaced() { + fn the_desktop_binary_and_themes_are_replaced() { let dir = if cfg!(windows) { "C:/echo" } else { "/opt/echo" }; - let exe = format!("{dir}/{}", bin_name("spotify")); - let desktop = format!("{dir}/{}", bin_name("echo-desktop")); - let plan = resolve(Path::new(&exe), present(&[&exe, &desktop])).unwrap(); - assert_eq!( - plan.targets, - vec![PathBuf::from(&exe), PathBuf::from(&desktop)] - ); + let exe = format!("{dir}/{}", bin_name("echo-desktop")); + let themes = format!("{dir}/themes"); + let plan = resolve(Path::new(&exe), present(&[&exe, &themes])).unwrap(); + assert_eq!(plan.targets, vec![PathBuf::from(&exe)]); + assert_eq!(plan.themes, Some(PathBuf::from(themes))); } #[test] - fn only_the_binaries_actually_installed_are_replaced() { - // Someone who kept just the TUI out of the archive gets the same archive back, with - // `echo-desktop` left out of it rather than dropped beside a binary they never had. + fn a_leftover_terminal_client_is_not_an_upgrade_target() { + // Replacing it would pull the stub back in on every upgrade; `remove_retired` deletes it. let dir = if cfg!(windows) { "C:/echo" } else { "/opt/echo" }; - let exe = format!("{dir}/{}", bin_name("spotify")); - let themes = format!("{dir}/themes"); - let plan = resolve(Path::new(&exe), present(&[&exe, &themes])).unwrap(); + let exe = format!("{dir}/{}", bin_name("echo-desktop")); + let spotify = format!("{dir}/{}", bin_name("spotify")); + let plan = resolve(Path::new(&exe), present(&[&exe, &spotify])).unwrap(); assert_eq!(plan.targets, vec![PathBuf::from(&exe)]); - assert_eq!(plan.themes, Some(PathBuf::from(themes))); } #[test] - fn an_install_with_neither_binary_is_not_upgradeable() { + fn retired_binaries_are_deleted_on_their_own() { + let dir = scratch_dir("retired"); + std::fs::write(dir.join(bin_name("spotify")), b"stub").unwrap(); + std::fs::write(dir.join(bin_name("echo-desktop")), b"app").unwrap(); + + assert!(remove_retired_from(&dir, None)); + assert!(!dir.join(bin_name("spotify")).exists()); + assert!(dir.join(bin_name("echo-desktop")).exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn only_links_into_the_install_are_removed() { + let root = scratch_dir("retired-links"); + let install = root.join("install"); + let ours = root.join("bin-ours"); + let theirs = root.join("bin-theirs"); + for dir in [&install, &ours, &theirs] { + std::fs::create_dir(dir).unwrap(); + } + std::fs::write(install.join("spotify"), b"stub").unwrap(); + let elsewhere = root.join("some-other-spotify"); + std::fs::write(&elsewhere, b"not ours").unwrap(); + std::os::unix::fs::symlink(install.join("spotify"), ours.join("spotify")).unwrap(); + std::os::unix::fs::symlink(&elsewhere, theirs.join("spotify")).unwrap(); + + remove_retired_from(&install, Some(&ours)); + assert!(std::fs::symlink_metadata(ours.join("spotify")).is_err()); + assert!(!install.join("spotify").exists()); + + std::fs::write(install.join("spotify"), b"stub").unwrap(); + remove_retired_from(&install, Some(&theirs)); + assert!(std::fs::symlink_metadata(theirs.join("spotify")).is_ok()); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_install_without_the_desktop_binary_is_not_upgradeable() { let dir = if cfg!(windows) { "C:/echo" } else { @@ -927,10 +1011,9 @@ mod tests { fn a_mac_bundle_finds_themes_in_resources() { // install.sh drops echo.app in /Applications, so the upgrade target is the bundle's // own MacOS directory — themes are one level up in Resources, not beside the binaries. - let exe = "/Applications/echo.app/Contents/MacOS/spotify"; - let desktop = "/Applications/echo.app/Contents/MacOS/echo-desktop"; + let exe = "/Applications/echo.app/Contents/MacOS/echo-desktop"; let themes = "/Applications/echo.app/Contents/Resources/themes"; - let plan = resolve(Path::new(exe), present(&[exe, desktop, themes])).unwrap(); + let plan = resolve(Path::new(exe), present(&[exe, themes])).unwrap(); assert_eq!(plan.themes, Some(PathBuf::from(themes))); } @@ -962,13 +1045,13 @@ mod tests { #[test] fn unpack_then_apply_replaces_binaries_and_themes() { let dir = scratch_dir("roundtrip"); - let binary = dir.join(bin_name("spotify")); + let binary = dir.join(bin_name("echo-desktop")); std::fs::write(&binary, b"old binary").unwrap(); std::fs::create_dir(dir.join("themes")).unwrap(); std::fs::write(dir.join("themes").join("echo.toml"), b"old theme").unwrap(); let archive = build_archive(&[ - (bin_name("spotify").as_str(), b"new binary"), + (bin_name("echo-desktop").as_str(), b"new binary"), ("themes/echo.toml", b"new theme"), ("themes/added.toml", b"added theme"), ]); @@ -1007,14 +1090,17 @@ mod tests { #[test] fn archives_in_the_shape_ci_produces_unpack() { let dir = scratch_dir("ci-shape"); - let binary = dir.join(bin_name("spotify")); + let binary = dir.join(bin_name("echo-desktop")); std::fs::write(&binary, b"old binary").unwrap(); std::fs::create_dir(dir.join("themes")).unwrap(); std::fs::write(dir.join("themes").join("echo.toml"), b"old theme").unwrap(); let archive = build_archive(&[ ("./", b""), - (format!("./{}", bin_name("spotify")).as_str(), b"new binary"), + ( + format!("./{}", bin_name("echo-desktop")).as_str(), + b"new binary", + ), ("./themes/echo.toml", b"new theme"), ]); @@ -1042,10 +1128,10 @@ mod tests { #[test] fn a_truncated_archive_is_rejected_rather_than_half_unpacked() { let dir = scratch_dir("truncated"); - let binary = dir.join(bin_name("spotify")); + let binary = dir.join(bin_name("echo-desktop")); std::fs::write(&binary, b"old binary").unwrap(); - let archive = build_archive(&[(bin_name("spotify").as_str(), b"new binary")]); + let archive = build_archive(&[(bin_name("echo-desktop").as_str(), b"new binary")]); // Losing the gzip trailer is exactly what a dropped connection looks like. `tar` alone // would not notice, because it stops at the end-of-archive marker. let truncated = &archive[..archive.len() - 8]; @@ -1064,7 +1150,7 @@ mod tests { #[test] fn an_archive_missing_a_wanted_binary_is_rejected() { let dir = scratch_dir("missing"); - let binary = dir.join(bin_name("spotify")); + let binary = dir.join(bin_name("echo-desktop")); std::fs::write(&binary, b"old binary").unwrap(); let archive = build_archive(&[("themes/echo.toml", b"only themes")]); diff --git a/crates/echo-core/src/worker/api/first_party.rs b/crates/echo-core/src/worker/api/first_party.rs index a2a519d..52da090 100644 --- a/crates/echo-core/src/worker/api/first_party.rs +++ b/crates/echo-core/src/worker/api/first_party.rs @@ -5,9 +5,7 @@ use librespot_core::{ use librespot_oauth::OAuthClientBuilder; use reqwest::header::RETRY_AFTER; use std::{io::Write, path::PathBuf, sync::Arc, time::Duration}; -use tokio::sync::{Mutex, mpsc}; - -use crate::events::WorkerEvent; +use tokio::sync::Mutex; pub use super::rate_limit::{SpotifyRateLimitError, rate_limit_error}; use super::rate_limit::{fallback_backoff, parse_retry_after, should_retry_inline}; @@ -41,18 +39,16 @@ const OAUTH_SCOPES: &[&str] = &[ pub struct SpotifySessionManager { cache: Cache, session: Arc>>, - tx: mpsc::Sender, } impl SpotifySessionManager { - pub fn new(tx: mpsc::Sender) -> Result { + pub fn new() -> Result { let cache = Cache::new(Some(Self::cache_dir()?), None, None, None) .context("Failed to create first-party Spotify session cache")?; Ok(Self { cache, session: Arc::new(Mutex::new(None)), - tx, }) } @@ -145,7 +141,6 @@ impl SpotifySessionManager { .get_access_token() .context("Failed to get first-party Spotify OAuth token")?; - clear_terminal_after_oauth(&self.tx); Ok(Credentials::with_access_token(token.access_token)) } @@ -311,14 +306,6 @@ impl From for anyhow::Error { } } -fn clear_terminal_after_oauth(tx: &mpsc::Sender) { - // Raw ANSI rather than crossterm so the core stays frontend-free; a windowed frontend's - // stdout ignores this harmlessly. - let _ = write!(std::io::stdout(), "\x1b[2J\x1b[H"); - let _ = std::io::stdout().flush(); - let _ = tx.try_send(WorkerEvent::ForceRedraw); -} - fn append_api_log(message: &str) { if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) diff --git a/crates/echo-core/src/worker/audio.rs b/crates/echo-core/src/worker/audio.rs index d3a9b8b..25c610d 100644 --- a/crates/echo-core/src/worker/audio.rs +++ b/crates/echo-core/src/worker/audio.rs @@ -316,16 +316,6 @@ pub async fn spawn_librespot_daemon( let oauth_client = client_builder.build()?; let t = oauth_client.get_access_token()?; - // Clear the terminal because librespot-oauth hardcodes a `println!` that - // corrupts the TUI layout. Raw ANSI rather than crossterm so the core stays - // frontend-free; a windowed frontend's stdout ignores this harmlessly. - { - use std::io::Write; - let _ = write!(std::io::stdout(), "\x1b[2J\x1b[H"); - let _ = std::io::stdout().flush(); - } - let _ = tx.send(WorkerEvent::ForceRedraw).await; - let creds = Credentials::with_access_token(t.access_token); cache.save_credentials(&creds); let _ = std::fs::remove_file(crate::config::debug_log_path( diff --git a/crates/echo-core/src/worker/mod.rs b/crates/echo-core/src/worker/mod.rs index 3a8ea7e..1b4d794 100644 --- a/crates/echo-core/src/worker/mod.rs +++ b/crates/echo-core/src/worker/mod.rs @@ -498,7 +498,7 @@ impl Worker { ) -> Self { let (media_tx, media_rx) = mpsc::channel(32); media::spawn_media_thread(media_rx, app_tx); - let first_party = api::first_party::SpotifySessionManager::new(tx.clone()) + let first_party = api::first_party::SpotifySessionManager::new() .map(api::first_party::SpotifyWebApi::new) .ok(); Self { diff --git a/crates/echo-desktop/Cargo.toml b/crates/echo-desktop/Cargo.toml index 60875f4..278e0b7 100644 --- a/crates/echo-desktop/Cargo.toml +++ b/crates/echo-desktop/Cargo.toml @@ -48,7 +48,6 @@ ksni = { version = "0.3", default-features = false, features = ["async-io"] } [build-dependencies] embed-resource = "3" -# The plain "echo" identity is this crate's: the TUI packages as "echo TUI"/com.echo.tui. # Brand is styled all-lowercase everywhere, including installers and shortcuts. [package.metadata.packager] product-name = "echo" @@ -64,15 +63,10 @@ icons = [ "../../icons/128x128@2x.png", ] resources = [{ src = "../../themes", target = "themes" }] -# The echo package carries both frontends: the desktop app plus the `spotify` TUI, so one -# install puts the terminal command on PATH (MSI: install dir appended via the WiX fragment -# below; macOS: both inside echo.app/Contents/MacOS). Linux does not build an installer at -# all — assets/install.sh lays down the portable archive instead. -binaries = [ - { path = "echo-desktop", main = true }, - { path = "spotify", main = false }, -] -before-packaging-command = "cargo build --release -p echo-desktop -p spotify-tui" +# Linux does not build an installer at all — assets/install.sh lays down the portable archive +# instead. +binaries = [{ path = "echo-desktop", main = true }] +before-packaging-command = "cargo build --release -p echo-desktop" [package.metadata.packager.wix] # Appends the install dir to the machine PATH (and removes it on uninstall) so `spotify` diff --git a/crates/echo-desktop/src/main.rs b/crates/echo-desktop/src/main.rs index 888e427..417ce62 100644 --- a/crates/echo-desktop/src/main.rs +++ b/crates/echo-desktop/src/main.rs @@ -1,9 +1,9 @@ //! echo — the GPUI desktop frontend. //! -//! Same architecture as the `spotify` TUI: [`echo_core::bootstrap::init`] spawns the worker on a -//! tokio runtime and hands back the two event channels; the frontend applies worker events to -//! [`AppState`](echo_core::app::AppState) and draws from it. Here the 16ms poll loop is replaced -//! by GPUI's reactive model — a spawned task awaits worker events and calls `cx.notify()`. +//! [`echo_core::bootstrap::init`] spawns the worker on a tokio runtime and hands back the two +//! event channels; the app applies worker events to [`AppState`](echo_core::app::AppState) and +//! draws from it. GPUI's reactive model awaits worker events in a spawned task and calls +//! `cx.notify()`. //! //! The tokio runtime lives on the main function's stack and stays entered for the lifetime of //! the UI, so worker tasks keep running on its threads while GPUI blocks in `run()`. @@ -316,7 +316,7 @@ fn mac_menus() -> Vec { /// `Modifiers::secondary` is cmd on macOS and ctrl elsewhere; a Mac that only matched `control` /// ignored cmd-v, which made the setup card — the first thing a new install shows, and a place /// where credentials are pasted rather than typed — impossible to paste into. ctrl-v is still -/// accepted everywhere, since the desktop app mirrors the TUI's keymap. +/// accepted everywhere as an alternative shortcut. fn is_paste_chord(modifiers: &gpui::Modifiers) -> bool { modifiers.secondary() || modifiers.control } @@ -499,8 +499,7 @@ impl EchoApp { if app.state.playback.is_playing || animating { cx.notify(); } - // Status messages carry an expiry the TUI checks each frame; here the - // periodic tick retires them. + // Status messages carry an expiry; the periodic tick retires them. if app .state .ui @@ -691,7 +690,7 @@ impl EchoApp { } /// Rows in whatever currently has keyboard focus: the device modal when open, else the - /// `active_view` list — the same routing the TUI's navigation handler does. + /// `active_view` list. fn list_len(&self) -> usize { if self.state.ui.playlist_edit.is_some() { return 0; @@ -925,8 +924,8 @@ impl EchoApp { self.set_selection(current.saturating_add_signed(delta).min(len - 1), cx); } - /// `l`, matching the TUI and README: like/unlike the focused track. On library rows it - /// acts as Enter (the TUI's `l`-opens behavior); un-liking stages the confirm prompt. + /// `l`: like/unlike the focused track. On library rows it acts as Enter; un-liking stages + /// the confirm prompt. fn toggle_like(&mut self, cx: &mut Context) { if self.overlay_open() { return; @@ -965,7 +964,7 @@ impl EchoApp { } /// Accept an open confirm prompt, mirroring the Confirm button in `views::prompt_modal`. - /// Bound to Enter (through [`Self::activate_selection`]) and to `y` like the TUI. + /// Bound to Enter (through [`Self::activate_selection`]) and to `y`. fn confirm_prompt(&mut self, cx: &mut Context) { self.pending_count = None; if !echo_core::intent::prompt_active(&self.state) { @@ -1084,10 +1083,10 @@ impl EchoApp { } /// Opening the queue leaves the immersive view, which has nowhere to show it. - /// `shift-Q`: the full-page, keyboard-navigable queue view (the TUI's `Q`). + /// `shift-Q`: the full-page, keyboard-navigable queue view. pub(crate) fn toggle_queue_view(&mut self, cx: &mut Context) { if self.state.ui.active_view == ActiveView::Queue { - // Mirrors the TUI's `q` from the queue view. + // `q` from the queue view returns to the library. self.state.ui.active_view = ActiveView::Library; } else { let event = echo_core::intent::open_queue(&mut self.state); @@ -1149,8 +1148,7 @@ impl EchoApp { cx.notify(); } - /// Escape / `h` / backspace: close whatever is topmost, else go back — the same ordering - /// as the TUI's back handling, with the desktop-only modals checked first. + /// Escape / `h` / backspace: close whatever is topmost, else go back, checking modals first. fn dismiss(&mut self, cx: &mut Context) { if self.state.ui.playlist_edit.is_some() { echo_core::intent::cancel_playlist_edit(&mut self.state); @@ -1210,7 +1208,7 @@ impl EchoApp { self.close_artist_page(cx); } } else if self.state.ui.active_view == ActiveView::SearchResults { - // Mirrors the TUI: leaving search results drops them entirely. + // Leaving search results drops them entirely. self.state.ui.active_view = ActiveView::Library; self.state.data.search_results = Default::default(); self.state.ui.search_context_query.clear(); @@ -1481,7 +1479,7 @@ impl EchoApp { } /// The track `a` / `shift-a` act on: the focused row where the view has one, otherwise the - /// currently playing track. Mirrors the TUI's `A` handler. + /// currently playing track. pub(crate) fn action_target(&self) -> Option { use echo_core::models::ActionMenuContext; let ui = &self.state.ui; @@ -2025,8 +2023,8 @@ impl EchoApp { cx.notify(); } - /// Apply a sort from the picker by running the matching `:sort` command, so the desktop - /// and the TUI cannot drift apart on what each option does. + /// Apply a sort from the picker by running the matching `:sort` command, so picker behavior + /// cannot drift from the command. pub(crate) fn apply_sort(&mut self, arg: &str, cx: &mut Context) { self.sort_menu_open = false; if let Some(event) = echo_core::commands::run(&mut self.state, &format!("sort {arg}")) { @@ -2055,9 +2053,9 @@ impl EchoApp { || self.help_open } - /// Backspace: close the topmost overlay if one is open, else hand keyboard focus back to - /// the library sidebar while keeping the current page visible — the TUI's quick two-pane - /// hop, without walking the view history the way `h`/escape do. + /// Backspace: close the topmost overlay if one is open, else hand keyboard focus back to the + /// library sidebar while keeping the current page visible, without walking the view history + /// the way `h`/escape do. fn back_or_focus_library(&mut self, cx: &mut Context) { self.pending_count = None; if self.overlay_open() { @@ -2068,8 +2066,8 @@ impl EchoApp { } // Vim-style command mode (`:`) and track filter (`/`) — a bar above the playback bar that - // owns key handling while one of the modes is active. The command registry itself is - // `echo_core::commands`, shared with the TUI. + // owns key handling while one of the modes is active. The command registry itself lives in + // `echo_core::commands`. fn open_command(&mut self, prefill: &str, window: &mut Window, cx: &mut Context) { echo_core::commands::clear_suggestions(&mut self.state); @@ -2232,7 +2230,7 @@ impl EchoApp { } fn add_to_queue(&mut self, cx: &mut Context) { - // In visual mode `q` queues the whole range, matching the TUI. + // In visual mode `q` queues the whole range. if self.multi_selected() { if let Some(event) = echo_core::intent::queue_visual_selection(&mut self.state) { self.dispatch(event); @@ -2745,8 +2743,8 @@ impl EchoApp { cx.notify(); } - /// Sends an intent-produced event to the worker, with the same side channels the TUI's main - /// loop has: a LoadContextTracks with cover art also kicks off the header image fetch, and + /// Sends an intent-produced event to the worker with its required side channels: a + /// LoadContextTracks with cover art also kicks off the header image fetch, and /// ReloadHeaderImage is handled entirely here (the worker has no handler for it). pub(crate) fn dispatch(&mut self, event: AppEvent) { if let AppEvent::GlobalSearch(query) = &event { @@ -2848,8 +2846,8 @@ impl EchoApp { .flatten(); let vis_bins = self.state.ui.vis_bins.clamp(5, 32); - // The pixelate transfer trick from the TUI: keep showing the previous cover while the - // current one refetches. + // Keep showing the previous cover while the current one refetches after a pixelate + // change. let cover = self .state .playback @@ -2859,7 +2857,7 @@ impl EchoApp { .cloned() .and_then(|artwork| self.images.get(&artwork)); - // The TUI's condensed-lyrics line: current lyric (accented) with the next one below, + // The condensed-lyrics line shows the current lyric accented with the next one below, // riding the same playback tick that advances the seek bar. let inline_lyrics = self .state @@ -3072,9 +3070,8 @@ impl EchoApp { }) .when_some(visualizer_bands, |el, bands| { // The engine always fills 32 bands, 0–100; they are averaged down - // to the configured bin count (`:visbins`, same math as the TUI) - // and painted as bottom-anchored bars. Repaints ride the fast - // tick. + // to the configured bin count (`:visbins`) and painted as + // bottom-anchored bars. Repaints ride the fast tick. el.child( div().flex_none().w(px(120.0)).h(px(32.0)).child( canvas( @@ -3997,6 +3994,7 @@ fn main() { // Windows cannot delete the image of a running process, so a previous upgrade leaves its // backups behind. Nothing holds them now. echo_core::update::sweep_backups(); + echo_core::update::remove_retired(); echo_core::i18n::init(); let boot = echo_core::bootstrap::init(); @@ -4013,9 +4011,9 @@ fn main() { // (Start Menu, desktop shortcut, raw exe) groups onto the same pinned button. cx.set_app_identity("com.echo.app", "echo"); cx.on_action(|_: &Quit, cx| cx.quit()); - // The keymap mirrors the TUI's, so it is ctrl-based everywhere. These are the chords a - // Mac user reaches for without thinking, and they are what the menu bar below shows as - // its accelerators — gpui reads them back out of the keymap when it builds the menu. + // The keymap is ctrl-based everywhere. These are the chords a Mac user reaches for + // without thinking, and they are what the menu bar below shows as its accelerators — + // gpui reads them back out of the keymap when it builds the menu. if cfg!(target_os = "macos") { cx.bind_keys([ KeyBinding::new("cmd-q", Quit, None), @@ -4050,16 +4048,16 @@ fn main() { KeyBinding::new("home", SelectFirst, LIST_KEYS), KeyBinding::new("end", SelectLast, LIST_KEYS), KeyBinding::new("enter", Activate, LIST_KEYS), - // `z` is the TUI's Enter alias. + // `z` is an Enter alias. KeyBinding::new("z", Activate, LIST_KEYS), - // The TUI answers confirm prompts with `y`; Enter reaches the same handler. + // `y` answers confirm prompts; Enter reaches the same handler. KeyBinding::new("y", ConfirmPrompt, LIST_KEYS), KeyBinding::new("left", FocusLibrary, LIST_KEYS), KeyBinding::new("right", FocusTracks, LIST_KEYS), - // `l` matches the TUI/README: like the focused track (Enter on library rows); + // `l` likes the focused track (or acts as Enter on library rows); // `right` keeps pane focus for arrow navigation. KeyBinding::new("l", ToggleLike, LIST_KEYS), - // Vim motions, matching the TUI's navigation handler. + // Vim-style navigation motions. KeyBinding::new("g g", SelectFirst, LIST_KEYS), KeyBinding::new("shift-g", SelectLast, LIST_KEYS), KeyBinding::new("g c", JumpToCurrent, LIST_KEYS), @@ -4067,14 +4065,13 @@ fn main() { KeyBinding::new("ctrl-d", HalfPageDown, LIST_KEYS), KeyBinding::new("ctrl-b", PageUp, LIST_KEYS), KeyBinding::new("ctrl-f", PageDown, LIST_KEYS), - // `h` is "back" in the TUI, not "focus sidebar" — the left arrow keeps that role. + // `h` is "back", not "focus sidebar" — the left arrow keeps that role. KeyBinding::new("h", Dismiss, LIST_KEYS), - // Backspace hops focus back to the sidebar (TUI habit); `h`/escape keep going - // back through the view history. + // Backspace hops focus back to the sidebar; `h`/escape keep going through history. KeyBinding::new("backspace", BackOrFocusLibrary, LIST_KEYS), KeyBinding::new("escape", Dismiss, LIST_KEYS), KeyBinding::new("tab", CycleTab, LIST_KEYS), - // Transport, the TUI's default keymap. + // Playback transport. KeyBinding::new("ctrl-right", NextTrack, LIST_KEYS), KeyBinding::new("ctrl-left", PreviousTrack, LIST_KEYS), KeyBinding::new("]", NextTrack, LIST_KEYS), @@ -4105,8 +4102,8 @@ fn main() { KeyBinding::new("f", CommandSearch, LIST_KEYS), KeyBinding::new("c", NewPlaylistPrompt, LIST_KEYS), KeyBinding::new("e", RenamePrompt, LIST_KEYS), - // `/` filters the loaded track list like the TUI; the global search box gets - // ctrl-k (and stays clickable). + // `/` filters the loaded track list; the global search box gets ctrl-k (and stays + // clickable). KeyBinding::new("/", OpenFilter, LIST_KEYS), KeyBinding::new("n", NextMatch, LIST_KEYS), KeyBinding::new("shift-n", PrevMatch, LIST_KEYS), diff --git a/crates/echo-desktop/src/theme.rs b/crates/echo-desktop/src/theme.rs index dc5ff30..42905ac 100644 --- a/crates/echo-desktop/src/theme.rs +++ b/crates/echo-desktop/src/theme.rs @@ -1,9 +1,7 @@ //! GPUI bindings for the neutral theme types. //! -//! The mirror of `spotify-tui`'s ratatui extension trait: [`echo_core::theme::ThemeColor`] -//! resolves to a [`gpui::Hsla`]. Named ANSI colors map through a fixed palette (the terminal's -//! palette isn't available — or meaningful — in a window), and `Reset` falls back to a slot- -//! appropriate default supplied by the caller. +//! [`echo_core::theme::ThemeColor`] resolves to a [`gpui::Hsla`]. Named ANSI colors map through +//! a fixed palette, and `Reset` falls back to a slot-appropriate default supplied by the caller. use echo_core::theme::{NamedColor, ThemeColor}; use gpui::{Hsla, Rgba}; diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 7198d75..1cb37d3 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -1,14 +1,13 @@ //! Library sidebar and track list — the first real views of the desktop app. //! //! Both are thin projections of [`AppState`]: rows come straight from `library_view` / -//! `saved_albums` / `tracks`, and activating a row goes through [`echo_core::intent`], the same -//! functions the TUI's Enter key uses. +//! `saved_albums` / `tracks`, and keyboard or pointer activation goes through +//! [`echo_core::intent`]. //! //! Thumbnails ride the core's [`echo_core::thumbnails`] cache. Rendering a row requests its //! cover; `drain_pending` (called after each row batch) spawns the fetches, and the resulting -//! worker events repaint. Unlike the TUI this ignores the `library_thumbnails` config toggle — -//! that flag exists because thumbnails cost real estate and glitch on some terminals, neither of -//! which applies here. +//! worker events repaint. This view always renders thumbnails because they fit the available +//! space and render reliably. use echo_core::app::{ActiveView, AppMode, LibraryTab, QueueRow, QueueTab, SearchTab}; use echo_core::config::RightPanel; @@ -90,8 +89,8 @@ fn row_marked( } /// Resolves a translation in the configured language. Every user-facing desktop string goes -/// through this so `:lang` applies on the next frame, like the TUI. Missing keys fall back to -/// English inside [`echo_core::i18n::t`]. +/// through this so `:lang` applies on the next frame. Missing keys fall back to English inside +/// [`echo_core::i18n::t`]. pub(crate) fn tr(state: &echo_core::app::AppState, key: &str) -> SharedString { SharedString::from(echo_core::i18n::t(key, &state.ui.library_config.language)) } @@ -145,7 +144,7 @@ fn pill_row( .child(build(pill)) } -/// The TUI's start-screen wordmark, shown in the main area while nothing is selected. +/// The start-screen wordmark, shown in the main area while nothing is selected. const ECHO_LOGO: [&str; 6] = [ "███████╗ ██████╗██╗ ██╗ ██████╗ ██████╗ ███████╗", "██╔════╝██╔════╝██║ ██║██╔═══██╗ ██╔══██╗██╔════╝", @@ -791,7 +790,7 @@ pub fn sidebar(app: &mut EchoApp, cx: &mut Context) -> impl IntoElement )), ) .child({ - // The TUI's Browse nodes, as quick links. + // Browse nodes presented as quick links. let browse_link = |id: &'static str, icon: &'static str, label: SharedString, @@ -1246,8 +1245,8 @@ pub fn main_area( .child(body) } -/// First-run credentials: a BYOK (bring-your-own-key) card matching the TUI's setup screen, -/// writing to the same `state.ui.setup_*` fields and submitting through the same intent. +/// First-run credentials: a BYOK (bring-your-own-key) card writing to the +/// `state.ui.setup_*` fields and submitting through the setup intent. fn setup_view( app: &mut EchoApp, window: &mut Window, @@ -2925,8 +2924,8 @@ pub(crate) fn playing_context_label(state: &echo_core::app::AppState) -> Option< Some(tr(state, "desktop.playing_from").replace("{}", &name)) } -/// Index-column text for a list row, honoring `track_index_base` (negative hides the -/// column — the caller omits the div) and `relative_line_numbers`, exactly as the TUI does. +/// Index-column text for a list row, honoring `track_index_base` (negative hides the column — +/// the caller omits the div) and `relative_line_numbers`. fn row_number( state: &echo_core::app::AppState, ix: usize, @@ -6054,8 +6053,8 @@ pub fn device_modal(app: &mut EchoApp, cx: &mut Context) -> impl IntoEl ) } -/// Add-to-playlist picker over the choices both frontends share (own Spotify playlists plus -/// local ones); Enter and row clicks resolve through `action_menu::commit_playlist_add`. +/// Add-to-playlist picker over the core choices (own Spotify playlists plus local ones); Enter +/// and row clicks resolve through `action_menu::commit_playlist_add`. pub fn playlist_add_modal(app: &mut EchoApp, cx: &mut Context) -> impl IntoElement { let theme = &app.state.ui.active_theme; let palette = DesktopPalette::resolve(theme); @@ -6210,8 +6209,8 @@ pub fn playlist_add_modal(app: &mut EchoApp, cx: &mut Context) -> impl ) } -/// The main-area empty state: the TUI's ECHO wordmark with a vertical secondary→primary -/// gradient, or plain status text while authenticating. +/// The main-area empty state: the ECHO wordmark with a vertical secondary→primary gradient, or +/// plain status text while authenticating. fn library_placeholder(app: &EchoApp) -> AnyElement { let theme = &app.state.ui.active_theme; let muted = theme.text_muted.gpui(WINDOW_FG()); @@ -6818,10 +6817,9 @@ pub fn context_menu(app: &mut EchoApp, cx: &mut Context) -> impl IntoEl ) } -/// Right-click menu for a track row. The item set and labels come from the shared action-menu -/// model (`ActionMenuContext::actions()` / `action_menu::label`), so it always matches the -/// TUI's `A` popup; remove-from-playlist is appended for modifiable playlists, where the TUI -/// uses `dd` instead. +/// Right-click menu for a track row. The item set and labels come from the core action-menu +/// model (`ActionMenuContext::actions()` / `action_menu::label`); remove-from-playlist is +/// appended for modifiable playlists. /// The open track menu's rows: `(label, item, is_destructive)`. Keyboard selection indexes /// into this, so it is the single source of the menu's contents. pub fn track_menu_items(app: &EchoApp) -> Vec<(SharedString, TrackMenuItem, bool)> { @@ -7148,8 +7146,8 @@ fn playlist_submenu( .into_any_element() } -/// Confirm dialog for whichever destructive prompt is staged, resolved through the same -/// `intent::confirm_prompt`/`cancel_prompt` the TUI's y/n keys use. +/// Confirm dialog for whichever destructive prompt is staged, resolved through +/// `intent::confirm_prompt`/`cancel_prompt`. pub fn prompt_modal(app: &mut EchoApp, cx: &mut Context) -> impl IntoElement { let theme = &app.state.ui.active_theme; let palette = DesktopPalette::resolve(theme); diff --git a/crates/echo-desktop/wix/env-path.wxs b/crates/echo-desktop/wix/env-path.wxs index 33911ab..155ae53 100644 --- a/crates/echo-desktop/wix/env-path.wxs +++ b/crates/echo-desktop/wix/env-path.wxs @@ -1,8 +1,7 @@ diff --git a/crates/spotify-stub/Cargo.toml b/crates/spotify-stub/Cargo.toml new file mode 100644 index 0000000..4a7d765 --- /dev/null +++ b/crates/spotify-stub/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "spotify-stub" +version.workspace = true +edition.workspace = true + +# Stands in for the removed terminal client inside the release archive. Updaters from v0.6.7 +# and earlier replace every binary they find installed and reject an archive missing one, so +# without this every existing install would fail to update. Newer desktop builds delete it at +# startup (`echo_core::update::remove_retired`). Drop this crate once those versions are gone. +[[bin]] +name = "spotify" +path = "src/main.rs" diff --git a/crates/spotify-stub/src/main.rs b/crates/spotify-stub/src/main.rs new file mode 100644 index 0000000..97a8dde --- /dev/null +++ b/crates/spotify-stub/src/main.rs @@ -0,0 +1,8 @@ +//! What the old `spotify` command turns into after an upgrade from v0.6.7 or earlier. + +fn main() { + eprintln!( + "The echo terminal client has been removed. Use the echo desktop app (echo-desktop) instead." + ); + std::process::exit(1); +} diff --git a/crates/spotify-tui/Cargo.toml b/crates/spotify-tui/Cargo.toml deleted file mode 100644 index 707085b..0000000 --- a/crates/spotify-tui/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "spotify-tui" -version.workspace = true -edition.workspace = true - -[[bin]] -name = "spotify" -path = "src/main.rs" - -[dependencies] -echo-core = { workspace = true } - -anyhow = { workspace = true } -crossterm = { workspace = true } -image = { workspace = true } -ratatui = { workspace = true } -ratatui-image = { workspace = true } -tokio = { workspace = true } -unicode-segmentation = { workspace = true } -unicode-width = { workspace = true } - -[dev-dependencies] -# Redirects echo-core's config root before tests run (see redirect_config_for_tests). -ctor = "0.4" - -# No `[package.metadata.packager]` here on purpose: the TUI is never packaged on its own. It -# ships inside the one echo package alongside the desktop app (see echo-desktop/Cargo.toml), -# so there is only ever one `spotify` command on a machine and one thing for `spotify upgrade` -# to replace. diff --git a/crates/spotify-tui/src/cli.rs b/crates/spotify-tui/src/cli.rs deleted file mode 100644 index 880c3dc..0000000 --- a/crates/spotify-tui/src/cli.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Argv handling for the `spotify` binary. -//! -//! Deliberately hand-rolled: the crate has no CLI dependency and this is three subcommands. -//! Anything unrecognised falls through to the TUI, because desktop launchers and terminal -//! emulators pass through arguments we do not control. - -use std::io::{IsTerminal, Write}; - -use echo_core::update::{self, UpdateError}; - -const HELP: &str = "\ -spotify — a terminal Spotify client - -Usage: - spotify Start the player - spotify upgrade Upgrade to the latest release - spotify upgrade Upgrade (or downgrade) to a specific version - spotify upgrade --check Report whether a newer release exists, change nothing - -Options: - -V, --version Print the installed version - -h, --help Print this message -"; - -#[derive(Debug, PartialEq, Eq)] -pub enum Command { - /// Start the player — the default for a bare invocation or unknown arguments. - Tui, - Version, - Help, - Upgrade { - target: Option, - check_only: bool, - }, -} - -pub fn parse>(args: I) -> Command { - let mut args = args.into_iter(); - let Some(first) = args.next() else { - return Command::Tui; - }; - match first.as_str() { - "upgrade" => { - let mut target = None; - let mut check_only = false; - for arg in args { - match arg.as_str() { - "--check" | "-n" => check_only = true, - // The first bare word is the version; `v1.2.3` and `1.2.3` both work. - _ if !arg.starts_with('-') && target.is_none() => target = Some(arg), - _ => {} - } - } - Command::Upgrade { target, check_only } - } - "--version" | "-V" => Command::Version, - "--help" | "-h" => Command::Help, - _ => Command::Tui, - } -} - -/// Run a non-TUI subcommand and return the process exit code. -pub async fn run(command: Command) -> i32 { - match command { - Command::Tui => 0, - Command::Help => { - print!("{HELP}"); - 0 - } - Command::Version => { - if update::is_dev_build() { - println!("spotify {} (dev)", update::current_version()); - } else { - println!("spotify {}", update::current_version()); - } - 0 - } - Command::Upgrade { target, check_only } => match upgrade(target, check_only).await { - Ok(code) => code, - Err(error) => { - eprintln!("error: {error}"); - 1 - } - }, - } -} - -async fn upgrade(target: Option, check_only: bool) -> Result { - // Not an error: a working-tree build simply has nothing to upgrade to, and scripts that - // call this in a dev checkout should not fail because of it. - if update::is_dev_build() { - println!( - " {} is a development build — install a release to use upgrade", - update::current_version() - ); - return Ok(0); - } - - let current = update::current_version(); - println!(" current {current}"); - - let release = match &target { - Some(version) => update::release_for(version).await?, - None => update::latest_release().await?, - }; - let version = release.version().to_string(); - println!(" latest {version}"); - - if check_only { - if update::is_newer(&version, current) { - println!(" an upgrade is available — run `spotify upgrade` to install it"); - } else { - println!(" already up to date"); - } - return Ok(0); - } - - // An explicit version is honoured even when it is older, so a bad release can be rolled back. - if target.is_none() && !update::is_newer(&version, current) { - println!(" already up to date"); - return Ok(0); - } - - // Resolved before downloading so an install we cannot write to fails in a second rather - // than after 25 MB. - let plan = update::plan()?; - let asset = plan.asset_name()?; - - let mut progress = Progress::new(&asset); - let staged = - update::download(plan.clone(), &release, |percent| progress.update(percent)).await?; - progress.finish(); - - for path in &plan.targets { - println!(" replacing {}", path.display()); - } - if let Some(themes) = &plan.themes { - println!(" replacing {}", themes.display()); - } - - let installed = update::apply(staged)?; - println!(" upgraded to {installed} — restart spotify to use it"); - Ok(0) -} - -/// A single rewritten line on a terminal; one line per decile when piped, so logs stay sane. -struct Progress { - asset: String, - interactive: bool, - last_decile: Option, -} - -impl Progress { - fn new(asset: &str) -> Self { - Self { - asset: asset.to_string(), - interactive: std::io::stdout().is_terminal(), - last_decile: None, - } - } - - fn update(&mut self, percent: u8) { - if self.interactive { - print!("\r downloading {} {percent:>3}%", self.asset); - let _ = std::io::stdout().flush(); - return; - } - let decile = percent / 10; - if self.last_decile != Some(decile) { - self.last_decile = Some(decile); - println!(" downloading {} {percent:>3}%", self.asset); - } - } - - fn finish(&mut self) { - if self.interactive { - println!(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse_args(args: &[&str]) -> Command { - parse(args.iter().map(|arg| (*arg).to_string())) - } - - #[test] - fn no_arguments_start_the_player() { - assert_eq!(parse_args(&[]), Command::Tui); - } - - #[test] - fn unknown_arguments_fall_through_to_the_player() { - // .desktop launchers and terminal emulators pass through things we do not control, - // so an unrecognised argument must never turn into an error. - assert_eq!(parse_args(&["--enable-features=x"]), Command::Tui); - assert_eq!(parse_args(&["spotify:track:123"]), Command::Tui); - } - - #[test] - fn upgrade_takes_an_optional_version_and_a_check_flag() { - assert_eq!( - parse_args(&["upgrade"]), - Command::Upgrade { - target: None, - check_only: false - } - ); - assert_eq!( - parse_args(&["upgrade", "0.4.6"]), - Command::Upgrade { - target: Some("0.4.6".into()), - check_only: false - } - ); - assert_eq!( - parse_args(&["upgrade", "--check"]), - Command::Upgrade { - target: None, - check_only: true - } - ); - assert_eq!( - parse_args(&["upgrade", "v0.4.6", "--check"]), - Command::Upgrade { - target: Some("v0.4.6".into()), - check_only: true - } - ); - } - - #[test] - fn version_and_help_have_short_forms() { - assert_eq!(parse_args(&["--version"]), Command::Version); - assert_eq!(parse_args(&["-V"]), Command::Version); - assert_eq!(parse_args(&["--help"]), Command::Help); - assert_eq!(parse_args(&["-h"]), Command::Help); - } -} diff --git a/crates/spotify-tui/src/handlers/browse.rs b/crates/spotify-tui/src/handlers/browse.rs deleted file mode 100644 index 00a0efb..0000000 --- a/crates/spotify-tui/src/handlers/browse.rs +++ /dev/null @@ -1,181 +0,0 @@ -use echo_core::{app::AppState, events::AppEvent, models::BrowseNode}; - -pub fn load_event_if_needed(_state: &AppState) -> Option { - None -} - -pub fn enter_active_node(state: &mut AppState) -> Option { - match state.ui.active_browse_node { - BrowseNode::TopTracks => echo_core::intent::open_top_tracks(state), - BrowseNode::RecentlyPlayed => echo_core::intent::open_recently_played(state), - BrowseNode::FollowedArtists => echo_core::intent::open_artist_list(state), - BrowseNode::TopArtists => echo_core::intent::open_top_artists(state), - BrowseNode::WhatsNew => echo_core::intent::open_whats_new(state), - } -} - -pub fn select_node_from_library_index(state: &mut AppState) { - state.ui.active_browse_node = match state.ui.selected_playlist_index { - 0 => BrowseNode::TopTracks, - 1 => BrowseNode::RecentlyPlayed, - 2 => BrowseNode::FollowedArtists, - 3 => BrowseNode::TopArtists, - _ => BrowseNode::WhatsNew, - }; -} - -#[cfg(test)] -mod tests { - use super::*; - use echo_core::app::ActiveView; - - fn fetched_this_session(state: &mut AppState) { - state.data.home_fetches.insert( - echo_core::home::HomeFeed::TopTracks, - echo_core::home::HomeFetch { - in_flight: false, - fetched_at: Some(std::time::Instant::now()), - range: Some(state.ui.library_config.top_items_range), - }, - ); - } - - #[test] - fn top_tracks_selection_does_not_request_fetch() { - let mut state = AppState::new(); - state.ui.active_browse_node = BrowseNode::TopTracks; - - assert!(load_event_if_needed(&state).is_none()); - } - - #[test] - fn entering_empty_top_tracks_requests_fetch() { - let mut state = AppState::new(); - state.ui.active_browse_node = BrowseNode::TopTracks; - - assert!(matches!( - enter_active_node(&mut state), - Some(AppEvent::FetchTopTracks { .. }) - )); - } - - #[test] - fn browse_index_three_maps_to_top_artists() { - let mut state = AppState::new(); - state.ui.selected_playlist_index = 3; - - select_node_from_library_index(&mut state); - - assert_eq!(state.ui.active_browse_node, BrowseNode::TopArtists); - } - - #[test] - fn entering_empty_top_artists_opens_list_and_requests_fetch() { - let mut state = AppState::new(); - state.ui.active_browse_node = BrowseNode::TopArtists; - - let event = enter_active_node(&mut state); - - assert!(matches!(event, Some(AppEvent::FetchTopArtists { .. }))); - assert_eq!(state.ui.active_view, ActiveView::ArtistList); - assert_eq!( - state.ui.artist_list_source, - echo_core::app::ArtistListSource::Top - ); - } - - #[test] - fn clicking_top_track_loads_artist_page() { - let mut state = AppState::new(); - state.ui.active_view = ActiveView::Library; - state.ui.selected_playlist_index = 0; // The "Browse" node - state.data.top_tracks = vec![echo_core::models::Track { - explicit: false, - added_by: None, - id: "track1".to_string(), - source: echo_core::models::TrackSource::Spotify, - local_path: None, - name: "Test Track".to_string(), - artist: "Test Artist".to_string(), - album: String::new(), - added_at: None, - artist_id: Some("artist1".to_string()), - album_id: None, - duration_ms: 60000, - image_url: None, - artists: Vec::new(), - }]; - fetched_this_session(&mut state); - - assert!(enter_active_node(&mut state).is_none()); - assert!( - !state - .data - .active_tracklist_context - .as_ref() - .unwrap() - .requires_worker_load() - ); - } - - #[test] - fn restored_top_tracks_open_and_refresh_in_place() { - let mut state = AppState::new(); - state.ui.active_browse_node = BrowseNode::TopTracks; - state.data.top_tracks = vec![echo_core::models::Track { - explicit: false, - added_by: None, - id: "track".to_string(), - source: echo_core::models::TrackSource::Spotify, - local_path: None, - name: "Track".to_string(), - artist: "Artist".to_string(), - album: String::new(), - added_at: None, - artist_id: None, - duration_ms: 1000, - image_url: None, - album_id: None, - artists: Vec::new(), - }]; - - assert!(matches!( - enter_active_node(&mut state), - Some(AppEvent::FetchTopTracks { .. }) - )); - assert_eq!(state.ui.active_view, ActiveView::TrackList); - } - - #[test] - fn generated_top_tracks_do_not_request_worker_load() { - let mut state = AppState::new(); - state.ui.active_browse_node = BrowseNode::TopTracks; - state.data.top_tracks = vec![echo_core::models::Track { - explicit: false, - added_by: None, - id: "track".to_string(), - source: echo_core::models::TrackSource::Spotify, - local_path: None, - name: "Track".to_string(), - artist: "Artist".to_string(), - album: String::new(), - added_at: None, - artist_id: None, - duration_ms: 1000, - image_url: None, - album_id: None, - artists: Vec::new(), - }]; - fetched_this_session(&mut state); - - assert!(enter_active_node(&mut state).is_none()); - assert!( - !state - .data - .active_tracklist_context - .as_ref() - .unwrap() - .requires_worker_load() - ); - } -} diff --git a/crates/spotify-tui/src/handlers/command.rs b/crates/spotify-tui/src/handlers/command.rs deleted file mode 100644 index 2f9c743..0000000 --- a/crates/spotify-tui/src/handlers/command.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Key handling for command mode. What the commands do lives in [`echo_core::commands`], -//! shared with the desktop app's command bar. - -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::app::{AppMode, AppState}; -use echo_core::events::AppEvent; - -pub fn handle_key(state: &mut AppState, key: &KeyEvent) -> Option { - match key.code { - KeyCode::Tab => { - echo_core::commands::cycle_suggestion(state, true); - None - } - KeyCode::BackTab => { - echo_core::commands::cycle_suggestion(state, false); - None - } - KeyCode::Esc => { - echo_core::commands::clear_suggestions(state); - state.ui.mode = AppMode::Normal; - state.ui.command_buffer.clear(); - state.ui.needs_terminal_clear = true; - None - } - KeyCode::Backspace => { - echo_core::commands::clear_suggestions(state); - state.ui.command_buffer.pop(); - None - } - KeyCode::Char(c) => { - echo_core::commands::clear_suggestions(state); - state.ui.command_buffer.push(c); - None - } - KeyCode::Enter => echo_core::commands::submit(state), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn enter_submits_the_buffer_through_the_shared_registry() { - let mut state = AppState::new(); - state.ui.mode = AppMode::Command; - state.ui.command_buffer = "newlocalplaylist Road Mix".to_string(); - let key = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE); - - let Some(AppEvent::CreateLocalPlaylist(name)) = handle_key(&mut state, &key) else { - panic!("expected CreateLocalPlaylist"); - }; - - assert_eq!(name, "Road Mix"); - assert_eq!(state.ui.mode, AppMode::Normal); - } -} diff --git a/crates/spotify-tui/src/handlers/keymap.rs b/crates/spotify-tui/src/handlers/keymap.rs deleted file mode 100644 index 280fe46..0000000 --- a/crates/spotify-tui/src/handlers/keymap.rs +++ /dev/null @@ -1,285 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use crate::handlers::navigation::{self, NavigationCommand}; -use echo_core::app::{AppState, TrackSort}; -use echo_core::events::AppEvent; - -const SEQUENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum KeymapAction { - First, - Last, - PageUp, - PageDown, - HalfPageUp, - HalfPageDown, - CurrentContext, - PlayPause, - Next, - Previous, - Shuffle, - Repeat, - SeekBackward, - SeekForward, - SeekStart, - Mute, - SortOriginal, - SortTitle, - SortArtist, - SortAlbum, - SortDuration, - SortAdded, - ReverseTracks, - Redraw, - ToggleThumbnails, -} - -pub struct ConfiguredKey { - pub consumed: bool, - pub action: Option, -} - -pub fn configured_action(state: &mut AppState, key: &KeyEvent) -> ConfiguredKey { - if state.ui.library_config.keybindings.is_empty() { - return ConfiguredKey { - consumed: false, - action: None, - }; - } - if state - .ui - .pending_key_sequence - .as_ref() - .is_some_and(|(_, started)| started.elapsed() >= SEQUENCE_TIMEOUT) - { - state.ui.pending_key_sequence = None; - } - let token = key_token(key); - if let Some((prefix, _)) = state.ui.pending_key_sequence.as_ref() { - let sequence = format!("{prefix} {token}"); - if let Some(action) = state - .ui - .library_config - .keybindings - .get(&sequence) - .and_then(|name| parse_action(name)) - { - state.ui.pending_key_sequence = None; - return ConfiguredKey { - consumed: true, - action: Some(action), - }; - } - } - if let Some(action) = state - .ui - .library_config - .keybindings - .get(&token) - .and_then(|name| parse_action(name)) - { - return ConfiguredKey { - consumed: true, - action: Some(action), - }; - } - let prefix = format!("{token} "); - if state - .ui - .library_config - .keybindings - .keys() - .any(|binding| binding.starts_with(&prefix)) - { - state.ui.pending_key_sequence = Some((token, std::time::Instant::now())); - return ConfiguredKey { - consumed: true, - action: None, - }; - } - ConfiguredKey { - consumed: false, - action: None, - } -} - -pub fn execute(state: &mut AppState, action: KeymapAction) -> Option { - let navigation_command = match action { - KeymapAction::First => Some(NavigationCommand::First), - KeymapAction::Last => Some(NavigationCommand::Last), - KeymapAction::PageUp => Some(NavigationCommand::PageUp), - KeymapAction::PageDown => Some(NavigationCommand::PageDown), - KeymapAction::HalfPageUp => Some(NavigationCommand::HalfPageUp), - KeymapAction::HalfPageDown => Some(NavigationCommand::HalfPageDown), - KeymapAction::CurrentContext => Some(NavigationCommand::CurrentContext), - _ => None, - }; - if let Some(command) = navigation_command { - return navigation::execute(state, command); - } - match action { - KeymapAction::PlayPause => Some(echo_core::intent::toggle_playback(state)), - KeymapAction::Next => Some(echo_core::intent::next_track(state)), - KeymapAction::Previous => Some(echo_core::intent::previous_track(state)), - KeymapAction::Shuffle => Some(echo_core::intent::toggle_shuffle(state)), - KeymapAction::Repeat => Some(echo_core::intent::cycle_repeat(state)), - KeymapAction::SeekBackward => echo_core::intent::seek_by(state, -5), - KeymapAction::SeekForward => echo_core::intent::seek_by(state, 5), - KeymapAction::SeekStart => echo_core::intent::seek_to(state, 0), - KeymapAction::Mute => Some(echo_core::intent::toggle_mute(state)), - KeymapAction::SortOriginal => sort(state, TrackSort::Original), - KeymapAction::SortTitle => sort(state, TrackSort::Title), - KeymapAction::SortArtist => sort(state, TrackSort::Artist), - KeymapAction::SortAlbum => sort(state, TrackSort::Album), - KeymapAction::SortDuration => sort(state, TrackSort::Duration), - KeymapAction::SortAdded => sort(state, TrackSort::Added), - KeymapAction::ReverseTracks => { - state.reverse_tracks(); - None - } - KeymapAction::Redraw => { - state.ui.needs_terminal_clear = true; - None - } - KeymapAction::ToggleThumbnails => { - let enabled = !state.ui.library_config.library_thumbnails; - state.set_library_thumbnails(enabled); - None - } - KeymapAction::First - | KeymapAction::Last - | KeymapAction::PageUp - | KeymapAction::PageDown - | KeymapAction::HalfPageUp - | KeymapAction::HalfPageDown - | KeymapAction::CurrentContext => unreachable!(), - } -} - -fn sort(state: &mut AppState, mode: TrackSort) -> Option { - if state.ui.active_view == echo_core::app::ActiveView::TrackList { - state.sort_tracks(mode); - } - None -} - -fn key_token(key: &KeyEvent) -> String { - let key_name = match key.code { - KeyCode::Char(' ') => "space".to_string(), - KeyCode::Char(character) => character.to_string(), - KeyCode::Enter => "enter".to_string(), - KeyCode::Esc => "esc".to_string(), - KeyCode::Tab => "tab".to_string(), - KeyCode::BackTab => "backtab".to_string(), - KeyCode::Backspace => "backspace".to_string(), - KeyCode::Up => "up".to_string(), - KeyCode::Down => "down".to_string(), - KeyCode::Left => "left".to_string(), - KeyCode::Right => "right".to_string(), - KeyCode::Home => "home".to_string(), - KeyCode::End => "end".to_string(), - KeyCode::PageUp => "pageup".to_string(), - KeyCode::PageDown => "pagedown".to_string(), - _ => return format!("{:?}", key.code).to_lowercase(), - }; - let mut modifiers = Vec::new(); - if key.modifiers.contains(KeyModifiers::CONTROL) { - modifiers.push("ctrl"); - } - if key.modifiers.contains(KeyModifiers::ALT) { - modifiers.push("alt"); - } - if modifiers.is_empty() { - key_name - } else { - format!("{}-{key_name}", modifiers.join("-")) - } -} - -fn parse_action(name: &str) -> Option { - Some(match name { - "first" => KeymapAction::First, - "last" => KeymapAction::Last, - "page_up" => KeymapAction::PageUp, - "page_down" => KeymapAction::PageDown, - "half_page_up" => KeymapAction::HalfPageUp, - "half_page_down" => KeymapAction::HalfPageDown, - "current_context" => KeymapAction::CurrentContext, - "play_pause" => KeymapAction::PlayPause, - "next" => KeymapAction::Next, - "previous" => KeymapAction::Previous, - "shuffle" => KeymapAction::Shuffle, - "repeat" => KeymapAction::Repeat, - "seek_backward" => KeymapAction::SeekBackward, - "seek_forward" => KeymapAction::SeekForward, - "seek_start" => KeymapAction::SeekStart, - "mute" => KeymapAction::Mute, - "sort_original" => KeymapAction::SortOriginal, - "sort_title" => KeymapAction::SortTitle, - "sort_artist" => KeymapAction::SortArtist, - "sort_album" => KeymapAction::SortAlbum, - "sort_duration" => KeymapAction::SortDuration, - "sort_added" => KeymapAction::SortAdded, - "reverse_tracks" => KeymapAction::ReverseTracks, - "redraw" => KeymapAction::Redraw, - "toggle_thumbnails" => KeymapAction::ToggleThumbnails, - _ => return None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn configured_sequences_resolve_to_semantic_actions() { - let mut state = AppState::new(); - state - .ui - .library_config - .keybindings - .insert("s d".to_string(), "sort_duration".to_string()); - let first = configured_action( - &mut state, - &KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), - ); - assert!(first.consumed && first.action.is_none()); - let second = configured_action( - &mut state, - &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), - ); - assert_eq!(second.action, Some(KeymapAction::SortDuration)); - } - - #[test] - fn control_keys_use_stable_names() { - assert_eq!( - key_token(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), - "ctrl-f" - ); - } - - #[test] - fn configured_redraw_requests_terminal_clear() { - let mut state = AppState::new(); - state - .ui - .library_config - .keybindings - .insert("z r".to_string(), "redraw".to_string()); - - let first = configured_action( - &mut state, - &KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), - ); - assert!(first.consumed); - let second = configured_action( - &mut state, - &KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), - ); - execute(&mut state, second.action.unwrap()); - - assert!(state.ui.needs_terminal_clear); - } -} diff --git a/crates/spotify-tui/src/handlers/mod.rs b/crates/spotify-tui/src/handlers/mod.rs deleted file mode 100644 index cc470e0..0000000 --- a/crates/spotify-tui/src/handlers/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -pub mod browse; -pub mod command; -pub mod keymap; -pub mod navigation; -pub mod normal; -pub mod search; -pub mod setup; -pub mod tracklist; -pub mod visual; - -use crossterm::event::KeyEvent; -use echo_core::app::{AppMode, AppState}; -use echo_core::events::AppEvent; - -pub fn handle_event(state: &mut AppState, key_event: &KeyEvent) -> Option { - match state.ui.mode { - AppMode::Setup => setup::handle_key(state, key_event), - AppMode::Normal => normal::handle_key(state, key_event), - AppMode::Command => command::handle_key(state, key_event), - AppMode::Search => search::handle_key(state, key_event), - AppMode::Visual => visual::handle_key(state, key_event), - _ => None, - } -} diff --git a/crates/spotify-tui/src/handlers/navigation.rs b/crates/spotify-tui/src/handlers/navigation.rs deleted file mode 100644 index 3c7c696..0000000 --- a/crates/spotify-tui/src/handlers/navigation.rs +++ /dev/null @@ -1,277 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use crate::handlers::browse; -use echo_core::app::{ActiveView, AppState, LibraryTab, SearchTab}; -use echo_core::events::AppEvent; - -const PAGE_ROWS: usize = 20; -const SEQUENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum NavigationCommand { - First, - Last, - PageUp, - PageDown, - HalfPageUp, - HalfPageDown, - CurrentContext, -} - -pub struct NavigationKey { - pub consumed: bool, - pub command: Option, -} - -pub fn command_for_key(state: &mut AppState, key: &KeyEvent) -> NavigationKey { - if state - .ui - .pending_key_sequence - .as_ref() - .is_some_and(|(_, started)| started.elapsed() >= SEQUENCE_TIMEOUT) - { - state.ui.pending_key_sequence = None; - } - - if state - .ui - .pending_key_sequence - .as_ref() - .is_some_and(|(pending, _)| pending == "g") - && key.code == KeyCode::Char('c') - && key.modifiers.is_empty() - { - state.ui.pending_key_sequence = None; - return NavigationKey { - consumed: true, - command: Some(NavigationCommand::CurrentContext), - }; - } - - if key.code == KeyCode::Char('g') && key.modifiers.is_empty() { - if state - .ui - .pending_key_sequence - .take() - .is_some_and(|(key, _)| key == "g") - { - return NavigationKey { - consumed: true, - command: Some(NavigationCommand::First), - }; - } - state.ui.pending_key_sequence = Some(("g".to_string(), std::time::Instant::now())); - return NavigationKey { - consumed: true, - command: None, - }; - } - - state.ui.pending_key_sequence = None; - let command = match (key.code, key.modifiers) { - (KeyCode::Char('G') | KeyCode::End, _) => Some(NavigationCommand::Last), - (KeyCode::Home, _) => Some(NavigationCommand::First), - (KeyCode::PageUp, _) => Some(NavigationCommand::PageUp), - (KeyCode::PageDown, _) => Some(NavigationCommand::PageDown), - (KeyCode::Char('b'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { - Some(NavigationCommand::PageUp) - } - (KeyCode::Char('f'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { - Some(NavigationCommand::PageDown) - } - (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { - Some(NavigationCommand::HalfPageUp) - } - (KeyCode::Char('d'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => { - Some(NavigationCommand::HalfPageDown) - } - _ => None, - }; - NavigationKey { - consumed: command.is_some(), - command, - } -} - -pub fn execute(state: &mut AppState, command: NavigationCommand) -> Option { - if command == NavigationCommand::CurrentContext { - return echo_core::intent::jump_to_current_context(state); - } - let len = selected_list_len(state); - if len == 0 { - return None; - } - let current = selected_index(state); - let target = match command { - NavigationCommand::First => 0, - NavigationCommand::Last => len - 1, - NavigationCommand::PageUp => current.saturating_sub(PAGE_ROWS), - NavigationCommand::PageDown => (current + PAGE_ROWS).min(len - 1), - NavigationCommand::HalfPageUp => current.saturating_sub(PAGE_ROWS / 2), - NavigationCommand::HalfPageDown => (current + PAGE_ROWS / 2).min(len - 1), - NavigationCommand::CurrentContext => unreachable!(), - }; - set_selected_index(state, target); - if state.ui.active_view == ActiveView::Library - && state.ui.active_library_tab == LibraryTab::Browse - { - browse::select_node_from_library_index(state); - return browse::load_event_if_needed(state); - } - None -} - -fn selected_list_len(state: &AppState) -> usize { - match state.ui.active_view { - ActiveView::Library => match state.ui.active_library_tab { - LibraryTab::Playlists => state.data.library_view.len(), - LibraryTab::Albums => state.data.saved_albums.len(), - LibraryTab::Browse => 5, - // Desktop-only tab; unreachable in the TUI. - LibraryTab::Artists => state.data.followed_artists.len(), - }, - ActiveView::TrackList => state.data.tracks.len(), - ActiveView::SearchResults => match state.ui.active_search_tab { - SearchTab::All | SearchTab::Tracks => state.data.search_results.tracks.len(), - SearchTab::Albums => state.data.search_results.albums.len(), - SearchTab::Artists => state.data.search_results.artists.len(), - SearchTab::Playlists => state.data.search_results.playlists.len(), - }, - ActiveView::Queue => state.queue_view_len(), - ActiveView::Devices => state.data.devices.len(), - ActiveView::ArtistList => state.artist_list().len(), - ActiveView::ArtistPage => state - .data - .artist_page_data - .as_ref() - .map_or(0, |data| data.albums.len()), - ActiveView::WhatsNew => state.data.whats_new.len(), - ActiveView::Home => 0, - } -} - -fn selected_index(state: &AppState) -> usize { - match state.ui.active_view { - ActiveView::Library => state.ui.selected_playlist_index, - ActiveView::TrackList => state.ui.selected_track_index, - ActiveView::SearchResults => state.ui.selected_search_index, - ActiveView::Queue => state.ui.selected_queue_index, - ActiveView::Devices => state.ui.selected_device_index, - ActiveView::ArtistList => state.ui.selected_artist_index, - ActiveView::ArtistPage => state.ui.artist_page_album_index, - ActiveView::WhatsNew => state.ui.selected_whats_new_index, - ActiveView::Home => 0, - } -} - -fn set_selected_index(state: &mut AppState, index: usize) { - match state.ui.active_view { - ActiveView::Library => state.ui.selected_playlist_index = index, - ActiveView::TrackList => state.ui.selected_track_index = index, - ActiveView::SearchResults => state.ui.selected_search_index = index, - ActiveView::Queue => state.ui.selected_queue_index = index, - ActiveView::Devices => state.ui.selected_device_index = index, - ActiveView::ArtistList => state.ui.selected_artist_index = index, - ActiveView::ArtistPage => state.ui.artist_page_album_index = index, - ActiveView::WhatsNew => state.ui.selected_whats_new_index = index, - ActiveView::Home => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gg_and_g_move_to_list_boundaries() { - let mut state = AppState::new(); - state.ui.active_view = ActiveView::TrackList; - state.data.tracks = (0..30) - .map(|index| echo_core::models::Track { - explicit: false, - added_by: None, - id: index.to_string(), - source: echo_core::models::TrackSource::Spotify, - local_path: None, - name: index.to_string(), - artist: String::new(), - album: String::new(), - added_at: None, - duration_ms: 0, - image_url: None, - album_id: None, - artist_id: None, - artists: Vec::new(), - }) - .collect(); - state.ui.selected_track_index = 12; - - let first_g = command_for_key( - &mut state, - &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), - ); - assert!(first_g.consumed && first_g.command.is_none()); - let second_g = command_for_key( - &mut state, - &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), - ); - execute(&mut state, second_g.command.unwrap()); - assert_eq!(state.ui.selected_track_index, 0); - - execute(&mut state, NavigationCommand::Last); - assert_eq!(state.ui.selected_track_index, 29); - } - - #[test] - fn page_motion_clamps_to_list_bounds() { - let mut state = AppState::new(); - state.ui.active_view = ActiveView::Queue; - state.data.queue = vec![echo_core::models::Track { - explicit: false, - added_by: None, - id: "one".to_string(), - source: echo_core::models::TrackSource::Spotify, - local_path: None, - name: String::new(), - artist: String::new(), - album: String::new(), - added_at: None, - duration_ms: 0, - image_url: None, - album_id: None, - artist_id: None, - artists: Vec::new(), - }]; - execute(&mut state, NavigationCommand::PageDown); - assert_eq!(state.ui.selected_queue_index, 0); - } - - #[test] - fn gc_highlights_playing_track_in_loaded_context() { - let mut state = AppState::new(); - state.ui.active_view = ActiveView::TrackList; - state.data.tracks = ["one", "two"] - .into_iter() - .map(|id| echo_core::models::Track { - explicit: false, - added_by: None, - id: id.to_string(), - source: echo_core::models::TrackSource::Spotify, - local_path: None, - name: id.to_string(), - artist: String::new(), - album: String::new(), - added_at: None, - duration_ms: 0, - image_url: None, - album_id: None, - artist_id: None, - artists: Vec::new(), - }) - .collect(); - state.playback.playing_track_id = Some("two".to_string()); - - execute(&mut state, NavigationCommand::CurrentContext); - assert_eq!(state.ui.selected_track_index, 1); - } -} diff --git a/crates/spotify-tui/src/handlers/normal/mod.rs b/crates/spotify-tui/src/handlers/normal/mod.rs deleted file mode 100644 index fcb8a6f..0000000 --- a/crates/spotify-tui/src/handlers/normal/mod.rs +++ /dev/null @@ -1,645 +0,0 @@ -use crate::handlers::{browse, tracklist}; -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::app::{ActiveView, AppState}; -use echo_core::events::AppEvent; - -mod modals; -mod prompts; - -pub fn handle_key(state: &mut AppState, key: &KeyEvent) -> Option { - let (prompt_active, prompt_event) = prompts::handle(state, key); - if let Some(event) = prompt_event { - return Some(event); - } - if prompt_active { - return None; - } - - let (modal_active, modal_event) = modals::handle(state, key); - if let Some(event) = modal_event { - return Some(event); - } - if modal_active { - return None; - } - - let configured = crate::handlers::keymap::configured_action(state, key); - if let Some(action) = configured.action { - return crate::handlers::keymap::execute(state, action); - } - if configured.consumed { - return None; - } - - let navigation = crate::handlers::navigation::command_for_key(state, key); - if let Some(command) = navigation.command { - return crate::handlers::navigation::execute(state, command); - } - if navigation.consumed { - return None; - } - - if key.code != KeyCode::Char('d') { - state.ui.pending_d_press = false; - } - - match key.code { - KeyCode::Char('j') | KeyCode::Down => match state.ui.active_view { - ActiveView::Home => {} - ActiveView::Library => { - if state.ui.active_library_tab == echo_core::app::LibraryTab::Browse { - if state.ui.selected_playlist_index < 4 { - state.ui.selected_playlist_index += 1; - } - browse::select_node_from_library_index(state); - return browse::load_event_if_needed(state); - } else { - let max_len = - if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - state.data.saved_albums.len() - } else { - state.data.library_view.len() - }; - if max_len > 0 && state.ui.selected_playlist_index < max_len.saturating_sub(1) { - state.ui.selected_playlist_index += 1; - } - } - } - ActiveView::TrackList => { - if state.ui.selected_track_index < state.data.tracks.len().saturating_sub(1) { - state.ui.selected_track_index += 1; - } - } - ActiveView::ArtistList => { - if !state.data.followed_artists.is_empty() - && state.ui.selected_artist_index - < state.data.followed_artists.len().saturating_sub(1) - { - state.ui.selected_artist_index += 1; - } - } - ActiveView::ArtistPage => { - if let Some(ref data) = state.data.artist_page_data - && !data.albums.is_empty() - && state.ui.artist_page_album_index < data.albums.len().saturating_sub(1) - { - state.ui.artist_page_album_index += 1; - } - } - ActiveView::SearchResults => { - let max = search_results_len(state); - if max > 0 && state.ui.selected_search_index < max.saturating_sub(1) { - state.ui.selected_search_index += 1; - } - } - ActiveView::Queue => { - if state.ui.selected_queue_index + 1 < state.queue_view_len() { - state.ui.selected_queue_index += 1; - } - } - ActiveView::Devices => { - if !state.data.devices.is_empty() - && state.ui.selected_device_index < state.data.devices.len().saturating_sub(1) - { - state.ui.selected_device_index += 1; - } - } - ActiveView::WhatsNew => { - if !state.data.whats_new.is_empty() - && state.ui.selected_whats_new_index - < state.data.whats_new.len().saturating_sub(1) - { - state.ui.selected_whats_new_index += 1; - } - } - }, - KeyCode::Char('k') | KeyCode::Up => match state.ui.active_view { - ActiveView::Home => {} - ActiveView::Library => { - if state.ui.active_library_tab == echo_core::app::LibraryTab::Browse { - if state.ui.selected_playlist_index > 0 { - state.ui.selected_playlist_index -= 1; - } - browse::select_node_from_library_index(state); - return browse::load_event_if_needed(state); - } else if state.ui.selected_playlist_index > 0 { - state.ui.selected_playlist_index -= 1; - } - } - ActiveView::TrackList => { - if state.ui.selected_track_index > 0 { - state.ui.selected_track_index -= 1; - } - } - ActiveView::ArtistList => { - if state.ui.selected_artist_index > 0 { - state.ui.selected_artist_index -= 1; - } - } - ActiveView::ArtistPage => { - if state.ui.artist_page_album_index > 0 { - state.ui.artist_page_album_index -= 1; - } - } - ActiveView::SearchResults => { - if state.ui.selected_search_index > 0 { - state.ui.selected_search_index -= 1; - } - } - ActiveView::Queue => { - if state.ui.selected_queue_index > 0 { - state.ui.selected_queue_index -= 1; - } - } - ActiveView::Devices => { - if state.ui.selected_device_index > 0 { - state.ui.selected_device_index -= 1; - } - } - ActiveView::WhatsNew => { - if state.ui.selected_whats_new_index > 0 { - state.ui.selected_whats_new_index -= 1; - } - } - }, - KeyCode::Char('l') | KeyCode::Char('L') - if key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - && !key - .modifiers - .contains(crossterm::event::KeyModifiers::SHIFT) => - { - state.ui.needs_terminal_clear = true; - } - KeyCode::Char('l') => { - if state.ui.active_view == ActiveView::Library { - return crate::handlers::normal::handle_key( - state, - &KeyEvent::new(KeyCode::Enter, key.modifiers), - ); - } - return echo_core::intent::toggle_like_selected(state); - } - KeyCode::Char('L') => { - if key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) - { - echo_core::intent::toggle_condensed_lyrics(state); - } else { - state.ui.lyrics_modal_open = !state.ui.lyrics_modal_open; - } - } - KeyCode::Enter | KeyCode::Char('z') => { - if state.ui.active_view == ActiveView::Library { - let index = state.ui.selected_playlist_index; - if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - return echo_core::intent::open_album(state, index); - } else if state.ui.active_library_tab == echo_core::app::LibraryTab::Browse { - if let Some(event) = browse::enter_active_node(state) { - return Some(event); - } - } else { - return echo_core::intent::open_library_entry(state, index); - } - } else if state.ui.active_view == ActiveView::TrackList { - return tracklist::play_selected(state); - } else if state.ui.active_view == ActiveView::Queue { - return echo_core::intent::play_queue_track_at( - state, - state.ui.selected_queue_index, - ); - } else if state.ui.active_view == ActiveView::SearchResults { - return echo_core::intent::activate_search_result( - state, - state.ui.selected_search_index, - ); - } else if state.ui.active_view == ActiveView::ArtistList { - return echo_core::intent::open_artist_at(state, state.ui.selected_artist_index); - } else if state.ui.active_view == ActiveView::ArtistPage { - return echo_core::intent::open_artist_album( - state, - state.ui.artist_page_album_index, - ); - } else if state.ui.active_view == ActiveView::WhatsNew { - return echo_core::intent::open_whats_new_album( - state, - state.ui.selected_whats_new_index, - ); - } - } - KeyCode::Char(':') => { - state.ui.mode = echo_core::app::AppMode::Command; - state.ui.command_buffer.clear(); - state.ui.status_message = None; - } - KeyCode::Char('/') => { - state.ui.mode = echo_core::app::AppMode::Search; - state.ui.search_query.clear(); - state.ui.search_matches.clear(); - state.ui.status_message = None; - } - KeyCode::Char('f') => { - state.ui.mode = echo_core::app::AppMode::Command; - state.ui.command_buffer = "search ".to_string(); - state.ui.status_message = None; - } - KeyCode::Char('n') if !state.ui.search_matches.is_empty() => { - echo_core::intent::next_search_match(state, true); - } - KeyCode::Char('N') if !state.ui.search_matches.is_empty() => { - echo_core::intent::next_search_match(state, false); - } - // `x` cuts a playlist into the operation register so `p` can paste it into a folder. - // The synthetic rows are excluded: their position is fixed. - KeyCode::Char('x') if state.ui.active_view == ActiveView::Library => { - if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - return None; - } - if let Some(echo_core::models::LibraryNode::Playlist { playlist, .. }) = state - .data - .library_view - .get(state.ui.selected_playlist_index) - && playlist.id != "LIKED_SONGS" - && playlist.id != "local-library" - { - let playlist_id = playlist.id.clone(); - state.ui.operation_register = vec![playlist_id.clone()]; - for f in &mut state.ui.library_config.folders { - f.playlists.retain(|id| id != &playlist_id); - } - state.save_library_config(); - state.compute_library_view(); - } - } - KeyCode::Char('d') => { - echo_core::intent::mark_selected_for_delete(state); - } - KeyCode::Char('A') => { - let ctx = if state.ui.active_view == ActiveView::TrackList - && state.ui.selected_track_index < state.data.tracks.len() - { - let t = &state.data.tracks[state.ui.selected_track_index]; - Some(echo_core::models::ActionMenuContext::from(t)) - } else if state.ui.active_view == ActiveView::Queue - && let Some(t) = state.queue_view_track(state.ui.selected_queue_index) - { - Some(echo_core::models::ActionMenuContext::from(t)) - } else if let Some(t) = echo_core::intent::selected_artist_top_track(state) { - Some(echo_core::models::ActionMenuContext::from(t)) - } else if let Some(t) = echo_core::intent::selected_search_track(state) { - Some(echo_core::models::ActionMenuContext::from(t)) - } else if state.playback.playing_track_id.is_some() { - Some(echo_core::models::ActionMenuContext { - track_id: state.playback.playing_track_id.clone().unwrap_or_default(), - source: state - .playback - .playing_track_source - .unwrap_or(echo_core::models::TrackSource::Spotify), - track_name: state.playback.playing_track_title.clone(), - local_path: state.playback.playing_track_local_path.clone(), - album_id: state.playback.playing_track_album_id.clone(), - album_name: state - .data - .local_library - .tracks - .iter() - .find(|track| { - Some(track.id.as_str()) == state.playback.playing_track_id.as_deref() - }) - .map(|track| track.album.clone()) - .unwrap_or_default(), - artist_id: state.playback.playing_track_artist_id.clone(), - artist_name: state.playback.playing_track_artist.clone(), - }) - } else { - None - }; - - if let Some(ctx) = ctx { - state.ui.action_menu_context = Some(ctx); - state.ui.action_menu_open = true; - state.ui.selected_action_index = 0; - } - } - KeyCode::Char('a') => { - if state.ui.active_view == ActiveView::SearchResults - && state.ui.active_search_tab == echo_core::app::SearchTab::Albums - { - if state.ui.selected_search_index < state.data.search_results.albums.len() { - let album = &state.data.search_results.albums[state.ui.selected_search_index]; - state.ui.status_message = Some( - echo_core::i18n::t( - "messages.saved_to_library", - &state.ui.library_config.language, - ) - .replace("{}", &album.name), - ); - state.ui.status_message_expiry = - Some(std::time::Instant::now() + std::time::Duration::from_secs(3)); - return Some(AppEvent::SaveAlbums(vec![album.id.clone()])); - } - } else { - state.ui.playlist_add_modal_open = true; - state.ui.selected_playlist_modal_index = 0; - } - } - KeyCode::Char('p') - if state.ui.active_view == ActiveView::Library - && !state.ui.operation_register.is_empty() - && state.ui.selected_playlist_index < state.data.library_view.len() => - { - let node = &state.data.library_view[state.ui.selected_playlist_index]; - match node { - echo_core::models::LibraryNode::Folder(f) => { - let folder_name = f.name.clone(); - if let Some(folder) = state - .ui - .library_config - .folders - .iter_mut() - .find(|fd| fd.name == folder_name) - { - for id in &state.ui.operation_register { - if !folder.playlists.contains(id) { - folder.playlists.push(id.clone()); - } - } - } - for id in &state.ui.operation_register { - state.ui.library_config.pinned.retain(|p| p != id); - } - } - echo_core::models::LibraryNode::Playlist { .. } => {} - } - state.ui.operation_register.clear(); - state.save_library_config(); - state.compute_library_view(); - } - KeyCode::Char('m') if state.ui.active_view == ActiveView::Library => { - echo_core::intent::toggle_pin_selected(state); - } - KeyCode::Char('h') | KeyCode::Esc | KeyCode::Backspace => { - if state.pop_view_history() { - state.clear_pending_artist_page(); - if state.data.tracklist_image_url.is_some() { - return Some(AppEvent::ReloadHeaderImage); - } - } else if state.ui.active_view == ActiveView::TrackList { - if search_has_results(state) { - state.ui.active_view = ActiveView::SearchResults; - } else { - state.ui.active_view = ActiveView::Library; - } - } else if state.ui.active_view == ActiveView::Queue - || state.ui.active_view == ActiveView::ArtistList - { - state.ui.active_view = ActiveView::Library; - if state.data.tracklist_image_url.is_some() { - return Some(AppEvent::ReloadHeaderImage); - } - } else if state.ui.active_view == ActiveView::ArtistPage { - if search_has_results(state) { - state.ui.active_view = ActiveView::SearchResults; - state.clear_pending_artist_page(); - return Some(AppEvent::CancelArtistPageLoad); - } - return Some(echo_core::intent::back_to_artist_list(state)); - } else if state.ui.active_view == ActiveView::SearchResults { - state.ui.active_view = ActiveView::Library; - state.data.search_results = echo_core::models::SearchResults::default(); - state.ui.search_context_query.clear(); - state.ui.status_message = None; - if state.data.tracklist_image_url.is_some() { - return Some(AppEvent::ReloadHeaderImage); - } - } - } - KeyCode::Char('q') => { - return echo_core::intent::queue_selected_track(state); - } - KeyCode::Char('Q') => { - return Some(echo_core::intent::open_queue(state)); - } - KeyCode::Char('D') => { - return Some(echo_core::intent::open_device_picker(state)); - } - KeyCode::Char(' ') => { - state.playback.is_playing = !state.playback.is_playing; - state.playback.playback_last_updated_at = Some(std::time::Instant::now()); - return Some(AppEvent::TogglePlayback(state.playback.is_playing)); - } - KeyCode::Char('c') if state.ui.active_view == ActiveView::Library => { - state.ui.mode = echo_core::app::AppMode::Command; - state.ui.command_buffer = "newplaylist ".to_string(); - } - KeyCode::Char('e') => { - if state.ui.active_view == ActiveView::Library - && let Some(node) = state - .data - .library_view - .get(state.ui.selected_playlist_index) - { - match node { - echo_core::models::LibraryNode::Playlist { playlist, .. } => { - state.ui.mode = echo_core::app::AppMode::Command; - state.ui.command_buffer = format!("rename {}", playlist.name); - } - echo_core::models::LibraryNode::Folder(f) => { - state.ui.mode = echo_core::app::AppMode::Command; - state.ui.command_buffer = format!("rename {}", f.name); - } - } - } - } - KeyCode::Char('s') => { - return Some(echo_core::intent::toggle_shuffle(state)); - } - KeyCode::Char('v') => { - state.ui.mode = echo_core::app::AppMode::Visual; - let current_idx = match state.ui.active_view { - ActiveView::Home => 0, - ActiveView::TrackList => state.ui.selected_track_index, - ActiveView::SearchResults => state.ui.selected_search_index, - ActiveView::Queue => state.ui.selected_queue_index, - ActiveView::Library => state.ui.selected_playlist_index, - ActiveView::Devices => state.ui.selected_device_index, - ActiveView::ArtistList => state.ui.selected_artist_index, - ActiveView::ArtistPage => state.ui.artist_page_album_index, - ActiveView::WhatsNew => state.ui.selected_whats_new_index, - }; - state.ui.visual_selection_start = Some(current_idx); - state.ui.status_message = Some(echo_core::i18n::t( - "messages.visual_block", - &state.ui.library_config.language, - )); - } - KeyCode::Char(',') => return seek_by(state, -5), - KeyCode::Char('.') => return seek_by(state, 5), - KeyCode::Char('0') => return seek_to(state, 0), - KeyCode::Char('M') => { - let volume = state.playback.toggle_mute_target(); - state.playback.volume = volume; - state.save_volume(); - return Some(AppEvent::SetVolume(volume as u8)); - } - KeyCode::Char('T') => { - let enabled = !state.ui.library_config.library_thumbnails; - state.set_library_thumbnails(enabled); - } - KeyCode::Char(']') | KeyCode::Char('>') => { - state.playback.progress_ms = 0; - state.playback.duration_ms = 0; - state.playback.playback_last_updated_at = Some(std::time::Instant::now()); - return Some(AppEvent::NextTrack { - current_track_id: state.playback.playing_track_id.clone(), - }); - } - KeyCode::Char('[') | KeyCode::Char('<') => { - state.playback.progress_ms = 0; - state.playback.duration_ms = 0; - state.playback.playback_last_updated_at = Some(std::time::Instant::now()); - return Some(AppEvent::PreviousTrack { - current_track_id: state.playback.playing_track_id.clone(), - }); - } - KeyCode::Char('R') => { - return echo_core::intent::refresh_view(state); - } - KeyCode::Char('r') => { - return Some(echo_core::intent::cycle_repeat(state)); - } - KeyCode::Char('=') => { - return Some(echo_core::intent::adjust_volume(state, 1)); - } - KeyCode::Char('-') => { - return Some(echo_core::intent::adjust_volume(state, -1)); - } - KeyCode::Char('+') => { - return Some(echo_core::intent::adjust_volume(state, 5)); - } - KeyCode::Char('_') => { - return Some(echo_core::intent::adjust_volume(state, -5)); - } - KeyCode::Char('F') if state.ui.active_view == ActiveView::ArtistPage => { - return echo_core::intent::toggle_follow_artist(state); - } - KeyCode::Tab => { - if state.ui.active_view == ActiveView::Queue { - echo_core::intent::cycle_queue_tab(state); - } else if state.ui.active_view == ActiveView::ArtistPage { - echo_core::intent::cycle_discography_filter(state); - } else if state.ui.active_view == ActiveView::Library { - state.ui.active_library_tab = match state.ui.active_library_tab { - echo_core::app::LibraryTab::Playlists => echo_core::app::LibraryTab::Albums, - echo_core::app::LibraryTab::Albums => echo_core::app::LibraryTab::Browse, - // Artists is desktop-only; fold it into the cycle defensively. - echo_core::app::LibraryTab::Browse | echo_core::app::LibraryTab::Artists => { - echo_core::app::LibraryTab::Playlists - } - }; - state.ui.selected_playlist_index = 0; - if state.ui.active_library_tab == echo_core::app::LibraryTab::Browse { - state.ui.active_browse_node = echo_core::models::BrowseNode::TopTracks; - return browse::load_event_if_needed(state); - } - } else if state.ui.active_view == ActiveView::SearchResults { - state.ui.active_search_tab = match state.ui.active_search_tab { - echo_core::app::SearchTab::All | echo_core::app::SearchTab::Tracks => { - echo_core::app::SearchTab::Albums - } - echo_core::app::SearchTab::Albums => echo_core::app::SearchTab::Artists, - echo_core::app::SearchTab::Artists => echo_core::app::SearchTab::Playlists, - echo_core::app::SearchTab::Playlists => echo_core::app::SearchTab::Tracks, - }; - state.ui.selected_search_index = 0; - } - } - _ => {} - } - None -} - -fn seek_by(state: &mut AppState, seconds: i64) -> Option { - let target = state.playback.seek_target(seconds); - seek_to(state, target) -} - -fn seek_to(state: &mut AppState, progress_ms: u32) -> Option { - if state.playback.playing_track_id.is_none() || state.playback.duration_ms == 0 { - state.ui.status_message = Some("Nothing is currently seekable".to_string()); - state.ui.status_message_expiry = - Some(std::time::Instant::now() + std::time::Duration::from_secs(3)); - return None; - } - state.playback.set_optimistic_progress(progress_ms); - Some(AppEvent::SeekTo(state.playback.progress_ms)) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn search_results_len(state: &AppState) -> usize { - match state.ui.active_search_tab { - echo_core::app::SearchTab::All | echo_core::app::SearchTab::Tracks => { - state.data.search_results.tracks.len() - } - echo_core::app::SearchTab::Albums => state.data.search_results.albums.len(), - echo_core::app::SearchTab::Artists => state.data.search_results.artists.len(), - echo_core::app::SearchTab::Playlists => state.data.search_results.playlists.len(), - } -} - -fn search_has_results(state: &AppState) -> bool { - !state.data.search_results.tracks.is_empty() - || !state.data.search_results.albums.is_empty() - || !state.data.search_results.artists.is_empty() - || !state.data.search_results.playlists.is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn seek_keys_emit_absolute_targets() { - let mut state = AppState::new(); - state.playback.playing_track_id = Some("track".to_string()); - state.playback.duration_ms = 60_000; - state.playback.progress_ms = 10_000; - - let event = handle_key( - &mut state, - &KeyEvent::new(KeyCode::Char('.'), crossterm::event::KeyModifiers::NONE), - ); - assert!(matches!(event, Some(AppEvent::SeekTo(15_000)))); - } - - #[test] - fn mute_key_preserves_volume_for_restore() { - let mut state = AppState::new(); - state.playback.volume = 42; - - let event = handle_key( - &mut state, - &KeyEvent::new(KeyCode::Char('M'), crossterm::event::KeyModifiers::NONE), - ); - assert!(matches!(event, Some(AppEvent::SetVolume(0)))); - assert_eq!(state.playback.previous_volume, Some(42)); - } - - #[test] - fn ctrl_l_requests_redraw_without_triggering_navigation() { - let mut state = AppState::new(); - - let event = handle_key( - &mut state, - &KeyEvent::new(KeyCode::Char('l'), crossterm::event::KeyModifiers::CONTROL), - ); - - assert!(event.is_none()); - assert!(state.ui.needs_terminal_clear); - } -} diff --git a/crates/spotify-tui/src/handlers/normal/modals.rs b/crates/spotify-tui/src/handlers/normal/modals.rs deleted file mode 100644 index 42cce33..0000000 --- a/crates/spotify-tui/src/handlers/normal/modals.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::action_menu; -use echo_core::app::AppState; -use echo_core::events::AppEvent; - -pub fn handle(state: &mut AppState, key: &KeyEvent) -> (bool, Option) { - if state.ui.playlist_add_modal_open { - match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - action_menu::cancel_playlist_add(state); - } - KeyCode::Char('j') | KeyCode::Down - if state.ui.selected_playlist_modal_index + 1 - < action_menu::playlist_add_choices(state).len() => - { - state.ui.selected_playlist_modal_index += 1; - } - KeyCode::Char('k') | KeyCode::Up if state.ui.selected_playlist_modal_index > 0 => { - state.ui.selected_playlist_modal_index -= 1; - } - KeyCode::Enter => { - let index = state.ui.selected_playlist_modal_index; - if let Some(event) = action_menu::commit_playlist_add(state, index) { - return (true, Some(event)); - } - } - _ => {} - } - return (true, None); - } - - if state.ui.device_modal_open { - match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - state.ui.device_modal_open = false; - } - KeyCode::Char('j') | KeyCode::Down - if state.ui.selected_device_index + 1 < state.data.devices.len() => - { - state.ui.selected_device_index += 1; - } - KeyCode::Char('k') | KeyCode::Up if state.ui.selected_device_index > 0 => { - state.ui.selected_device_index -= 1; - } - KeyCode::Enter => { - let index = state.ui.selected_device_index; - if let Some(event) = echo_core::intent::transfer_to_device(state, index) { - return (true, Some(event)); - } - } - _ => {} - } - return (true, None); - } - - if state.ui.lyrics_modal_open { - match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('L') => { - state.ui.lyrics_modal_open = false; - } - _ => {} - } - return (true, None); - } - - if state.ui.action_menu_open { - let action_count = state - .ui - .action_menu_context - .as_ref() - .map_or(0, |ctx| ctx.actions().len()); - match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - state.ui.action_menu_open = false; - state.ui.action_menu_context = None; - state.ui.selected_action_index = 0; - } - KeyCode::Char('j') | KeyCode::Down - if state.ui.selected_action_index + 1 < action_count => - { - state.ui.selected_action_index += 1; - } - KeyCode::Char('k') | KeyCode::Up if state.ui.selected_action_index > 0 => { - state.ui.selected_action_index -= 1; - } - KeyCode::Enter => { - if let Some(ctx) = state.ui.action_menu_context.clone() { - let action = ctx.actions().get(state.ui.selected_action_index).copied(); - state.ui.action_menu_open = false; - state.ui.action_menu_context = None; - state.ui.selected_action_index = 0; - if let Some(action) = action { - return (true, action_menu::run(state, ctx, action)); - } - } - } - _ => {} - } - return (true, None); - } - - (false, None) -} diff --git a/crates/spotify-tui/src/handlers/normal/prompts.rs b/crates/spotify-tui/src/handlers/normal/prompts.rs deleted file mode 100644 index e46b9c5..0000000 --- a/crates/spotify-tui/src/handlers/normal/prompts.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! y/n handling for the destructive-action prompts. The prompts themselves live in -//! `echo_core::intent` (`prompt_active`/`confirm_prompt`/`cancel_prompt`), shared with the -//! desktop app's confirm modal. - -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::app::AppState; -use echo_core::events::AppEvent; - -pub fn handle(state: &mut AppState, key: &KeyEvent) -> (bool, Option) { - if !echo_core::intent::prompt_active(state) { - return (false, None); - } - if key.code == KeyCode::Char('y') { - (true, echo_core::intent::confirm_prompt(state)) - } else { - echo_core::intent::cancel_prompt(state); - (true, None) - } -} diff --git a/crates/spotify-tui/src/handlers/search.rs b/crates/spotify-tui/src/handlers/search.rs deleted file mode 100644 index b377a08..0000000 --- a/crates/spotify-tui/src/handlers/search.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::app::{AppMode, AppState}; -use echo_core::events::AppEvent; - -pub fn handle_key(state: &mut AppState, key: &KeyEvent) -> Option { - match key.code { - KeyCode::Esc => { - state.ui.mode = AppMode::Normal; - state.ui.search_query.clear(); - state.ui.search_matches.clear(); - } - KeyCode::Backspace => { - state.ui.search_query.pop(); - echo_core::intent::update_search_matches(state); - } - KeyCode::Char(c) => { - state.ui.search_query.push(c); - echo_core::intent::update_search_matches(state); - } - KeyCode::Enter => { - state.ui.mode = AppMode::Normal; - if !state.ui.search_matches.is_empty() { - state.ui.selected_track_index = state.ui.search_matches[0]; - } - } - _ => {} - } - None -} diff --git a/crates/spotify-tui/src/handlers/setup.rs b/crates/spotify-tui/src/handlers/setup.rs deleted file mode 100644 index 6050938..0000000 --- a/crates/spotify-tui/src/handlers/setup.rs +++ /dev/null @@ -1,31 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::app::AppState; -use echo_core::events::AppEvent; - -pub fn handle_key(state: &mut AppState, key: &KeyEvent) -> Option { - match key.code { - KeyCode::Tab => { - state.ui.setup_focus_secret = !state.ui.setup_focus_secret; - None - } - KeyCode::Enter => echo_core::intent::submit_setup_credentials(state), - KeyCode::Backspace => { - if state.ui.setup_focus_secret { - state.ui.setup_client_secret.pop(); - } else { - state.ui.setup_client_id.pop(); - } - None - } - KeyCode::Char(c) => { - if state.ui.setup_focus_secret { - state.ui.setup_client_secret.push(c); - } else { - state.ui.setup_client_id.push(c); - } - None - } - KeyCode::Esc => Some(AppEvent::Quit), - _ => None, - } -} diff --git a/crates/spotify-tui/src/handlers/tracklist.rs b/crates/spotify-tui/src/handlers/tracklist.rs deleted file mode 100644 index fac11b9..0000000 --- a/crates/spotify-tui/src/handlers/tracklist.rs +++ /dev/null @@ -1,5 +0,0 @@ -use echo_core::{app::AppState, events::AppEvent, intent}; - -pub fn play_selected(state: &mut AppState) -> Option { - intent::play_track_at(state, state.ui.selected_track_index) -} diff --git a/crates/spotify-tui/src/handlers/visual.rs b/crates/spotify-tui/src/handlers/visual.rs deleted file mode 100644 index fd5718a..0000000 --- a/crates/spotify-tui/src/handlers/visual.rs +++ /dev/null @@ -1,405 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use echo_core::app::{ActiveView, AppMode, AppState}; -use echo_core::events::AppEvent; - -pub fn handle_key(state: &mut AppState, key: &KeyEvent) -> Option { - let configured = crate::handlers::keymap::configured_action(state, key); - if let Some(action) = configured.action { - return crate::handlers::keymap::execute(state, action); - } - if configured.consumed { - return None; - } - let navigation = crate::handlers::navigation::command_for_key(state, key); - if let Some(command) = navigation.command { - return crate::handlers::navigation::execute(state, command); - } - if navigation.consumed { - return None; - } - if let Some((playlist_id, track_ids)) = state.ui.track_delete_prompt.clone() { - if key.code == KeyCode::Char('y') { - state.ui.track_delete_prompt = None; - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - return Some(AppEvent::RemoveTracksFromPlaylist(playlist_id, track_ids)); - } - state.ui.track_delete_prompt = None; - return None; - } - - if let Some(playlist_ids) = state.ui.playlist_delete_prompt.clone() { - if key.code == KeyCode::Char('y') { - state.ui.playlist_delete_prompt = None; - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - return Some(AppEvent::DeletePlaylists(playlist_ids)); - } - state.ui.playlist_delete_prompt = None; - return None; - } - - if let Some(album_ids) = state.ui.album_mass_delete_prompt.clone() { - if key.code == KeyCode::Char('y') { - state.ui.album_mass_delete_prompt = None; - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - return Some(AppEvent::RemoveAlbums(album_ids)); - } - state.ui.album_mass_delete_prompt = None; - return None; - } - - if state.ui.playlist_add_modal_open { - match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - state.ui.playlist_add_modal_open = false; - state.ui.selected_playlist_modal_index = 0; - } - KeyCode::Char('j') | KeyCode::Down - if state.ui.selected_playlist_modal_index + 1 - < echo_core::action_menu::playlist_add_choices(state).len() => - { - state.ui.selected_playlist_modal_index += 1; - } - KeyCode::Char('k') | KeyCode::Up if state.ui.selected_playlist_modal_index > 0 => { - state.ui.selected_playlist_modal_index -= 1; - } - KeyCode::Enter => { - let playlists = echo_core::action_menu::playlist_add_choices(state); - if let Some(playlist) = playlists.get(state.ui.selected_playlist_modal_index) { - let tracks = if let Some((start, end)) = state.get_visual_selection_range() { - match state.ui.active_view { - ActiveView::TrackList => state.data.tracks[start..=end].to_vec(), - ActiveView::SearchResults => { - if state.ui.active_search_tab == echo_core::app::SearchTab::Tracks { - state.data.search_results.tracks[start..=end] - .iter() - .map(|t| echo_core::models::Track { - explicit: t.explicit, - added_by: None, - id: t.id.clone(), - source: t.source, - local_path: t.local_path.clone(), - name: t.name.clone(), - artist: t.artist.clone(), - album: t.album.clone(), - added_at: None, - duration_ms: t.duration_ms, - image_url: t.image_url.clone(), - album_id: t.album_id.clone(), - artist_id: t.artist_id.clone(), - artists: Vec::new(), - }) - .collect() - } else { - vec![] - } - } - ActiveView::Queue => state.queue_view_tracks(start, end), - _ => vec![], - } - } else { - vec![] - }; - state.ui.playlist_add_modal_open = false; - state.ui.selected_playlist_modal_index = 0; - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - if !tracks.is_empty() { - return Some(AppEvent::AddTracksToPlaylist(playlist.id.clone(), tracks)); - } - } - } - _ => {} - } - return None; - } - - if key.code != KeyCode::Char('d') { - state.ui.pending_d_press = false; - } - - match key.code { - KeyCode::Esc => { - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - state.ui.status_message = None; - } - KeyCode::Char('j') | KeyCode::Down => match state.ui.active_view { - ActiveView::Home => {} - ActiveView::TrackList => { - if state.ui.selected_track_index + 1 < state.data.tracks.len() { - state.ui.selected_track_index += 1; - } - } - ActiveView::SearchResults => { - let max = search_results_len(state); - if max > 0 && state.ui.selected_search_index < max.saturating_sub(1) { - state.ui.selected_search_index += 1; - } - } - ActiveView::Queue => { - if state.ui.selected_queue_index + 1 < state.queue_view_len() { - state.ui.selected_queue_index += 1; - } - } - ActiveView::Library => { - let max_len = if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - state.data.saved_albums.len() - } else { - state.data.library_view.len() - }; - if max_len > 0 && state.ui.selected_playlist_index < max_len.saturating_sub(1) { - state.ui.selected_playlist_index += 1; - } - } - ActiveView::Devices => { - if state.ui.selected_device_index + 1 < state.data.devices.len() { - state.ui.selected_device_index += 1; - } - } - ActiveView::ArtistList => { - if state.ui.selected_artist_index + 1 < state.artist_list().len() { - state.ui.selected_artist_index += 1; - } - } - ActiveView::ArtistPage => { - if let Some(ref data) = state.data.artist_page_data - && state.ui.artist_page_album_index + 1 < data.albums.len() - { - state.ui.artist_page_album_index += 1; - } - } - ActiveView::WhatsNew => { - if state.ui.selected_whats_new_index + 1 < state.data.whats_new.len() { - state.ui.selected_whats_new_index += 1; - } - } - }, - KeyCode::Char('k') | KeyCode::Up => match state.ui.active_view { - ActiveView::Home => {} - ActiveView::TrackList => { - if state.ui.selected_track_index > 0 { - state.ui.selected_track_index -= 1; - } - } - ActiveView::SearchResults => { - if state.ui.selected_search_index > 0 { - state.ui.selected_search_index -= 1; - } - } - ActiveView::Queue => { - if state.ui.selected_queue_index > 0 { - state.ui.selected_queue_index -= 1; - } - } - ActiveView::Library => { - if state.ui.selected_playlist_index > 0 { - state.ui.selected_playlist_index -= 1; - } - } - ActiveView::Devices => { - if state.ui.selected_device_index > 0 { - state.ui.selected_device_index -= 1; - } - } - ActiveView::ArtistList => { - if state.ui.selected_artist_index > 0 { - state.ui.selected_artist_index -= 1; - } - } - ActiveView::ArtistPage => { - if state.ui.artist_page_album_index > 0 { - state.ui.artist_page_album_index -= 1; - } - } - ActiveView::WhatsNew => { - if state.ui.selected_whats_new_index > 0 { - state.ui.selected_whats_new_index -= 1; - } - } - }, - KeyCode::Char('q') => { - if let Some((start, end)) = state.get_visual_selection_range() { - let track_ids: Vec = match state.ui.active_view { - ActiveView::TrackList => state.data.tracks[start..=end] - .iter() - .map(|t| t.id.clone()) - .collect(), - ActiveView::SearchResults => { - if state.ui.active_search_tab == echo_core::app::SearchTab::Tracks { - state.data.search_results.tracks[start..=end] - .iter() - .map(|t| t.id.clone()) - .collect() - } else { - vec![] - } - } - ActiveView::Queue => state - .queue_view_tracks(start, end) - .iter() - .map(|t| t.id.clone()) - .collect(), - ActiveView::ArtistPage => vec![], - _ => vec![], - }; - - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - state.ui.status_message = - Some(format!("Added {} tracks to queue", track_ids.len())); - - if !track_ids.is_empty() { - return Some(AppEvent::AddToQueue(track_ids)); - } - } else { - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - state.ui.status_message = None; - } - } - KeyCode::Char('x') => { - if state.ui.active_view == ActiveView::Library - && state.ui.active_library_tab != echo_core::app::LibraryTab::Albums - && let Some((start, end)) = state.get_visual_selection_range() - { - let selected_ids: Vec = state.data.library_view[start..=end] - .iter() - .filter_map(|node| match node { - echo_core::models::LibraryNode::Playlist { playlist, .. } => { - if playlist.id != "LIKED_SONGS" && playlist.id != "local-library" { - Some(playlist.id.clone()) - } else { - None - } - } - _ => None, - }) - .collect(); - - if !selected_ids.is_empty() { - state.ui.operation_register = selected_ids; - - for f in &mut state.ui.library_config.folders { - f.playlists - .retain(|id| !state.ui.operation_register.contains(id)); - } - state.save_library_config(); - state.compute_library_view(); - - state.ui.mode = AppMode::Normal; - state.ui.visual_selection_start = None; - state.ui.status_message = Some("Cut playlists".to_string()); - } - } - } - KeyCode::Char('d') => { - if state.ui.active_view == ActiveView::TrackList - && let Some((start, end)) = state.get_visual_selection_range() - && let Some(context) = &state.data.active_tracklist_context - && context.can_modify_playlist(state.data.user_id.as_ref()) - { - if state.ui.pending_d_press { - let track_ids = state.data.tracks[start..=end] - .iter() - .map(|t| t.id.clone()) - .collect(); - state.ui.track_delete_prompt = Some((context.id.clone(), track_ids)); - state.ui.pending_d_press = false; - } else { - state.ui.pending_d_press = true; - } - } - if state.ui.active_view == ActiveView::Library - && state.ui.active_library_tab != echo_core::app::LibraryTab::Albums - { - if let Some((start, end)) = state.get_visual_selection_range() { - if state.ui.pending_d_press { - let selected_ids: Vec = state.data.library_view[start..=end] - .iter() - .filter_map(|node| match node { - echo_core::models::LibraryNode::Playlist { playlist, .. } => { - if playlist.id.starts_with("local-playlist:") - || (playlist.id != "LIKED_SONGS" - && playlist.id != "local-library" - && Some(&playlist.owner_id) - == state.data.user_id.as_ref()) - { - Some(playlist.id.clone()) - } else { - None - } - } - _ => None, - }) - .collect(); - - if !selected_ids.is_empty() { - state.ui.playlist_delete_prompt = Some(selected_ids); - state.ui.pending_d_press = false; - } - } else { - state.ui.pending_d_press = true; - } - } - } else if state.ui.active_view == ActiveView::Library - && let Some((start, end)) = state.get_visual_selection_range() - { - if state.ui.pending_d_press { - if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - let album_ids = state.data.saved_albums[start..=end] - .iter() - .map(|a| a.id.clone()) - .collect(); - state.ui.album_mass_delete_prompt = Some(album_ids); - } else { - let selected_ids: Vec = state.data.library_view[start..=end] - .iter() - .filter_map(|node| match node { - echo_core::models::LibraryNode::Playlist { playlist, .. } => { - if playlist.id.starts_with("local-playlist:") - || (playlist.id != "LIKED_SONGS" - && playlist.id != "local-library" - && Some(&playlist.owner_id) - == state.data.user_id.as_ref()) - { - Some(playlist.id.clone()) - } else { - None - } - } - _ => None, - }) - .collect(); - - if !selected_ids.is_empty() { - state.ui.playlist_delete_prompt = Some(selected_ids); - } - } - state.ui.pending_d_press = false; - } else { - state.ui.pending_d_press = true; - } - } - } - KeyCode::Char('a') => { - state.ui.playlist_add_modal_open = true; - state.ui.selected_playlist_modal_index = 0; - } - _ => {} - } - None -} - -fn search_results_len(state: &AppState) -> usize { - match state.ui.active_search_tab { - echo_core::app::SearchTab::All | echo_core::app::SearchTab::Tracks => { - state.data.search_results.tracks.len() - } - echo_core::app::SearchTab::Albums => state.data.search_results.albums.len(), - echo_core::app::SearchTab::Artists => state.data.search_results.artists.len(), - echo_core::app::SearchTab::Playlists => state.data.search_results.playlists.len(), - } -} diff --git a/crates/spotify-tui/src/main.rs b/crates/spotify-tui/src/main.rs deleted file mode 100644 index c701761..0000000 --- a/crates/spotify-tui/src/main.rs +++ /dev/null @@ -1,208 +0,0 @@ -// This crate's tests exercise handlers that load and save the user config through echo-core, -// which is compiled without cfg(test) here — redirect its config root to a temp directory -// before any test runs so the real ~/.config/echo is never touched. -#[cfg(test)] -#[ctor::ctor] -fn redirect_config_for_tests() { - let dir = std::env::temp_dir().join(format!("echo-tui-test-config-{}", std::process::id())); - // SAFETY: ctor runs before main, while the process is still single-threaded. - unsafe { std::env::set_var("ECHO_CONFIG_DIR", &dir) }; -} - -mod cli; -mod handlers; -mod tui; - -use anyhow::Result; -use crossterm::event::{self, Event, KeyEventKind}; -use std::panic; -use std::time::Duration; - -use echo_core::{apply_worker_event, i18n, image_tasks, thumbnails}; - -use echo_core::events::AppEvent; -use tui::Tui; -use tui::theme::ToRatatui; - -#[tokio::main] -async fn main() -> Result<()> { - // Ahead of everything, including the Linux relaunch below: `launch_in_terminal` forwards - // argv to a spawned terminal, so an un-intercepted `upgrade` would open a window to run in. - let command = cli::parse(std::env::args().skip(1)); - if command != cli::Command::Tui { - std::process::exit(cli::run(command).await); - } - - // Windows cannot delete the image of a running process, so a previous upgrade leaves its - // backups behind. Nothing holds them now. - echo_core::update::sweep_backups(); - - #[cfg(target_os = "linux")] - { - use std::io::IsTerminal; - if !std::io::stdout().is_terminal() { - launch_in_terminal(); - } - } - - print!("\x1b]0;spotify\x07"); - i18n::init(); - - let original_hook = panic::take_hook(); - panic::set_hook(Box::new(move |panic_info| { - let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen); - let _ = crossterm::terminal::disable_raw_mode(); - original_hook(panic_info); - })); - - let echo_core::bootstrap::Bootstrap { - mut state, - config: _config, - app_tx, - mut app_rx, - worker_tx: worker_tx_clone, - } = echo_core::bootstrap::init(); - - // Queries the terminal for a pixel graphics protocol (kitty/sixel/iTerm2) before entering - // the alternate screen; without one, covers fall back to half-block cells. - tui::image::init_picker(); - - let mut tui = Tui::new()?; - tui.enter()?; - - let mut is_first_frame = true; - - while state.ui.is_running { - let mut needs_draw = is_first_frame; - is_first_frame = false; - - if let Some(expiry) = state.ui.status_message_expiry - && std::time::Instant::now() >= expiry - { - state.ui.status_message = None; - state.ui.status_message_expiry = None; - state.ui.recent_queue_count = 0; - needs_draw = true; - } - - if state.ui.needs_terminal_clear { - needs_draw = true; - } - - if event::poll(Duration::from_millis(16))? - && let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - { - needs_draw = true; - let outgoing_event = handlers::handle_event(&mut state, &key); - - if !state.ui.is_running { - echo_core::session::persist(&state); - let _ = app_tx.send(AppEvent::Quit); - } else if let Some(ev) = outgoing_event { - if let AppEvent::LoadContextTracks(ref context) = ev { - if let Some(url) = context.image_url.as_ref() { - state.data.tracklist_image_url = Some(url.clone()); - image_tasks::spawn_header_for_url( - url, - worker_tx_clone.clone(), - state.ui.library_config.cover_img_pixels, - ); - } - let _ = app_tx.send(ev); - } else if let AppEvent::ReloadHeaderImage = ev { - if let Some(url) = &state.data.tracklist_image_url { - image_tasks::spawn_header_for_url( - url, - worker_tx_clone.clone(), - state.ui.library_config.cover_img_pixels, - ); - } - } else { - let _ = app_tx.send(ev); - } - } - } - - while let Ok(worker_event) = app_rx.try_recv() { - needs_draw = true; - apply_worker_event::apply_worker_event( - worker_event, - &mut state, - &app_tx, - &worker_tx_clone, - ); - } - - if needs_draw { - let force_clear = state.ui.needs_terminal_clear; - tui.apply_background(state.ui.active_theme.background.rat(), force_clear)?; - state.ui.needs_terminal_clear = false; - tui.terminal.draw(|f| { - tui::render::render_app(f, &mut state); - })?; - } - - thumbnails::drain_pending(&mut state, &worker_tx_clone); - } - - tui.exit()?; - Ok(()) -} - -#[cfg(target_os = "linux")] -fn launch_in_terminal() -> ! { - let exe = match std::env::current_exe() { - Ok(e) => e, - Err(_) => std::process::exit(1), - }; - let exe_str = exe.display().to_string(); - let args: Vec = std::env::args().skip(1).collect(); - - let terminals: &[&[&str]] = &[ - &["x-terminal-emulator", "-e"], - &["gnome-terminal", "--"], - &["konsole", "-e"], - &["xfce4-terminal", "-e"], - &["mate-terminal", "-e"], - &["ghostty", "-e"], - &["alacritty", "-e"], - &["kitty", "--"], - &["wezterm", "start", "--"], - &["terminator", "-e"], - &["xterm", "-e"], - ]; - - if let Ok(term) = std::env::var("TERMINAL") { - let mut cmd = std::process::Command::new(&term); - cmd.arg("-e").arg(&exe_str); - for a in &args { - cmd.arg(a); - } - if let Ok(mut child) = cmd.spawn() { - let _ = child.wait(); - std::process::exit(0); - } - } - - for entry in terminals { - let mut cmd = std::process::Command::new(entry[0]); - cmd.args(&entry[1..]).arg(&exe_str); - for a in &args { - cmd.arg(a); - } - if let Ok(mut child) = cmd.spawn() { - let _ = child.wait(); - std::process::exit(0); - } - } - - let _ = std::process::Command::new("zenity") - .args([ - "--error", - "--title=echo", - "--text=echo is a terminal application but no terminal emulator was found.\nPlease run it from a terminal.", - ]) - .spawn(); - std::process::exit(1); -} diff --git a/crates/spotify-tui/src/tui/artist.rs b/crates/spotify-tui/src/tui/artist.rs deleted file mode 100644 index 700f7a5..0000000 --- a/crates/spotify-tui/src/tui/artist.rs +++ /dev/null @@ -1,390 +0,0 @@ -use crate::tui::render::{ - padded_library_list, stabilize_terminal_emoji_width, truncate_to_width_with_ellipsis, -}; -use crate::tui::theme::{ThemeStyles, ToRatatui}; -use echo_core::app::{ActiveView, AppState}; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{ - Block, Borders, Cell, HighlightSpacing, ListItem, ListState, Row, Table, TableState, - }, -}; - -pub fn render_artist_list(frame: &mut Frame, state: &mut AppState, area: Rect) { - let title = match state.ui.artist_list_source { - echo_core::app::ArtistListSource::Followed => " Followed Artists ", - echo_core::app::ArtistListSource::Top => " Top Artists ", - }; - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .style(state.ui.active_theme.base_style()) - .border_style(if state.ui.active_view == ActiveView::ArtistList { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.primary_style() - }); - - let inner_area = block.inner(area); - frame.render_widget(block, area); - - let items: Vec = state - .artist_list() - .iter() - .enumerate() - .map(|(i, artist)| { - let style = if i == state.ui.selected_artist_index - && state.ui.active_view == ActiveView::ArtistList - { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - - let text = format!(" {} ", stabilize_terminal_emoji_width(&artist.name)); - ListItem::new(truncate_to_width_with_ellipsis(&text, inner_area.width)).style(style) - }) - .collect(); - - let list = padded_library_list(items).highlight_style( - state - .ui - .active_theme - .selected_style() - .add_modifier(Modifier::BOLD), - ); - - let mut list_state = ListState::default(); - list_state.select(Some(state.ui.selected_artist_index)); - frame.render_stateful_widget(list, inner_area, &mut list_state); -} - -pub fn render_artist_page(frame: &mut Frame, state: &mut AppState, area: Rect) { - let artist_name = state - .data - .artist_page_data - .as_ref() - .map(|d| d.artist_name.clone()) - .unwrap_or_default(); - - let is_active = state.ui.active_view == ActiveView::ArtistPage; - let following = state.data.artist_page_data.as_ref().is_some_and(|data| { - state - .data - .followed_artists - .iter() - .any(|artist| artist.id == data.artist_id) - }); - let title = if following { - format!( - " {} · {} ", - artist_name, - echo_core::i18n::t("desktop.following", &state.ui.library_config.language) - ) - } else { - format!(" {} ", artist_name) - }; - - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .style(state.ui.active_theme.base_style()) - .border_style(if is_active { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.primary_style() - }); - - let inner_area = block.inner(area); - frame.render_widget(block, area); - - let Some((artist_image_url, albums)) = state - .data - .artist_page_data - .as_ref() - .map(|data| (data.image_url.clone(), data.albums.clone())) - else { - let message = if state.data.artist_page_loading { - " Loading artist..." - } else { - " Artist page unavailable." - }; - let p = - ratatui::widgets::Paragraph::new(message).style(state.ui.active_theme.muted_style()); - frame.render_widget(p, inner_area); - return; - }; - - let has_image = artist_image_url.is_some() && state.ui.active_library_header_image.is_some(); - let (header_area, albums_area) = if has_image { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(7), Constraint::Min(0)]) - .split(inner_area); - (Some(chunks[0]), chunks[1]) - } else { - (None, inner_area) - }; - - if let Some(header_area) = header_area { - render_artist_header(frame, state, header_area, &artist_name); - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) - .split(albums_area); - render_discography_chips(frame, state, chunks[0]); - let albums_area = chunks[1]; - - if albums.is_empty() { - let message = if state.data.artist_albums_loading { - " Loading albums..." - } else { - " No albums loaded." - }; - let p = - ratatui::widgets::Paragraph::new(message).style(state.ui.active_theme.muted_style()); - frame.render_widget(p, albums_area); - return; - } - - let selected_idx = state.ui.artist_page_album_index; - let show_track_count = albums.iter().any(|album| album.track_count.is_some()); - let header_style = if is_active { - state - .ui - .active_theme - .secondary_style() - .add_modifier(Modifier::BOLD) - } else { - state - .ui - .active_theme - .primary_style() - .add_modifier(Modifier::BOLD) - }; - - let rows: Vec = albums - .iter() - .enumerate() - .map(|(i, album)| { - let style = if i == selected_idx && is_active { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let album_name_width = if show_track_count { - albums_area.width.saturating_mul(70) / 100 - } else { - albums_area.width.saturating_mul(82) / 100 - }; - let name_cell = Cell::from(truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&album.name), - album_name_width.saturating_sub(1), - )); - let year_cell = Cell::from(album.release_year.clone()); - let row = if show_track_count { - let track_count = album - .track_count - .map(|count| count.to_string()) - .unwrap_or_else(|| "-".to_string()); - Row::new(vec![name_cell, Cell::from(track_count), year_cell]) - } else { - Row::new(vec![name_cell, year_cell]) - }; - row.style(style) - }) - .collect(); - - let table = if show_track_count { - Table::new( - rows, - [ - Constraint::Percentage(70), - Constraint::Length(8), - Constraint::Length(6), - ], - ) - .header( - Row::new(vec!["Album", "Tracks", "Year"]) - .style(header_style) - .height(1), - ) - } else { - Table::new(rows, [Constraint::Percentage(82), Constraint::Length(6)]).header( - Row::new(vec!["Album", "Year"]) - .style(header_style) - .height(1), - ) - } - .column_spacing(1) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - - let mut table_state = TableState::default(); - table_state.select(Some(selected_idx)); - frame.render_stateful_widget(table, albums_area, &mut table_state); -} - -pub fn render_whats_new(frame: &mut Frame, state: &mut AppState, area: Rect) { - let is_active = state.ui.active_view == ActiveView::WhatsNew; - let title = match state.data.whats_new_progress { - Some((done, total)) => format!(" What's New ({done}/{total}) "), - None => " What's New ".to_string(), - }; - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .style(state.ui.active_theme.base_style()) - .border_style(if is_active { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.primary_style() - }); - - let inner_area = block.inner(area); - frame.render_widget(block, area); - - if state.data.whats_new.is_empty() { - let message = if state.data.whats_new_progress.is_some() { - " Checking followed artists..." - } else { - " No recent releases from followed artists." - }; - let p = - ratatui::widgets::Paragraph::new(message).style(state.ui.active_theme.muted_style()); - frame.render_widget(p, inner_area); - return; - } - - let header_style = if is_active { - state - .ui - .active_theme - .secondary_style() - .add_modifier(Modifier::BOLD) - } else { - state - .ui - .active_theme - .primary_style() - .add_modifier(Modifier::BOLD) - }; - - let name_width = inner_area.width.saturating_mul(45) / 100; - let artist_width = inner_area.width.saturating_mul(35) / 100; - let rows: Vec = state - .data - .whats_new - .iter() - .enumerate() - .map(|(i, album)| { - let style = if i == state.ui.selected_whats_new_index && is_active { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let released = album - .release_date - .clone() - .unwrap_or_else(|| album.release_year.clone()); - Row::new(vec![ - Cell::from(truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&album.name), - name_width.saturating_sub(1), - )), - Cell::from(truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&album.artists), - artist_width.saturating_sub(1), - )), - Cell::from(released), - ]) - .style(style) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Percentage(45), - Constraint::Percentage(35), - Constraint::Length(10), - ], - ) - .header( - Row::new(vec!["Album", "Artist", "Released"]) - .style(header_style) - .height(1), - ) - .column_spacing(1) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - - let mut table_state = TableState::default(); - table_state.select(Some(state.ui.selected_whats_new_index)); - frame.render_stateful_widget(table, inner_area, &mut table_state); -} - -/// One line of discography chips above the album table; `Tab` moves the highlight. -fn render_discography_chips(frame: &mut Frame, state: &AppState, area: Rect) { - let lang = &state.ui.library_config.language; - let active = state.ui.artist_discography_filter; - let mut spans = vec![Span::raw(" ")]; - for filter in echo_core::models::DiscographyFilter::ALL { - let style = if filter == active { - state - .ui - .active_theme - .secondary_style() - .add_modifier(Modifier::BOLD) - } else { - state.ui.active_theme.muted_style() - }; - spans.push(Span::styled( - format!(" {} ", echo_core::i18n::t(filter.label_key(), lang)), - style, - )); - spans.push(Span::raw(" ")); - } - frame.render_widget(ratatui::widgets::Paragraph::new(Line::from(spans)), area); -} - -fn render_artist_header(frame: &mut Frame, state: &mut AppState, area: Rect, artist_name: &str) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(14), Constraint::Min(0)]) - .split(area); - - let img_area = Rect { - x: chunks[0].x + 2, - y: chunks[0].y + 1, - width: 10, - height: 5, - }; - - if let Some(artwork) = state.ui.active_library_header_image.clone() { - crate::tui::image::draw(frame.buffer_mut(), img_area, &artwork); - } - - let text_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), - Constraint::Length(1), - Constraint::Min(0), - ]) - .split(chunks[1]); - - let title_para = ratatui::widgets::Paragraph::new(artist_name.to_string()).style( - Style::default() - .fg(state.ui.active_theme.primary.rat()) - .add_modifier(Modifier::BOLD), - ); - frame.render_widget(title_para, text_chunks[1]); -} diff --git a/crates/spotify-tui/src/tui/image.rs b/crates/spotify-tui/src/tui/image.rs deleted file mode 100644 index f86b7ac..0000000 --- a/crates/spotify-tui/src/tui/image.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Cover art rendering for the ratatui views. -//! -//! Two paths, chosen once at startup by querying the terminal: -//! -//! * A pixel graphics protocol (kitty, sixel, iTerm2) via `ratatui-image` — full-resolution -//! covers, the same fidelity the pre-workspace-split app had. -//! * The half-block sampler from [`echo_core::artwork`] — two vertical samples per cell, for -//! terminals that never answer the graphics query. -//! -//! The protocol objects `ratatui-image` needs are stateful and tied to this frontend, so they -//! live here in a thread-local cache keyed by the artwork's `Arc` address, invisible to callers: -//! the views just call [`draw`] with the raw pixels either way. The render loop is -//! single-threaded, so a thread-local is safe and keeps the cache out of every render signature. - -use std::cell::RefCell; -use std::collections::HashMap; -use std::sync::Arc; - -use echo_core::artwork::{self, SharedArtwork}; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; -use ratatui::widgets::StatefulWidget; -use ratatui_image::StatefulImage; -use ratatui_image::picker::{Picker, ProtocolType}; -use ratatui_image::protocol::StatefulProtocol; - -/// Protocol cache cap: room for the ~300-entry thumbnail cache plus the large covers. Entries -/// rebuild cheaply from the retained `Artwork`, so overflow just clears the map. -const PROTOCOL_CACHE_CAP: usize = 400; - -struct PixelRenderer { - picker: Picker, - /// Keyed by `Arc` address. Each entry retains its `SharedArtwork`, so an address can't be - /// freed and reused by a different image while the entry lives. - protocols: HashMap, -} - -thread_local! { - static PIXEL: RefCell> = const { RefCell::new(None) }; -} - -/// Queries the terminal for a pixel graphics protocol. Call once at startup, from the thread -/// that renders. Terminals that answer "halfblocks" (or not at all) keep the built-in sampler, -/// which draws the same thing without protocol state. -pub fn init_picker() { - if let Ok(picker) = Picker::from_query_stdio() - && picker.protocol_type() != ProtocolType::Halfblocks - { - PIXEL.with(|cell| { - *cell.borrow_mut() = Some(PixelRenderer { - picker, - protocols: HashMap::new(), - }); - }); - } -} - -/// Draws `artwork` into `area` at the best fidelity the terminal offers. -pub fn draw(buffer: &mut Buffer, area: Rect, artwork: &SharedArtwork) { - if area.width == 0 || area.height == 0 { - return; - } - let drawn_as_pixels = PIXEL.with(|cell| { - let mut cell = cell.borrow_mut(); - let Some(renderer) = cell.as_mut() else { - return false; - }; - let key = Arc::as_ptr(artwork) as usize; - if !renderer.protocols.contains_key(&key) { - let Some(image) = to_dynamic_image(artwork) else { - return false; - }; - if renderer.protocols.len() >= PROTOCOL_CACHE_CAP { - renderer.protocols.clear(); - } - let protocol = renderer.picker.new_resize_protocol(image); - renderer.protocols.insert(key, (artwork.clone(), protocol)); - } - let (_, protocol) = renderer.protocols.get_mut(&key).expect("just inserted"); - StatefulImage::default().render(area, buffer, protocol); - true - }); - - if !drawn_as_pixels { - half_blocks(buffer, area, artwork); - } -} - -fn to_dynamic_image(artwork: &SharedArtwork) -> Option { - image::RgbaImage::from_raw(artwork.width, artwork.height, artwork.pixels.clone()) - .map(image::DynamicImage::ImageRgba8) -} - -/// The fallback: one `▀` per cell, foreground/background carrying the two vertical samples. -fn half_blocks(buffer: &mut Buffer, area: Rect, artwork: &SharedArtwork) { - let cells = artwork::sample_cells(artwork, area.width as u32, area.height as u32); - - for (index, cell) in cells.iter().enumerate() { - let x = area.x + (index as u16 % area.width); - let y = area.y + (index as u16 / area.width); - if x >= buffer.area.width || y >= buffer.area.height { - continue; - } - let target = &mut buffer[(x, y)]; - target.set_symbol(&cell.glyph().to_string()); - target.set_style( - Style::default() - .fg(to_color(cell.top)) - .bg(to_color(cell.bottom)), - ); - } -} - -fn to_color(color: artwork::Rgba) -> Color { - Color::Rgb(color.r, color.g, color.b) -} diff --git a/crates/spotify-tui/src/tui/library.rs b/crates/spotify-tui/src/tui/library.rs deleted file mode 100644 index 0ee6f46..0000000 --- a/crates/spotify-tui/src/tui/library.rs +++ /dev/null @@ -1,953 +0,0 @@ -use crate::tui::render::{ - DURATION_COLUMN_WIDTH, format_duration_text, format_time, padded_library_list, - repair_wide_grapheme_trailing_styles, row_text_width, stabilize_terminal_emoji_width, - truncate_to_width_with_ellipsis, -}; -use crate::tui::theme::{ThemeStyles, ToRatatui}; -use echo_core::app::{ActiveView, AppMode, AppState, displayed_track_number}; -use ratatui::{ - Frame, - buffer::Buffer, - layout::{Constraint, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{ - Block, Borders, Cell, HighlightSpacing, ListItem, ListState, Row, Table, TableState, - }, -}; - -pub use crate::tui::artist::{render_artist_list, render_artist_page, render_whats_new}; - -const ECHO_LOGO: [&str; 6] = [ - "███████╗ ██████╗██╗ ██╗ ██████╗ ██████╗ ███████╗", - "██╔════╝██╔════╝██║ ██║██╔═══██╗ ██╔══██╗██╔════╝", - "█████╗ ██║ ███████║██║ ██║ █████╗ ██████╔╝███████╗", - "██╔══╝ ██║ ██╔══██║██║ ██║ ╚════╝ ██╔══██╗╚════██║", - "███████╗╚██████╗██║ ██║╚██████╔╝ ██║ ██║███████║", - "╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝", -]; - -fn color_to_rgb(color: Color) -> (f32, f32, f32) { - match color { - Color::Reset | Color::Black => (0., 0., 0.), - Color::Red | Color::LightRed => (255., 0., 0.), - Color::Green | Color::LightGreen => (0., 255., 0.), - Color::Yellow | Color::LightYellow => (255., 255., 0.), - Color::Blue | Color::LightBlue => (0., 0., 255.), - Color::Magenta | Color::LightMagenta => (255., 0., 255.), - Color::Cyan | Color::LightCyan => (0., 255., 255.), - Color::Gray | Color::DarkGray => (128., 128., 128.), - Color::White => (255., 255., 255.), - Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32), - Color::Indexed(_) => (255., 255., 255.), - } -} - -fn lerp_color(c1: Color, c2: Color, t: f32) -> Color { - let (r1, g1, b1) = color_to_rgb(c1); - let (r2, g2, b2) = color_to_rgb(c2); - - let r = r1 + (r2 - r1) * t; - let g = g1 + (g2 - g1) * t; - let b = b1 + (b2 - b1) * t; - - Color::Rgb(r as u8, g as u8, b as u8) -} - -pub fn render_library_list(frame: &mut Frame, state: &mut AppState, library_area: Rect) { - let is_focused = state.ui.active_view == ActiveView::Library; - let p_title = if state.ui.active_library_tab == echo_core::app::LibraryTab::Playlists { - format!( - "[{}]", - echo_core::i18n::t("ui.playlists", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.playlists", &state.ui.library_config.language) - ) - }; - let a_title = if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - format!( - "[{}]", - echo_core::i18n::t("ui.albums", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.albums", &state.ui.library_config.language) - ) - }; - let b_title = if state.ui.active_library_tab == echo_core::app::LibraryTab::Browse { - format!( - "[{}]", - echo_core::i18n::t("ui.browse", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.browse", &state.ui.library_config.language) - ) - }; - let title_text = format!("{} {} {}", p_title, a_title, b_title); - - let library_border_style = if is_focused { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.primary_style() - }; - - let library_block = Block::default() - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()) - .border_style(library_border_style) - .title(title_text); - let library_list_area = library_block.inner(library_area); - let library_text_width = row_text_width(library_list_area); - frame.render_widget(library_block, library_area); - - let visual_range = if is_focused && state.ui.mode == AppMode::Visual { - state.get_visual_selection_range() - } else { - None - }; - - if state.ui.library_config.library_thumbnails - && state.ui.active_library_tab != echo_core::app::LibraryTab::Browse - { - render_library_thumbnails(frame, state, library_list_area, is_focused, visual_range); - return; - } - - let library_items: Vec = state - .data - .library_view - .iter() - .enumerate() - .map(|(i, node)| { - let is_in_visual = if let Some((start, end)) = visual_range { - i >= start && i <= end - } else { - false - }; - - let style = if is_in_visual { - state - .ui - .active_theme - .selected_style() - .bg(state.ui.active_theme.primary.rat()) - } else if i == state.ui.selected_playlist_index { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - - match node { - echo_core::models::LibraryNode::Folder(f) => { - let prefix = if f.is_open { "▼" } else { "▶" }; - let text = truncate_to_width_with_ellipsis( - &format!("{} {}", prefix, stabilize_terminal_emoji_width(&f.name)), - library_text_width, - ); - let folder_style = if i == state.ui.selected_playlist_index { - style - } else { - state.ui.active_theme.primary_style() - }; - ListItem::new(text).style(folder_style.add_modifier(Modifier::BOLD)) - } - echo_core::models::LibraryNode::Playlist { playlist, indent } => { - let mut prefix = String::new(); - for _ in 0..*indent { - prefix.push_str(" "); - } - if state.ui.library_config.pinned.contains(&playlist.id) { - prefix.push_str("📌 "); - } - - let text = format!( - "{}{}", - prefix, - stabilize_terminal_emoji_width(&playlist.name) - ); - let text = truncate_to_width_with_ellipsis(&text, library_text_width); - - // Mark as ghosted if it is in the cut register - let list_style = if state.ui.operation_register.contains(&playlist.id) { - style.fg(state.ui.active_theme.text_muted.rat()) - } else { - style - }; - - ListItem::new(text).style(list_style) - } - } - }) - .collect(); - - if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - let items: Vec = state - .data - .saved_albums - .iter() - .enumerate() - .map(|(i, album)| { - let is_in_visual = if let Some((start, end)) = visual_range { - i >= start && i <= end - } else { - false - }; - let style = if is_in_visual { - state - .ui - .active_theme - .selected_style() - .bg(state.ui.active_theme.primary.rat()) - } else if is_focused && i == state.ui.selected_playlist_index { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - ListItem::new(truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&album.name), - library_text_width, - )) - .style(style) - }) - .collect(); - - let list = padded_library_list(items).highlight_style( - state - .ui - .active_theme - .selected_style() - .add_modifier(Modifier::BOLD), - ); - - let mut list_state = ListState::default(); - list_state.select(Some(state.ui.selected_playlist_index)); - frame.render_stateful_widget(list, library_list_area, &mut list_state); - repair_wide_grapheme_trailing_styles(frame.buffer_mut(), library_list_area); - } else if state.ui.active_library_tab == echo_core::app::LibraryTab::Browse { - let items: Vec = vec![ - "📈 Top Tracks", - "🕒 Recently Played", - "👤 Followed Artists", - "⭐ Top Artists", - "🆕 What's New", - ] - .into_iter() - .enumerate() - .map(|(i, name)| { - let is_in_visual = if let Some((start, end)) = visual_range { - i >= start && i <= end - } else { - false - }; - let style = if is_in_visual { - state - .ui - .active_theme - .selected_style() - .bg(state.ui.active_theme.primary.rat()) - } else if is_focused && i == state.ui.selected_playlist_index { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let text = stabilize_terminal_emoji_width(name); - ListItem::new(truncate_to_width_with_ellipsis(&text, library_text_width)).style(style) - }) - .collect(); - - let list = padded_library_list(items).highlight_style( - state - .ui - .active_theme - .selected_style() - .add_modifier(Modifier::BOLD), - ); - - let mut list_state = ListState::default(); - list_state.select(Some(state.ui.selected_playlist_index)); - frame.render_stateful_widget(list, library_list_area, &mut list_state); - repair_wide_grapheme_trailing_styles(frame.buffer_mut(), library_list_area); - } else { - let playlist_list = padded_library_list(library_items); - let mut playlist_state = ListState::default(); - playlist_state.select(Some(state.ui.selected_playlist_index)); - frame.render_stateful_widget(playlist_list, library_list_area, &mut playlist_state); - repair_wide_grapheme_trailing_styles(frame.buffer_mut(), library_list_area); - } -} - -struct ThumbRow { - title: String, - subtitle: String, - thumb_url: Option, - indent: u16, - ghosted: bool, - is_folder: bool, -} - -/// Folders have no cover art and stay compact single-liners; everything else -/// gets a cover-sized multi-line row. -fn thumb_row_height(row: &ThumbRow) -> u16 { - if row.is_folder { - 1 - } else { - echo_core::thumbnails::ROW_H - } -} - -/// Scroll math mirroring a per-frame recreated `ListState`, generalized to -/// variable row heights: the viewport only ever scrolls down far enough that -/// the selected row is fully visible at the bottom. -pub fn thumb_first_visible(selected: usize, heights: &[u16], viewport: u16) -> usize { - if heights.is_empty() { - return 0; - } - let selected = selected.min(heights.len() - 1); - let mut used = 0u16; - let mut first = selected; - for i in (0..=selected).rev() { - if used.saturating_add(heights[i]) > viewport { - // Selected row taller than the viewport: render it clipped. - return if i == selected { selected } else { first }; - } - used = used.saturating_add(heights[i]); - first = i; - } - first -} - -fn render_library_thumbnails( - frame: &mut Frame, - state: &mut AppState, - area: Rect, - is_focused: bool, - visual_range: Option<(usize, usize)>, -) { - use echo_core::thumbnails::{THUMB_H, THUMB_W, ThumbState}; - - let rows: Vec = if state.ui.active_library_tab == echo_core::app::LibraryTab::Albums { - state - .data - .saved_albums - .iter() - .map(|album| ThumbRow { - title: stabilize_terminal_emoji_width(&album.name), - subtitle: album.artists.clone(), - thumb_url: album.thumb_url.clone().or_else(|| album.image_url.clone()), - indent: 0, - ghosted: false, - is_folder: false, - }) - .collect() - } else { - state - .data - .library_view - .iter() - .map(|node| match node { - echo_core::models::LibraryNode::Folder(f) => ThumbRow { - title: format!( - "{} {}", - if f.is_open { "▼" } else { "▶" }, - stabilize_terminal_emoji_width(&f.name) - ), - subtitle: String::new(), - thumb_url: None, - indent: 0, - ghosted: false, - is_folder: true, - }, - echo_core::models::LibraryNode::Playlist { playlist, indent } => { - let mut prefix = String::new(); - if state.ui.library_config.pinned.contains(&playlist.id) { - prefix.push_str("📌 "); - } - ThumbRow { - title: format!( - "{}{}", - prefix, - stabilize_terminal_emoji_width(&playlist.name) - ), - subtitle: playlist.owner.clone(), - thumb_url: playlist - .thumb_url - .clone() - .or_else(|| playlist.image_url.clone()), - indent: (*indent as u16).saturating_mul(2), - ghosted: state.ui.operation_register.contains(&playlist.id), - is_folder: false, - } - } - }) - .collect() - }; - - if rows.is_empty() { - return; - } - - let heights: Vec = rows.iter().map(thumb_row_height).collect(); - let selected = state.ui.selected_playlist_index.min(rows.len() - 1); - let first = thumb_first_visible(selected, &heights, area.height); - let mut last = first; - let mut used = 0u16; - while last < rows.len() && used < area.height { - used = used.saturating_add(heights[last]); - last += 1; - } - - // Request pass: queue loads for any visible thumbnail not yet decoded. - for row in &rows[first..last] { - let Some(url) = row.thumb_url.as_deref() else { - continue; - }; - if state - .ui - .thumbnails - .get(url, echo_core::thumbnails::ThumbTier::Small) - .is_none() - { - state - .ui - .thumbnails - .request(url, echo_core::thumbnails::ThumbTier::Small); - } - } - - let base_style = state.ui.active_theme.base_style(); - let selected_style = state.ui.active_theme.selected_style(); - let visual_style = state.ui.active_theme.selected_style().bg(state - .ui - .active_theme - .primary - .rat()); - let folder_style = state - .ui - .active_theme - .primary_style() - .add_modifier(Modifier::BOLD); - let muted = state.ui.active_theme.text_muted.rat(); - let show_selection = - state.ui.active_library_tab == echo_core::app::LibraryTab::Playlists || is_focused; - - let mut y = area.y; - for i in first..last { - let row = &rows[i]; - if y >= area.bottom() { - break; - } - let row_bottom = (y + heights[i]).min(area.bottom()); - let in_visual = visual_range.is_some_and(|(start, end)| i >= start && i <= end); - let mut style = if in_visual { - visual_style - } else if show_selection && i == selected { - selected_style - } else if row.is_folder { - folder_style - } else { - base_style - }; - if row.is_folder { - style = style.add_modifier(Modifier::BOLD); - } - if row.ghosted { - style = style.fg(muted); - } - - let buf = frame.buffer_mut(); - for yy in y..row_bottom { - for xx in area.left()..area.right() { - let cell = &mut buf[(xx, yy)]; - cell.set_style(style); - cell.set_symbol(" "); - } - } - - let img_x = area.x + 1 + row.indent; - let mut text_x = area.x + 1 + row.indent; - if !row.is_folder && img_x + THUMB_W < area.right() { - let img_area = Rect { - x: img_x, - y, - width: THUMB_W, - height: THUMB_H.min(row_bottom.saturating_sub(y)), - }; - let artwork = row.thumb_url.as_deref().and_then(|url| { - match state - .ui - .thumbnails - .get(url, echo_core::thumbnails::ThumbTier::Small) - { - Some(ThumbState::Ready { artwork }) => Some(artwork.clone()), - _ => None, - } - }); - if let Some(artwork) = artwork { - crate::tui::image::draw(buf, img_area, &artwork); - } else { - draw_thumb_placeholder(buf, img_area, style.fg(muted), "♪"); - } - text_x = img_x + THUMB_W + 1; - } - - if text_x < area.right() { - let text_w = (area.right() - text_x).saturating_sub(1); - let title = truncate_to_width_with_ellipsis(&row.title, text_w); - buf.set_stringn(text_x, y, &title, text_w as usize, style); - if !row.subtitle.is_empty() && y + 1 < row_bottom { - let subtitle = truncate_to_width_with_ellipsis(&row.subtitle, text_w); - buf.set_stringn(text_x, y + 1, &subtitle, text_w as usize, style.fg(muted)); - } - // Repair wide-grapheme trailers over the text columns only; the - // image cells carry protocol payloads and must not be restyled. - repair_wide_grapheme_trailing_styles( - buf, - Rect { - x: text_x, - y, - width: area.right() - text_x, - height: row_bottom.saturating_sub(y), - }, - ); - } - - y = row_bottom; - } -} - -fn draw_thumb_placeholder(buf: &mut Buffer, area: Rect, style: Style, symbol: &str) { - if area.width < 2 || area.height == 0 { - return; - } - let right = area.x + area.width - 1; - let bottom_row = area.y + area.height - 1; - for y in area.y..=bottom_row { - for x in area.x..=right { - let cell = &mut buf[(x, y)]; - let glyph = match (y, x) { - (yy, xx) if yy == area.y && xx == area.x => "┌", - (yy, xx) if yy == area.y && xx == right => "┐", - (yy, xx) if yy == bottom_row && xx == area.x => "└", - (yy, xx) if yy == bottom_row && xx == right => "┘", - (yy, _) if yy == area.y || yy == bottom_row => "─", - (_, xx) if xx == area.x || xx == right => "│", - _ => " ", - }; - cell.set_style(style); - cell.set_symbol(glyph); - } - } - if !symbol.is_empty() && area.height >= 2 { - let center_x = area.x + area.width / 2; - let center_y = area.y + area.height / 2; - buf[(center_x, center_y)].set_symbol(symbol); - } -} - -pub fn render_track_list(frame: &mut Frame, state: &mut AppState, tracks_area: Rect) { - let is_album_context = state - .data - .active_tracklist_context - .as_ref() - .map(|context| context.is_album()) - .unwrap_or(false); - - let visual_range = if state.ui.active_view == ActiveView::TrackList { - state.get_visual_selection_range() - } else { - None - }; - - let is_liked_songs = state - .data - .active_tracklist_context - .as_ref() - .is_some_and(|context| context.id == "LIKED_SONGS"); - - let track_rows: Vec = state - .data - .tracks - .iter() - .enumerate() - .map(|(i, t)| { - let is_match = state.ui.mode == AppMode::Search && state.ui.search_matches.contains(&i); - - let is_in_visual = if let Some((start, end)) = visual_range { - i >= start && i <= end - } else { - false - }; - - let style = if is_in_visual { - state - .ui - .active_theme - .selected_style() - .bg(state.ui.active_theme.primary.rat()) - } else if i == state.ui.selected_track_index { - state.ui.active_theme.selected_style() - } else if is_match { - state - .ui - .active_theme - .base_style() - .fg(state.ui.active_theme.secondary.rat()) - } else { - state.ui.active_theme.base_style() - }; - - let prefix = if Some(t.id.clone()) == state.playback.playing_track_id { - "▶ " - } else { - "" - }; - - let number_cell = if state.ui.library_config.track_index_base < 0 { - Cell::from("") - } else { - Cell::from(format!( - "{:>3}", - displayed_track_number( - i, - state.ui.selected_track_index, - state.ui.library_config.track_index_base, - state.ui.library_config.relative_line_numbers, - ) - )) - }; - - let liked_str = if is_liked_songs { - "" - } else if state.data.liked_tracks.contains(&t.id) { - "♥" - } else { - " " - }; - - let is_selected = is_in_visual || i == state.ui.selected_track_index; - let liked_cell = if is_selected { - Cell::from(liked_str) - } else { - Cell::from(liked_str) - .style(Style::default().fg(state.ui.active_theme.secondary.rat())) - }; - - let title_cell = Cell::from(format!( - "{}{}", - prefix, - stabilize_terminal_emoji_width(&t.name) - )); - - let duration_cell = Cell::from(format_duration_text(format_time(t.duration_ms / 1000))); - - let row = if is_album_context { - Row::new(vec![number_cell, liked_cell, title_cell, duration_cell]) - } else { - let artist_cell = Cell::from(stabilize_terminal_emoji_width(&t.artist)); - Row::new(vec![ - number_cell, - liked_cell, - title_cell, - artist_cell, - duration_cell, - ]) - }; - - row.style(style) - }) - .collect(); - - let is_track_focused = state.ui.active_view == ActiveView::TrackList; - let track_border_style = if is_track_focused { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.primary_style() - }; - - let track_block = Block::default() - .title(echo_core::i18n::t( - "ui.tracks", - &state.ui.library_config.language, - )) - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()) - .border_style(track_border_style); - let track_inner_area = track_block.inner(tracks_area); - - let header_style = track_border_style.add_modifier(Modifier::BOLD); - - let liked_width = if is_liked_songs { 0 } else { 2 }; - - let table = if is_album_context { - let number_header = if state.ui.library_config.track_index_base < 0 { - "" - } else { - " #" - }; - let header = Row::new(vec![number_header, "", "Track", "Duration "]) - .style(header_style) - .height(1); - let number_width = if state.ui.library_config.track_index_base < 0 { - 0 - } else { - 4 - }; - let mut t = Table::new( - track_rows, - [ - Constraint::Length(number_width), - Constraint::Length(liked_width), - Constraint::Min(20), - Constraint::Length(DURATION_COLUMN_WIDTH), - ], - ) - .column_spacing(1) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - - if !state.data.tracks.is_empty() { - t = t.header(header); - } - t - } else { - let number_header = if state.ui.library_config.track_index_base < 0 { - "" - } else { - " #" - }; - let header = Row::new(vec![number_header, "", "Track", "Artist", "Duration "]) - .style(header_style) - .height(1); - let number_width = if state.ui.library_config.track_index_base < 0 { - 0 - } else { - 4 - }; - let mut t = Table::new( - track_rows, - [ - Constraint::Length(number_width), - Constraint::Length(liked_width), - Constraint::Percentage(50), - Constraint::Percentage(50), - Constraint::Length(DURATION_COLUMN_WIDTH), - ], - ) - .column_spacing(1) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - - if !state.data.tracks.is_empty() { - t = t.header(header); - } - t - }; - - frame.render_widget(track_block, tracks_area); - - let mut header_info: Option<(String, String)> = None; - if !state.data.tracks.is_empty() { - header_info = state - .data - .active_tracklist_context - .as_ref() - .map(|context| (context.title.clone(), context.subtitle.clone())); - } - - let (header_area, table_area) = if header_info.is_some() { - let chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(7), // Header height - ratatui::layout::Constraint::Min(0), - ]) - .split(track_inner_area); - (Some(chunks[0]), chunks[1]) - } else { - (None, track_inner_area) - }; - - if let Some(h_area) = header_area - && let Some((title, author)) = header_info - { - let has_image = state.ui.active_library_header_image.is_some(); - let image_width = if has_image { 14 } else { 2 }; - - let chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - ratatui::layout::Constraint::Length(image_width), - ratatui::layout::Constraint::Min(0), - ]) - .split(h_area); - - let img_area = Rect { - x: chunks[0].x + if has_image { 2 } else { 0 }, - y: chunks[0].y + 1, // 1 top margin - width: if has_image { 10 } else { 0 }, - height: if has_image { 5 } else { 0 }, - }; - - if let Some(artwork) = state.ui.active_library_header_image.clone() { - crate::tui::image::draw(frame.buffer_mut(), img_area, &artwork); - } - - let text_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Min(0), - ]) - .split(chunks[1]); - - let title_para = ratatui::widgets::Paragraph::new(title).style( - Style::default() - .fg(state.ui.active_theme.primary.rat()) - .add_modifier(Modifier::BOLD), - ); - let author_para = ratatui::widgets::Paragraph::new(author) - .style(Style::default().fg(state.ui.active_theme.secondary.rat())); - let count_para = ratatui::widgets::Paragraph::new(format!( - "{} {}", - state.data.tracks.len(), - echo_core::i18n::t("ui.tracks", &state.ui.library_config.language) - )) - .style(Style::default().fg(Color::DarkGray)); - - frame.render_widget(title_para, text_chunks[1]); - frame.render_widget(author_para, text_chunks[2]); - frame.render_widget(count_para, text_chunks[3]); - } - - let mut ts = TableState::default(); - let sel = if state.data.tracks.is_empty() { - 0 - } else { - state - .ui - .selected_track_index - .min(state.data.tracks.len() - 1) - }; - ts.select(Some(sel)); - frame.render_stateful_widget(table, table_area, &mut ts); - - if state.data.tracks.is_empty() { - let logo_height = ECHO_LOGO.len() as u16; - let logo_width = 63; // Width of the longest line in ECHO_LOGO - - if track_inner_area.width > logo_width && track_inner_area.height > logo_height { - let x_offset = (track_inner_area.width - logo_width) / 2; - let y_offset = (track_inner_area.height - logo_height) / 2; - - let gradient_lines: Vec = ECHO_LOGO - .iter() - .map(|&line| { - let mut spans = Vec::new(); - for (i, c) in line.chars().enumerate() { - let t = i as f32 / logo_width as f32; - let base_color = lerp_color( - state.ui.active_theme.secondary.rat(), - state.ui.active_theme.primary.rat(), - t, - ); - - let style = if c == '█' { - Style::default().fg(base_color) - } else if c != ' ' { - let (r, g, b) = color_to_rgb(base_color); - let (bg_r, bg_g, bg_b) = - color_to_rgb(state.ui.active_theme.background.rat()); - let alpha = 0.4; - let shadow_color = Color::Rgb( - (r * alpha + bg_r * (1.0 - alpha)) as u8, - (g * alpha + bg_g * (1.0 - alpha)) as u8, - (b * alpha + bg_b * (1.0 - alpha)) as u8, - ); - Style::default().fg(shadow_color) - } else { - Style::default() - }; - spans.push(Span::styled(c.to_string(), style)); - } - Line::from(spans) - }) - .collect(); - let gradient_area = Rect { - x: track_inner_area.x + x_offset, - y: track_inner_area.y + y_offset, - width: logo_width, - height: logo_height, - }; - frame.render_widget( - ratatui::widgets::Paragraph::new(gradient_lines), - gradient_area, - ); - } - } -} - -#[cfg(test)] -mod thumb_tests { - use super::thumb_first_visible; - - fn visible_height(first: usize, selected: usize, heights: &[u16]) -> u16 { - heights[first..=selected].iter().sum() - } - - #[test] - fn selection_is_always_fully_visible() { - let uniform = vec![3u16; 50]; - let mixed: Vec = (0..50).map(|i| if i % 4 == 0 { 1 } else { 3 }).collect(); - for heights in [&uniform, &mixed] { - for viewport in [3u16, 7, 20] { - for selected in [0usize, 1, 10, 49, 60] { - let first = thumb_first_visible(selected, heights, viewport); - let clamped = selected.min(heights.len() - 1); - assert!(first <= clamped); - assert!(visible_height(first, clamped, heights) <= viewport); - } - } - } - } - - #[test] - fn empty_list_starts_at_zero() { - assert_eq!(thumb_first_visible(0, &[], 5), 0); - } - - #[test] - fn short_list_starts_at_top() { - assert_eq!(thumb_first_visible(2, &[3, 3, 3], 24), 0); - } - - #[test] - fn selection_is_last_visible_once_past_first_page() { - // 15 rows of height 3, viewport 9 -> rows 8..=10 visible. - assert_eq!(thumb_first_visible(10, &[3u16; 15], 9), 8); - } - - #[test] - fn single_line_folders_let_more_rows_fit() { - // folder(1) + three playlists(3) = 10 cells: all fit in a 10-tall - // viewport, whereas uniform 3-tall rows would not. - let heights = [1u16, 3, 3, 3]; - assert_eq!(thumb_first_visible(3, &heights, 10), 0); - assert_eq!(thumb_first_visible(3, &[3u16, 3, 3, 3], 10), 1); - } - - #[test] - fn oversized_selected_row_renders_clipped() { - assert_eq!(thumb_first_visible(2, &[3u16, 3, 3], 2), 2); - } -} diff --git a/crates/spotify-tui/src/tui/mod.rs b/crates/spotify-tui/src/tui/mod.rs deleted file mode 100644 index 233211b..0000000 --- a/crates/spotify-tui/src/tui/mod.rs +++ /dev/null @@ -1,91 +0,0 @@ -pub mod artist; -pub mod image; -pub mod library; -pub mod playback; -pub mod queue; -pub mod render; -pub mod search; -pub mod theme; - -use anyhow::Result; -use crossterm::style::{Color as CrosstermColor, ResetColor, SetBackgroundColor}; -use crossterm::{ - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{Terminal, backend::CrosstermBackend, style::Color as RatatuiColor}; -use std::io::{Stdout, stdout}; - -pub struct Tui { - pub terminal: Terminal>, - background: Option, -} - -impl Tui { - pub fn new() -> Result { - let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::new(backend)?; - Ok(Self { - terminal, - background: None, - }) - } - - pub fn enter(&mut self) -> Result<()> { - enable_raw_mode()?; - execute!(stdout(), EnterAlternateScreen)?; - self.terminal.hide_cursor()?; - Ok(()) - } - - pub fn apply_background(&mut self, color: RatatuiColor, force_clear: bool) -> Result<()> { - if self.background == Some(color) && !force_clear { - return Ok(()); - } - - match to_crossterm_color(color) { - CrosstermColor::Reset => { - execute!(self.terminal.backend_mut(), ResetColor)?; - } - color => { - execute!(self.terminal.backend_mut(), SetBackgroundColor(color))?; - } - } - - self.terminal.clear()?; - execute!(self.terminal.backend_mut(), ResetColor)?; - self.background = Some(color); - Ok(()) - } - - pub fn exit(&mut self) -> Result<()> { - execute!(stdout(), ResetColor, LeaveAlternateScreen)?; - disable_raw_mode()?; - self.terminal.show_cursor()?; - Ok(()) - } -} - -fn to_crossterm_color(color: RatatuiColor) -> CrosstermColor { - match color { - RatatuiColor::Reset => CrosstermColor::Reset, - RatatuiColor::Black => CrosstermColor::Black, - RatatuiColor::Red => CrosstermColor::DarkRed, - RatatuiColor::Green => CrosstermColor::DarkGreen, - RatatuiColor::Yellow => CrosstermColor::DarkYellow, - RatatuiColor::Blue => CrosstermColor::DarkBlue, - RatatuiColor::Magenta => CrosstermColor::DarkMagenta, - RatatuiColor::Cyan => CrosstermColor::DarkCyan, - RatatuiColor::Gray => CrosstermColor::Grey, - RatatuiColor::DarkGray => CrosstermColor::DarkGrey, - RatatuiColor::LightRed => CrosstermColor::Red, - RatatuiColor::LightGreen => CrosstermColor::Green, - RatatuiColor::LightYellow => CrosstermColor::Yellow, - RatatuiColor::LightBlue => CrosstermColor::Blue, - RatatuiColor::LightMagenta => CrosstermColor::Magenta, - RatatuiColor::LightCyan => CrosstermColor::Cyan, - RatatuiColor::White => CrosstermColor::White, - RatatuiColor::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b }, - RatatuiColor::Indexed(i) => CrosstermColor::AnsiValue(i), - } -} diff --git a/crates/spotify-tui/src/tui/playback.rs b/crates/spotify-tui/src/tui/playback.rs deleted file mode 100644 index a239ba6..0000000 --- a/crates/spotify-tui/src/tui/playback.rs +++ /dev/null @@ -1,390 +0,0 @@ -use crate::tui::render::{format_time, stabilize_terminal_emoji_width}; -use crate::tui::theme::{ThemeStyles, ToRatatui}; -use echo_core::app::AppState; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::Modifier, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, Paragraph}, -}; - -pub fn render_playback_bar(frame: &mut Frame, state: &mut AppState, area: Rect) { - let shuffle_str = if state.playback.is_shuffled { - echo_core::i18n::t("ui.on", &state.ui.library_config.language) - } else { - echo_core::i18n::t("ui.off", &state.ui.library_config.language) - }; - - let repeat_str = if state.playback.repeat_mode == "Off" { - echo_core::i18n::t("ui.off", &state.ui.library_config.language) - } else { - state.playback.repeat_mode.clone() - }; - - let mut border_title = echo_core::i18n::t("ui.playing", &state.ui.library_config.language); - border_title = border_title.replacen("{}", &state.playback.device_name, 1); - border_title = border_title.replacen("{}", &format!("{:<7}", shuffle_str), 1); - border_title = border_title.replacen("{}", &format!("{:<7}", repeat_str), 1); - border_title = border_title.replacen("{}", &format!("{:>3}", state.playback.volume), 1); - - let playback_block = Block::default() - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()) - .title(border_title); - - let playback_inner = playback_block.inner(area); - frame.render_widget(playback_block, area); - - let playback_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), // Track Info - Constraint::Length(1), // Progress bar - ]) - .split(playback_inner); - - // Render Progress Bar - let pb = &state.playback; - let effective_progress_ms = pb.display_progress_ms(); - let ratio = if pb.duration_ms > 0 { - (effective_progress_ms as f64 / pb.duration_ms as f64).clamp(0.0, 1.0) - } else { - 0.0 - }; - - let progress_sec = effective_progress_ms / 1000; - let duration_sec = pb.duration_ms / 1000; - - let progress_str = format_time(progress_sec); - let duration_str = format_time(duration_sec); - - let pb_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(2), // left padding (aligns with track cover) - Constraint::Length(2), // play/pause icon - Constraint::Length(6), // current time - Constraint::Min(0), // gauge - Constraint::Length(6), // duration - ]) - .split(playback_chunks[1]); - - let play_icon = if state.playback.is_playing { - "▌▌" // Use two half-blocks to avoid emoji rendering with a background - } else { - "▶ " - }; - - let play_icon_p = Paragraph::new(play_icon) - .alignment(Alignment::Left) - .style(state.ui.active_theme.primary_style()); - - let current_time_p = Paragraph::new(progress_str) - .alignment(Alignment::Right) - .style(state.ui.active_theme.base_style()); - let total_time_p = Paragraph::new(duration_str) - .alignment(Alignment::Left) - .style(state.ui.active_theme.base_style()); - - let gauge = Gauge::default() - .gauge_style(state.ui.active_theme.gauge_style()) - .ratio(ratio) - .label(""); // hide inner text - - frame.render_widget(play_icon_p, pb_chunks[1]); - frame.render_widget(current_time_p, pb_chunks[2]); - - // Add a tiny margin around the gauge to separate it from the text - let mut gauge_area = pb_chunks[3]; - if gauge_area.width > 2 { - gauge_area.x += 1; - gauge_area.width -= 2; - } - frame.render_widget(gauge, gauge_area); - frame.render_widget(total_time_p, pb_chunks[4]); - - // Render Track Info - let is_vis_enabled = state - .playback - .enable_visualizer - .as_ref() - .map(|f| f.load(std::sync::atomic::Ordering::Relaxed)) - .unwrap_or(state.ui.library_config.enable_visualizer); - - let mut main_constraints = vec![Constraint::Length(38), Constraint::Min(0)]; - if is_vis_enabled { - main_constraints.push(Constraint::Length(38)); - } - - let main_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints(main_constraints) - .split(playback_chunks[0]); - - let track_info_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(2), // Left padding - Constraint::Length(10), // Image width - Constraint::Length(2), // Middle gap to text - Constraint::Min(0), // Text width takes up rest of the 38 - ]) - .split(main_layout[0]); - - let text_idx = 3; - let mut vis_idx = None; - let mut vis_area = ratatui::layout::Rect::default(); - - if is_vis_enabled { - let vis_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(0), - Constraint::Length(32), - Constraint::Length(2), - ]) - .split(main_layout[2]); - vis_idx = Some(1); - vis_area = vis_chunks[1]; - } - - // Cover art. Sampling is cheap and stateless, so this runs every frame — the off-screen - // Buffer cache existed only because re-rendering a StatefulProtocol re-emitted escapes. - let artwork = state - .playback - .playing_track_image - .as_ref() - .or(state.playback.previous_track_image.as_ref()) - .cloned(); - - if let Some(artwork) = artwork { - let mut image_area = track_info_chunks[1]; - if image_area.height >= 7 { - image_area.y += 1; - image_area.height = 5; - } - crate::tui::image::draw(frame.buffer_mut(), image_area, &artwork); - } - - // Create Title & Artist Text - let track_text_width = track_info_chunks[text_idx].width as usize; - - let track_title = if state.playback.playing_track_title.is_empty() { - String::new() - } else { - crate::tui::render::truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&state.playback.playing_track_title), - track_text_width as u16, - ) - }; - - let track_artist = crate::tui::render::truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&state.playback.playing_track_artist), - track_text_width as u16, - ); - - let text_lines = vec![ - Line::from(Span::styled( - track_title, - state - .ui - .active_theme - .base_style() - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - track_artist, - state.ui.active_theme.muted_style(), - )), - ]; - let track_text_p = Paragraph::new(text_lines) - .alignment(Alignment::Left) - .style(state.ui.active_theme.base_style()) - // Add top padding to vertically align with the center of the image - .block(Block::default().padding(ratatui::widgets::Padding::new(0, 0, 2, 0))); - frame.render_widget(track_text_p, track_info_chunks[text_idx]); - - // Render Condensed Lyrics perfectly centered - if state.ui.condensed_lyrics_enabled { - let center_width = main_layout[1].width; - let total_width = playback_chunks[0].width; - let center_x = main_layout[1].x; - - let format_lyric = |line: &str, style: ratatui::style::Style| -> Line { - let stabilized = stabilize_terminal_emoji_width(line); - let line_width = unicode_width::UnicodeWidthStr::width(stabilized.as_str()) as u16; - - // Ideal start x to be perfectly centered on the entire screen - let ideal_start_x = (total_width / 2).saturating_sub(line_width / 2); - - let pad_len = ideal_start_x.saturating_sub(center_x); - let pad_str = " ".repeat(pad_len as usize); - - let available_w = center_width.saturating_sub(pad_len); - let trunc_line = - crate::tui::render::truncate_to_width_with_ellipsis(&stabilized, available_w); - - Line::from(vec![Span::raw(pad_str), Span::styled(trunc_line, style)]) - }; - - let lyrics_lines = if let Some(lyrics) = &state.playback.current_lyrics { - if lyrics.lines.is_empty() { - vec![format_lyric( - "No lyrics found.", - state.ui.active_theme.muted_style(), - )] - } else { - let mut current_lyric_idx = 0; - let current_progress = state.playback.progress_ms; - for (i, line) in lyrics.lines.iter().enumerate() { - if line.start_ms <= current_progress { - current_lyric_idx = i; - } else { - break; - } - } - - let current_line = lyrics - .lines - .get(current_lyric_idx) - .map(|l| l.text.as_str()) - .unwrap_or(""); - let next_line = lyrics - .lines - .get(current_lyric_idx + 1) - .map(|l| l.text.as_str()) - .unwrap_or(""); - - vec![ - format_lyric( - current_line, - state - .ui - .active_theme - .primary_style() - .add_modifier(Modifier::BOLD), - ), - format_lyric(next_line, state.ui.active_theme.muted_style()), - ] - } - } else if !state.playback.playing_track_title.is_empty() { - vec![format_lyric( - "No lyrics found.", - state.ui.active_theme.muted_style(), - )] - } else { - vec![] - }; - - if !lyrics_lines.is_empty() { - let lyrics_p = Paragraph::new(lyrics_lines) - .alignment(Alignment::Left) - .style(state.ui.active_theme.base_style()) - .block(Block::default().padding(ratatui::widgets::Padding::new(0, 0, 2, 0))); - frame.render_widget(lyrics_p, main_layout[1]); - } - } - - if is_vis_enabled - && let Some(shared_bands) = &state.playback.audio_visualization - && let Some(bands) = shared_bands.try_lock() - { - use ratatui::widgets::{Bar, BarChart, BarGroup}; - let c_primary = state.ui.active_theme.primary.rat(); - let c_secondary = state.ui.active_theme.secondary.rat(); - let c_mid_low = interpolate_color(c_secondary, c_primary, 0.33); - let c_mid_high = interpolate_color(c_secondary, c_primary, 0.66); - - let num_bins = state.ui.vis_bins.clamp(5, 32); - let mut bars = Vec::with_capacity(num_bins); - let chunk_size = 32.0 / num_bins as f32; - - for i in 0..num_bins { - let start_idx = (i as f32 * chunk_size).floor() as usize; - let mut end_idx = ((i + 1) as f32 * chunk_size).floor() as usize; - if i == num_bins - 1 { - end_idx = 32; - } - - let mut sum = 0.0; - let mut count = 0; - for j in start_idx..end_idx { - sum += bands[j]; - count += 1; - } - let val = if count > 0 { sum / count as f32 } else { 0.0 }; - - let ratio = (val / 100.0).clamp(0.0, 1.0); - - let color = if ratio < 0.25 { - c_secondary - } else if ratio < 0.50 { - c_mid_low - } else if ratio < 0.75 { - c_mid_high - } else { - c_primary - }; - - let bar = Bar::default() - .value(val as u64) - .text_value("") - .style(ratatui::style::Style::default().fg(color)); - bars.push(bar); - } - - let bw = (32 / num_bins).max(1) as u16; - let barchart = BarChart::default() - .data(BarGroup::default().bars(&bars)) - .bar_width(bw) - .bar_gap(0) - .max(100); - if vis_idx.is_some() { - if vis_area.height >= 7 { - vis_area.y += 2; - vis_area.height = 4; - } - frame.render_widget(barchart, vis_area); - } - } -} - -fn color_to_rgb(c: ratatui::style::Color) -> (u8, u8, u8) { - match c { - ratatui::style::Color::Rgb(r, g, b) => (r, g, b), - ratatui::style::Color::Black => (0, 0, 0), - ratatui::style::Color::Red => (255, 0, 0), - ratatui::style::Color::Green => (0, 255, 0), - ratatui::style::Color::Yellow => (255, 255, 0), - ratatui::style::Color::Blue => (0, 0, 255), - ratatui::style::Color::Magenta => (255, 0, 255), - ratatui::style::Color::Cyan => (0, 255, 255), - ratatui::style::Color::Gray => (128, 128, 128), - ratatui::style::Color::DarkGray => (64, 64, 64), - ratatui::style::Color::LightRed => (255, 128, 128), - ratatui::style::Color::LightGreen => (128, 255, 128), - ratatui::style::Color::LightYellow => (255, 255, 128), - ratatui::style::Color::LightBlue => (128, 128, 255), - ratatui::style::Color::LightMagenta => (255, 128, 255), - ratatui::style::Color::LightCyan => (128, 255, 255), - ratatui::style::Color::White => (255, 255, 255), - _ => (255, 255, 255), - } -} - -fn interpolate_color( - c1: ratatui::style::Color, - c2: ratatui::style::Color, - ratio: f32, -) -> ratatui::style::Color { - let rgb1 = color_to_rgb(c1); - let rgb2 = color_to_rgb(c2); - - let r = (rgb1.0 as f32 + (rgb2.0 as f32 - rgb1.0 as f32) * ratio) as u8; - let g = (rgb1.1 as f32 + (rgb2.1 as f32 - rgb1.1 as f32) * ratio) as u8; - let b = (rgb1.2 as f32 + (rgb2.2 as f32 - rgb1.2 as f32) * ratio) as u8; - - ratatui::style::Color::Rgb(r, g, b) -} diff --git a/crates/spotify-tui/src/tui/queue.rs b/crates/spotify-tui/src/tui/queue.rs deleted file mode 100644 index 1073286..0000000 --- a/crates/spotify-tui/src/tui/queue.rs +++ /dev/null @@ -1,232 +0,0 @@ -use crate::tui::render::{ - format_duration_text, format_time, stabilize_terminal_emoji_width, - truncate_to_width_with_ellipsis, -}; -use crate::tui::theme::{ThemeStyles, ToRatatui}; -use echo_core::app::{AppState, QueueRow, QueueTab}; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Rect}, - widgets::{Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, -}; - -pub fn render_queue(frame: &mut Frame, state: &AppState, area: Rect) { - let header_style = state.ui.active_theme.muted_style(); - let lang = &state.ui.library_config.language; - let recent = state.ui.queue_tab == QueueTab::Recent; - let queue_label = echo_core::i18n::t("ui.queue", lang); - let recent_label = echo_core::i18n::t("ui.recent", lang); - let title = if recent { - format!(" {queue_label} | [{recent_label}] ") - } else { - format!( - " [{queue_label}] | {recent_label} ({} upcoming) ", - state.data.queue.len() - ) - }; - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(state.ui.active_theme.primary_style()); - let inner = block.inner(area); - frame.render_widget(block, area); - - if recent { - render_recent(frame, state, inner); - return; - } - - if state.data.queue.is_empty() { - let msg = Paragraph::new("Queue is empty. Press q on any track to add it.") - .style(state.ui.active_theme.muted_style()) - .alignment(Alignment::Center); - frame.render_widget(msg, inner); - return; - } - - let w_track = inner.width.saturating_sub(11) * 60 / 100; - let w_artist = inner.width.saturating_sub(11).saturating_sub(w_track); - - let header = Row::new(vec![ - "".to_string(), // liked col - echo_core::i18n::t("ui.tracks", &state.ui.library_config.language), - echo_core::i18n::t("ui.artist", &state.ui.library_config.language), - echo_core::i18n::t("ui.duration", &state.ui.library_config.language), - ]) - .style(header_style) - .height(1); - - let visual_range = if state.ui.active_view == echo_core::app::ActiveView::Queue { - state.get_visual_selection_range() - } else { - None - }; - - let sel = state.ui.selected_queue_index; - let queue_rows = state.queue_rows(); - let selected_row = state.queue_row_of(sel); - let rows: Vec = queue_rows - .into_iter() - .map(|row| match row { - QueueRow::Header(text) => { - Row::new(vec![Cell::from(""), Cell::from(text)]).style(header_style) - } - QueueRow::Track(i, track) => { - let is_in_visual = if let Some((start, end)) = visual_range { - i >= start && i <= end - } else { - false - }; - - let style = if is_in_visual { - state - .ui - .active_theme - .selected_style() - .bg(state.ui.active_theme.primary.rat()) - } else if i == sel { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let name = truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&track.name), - w_track, - ); - let artist = truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&track.artist), - w_artist, - ); - let dur = format_duration_text(format_time(track.duration_ms / 1000)); - let liked_str = if state.data.liked_tracks.contains(&track.id) { - "♥" - } else { - " " - }; - let liked_cell = - Cell::from(liked_str).style(state.ui.active_theme.secondary_style()); - - Row::new(vec![ - liked_cell, - Cell::from(name), - Cell::from(artist).style(style.fg(state.ui.active_theme.text_muted.rat())), - Cell::from(dur).style(style.fg(state.ui.active_theme.text_muted.rat())), - ]) - .style(style) - } - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(2), - Constraint::Length(w_track), - Constraint::Min(0), - Constraint::Length(9), // DURATION_COLUMN_WIDTH - ], - ) - .column_spacing(1) - .header(header) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - - let mut ts = TableState::default(); - ts.select(selected_row); - frame.render_stateful_widget(table, inner, &mut ts); -} - -fn render_recent(frame: &mut Frame, state: &AppState, inner: Rect) { - let lang = &state.ui.library_config.language; - if state.data.recent_plays.is_empty() { - let msg = Paragraph::new(echo_core::i18n::t("desktop.recent_empty", lang)) - .style(state.ui.active_theme.muted_style()) - .alignment(Alignment::Center); - frame.render_widget(msg, inner); - return; - } - - let w_ago = 16u16; - let w_track = inner.width.saturating_sub(w_ago + 4) * 60 / 100; - let w_artist = inner - .width - .saturating_sub(w_ago + 4) - .saturating_sub(w_track); - let header = Row::new(vec![ - "".to_string(), - echo_core::i18n::t("ui.tracks", lang), - echo_core::i18n::t("ui.artist", lang), - echo_core::i18n::t("ui.recent", lang), - ]) - .style(state.ui.active_theme.muted_style()) - .height(1); - - let visual_range = state.get_visual_selection_range(); - let sel = state.ui.selected_queue_index; - let tr = |key: &str| echo_core::i18n::t(key, lang); - let rows: Vec = state - .data - .recent_plays - .iter() - .enumerate() - .map(|(i, record)| { - let track = &record.track; - let in_visual = visual_range.is_some_and(|(start, end)| i >= start && i <= end); - let style = if in_visual { - state - .ui - .active_theme - .selected_style() - .bg(state.ui.active_theme.primary.rat()) - } else if i == sel { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let liked_str = if state.data.liked_tracks.contains(&track.id) { - "♥" - } else { - " " - }; - Row::new(vec![ - Cell::from(liked_str).style(state.ui.active_theme.secondary_style()), - Cell::from(truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&track.name), - w_track, - )), - Cell::from(truncate_to_width_with_ellipsis( - &stabilize_terminal_emoji_width(&track.artist), - w_artist, - )) - .style(style.fg(state.ui.active_theme.text_muted.rat())), - Cell::from(echo_core::context_details::format_added_at_with( - std::time::SystemTime::now().into(), - &record.played_at, - &tr, - )) - .style(style.fg(state.ui.active_theme.text_muted.rat())), - ]) - .style(style) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(2), - Constraint::Length(w_track), - Constraint::Min(0), - Constraint::Length(w_ago), - ], - ) - .column_spacing(1) - .header(header) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - - let mut ts = TableState::default(); - ts.select(Some(sel)); - frame.render_stateful_widget(table, inner, &mut ts); -} diff --git a/crates/spotify-tui/src/tui/render.rs b/crates/spotify-tui/src/tui/render.rs deleted file mode 100644 index 431f6fa..0000000 --- a/crates/spotify-tui/src/tui/render.rs +++ /dev/null @@ -1,975 +0,0 @@ -use crate::tui::theme::{ThemeStyles, ToRatatui}; -use echo_core::app::{ActiveView, AppMode, AppState}; -use ratatui::{ - Frame, - buffer::Buffer, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::Modifier, - text::{Line, Span}, - widgets::{Block, Borders, HighlightSpacing, List, ListItem, Paragraph}, -}; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -const ROW_TEXT_LEFT_GUTTER: u16 = 1; -const ROW_TEXT_RIGHT_GUTTER: u16 = 1; -pub const DURATION_COLUMN_WIDTH: u16 = 9; - -pub fn render_app(frame: &mut Frame, state: &mut AppState) { - fill_background(frame, state); - - if state.ui.mode == AppMode::Setup { - render_setup(frame, state); - return; - } - - if state.ui.mode == AppMode::Authenticating { - render_authenticating(frame, state); - return; - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), // Main content - Constraint::Length(10), // Playback Bar (7 info + 1 pb + 2 border) - Constraint::Length(1), // Command bar - ]) - .split(frame.area()); - - let main_content_area = chunks[0]; - let playback_bar_area = chunks[1]; - let command_bar_area = chunks[2]; - - let main_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Length(1), - Constraint::Percentage(70), - ]) - .split(main_content_area); - let library_area = main_chunks[0]; - let tracks_area = main_chunks[2]; - - crate::tui::library::render_library_list(frame, state, library_area); - - if state.ui.active_view == ActiveView::SearchResults { - crate::tui::search::render_search_results(frame, state, tracks_area); - } else if state.ui.active_view == ActiveView::Queue { - crate::tui::queue::render_queue(frame, state, tracks_area); - } else if state.ui.active_view == ActiveView::ArtistList { - crate::tui::library::render_artist_list(frame, state, tracks_area); - } else if state.ui.active_view == ActiveView::ArtistPage { - crate::tui::library::render_artist_page(frame, state, tracks_area); - } else if state.ui.active_view == ActiveView::WhatsNew { - crate::tui::library::render_whats_new(frame, state, tracks_area); - } else { - crate::tui::library::render_track_list(frame, state, tracks_area); - } - - crate::tui::playback::render_playback_bar(frame, state, playback_bar_area); - - // Render Command Bar - let (cmd_text, cmd_style) = command_bar_content(state); - let cmd_bar = Paragraph::new(cmd_text).style(cmd_style); - frame.render_widget(cmd_bar, command_bar_area); - - if state.ui.mode == AppMode::Command && !state.ui.command_suggestions.is_empty() { - let max_len = state - .ui - .command_suggestions - .iter() - .map(|s| s.len()) - .max() - .unwrap_or(10) as u16; - let width = max_len + 4; - let height = state.ui.command_suggestions.len() as u16 + 2; - let x = 2; - let y = command_bar_area.y.saturating_sub(height); - let popup_area = Rect { - x, - y, - width, - height, - }; - - let items: Vec = state - .ui - .command_suggestions - .iter() - .enumerate() - .map(|(i, s)| { - let style = if Some(i) == state.ui.command_suggestion_index { - ratatui::style::Style::default() - .bg(state.ui.active_theme.highlight_bg.rat()) - .fg(state.ui.active_theme.highlight_fg.rat()) - } else { - state.ui.active_theme.base_style() - }; - ratatui::widgets::ListItem::new(s.clone()).style(style) - }) - .collect(); - - let list = ratatui::widgets::List::new(items).block( - Block::default() - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()), - ); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(list, popup_area); - } - - if let Some(playlist_ids) = &state.ui.playlist_delete_prompt { - let count = playlist_ids.len(); - let popup_area = centered_rect(60, 20, frame.area()); - let popup = Paragraph::new(vec![ - Line::from(Span::styled( - if count == 1 { - "Are you sure you want to delete this playlist?".to_string() - } else { - format!("Are you sure you want to delete these {} playlists?", count) - }, - state.ui.active_theme.error_style(), - )), - Line::from(""), - Line::from(Span::styled( - "Press 'y' to confirm or any other key to cancel.", - state - .ui - .active_theme - .base_style() - .add_modifier(Modifier::BOLD), - )), - ]) - .style(state.ui.active_theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Delete Playlist ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.error_style()), - ); - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(popup, popup_area); - } - - if let Some(album_ids) = &state.ui.album_mass_delete_prompt { - let count = album_ids.len(); - let popup_area = centered_rect(60, 20, frame.area()); - let popup = Paragraph::new(vec![ - Line::from(Span::styled( - if count == 1 { - "Are you sure you want to remove this album from your library?".to_string() - } else { - format!( - "Are you sure you want to remove these {} albums from your library?", - count - ) - }, - state.ui.active_theme.error_style(), - )), - Line::from(""), - Line::from(Span::styled( - "Press 'y' to confirm or any other key to cancel.", - state - .ui - .active_theme - .base_style() - .add_modifier(Modifier::BOLD), - )), - ]) - .style(state.ui.active_theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Remove Album ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.error_style()), - ); - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(popup, popup_area); - } - - if let Some(folder_name) = &state.ui.folder_delete_prompt { - let popup_area = centered_rect(60, 40, frame.area()); - let popup = Paragraph::new(vec![ - Line::from(Span::styled( - format!( - "Are you sure you want to delete the folder '{}'?", - folder_name - ), - state.ui.active_theme.error_style(), - )), - Line::from(""), - Line::from("Any playlists inside will be safely returned to the main library."), - Line::from(""), - Line::from(Span::styled( - "Press 'y' to confirm or any other key to cancel.", - state - .ui - .active_theme - .base_style() - .add_modifier(Modifier::BOLD), - )), - ]) - .style(state.ui.active_theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Delete Folder ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.error_style()), - ) - .alignment(ratatui::layout::Alignment::Center) - .wrap(ratatui::widgets::Wrap { trim: true }); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(popup, popup_area); - } - - if let Some((_, track_ids)) = &state.ui.track_delete_prompt { - let popup_area = centered_rect(60, 40, frame.area()); - let popup = Paragraph::new(vec![ - Line::from(Span::styled( - if track_ids.len() == 1 { - "Are you sure you want to remove this track from the playlist?".to_string() - } else { - format!( - "Are you sure you want to remove these {} tracks from the playlist?", - track_ids.len() - ) - }, - state.ui.active_theme.error_style(), - )), - Line::from(""), - Line::from(Span::styled( - "Press 'y' to confirm or any other key to cancel.", - state - .ui - .active_theme - .base_style() - .add_modifier(Modifier::BOLD), - )), - ]) - .style(state.ui.active_theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Remove From Playlist ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.error_style()), - ) - .alignment(ratatui::layout::Alignment::Center) - .wrap(ratatui::widgets::Wrap { trim: true }); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(popup, popup_area); - } - - if state.ui.liked_track_remove_prompt.is_some() { - let popup_area = centered_rect(60, 40, frame.area()); - let popup = Paragraph::new(vec![Line::from(Span::styled( - echo_core::i18n::t( - "prompts.remove_from_liked", - &state.ui.library_config.language, - ), - state.ui.active_theme.error_style(), - ))]) - .style(state.ui.active_theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Remove Liked Song ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.error_style()), - ) - .alignment(ratatui::layout::Alignment::Center) - .wrap(ratatui::widgets::Wrap { trim: true }); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(popup, popup_area); - } - - if state.ui.playlist_add_modal_open { - let popup_area = centered_rect(50, 60, frame.area()); - - let user_playlists = echo_core::action_menu::playlist_add_choices(state); - - let items: Vec = user_playlists - .iter() - .enumerate() - .map(|(i, p)| { - let style = if i == state.ui.selected_playlist_modal_index { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let label = if p.owner_id == "local" { - format!("{} · Local", p.name) - } else { - p.name.clone() - }; - ratatui::widgets::ListItem::new(label).style(style) - }) - .collect(); - - let list = ratatui::widgets::List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Add to Playlist ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()), - ) - .highlight_style(state.ui.active_theme.selected_style()); - - let mut list_state = ratatui::widgets::ListState::default(); - list_state.select(Some(state.ui.selected_playlist_modal_index)); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_stateful_widget(list, popup_area, &mut list_state); - } - - if state.ui.device_modal_open { - let popup_area = centered_rect(50, 60, frame.area()); - - let items: Vec = state - .data - .devices - .iter() - .enumerate() - .map(|(i, d)| { - let mut style = if i == state.ui.selected_device_index { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - - let active_marker = if d.is_active { " [Active]" } else { "" }; - let text = format!("{} ({}%){}", d.name, d.volume_percent, active_marker); - - if d.is_active && i != state.ui.selected_device_index { - style = style.fg(state.ui.active_theme.primary.rat()); - } - - ratatui::widgets::ListItem::new(text).style(style) - }) - .collect(); - - let list = ratatui::widgets::List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Switch Device ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()), - ) - .highlight_style(state.ui.active_theme.selected_style()); - - let mut list_state = ratatui::widgets::ListState::default(); - list_state.select(Some(state.ui.selected_device_index)); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_stateful_widget(list, popup_area, &mut list_state); - } - - // Check if we are waiting for discovery - if echo_core::config::debug_log_path("echo-librespot-status.log").exists() { - let popup_area = centered_rect(72, 32, frame.area()); - let popup = Paragraph::new(vec![ - Line::from("Spotify Connect Onboarding Required") - .style(state.ui.active_theme.secondary_style()), - Line::from(""), - Line::from("1. Open Spotify on your phone or desktop."), - Line::from("2. Open the Devices picker."), - Line::from("3. Select 'echo-rs' from available devices."), - Line::from(""), - Line::from("echo will continue once the device connects."), - ]) - .style(state.ui.active_theme.base_style()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Setup ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.secondary_style()), - ) - .alignment(ratatui::layout::Alignment::Left) - .wrap(ratatui::widgets::Wrap { trim: true }); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - frame.render_widget(popup, popup_area); - } - - render_action_menu(frame, state); - render_lyrics_modal(frame, state); -} - -fn command_bar_content(state: &AppState) -> (String, ratatui::style::Style) { - match state.ui.mode { - AppMode::Command => ( - format!(":{}", state.ui.command_buffer), - state.ui.active_theme.base_style(), - ), - AppMode::Search => ( - format!("/{}", state.ui.search_query), - state.ui.active_theme.base_style(), - ), - _ => match state.ui.audio_output_error.as_ref() { - Some(message) => (message.clone(), state.ui.active_theme.error_style()), - None => ( - state.ui.status_message.clone().unwrap_or_default(), - state.ui.active_theme.muted_style(), - ), - }, - } -} - -pub fn render_action_menu(frame: &mut Frame, state: &AppState) { - if !state.ui.action_menu_open { - return; - } - - let ctx = match &state.ui.action_menu_context { - Some(c) => c, - None => return, - }; - - let lang = &state.ui.library_config.language; - let actions = ctx.actions(); - let labels: Vec = actions - .iter() - .map(|&action| echo_core::action_menu::label(state, ctx, action)) - .collect(); - - // Measure popup size — title + 2 border lines + 1 blank + N actions - let title_format = echo_core::i18n::t("actions.title", lang); - let track_label = title_format.replace("{}", &ctx.track_name); - let label_width = track_label.len() as u16; - let popup_w = label_width.clamp(32, 50); - let popup_h = (labels.len() as u16) + 4; - - let area = frame.area(); - let x = (area.width.saturating_sub(popup_w)) / 2; - let y = (area.height.saturating_sub(popup_h)) / 2; - let popup_area = Rect { - x, - y, - width: popup_w, - height: popup_h, - }; - - frame.render_widget(ratatui::widgets::Clear, popup_area); - - let block = Block::default() - .borders(Borders::ALL) - .title(track_label) - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()); - - let items: Vec = labels - .iter() - .enumerate() - .map(|(i, label)| { - let style = if i == state.ui.selected_action_index { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let text = format!(" {} ", label); - ListItem::new(text).style(style) - }) - .collect(); - - let list = List::new(items) - .block(block) - .highlight_spacing(HighlightSpacing::Never); - - frame.render_widget(list, popup_area); -} - -pub fn render_lyrics_modal(frame: &mut Frame, state: &mut AppState) { - if !state.ui.lyrics_modal_open { - return; - } - - let popup_area = centered_rect(60, 80, frame.area()); - - let block = Block::default() - .borders(Borders::ALL) - .title(" Lyrics ") - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()); - - frame.render_widget(ratatui::widgets::Clear, popup_area); - - if state.playback.is_fetching_lyrics { - let p = Paragraph::new("Loading lyrics...") - .alignment(Alignment::Center) - .block(block) - .style(state.ui.active_theme.base_style()); - frame.render_widget(p, popup_area); - return; - } - - if let Some(lyrics) = &state.playback.current_lyrics { - let pb = &state.playback; - let mut current_idx = 0; - - for (i, line) in lyrics.lines.iter().enumerate() { - if line.start_ms <= pb.progress_ms { - current_idx = i; - } else { - break; - } - } - - let mut items = Vec::new(); - for (i, line) in lyrics.lines.iter().enumerate() { - let style = if i == current_idx { - state - .ui - .active_theme - .primary_style() - .add_modifier(Modifier::BOLD) - } else if i > current_idx { - state.ui.active_theme.muted_style() - } else { - state.ui.active_theme.base_style() - }; - items.push(ratatui::widgets::ListItem::new(line.text.clone()).style(style)); - } - - let list = ratatui::widgets::List::new(items) - .block(block) - .highlight_style( - state - .ui - .active_theme - .primary_style() - .add_modifier(Modifier::BOLD), - ); - - let mut list_state = ratatui::widgets::ListState::default(); - - // Center the active item - let height = popup_area.height.saturating_sub(2) as usize; - let offset = height / 2; - let start = current_idx.saturating_sub(offset); - - *list_state.offset_mut() = start; - list_state.select(Some(current_idx)); - - frame.render_stateful_widget(list, popup_area, &mut list_state); - } else { - let p = Paragraph::new("No lyrics found.") - .alignment(Alignment::Center) - .block(block) - .style(state.ui.active_theme.base_style()); - frame.render_widget(p, popup_area); - } -} - -pub fn fill_background(frame: &mut Frame, state: &AppState) { - let area = frame.area(); - let style = state.ui.active_theme.base_style(); - let buffer = frame.buffer_mut(); - - for y in area.top()..area.bottom() { - for x in area.left()..area.right() { - let cell = &mut buffer[(x, y)]; - if cell.skip { - continue; - } - cell.set_style(style); - cell.set_symbol(" "); - } - } -} - -pub fn repair_wide_grapheme_trailing_styles(buffer: &mut Buffer, area: Rect) { - let area = buffer.area().intersection(area); - - for y in area.top()..area.bottom() { - let mut x = area.left(); - while x < area.right() { - let width = buffer[(x, y)].symbol().width() as u16; - if width > 1 { - let style = buffer[(x, y)].style(); - for hidden_x in (x + 1)..x.saturating_add(width).min(area.right()) { - buffer[(hidden_x, y)].set_style(style).set_skip(true); - } - } - x = x.saturating_add(width.max(1)); - } - } -} - -pub fn padded_library_list(items: Vec) -> List { - List::new(items) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always) -} - -pub fn row_text_width(area: Rect) -> u16 { - area.width - .saturating_sub(ROW_TEXT_LEFT_GUTTER + ROW_TEXT_RIGHT_GUTTER) -} - -pub fn format_duration_text(time: String) -> String { - format!("{:>8} ", time) -} - -pub fn format_time(s: u32) -> String { - format!("{}:{:02}", s / 60, s % 60) -} - -pub fn truncate_to_width_with_ellipsis(text: &str, max_width: u16) -> String { - let max_width = max_width as usize; - - if text.width() <= max_width { - return text.to_string(); - } - - let ellipsis = "..."; - let ellipsis_width = ellipsis.width(); - if max_width == 0 { - return String::new(); - } - if max_width <= ellipsis_width { - return ".".repeat(max_width); - } - - let content_width = max_width - ellipsis_width; - let mut truncated = String::new(); - let mut width = 0; - - for grapheme in UnicodeSegmentation::graphemes(text, true) { - let grapheme_width = grapheme.width(); - if width + grapheme_width > content_width { - break; - } - - truncated.push_str(grapheme); - width += grapheme_width; - } - - truncated.push_str(ellipsis); - truncated -} - -pub fn stabilize_terminal_emoji_width(text: &str) -> String { - let mut stabilized = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - - while let Some(ch) = chars.next() { - stabilized.push(ch); - - if needs_emoji_variation_selector(ch) - && !matches!(chars.peek(), Some('\u{fe0e}' | '\u{fe0f}')) - { - stabilized.push('\u{fe0f}'); - } - } - - stabilized -} - -pub fn needs_emoji_variation_selector(ch: char) -> bool { - matches!(ch, '\u{1f578}') -} - -// Helper function to create a centered rect -pub fn centered_rect( - percent_x: u16, - percent_y: u16, - r: ratatui::layout::Rect, -) -> ratatui::layout::Rect { - let popup_layout = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Percentage((100 - percent_y) / 2), - ratatui::layout::Constraint::Percentage(percent_y), - ratatui::layout::Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - ratatui::layout::Constraint::Percentage((100 - percent_x) / 2), - ratatui::layout::Constraint::Percentage(percent_x), - ratatui::layout::Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - -pub fn render_authenticating(frame: &mut Frame, state: &AppState) { - let block = Block::default() - .title(" Authenticating ") - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()); - let text = vec![ - Line::from("Waiting for Spotify sign-in or reauthorization..."), - Line::from( - "Please check your browser. The Web API redirect URI is http://127.0.0.1:8888/callback.", - ), - ]; - let paragraph = Paragraph::new(text) - .style(state.ui.active_theme.base_style()) - .block(block) - .alignment(Alignment::Center); - frame.render_widget(paragraph, frame.area()); -} - -pub fn render_setup(frame: &mut Frame, state: &AppState) { - let area = frame.area(); - let layout = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Percentage(30), - ratatui::layout::Constraint::Length(10), - ratatui::layout::Constraint::Percentage(30), - ]) - .split(area); - - let inner_layout = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - ratatui::layout::Constraint::Percentage(10), - ratatui::layout::Constraint::Percentage(80), - ratatui::layout::Constraint::Percentage(10), - ]) - .split(layout[1]); - - let setup_area = inner_layout[1]; - - let block = Block::default() - .title(" BYOK Setup (Bring Your Own Key) ") - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()) - .border_style(state.ui.active_theme.primary_style()); - - let id_style = if !state.ui.setup_focus_secret { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.base_style() - }; - - let secret_style = if state.ui.setup_focus_secret { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.base_style() - }; - - let text = vec![ - Line::from("Spotify Developer credentials not found in config.toml"), - Line::from("Please paste your Client ID and Client Secret."), - Line::from("Press [TAB] to switch fields, [ENTER] to save and authenticate."), - Line::from(""), - Line::from(vec![ - Span::styled("Client ID: ", id_style), - Span::raw(&state.ui.setup_client_id), - ]), - Line::from(vec![ - Span::styled("Client Secret: ", secret_style), - Span::raw(&state.ui.setup_client_secret), - ]), - ]; - - let paragraph = Paragraph::new(text).block(block).alignment(Alignment::Left); - let paragraph = paragraph.style(state.ui.active_theme.base_style()); - frame.render_widget(paragraph, setup_area); -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::{ - buffer::Buffer, - style::{Color, Style}, - }; - use unicode_width::UnicodeWidthStr; - - fn render_row_text(text: &str, style: Style) -> Buffer { - let area = Rect::new(0, 0, 24, 1); - let mut buffer = Buffer::empty(area); - buffer.set_style(area, style); - buffer.set_string(0, 0, text, style); - buffer - } - - #[test] - fn persistent_audio_error_has_priority_over_timed_status() { - let mut state = AppState::new(); - state.ui.status_message = Some("ordinary status".to_string()); - state.ui.audio_output_error = Some("audio disconnected".to_string()); - - let (text, style) = command_bar_content(&state); - - assert_eq!(text, "audio disconnected"); - assert_eq!(style, state.ui.active_theme.error_style()); - } - - #[test] - fn command_input_temporarily_covers_persistent_audio_error() { - let mut state = AppState::new(); - state.ui.audio_output_error = Some("audio disconnected".to_string()); - state.ui.mode = AppMode::Command; - state.ui.command_buffer = "redraw".to_string(); - - assert_eq!(command_bar_content(&state).0, ":redraw"); - assert_eq!( - state.ui.audio_output_error.as_deref(), - Some("audio disconnected") - ); - - state.ui.mode = AppMode::Normal; - assert_eq!(command_bar_content(&state).0, "audio disconnected"); - } - - #[test] - fn repairs_liked_songs_hidden_cell_style() { - let style = Style::default().fg(Color::White).bg(Color::Rgb(12, 34, 56)); - let mut buffer = render_row_text("♥️ Liked Songs", style); - - assert_eq!(buffer[(1, 0)].bg, Color::Reset); - - repair_wide_grapheme_trailing_styles(&mut buffer, Rect::new(0, 0, 24, 1)); - - assert_eq!(buffer[(1, 0)].symbol(), " "); - assert_eq!(buffer[(1, 0)].fg, Color::White); - assert_eq!(buffer[(1, 0)].bg, Color::Rgb(12, 34, 56)); - assert!(buffer[(1, 0)].skip); - } - - #[test] - fn repairs_pinned_playlist_hidden_cell_style() { - let style = Style::default().fg(Color::Cyan).bg(Color::Rgb(8, 9, 10)); - let mut buffer = render_row_text("📌 Playlist", style); - - assert_eq!(buffer[(1, 0)].bg, Color::Reset); - - repair_wide_grapheme_trailing_styles(&mut buffer, Rect::new(0, 0, 24, 1)); - - assert_eq!(buffer[(1, 0)].symbol(), " "); - assert_eq!(buffer[(1, 0)].fg, Color::Cyan); - assert_eq!(buffer[(1, 0)].bg, Color::Rgb(8, 9, 10)); - assert!(buffer[(1, 0)].skip); - } - - #[test] - fn leaves_ascii_playlist_names_unchanged() { - let style = Style::default().fg(Color::White).bg(Color::Rgb(1, 2, 3)); - let mut buffer = render_row_text("Plain Playlist", style); - let original = buffer.clone(); - - repair_wide_grapheme_trailing_styles(&mut buffer, Rect::new(0, 0, 24, 1)); - - assert_eq!(buffer, original); - } - - #[test] - fn preserves_selected_style_on_hidden_cells() { - let style = Style::default().fg(Color::Black).bg(Color::White); - let mut buffer = render_row_text("📌 Selected Playlist", style); - - assert_eq!(buffer[(1, 0)].bg, Color::Reset); - - repair_wide_grapheme_trailing_styles(&mut buffer, Rect::new(0, 0, 24, 1)); - - assert_eq!(buffer[(1, 0)].fg, Color::Black); - assert_eq!(buffer[(1, 0)].bg, Color::White); - assert!(buffer[(1, 0)].skip); - } - - #[test] - fn repair_does_not_cross_area_boundary() { - let selected = Style::default().fg(Color::Black).bg(Color::White); - let border = Style::default().fg(Color::Magenta).bg(Color::Rgb(1, 2, 3)); - let mut buffer = Buffer::empty(Rect::new(0, 0, 6, 1)); - buffer[(3, 0)].set_symbol("📌").set_style(selected); - buffer[(4, 0)].set_symbol("│").set_style(border); - - repair_wide_grapheme_trailing_styles(&mut buffer, Rect::new(1, 0, 3, 1)); - - assert_eq!(buffer[(4, 0)].symbol(), "│"); - assert_eq!(buffer[(4, 0)].fg, Color::Magenta); - assert_eq!(buffer[(4, 0)].bg, Color::Rgb(1, 2, 3)); - } - - #[test] - fn repair_skips_hidden_cells_in_diff() { - let old_style = Style::default().fg(Color::White).bg(Color::Black); - let new_style = Style::default().fg(Color::Black).bg(Color::White); - let mut previous = render_row_text("♥️ Liked Songs", old_style); - repair_wide_grapheme_trailing_styles(&mut previous, Rect::new(0, 0, 24, 1)); - - let mut next = render_row_text("♥️ Liked Songs", new_style); - repair_wide_grapheme_trailing_styles(&mut next, Rect::new(0, 0, 24, 1)); - - let diff = previous.diff(&next); - - assert!( - diff.iter() - .any(|(x, y, cell)| { *x == 0 && *y == 0 && cell.symbol() == "♥️" }) - ); - assert!(!diff.iter().any(|(x, y, _)| *x == 1 && *y == 0)); - } - - #[test] - fn stabilizes_cobweb_to_emoji_width() { - let stabilized = stabilize_terminal_emoji_width("🕸"); - - assert_eq!(stabilized, "🕸️"); - assert_eq!(stabilized.width(), 2); - } - - #[test] - fn does_not_duplicate_existing_cobweb_variation_selector() { - let stabilized = stabilize_terminal_emoji_width("🕸️"); - - assert_eq!(stabilized, "🕸️"); - assert_eq!(stabilized.width(), 2); - } - - #[test] - fn row_text_width_reserves_left_and_right_gutters() { - assert_eq!(row_text_width(Rect::new(0, 0, 12, 1)), 10); - assert_eq!(row_text_width(Rect::new(0, 0, 1, 1)), 0); - } - - #[test] - fn truncates_long_ascii_text_with_right_gap_budget() { - let truncated = truncate_to_width_with_ellipsis("abcdef", 5); - - assert_eq!(truncated, "ab..."); - assert_eq!(truncated.width(), 5); - } - - #[test] - fn keeps_text_that_fits_width_budget() { - let text = truncate_to_width_with_ellipsis("abc", 5); - - assert_eq!(text, "abc"); - } - - #[test] - fn truncates_wide_text_without_exceeding_width() { - let truncated = truncate_to_width_with_ellipsis("我的歌单 36", 8); - - assert!(truncated.ends_with("...")); - assert!(truncated.width() <= 8); - } - - #[test] - fn duration_text_keeps_one_trailing_cell() { - let duration = format_duration_text("2:46".to_string()); - - assert_eq!(duration, " 2:46 "); - assert_eq!(duration.width(), DURATION_COLUMN_WIDTH as usize); - } -} diff --git a/crates/spotify-tui/src/tui/search.rs b/crates/spotify-tui/src/tui/search.rs deleted file mode 100644 index 883bdc4..0000000 --- a/crates/spotify-tui/src/tui/search.rs +++ /dev/null @@ -1,298 +0,0 @@ -use crate::tui::render::{ - DURATION_COLUMN_WIDTH, format_duration_text, truncate_to_width_with_ellipsis, -}; -use crate::tui::theme::{ThemeStyles, ToRatatui}; -use echo_core::app::{ActiveView, AppState, SearchTab}; -use ratatui::{ - Frame, - layout::{Constraint, Rect}, - style::Modifier, - widgets::{Block, Borders, Cell, HighlightSpacing, Row, Table, TableState}, -}; - -pub fn render_search_results(frame: &mut Frame, state: &AppState, area: Rect) { - let is_focused = state.ui.active_view == ActiveView::SearchResults; - let border_style = if is_focused { - state.ui.active_theme.secondary_style() - } else { - state.ui.active_theme.primary_style() - }; - - let t_title = if state.ui.active_search_tab == SearchTab::Tracks { - format!( - "[{}]", - echo_core::i18n::t("ui.tracks", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.tracks", &state.ui.library_config.language) - ) - }; - let a_title = if state.ui.active_search_tab == SearchTab::Albums { - format!( - "[{}]", - echo_core::i18n::t("ui.albums", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.albums", &state.ui.library_config.language) - ) - }; - let ar_title = if state.ui.active_search_tab == SearchTab::Artists { - format!( - "[{}]", - echo_core::i18n::t("ui.artists", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.artists", &state.ui.library_config.language) - ) - }; - let p_title = if state.ui.active_search_tab == SearchTab::Playlists { - format!( - "[{}]", - echo_core::i18n::t("ui.playlists", &state.ui.library_config.language) - ) - } else { - format!( - " {} ", - echo_core::i18n::t("ui.playlists", &state.ui.library_config.language) - ) - }; - let tab_title = format!("{} {} {} {}", t_title, a_title, ar_title, p_title); - - let search_block = Block::default() - .borders(Borders::ALL) - .style(state.ui.active_theme.base_style()) - .border_style(border_style) - .title(format!( - " {}: {} — {} ", - echo_core::i18n::t("ui.search", &state.ui.library_config.language), - state.ui.search_context_query, - tab_title - )); - - let inner = search_block.inner(area); - frame.render_widget(search_block, area); - - let sel = state.ui.selected_search_index; - let header_style = border_style.add_modifier(Modifier::BOLD); - - match state.ui.active_search_tab { - SearchTab::All | SearchTab::Tracks => { - let header = Row::new(vec![ - "".to_string(), // liked col - echo_core::i18n::t("ui.tracks", &state.ui.library_config.language), - echo_core::i18n::t("ui.artist", &state.ui.library_config.language), - echo_core::i18n::t("ui.album", &state.ui.library_config.language), - echo_core::i18n::t("ui.source", &state.ui.library_config.language), - echo_core::i18n::t("ui.duration", &state.ui.library_config.language), - ]) - .style(header_style) - .height(1); - let visual_range = if state.ui.active_view == ActiveView::SearchResults { - state.get_visual_selection_range() - } else { - None - }; - - let rows: Vec = state - .data - .search_results - .tracks - .iter() - .enumerate() - .map(|(i, t)| { - let is_in_visual = if let Some((start, end)) = visual_range { - i >= start && i <= end - } else { - false - }; - - let style = if is_in_visual { - state.ui.active_theme.selected_style().bg(state - .ui - .active_theme - .primary - .rat()) - } else if i == sel { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let dur = format!( - "{}:{:02}", - t.duration_ms / 1000 / 60, - t.duration_ms / 1000 % 60 - ); - let w_track = (inner.width * 32 / 100).saturating_sub(1); - let w_artist = (inner.width * 23 / 100).saturating_sub(1); - let w_album = (inner.width * 26 / 100).saturating_sub(1); - let source = match t.source { - echo_core::models::TrackSource::Local => { - echo_core::i18n::t("ui.local", &state.ui.library_config.language) - } - echo_core::models::TrackSource::Spotify => { - echo_core::i18n::t("ui.spotify", &state.ui.library_config.language) - } - }; - - let liked_str = if state.data.liked_tracks.contains(&t.id) { - "♥" - } else { - " " - }; - let liked_cell = - Cell::from(liked_str).style(state.ui.active_theme.secondary_style()); - - Row::new(vec![ - liked_cell, - Cell::from(truncate_to_width_with_ellipsis(&t.name, w_track)), - Cell::from(truncate_to_width_with_ellipsis(&t.artist, w_artist)), - Cell::from(truncate_to_width_with_ellipsis(&t.album, w_album)), - Cell::from(source), - Cell::from(format_duration_text(dur)), - ]) - .style(style) - }) - .collect(); - let table = Table::new( - rows, - [ - Constraint::Length(2), - Constraint::Percentage(32), - Constraint::Percentage(23), - Constraint::Percentage(26), - Constraint::Length(8), - Constraint::Length(DURATION_COLUMN_WIDTH), - ], - ) - .column_spacing(1) - .header(header) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - let mut ts = TableState::default(); - ts.select(Some(sel)); - frame.render_stateful_widget(table, inner, &mut ts); - } - SearchTab::Albums => { - let header = Row::new(vec!["Album", "Artist"]) - .style(header_style) - .height(1); - let rows: Vec = state - .data - .search_results - .albums - .iter() - .enumerate() - .map(|(i, a)| { - let style = if i == sel { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let w_album = (inner.width * 50 / 100).saturating_sub(1); - let w_artist = (inner.width * 50 / 100).saturating_sub(1); - Row::new(vec![ - Cell::from(truncate_to_width_with_ellipsis(&a.name, w_album)), - Cell::from(truncate_to_width_with_ellipsis(&a.artist, w_artist)), - ]) - .style(style) - }) - .collect(); - let table = Table::new( - rows, - [Constraint::Percentage(50), Constraint::Percentage(50)], - ) - .column_spacing(1) - .header(header) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - let mut ts = TableState::default(); - ts.select(Some(sel)); - frame.render_stateful_widget(table, inner, &mut ts); - } - SearchTab::Artists => { - let header = Row::new(vec![echo_core::i18n::t( - "ui.artist", - &state.ui.library_config.language, - )]) - .style(header_style) - .height(1); - let rows: Vec = state - .data - .search_results - .artists - .iter() - .enumerate() - .map(|(i, artist)| { - let style = if i == sel { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - Row::new(vec![Cell::from(truncate_to_width_with_ellipsis( - &artist.name, - inner.width.saturating_sub(1), - ))]) - .style(style) - }) - .collect(); - let table = Table::new(rows, [Constraint::Percentage(100)]) - .column_spacing(1) - .header(header) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - let mut ts = TableState::default(); - ts.select(Some(sel)); - frame.render_stateful_widget(table, inner, &mut ts); - } - SearchTab::Playlists => { - let header = Row::new(vec![ - echo_core::i18n::t("ui.playlists", &state.ui.library_config.language), - "Owner".to_string(), - ]) - .style(header_style) - .height(1); - let rows: Vec = state - .data - .search_results - .playlists - .iter() - .enumerate() - .map(|(i, playlist)| { - let style = if i == sel { - state.ui.active_theme.selected_style() - } else { - state.ui.active_theme.base_style() - }; - let w_name = (inner.width * 50 / 100).saturating_sub(1); - let w_owner = (inner.width * 50 / 100).saturating_sub(1); - Row::new(vec![ - Cell::from(truncate_to_width_with_ellipsis(&playlist.name, w_name)), - Cell::from(truncate_to_width_with_ellipsis(&playlist.owner, w_owner)), - ]) - .style(style) - }) - .collect(); - let table = Table::new( - rows, - [Constraint::Percentage(50), Constraint::Percentage(50)], - ) - .column_spacing(1) - .header(header) - .row_highlight_style(state.ui.active_theme.selected_style()) - .highlight_symbol(" ") - .highlight_spacing(HighlightSpacing::Always); - let mut ts = TableState::default(); - ts.select(Some(sel)); - frame.render_stateful_widget(table, inner, &mut ts); - } - } -} diff --git a/crates/spotify-tui/src/tui/theme.rs b/crates/spotify-tui/src/tui/theme.rs deleted file mode 100644 index 4f1eb97..0000000 --- a/crates/spotify-tui/src/tui/theme.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Ratatui bindings for the neutral theme types. -//! -//! [`echo_core::theme::ThemeColor`] carries no rendering dependency; these extension traits give the -//! ratatui views back the `Color`/`Style` vocabulary they render with. Extension traits rather -//! than `From` impls so this keeps compiling when theme types and the TUI live in separate -//! crates (orphan rule). - -use echo_core::theme::{NamedColor, ResolvedTheme, ThemeColor}; -use ratatui::style::{Color, Style}; - -/// Converts a neutral theme color to the ratatui equivalent. -pub trait ToRatatui { - fn rat(self) -> Color; -} - -impl ToRatatui for ThemeColor { - fn rat(self) -> Color { - match self { - ThemeColor::Reset => Color::Reset, - ThemeColor::Indexed(i) => Color::Indexed(i), - ThemeColor::Rgb(r, g, b) => Color::Rgb(r, g, b), - ThemeColor::Named(named) => match named { - NamedColor::Black => Color::Black, - NamedColor::Red => Color::Red, - NamedColor::Green => Color::Green, - NamedColor::Yellow => Color::Yellow, - NamedColor::Blue => Color::Blue, - NamedColor::Magenta => Color::Magenta, - NamedColor::Cyan => Color::Cyan, - NamedColor::Gray => Color::Gray, - NamedColor::DarkGray => Color::DarkGray, - NamedColor::LightRed => Color::LightRed, - NamedColor::LightGreen => Color::LightGreen, - NamedColor::LightYellow => Color::LightYellow, - NamedColor::LightBlue => Color::LightBlue, - NamedColor::LightMagenta => Color::LightMagenta, - NamedColor::LightCyan => Color::LightCyan, - NamedColor::White => Color::White, - }, - } - } -} - -/// The style vocabulary the views render with, derived from a resolved theme. -pub trait ThemeStyles { - fn base_style(&self) -> Style; - fn muted_style(&self) -> Style; - fn primary_style(&self) -> Style; - fn secondary_style(&self) -> Style; - fn error_style(&self) -> Style; - fn selected_style(&self) -> Style; - fn gauge_style(&self) -> Style; -} - -impl ThemeStyles for ResolvedTheme { - fn base_style(&self) -> Style { - Style::default() - .fg(self.text.rat()) - .bg(self.background.rat()) - } - - fn muted_style(&self) -> Style { - self.base_style().fg(self.text_muted.rat()) - } - - fn primary_style(&self) -> Style { - self.base_style().fg(self.primary.rat()) - } - - fn secondary_style(&self) -> Style { - self.base_style().fg(self.secondary.rat()) - } - - fn error_style(&self) -> Style { - self.base_style().fg(self.error.rat()) - } - - fn selected_style(&self) -> Style { - Style::default() - .fg(self.highlight_fg.rat()) - .bg(self.highlight_bg.rat()) - } - - fn gauge_style(&self) -> Style { - Style::default() - .fg(self.text.rat()) - .bg(self.text_muted.rat()) - } -} diff --git a/locales/en.json b/locales/en.json index 08c4e1d..0aabd9c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -12,16 +12,11 @@ "queue_next_up": "Next up", "playlists": "Playlists", "albums": "Albums", - "browse": "Browse", - "tracks": "Tracks", "artist": "Artist", "artists": "Artists", "album": "Album", - "source": "Source", "local": "Local", - "spotify": "Spotify", "duration": "Duration", - "playing": " Playing (Device: {} | Shuffle: {} | Repeat: {} | Volume: {}%) ", "on": "On", "off": "Off" }, @@ -32,7 +27,6 @@ "messages": { "playlist_updated": "Playlist updated", "followed_playlist": "Saved playlist to Your Library", - "visual_block": "VISUAL BLOCK", "fetching": "Fetching...", "fetching_album": "Fetching album...", "fetching_playlist": "Fetching playlist...", @@ -60,8 +54,7 @@ "create_playlist": "New Playlist Name: ", "rename_playlist": "Rename Playlist: ", "theme": "Theme: ", - "lang": "Lang: ", - "remove_from_liked": "Remove from Liked Songs? (y/n)" + "lang": "Lang: " }, "actions": { "go_to_album": "Go to Album", @@ -70,8 +63,7 @@ "add_to_queue": "Add to Queue", "clear_queue": "Clear queue", "unlike_track": "Unlike Track", - "like_track": "Like Track", - "title": " Actions: {} " + "like_track": "Like Track" }, "desktop": { "edit_details": "Edit details", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 8e09f7e..6d943bc 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -12,16 +12,11 @@ "queue_next_up": "接下来播放", "playlists": "播放列表", "albums": "专辑", - "browse": "浏览", - "tracks": "歌曲", "artist": "歌手", "artists": "歌手", "album": "专辑", - "source": "来源", "local": "本地", - "spotify": "Spotify", "duration": "时长", - "playing": " 正在播放 (设备: {} | 随机: {} | 循环: {} | 音量: {}%) ", "on": "开", "off": "关" }, @@ -32,7 +27,6 @@ "messages": { "playlist_updated": "歌单已更新", "followed_playlist": "已将歌单收藏到音乐库", - "visual_block": "可视块选择模式", "fetching": "加载中...", "fetching_album": "正在加载专辑...", "fetching_playlist": "正在加载播放列表...", @@ -60,8 +54,7 @@ "create_playlist": "新播放列表名称: ", "rename_playlist": "重命名播放列表: ", "theme": "主题: ", - "lang": "语言: ", - "remove_from_liked": "是否从喜欢的歌曲中移除? (y/n)" + "lang": "语言: " }, "actions": { "go_to_album": "转到专辑", @@ -70,8 +63,7 @@ "add_to_queue": "添加到播放队列", "clear_queue": "清空队列", "unlike_track": "取消喜欢", - "like_track": "喜欢这首歌", - "title": " 操作: {} " + "like_track": "喜欢这首歌" }, "desktop": { "edit_details": "编辑详情", diff --git a/release.md b/release.md index eeed27a..f440885 100644 --- a/release.md +++ b/release.md @@ -1,8 +1,13 @@ # Changelog ### Liked Songs - - Liked Songs shows the whole library instead of the first 100 songs, in both apps, straight from `~/.config/echo/liked_songs.json` + - Liked Songs shows the whole library instead of the first 100 songs, straight from `~/.config/echo/liked_songs.json` - Keeping it current usually costs one request: new likes show up at the top, and a full re-read happens only on first use, when songs were removed on another device, or once a week - A full re-read goes one page a second, stops on a rate limit and waits out Spotify's retry time, and picks up where it left off after a restart - A failed or rate-limited sync no longer leaves hearts missing, and hearts on local files are no longer cleared by it - Unliking a song in echo removes it from the open list right away + +### Terminal client removed + - The `spotify` terminal client is gone; echo is now the desktop app only + - Updating removes the old `spotify` command and its `~/.local/bin` link + - Traditional Chinese has been dropped; English and Simplified Chinese remain diff --git a/themes/cloud.toml b/themes/cloud.toml index 468d883..12fdb90 100644 --- a/themes/cloud.toml +++ b/themes/cloud.toml @@ -15,7 +15,7 @@ text_muted = "#849e8f" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#517b68" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#e7f3eb" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#e7f3eb" # Unused by the desktop app. error = "#ca4032" # Errors: failure messages and the audio-device banner. diff --git a/themes/crimson.toml b/themes/crimson.toml index 34d3f8c..6e4850f 100644 --- a/themes/crimson.toml +++ b/themes/crimson.toml @@ -15,7 +15,7 @@ text_muted = "#7c453d" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#cc494b" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#221412" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#221412" # Unused by the desktop app. error = "#f1cd9a" # Errors: failure messages and the audio-device banner. diff --git a/themes/cyber_citrus.toml b/themes/cyber_citrus.toml index cd79284..d80e862 100644 --- a/themes/cyber_citrus.toml +++ b/themes/cyber_citrus.toml @@ -15,7 +15,7 @@ text_muted = "#a4a28a" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#d0ff4f" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#242436" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#242436" # Unused by the desktop app. error = "#f75a2f" # Errors: failure messages and the audio-device banner. diff --git a/themes/echo.toml b/themes/echo.toml index ecd0c77..6cdf3d9 100644 --- a/themes/echo.toml +++ b/themes/echo.toml @@ -15,7 +15,7 @@ text_muted = "#505e68" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#39d1ff" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#1b1a1f" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#1b1a1f" # Unused by the desktop app. error = "Red" # Errors: failure messages and the audio-device banner. diff --git a/themes/everforest.toml b/themes/everforest.toml index 4992830..3dd38f7 100644 --- a/themes/everforest.toml +++ b/themes/everforest.toml @@ -15,7 +15,7 @@ text_muted = "#777e60" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#c4e491" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#3d4050" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#3d4050" # Unused by the desktop app. error = "#f5aa7e" # Errors: failure messages and the audio-device banner. diff --git a/themes/generate_desktop.py b/themes/generate_desktop.py index 51fb220..b24169c 100644 --- a/themes/generate_desktop.py +++ b/themes/generate_desktop.py @@ -94,7 +94,7 @@ def hexstr(rgb): ("text", NAMED["white"], WINDOW_FG, "Main text."), ("text_muted", NAMED["darkgray"], WINDOW_FG, "Secondary text: artist/album columns, hints, timestamps, inactive icons."), ("highlight_bg", NAMED["white"], WINDOW_FG, "Selection source color; selected rows derive their fill from this."), - ("highlight_fg", NAMED["black"], WINDOW_FG, "Text on strong highlights (the TUI's selected rows)."), + ("highlight_fg", NAMED["black"], WINDOW_FG, "Unused by the desktop app."), ("error", NAMED["red"], WINDOW_FG, "Errors: failure messages and the audio-device banner."), ] diff --git a/themes/gruvbox.toml b/themes/gruvbox.toml index a6f5b74..8833083 100644 --- a/themes/gruvbox.toml +++ b/themes/gruvbox.toml @@ -15,7 +15,7 @@ text_muted = "#7c7666" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#81c3c5" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#3a3a3f" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#3a3a3f" # Unused by the desktop app. error = "#ee6e5d" # Errors: failure messages and the audio-device banner. diff --git a/themes/neon.toml b/themes/neon.toml index b61408f..9eb07bf 100644 --- a/themes/neon.toml +++ b/themes/neon.toml @@ -15,7 +15,7 @@ text_muted = "#58584a" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#fdae66" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#25251a" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#25251a" # Unused by the desktop app. error = "#f64b67" # Errors: failure messages and the audio-device banner. diff --git a/themes/noir.toml b/themes/noir.toml index 9de176d..d7ace62 100644 --- a/themes/noir.toml +++ b/themes/noir.toml @@ -15,7 +15,7 @@ text_muted = "#5a6268" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#c0d1d6" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#1e1e20" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#1e1e20" # Unused by the desktop app. error = "#a94039" # Errors: failure messages and the audio-device banner. diff --git a/themes/pink.toml b/themes/pink.toml index b5aea9d..8626ec4 100644 --- a/themes/pink.toml +++ b/themes/pink.toml @@ -15,7 +15,7 @@ text_muted = "#c393ab" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#e34987" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#eee7dd" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#eee7dd" # Unused by the desktop app. error = "#df3358" # Errors: failure messages and the audio-device banner. diff --git a/themes/sea_foam.toml b/themes/sea_foam.toml index 7443bf2..06df717 100644 --- a/themes/sea_foam.toml +++ b/themes/sea_foam.toml @@ -15,7 +15,7 @@ text_muted = "#52707f" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#ffc19d" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#555b6e" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#555b6e" # Unused by the desktop app. error = "#d54d4d" # Errors: failure messages and the audio-device banner. diff --git a/themes/tokyo_night.toml b/themes/tokyo_night.toml index 6264f39..a5d1afd 100644 --- a/themes/tokyo_night.toml +++ b/themes/tokyo_night.toml @@ -15,7 +15,7 @@ text_muted = "#596180" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#609bc2" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#2e3450" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#2e3450" # Unused by the desktop app. error = "#e7403a" # Errors: failure messages and the audio-device banner. diff --git a/themes/vanilla.toml b/themes/vanilla.toml index 9270058..fc3f1da 100644 --- a/themes/vanilla.toml +++ b/themes/vanilla.toml @@ -15,7 +15,7 @@ text_muted = "#88795e" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#a48b4f" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#3a3227" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#3a3227" # Unused by the desktop app. error = "#a8423e" # Errors: failure messages and the audio-device banner. diff --git a/themes/wave.toml b/themes/wave.toml index eecdc59..d0edfd2 100644 --- a/themes/wave.toml +++ b/themes/wave.toml @@ -15,7 +15,7 @@ text_muted = "#9db0d8" # Secondary text: artist/album columns, hints, timestamps highlight_bg = "#cad8e6" # Selection source color; selected rows derive their fill from this. -highlight_fg = "#3866a1" # Text on strong highlights (the TUI's selected rows). +highlight_fg = "#3866a1" # Unused by the desktop app. error = "#ff9fba" # Errors: failure messages and the audio-device banner. diff --git a/tools/theme-preview/index.html b/tools/theme-preview/index.html index 62b23f1..d0d6726 100644 --- a/tools/theme-preview/index.html +++ b/tools/theme-preview/index.html @@ -414,7 +414,7 @@

Liked Songs

142 tracks let lastValues = {}; // highlight_bg/fg aren't painted directly by the desktop — annotate them in the legend. - const NOTES = { highlight_bg: "selection source", highlight_fg: "TUI only" }; + const NOTES = { highlight_bg: "selection source", highlight_fg: "unused by desktop app" }; const cssVar = (key) => "--" + key.replaceAll("_", "-");