Krolyxon
Commit 3de1a21

rmptui3de1a21

Start v0.2 rewrite: async MPD client and new config system

Remove the v0.1 sources and lay the foundation for the rewrite:

- mpd: pure protocol encoding/parsing, TCP and Unix socket connections,
  typed models, and background idle/command workers so the UI never
  awaits the server.
- config: documented default.toml as the single source of truth, merged
  table-by-table with the user file; typed actions and per-context
  keymaps with conflict validation; theme colors; MPD_HOST/MPD_PORT.
Author
krolyxon
Date
2026-10-17 16:40 +0530
Parent
c847cda
Commit
3de1a21abf90c18696f85290a2ba278e0b9818ab

+4787 −3508

29 files changed

  1. M Cargo.lock +1679 −48
  2. M Cargo.toml +25 −15
  3. A src/action.rs +317 −0
  4. D src/app.rs +0 −501
  5. D src/browser.rs +0 −164
  6. D src/config.rs +0 −669
  7. A src/config/default.toml +199 −0
  8. A src/config/keys.rs +305 −0
  9. A src/config/mod.rs +515 −0
  10. A src/config/theme.rs +91 −0
  11. D src/connection.rs +0 −741
  12. D src/event_handler/handler.rs +0 −473
  13. D src/event_handler/mod.rs +0 −1
  14. M src/lib.rs +3 −14
  15. D src/list.rs +0 −30
  16. D src/logging.rs +0 −86
  17. M src/main.rs +1 −188
  18. A src/mpd/commands.rs +162 −0
  19. A src/mpd/connection.rs +156 −0
  20. A src/mpd/error.rs +42 −0
  21. A src/mpd/mod.rs +14 −0
  22. A src/mpd/protocol.rs +227 −0
  23. A src/mpd/types.rs +425 −0
  24. A src/mpd/worker.rs +403 −0
  25. D src/tui.rs +0 −62
  26. D src/ui.rs +0 −489
  27. A src/util.rs +59 −0
  28. D src/utils.rs +0 −27
  29. A tests/mpd_client.rs +164 −0
M Cargo.lock View
@@ -2,12 +2,83 @@
22 # It is not intended for manual editing.
33 version = 4
44
5+[[package]]
6+name = "adler2"
7+version = "2.0.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10+
11+[[package]]
12+name = "aho-corasick"
13+version = "1.1.5"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
16+dependencies = [
17+ "memchr",
18+]
19+
520 [[package]]
621 name = "allocator-api2"
722 version = "0.2.21"
823 source = "registry+https://github.com/rust-lang/crates.io-index"
924 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
1025
26+[[package]]
27+name = "anstream"
28+version = "1.0.0"
29+source = "registry+https://github.com/rust-lang/crates.io-index"
30+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
31+dependencies = [
32+ "anstyle",
33+ "anstyle-parse",
34+ "anstyle-query",
35+ "anstyle-wincon",
36+ "colorchoice",
37+ "is_terminal_polyfill",
38+ "utf8parse",
39+]
40+
41+[[package]]
42+name = "anstyle"
43+version = "1.0.14"
44+source = "registry+https://github.com/rust-lang/crates.io-index"
45+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
46+
47+[[package]]
48+name = "anstyle-parse"
49+version = "1.0.0"
50+source = "registry+https://github.com/rust-lang/crates.io-index"
51+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
52+dependencies = [
53+ "utf8parse",
54+]
55+
56+[[package]]
57+name = "anstyle-query"
58+version = "1.1.5"
59+source = "registry+https://github.com/rust-lang/crates.io-index"
60+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
61+dependencies = [
62+ "windows-sys 0.61.2",
63+]
64+
65+[[package]]
66+name = "anstyle-wincon"
67+version = "3.0.11"
68+source = "registry+https://github.com/rust-lang/crates.io-index"
69+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
70+dependencies = [
71+ "anstyle",
72+ "once_cell_polyfill",
73+ "windows-sys 0.61.2",
74+]
75+
76+[[package]]
77+name = "anyhow"
78+version = "1.0.104"
79+source = "registry+https://github.com/rust-lang/crates.io-index"
80+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
81+
1182 [[package]]
1283 name = "approx"
1384 version = "0.5.1"
@@ -17,24 +88,71 @@ dependencies = [
1788 "num-traits",
1889 ]
1990
91+[[package]]
92+name = "atomic-waker"
93+version = "1.1.2"
94+source = "registry+https://github.com/rust-lang/crates.io-index"
95+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
96+
2097 [[package]]
2198 name = "autocfg"
2299 version = "1.5.1"
23100 source = "registry+https://github.com/rust-lang/crates.io-index"
24101 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
25102
103+[[package]]
104+name = "aws-lc-rs"
105+version = "1.18.2"
106+source = "registry+https://github.com/rust-lang/crates.io-index"
107+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
108+dependencies = [
109+ "aws-lc-sys",
110+ "zeroize",
111+]
112+
113+[[package]]
114+name = "aws-lc-sys"
115+version = "0.46.0"
116+source = "registry+https://github.com/rust-lang/crates.io-index"
117+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
118+dependencies = [
119+ "cc",
120+ "cmake",
121+ "dunce",
122+ "fs_extra",
123+ "pkg-config",
124+]
125+
126+[[package]]
127+name = "base64"
128+version = "0.23.1"
129+source = "registry+https://github.com/rust-lang/crates.io-index"
130+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
131+
26132 [[package]]
27133 name = "bitflags"
28134 version = "2.13.2"
29135 source = "registry+https://github.com/rust-lang/crates.io-index"
30136 checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
31137
138+[[package]]
139+name = "bumpalo"
140+version = "3.20.3"
141+source = "registry+https://github.com/rust-lang/crates.io-index"
142+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
143+
32144 [[package]]
33145 name = "by_address"
34146 version = "1.2.1"
35147 source = "registry+https://github.com/rust-lang/crates.io-index"
36148 checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
37149
150+[[package]]
151+name = "byteorder"
152+version = "1.5.0"
153+source = "registry+https://github.com/rust-lang/crates.io-index"
154+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
155+
38156 [[package]]
39157 name = "bytes"
40158 version = "1.12.1"
@@ -50,12 +168,106 @@ dependencies = [
50168 "rustversion",
51169 ]
52170
171+[[package]]
172+name = "cc"
173+version = "1.8.0"
174+source = "registry+https://github.com/rust-lang/crates.io-index"
175+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
176+dependencies = [
177+ "find-msvc-tools",
178+ "jobserver",
179+ "libc",
180+ "shlex",
181+]
182+
53183 [[package]]
54184 name = "cfg-if"
55185 version = "1.0.5"
56186 source = "registry+https://github.com/rust-lang/crates.io-index"
57187 checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
58188
189+[[package]]
190+name = "cfg_aliases"
191+version = "0.2.2"
192+source = "registry+https://github.com/rust-lang/crates.io-index"
193+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
194+
195+[[package]]
196+name = "chacha20"
197+version = "0.10.2"
198+source = "registry+https://github.com/rust-lang/crates.io-index"
199+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
200+dependencies = [
201+ "cfg-if",
202+ "cpufeatures",
203+ "rand_core",
204+]
205+
206+[[package]]
207+name = "clap"
208+version = "4.6.7"
209+source = "registry+https://github.com/rust-lang/crates.io-index"
210+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
211+dependencies = [
212+ "clap_builder",
213+ "clap_derive",
214+]
215+
216+[[package]]
217+name = "clap_builder"
218+version = "4.6.7"
219+source = "registry+https://github.com/rust-lang/crates.io-index"
220+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
221+dependencies = [
222+ "anstream",
223+ "anstyle",
224+ "clap_lex",
225+ "strsim",
226+]
227+
228+[[package]]
229+name = "clap_derive"
230+version = "4.6.7"
231+source = "registry+https://github.com/rust-lang/crates.io-index"
232+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
233+dependencies = [
234+ "heck",
235+ "proc-macro2",
236+ "quote",
237+ "syn 3.0.9",
238+]
239+
240+[[package]]
241+name = "clap_lex"
242+version = "1.1.1"
243+source = "registry+https://github.com/rust-lang/crates.io-index"
244+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
245+
246+[[package]]
247+name = "cmake"
248+version = "0.1.58"
249+source = "registry+https://github.com/rust-lang/crates.io-index"
250+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
251+dependencies = [
252+ "cc",
253+]
254+
255+[[package]]
256+name = "colorchoice"
257+version = "1.0.5"
258+source = "registry+https://github.com/rust-lang/crates.io-index"
259+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
260+
261+[[package]]
262+name = "combine"
263+version = "4.6.9"
264+source = "registry+https://github.com/rust-lang/crates.io-index"
265+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
266+dependencies = [
267+ "bytes",
268+ "memchr",
269+]
270+
59271 [[package]]
60272 name = "compact_str"
61273 version = "0.9.1"
@@ -79,6 +291,40 @@ dependencies = [
79291 "unicode-segmentation",
80292 ]
81293
294+[[package]]
295+name = "core-foundation"
296+version = "0.10.1"
297+source = "registry+https://github.com/rust-lang/crates.io-index"
298+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
299+dependencies = [
300+ "core-foundation-sys",
301+ "libc",
302+]
303+
304+[[package]]
305+name = "core-foundation-sys"
306+version = "0.8.7"
307+source = "registry+https://github.com/rust-lang/crates.io-index"
308+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
309+
310+[[package]]
311+name = "cpufeatures"
312+version = "0.3.1"
313+source = "registry+https://github.com/rust-lang/crates.io-index"
314+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
315+dependencies = [
316+ "libc",
317+]
318+
319+[[package]]
320+name = "crc32fast"
321+version = "1.5.2"
322+source = "registry+https://github.com/rust-lang/crates.io-index"
323+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
324+dependencies = [
325+ "cfg-if",
326+]
327+
82328 [[package]]
83329 name = "crossterm"
84330 version = "0.29.0"
@@ -141,6 +387,12 @@ dependencies = [
141387 "syn 3.0.9",
142388 ]
143389
390+[[package]]
391+name = "data-encoding"
392+version = "2.11.1"
393+source = "registry+https://github.com/rust-lang/crates.io-index"
394+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
395+
144396 [[package]]
145397 name = "deranged"
146398 version = "0.5.9"
@@ -169,6 +421,17 @@ dependencies = [
169421 "syn 2.0.119",
170422 ]
171423
424+[[package]]
425+name = "displaydoc"
426+version = "0.2.7"
427+source = "registry+https://github.com/rust-lang/crates.io-index"
428+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
429+dependencies = [
430+ "proc-macro2",
431+ "quote",
432+ "syn 3.0.9",
433+]
434+
172435 [[package]]
173436 name = "document-features"
174437 version = "0.2.12"
@@ -178,6 +441,12 @@ dependencies = [
178441 "litrs",
179442 ]
180443
444+[[package]]
445+name = "dunce"
446+version = "1.0.5"
447+source = "registry+https://github.com/rust-lang/crates.io-index"
448+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
449+
181450 [[package]]
182451 name = "either"
183452 version = "1.19.0"
@@ -197,7 +466,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
197466 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
198467 dependencies = [
199468 "libc",
200− "windows-sys",
469+ "windows-sys 0.61.2",
470+]
471+
472+[[package]]
473+name = "find-msvc-tools"
474+version = "0.1.14"
475+source = "registry+https://github.com/rust-lang/crates.io-index"
476+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
477+
478+[[package]]
479+name = "flate2"
480+version = "1.1.10"
481+source = "registry+https://github.com/rust-lang/crates.io-index"
482+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
483+dependencies = [
484+ "crc32fast",
485+ "miniz_oxide",
486+ "zlib-rs",
201487 ]
202488
203489 [[package]]
@@ -206,6 +492,30 @@ version = "0.2.0"
206492 source = "registry+https://github.com/rust-lang/crates.io-index"
207493 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
208494
495+[[package]]
496+name = "form_urlencoded"
497+version = "1.2.2"
498+source = "registry+https://github.com/rust-lang/crates.io-index"
499+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
500+dependencies = [
501+ "percent-encoding",
502+]
503+
504+[[package]]
505+name = "fs_extra"
506+version = "1.3.0"
507+source = "registry+https://github.com/rust-lang/crates.io-index"
508+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
509+
510+[[package]]
511+name = "futures-channel"
512+version = "0.3.34"
513+source = "registry+https://github.com/rust-lang/crates.io-index"
514+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
515+dependencies = [
516+ "futures-core",
517+]
518+
209519 [[package]]
210520 name = "futures-core"
211521 version = "0.3.34"
@@ -242,6 +552,33 @@ dependencies = [
242552 "slab",
243553 ]
244554
555+[[package]]
556+name = "getrandom"
557+version = "0.2.17"
558+source = "registry+https://github.com/rust-lang/crates.io-index"
559+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
560+dependencies = [
561+ "cfg-if",
562+ "js-sys",
563+ "libc",
564+ "wasi",
565+ "wasm-bindgen",
566+]
567+
568+[[package]]
569+name = "getrandom"
570+version = "0.4.3"
571+source = "registry+https://github.com/rust-lang/crates.io-index"
572+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
573+dependencies = [
574+ "cfg-if",
575+ "js-sys",
576+ "libc",
577+ "r-efi",
578+ "rand_core",
579+ "wasm-bindgen",
580+]
581+
245582 [[package]]
246583 name = "hashbrown"
247584 version = "0.16.1"
@@ -271,74 +608,361 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
271608 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
272609
273610 [[package]]
274−name = "ident_case"
275−version = "1.0.1"
611+name = "http"
612+version = "1.5.0"
276613 source = "registry+https://github.com/rust-lang/crates.io-index"
277−checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
614+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
615+dependencies = [
616+ "bytes",
617+ "itoa",
618+]
278619
279620 [[package]]
280−name = "indexmap"
281−version = "2.14.2"
621+name = "http-body"
622+version = "1.1.0"
282623 source = "registry+https://github.com/rust-lang/crates.io-index"
283−checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
624+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
284625 dependencies = [
285− "equivalent",
286− "hashbrown 0.17.1",
626+ "bytes",
627+ "http",
287628 ]
288629
289630 [[package]]
290−name = "indoc"
291−version = "2.0.8"
631+name = "http-body-util"
632+version = "0.1.5"
292633 source = "registry+https://github.com/rust-lang/crates.io-index"
293−checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
634+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
294635 dependencies = [
295− "rustversion",
636+ "bytes",
637+ "futures-core",
638+ "http",
639+ "http-body",
640+ "pin-project-lite",
296641 ]
297642
298643 [[package]]
299−name = "instability"
300−version = "0.3.14"
644+name = "httparse"
645+version = "1.10.1"
301646 source = "registry+https://github.com/rust-lang/crates.io-index"
302−checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
647+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
648+
649+[[package]]
650+name = "hyper"
651+version = "1.12.0"
652+source = "registry+https://github.com/rust-lang/crates.io-index"
653+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
303654 dependencies = [
304− "darling",
305− "indoc",
306− "proc-macro2",
307− "quote",
308− "syn 3.0.9",
655+ "atomic-waker",
656+ "bytes",
657+ "futures-core",
658+ "http",
659+ "http-body",
660+ "httparse",
661+ "itoa",
662+ "pin-project-lite",
663+ "smallvec",
664+ "tokio",
665+ "want",
309666 ]
310667
311668 [[package]]
312−name = "itertools"
313−version = "0.14.0"
669+name = "hyper-rustls"
670+version = "0.27.10"
314671 source = "registry+https://github.com/rust-lang/crates.io-index"
315−checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
672+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
316673 dependencies = [
317− "either",
674+ "http",
675+ "hyper",
676+ "hyper-util",
677+ "rustls",
678+ "tokio",
679+ "tokio-rustls",
680+ "tower-service",
318681 ]
319682
320683 [[package]]
321−name = "itoa"
322−version = "1.0.18"
684+name = "hyper-util"
685+version = "0.1.21"
323686 source = "registry+https://github.com/rust-lang/crates.io-index"
324−checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
687+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
688+dependencies = [
689+ "base64",
690+ "bytes",
691+ "futures-channel",
692+ "futures-util",
693+ "http",
694+ "http-body",
695+ "httparse",
696+ "hyper",
697+ "ipnet",
698+ "libc",
699+ "percent-encoding",
700+ "pin-project-lite",
701+ "socket2",
702+ "tokio",
703+ "tower-service",
704+ "tracing",
705+]
325706
326707 [[package]]
327−name = "kasuari"
328−version = "0.4.12"
708+name = "icu_collections"
709+version = "2.1.1"
329710 source = "registry+https://github.com/rust-lang/crates.io-index"
330−checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
711+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
331712 dependencies = [
332− "hashbrown 0.16.1",
333− "portable-atomic",
334− "thiserror",
713+ "displaydoc",
714+ "potential_utf",
715+ "yoke",
716+ "zerofrom",
717+ "zerovec",
335718 ]
336719
337720 [[package]]
338−name = "libc"
339−version = "0.2.190"
721+name = "icu_locale_core"
722+version = "2.1.1"
340723 source = "registry+https://github.com/rust-lang/crates.io-index"
341−checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
724+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
725+dependencies = [
726+ "displaydoc",
727+ "litemap",
728+ "tinystr",
729+ "writeable",
730+ "zerovec",
731+]
732+
733+[[package]]
734+name = "icu_normalizer"
735+version = "2.1.1"
736+source = "registry+https://github.com/rust-lang/crates.io-index"
737+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
738+dependencies = [
739+ "icu_collections",
740+ "icu_normalizer_data",
741+ "icu_properties",
742+ "icu_provider",
743+ "smallvec",
744+ "zerovec",
745+]
746+
747+[[package]]
748+name = "icu_normalizer_data"
749+version = "2.1.1"
750+source = "registry+https://github.com/rust-lang/crates.io-index"
751+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
752+
753+[[package]]
754+name = "icu_properties"
755+version = "2.1.2"
756+source = "registry+https://github.com/rust-lang/crates.io-index"
757+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
758+dependencies = [
759+ "icu_collections",
760+ "icu_locale_core",
761+ "icu_properties_data",
762+ "icu_provider",
763+ "zerotrie",
764+ "zerovec",
765+]
766+
767+[[package]]
768+name = "icu_properties_data"
769+version = "2.1.2"
770+source = "registry+https://github.com/rust-lang/crates.io-index"
771+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
772+
773+[[package]]
774+name = "icu_provider"
775+version = "2.1.1"
776+source = "registry+https://github.com/rust-lang/crates.io-index"
777+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
778+dependencies = [
779+ "displaydoc",
780+ "icu_locale_core",
781+ "writeable",
782+ "yoke",
783+ "zerofrom",
784+ "zerotrie",
785+ "zerovec",
786+]
787+
788+[[package]]
789+name = "ident_case"
790+version = "1.0.1"
791+source = "registry+https://github.com/rust-lang/crates.io-index"
792+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
793+
794+[[package]]
795+name = "idna"
796+version = "1.1.0"
797+source = "registry+https://github.com/rust-lang/crates.io-index"
798+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
799+dependencies = [
800+ "idna_adapter",
801+ "smallvec",
802+ "utf8_iter",
803+]
804+
805+[[package]]
806+name = "idna_adapter"
807+version = "1.2.1"
808+source = "registry+https://github.com/rust-lang/crates.io-index"
809+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
810+dependencies = [
811+ "icu_normalizer",
812+ "icu_properties",
813+]
814+
815+[[package]]
816+name = "indexmap"
817+version = "2.14.2"
818+source = "registry+https://github.com/rust-lang/crates.io-index"
819+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
820+dependencies = [
821+ "equivalent",
822+ "hashbrown 0.17.1",
823+]
824+
825+[[package]]
826+name = "indoc"
827+version = "2.0.8"
828+source = "registry+https://github.com/rust-lang/crates.io-index"
829+checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
830+dependencies = [
831+ "rustversion",
832+]
833+
834+[[package]]
835+name = "instability"
836+version = "0.3.14"
837+source = "registry+https://github.com/rust-lang/crates.io-index"
838+checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
839+dependencies = [
840+ "darling",
841+ "indoc",
842+ "proc-macro2",
843+ "quote",
844+ "syn 3.0.9",
845+]
846+
847+[[package]]
848+name = "ipnet"
849+version = "2.12.2"
850+source = "registry+https://github.com/rust-lang/crates.io-index"
851+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
852+
853+[[package]]
854+name = "is_terminal_polyfill"
855+version = "1.70.2"
856+source = "registry+https://github.com/rust-lang/crates.io-index"
857+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
858+
859+[[package]]
860+name = "itertools"
861+version = "0.14.0"
862+source = "registry+https://github.com/rust-lang/crates.io-index"
863+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
864+dependencies = [
865+ "either",
866+]
867+
868+[[package]]
869+name = "itoa"
870+version = "1.0.18"
871+source = "registry+https://github.com/rust-lang/crates.io-index"
872+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
873+
874+[[package]]
875+name = "jni"
876+version = "0.22.4"
877+source = "registry+https://github.com/rust-lang/crates.io-index"
878+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
879+dependencies = [
880+ "cfg-if",
881+ "combine",
882+ "jni-macros",
883+ "jni-sys",
884+ "log",
885+ "simd_cesu8",
886+ "thiserror",
887+ "walkdir",
888+ "windows-link",
889+]
890+
891+[[package]]
892+name = "jni-macros"
893+version = "0.22.4"
894+source = "registry+https://github.com/rust-lang/crates.io-index"
895+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
896+dependencies = [
897+ "proc-macro2",
898+ "quote",
899+ "rustc_version",
900+ "simd_cesu8",
901+ "syn 2.0.119",
902+]
903+
904+[[package]]
905+name = "jni-sys"
906+version = "0.4.1"
907+source = "registry+https://github.com/rust-lang/crates.io-index"
908+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
909+dependencies = [
910+ "jni-sys-macros",
911+]
912+
913+[[package]]
914+name = "jni-sys-macros"
915+version = "0.4.1"
916+source = "registry+https://github.com/rust-lang/crates.io-index"
917+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
918+dependencies = [
919+ "quote",
920+ "syn 2.0.119",
921+]
922+
923+[[package]]
924+name = "jobserver"
925+version = "0.1.35"
926+source = "registry+https://github.com/rust-lang/crates.io-index"
927+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
928+dependencies = [
929+ "getrandom 0.4.3",
930+ "libc",
931+]
932+
933+[[package]]
934+name = "js-sys"
935+version = "0.3.106"
936+source = "registry+https://github.com/rust-lang/crates.io-index"
937+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
938+dependencies = [
939+ "cfg-if",
940+ "futures-util",
941+ "wasm-bindgen",
942+]
943+
944+[[package]]
945+name = "kasuari"
946+version = "0.4.12"
947+source = "registry+https://github.com/rust-lang/crates.io-index"
948+checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
949+dependencies = [
950+ "hashbrown 0.16.1",
951+ "portable-atomic",
952+ "thiserror",
953+]
954+
955+[[package]]
956+name = "lazy_static"
957+version = "1.5.1"
958+source = "registry+https://github.com/rust-lang/crates.io-index"
959+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
960+
961+[[package]]
962+name = "libc"
963+version = "0.2.190"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
342966
343967 [[package]]
344968 name = "libm"
@@ -361,6 +985,12 @@ version = "0.12.1"
361985 source = "registry+https://github.com/rust-lang/crates.io-index"
362986 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
363987
988+[[package]]
989+name = "litemap"
990+version = "0.8.3"
991+source = "registry+https://github.com/rust-lang/crates.io-index"
992+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
993+
364994 [[package]]
365995 name = "litrs"
366996 version = "1.0.1"
@@ -376,6 +1006,32 @@ dependencies = [
3761006 "scopeguard",
3771007 ]
3781008
1009+[[package]]
1010+name = "lofty"
1011+version = "0.25.4"
1012+source = "registry+https://github.com/rust-lang/crates.io-index"
1013+checksum = "bb21dd79145b0781f2423fda92dd807fcd3911e44b66044d86db3b11ec7accf9"
1014+dependencies = [
1015+ "byteorder",
1016+ "data-encoding",
1017+ "flate2",
1018+ "lofty_attr",
1019+ "log",
1020+ "ogg_pager",
1021+ "paste",
1022+]
1023+
1024+[[package]]
1025+name = "lofty_attr"
1026+version = "0.13.0"
1027+source = "registry+https://github.com/rust-lang/crates.io-index"
1028+checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd"
1029+dependencies = [
1030+ "proc-macro2",
1031+ "quote",
1032+ "syn 3.0.9",
1033+]
1034+
3791035 [[package]]
3801036 name = "log"
3811037 version = "0.4.34"
@@ -391,6 +1047,37 @@ dependencies = [
3911047 "hashbrown 0.17.1",
3921048 ]
3931049
1050+[[package]]
1051+name = "lru-slab"
1052+version = "0.1.3"
1053+source = "registry+https://github.com/rust-lang/crates.io-index"
1054+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
1055+
1056+[[package]]
1057+name = "matchers"
1058+version = "0.2.0"
1059+source = "registry+https://github.com/rust-lang/crates.io-index"
1060+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1061+dependencies = [
1062+ "regex-automata",
1063+]
1064+
1065+[[package]]
1066+name = "memchr"
1067+version = "2.8.3"
1068+source = "registry+https://github.com/rust-lang/crates.io-index"
1069+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
1070+
1071+[[package]]
1072+name = "miniz_oxide"
1073+version = "0.9.1"
1074+source = "registry+https://github.com/rust-lang/crates.io-index"
1075+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
1076+dependencies = [
1077+ "adler2",
1078+ "simd-adler32",
1079+]
1080+
3941081 [[package]]
3951082 name = "mio"
3961083 version = "1.2.4"
@@ -400,7 +1087,26 @@ dependencies = [
4001087 "libc",
4011088 "log",
4021089 "wasi",
403− "windows-sys",
1090+ "windows-sys 0.61.2",
1091+]
1092+
1093+[[package]]
1094+name = "nucleo-matcher"
1095+version = "0.3.1"
1096+source = "registry+https://github.com/rust-lang/crates.io-index"
1097+checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
1098+dependencies = [
1099+ "memchr",
1100+ "unicode-segmentation",
1101+]
1102+
1103+[[package]]
1104+name = "num-complex"
1105+version = "0.4.6"
1106+source = "registry+https://github.com/rust-lang/crates.io-index"
1107+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
1108+dependencies = [
1109+ "num-traits",
4041110 ]
4051111
4061112 [[package]]
@@ -409,6 +1115,15 @@ version = "0.2.2"
4091115 source = "registry+https://github.com/rust-lang/crates.io-index"
4101116 checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
4111117
1118+[[package]]
1119+name = "num-integer"
1120+version = "0.1.47"
1121+source = "registry+https://github.com/rust-lang/crates.io-index"
1122+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1123+dependencies = [
1124+ "num-traits",
1125+]
1126+
4121127 [[package]]
4131128 name = "num-traits"
4141129 version = "0.2.19"
@@ -427,6 +1142,33 @@ dependencies = [
4271142 "libc",
4281143 ]
4291144
1145+[[package]]
1146+name = "ogg_pager"
1147+version = "0.7.2"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
1150+dependencies = [
1151+ "byteorder",
1152+]
1153+
1154+[[package]]
1155+name = "once_cell"
1156+version = "1.21.4"
1157+source = "registry+https://github.com/rust-lang/crates.io-index"
1158+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1159+
1160+[[package]]
1161+name = "once_cell_polyfill"
1162+version = "1.70.2"
1163+source = "registry+https://github.com/rust-lang/crates.io-index"
1164+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1165+
1166+[[package]]
1167+name = "openssl-probe"
1168+version = "0.2.1"
1169+source = "registry+https://github.com/rust-lang/crates.io-index"
1170+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1171+
4301172 [[package]]
4311173 name = "palette"
4321174 version = "0.7.7"
@@ -483,24 +1225,60 @@ dependencies = [
4831225 "windows-link",
4841226 ]
4851227
1228+[[package]]
1229+name = "paste"
1230+version = "1.0.15"
1231+source = "registry+https://github.com/rust-lang/crates.io-index"
1232+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
1233+
1234+[[package]]
1235+name = "percent-encoding"
1236+version = "2.3.2"
1237+source = "registry+https://github.com/rust-lang/crates.io-index"
1238+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1239+
4861240 [[package]]
4871241 name = "pin-project-lite"
4881242 version = "0.2.17"
4891243 source = "registry+https://github.com/rust-lang/crates.io-index"
4901244 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
4911245
1246+[[package]]
1247+name = "pkg-config"
1248+version = "0.3.34"
1249+source = "registry+https://github.com/rust-lang/crates.io-index"
1250+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1251+
4921252 [[package]]
4931253 name = "portable-atomic"
4941254 version = "1.15.0"
4951255 source = "registry+https://github.com/rust-lang/crates.io-index"
4961256 checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
4971257
1258+[[package]]
1259+name = "potential_utf"
1260+version = "0.1.6"
1261+source = "registry+https://github.com/rust-lang/crates.io-index"
1262+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
1263+dependencies = [
1264+ "zerovec",
1265+]
1266+
4981267 [[package]]
4991268 name = "powerfmt"
5001269 version = "0.2.1"
5011270 source = "registry+https://github.com/rust-lang/crates.io-index"
5021271 checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
5031272
1273+[[package]]
1274+name = "primal-check"
1275+version = "0.3.4"
1276+source = "registry+https://github.com/rust-lang/crates.io-index"
1277+checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
1278+dependencies = [
1279+ "num-integer",
1280+]
1281+
5041282 [[package]]
5051283 name = "proc-macro2"
5061284 version = "1.0.107"
@@ -510,6 +1288,63 @@ dependencies = [
5101288 "unicode-ident",
5111289 ]
5121290
1291+[[package]]
1292+name = "quinn"
1293+version = "0.11.12"
1294+source = "registry+https://github.com/rust-lang/crates.io-index"
1295+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
1296+dependencies = [
1297+ "bytes",
1298+ "cfg_aliases",
1299+ "pin-project-lite",
1300+ "quinn-proto",
1301+ "quinn-udp",
1302+ "rustc-hash",
1303+ "rustls",
1304+ "socket2",
1305+ "thiserror",
1306+ "tokio",
1307+ "tracing",
1308+ "web-time",
1309+]
1310+
1311+[[package]]
1312+name = "quinn-proto"
1313+version = "0.11.19"
1314+source = "registry+https://github.com/rust-lang/crates.io-index"
1315+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
1316+dependencies = [
1317+ "aws-lc-rs",
1318+ "bytes",
1319+ "getrandom 0.4.3",
1320+ "lru-slab",
1321+ "rand",
1322+ "rand_pcg",
1323+ "ring",
1324+ "rustc-hash",
1325+ "rustls",
1326+ "rustls-pki-types",
1327+ "slab",
1328+ "thiserror",
1329+ "tinyvec",
1330+ "tracing",
1331+ "web-time",
1332+]
1333+
1334+[[package]]
1335+name = "quinn-udp"
1336+version = "0.5.16"
1337+source = "registry+https://github.com/rust-lang/crates.io-index"
1338+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
1339+dependencies = [
1340+ "cfg_aliases",
1341+ "libc",
1342+ "once_cell",
1343+ "socket2",
1344+ "tracing",
1345+ "windows-sys 0.61.2",
1346+]
1347+
5131348 [[package]]
5141349 name = "quote"
5151350 version = "1.0.47"
@@ -519,6 +1354,38 @@ dependencies = [
5191354 "proc-macro2",
5201355 ]
5211356
1357+[[package]]
1358+name = "r-efi"
1359+version = "6.0.0"
1360+source = "registry+https://github.com/rust-lang/crates.io-index"
1361+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1362+
1363+[[package]]
1364+name = "rand"
1365+version = "0.10.3"
1366+source = "registry+https://github.com/rust-lang/crates.io-index"
1367+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
1368+dependencies = [
1369+ "chacha20",
1370+ "getrandom 0.4.3",
1371+ "rand_core",
1372+]
1373+
1374+[[package]]
1375+name = "rand_core"
1376+version = "0.10.1"
1377+source = "registry+https://github.com/rust-lang/crates.io-index"
1378+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1379+
1380+[[package]]
1381+name = "rand_pcg"
1382+version = "0.10.2"
1383+source = "registry+https://github.com/rust-lang/crates.io-index"
1384+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
1385+dependencies = [
1386+ "rand_core",
1387+]
1388+
5221389 [[package]]
5231390 name = "ratatui"
5241391 version = "0.30.2"
@@ -585,6 +1452,15 @@ dependencies = [
5851452 "unicode-width",
5861453 ]
5871454
1455+[[package]]
1456+name = "realfft"
1457+version = "3.5.0"
1458+source = "registry+https://github.com/rust-lang/crates.io-index"
1459+checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
1460+dependencies = [
1461+ "rustfft",
1462+]
1463+
5881464 [[package]]
5891465 name = "redox_syscall"
5901466 version = "0.5.18"
@@ -594,24 +1470,102 @@ dependencies = [
5941470 "bitflags",
5951471 ]
5961472
1473+[[package]]
1474+name = "regex-automata"
1475+version = "0.4.18"
1476+source = "registry+https://github.com/rust-lang/crates.io-index"
1477+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1478+dependencies = [
1479+ "aho-corasick",
1480+ "memchr",
1481+ "regex-syntax",
1482+]
1483+
1484+[[package]]
1485+name = "regex-syntax"
1486+version = "0.8.11"
1487+source = "registry+https://github.com/rust-lang/crates.io-index"
1488+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1489+
1490+[[package]]
1491+name = "reqwest"
1492+version = "0.13.5"
1493+source = "registry+https://github.com/rust-lang/crates.io-index"
1494+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
1495+dependencies = [
1496+ "base64",
1497+ "bytes",
1498+ "futures-core",
1499+ "http",
1500+ "http-body",
1501+ "http-body-util",
1502+ "hyper",
1503+ "hyper-rustls",
1504+ "hyper-util",
1505+ "js-sys",
1506+ "log",
1507+ "percent-encoding",
1508+ "pin-project-lite",
1509+ "quinn",
1510+ "rustls",
1511+ "rustls-pki-types",
1512+ "rustls-platform-verifier",
1513+ "serde",
1514+ "serde_json",
1515+ "sync_wrapper",
1516+ "tokio",
1517+ "tokio-rustls",
1518+ "tower",
1519+ "tower-http",
1520+ "tower-service",
1521+ "url",
1522+ "wasm-bindgen",
1523+ "wasm-bindgen-futures",
1524+ "web-sys",
1525+]
1526+
1527+[[package]]
1528+name = "ring"
1529+version = "0.17.14"
1530+source = "registry+https://github.com/rust-lang/crates.io-index"
1531+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1532+dependencies = [
1533+ "cc",
1534+ "cfg-if",
1535+ "getrandom 0.2.17",
1536+ "libc",
1537+ "untrusted",
1538+ "windows-sys 0.52.0",
1539+]
1540+
5971541 [[package]]
5981542 name = "rmptui"
599−version = "0.1.6"
1543+version = "0.2.0"
6001544 dependencies = [
1545+ "anyhow",
1546+ "clap",
6011547 "crossterm",
6021548 "futures-util",
1549+ "lofty",
1550+ "nucleo-matcher",
6031551 "ratatui",
604− "rust-fuzzy-search",
1552+ "realfft",
1553+ "reqwest",
6051554 "serde",
1555+ "serde_json",
1556+ "thiserror",
6061557 "tokio",
6071558 "toml",
1559+ "tracing",
1560+ "tracing-subscriber",
1561+ "unicode-width",
6081562 ]
6091563
6101564 [[package]]
611−name = "rust-fuzzy-search"
612−version = "0.1.1"
1565+name = "rustc-hash"
1566+version = "2.1.3"
6131567 source = "registry+https://github.com/rust-lang/crates.io-index"
614−checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2"
1568+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
6151569
6161570 [[package]]
6171571 name = "rustc_version"
@@ -622,6 +1576,20 @@ dependencies = [
6221576 "semver",
6231577 ]
6241578
1579+[[package]]
1580+name = "rustfft"
1581+version = "6.4.1"
1582+source = "registry+https://github.com/rust-lang/crates.io-index"
1583+checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
1584+dependencies = [
1585+ "num-complex",
1586+ "num-integer",
1587+ "num-traits",
1588+ "primal-check",
1589+ "strength_reduce",
1590+ "transpose",
1591+]
1592+
6251593 [[package]]
6261594 name = "rustix"
6271595 version = "1.1.5"
@@ -632,7 +1600,82 @@ dependencies = [
6321600 "errno",
6331601 "libc",
6341602 "linux-raw-sys",
635− "windows-sys",
1603+ "windows-sys 0.61.2",
1604+]
1605+
1606+[[package]]
1607+name = "rustls"
1608+version = "0.23.46"
1609+source = "registry+https://github.com/rust-lang/crates.io-index"
1610+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
1611+dependencies = [
1612+ "aws-lc-rs",
1613+ "once_cell",
1614+ "rustls-pki-types",
1615+ "rustls-webpki",
1616+ "subtle",
1617+ "zeroize",
1618+]
1619+
1620+[[package]]
1621+name = "rustls-native-certs"
1622+version = "0.8.5"
1623+source = "registry+https://github.com/rust-lang/crates.io-index"
1624+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
1625+dependencies = [
1626+ "openssl-probe",
1627+ "rustls-pki-types",
1628+ "schannel",
1629+ "security-framework",
1630+]
1631+
1632+[[package]]
1633+name = "rustls-pki-types"
1634+version = "1.15.1"
1635+source = "registry+https://github.com/rust-lang/crates.io-index"
1636+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1637+dependencies = [
1638+ "web-time",
1639+ "zeroize",
1640+]
1641+
1642+[[package]]
1643+name = "rustls-platform-verifier"
1644+version = "0.7.1"
1645+source = "registry+https://github.com/rust-lang/crates.io-index"
1646+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
1647+dependencies = [
1648+ "core-foundation",
1649+ "core-foundation-sys",
1650+ "jni",
1651+ "log",
1652+ "once_cell",
1653+ "rustls",
1654+ "rustls-native-certs",
1655+ "rustls-platform-verifier-android",
1656+ "rustls-webpki",
1657+ "security-framework",
1658+ "security-framework-sys",
1659+ "webpki-root-certs",
1660+ "windows-sys 0.61.2",
1661+]
1662+
1663+[[package]]
1664+name = "rustls-platform-verifier-android"
1665+version = "0.2.0"
1666+source = "registry+https://github.com/rust-lang/crates.io-index"
1667+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
1668+
1669+[[package]]
1670+name = "rustls-webpki"
1671+version = "0.103.15"
1672+source = "registry+https://github.com/rust-lang/crates.io-index"
1673+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
1674+dependencies = [
1675+ "aws-lc-rs",
1676+ "ring",
1677+ "rustls-pki-types",
1678+ "untrusted",
6361679 ]
6371680
6381681 [[package]]
@@ -647,12 +1690,53 @@ version = "1.0.23"
6471690 source = "registry+https://github.com/rust-lang/crates.io-index"
6481691 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
6491692
1693+[[package]]
1694+name = "same-file"
1695+version = "1.0.6"
1696+source = "registry+https://github.com/rust-lang/crates.io-index"
1697+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1698+dependencies = [
1699+ "winapi-util",
1700+]
1701+
1702+[[package]]
1703+name = "schannel"
1704+version = "0.1.30"
1705+source = "registry+https://github.com/rust-lang/crates.io-index"
1706+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
1707+dependencies = [
1708+ "windows-sys 0.61.2",
1709+]
1710+
6501711 [[package]]
6511712 name = "scopeguard"
6521713 version = "1.2.0"
6531714 source = "registry+https://github.com/rust-lang/crates.io-index"
6541715 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
6551716
1717+[[package]]
1718+name = "security-framework"
1719+version = "3.7.0"
1720+source = "registry+https://github.com/rust-lang/crates.io-index"
1721+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1722+dependencies = [
1723+ "bitflags",
1724+ "core-foundation",
1725+ "core-foundation-sys",
1726+ "libc",
1727+ "security-framework-sys",
1728+]
1729+
1730+[[package]]
1731+name = "security-framework-sys"
1732+version = "2.17.0"
1733+source = "registry+https://github.com/rust-lang/crates.io-index"
1734+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1735+dependencies = [
1736+ "core-foundation-sys",
1737+ "libc",
1738+]
1739+
6561740 [[package]]
6571741 name = "semver"
6581742 version = "1.0.28"
@@ -689,6 +1773,19 @@ dependencies = [
6891773 "syn 3.0.9",
6901774 ]
6911775
1776+[[package]]
1777+name = "serde_json"
1778+version = "1.0.154"
1779+source = "registry+https://github.com/rust-lang/crates.io-index"
1780+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
1781+dependencies = [
1782+ "itoa",
1783+ "memchr",
1784+ "serde",
1785+ "serde_core",
1786+ "zmij",
1787+]
1788+
6921789 [[package]]
6931790 name = "serde_spanned"
6941791 version = "1.1.2"
@@ -698,6 +1795,21 @@ dependencies = [
6981795 "serde_core",
6991796 ]
7001797
1798+[[package]]
1799+name = "sharded-slab"
1800+version = "0.1.7"
1801+source = "registry+https://github.com/rust-lang/crates.io-index"
1802+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1803+dependencies = [
1804+ "lazy_static",
1805+]
1806+
1807+[[package]]
1808+name = "shlex"
1809+version = "2.0.1"
1810+source = "registry+https://github.com/rust-lang/crates.io-index"
1811+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1812+
7011813 [[package]]
7021814 name = "signal-hook"
7031815 version = "0.3.18"
@@ -729,6 +1841,28 @@ dependencies = [
7291841 "libc",
7301842 ]
7311843
1844+[[package]]
1845+name = "simd-adler32"
1846+version = "0.3.10"
1847+source = "registry+https://github.com/rust-lang/crates.io-index"
1848+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
1849+
1850+[[package]]
1851+name = "simd_cesu8"
1852+version = "1.2.0"
1853+source = "registry+https://github.com/rust-lang/crates.io-index"
1854+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
1855+dependencies = [
1856+ "rustc_version",
1857+ "simdutf8",
1858+]
1859+
1860+[[package]]
1861+name = "simdutf8"
1862+version = "0.1.5"
1863+source = "registry+https://github.com/rust-lang/crates.io-index"
1864+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
1865+
7321866 [[package]]
7331867 name = "slab"
7341868 version = "0.4.12"
@@ -748,15 +1882,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
7481882 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
7491883 dependencies = [
7501884 "libc",
751− "windows-sys",
1885+ "windows-sys 0.61.2",
7521886 ]
7531887
1888+[[package]]
1889+name = "stable_deref_trait"
1890+version = "1.2.1"
1891+source = "registry+https://github.com/rust-lang/crates.io-index"
1892+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1893+
7541894 [[package]]
7551895 name = "static_assertions"
7561896 version = "1.1.0"
7571897 source = "registry+https://github.com/rust-lang/crates.io-index"
7581898 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
7591899
1900+[[package]]
1901+name = "strength_reduce"
1902+version = "0.2.4"
1903+source = "registry+https://github.com/rust-lang/crates.io-index"
1904+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
1905+
7601906 [[package]]
7611907 name = "strsim"
7621908 version = "0.11.1"
@@ -784,6 +1930,12 @@ dependencies = [
7841930 "syn 2.0.119",
7851931 ]
7861932
1933+[[package]]
1934+name = "subtle"
1935+version = "2.6.1"
1936+source = "registry+https://github.com/rust-lang/crates.io-index"
1937+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1938+
7871939 [[package]]
7881940 name = "syn"
7891941 version = "2.0.119"
@@ -799,11 +1951,31 @@ dependencies = [
7991951 name = "syn"
8001952 version = "3.0.9"
8011953 source = "registry+https://github.com/rust-lang/crates.io-index"
802−checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
1954+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
1955+dependencies = [
1956+ "proc-macro2",
1957+ "quote",
1958+ "unicode-ident",
1959+]
1960+
1961+[[package]]
1962+name = "sync_wrapper"
1963+version = "1.0.2"
1964+source = "registry+https://github.com/rust-lang/crates.io-index"
1965+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1966+dependencies = [
1967+ "futures-core",
1968+]
1969+
1970+[[package]]
1971+name = "synstructure"
1972+version = "0.14.0"
1973+source = "registry+https://github.com/rust-lang/crates.io-index"
1974+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
8031975 dependencies = [
8041976 "proc-macro2",
8051977 "quote",
806− "unicode-ident",
1978+ "syn 3.0.9",
8071979 ]
8081980
8091981 [[package]]
@@ -826,6 +1998,15 @@ dependencies = [
8261998 "syn 3.0.9",
8271999 ]
8282000
2001+[[package]]
2002+name = "thread_local"
2003+version = "1.1.10"
2004+source = "registry+https://github.com/rust-lang/crates.io-index"
2005+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
2006+dependencies = [
2007+ "cfg-if",
2008+]
2009+
8292010 [[package]]
8302011 name = "time"
8312012 version = "0.3.55"
@@ -847,6 +2028,22 @@ version = "0.1.9"
8472028 source = "registry+https://github.com/rust-lang/crates.io-index"
8482029 checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
8492030
2031+[[package]]
2032+name = "tinystr"
2033+version = "0.8.4"
2034+source = "registry+https://github.com/rust-lang/crates.io-index"
2035+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
2036+dependencies = [
2037+ "displaydoc",
2038+ "zerovec",
2039+]
2040+
2041+[[package]]
2042+name = "tinyvec"
2043+version = "1.13.3"
2044+source = "registry+https://github.com/rust-lang/crates.io-index"
2045+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
2046+
8502047 [[package]]
8512048 name = "tokio"
8522049 version = "1.53.3"
@@ -859,7 +2056,7 @@ dependencies = [
8592056 "pin-project-lite",
8602057 "socket2",
8612058 "tokio-macros",
862− "windows-sys",
2059+ "windows-sys 0.61.2",
8632060 ]
8642061
8652062 [[package]]
@@ -873,6 +2070,16 @@ dependencies = [
8732070 "syn 3.0.9",
8742071 ]
8752072
2073+[[package]]
2074+name = "tokio-rustls"
2075+version = "0.26.6"
2076+source = "registry+https://github.com/rust-lang/crates.io-index"
2077+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
2078+dependencies = [
2079+ "rustls",
2080+ "tokio",
2081+]
2082+
8762083 [[package]]
8772084 name = "toml"
8782085 version = "1.1.8+spec-1.1.0"
@@ -912,6 +2119,113 @@ version = "1.1.3+spec-1.1.0"
9122119 source = "registry+https://github.com/rust-lang/crates.io-index"
9132120 checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
9142121
2122+[[package]]
2123+name = "tower"
2124+version = "0.5.3"
2125+source = "registry+https://github.com/rust-lang/crates.io-index"
2126+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
2127+dependencies = [
2128+ "futures-core",
2129+ "futures-util",
2130+ "pin-project-lite",
2131+ "sync_wrapper",
2132+ "tokio",
2133+ "tower-layer",
2134+ "tower-service",
2135+]
2136+
2137+[[package]]
2138+name = "tower-http"
2139+version = "0.6.11"
2140+source = "registry+https://github.com/rust-lang/crates.io-index"
2141+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
2142+dependencies = [
2143+ "bitflags",
2144+ "bytes",
2145+ "futures-util",
2146+ "http",
2147+ "http-body",
2148+ "pin-project-lite",
2149+ "tower",
2150+ "tower-layer",
2151+ "tower-service",
2152+ "url",
2153+]
2154+
2155+[[package]]
2156+name = "tower-layer"
2157+version = "0.3.3"
2158+source = "registry+https://github.com/rust-lang/crates.io-index"
2159+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2160+
2161+[[package]]
2162+name = "tower-service"
2163+version = "0.3.3"
2164+source = "registry+https://github.com/rust-lang/crates.io-index"
2165+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2166+
2167+[[package]]
2168+name = "tracing"
2169+version = "0.1.44"
2170+source = "registry+https://github.com/rust-lang/crates.io-index"
2171+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2172+dependencies = [
2173+ "pin-project-lite",
2174+ "tracing-attributes",
2175+ "tracing-core",
2176+]
2177+
2178+[[package]]
2179+name = "tracing-attributes"
2180+version = "0.1.31"
2181+source = "registry+https://github.com/rust-lang/crates.io-index"
2182+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2183+dependencies = [
2184+ "proc-macro2",
2185+ "quote",
2186+ "syn 2.0.119",
2187+]
2188+
2189+[[package]]
2190+name = "tracing-core"
2191+version = "0.1.36"
2192+source = "registry+https://github.com/rust-lang/crates.io-index"
2193+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2194+dependencies = [
2195+ "once_cell",
2196+]
2197+
2198+[[package]]
2199+name = "tracing-subscriber"
2200+version = "0.3.23"
2201+source = "registry+https://github.com/rust-lang/crates.io-index"
2202+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
2203+dependencies = [
2204+ "matchers",
2205+ "once_cell",
2206+ "regex-automata",
2207+ "sharded-slab",
2208+ "thread_local",
2209+ "tracing",
2210+ "tracing-core",
2211+]
2212+
2213+[[package]]
2214+name = "transpose"
2215+version = "0.2.3"
2216+source = "registry+https://github.com/rust-lang/crates.io-index"
2217+checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
2218+dependencies = [
2219+ "num-integer",
2220+ "strength_reduce",
2221+]
2222+
2223+[[package]]
2224+name = "try-lock"
2225+version = "0.2.5"
2226+source = "registry+https://github.com/rust-lang/crates.io-index"
2227+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2228+
9152229 [[package]]
9162230 name = "unicode-ident"
9172231 version = "1.0.27"
@@ -941,12 +2255,146 @@ version = "0.2.2"
9412255 source = "registry+https://github.com/rust-lang/crates.io-index"
9422256 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
9432257
2258+[[package]]
2259+name = "untrusted"
2260+version = "0.9.0"
2261+source = "registry+https://github.com/rust-lang/crates.io-index"
2262+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2263+
2264+[[package]]
2265+name = "url"
2266+version = "2.5.8"
2267+source = "registry+https://github.com/rust-lang/crates.io-index"
2268+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2269+dependencies = [
2270+ "form_urlencoded",
2271+ "idna",
2272+ "percent-encoding",
2273+ "serde",
2274+]
2275+
2276+[[package]]
2277+name = "utf8_iter"
2278+version = "1.0.4"
2279+source = "registry+https://github.com/rust-lang/crates.io-index"
2280+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2281+
2282+[[package]]
2283+name = "utf8parse"
2284+version = "0.2.2"
2285+source = "registry+https://github.com/rust-lang/crates.io-index"
2286+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2287+
2288+[[package]]
2289+name = "walkdir"
2290+version = "2.5.0"
2291+source = "registry+https://github.com/rust-lang/crates.io-index"
2292+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2293+dependencies = [
2294+ "same-file",
2295+ "winapi-util",
2296+]
2297+
2298+[[package]]
2299+name = "want"
2300+version = "0.3.2"
2301+source = "registry+https://github.com/rust-lang/crates.io-index"
2302+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
2303+dependencies = [
2304+ "try-lock",
2305+]
2306+
9442307 [[package]]
9452308 name = "wasi"
9462309 version = "0.11.1+wasi-snapshot-preview1"
9472310 source = "registry+https://github.com/rust-lang/crates.io-index"
9482311 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
9492312
2313+[[package]]
2314+name = "wasm-bindgen"
2315+version = "0.2.129"
2316+source = "registry+https://github.com/rust-lang/crates.io-index"
2317+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
2318+dependencies = [
2319+ "cfg-if",
2320+ "once_cell",
2321+ "rustversion",
2322+ "wasm-bindgen-macro",
2323+ "wasm-bindgen-shared",
2324+]
2325+
2326+[[package]]
2327+name = "wasm-bindgen-futures"
2328+version = "0.4.79"
2329+source = "registry+https://github.com/rust-lang/crates.io-index"
2330+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
2331+dependencies = [
2332+ "js-sys",
2333+ "tokio",
2334+ "wasm-bindgen",
2335+]
2336+
2337+[[package]]
2338+name = "wasm-bindgen-macro"
2339+version = "0.2.129"
2340+source = "registry+https://github.com/rust-lang/crates.io-index"
2341+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
2342+dependencies = [
2343+ "quote",
2344+ "wasm-bindgen-macro-support",
2345+]
2346+
2347+[[package]]
2348+name = "wasm-bindgen-macro-support"
2349+version = "0.2.129"
2350+source = "registry+https://github.com/rust-lang/crates.io-index"
2351+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
2352+dependencies = [
2353+ "bumpalo",
2354+ "proc-macro2",
2355+ "quote",
2356+ "syn 3.0.9",
2357+ "wasm-bindgen-shared",
2358+]
2359+
2360+[[package]]
2361+name = "wasm-bindgen-shared"
2362+version = "0.2.129"
2363+source = "registry+https://github.com/rust-lang/crates.io-index"
2364+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
2365+dependencies = [
2366+ "unicode-ident",
2367+]
2368+
2369+[[package]]
2370+name = "web-sys"
2371+version = "0.3.106"
2372+source = "registry+https://github.com/rust-lang/crates.io-index"
2373+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
2374+dependencies = [
2375+ "js-sys",
2376+ "wasm-bindgen",
2377+]
2378+
2379+[[package]]
2380+name = "web-time"
2381+version = "1.1.0"
2382+source = "registry+https://github.com/rust-lang/crates.io-index"
2383+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2384+dependencies = [
2385+ "js-sys",
2386+ "wasm-bindgen",
2387+]
2388+
2389+[[package]]
2390+name = "webpki-root-certs"
2391+version = "1.0.9"
2392+source = "registry+https://github.com/rust-lang/crates.io-index"
2393+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
2394+dependencies = [
2395+ "rustls-pki-types",
2396+]
2397+
9502398 [[package]]
9512399 name = "winapi"
9522400 version = "0.3.9"
@@ -963,6 +2411,15 @@ version = "0.4.0"
9632411 source = "registry+https://github.com/rust-lang/crates.io-index"
9642412 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
9652413
2414+[[package]]
2415+name = "winapi-util"
2416+version = "0.1.11"
2417+source = "registry+https://github.com/rust-lang/crates.io-index"
2418+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2419+dependencies = [
2420+ "windows-sys 0.61.2",
2421+]
2422+
9662423 [[package]]
9672424 name = "winapi-x86_64-pc-windows-gnu"
9682425 version = "0.4.0"
@@ -975,6 +2432,15 @@ version = "0.2.1"
9752432 source = "registry+https://github.com/rust-lang/crates.io-index"
9762433 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
9772434
2435+[[package]]
2436+name = "windows-sys"
2437+version = "0.52.0"
2438+source = "registry+https://github.com/rust-lang/crates.io-index"
2439+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2440+dependencies = [
2441+ "windows-targets",
2442+]
2443+
9782444 [[package]]
9792445 name = "windows-sys"
9802446 version = "0.61.2"
@@ -984,8 +2450,173 @@ dependencies = [
9842450 "windows-link",
9852451 ]
9862452
2453+[[package]]
2454+name = "windows-targets"
2455+version = "0.52.6"
2456+source = "registry+https://github.com/rust-lang/crates.io-index"
2457+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2458+dependencies = [
2459+ "windows_aarch64_gnullvm",
2460+ "windows_aarch64_msvc",
2461+ "windows_i686_gnu",
2462+ "windows_i686_gnullvm",
2463+ "windows_i686_msvc",
2464+ "windows_x86_64_gnu",
2465+ "windows_x86_64_gnullvm",
2466+ "windows_x86_64_msvc",
2467+]
2468+
2469+[[package]]
2470+name = "windows_aarch64_gnullvm"
2471+version = "0.52.6"
2472+source = "registry+https://github.com/rust-lang/crates.io-index"
2473+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2474+
2475+[[package]]
2476+name = "windows_aarch64_msvc"
2477+version = "0.52.6"
2478+source = "registry+https://github.com/rust-lang/crates.io-index"
2479+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2480+
2481+[[package]]
2482+name = "windows_i686_gnu"
2483+version = "0.52.6"
2484+source = "registry+https://github.com/rust-lang/crates.io-index"
2485+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2486+
2487+[[package]]
2488+name = "windows_i686_gnullvm"
2489+version = "0.52.6"
2490+source = "registry+https://github.com/rust-lang/crates.io-index"
2491+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2492+
2493+[[package]]
2494+name = "windows_i686_msvc"
2495+version = "0.52.6"
2496+source = "registry+https://github.com/rust-lang/crates.io-index"
2497+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2498+
2499+[[package]]
2500+name = "windows_x86_64_gnu"
2501+version = "0.52.6"
2502+source = "registry+https://github.com/rust-lang/crates.io-index"
2503+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2504+
2505+[[package]]
2506+name = "windows_x86_64_gnullvm"
2507+version = "0.52.6"
2508+source = "registry+https://github.com/rust-lang/crates.io-index"
2509+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2510+
2511+[[package]]
2512+name = "windows_x86_64_msvc"
2513+version = "0.52.6"
2514+source = "registry+https://github.com/rust-lang/crates.io-index"
2515+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2516+
9872517 [[package]]
9882518 name = "winnow"
9892519 version = "1.0.4"
9902520 source = "registry+https://github.com/rust-lang/crates.io-index"
9912521 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
2522+
2523+[[package]]
2524+name = "writeable"
2525+version = "0.6.4"
2526+source = "registry+https://github.com/rust-lang/crates.io-index"
2527+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
2528+
2529+[[package]]
2530+name = "yoke"
2531+version = "0.8.3"
2532+source = "registry+https://github.com/rust-lang/crates.io-index"
2533+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2534+dependencies = [
2535+ "stable_deref_trait",
2536+ "yoke-derive",
2537+ "zerofrom",
2538+]
2539+
2540+[[package]]
2541+name = "yoke-derive"
2542+version = "0.8.4"
2543+source = "registry+https://github.com/rust-lang/crates.io-index"
2544+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
2545+dependencies = [
2546+ "proc-macro2",
2547+ "quote",
2548+ "syn 3.0.9",
2549+ "synstructure",
2550+]
2551+
2552+[[package]]
2553+name = "zerofrom"
2554+version = "0.1.8"
2555+source = "registry+https://github.com/rust-lang/crates.io-index"
2556+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2557+dependencies = [
2558+ "zerofrom-derive",
2559+]
2560+
2561+[[package]]
2562+name = "zerofrom-derive"
2563+version = "0.1.8"
2564+source = "registry+https://github.com/rust-lang/crates.io-index"
2565+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
2566+dependencies = [
2567+ "proc-macro2",
2568+ "quote",
2569+ "syn 3.0.9",
2570+ "synstructure",
2571+]
2572+
2573+[[package]]
2574+name = "zeroize"
2575+version = "1.9.1"
2576+source = "registry+https://github.com/rust-lang/crates.io-index"
2577+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
2578+
2579+[[package]]
2580+name = "zerotrie"
2581+version = "0.2.5"
2582+source = "registry+https://github.com/rust-lang/crates.io-index"
2583+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
2584+dependencies = [
2585+ "displaydoc",
2586+ "yoke",
2587+ "zerofrom",
2588+]
2589+
2590+[[package]]
2591+name = "zerovec"
2592+version = "0.11.8"
2593+source = "registry+https://github.com/rust-lang/crates.io-index"
2594+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
2595+dependencies = [
2596+ "yoke",
2597+ "zerofrom",
2598+ "zerovec-derive",
2599+]
2600+
2601+[[package]]
2602+name = "zerovec-derive"
2603+version = "0.11.6"
2604+source = "registry+https://github.com/rust-lang/crates.io-index"
2605+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
2606+dependencies = [
2607+ "proc-macro2",
2608+ "quote",
2609+ "syn 3.0.9",
2610+]
2611+
2612+[[package]]
2613+name = "zlib-rs"
2614+version = "0.6.8"
2615+source = "registry+https://github.com/rust-lang/crates.io-index"
2616+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
2617+
2618+[[package]]
2619+name = "zmij"
2620+version = "1.0.23"
2621+source = "registry+https://github.com/rust-lang/crates.io-index"
2622+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
M Cargo.toml View
@@ -1,28 +1,38 @@
11 [package]
22 name = "rmptui"
33 authors = ["krolyxon"]
4−description = "A fast and minimal terminal MPD client"
4+description = "A fast, keyboard-driven MPD client for the terminal"
55 readme = "README.md"
6−version = "0.1.6"
7−edition = "2021"
6+version = "0.2.0"
7+edition = "2024"
8+rust-version = "1.85"
89 license = "AGPL-3.0-only"
910 repository = "https://github.com/krolyxon/rmptui"
1011 keywords = ["mpd", "music", "cli", "tui", "client"]
12+categories = ["command-line-utilities", "multimedia::audio"]
1113
1214 [dependencies]
13−ratatui = { version = "0.30.2", default-features = false, features = [
14− "crossterm_0_29",
15−] }
15+anyhow = "1.0.104"
16+clap = { version = "4.6.7", features = ["derive"] }
1617 crossterm = { version = "0.29.0", features = ["event-stream"] }
17−rust-fuzzy-search = "0.1.1"
1818 futures-util = "0.3.34"
19+lofty = "0.25.4"
20+nucleo-matcher = "0.3.1"
21+ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] }
22+realfft = "3.5.0"
23+reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
1924 serde = { version = "1.0.229", features = ["derive"] }
25+serde_json = "1.0.154"
26+thiserror = "2.0.21"
27+tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread", "net", "io-util", "time", "sync", "fs"] }
2028 toml = "1.1.8"
21−tokio = { version = "1.53.3", features = [
22− "macros",
23− "rt-multi-thread",
24− "net",
25− "io-util",
26− "time",
27− "sync",
28−] }
29+tracing = "0.1.44"
30+tracing-subscriber = { version = "0.3.23", default-features = false, features = ["fmt", "std", "env-filter"] }
31+unicode-width = "0.2.2"
32+
33+[dev-dependencies]
34+tokio = { version = "1.53.3", features = ["test-util"] }
35+
36+[profile.release]
37+lto = "thin"
38+codegen-units = 1
A src/action.rs View
@@ -0,0 +1,317 @@
1+//! Every user-triggerable action and the key contexts they are bound in.
2+
3+use std::{fmt, str::FromStr};
4+
5+use serde::{Deserialize, Deserializer, de};
6+
7+macro_rules! named_enum {
8+ (
9+ $(#[$meta:meta])*
10+ pub enum $name:ident { $($variant:ident = $str:literal),* $(,)? }
11+ ) => {
12+ $(#[$meta])*
13+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
14+ pub enum $name { $($variant),* }
15+
16+ impl $name {
17+ pub const ALL: &[Self] = &[$(Self::$variant),*];
18+
19+ pub fn name(self) -> &'static str {
20+ match self { $(Self::$variant => $str),* }
21+ }
22+ }
23+
24+ impl fmt::Display for $name {
25+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26+ f.write_str(self.name())
27+ }
28+ }
29+
30+ impl FromStr for $name {
31+ type Err = String;
32+
33+ fn from_str(s: &str) -> Result<Self, Self::Err> {
34+ Self::ALL
35+ .iter()
36+ .copied()
37+ .find(|v| v.name() == s)
38+ .ok_or_else(|| format!("unknown {} `{s}`", stringify!($name).to_lowercase()))
39+ }
40+ }
41+
42+ impl<'de> Deserialize<'de> for $name {
43+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
44+ let s = String::deserialize(deserializer)?;
45+ s.parse().map_err(de::Error::custom)
46+ }
47+ }
48+ };
49+}
50+
51+named_enum! {
52+ /// A set of bindings that is active in a certain part of the UI.
53+ ///
54+ /// Lookups go from the most specific context (popup or screen) through
55+ /// `navigation` to `global`, so specific bindings shadow general ones.
56+ pub enum Context {
57+ Global = "global",
58+ Navigation = "navigation",
59+ Queue = "queue",
60+ Browser = "browser",
61+ Playlists = "playlists",
62+ Library = "library",
63+ TagEditor = "tag_editor",
64+ Visualizer = "visualizer",
65+ Lyrics = "lyrics",
66+ Popup = "popup",
67+ Input = "input",
68+ }
69+}
70+
71+named_enum! {
72+ pub enum Action {
73+ // Global
74+ Quit = "quit",
75+ Help = "help",
76+ TogglePause = "toggle_pause",
77+ Pause = "pause",
78+ Stop = "stop",
79+ NextTrack = "next_track",
80+ PreviousTrack = "previous_track",
81+ SeekForward = "seek_forward",
82+ SeekBackward = "seek_backward",
83+ VolumeUp = "volume_up",
84+ VolumeDown = "volume_down",
85+ ToggleMute = "toggle_mute",
86+ ToggleRepeat = "toggle_repeat",
87+ ToggleRandom = "toggle_random",
88+ ToggleSingle = "toggle_single",
89+ ToggleConsume = "toggle_consume",
90+ UpdateDatabase = "update_database",
91+ NextScreen = "next_screen",
92+ PreviousScreen = "previous_screen",
93+ ScreenQueue = "screen_queue",
94+ ScreenBrowser = "screen_browser",
95+ ScreenPlaylists = "screen_playlists",
96+ ScreenLibrary = "screen_library",
97+ ScreenTagEditor = "screen_tag_editor",
98+ ScreenVisualizer = "screen_visualizer",
99+ ScreenLyrics = "screen_lyrics",
100+ // Navigation (lists)
101+ Up = "up",
102+ Down = "down",
103+ Top = "top",
104+ Bottom = "bottom",
105+ PageUp = "page_up",
106+ PageDown = "page_down",
107+ HalfPageUp = "half_page_up",
108+ HalfPageDown = "half_page_down",
109+ Left = "left",
110+ Right = "right",
111+ Activate = "activate",
112+ ToggleMark = "toggle_mark",
113+ ClearMarks = "clear_marks",
114+ Search = "search",
115+ SearchNext = "search_next",
116+ SearchPrevious = "search_previous",
117+ AddToQueue = "add_to_queue",
118+ AddToPlaylist = "add_to_playlist",
119+ // Screen specific
120+ Delete = "delete",
121+ MoveItemUp = "move_item_up",
122+ MoveItemDown = "move_item_down",
123+ ClearQueue = "clear_queue",
124+ ShuffleQueue = "shuffle_queue",
125+ SaveQueue = "save_queue",
126+ JumpToCurrent = "jump_to_current",
127+ ToggleLyricsPane = "toggle_lyrics_pane",
128+ DeletePlaylist = "delete_playlist",
129+ RenamePlaylist = "rename_playlist",
130+ ToggleArtistTag = "toggle_artist_tag",
131+ SaveTags = "save_tags",
132+ RevertTags = "revert_tags",
133+ ReloadLyrics = "reload_lyrics",
134+ // Popups and text input
135+ Close = "close",
136+ Submit = "submit",
137+ Cancel = "cancel",
138+ Backspace = "backspace",
139+ DeleteChar = "delete_char",
140+ DeleteWord = "delete_word",
141+ CursorLeft = "cursor_left",
142+ CursorRight = "cursor_right",
143+ CursorHome = "cursor_home",
144+ CursorEnd = "cursor_end",
145+ }
146+}
147+
148+named_enum! {
149+ /// The screens, in tab order.
150+ pub enum ScreenId {
151+ Queue = "queue",
152+ Browser = "browser",
153+ Playlists = "playlists",
154+ Library = "library",
155+ TagEditor = "tag_editor",
156+ Visualizer = "visualizer",
157+ Lyrics = "lyrics",
158+ }
159+}
160+
161+impl ScreenId {
162+ pub fn title(self) -> &'static str {
163+ match self {
164+ Self::Queue => "Queue",
165+ Self::Browser => "Browse",
166+ Self::Playlists => "Playlists",
167+ Self::Library => "Library",
168+ Self::TagEditor => "Tags",
169+ Self::Visualizer => "Visualizer",
170+ Self::Lyrics => "Lyrics",
171+ }
172+ }
173+
174+ /// The key context that is active while the screen is focused.
175+ pub fn context(self) -> Context {
176+ match self {
177+ Self::Queue => Context::Queue,
178+ Self::Browser => Context::Browser,
179+ Self::Playlists => Context::Playlists,
180+ Self::Library => Context::Library,
181+ Self::TagEditor => Context::TagEditor,
182+ Self::Visualizer => Context::Visualizer,
183+ Self::Lyrics => Context::Lyrics,
184+ }
185+ }
186+
187+ fn index(self) -> usize {
188+ Self::ALL.iter().position(|&s| s == self).unwrap_or(0)
189+ }
190+
191+ pub fn next(self) -> Self {
192+ Self::ALL[(self.index() + 1) % Self::ALL.len()]
193+ }
194+
195+ pub fn previous(self) -> Self {
196+ Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()]
197+ }
198+}
199+
200+impl Action {
201+ /// The contexts this action may be bound in.
202+ pub fn contexts(self) -> &'static [Context] {
203+ use Action::*;
204+ use Context as C;
205+ match self {
206+ Quit | Help | TogglePause | Pause | Stop | NextTrack | PreviousTrack | SeekForward
207+ | SeekBackward | VolumeUp | VolumeDown | ToggleMute | ToggleRepeat | ToggleRandom
208+ | ToggleSingle | ToggleConsume | UpdateDatabase | NextScreen | PreviousScreen
209+ | ScreenQueue | ScreenBrowser | ScreenPlaylists | ScreenLibrary | ScreenTagEditor
210+ | ScreenVisualizer | ScreenLyrics => &[C::Global],
211+ Up | Down | Top | Bottom | PageUp | PageDown | HalfPageUp | HalfPageDown | Left
212+ | Right | Activate | ToggleMark | ClearMarks | Search | SearchNext | SearchPrevious
213+ | AddToQueue | AddToPlaylist => &[C::Navigation],
214+ Delete | MoveItemUp | MoveItemDown => &[C::Queue, C::Playlists],
215+ ClearQueue | ShuffleQueue | SaveQueue | JumpToCurrent | ToggleLyricsPane => &[C::Queue],
216+ DeletePlaylist | RenamePlaylist => &[C::Playlists],
217+ ToggleArtistTag => &[C::Library],
218+ SaveTags | RevertTags => &[C::TagEditor],
219+ ReloadLyrics => &[C::Lyrics],
220+ Close => &[C::Popup],
221+ Submit | Cancel | Backspace | DeleteChar | DeleteWord | CursorLeft | CursorRight
222+ | CursorHome | CursorEnd => &[C::Input],
223+ }
224+ }
225+
226+ /// A short human-readable description for the help popup.
227+ pub fn description(self) -> &'static str {
228+ use Action::*;
229+ match self {
230+ Quit => "Quit rmptui",
231+ Help => "Show key bindings",
232+ TogglePause => "Toggle play/pause",
233+ Pause => "Pause",
234+ Stop => "Stop playback",
235+ NextTrack => "Play next track",
236+ PreviousTrack => "Play previous track",
237+ SeekForward => "Seek forward",
238+ SeekBackward => "Seek backward",
239+ VolumeUp => "Increase volume",
240+ VolumeDown => "Decrease volume",
241+ ToggleMute => "Toggle mute",
242+ ToggleRepeat => "Toggle repeat",
243+ ToggleRandom => "Toggle random",
244+ ToggleSingle => "Toggle single",
245+ ToggleConsume => "Toggle consume",
246+ UpdateDatabase => "Rescan the music database",
247+ NextScreen => "Next screen",
248+ PreviousScreen => "Previous screen",
249+ ScreenQueue => "Show the queue",
250+ ScreenBrowser => "Show the song browser",
251+ ScreenPlaylists => "Show the playlist editor",
252+ ScreenLibrary => "Show the media library",
253+ ScreenTagEditor => "Show the tag editor",
254+ ScreenVisualizer => "Show the visualizer",
255+ ScreenLyrics => "Show the lyrics",
256+ Up => "Move up",
257+ Down => "Move down",
258+ Top => "Go to top",
259+ Bottom => "Go to bottom",
260+ PageUp => "Page up",
261+ PageDown => "Page down",
262+ HalfPageUp => "Half page up",
263+ HalfPageDown => "Half page down",
264+ Left => "Go back / previous column",
265+ Right => "Open / next column",
266+ Activate => "Play or open the selected item",
267+ ToggleMark => "Mark or unmark the item",
268+ ClearMarks => "Clear all marks",
269+ Search => "Search the current list",
270+ SearchNext => "Next search match",
271+ SearchPrevious => "Previous search match",
272+ AddToQueue => "Add to (or toggle in) the queue",
273+ AddToPlaylist => "Add to a playlist",
274+ Delete => "Remove the item",
275+ MoveItemUp => "Move the item up",
276+ MoveItemDown => "Move the item down",
277+ ClearQueue => "Clear the queue",
278+ ShuffleQueue => "Shuffle the queue",
279+ SaveQueue => "Save the queue as a playlist",
280+ JumpToCurrent => "Jump to the playing song",
281+ ToggleLyricsPane => "Toggle the lyrics pane",
282+ DeletePlaylist => "Delete the playlist",
283+ RenamePlaylist => "Rename the playlist",
284+ ToggleArtistTag => "Switch between AlbumArtist and Artist",
285+ SaveTags => "Write pending tag changes",
286+ RevertTags => "Discard pending tag changes",
287+ ReloadLyrics => "Fetch lyrics again",
288+ Close => "Close the popup",
289+ Submit => "Submit",
290+ Cancel => "Cancel",
291+ Backspace => "Delete previous character",
292+ DeleteChar => "Delete next character",
293+ DeleteWord => "Delete previous word",
294+ CursorLeft => "Cursor left",
295+ CursorRight => "Cursor right",
296+ CursorHome => "Cursor to start",
297+ CursorEnd => "Cursor to end",
298+ }
299+ }
300+}
301+
302+#[cfg(test)]
303+mod tests {
304+ use super::*;
305+
306+ #[test]
307+ fn names_round_trip() {
308+ for action in Action::ALL {
309+ assert_eq!(action.name().parse::<Action>().unwrap(), *action);
310+ assert!(!action.contexts().is_empty());
311+ }
312+ for context in Context::ALL {
313+ assert_eq!(context.name().parse::<Context>().unwrap(), *context);
314+ }
315+ assert!("nope".parse::<Action>().is_err());
316+ }
317+}
D src/app.rs View
@@ -1,501 +0,0 @@
1−use std::time::Duration;
2−
3−use crate::{
4− browser::{BrowserEntry, FileBrowser},
5− config::Config,
6− connection::{Connection, MpdSnapshot, Song},
7− list::ContentList,
8− ui::InputMode,
9− utils::is_audio_file,
10− AppResult,
11−};
12−use ratatui::widgets::{ListState, TableState};
13−
14−#[derive(Debug)]
15−pub struct App {
16− pub running: bool,
17− pub conn: Connection,
18− pub config: Config,
19− pub browser: FileBrowser,
20− pub queue_list: ContentList<Song>,
21− pub pl_list: ContentList<String>,
22− pub playlist_focus: PlaylistFocus,
23− pub playlist_song_index: usize,
24− pub selected_tab: SelectedTab,
25− pub inputmode: InputMode,
26− pub search_input: String,
27− pub search_cursor_pos: usize,
28− pub browser_search_results: Vec<usize>,
29− pub browser_search_index: usize,
30− pub pl_newname_input: String,
31− pub pl_cursor_pos: usize,
32− pub pl_new_pl_input: String,
33− pub pl_new_pl_cursor_pos: usize,
34− pub pl_new_pl_songs_buffer: Vec<Song>,
35− pub playlist_popup: bool,
36− pub append_list: ContentList<String>,
37− pub should_update_song_list: bool,
38− pub queue_state: TableState,
39− pub browser_state: TableState,
40− pub playlists_state: ListState,
41− pub playlist_songs_state: TableState,
42− pub playlist_contents: Vec<Song>,
43−}
44−
45−#[derive(Debug, PartialEq, Clone, Copy)]
46−pub enum SelectedTab {
47− DirectoryBrowser,
48− Queue,
49− Playlists,
50−}
51−
52−#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
53−pub enum PlaylistFocus {
54− #[default]
55− List,
56− Songs,
57−}
58−
59−impl App {
60− pub async fn new(address: &str, password: Option<&str>, config: Config) -> AppResult<Self> {
61− let mut conn = Connection::connect(address, password).await?;
62− let mut queue_list = ContentList::new();
63− queue_list.list = conn.queue().await?;
64− let mut pl_list = ContentList::new();
65− pl_list.list = conn.playlists().await?;
66− pl_list.list.sort();
67− let append_list = Self::get_append_list(&mut conn).await?;
68− let mut browser = FileBrowser::new();
69− browser.update_directory(&mut conn).await?;
70−
71− if let Some(position) = conn.current_song.place {
72− queue_list.index = position.pos as usize;
73− }
74−
75− let mut app = Self {
76− running: true,
77− conn,
78− config,
79− browser,
80− queue_list,
81− pl_list,
82− playlist_focus: PlaylistFocus::List,
83− playlist_song_index: 0,
84− selected_tab: SelectedTab::Queue,
85− inputmode: InputMode::Normal,
86− search_input: String::new(),
87− search_cursor_pos: 0,
88− browser_search_results: Vec::new(),
89− browser_search_index: 0,
90− pl_newname_input: String::new(),
91− pl_cursor_pos: 0,
92− pl_new_pl_input: String::new(),
93− pl_new_pl_cursor_pos: 0,
94− pl_new_pl_songs_buffer: Vec::new(),
95− playlist_popup: false,
96− append_list,
97− should_update_song_list: false,
98− queue_state: TableState::default(),
99− browser_state: TableState::default(),
100− playlists_state: ListState::default(),
101− playlist_songs_state: TableState::default(),
102− playlist_contents: Vec::new(),
103− };
104− app.refresh_playlist_view().await?;
105− Ok(app)
106− }
107−
108− pub async fn apply_snapshot(&mut self, snapshot: MpdSnapshot) -> AppResult<()> {
109− let MpdSnapshot {
110− status,
111− stats,
112− current_song,
113− queue,
114− } = snapshot;
115− self.conn.apply_snapshot(status, stats, current_song);
116− self.queue_list.list = queue;
117− if self.queue_list.list.is_empty() {
118− self.queue_list.index = 0;
119− } else {
120− self.queue_list.index = self.queue_list.index.min(self.queue_list.list.len() - 1);
121− }
122−
123− if self.should_update_song_list && self.conn.status.updating_db.is_none() {
124− self.conn.refresh_song_list().await?;
125− self.browser.update_directory(&mut self.conn).await?;
126− self.should_update_song_list = false;
127− }
128− Ok(())
129− }
130−
131− pub fn quit(&mut self) {
132− self.running = false;
133− }
134−
135− pub async fn update_queue(&mut self) -> AppResult<()> {
136− self.queue_list.list = self.conn.queue().await?;
137− if self.queue_list.list.is_empty() {
138− self.queue_list.index = 0;
139− } else {
140− self.queue_list.index = self.queue_list.index.min(self.queue_list.list.len() - 1);
141− }
142− Ok(())
143− }
144−
145− pub async fn refresh_playlists(&mut self) -> AppResult<()> {
146− self.pl_list.list = self.conn.playlists().await?;
147− self.pl_list.list.sort();
148− self.pl_list.index = self
149− .pl_list
150− .index
151− .min(self.pl_list.list.len().saturating_sub(1));
152− self.append_list = Self::get_append_list(&mut self.conn).await?;
153− self.refresh_playlist_view().await
154− }
155−
156− pub async fn refresh_playlist_view(&mut self) -> AppResult<()> {
157− self.playlist_contents = match self.pl_list.list.get(self.pl_list.index) {
158− Some(name) => self.conn.playlist(name).await?,
159− None => Vec::new(),
160− };
161− self.playlist_song_index = self
162− .playlist_song_index
163− .min(self.playlist_contents.len().saturating_sub(1));
164− Ok(())
165− }
166−
167− pub async fn delete_selected_playlist_song(&mut self) -> AppResult<()> {
168− let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else {
169− return Ok(());
170− };
171− if self.playlist_song_index >= self.playlist_contents.len() {
172− return Ok(());
173− }
174− self.conn
175− .delete_playlist_song(playlist, self.playlist_song_index)
176− .await?;
177− self.refresh_playlist_view().await
178− }
179−
180− pub async fn move_selected_playlist_song(&mut self, down: bool) -> AppResult<()> {
181− let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else {
182− return Ok(());
183− };
184− let target = if down {
185− self.playlist_song_index.checked_add(1)
186− } else {
187− self.playlist_song_index.checked_sub(1)
188− };
189− let Some(target) = target.filter(|target| *target < self.playlist_contents.len()) else {
190− return Ok(());
191− };
192− self.conn
193− .move_playlist_song(playlist, self.playlist_song_index, target)
194− .await?;
195− self.playlist_song_index = target;
196− self.refresh_playlist_view().await
197− }
198−
199− pub async fn delete_selected_playlist(&mut self) -> AppResult<()> {
200− let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else {
201− return Ok(());
202− };
203− self.conn.delete_playlist(playlist).await?;
204− self.refresh_playlists().await?;
205− Ok(())
206− }
207−
208− pub async fn play_selected_playlist_song(&mut self) -> AppResult<()> {
209− let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else {
210− return Ok(());
211− };
212− if self.playlist_song_index >= self.playlist_contents.len() {
213− return Ok(());
214− }
215− let queue_position = self.conn.queue().await?.len();
216− self.conn.append_playlist(playlist).await?;
217− self.conn
218− .play(Some((queue_position + self.playlist_song_index) as u32))
219− .await?;
220− self.update_queue().await?;
221− self.conn.update_status().await
222− }
223−
224− pub async fn get_append_list(conn: &mut Connection) -> AppResult<ContentList<String>> {
225− let mut list = ContentList::new();
226− list.list.push("Current Playlist".to_string());
227− list.list.push("New Playlist".to_string());
228− list.list.extend(conn.playlists().await?);
229− Ok(list)
230− }
231−
232− pub async fn toggle_selected_item(&mut self) -> AppResult<()> {
233− match self.selected_tab {
234− SelectedTab::DirectoryBrowser => {
235− let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else {
236− return Ok(());
237− };
238− match entry {
239− BrowserEntry::Directory(name) => {
240− self.conn.add(&name).await?;
241− }
242− BrowserEntry::Song(song) => {
243− let positions: Vec<u32> = self
244− .queue_list
245− .list
246− .iter()
247− .enumerate()
248− .filter_map(|(index, queued)| {
249− (queued.file == song.file).then_some(index as u32)
250− })
251− .collect();
252− if positions.is_empty() {
253− self.conn.add(&song.file).await?;
254− } else {
255− for position in positions.into_iter().rev() {
256− self.conn.delete(position).await?;
257− }
258− }
259− }
260− }
261− if self.browser.selected < self.browser.entries.len().saturating_sub(1) {
262− self.browser.selected += 1;
263− }
264− }
265− SelectedTab::Queue => {
266− if !self.queue_list.list.is_empty() {
267− self.conn.delete(self.queue_list.index as u32).await?;
268− }
269− }
270− SelectedTab::Playlists => {}
271− }
272− self.update_queue().await?;
273− self.conn.update_status().await?;
274− Ok(())
275− }
276−
277− pub fn cycle_tabs(&mut self) {
278− self.selected_tab = match self.selected_tab {
279− SelectedTab::Queue => SelectedTab::DirectoryBrowser,
280− SelectedTab::DirectoryBrowser => SelectedTab::Playlists,
281− SelectedTab::Playlists => SelectedTab::Queue,
282− };
283− }
284−
285− pub async fn handle_enter(&mut self) -> AppResult<()> {
286− let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else {
287− return Ok(());
288− };
289− match entry {
290− BrowserEntry::Directory(name) => {
291− self.browser.prev_selected = self.browser.selected;
292− self.browser.path = name;
293− self.browser.selected = 0;
294− self.browser.update_directory(&mut self.conn).await?;
295− }
296− BrowserEntry::Song(song) => {
297− if let Some(index) = self
298− .queue_list
299− .list
300− .iter()
301− .position(|queued| queued.file == song.file)
302− {
303− self.conn.play(Some(index as u32)).await?;
304− } else {
305− self.conn.push(&song).await?;
306− self.update_queue().await?;
307− }
308− self.conn.update_status().await?;
309− }
310− }
311− Ok(())
312− }
313−
314− pub async fn append_selected_to(&mut self) -> AppResult<()> {
315− let Some(playlist) = self.append_list.list.get(self.append_list.index).cloned() else {
316− return Ok(());
317− };
318− let songs = match self.selected_tab {
319− SelectedTab::Queue => self
320− .queue_list
321− .list
322− .get(self.queue_list.index)
323− .cloned()
324− .into_iter()
325− .collect(),
326− SelectedTab::DirectoryBrowser => {
327− let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else {
328− return Ok(());
329− };
330− match entry {
331− BrowserEntry::Song(song) => vec![song],
332− BrowserEntry::Directory(name) => self
333− .conn
334− .list_files(&name)
335− .await?
336− .into_iter()
337− .filter_map(|(kind, file)| {
338− (kind == "file" && is_audio_file(&file))
339− .then(|| Connection::song_from_file(&file))
340− })
341− .collect(),
342− }
343− }
344− SelectedTab::Playlists => {
345− let Some(name) = self.pl_list.list.get(self.pl_list.index) else {
346− return Ok(());
347− };
348− self.conn.playlist(name).await?
349− }
350− };
351−
352− if playlist == "Current Playlist" {
353− if self.selected_tab == SelectedTab::Playlists {
354− if let Some(name) = self.pl_list.list.get(self.pl_list.index) {
355− self.conn.load_playlist(name).await?;
356− self.update_queue().await?;
357− }
358− } else {
359− let selected_directory = if self.selected_tab == SelectedTab::DirectoryBrowser {
360− self.browser
361− .entries
362− .get(self.browser.selected)
363− .and_then(|entry| {
364− if let BrowserEntry::Directory(path) = entry {
365− Some(path.as_str())
366− } else {
367− None
368− }
369− })
370− } else {
371− None
372− };
373− if let Some(path) = selected_directory {
374− self.conn.add(path).await?;
375− } else {
376− for song in songs {
377− self.conn.add(&song.file).await?;
378− }
379− }
380− self.update_queue().await?;
381− }
382− } else if playlist == "New Playlist" {
383− self.pl_new_pl_songs_buffer = songs;
384− self.inputmode = InputMode::NewPlaylist;
385− } else {
386− for song in songs {
387− self.conn.add_to_playlist(&playlist, &song).await?;
388− }
389− self.refresh_playlists().await?;
390− }
391− self.playlist_popup = false;
392− self.append_list.index = 0;
393− Ok(())
394− }
395−
396− pub fn move_cursor_left(&mut self) {
397− let cursor = self.cursor_position().saturating_sub(1);
398− self.set_cursor_position(cursor);
399− }
400−
401− pub fn move_cursor_right(&mut self) {
402− let cursor = self.cursor_position().saturating_add(1);
403− self.set_cursor_position(cursor);
404− }
405−
406− fn cursor_position(&self) -> usize {
407− match self.inputmode {
408− InputMode::PlaylistRename => self.pl_cursor_pos,
409− InputMode::Editing => self.search_cursor_pos,
410− InputMode::NewPlaylist => self.pl_new_pl_cursor_pos,
411− InputMode::Normal => 0,
412− }
413− }
414−
415− fn set_cursor_position(&mut self, position: usize) {
416− let length = match self.inputmode {
417− InputMode::PlaylistRename => self.pl_newname_input.chars().count(),
418− InputMode::Editing => self.search_input.chars().count(),
419− InputMode::NewPlaylist => self.pl_new_pl_input.chars().count(),
420− InputMode::Normal => 0,
421− };
422− let position = position.min(length);
423− match self.inputmode {
424− InputMode::PlaylistRename => self.pl_cursor_pos = position,
425− InputMode::Editing => self.search_cursor_pos = position,
426− InputMode::NewPlaylist => self.pl_new_pl_cursor_pos = position,
427− InputMode::Normal => {}
428− }
429− }
430−
431− pub fn enter_char(&mut self, character: char) {
432− let (text, cursor) = match self.inputmode {
433− InputMode::PlaylistRename => (&mut self.pl_newname_input, self.pl_cursor_pos),
434− InputMode::Editing => (&mut self.search_input, self.search_cursor_pos),
435− InputMode::NewPlaylist => (&mut self.pl_new_pl_input, self.pl_new_pl_cursor_pos),
436− InputMode::Normal => return,
437− };
438− let byte_index = text
439− .char_indices()
440− .nth(cursor)
441− .map(|(index, _)| index)
442− .unwrap_or(text.len());
443− text.insert(byte_index, character);
444− self.move_cursor_right();
445− }
446−
447− pub fn delete_char(&mut self) {
448− let cursor = self.cursor_position();
449− if cursor == 0 {
450− return;
451− }
452− let text = match self.inputmode {
453− InputMode::PlaylistRename => &mut self.pl_newname_input,
454− InputMode::Editing => &mut self.search_input,
455− InputMode::NewPlaylist => &mut self.pl_new_pl_input,
456− InputMode::Normal => return,
457− };
458− let start = text.char_indices().nth(cursor - 1).map(|(i, _)| i);
459− let end = text
460− .char_indices()
461− .nth(cursor)
462− .map(|(i, _)| i)
463− .unwrap_or(text.len());
464− if let Some(start) = start {
465− text.replace_range(start..end, "");
466− self.move_cursor_left();
467− }
468− }
469−
470− pub fn reset_cursor(&mut self) {
471− self.set_cursor_position(0);
472− }
473−
474− pub fn format_time(time: Duration) -> String {
475− let seconds = time.as_secs();
476− let hours = seconds / 3600;
477− let minutes = (seconds % 3600) / 60;
478− let seconds = seconds % 60;
479− if hours == 0 {
480− format!("{minutes:02}:{seconds:02}")
481− } else {
482− format!("{hours:02}:{minutes:02}:{seconds:02}")
483− }
484− }
485−
486− pub fn handle_scroll_up(&mut self) {
487− match self.selected_tab {
488− SelectedTab::Queue => self.queue_list.prev(),
489− SelectedTab::DirectoryBrowser => self.browser.prev(),
490− SelectedTab::Playlists => self.pl_list.prev(),
491− }
492− }
493−
494− pub fn handle_scroll_down(&mut self) {
495− match self.selected_tab {
496− SelectedTab::Queue => self.queue_list.next(),
497− SelectedTab::DirectoryBrowser => self.browser.next(),
498− SelectedTab::Playlists => self.pl_list.next(),
499− }
500− }
501−}
D src/browser.rs View
@@ -1,164 +0,0 @@
1−use std::path::Path;
2−
3−use crate::{
4− connection::{Connection, Song},
5− utils::is_audio_file,
6− AppResult,
7−};
8−
9−#[derive(Debug, Default)]
10−pub struct FileBrowser {
11− pub entries: Vec<BrowserEntry>,
12− pub selected: usize,
13− pub prev_selected: usize,
14− pub path: String,
15−}
16−
17−#[derive(Clone, Debug, PartialEq, Eq)]
18−pub enum BrowserEntry {
19− Directory(String),
20− Song(Song),
21−}
22−
23−impl BrowserEntry {
24− pub fn name(&self) -> &str {
25− match self {
26− Self::Directory(path) => Path::new(path)
27− .file_name()
28− .and_then(|name| name.to_str())
29− .unwrap_or(path),
30− Self::Song(song) => Path::new(&song.file)
31− .file_name()
32− .and_then(|name| name.to_str())
33− .unwrap_or(&song.file),
34− }
35− }
36−}
37−
38−impl FileBrowser {
39− pub fn new() -> Self {
40− Self {
41− path: ".".to_string(),
42− ..Self::default()
43− }
44− }
45−
46− pub async fn update_directory(&mut self, conn: &mut Connection) -> AppResult<()> {
47− let query_path = self.path.trim_start_matches("./").trim_matches('/');
48− let (directories, songs) = conn.ls_info(query_path).await?;
49− let mut entries = Vec::new();
50− for directory in directories {
51− if let Some(name) = Path::new(&directory)
52− .file_name()
53− .and_then(|name| name.to_str())
54− {
55− if !name.starts_with('.') {
56− entries.push(BrowserEntry::Directory(directory));
57− }
58− }
59− }
60−
61− for song in songs {
62− let Some(name) = Path::new(&song.file)
63− .file_name()
64− .and_then(|name| name.to_str())
65− else {
66− continue;
67− };
68− if is_audio_file(name) {
69− entries.push(BrowserEntry::Song(song));
70− }
71− }
72−
73− entries.sort_by(|a, b| {
74− let kind_order = |entry: &BrowserEntry| match entry {
75− BrowserEntry::Directory(_) => 0,
76− BrowserEntry::Song(_) => 1,
77− };
78− kind_order(a).cmp(&kind_order(b)).then_with(|| {
79− let name_a = a.name();
80− let name_b = b.name();
81− let number_a = name_a.parse::<u32>().unwrap_or(u32::MAX);
82− let number_b = name_b.parse::<u32>().unwrap_or(u32::MAX);
83− number_a
84− .cmp(&number_b)
85− .then_with(|| name_a.to_lowercase().cmp(&name_b.to_lowercase()))
86− })
87− });
88−
89− self.entries = entries;
90− self.selected = self.selected.min(self.entries.len().saturating_sub(1));
91− Ok(())
92− }
93−
94− pub fn next(&mut self) {
95− if self.selected < self.entries.len().saturating_sub(1) {
96− self.selected += 1;
97− }
98− }
99−
100− pub fn prev(&mut self) {
101− self.selected = self.selected.saturating_sub(1);
102− }
103−
104− pub async fn handle_go_back(&mut self, conn: &mut Connection) -> AppResult<()> {
105− if self.path != "." {
106− self.path = Path::new(&self.path)
107− .parent()
108− .and_then(Path::to_str)
109− .filter(|path| !path.is_empty())
110− .unwrap_or(".")
111− .to_string();
112− self.update_directory(conn).await?;
113− }
114− self.selected = self.prev_selected.min(self.entries.len().saturating_sub(1));
115− Ok(())
116− }
117−}
118−
119−#[cfg(test)]
120−mod tests {
121− use super::*;
122− use tokio::{
123− io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
124− net::TcpListener,
125− };
126−
127− #[tokio::test]
128− async fn preserves_mpd_directory_uris_and_displays_their_basename() {
129− let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
130− let address = listener.local_addr().unwrap();
131− let server = tokio::spawn(async move {
132− let (stream, _) = listener.accept().await.unwrap();
133− let mut stream = BufReader::new(stream);
134− stream
135− .get_mut()
136− .write_all(b"OK MPD 0.23.0\n")
137− .await
138− .unwrap();
139−
140− for response in ["OK\n", "OK\n", "OK\n", "directory: library/Artist\nOK\n"] {
141− let mut command = String::new();
142− stream.read_line(&mut command).await.unwrap();
143− stream
144− .get_mut()
145− .write_all(response.as_bytes())
146− .await
147− .unwrap();
148− }
149− });
150−
151− let mut conn = Connection::connect(&address.to_string(), None)
152− .await
153− .unwrap();
154− let mut browser = FileBrowser::new();
155− browser.update_directory(&mut conn).await.unwrap();
156−
157− assert_eq!(
158− browser.entries,
159− vec![BrowserEntry::Directory("library/Artist".to_string())]
160− );
161− assert_eq!(browser.entries[0].name(), "Artist");
162− server.await.unwrap();
163− }
164−}
D src/config.rs View
@@ -1,669 +0,0 @@
1−use std::{
2− collections::BTreeMap,
3− env,
4− error::Error,
5− fs::{self, OpenOptions},
6− io::{self, Write},
7− path::PathBuf,
8−};
9−
10−use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11−use serde::{Deserialize, Serialize};
12−
13−use crate::AppResult;
14−
15−#[derive(Clone, Debug, Default, Deserialize, Serialize)]
16−#[serde(default)]
17−pub struct Config {
18− pub mpd: MpdConfig,
19− pub playback: PlaybackConfig,
20− pub keybindings: Keybindings,
21−}
22−
23−#[derive(Clone, Debug, Deserialize, Serialize)]
24−#[serde(default)]
25−pub struct MpdConfig {
26− pub host: String,
27− pub port: u16,
28− pub password: String,
29−}
30−
31−impl Default for MpdConfig {
32− fn default() -> Self {
33− Self {
34− host: "127.0.0.1".to_string(),
35− port: 6600,
36− password: String::new(),
37− }
38− }
39−}
40−
41−#[derive(Clone, Debug, Deserialize, Serialize)]
42−#[serde(default)]
43−pub struct PlaybackConfig {
44− pub seek_seconds: u64,
45− pub volume_step: u8,
46−}
47−
48−impl Default for PlaybackConfig {
49− fn default() -> Self {
50− Self {
51− seek_seconds: 2,
52− volume_step: 2,
53− }
54− }
55−}
56−
57−#[derive(Clone, Debug, Deserialize, Serialize)]
58−#[serde(default)]
59−pub struct Keybindings {
60− #[serde(flatten)]
61− bindings: BTreeMap<String, Vec<String>>,
62−}
63−
64−impl Default for Keybindings {
65− fn default() -> Self {
66− let defaults: &[(&str, &[&str])] = &[
67− ("quit", &["q", "Ctrl+c"]),
68− ("clear_queue", &["c", "C"]),
69− ("toggle_pause", &["p"]),
70− ("pause", &["s"]),
71− ("toggle_repeat", &["r"]),
72− ("toggle_random", &["z"]),
73− ("open_playlist_menu", &["a"]),
74− ("seek_forward", &["f"]),
75− ("seek_backward", &["b"]),
76− ("cycle_tabs", &["Tab"]),
77− ("select_queue", &["1"]),
78− ("select_browser", &["2"]),
79− ("select_playlists", &["3"]),
80− ("next_track", &[">"]),
81− ("previous_track", &["<"]),
82− ("volume_up", &["=", "+"]),
83− ("volume_down", &["-"]),
84− ("toggle_mute", &["m"]),
85− ("update_database", &["U"]),
86− ("search", &["/"]),
87− ("toggle_selected_item", &["Space"]),
88− ("search_next", &["n"]),
89− ("search_previous", &["N"]),
90− ("move_down", &["j", "Down"]),
91− ("move_up", &["k", "Up"]),
92− ("activate", &["Enter", "l", "Right"]),
93− ("go_back", &["h", "Left"]),
94− ("go_to_top", &["g"]),
95− ("go_to_bottom", &["G"]),
96− ("queue_delete", &["d"]),
97− ("queue_move_down", &["J"]),
98− ("queue_move_up", &["K"]),
99− ("playlist_rename", &["R"]),
100− ("playlist_focus_contents", &["Right", "l"]),
101− ("playlist_focus_list", &["Left", "h"]),
102− ("playlist_delete_song", &["d"]),
103− ("playlist_move_song_down", &["J"]),
104− ("playlist_move_song_up", &["K"]),
105− ("playlist_delete", &["D"]),
106− ("playlist_load", &["Space", "Enter"]),
107− ("popup_close", &["q", "Esc"]),
108− ("popup_select", &["Enter"]),
109− ("input_cancel", &["Esc"]),
110− ("input_backspace", &["Backspace"]),
111− ("input_cursor_left", &["Left"]),
112− ("input_cursor_right", &["Right"]),
113− ("input_submit", &["Enter"]),
114− ];
115− Self {
116− bindings: defaults
117− .iter()
118− .map(|(action, keys)| {
119− (
120− (*action).to_string(),
121− keys.iter().map(|key| (*key).to_string()).collect(),
122− )
123− })
124− .collect(),
125− }
126− }
127−}
128−
129−impl Keybindings {
130− pub fn matches(&self, action: &str, key: KeyEvent) -> bool {
131− self.bindings.get(action).is_some_and(|keys| {
132− keys.iter()
133− .any(|spec| parse_key_spec(spec).is_ok_and(|binding| key_matches(binding, key)))
134− })
135− }
136−
137− fn validate(&self) -> AppResult<()> {
138− let defaults = Self::default();
139− for (action, keys) in &self.bindings {
140− if !defaults.bindings.contains_key(action) {
141− return Err(invalid_config(&format!(
142− "keybindings contains unknown action `{action}`"
143− )));
144− }
145− for key in keys {
146− parse_key_spec(key).map_err(|error| {
147− invalid_config(&format!(
148− "invalid keybinding `{key}` for `{action}`: {error}"
149− ))
150− })?;
151− }
152− }
153− Ok(())
154− }
155−
156− fn apply_missing_defaults(&mut self) {
157− let defaults = Self::default().bindings;
158− for (action, keys) in &defaults {
159− if self
160− .bindings
161− .get(action)
162− .is_some_and(|current| current == &vec!["Right".to_string()])
163− && action == "playlist_focus_contents"
164− {
165− self.bindings.insert(action.clone(), keys.clone());
166− }
167− if self
168− .bindings
169− .get(action)
170− .is_some_and(|current| current == &vec!["Left".to_string()])
171− && action == "playlist_focus_list"
172− {
173− self.bindings.insert(action.clone(), keys.clone());
174− }
175− }
176− for (action, keys) in defaults {
177− self.bindings.entry(action).or_insert(keys);
178− }
179− }
180−}
181−
182−impl Config {
183− pub fn load_or_create() -> AppResult<(Self, PathBuf)> {
184− let path = config_path()?;
185− let config = Self::load_or_create_at(&path)?;
186− Ok((config, path))
187− }
188−
189− fn load_or_create_at(path: &std::path::Path) -> AppResult<Self> {
190− if let Some(parent) = path.parent() {
191− fs::create_dir_all(parent)?;
192− }
193−
194− let contents = match fs::read_to_string(path) {
195− Ok(contents) => contents,
196− Err(error) if error.kind() == io::ErrorKind::NotFound => {
197− let default_contents = default_config_text()?;
198− match create_config_file(path, default_contents.as_bytes()) {
199− Ok(()) => default_contents,
200− Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
201− fs::read_to_string(path)?
202− }
203− Err(error) => return Err(Box::new(error)),
204− }
205− }
206− Err(error) => return Err(Box::new(error)),
207− };
208−
209− let mut config: Self = toml::from_str(&contents)?;
210− config.keybindings.apply_missing_defaults();
211− config.validate()?;
212− Ok(config)
213− }
214−
215− fn validate(&self) -> AppResult<()> {
216− if self.mpd.host.trim().is_empty() {
217− return Err(invalid_config("mpd.host cannot be empty"));
218− }
219− if self.mpd.port == 0 {
220− return Err(invalid_config("mpd.port must be between 1 and 65535"));
221− }
222− if self.playback.seek_seconds == 0 {
223− return Err(invalid_config(
224− "playback.seek_seconds must be greater than zero",
225− ));
226− }
227− if !(1..=100).contains(&self.playback.volume_step) {
228− return Err(invalid_config(
229− "playback.volume_step must be between 1 and 100",
230− ));
231− }
232− self.keybindings.validate()
233− }
234−}
235−
236−fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> {
237− if spec == "+" {
238− return Ok(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty()));
239− }
240− let mut modifiers = KeyModifiers::empty();
241− let mut base = spec;
242− while !base.starts_with("Char(") {
243− let Some((modifier, rest)) = base.split_once('+') else {
244− break;
245− };
246− match modifier.to_ascii_lowercase().as_str() {
247− "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
248− "alt" => modifiers |= KeyModifiers::ALT,
249− "shift" => modifiers |= KeyModifiers::SHIFT,
250− _ => return Err(format!("unknown modifier `{modifier}`")),
251− }
252− base = rest;
253− }
254− if base.is_empty() {
255− return Err("missing key".into());
256− }
257− let lowered_base = base.to_ascii_lowercase();
258− let code = if let Some(number) = lowered_base
259− .strip_prefix('f')
260− .and_then(|number| number.parse::<u8>().ok())
261− .filter(|number| (1..=24).contains(number))
262− {
263− KeyCode::F(number)
264− } else {
265− match lowered_base.as_str() {
266− "esc" | "escape" => KeyCode::Esc,
267− "enter" | "return" => KeyCode::Enter,
268− "tab" => KeyCode::Tab,
269− "backtab" => KeyCode::BackTab,
270− "backspace" => KeyCode::Backspace,
271− "delete" | "del" => KeyCode::Delete,
272− "insert" => KeyCode::Insert,
273− "up" => KeyCode::Up,
274− "down" => KeyCode::Down,
275− "left" => KeyCode::Left,
276− "right" => KeyCode::Right,
277− "home" => KeyCode::Home,
278− "end" => KeyCode::End,
279− "pageup" => KeyCode::PageUp,
280− "pagedown" => KeyCode::PageDown,
281− "space" => KeyCode::Char(' '),
282− _ if lowered_base.starts_with("char(") && base.ends_with(')') => {
283− let inner = &base[5..base.len() - 1];
284− let mut chars = inner.chars();
285− let character = chars
286− .next()
287− .ok_or_else(|| "empty Char(...) key".to_string())?;
288− if chars.next().is_some() {
289− return Err("Char(...) must contain exactly one character".into());
290− }
291− KeyCode::Char(character)
292− }
293− _ => {
294− let mut chars = base.chars();
295− let character = chars.next().ok_or_else(|| "missing key".to_string())?;
296− if chars.next().is_some() {
297− return Err(format!("unknown key `{base}`"));
298− }
299− KeyCode::Char(character)
300− }
301− }
302− };
303− Ok(KeyEvent::new(code, modifiers))
304−}
305−
306−fn key_matches(binding: KeyEvent, event: KeyEvent) -> bool {
307− if binding.code != event.code {
308− return false;
309− }
310− let mut event_modifiers = event.modifiers;
311− if !binding.modifiers.contains(KeyModifiers::SHIFT) {
312− event_modifiers.remove(KeyModifiers::SHIFT);
313− }
314− binding.modifiers == event_modifiers
315−}
316−
317−pub fn config_path() -> io::Result<PathBuf> {
318− let base = env::var_os("CONFIG_HOME")
319− .or_else(|| env::var_os("XDG_CONFIG_HOME"))
320− .map(PathBuf::from)
321− .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
322− .ok_or_else(|| {
323− io::Error::new(
324− io::ErrorKind::NotFound,
325− "CONFIG_HOME, XDG_CONFIG_HOME, and HOME are all unset",
326− )
327− })?;
328− if base.as_os_str().is_empty() {
329− return Err(io::Error::new(
330− io::ErrorKind::InvalidInput,
331− "configuration home environment variable cannot be empty",
332− ));
333− }
334− Ok(base.join("rmptui").join("config.toml"))
335−}
336−
337−fn default_config_text() -> AppResult<String> {
338− let serialized = toml::to_string_pretty(&Config::default())?;
339− let mut documented = String::from(
340− "# rmptui configuration. Values below are the built-in defaults.\n\
341− # Save changes and restart rmptui for them to take effect.\n\
342− #\n\
343− # Config file location: $CONFIG_HOME/rmptui/config.toml, falling back to\n\
344− # $XDG_CONFIG_HOME/rmptui/config.toml, then $HOME/.config/rmptui/config.toml.\n\
345− #\n\
346− # Environment overrides:\n\
347− # MPD_HOST overrides [mpd].host. It may include a port (host:port),\n\
348− # or credentials as password@host. A port embedded in MPD_HOST wins\n\
349− # over MPD_PORT.\n\
350− # MPD_PORT overrides [mpd].port when MPD_HOST has no embedded port.\n\
351− #\n\
352− # Key names may be characters or named keys (Enter, Esc, Tab, Space,\n\
353− # arrows, Backspace, F1-F24). Prefix modifiers with Ctrl+, Alt+, or\n\
354− # Shift+. Use arrays for aliases and [] to disable an action.\n\
355− # The password is stored as plain text; this file is created with\n\
356− # owner-only permissions on Unix.\n\n",
357− );
358−
359− let mut section = "";
360− for line in serialized.lines() {
361− if line.starts_with('[') && line.ends_with(']') {
362− section = &line[1..line.len() - 1];
363− let description = match section {
364− "mpd" => "# MPD server connection settings.",
365− "playback" => "# Playback adjustment amounts.",
366− "keybindings" => "# Action keys, matched only in relevant UI contexts.",
367− _ => "# Configuration options.",
368− };
369− documented.push_str(description);
370− documented.push('\n');
371− documented.push_str(line);
372− documented.push('\n');
373− continue;
374− }
375−
376− if let Some((key, _)) = line.split_once(" = ") {
377− let description = option_description(section, key);
378− documented.push_str("# ");
379− documented.push_str(description);
380− documented.push('\n');
381− }
382− documented.push_str(line);
383− documented.push('\n');
384− }
385−
386− Ok(documented)
387−}
388−
389−fn option_description(section: &str, key: &str) -> &'static str {
390− match (section, key) {
391− ("mpd", "host") => "MPD hostname or address. MPD_HOST overrides this value.",
392− ("mpd", "port") => "MPD TCP port. MPD_PORT overrides it unless MPD_HOST includes a port.",
393− ("mpd", "password") => {
394− "Optional MPD password; leave empty when the server has no password."
395− }
396− ("playback", "seek_seconds") => "Seconds moved by seek_forward and seek_backward.",
397− ("playback", "volume_step") => {
398− "Volume percentage changed by volume_up and volume_down (1-100)."
399− }
400− ("keybindings", "quit") => "Exit rmptui.",
401− ("keybindings", "clear_queue") => "Remove every song from the current play queue.",
402− ("keybindings", "toggle_pause") => "Toggle playback between paused and playing.",
403− ("keybindings", "pause") => "Pause playback.",
404− ("keybindings", "toggle_repeat") => "Toggle repeat mode.",
405− ("keybindings", "toggle_random") => "Toggle random playback mode.",
406− ("keybindings", "open_playlist_menu") => {
407− "Open the menu for adding the selection to a playlist."
408− }
409− ("keybindings", "seek_forward") => "Seek forward by playback.seek_seconds.",
410− ("keybindings", "seek_backward") => "Seek backward by playback.seek_seconds.",
411− ("keybindings", "cycle_tabs") => "Cycle through Queue, Browser, and Playlists views.",
412− ("keybindings", "select_queue") => "Open the Queue view.",
413− ("keybindings", "select_browser") => "Open the Browser view.",
414− ("keybindings", "select_playlists") => "Open the Playlists view.",
415− ("keybindings", "next_track") => "Start the next track in the queue.",
416− ("keybindings", "previous_track") => "Start the previous track in the queue.",
417− ("keybindings", "volume_up") => "Increase volume by playback.volume_step.",
418− ("keybindings", "volume_down") => "Decrease volume by playback.volume_step.",
419− ("keybindings", "toggle_mute") => "Toggle mute.",
420− ("keybindings", "update_database") => "Ask MPD to rescan its music database.",
421− ("keybindings", "search") => "Start fuzzy search in the current list.",
422− ("keybindings", "toggle_selected_item") => {
423− "Toggle the selected song in the queue or browser."
424− }
425− ("keybindings", "search_next") => "Select the next browser fuzzy-search match.",
426− ("keybindings", "search_previous") => "Select the previous browser fuzzy-search match.",
427− ("keybindings", "move_down") => "Move selection down in the focused list.",
428− ("keybindings", "move_up") => "Move selection up in the focused list.",
429− ("keybindings", "activate") => {
430− "Activate the focused item (for example, play a song or enter a directory)."
431− }
432− ("keybindings", "go_back") => "Go to the parent directory in the Browser view.",
433− ("keybindings", "go_to_top") => "Select the first item in the focused list.",
434− ("keybindings", "go_to_bottom") => "Select the last item in the focused list.",
435− ("keybindings", "queue_delete") => "Remove the selected song from the current queue.",
436− ("keybindings", "queue_move_down") => "Move the selected queue song down one position.",
437− ("keybindings", "queue_move_up") => "Move the selected queue song up one position.",
438− ("keybindings", "playlist_rename") => "Rename the selected saved playlist.",
439− ("keybindings", "playlist_focus_contents") => {
440− "Focus the selected playlist's song contents."
441− }
442− ("keybindings", "playlist_focus_list") => "Return focus to the saved-playlist list.",
443− ("keybindings", "playlist_delete_song") => {
444− "Delete the selected song from the saved playlist."
445− }
446− ("keybindings", "playlist_move_song_down") => {
447− "Move the selected saved-playlist song down one position."
448− }
449− ("keybindings", "playlist_move_song_up") => {
450− "Move the selected saved-playlist song up one position."
451− }
452− ("keybindings", "playlist_delete") => "Delete the selected saved playlist.",
453− ("keybindings", "playlist_load") => {
454− "Append the selected saved playlist to the queue and start playback."
455− }
456− ("keybindings", "popup_close") => "Close the add-to-playlist menu.",
457− ("keybindings", "popup_select") => {
458− "Choose the selected destination in the add-to-playlist menu."
459− }
460− ("keybindings", "input_cancel") => "Cancel the active search or playlist-name input.",
461− ("keybindings", "input_backspace") => "Delete the character before the input cursor.",
462− ("keybindings", "input_cursor_left") => "Move the input cursor left.",
463− ("keybindings", "input_cursor_right") => "Move the input cursor right.",
464− ("keybindings", "input_submit") => "Submit the active search or playlist-name input.",
465− _ => "Key aliases for this action.",
466− }
467−}
468−
469−fn create_config_file(path: &std::path::Path, contents: &[u8]) -> io::Result<()> {
470− let mut options = OpenOptions::new();
471− options.write(true).create_new(true);
472− #[cfg(unix)]
473− {
474− use std::os::unix::fs::OpenOptionsExt;
475− options.mode(0o600);
476− }
477− let mut file = options.open(path)?;
478− file.write_all(contents)
479−}
480−
481−fn invalid_config(message: &str) -> Box<dyn Error + Send + Sync> {
482− Box::new(io::Error::new(
483− io::ErrorKind::InvalidData,
484− message.to_string(),
485− ))
486−}
487−
488−#[cfg(test)]
489−mod tests {
490− use super::*;
491−
492− #[test]
493− fn default_config_round_trips_with_all_sections() {
494− let config = Config::default();
495− let serialized = toml::to_string_pretty(&config).unwrap();
496− let parsed: Config = toml::from_str(&serialized).unwrap();
497−
498− assert_eq!(parsed.mpd.host, "127.0.0.1");
499− assert_eq!(parsed.mpd.port, 6600);
500− assert_eq!(parsed.playback.seek_seconds, 2);
501− assert_eq!(parsed.playback.volume_step, 2);
502− assert!(parsed.keybindings.matches(
503− "quit",
504− KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())
505− ));
506− assert!(parsed.keybindings.matches(
507− "quit",
508− KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
509− ));
510− }
511−
512− #[test]
513− fn generated_default_config_is_toml_and_has_helpful_comments() {
514− let contents = default_config_text().unwrap();
515− let parsed: Config = toml::from_str(&contents).unwrap();
516− assert_eq!(parsed.mpd.host, "127.0.0.1");
517− assert!(contents.starts_with("# rmptui configuration."));
518− assert!(contents.contains("password = \"\""));
519− assert!(contents.contains("[keybindings]"));
520− assert!(contents.contains("input_submit = [\"Enter\"]"));
521− assert!(contents.contains("MPD_HOST overrides [mpd].host"));
522− assert!(contents.contains("MPD_PORT overrides"));
523− assert!(contents.contains("# Focus the selected playlist's song contents."));
524− assert!(contents
525− .contains("# Append the selected saved playlist to the queue and start playback."));
526− assert!(contents.contains("playlist_focus_contents = ["));
527− for action in Keybindings::default().bindings.keys() {
528− assert_ne!(
529− option_description("keybindings", action),
530− "Key aliases for this action.",
531− "missing generated-config documentation for `{action}`"
532− );
533− }
534− }
535−
536− #[test]
537− fn creates_default_config_and_reads_user_changes() {
538− let unique = format!(
539− "rmptui-config-test-{}-{}",
540− std::process::id(),
541− std::time::SystemTime::now()
542− .duration_since(std::time::UNIX_EPOCH)
543− .unwrap()
544− .as_nanos()
545− );
546− let directory = env::temp_dir().join(unique);
547− let path = directory.join("rmptui").join("config.toml");
548−
549− let created = Config::load_or_create_at(&path).unwrap();
550− assert_eq!(created.playback.volume_step, 2);
551− let contents = fs::read_to_string(&path).unwrap();
552− assert!(contents.contains("password = \"\""));
553−
554− fs::write(
555− &path,
556− contents.replace("volume_step = 2", "volume_step = 8"),
557− )
558− .unwrap();
559− let loaded = Config::load_or_create_at(&path).unwrap();
560− assert_eq!(loaded.playback.volume_step, 8);
561−
562− fs::remove_dir_all(directory).unwrap();
563− }
564−
565− #[test]
566− fn partial_configuration_uses_defaults_for_missing_values() {
567− let parsed: Config = toml::from_str(
568− r#"
569− [mpd]
570− host = "music.local"
571− port = 6601
572− "#,
573− )
574− .unwrap();
575−
576− assert_eq!(parsed.mpd.host, "music.local");
577− assert_eq!(parsed.mpd.port, 6601);
578− assert_eq!(parsed.playback.seek_seconds, 2);
579− assert_eq!(
580− parsed.keybindings.bindings.len(),
581− Keybindings::default().bindings.len()
582− );
583− }
584−
585− #[test]
586− fn keybinding_parser_supports_aliases_modifiers_and_punctuation() {
587− let bindings = Keybindings::default();
588− assert!(bindings.matches(
589− "volume_up",
590− KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty())
591− ));
592− assert!(bindings.matches(
593− "move_down",
594− KeyEvent::new(KeyCode::Down, KeyModifiers::empty())
595− ));
596− assert!(bindings.matches(
597− "quit",
598− KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
599− ));
600− assert!(bindings.matches(
601− "search_previous",
602− KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT)
603− ));
604− assert!(parse_key_spec("Ctrl+Char(+)").is_ok());
605− assert_eq!(parse_key_spec("F12").unwrap().code, KeyCode::F(12));
606− }
607−
608− #[test]
609− fn partial_keybinding_configuration_keeps_other_defaults() {
610− let mut parsed: Config = toml::from_str(
611− r#"
612− [keybindings]
613− quit = ["x"]
614− "#,
615− )
616− .unwrap();
617− parsed.keybindings.apply_missing_defaults();
618−
619− assert!(parsed.keybindings.matches(
620− "quit",
621− KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())
622− ));
623− assert!(parsed.keybindings.matches(
624− "move_down",
625− KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty())
626− ));
627− }
628−
629− #[test]
630− fn playlist_focus_defaults_migrate_with_existing_default_config() {
631− let mut parsed: Config = toml::from_str(
632− r#"
633− [keybindings]
634− playlist_focus_contents = ["Right"]
635− playlist_focus_list = ["Left"]
636− "#,
637− )
638− .unwrap();
639− parsed.keybindings.apply_missing_defaults();
640−
641− assert_eq!(
642− parsed.keybindings.bindings["playlist_focus_contents"],
643− vec!["Right", "l"]
644− );
645− assert_eq!(
646− parsed.keybindings.bindings["playlist_focus_list"],
647− vec!["Left", "h"]
648− );
649− assert_eq!(
650− parsed.keybindings.bindings["playlist_load"],
651− vec!["Space", "Enter"]
652− );
653− }
654−
655− #[test]
656− fn invalid_keybinding_actions_and_keys_are_rejected() {
657− let mut bindings = Keybindings::default();
658− bindings
659− .bindings
660− .insert("unknown_action".to_string(), vec!["x".to_string()]);
661− assert!(bindings.validate().is_err());
662−
663− let mut bindings = Keybindings::default();
664− bindings
665− .bindings
666− .insert("quit".to_string(), vec!["not-a-key".to_string()]);
667− assert!(bindings.validate().is_err());
668− }
669−}
A src/config/default.toml View
@@ -0,0 +1,199 @@
1+# rmptui configuration
2+#
3+# Every option is shown with its default value. Settings you leave out keep
4+# their defaults, so you can trim this file down to the parts you change.
5+# Restart rmptui to apply changes.
6+
7+[mpd]
8+# Hostname, IP address, or the path of MPD's Unix socket (starting with `/`
9+# or `~`). Overridden by the MPD_HOST environment variable, which also accepts
10+# `password@host`.
11+host = "127.0.0.1"
12+# Overridden by MPD_PORT. Ignored for Unix sockets.
13+port = 6600
14+# Set this only if your MPD server requires a password. It is stored in plain
15+# text; rmptui creates this file readable only by you.
16+password = ""
17+# The music directory on this machine, required by the tag editor and for
18+# `.lrc` files stored next to songs. Leave empty to ask MPD, which only answers
19+# over a Unix socket.
20+music_directory = ""
21+
22+[playback]
23+# Seconds moved by seek_forward / seek_backward.
24+seek_seconds = 5
25+# Percent changed by volume_up / volume_down.
26+volume_step = 5
27+
28+[ui]
29+# Screen shown on startup: queue, browser, playlists, library, tag_editor,
30+# visualizer or lyrics.
31+startup_screen = "queue"
32+# Enable mouse support (click to select, wheel to scroll, click the progress
33+# bar to seek, click a tab to switch).
34+mouse = true
35+# Show the lyrics pane next to the queue on startup (toggle with `L`).
36+lyrics_pane = false
37+# Width of the lyrics pane in percent of the screen.
38+lyrics_pane_width = 40
39+# Seconds a status message stays visible.
40+message_timeout = 4
41+# Queue columns. `field` is one of artist, album_artist, title, album, track,
42+# disc, date, genre, duration or file; `width` is a relative weight.
43+queue_columns = [
44+ { field = "artist", width = 25 },
45+ { field = "track", width = 5 },
46+ { field = "title", width = 35 },
47+ { field = "album", width = 30 },
48+ { field = "duration", width = 7 },
49+]
50+
51+[theme]
52+# Colors are names (black, red, green, yellow, blue, magenta, cyan, gray,
53+# darkgray, lightred, …, white, reset), 256-color indices ("208") or hex
54+# values ("#c694ff").
55+accent = "magenta"
56+text = "reset"
57+dim = "darkgray"
58+selection_fg = "black"
59+selection_bg = "magenta"
60+playing = "green"
61+marked = "yellow"
62+directory = "blue"
63+border = "darkgray"
64+error = "red"
65+# Visualizer bar gradient, from the bottom of the screen to the top.
66+visualizer = ["blue", "cyan", "green", "yellow", "red"]
67+
68+[lyrics]
69+# Look up lyrics on https://lrclib.net when no local `.lrc` file exists.
70+online = true
71+# Keep fetched lyrics in $XDG_CACHE_HOME/rmptui/lyrics (~/.cache by default).
72+cache = true
73+lrclib_url = "https://lrclib.net"
74+
75+[visualizer]
76+# Path of an MPD fifo output. Add this to mpd.conf:
77+#
78+# audio_output {
79+# type "fifo"
80+# name "Visualizer feed"
81+# path "/tmp/mpd.fifo"
82+# format "44100:16:2"
83+# }
84+fifo = "/tmp/mpd.fifo"
85+# Must match the `format` of the fifo output.
86+sample_rate = 44100
87+channels = 2
88+framerate = 60
89+bar_width = 2
90+bar_gap = 1
91+min_frequency = 40
92+max_frequency = 16000
93+# How fast bars fall, in screen heights per second.
94+falloff = 1.5
95+
96+# Key bindings
97+#
98+# Each table is a context. Lookups start with the context of the focused
99+# popup or screen, then `navigation`, then `global`, so a key bound in a
100+# screen context shadows the same key in `global` on that screen.
101+#
102+# Each action takes a list of keys. A key is a character or one of Enter,
103+# Esc, Tab, Backspace, Delete, Insert, Space, Up, Down, Left, Right, Home,
104+# End, PageUp, PageDown and F1–F24, optionally prefixed by Ctrl+, Alt+ or
105+# Shift+ (e.g. "Ctrl+x", "Alt+Enter", "Shift+Tab"). Set an action to [] to
106+# unbind it; a key may only be used once per context.
107+
108+[keys.global]
109+quit = ["q", "Ctrl+c"]
110+help = ["?", "F1"]
111+toggle_pause = ["p"]
112+pause = ["s"]
113+stop = ["x"]
114+next_track = [">"]
115+previous_track = ["<"]
116+seek_forward = ["f"]
117+seek_backward = ["b"]
118+volume_up = ["+", "="]
119+volume_down = ["-"]
120+toggle_mute = ["m"]
121+toggle_repeat = ["r"]
122+toggle_random = ["z"]
123+toggle_single = ["y"]
124+toggle_consume = ["R"]
125+update_database = ["U"]
126+next_screen = ["Tab"]
127+previous_screen = ["Shift+Tab"]
128+screen_queue = ["1"]
129+screen_browser = ["2"]
130+screen_playlists = ["3"]
131+screen_library = ["4"]
132+screen_tag_editor = ["5"]
133+screen_visualizer = ["6"]
134+screen_lyrics = ["7"]
135+
136+[keys.navigation]
137+up = ["k", "Up"]
138+down = ["j", "Down"]
139+top = ["g", "Home"]
140+bottom = ["G", "End"]
141+page_up = ["PageUp"]
142+page_down = ["PageDown"]
143+half_page_up = ["Ctrl+u"]
144+half_page_down = ["Ctrl+d"]
145+left = ["h", "Left", "Backspace"]
146+right = ["l", "Right"]
147+activate = ["Enter"]
148+toggle_mark = ["v", "Insert"]
149+clear_marks = ["V"]
150+search = ["/"]
151+search_next = ["n"]
152+search_previous = ["N"]
153+add_to_queue = ["Space"]
154+add_to_playlist = ["a"]
155+
156+[keys.queue]
157+delete = ["d", "Delete"]
158+move_item_up = ["K"]
159+move_item_down = ["J"]
160+clear_queue = ["c"]
161+shuffle_queue = ["Z"]
162+save_queue = ["S"]
163+jump_to_current = ["o"]
164+toggle_lyrics_pane = ["L"]
165+
166+[keys.browser]
167+
168+[keys.playlists]
169+delete = ["d", "Delete"]
170+move_item_up = ["K"]
171+move_item_down = ["J"]
172+delete_playlist = ["D"]
173+rename_playlist = ["R"]
174+
175+[keys.library]
176+toggle_artist_tag = ["t"]
177+
178+[keys.tag_editor]
179+save_tags = ["w"]
180+revert_tags = ["u"]
181+
182+[keys.visualizer]
183+
184+[keys.lyrics]
185+reload_lyrics = ["F5"]
186+
187+[keys.popup]
188+close = ["q", "Esc"]
189+
190+[keys.input]
191+submit = ["Enter"]
192+cancel = ["Esc"]
193+backspace = ["Backspace"]
194+delete_char = ["Delete"]
195+delete_word = ["Ctrl+w"]
196+cursor_left = ["Left"]
197+cursor_right = ["Right"]
198+cursor_home = ["Home", "Ctrl+a"]
199+cursor_end = ["End", "Ctrl+e"]
A src/config/keys.rs View
@@ -0,0 +1,305 @@
1+//! Key specifications (`Ctrl+x`, `Space`, `F5`, …) and the context keymap.
2+
3+use std::{
4+ collections::{BTreeMap, HashMap},
5+ fmt,
6+ str::FromStr,
7+};
8+
9+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10+use serde::{Deserialize, Deserializer, de};
11+
12+use crate::action::{Action, Context};
13+
14+/// A key with modifiers, normalized so that lookups ignore how terminals
15+/// report shifted characters.
16+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17+pub struct KeyCombo {
18+ pub code: KeyCode,
19+ pub modifiers: KeyModifiers,
20+}
21+
22+impl KeyCombo {
23+ pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
24+ let mut modifiers =
25+ modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
26+ let code = match code {
27+ // `Shift+a` and `A` are the same key; the case carries the shift.
28+ KeyCode::Char(c) if modifiers.contains(KeyModifiers::SHIFT) => {
29+ modifiers.remove(KeyModifiers::SHIFT);
30+ KeyCode::Char(c.to_uppercase().next().unwrap_or(c))
31+ }
32+ KeyCode::Tab if modifiers.contains(KeyModifiers::SHIFT) => {
33+ modifiers.remove(KeyModifiers::SHIFT);
34+ KeyCode::BackTab
35+ }
36+ KeyCode::BackTab => {
37+ modifiers.remove(KeyModifiers::SHIFT);
38+ KeyCode::BackTab
39+ }
40+ other => other,
41+ };
42+ Self { code, modifiers }
43+ }
44+
45+ /// Whether this is a printable character without Ctrl/Alt, i.e. text.
46+ pub fn as_text(&self) -> Option<char> {
47+ match self.code {
48+ KeyCode::Char(c)
49+ if !self
50+ .modifiers
51+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
52+ {
53+ Some(c)
54+ }
55+ _ => None,
56+ }
57+ }
58+}
59+
60+impl From<KeyEvent> for KeyCombo {
61+ fn from(event: KeyEvent) -> Self {
62+ Self::new(event.code, event.modifiers)
63+ }
64+}
65+
66+impl FromStr for KeyCombo {
67+ type Err = String;
68+
69+ fn from_str(spec: &str) -> Result<Self, Self::Err> {
70+ let mut modifiers = KeyModifiers::NONE;
71+ let mut rest = spec;
72+ // A lone `+` (or a trailing one, as in `Ctrl++`) is the plus key.
73+ while let Some((modifier, tail)) = rest.split_once('+').filter(|(m, _)| !m.is_empty()) {
74+ match modifier.to_ascii_lowercase().as_str() {
75+ "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
76+ "alt" | "meta" => modifiers |= KeyModifiers::ALT,
77+ "shift" => modifiers |= KeyModifiers::SHIFT,
78+ _ => return Err(format!("unknown modifier `{modifier}` in `{spec}`")),
79+ }
80+ rest = tail;
81+ }
82+ if rest.is_empty() {
83+ return Err(format!("missing key in `{spec}`"));
84+ }
85+ let lower = rest.to_ascii_lowercase();
86+ let code = match lower.as_str() {
87+ "esc" | "escape" => KeyCode::Esc,
88+ "enter" | "return" => KeyCode::Enter,
89+ "tab" => KeyCode::Tab,
90+ "backtab" => KeyCode::BackTab,
91+ "backspace" => KeyCode::Backspace,
92+ "delete" | "del" => KeyCode::Delete,
93+ "insert" | "ins" => KeyCode::Insert,
94+ "up" => KeyCode::Up,
95+ "down" => KeyCode::Down,
96+ "left" => KeyCode::Left,
97+ "right" => KeyCode::Right,
98+ "home" => KeyCode::Home,
99+ "end" => KeyCode::End,
100+ "pageup" | "pgup" => KeyCode::PageUp,
101+ "pagedown" | "pgdn" => KeyCode::PageDown,
102+ "space" => KeyCode::Char(' '),
103+ _ => {
104+ if let Some(n) = lower.strip_prefix('f').and_then(|n| n.parse::<u8>().ok()) {
105+ if (1..=24).contains(&n) {
106+ KeyCode::F(n)
107+ } else {
108+ return Err(format!("unknown key `{rest}` in `{spec}`"));
109+ }
110+ } else {
111+ let mut chars = rest.chars();
112+ match (chars.next(), chars.next()) {
113+ (Some(c), None) => KeyCode::Char(c),
114+ _ => return Err(format!("unknown key `{rest}` in `{spec}`")),
115+ }
116+ }
117+ }
118+ };
119+ Ok(Self::new(code, modifiers))
120+ }
121+}
122+
123+impl fmt::Display for KeyCombo {
124+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125+ if self.modifiers.contains(KeyModifiers::CONTROL) {
126+ f.write_str("Ctrl+")?;
127+ }
128+ if self.modifiers.contains(KeyModifiers::ALT) {
129+ f.write_str("Alt+")?;
130+ }
131+ if self.modifiers.contains(KeyModifiers::SHIFT) {
132+ f.write_str("Shift+")?;
133+ }
134+ match self.code {
135+ KeyCode::Char(' ') => f.write_str("Space"),
136+ KeyCode::Char(c) => write!(f, "{c}"),
137+ KeyCode::F(n) => write!(f, "F{n}"),
138+ KeyCode::BackTab => f.write_str("Shift+Tab"),
139+ KeyCode::PageUp => f.write_str("PageUp"),
140+ KeyCode::PageDown => f.write_str("PageDown"),
141+ other => write!(f, "{other:?}"),
142+ }
143+ }
144+}
145+
146+impl<'de> Deserialize<'de> for KeyCombo {
147+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
148+ let s = String::deserialize(deserializer)?;
149+ s.parse().map_err(de::Error::custom)
150+ }
151+}
152+
153+/// The `[keys.*]` tables as written in the config file.
154+pub type Bindings = BTreeMap<Context, BTreeMap<Action, Vec<KeyCombo>>>;
155+
156+/// Resolved bindings: one lookup table per context.
157+#[derive(Clone, Debug, Default)]
158+pub struct Keymap {
159+ contexts: HashMap<Context, HashMap<KeyCombo, Action>>,
160+ bindings: Bindings,
161+}
162+
163+impl Keymap {
164+ /// Builds the keymap, rejecting actions bound in the wrong context and
165+ /// keys bound twice within one context.
166+ pub fn new(bindings: Bindings) -> Result<Self, String> {
167+ let mut contexts: HashMap<Context, HashMap<KeyCombo, Action>> = HashMap::new();
168+ for (&context, actions) in &bindings {
169+ let table = contexts.entry(context).or_default();
170+ for (&action, keys) in actions {
171+ if !action.contexts().contains(&context) {
172+ let allowed: Vec<_> = action.contexts().iter().map(|c| c.name()).collect();
173+ return Err(format!(
174+ "action `{action}` cannot be bound in [keys.{context}] (allowed in: {})",
175+ allowed.join(", ")
176+ ));
177+ }
178+ for &key in keys {
179+ if let Some(previous) = table.insert(key, action)
180+ && previous != action
181+ {
182+ return Err(format!(
183+ "key `{key}` is bound to both `{previous}` and `{action}` in [keys.{context}]; \
184+ unbind one of them (an action can be disabled with `action = []`)"
185+ ));
186+ }
187+ }
188+ }
189+ }
190+ Ok(Self { contexts, bindings })
191+ }
192+
193+ /// Finds the action for `key`, searching `contexts` in order.
194+ pub fn resolve(&self, contexts: &[Context], key: KeyCombo) -> Option<Action> {
195+ contexts
196+ .iter()
197+ .find_map(|context| self.contexts.get(context)?.get(&key).copied())
198+ }
199+
200+ /// The keys bound to `action` in `context`, for help texts.
201+ pub fn keys_for(&self, context: Context, action: Action) -> &[KeyCombo] {
202+ self.bindings
203+ .get(&context)
204+ .and_then(|actions| actions.get(&action))
205+ .map_or(&[], Vec::as_slice)
206+ }
207+
208+ pub fn bindings(&self) -> &Bindings {
209+ &self.bindings
210+ }
211+}
212+
213+#[cfg(test)]
214+mod tests {
215+ use super::*;
216+
217+ fn key(spec: &str) -> KeyCombo {
218+ spec.parse().unwrap()
219+ }
220+
221+ #[test]
222+ fn parses_specs() {
223+ assert_eq!(
224+ key("q"),
225+ KeyCombo::new(KeyCode::Char('q'), KeyModifiers::NONE)
226+ );
227+ assert_eq!(
228+ key("Ctrl+c"),
229+ KeyCombo::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
230+ );
231+ assert_eq!(
232+ key("+"),
233+ KeyCombo::new(KeyCode::Char('+'), KeyModifiers::NONE)
234+ );
235+ assert_eq!(
236+ key("Ctrl++"),
237+ KeyCombo::new(KeyCode::Char('+'), KeyModifiers::CONTROL)
238+ );
239+ assert_eq!(key("Space").code, KeyCode::Char(' '));
240+ assert_eq!(key("F5").code, KeyCode::F(5));
241+ assert_eq!(key("Shift+Tab").code, KeyCode::BackTab);
242+ assert_eq!(key("Shift+g"), key("G"));
243+ assert!("Hyper+x".parse::<KeyCombo>().is_err());
244+ assert!("abc".parse::<KeyCombo>().is_err());
245+ assert!("F99".parse::<KeyCombo>().is_err());
246+ }
247+
248+ #[test]
249+ fn normalizes_terminal_events() {
250+ // Terminals report `G` with or without SHIFT.
251+ let shifted = KeyCombo::from(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT));
252+ assert_eq!(shifted, key("G"));
253+ let backtab = KeyCombo::from(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
254+ assert_eq!(backtab, key("Shift+Tab"));
255+ }
256+
257+ #[test]
258+ fn displays_specs() {
259+ for spec in ["q", "Ctrl+c", "Space", "F5", "Enter", "Shift+Tab", "PageUp"] {
260+ assert_eq!(key(spec).to_string(), spec);
261+ }
262+ }
263+
264+ #[test]
265+ fn resolves_by_context_priority() {
266+ let mut bindings = Bindings::new();
267+ bindings
268+ .entry(Context::Global)
269+ .or_default()
270+ .insert(Action::ToggleConsume, vec![key("R")]);
271+ bindings
272+ .entry(Context::Playlists)
273+ .or_default()
274+ .insert(Action::RenamePlaylist, vec![key("R")]);
275+ let keymap = Keymap::new(bindings).unwrap();
276+ assert_eq!(
277+ keymap.resolve(&[Context::Playlists, Context::Global], key("R")),
278+ Some(Action::RenamePlaylist)
279+ );
280+ assert_eq!(
281+ keymap.resolve(&[Context::Queue, Context::Global], key("R")),
282+ Some(Action::ToggleConsume)
283+ );
284+ }
285+
286+ #[test]
287+ fn rejects_conflicts_and_misplaced_actions() {
288+ let mut bindings = Bindings::new();
289+ let global = bindings.entry(Context::Global).or_default();
290+ global.insert(Action::Quit, vec![key("q")]);
291+ global.insert(Action::Pause, vec![key("q")]);
292+ assert!(Keymap::new(bindings).unwrap_err().contains("bound to both"));
293+
294+ let mut bindings = Bindings::new();
295+ bindings
296+ .entry(Context::Queue)
297+ .or_default()
298+ .insert(Action::Quit, vec![key("q")]);
299+ assert!(
300+ Keymap::new(bindings)
301+ .unwrap_err()
302+ .contains("cannot be bound")
303+ );
304+ }
305+}
A src/config/mod.rs View
@@ -0,0 +1,515 @@
1+//! Configuration: loading, defaults, validation and the MPD address.
2+//!
3+//! `default.toml` is the single source of truth for defaults. A user file is
4+//! merged over it table by table, so partial files keep every default they
5+//! leave out.
6+
7+mod keys;
8+mod theme;
9+
10+use std::{
11+ env,
12+ fs::{self, OpenOptions},
13+ io::{self, Write},
14+ net::Ipv6Addr,
15+ path::{Path, PathBuf},
16+ time::Duration,
17+};
18+
19+use serde::Deserialize;
20+
21+pub use keys::{Bindings, KeyCombo, Keymap};
22+pub use theme::Theme;
23+
24+use crate::{action::ScreenId, mpd::Address, util::expand_tilde};
25+
26+pub const DEFAULT_CONFIG: &str = include_str!("default.toml");
27+
28+#[derive(Debug, thiserror::Error)]
29+pub enum ConfigError {
30+ #[error("cannot access {}: {source}", path.display())]
31+ Io { path: PathBuf, source: io::Error },
32+ #[error("invalid configuration in {}: {message}", path.display())]
33+ Parse { path: PathBuf, message: String },
34+ #[error("invalid configuration: {0}")]
35+ Invalid(String),
36+ #[error(
37+ "{} uses the rmptui 0.1 format, which 0.2 no longer reads.\n\
38+ Move it aside (e.g. to config.toml.old) and start rmptui again to \
39+ generate a documented 0.2 configuration, then copy your settings over.",
40+ .0.display()
41+ )]
42+ Legacy(PathBuf),
43+}
44+
45+#[derive(Clone, Debug, Deserialize)]
46+#[serde(deny_unknown_fields)]
47+pub struct MpdConfig {
48+ pub host: String,
49+ pub port: u16,
50+ pub password: String,
51+ pub music_directory: String,
52+}
53+
54+#[derive(Clone, Debug, Deserialize)]
55+#[serde(deny_unknown_fields)]
56+pub struct PlaybackConfig {
57+ pub seek_seconds: u32,
58+ pub volume_step: u8,
59+}
60+
61+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
62+#[serde(rename_all = "snake_case")]
63+pub enum ColumnField {
64+ Artist,
65+ AlbumArtist,
66+ Title,
67+ Album,
68+ Track,
69+ Disc,
70+ Date,
71+ Genre,
72+ Duration,
73+ File,
74+}
75+
76+impl ColumnField {
77+ pub fn title(self) -> &'static str {
78+ match self {
79+ Self::Artist => "Artist",
80+ Self::AlbumArtist => "Album Artist",
81+ Self::Title => "Title",
82+ Self::Album => "Album",
83+ Self::Track => "#",
84+ Self::Disc => "Disc",
85+ Self::Date => "Date",
86+ Self::Genre => "Genre",
87+ Self::Duration => "Time",
88+ Self::File => "File",
89+ }
90+ }
91+}
92+
93+#[derive(Clone, Copy, Debug, Deserialize)]
94+#[serde(deny_unknown_fields)]
95+pub struct Column {
96+ pub field: ColumnField,
97+ pub width: u16,
98+}
99+
100+#[derive(Clone, Debug, Deserialize)]
101+#[serde(deny_unknown_fields)]
102+pub struct UiConfig {
103+ pub startup_screen: ScreenId,
104+ pub mouse: bool,
105+ pub lyrics_pane: bool,
106+ pub lyrics_pane_width: u16,
107+ pub message_timeout: u64,
108+ pub queue_columns: Vec<Column>,
109+}
110+
111+#[derive(Clone, Debug, Deserialize)]
112+#[serde(deny_unknown_fields)]
113+pub struct LyricsConfig {
114+ pub online: bool,
115+ pub cache: bool,
116+ pub lrclib_url: String,
117+}
118+
119+#[derive(Clone, Debug, Deserialize)]
120+#[serde(deny_unknown_fields)]
121+pub struct VisualizerConfig {
122+ pub fifo: PathBuf,
123+ pub sample_rate: u32,
124+ pub channels: u16,
125+ pub framerate: u32,
126+ pub bar_width: u16,
127+ pub bar_gap: u16,
128+ pub min_frequency: f32,
129+ pub max_frequency: f32,
130+ pub falloff: f32,
131+}
132+
133+#[derive(Debug, Deserialize)]
134+#[serde(deny_unknown_fields)]
135+struct RawConfig {
136+ mpd: MpdConfig,
137+ playback: PlaybackConfig,
138+ ui: UiConfig,
139+ theme: Theme,
140+ lyrics: LyricsConfig,
141+ visualizer: VisualizerConfig,
142+ keys: Bindings,
143+}
144+
145+#[derive(Clone, Debug)]
146+pub struct Config {
147+ pub mpd: MpdConfig,
148+ pub playback: PlaybackConfig,
149+ pub ui: UiConfig,
150+ pub theme: Theme,
151+ pub lyrics: LyricsConfig,
152+ pub visualizer: VisualizerConfig,
153+ pub keymap: Keymap,
154+}
155+
156+impl Default for Config {
157+ fn default() -> Self {
158+ Self::from_toml(DEFAULT_CONFIG).expect("the built-in default configuration is valid")
159+ }
160+}
161+
162+impl Config {
163+ /// Loads the config at `path`, or at the default location. A missing
164+ /// file at the default location is created from the documented defaults.
165+ pub fn load(path: Option<&Path>) -> Result<(Self, PathBuf), ConfigError> {
166+ let (path, create) = match path {
167+ Some(path) => (path.to_path_buf(), false),
168+ None => (default_path(), true),
169+ };
170+ let io_error = |source| ConfigError::Io {
171+ path: path.clone(),
172+ source,
173+ };
174+ let contents = match fs::read_to_string(&path) {
175+ Ok(contents) => contents,
176+ Err(error) if error.kind() == io::ErrorKind::NotFound && create => {
177+ write_default(&path).map_err(io_error)?;
178+ DEFAULT_CONFIG.to_owned()
179+ }
180+ Err(error) => return Err(io_error(error)),
181+ };
182+ let user: toml::Table = toml::from_str(&contents).map_err(|error| ConfigError::Parse {
183+ path: path.clone(),
184+ message: error.to_string(),
185+ })?;
186+ if user.contains_key("keybindings") {
187+ return Err(ConfigError::Legacy(path));
188+ }
189+ let config = Self::from_table(user).map_err(|error| match error {
190+ ConfigError::Invalid(message) => ConfigError::Parse {
191+ path: path.clone(),
192+ message,
193+ },
194+ other => other,
195+ })?;
196+ Ok((config, path))
197+ }
198+
199+ /// Parses a (partial) config, filling in defaults.
200+ pub fn from_toml(contents: &str) -> Result<Self, ConfigError> {
201+ let user: toml::Table =
202+ toml::from_str(contents).map_err(|error| ConfigError::Invalid(error.to_string()))?;
203+ Self::from_table(user)
204+ }
205+
206+ fn from_table(user: toml::Table) -> Result<Self, ConfigError> {
207+ let mut merged: toml::Table =
208+ toml::from_str(DEFAULT_CONFIG).expect("the built-in default configuration is TOML");
209+ merge(&mut merged, user);
210+ let raw: RawConfig = toml::Value::Table(merged)
211+ .try_into()
212+ .map_err(|error: toml::de::Error| ConfigError::Invalid(error.to_string()))?;
213+ let config = Self {
214+ keymap: Keymap::new(raw.keys).map_err(ConfigError::Invalid)?,
215+ mpd: raw.mpd,
216+ playback: raw.playback,
217+ ui: raw.ui,
218+ theme: raw.theme,
219+ lyrics: raw.lyrics,
220+ visualizer: raw.visualizer,
221+ };
222+ config.validate()?;
223+ Ok(config)
224+ }
225+
226+ fn validate(&self) -> Result<(), ConfigError> {
227+ let check = |ok: bool, message: &str| {
228+ if ok {
229+ Ok(())
230+ } else {
231+ Err(ConfigError::Invalid(message.to_owned()))
232+ }
233+ };
234+ check(!self.mpd.host.trim().is_empty(), "mpd.host cannot be empty")?;
235+ check(self.mpd.port != 0, "mpd.port must be between 1 and 65535")?;
236+ check(
237+ self.playback.seek_seconds > 0,
238+ "playback.seek_seconds must be greater than zero",
239+ )?;
240+ check(
241+ (1..=100).contains(&self.playback.volume_step),
242+ "playback.volume_step must be between 1 and 100",
243+ )?;
244+ check(
245+ (10..=90).contains(&self.ui.lyrics_pane_width),
246+ "ui.lyrics_pane_width must be between 10 and 90",
247+ )?;
248+ check(
249+ !self.ui.queue_columns.is_empty(),
250+ "ui.queue_columns cannot be empty",
251+ )?;
252+ let vis = &self.visualizer;
253+ check(
254+ vis.sample_rate > 0,
255+ "visualizer.sample_rate must be greater than zero",
256+ )?;
257+ check(
258+ matches!(vis.channels, 1 | 2),
259+ "visualizer.channels must be 1 or 2",
260+ )?;
261+ check(
262+ (1..=240).contains(&vis.framerate),
263+ "visualizer.framerate must be between 1 and 240",
264+ )?;
265+ check(
266+ vis.bar_width > 0,
267+ "visualizer.bar_width must be greater than zero",
268+ )?;
269+ check(
270+ vis.min_frequency > 0.0 && vis.min_frequency < vis.max_frequency,
271+ "visualizer.min_frequency must be positive and below max_frequency",
272+ )?;
273+ check(
274+ vis.falloff > 0.0,
275+ "visualizer.falloff must be greater than zero",
276+ )?;
277+ check(
278+ !self.theme.visualizer.is_empty(),
279+ "theme.visualizer needs at least one color",
280+ )?;
281+ Ok(())
282+ }
283+
284+ /// The music directory from the config, if set.
285+ pub fn music_directory(&self) -> Option<PathBuf> {
286+ let dir = self.mpd.music_directory.trim();
287+ (!dir.is_empty()).then(|| expand_tilde(dir))
288+ }
289+
290+ pub fn message_timeout(&self) -> Duration {
291+ Duration::from_secs(self.ui.message_timeout)
292+ }
293+
294+ /// The MPD address and password, honoring `MPD_HOST` and `MPD_PORT`.
295+ pub fn mpd_connection(&self) -> Result<(Address, Option<String>), ConfigError> {
296+ resolve_connection(
297+ &self.mpd,
298+ env::var("MPD_HOST").ok().filter(|h| !h.is_empty()),
299+ env::var("MPD_PORT").ok().filter(|p| !p.is_empty()),
300+ )
301+ }
302+}
303+
304+/// Recursively merges `user` into `base`. Tables merge; other values replace.
305+fn merge(base: &mut toml::Table, user: toml::Table) {
306+ for (key, value) in user {
307+ match (base.get_mut(&key), value) {
308+ (Some(toml::Value::Table(base)), toml::Value::Table(user)) => merge(base, user),
309+ (_, value) => {
310+ base.insert(key, value);
311+ }
312+ }
313+ }
314+}
315+
316+/// `$CONFIG_HOME/rmptui/config.toml`, falling back to `$XDG_CONFIG_HOME`
317+/// and then `~/.config`.
318+pub fn default_path() -> PathBuf {
319+ env::var_os("CONFIG_HOME")
320+ .or_else(|| env::var_os("XDG_CONFIG_HOME"))
321+ .filter(|dir| !dir.is_empty())
322+ .map(PathBuf::from)
323+ .unwrap_or_else(|| expand_tilde("~/.config"))
324+ .join("rmptui")
325+ .join("config.toml")
326+}
327+
328+fn write_default(path: &Path) -> io::Result<()> {
329+ if let Some(parent) = path.parent() {
330+ fs::create_dir_all(parent)?;
331+ }
332+ let mut options = OpenOptions::new();
333+ options.write(true).create_new(true);
334+ #[cfg(unix)]
335+ {
336+ use std::os::unix::fs::OpenOptionsExt;
337+ // The file may hold the MPD password.
338+ options.mode(0o600);
339+ }
340+ match options.open(path) {
341+ Ok(mut file) => file.write_all(DEFAULT_CONFIG.as_bytes()),
342+ // Another instance created it in the meantime.
343+ Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
344+ Err(error) => Err(error),
345+ }
346+}
347+
348+fn resolve_connection(
349+ mpd: &MpdConfig,
350+ env_host: Option<String>,
351+ env_port: Option<String>,
352+) -> Result<(Address, Option<String>), ConfigError> {
353+ let configured_password = (!mpd.password.is_empty()).then(|| mpd.password.clone());
354+ let (host, password) = match env_host {
355+ Some(host) => match host.split_once('@') {
356+ Some((password, host)) => (host.to_owned(), Some(password.to_owned())),
357+ None => (host, configured_password),
358+ },
359+ None => (mpd.host.clone(), configured_password),
360+ };
361+ if host.is_empty() {
362+ return Err(ConfigError::Invalid("MPD host cannot be empty".into()));
363+ }
364+ if host.starts_with('/') || host.starts_with('~') {
365+ return Ok((Address::Unix(expand_tilde(&host)), password));
366+ }
367+ let port = match env_port {
368+ Some(port) => port
369+ .parse::<u16>()
370+ .ok()
371+ .filter(|&p| p != 0)
372+ .ok_or_else(|| ConfigError::Invalid(format!("invalid MPD_PORT value `{port}`")))?,
373+ None => mpd.port,
374+ };
375+ Ok((Address::Tcp(tcp_address(&host, port)), password))
376+}
377+
378+/// Formats `host` and `port` for connecting, keeping an explicit port in
379+/// `host` and bracketing IPv6 addresses.
380+fn tcp_address(host: &str, port: u16) -> String {
381+ if host.starts_with('[') && host.contains("]:") {
382+ host.to_owned()
383+ } else if let Some(ipv6) = host
384+ .strip_prefix('[')
385+ .and_then(|h| h.strip_suffix(']'))
386+ .filter(|h| h.parse::<Ipv6Addr>().is_ok())
387+ {
388+ format!("[{ipv6}]:{port}")
389+ } else if host.parse::<Ipv6Addr>().is_ok() {
390+ format!("[{host}]:{port}")
391+ } else if host
392+ .rsplit_once(':')
393+ .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok())
394+ {
395+ host.to_owned()
396+ } else {
397+ format!("{host}:{port}")
398+ }
399+}
400+
401+#[cfg(test)]
402+mod tests {
403+ use super::*;
404+ use crate::action::{Action, Context};
405+
406+ #[test]
407+ fn default_config_is_valid_and_binds_every_action() {
408+ let config = Config::default();
409+ for &action in Action::ALL {
410+ let bound = action
411+ .contexts()
412+ .iter()
413+ .any(|&context| !config.keymap.keys_for(context, action).is_empty());
414+ assert!(bound, "action `{action}` has no default binding");
415+ }
416+ assert_eq!(config.mpd.port, 6600);
417+ assert_eq!(config.ui.startup_screen, ScreenId::Queue);
418+ }
419+
420+ #[test]
421+ fn partial_config_keeps_defaults() {
422+ let config = Config::from_toml(
423+ r#"
424+ [playback]
425+ volume_step = 10
426+ [keys.global]
427+ quit = ["Q"]
428+ "#,
429+ )
430+ .unwrap();
431+ assert_eq!(config.playback.volume_step, 10);
432+ assert_eq!(config.playback.seek_seconds, 5);
433+ let quit = config.keymap.keys_for(Context::Global, Action::Quit);
434+ assert_eq!(quit, ["Q".parse::<KeyCombo>().unwrap()]);
435+ // Other global bindings are untouched.
436+ assert!(
437+ !config
438+ .keymap
439+ .keys_for(Context::Global, Action::TogglePause)
440+ .is_empty()
441+ );
442+ }
443+
444+ #[test]
445+ fn rejects_bad_values() {
446+ for bad in [
447+ "[mpd]\nport = 0",
448+ "[playback]\nvolume_step = 0",
449+ "[mpd]\nhots = \"typo\"",
450+ "[theme]\naccent = \"notacolor\"",
451+ "[keys.global]\nquit = [\"Hyper+q\"]",
452+ "[keys.global]\nnot_an_action = [\"x\"]",
453+ // `p` is already bound to toggle_pause.
454+ "[keys.global]\nquit = [\"p\"]",
455+ ] {
456+ assert!(Config::from_toml(bad).is_err(), "accepted `{bad}`");
457+ }
458+ }
459+
460+ #[test]
461+ fn detects_legacy_config() {
462+ let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
463+ .join("target/test-tmp")
464+ .join(format!("config-{}", std::process::id()));
465+ fs::create_dir_all(&dir).unwrap();
466+ let path = dir.join("legacy.toml");
467+ fs::write(&path, "[keybindings]\nquit = [\"q\"]\n").unwrap();
468+ let result = Config::load(Some(&path));
469+ fs::remove_dir_all(&dir).unwrap();
470+ assert!(matches!(result, Err(ConfigError::Legacy(_))));
471+ }
472+
473+ fn mpd_config(host: &str, password: &str) -> MpdConfig {
474+ MpdConfig {
475+ host: host.into(),
476+ port: 6600,
477+ password: password.into(),
478+ music_directory: String::new(),
479+ }
480+ }
481+
482+ #[test]
483+ fn resolves_connection() {
484+ let resolve = |host: &str, env_host: Option<&str>, env_port: Option<&str>| {
485+ resolve_connection(
486+ &mpd_config(host, "cfg"),
487+ env_host.map(Into::into),
488+ env_port.map(Into::into),
489+ )
490+ .unwrap()
491+ };
492+ assert_eq!(
493+ resolve("localhost", None, None),
494+ (Address::Tcp("localhost:6600".into()), Some("cfg".into()))
495+ );
496+ assert_eq!(
497+ resolve("x", Some("secret@host"), Some("6601")),
498+ (Address::Tcp("host:6601".into()), Some("secret".into()))
499+ );
500+ assert_eq!(
501+ resolve("/run/mpd/socket", None, None).0,
502+ Address::Unix("/run/mpd/socket".into())
503+ );
504+ assert!(resolve_connection(&mpd_config("x", ""), None, Some("nope".into())).is_err());
505+ }
506+
507+ #[test]
508+ fn formats_tcp_addresses() {
509+ assert_eq!(tcp_address("localhost", 6600), "localhost:6600");
510+ assert_eq!(tcp_address("localhost:6601", 6600), "localhost:6601");
511+ assert_eq!(tcp_address("[::1]", 6600), "[::1]:6600");
512+ assert_eq!(tcp_address("[::1]:6601", 6600), "[::1]:6601");
513+ assert_eq!(tcp_address("::1", 6600), "[::1]:6600");
514+ }
515+}
A src/config/theme.rs View
@@ -0,0 +1,91 @@
1+use std::str::FromStr;
2+
3+use ratatui::style::{Color, Modifier, Style};
4+use serde::{Deserialize, Deserializer, de};
5+
6+/// Colors used throughout the UI.
7+#[derive(Clone, Debug, Deserialize)]
8+#[serde(deny_unknown_fields)]
9+pub struct Theme {
10+ #[serde(deserialize_with = "color")]
11+ pub accent: Color,
12+ #[serde(deserialize_with = "color")]
13+ pub text: Color,
14+ #[serde(deserialize_with = "color")]
15+ pub dim: Color,
16+ #[serde(deserialize_with = "color")]
17+ pub selection_fg: Color,
18+ #[serde(deserialize_with = "color")]
19+ pub selection_bg: Color,
20+ #[serde(deserialize_with = "color")]
21+ pub playing: Color,
22+ #[serde(deserialize_with = "color")]
23+ pub marked: Color,
24+ #[serde(deserialize_with = "color")]
25+ pub directory: Color,
26+ #[serde(deserialize_with = "color")]
27+ pub border: Color,
28+ #[serde(deserialize_with = "color")]
29+ pub error: Color,
30+ #[serde(deserialize_with = "colors")]
31+ pub visualizer: Vec<Color>,
32+}
33+
34+impl Theme {
35+ pub fn text(&self) -> Style {
36+ Style::new().fg(self.text)
37+ }
38+
39+ pub fn dim(&self) -> Style {
40+ Style::new().fg(self.dim)
41+ }
42+
43+ pub fn accent(&self) -> Style {
44+ Style::new().fg(self.accent)
45+ }
46+
47+ pub fn border(&self) -> Style {
48+ Style::new().fg(self.border)
49+ }
50+
51+ /// The highlighted row of a focused list.
52+ pub fn selection(&self) -> Style {
53+ Style::new().fg(self.selection_fg).bg(self.selection_bg)
54+ }
55+
56+ /// The highlighted row of a list without focus.
57+ pub fn selection_inactive(&self) -> Style {
58+ Style::new().add_modifier(Modifier::REVERSED)
59+ }
60+
61+ pub fn playing(&self) -> Style {
62+ Style::new().fg(self.playing).add_modifier(Modifier::BOLD)
63+ }
64+
65+ pub fn marked(&self) -> Style {
66+ Style::new().fg(self.marked)
67+ }
68+
69+ pub fn directory(&self) -> Style {
70+ Style::new().fg(self.directory).add_modifier(Modifier::BOLD)
71+ }
72+
73+ pub fn error(&self) -> Style {
74+ Style::new().fg(self.error)
75+ }
76+}
77+
78+fn parse_color<E: de::Error>(value: &str) -> Result<Color, E> {
79+ Color::from_str(value).map_err(|_| E::custom(format!("invalid color `{value}`")))
80+}
81+
82+fn color<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Color, D::Error> {
83+ parse_color(&String::deserialize(deserializer)?)
84+}
85+
86+fn colors<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Color>, D::Error> {
87+ Vec::<String>::deserialize(deserializer)?
88+ .iter()
89+ .map(|value| parse_color(value))
90+ .collect()
91+}
D src/connection.rs View
@@ -1,741 +0,0 @@
1−use std::{
2− collections::HashMap,
3− error::Error,
4− fmt,
5− time::{Duration, Instant},
6−};
7−
8−use tokio::{
9− io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
10− net::TcpStream,
11−};
12−
13−use crate::AppResult;
14−
15−#[derive(Clone, Debug, Default, PartialEq, Eq)]
16−pub struct Song {
17− pub file: String,
18− pub title: Option<String>,
19− pub artist: Option<String>,
20− pub tags: Vec<(String, String)>,
21− pub duration: Option<Duration>,
22− pub place: Option<Place>,
23−}
24−
25−#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26−pub struct Place {
27− pub pos: u32,
28−}
29−
30−#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31−pub enum PlaybackState {
32− Playing,
33− Paused,
34− #[default]
35− Stopped,
36−}
37−
38−#[derive(Clone, Debug, Default)]
39−pub struct Status {
40− pub state: PlaybackState,
41− pub volume: i8,
42− pub repeat: bool,
43− pub random: bool,
44− pub song: Option<Place>,
45− pub elapsed: Duration,
46− pub total: Duration,
47− pub updating_db: Option<u32>,
48−}
49−
50−#[derive(Clone, Debug, Default)]
51−pub struct Stats {
52− pub songs: u32,
53−}
54−
55−#[derive(Clone, Copy, Debug)]
56−pub enum VolumeStatus {
57− Muted(i8),
58− Unmuted,
59−}
60−
61−#[derive(Clone, Debug)]
62−pub struct MpdSnapshot {
63− pub status: Status,
64− pub stats: Stats,
65− pub current_song: Song,
66− pub queue: Vec<Song>,
67−}
68−
69−#[derive(Debug)]
70−struct MpdError(String);
71−
72−impl fmt::Display for MpdError {
73− fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74− f.write_str(&self.0)
75− }
76−}
77−
78−impl Error for MpdError {}
79−
80−#[derive(Debug)]
81−pub struct Connection {
82− stream: BufReader<TcpStream>,
83− pub current_song: Song,
84− pub stats: Stats,
85− pub status: Status,
86− pub volume_status: VolumeStatus,
87− last_status_update: Instant,
88−}
89−
90−impl Connection {
91− async fn open(address: &str, password: Option<&str>) -> AppResult<Self> {
92− let stream = TcpStream::connect(address).await?;
93− let mut client = Self {
94− stream: BufReader::new(stream),
95− current_song: Song::default(),
96− stats: Stats::default(),
97− status: Status::default(),
98− volume_status: VolumeStatus::Unmuted,
99− last_status_update: Instant::now(),
100− };
101− let mut greeting = String::new();
102− client.stream.read_line(&mut greeting).await?;
103− if !greeting.starts_with("OK MPD ") {
104− return Err(Box::new(MpdError(format!(
105− "unexpected MPD greeting: {}",
106− greeting.trim_end()
107− ))));
108− }
109− if let Some(password) = password {
110− client.command("password", &[password]).await?;
111− }
112− Ok(client)
113− }
114−
115− async fn command(&mut self, name: &str, args: &[&str]) -> AppResult<Vec<String>> {
116− let mut request = name.to_string();
117− for arg in args {
118− request.push(' ');
119− request.push_str(&quote(arg));
120− }
121− request.push('\n');
122− self.stream.get_mut().write_all(request.as_bytes()).await?;
123− self.stream.get_mut().flush().await?;
124−
125− let mut lines = Vec::new();
126− loop {
127− let mut line = String::new();
128− let read = self.stream.read_line(&mut line).await?;
129− if read == 0 {
130− return Err(Box::new(MpdError(
131− "MPD closed the connection while replying to a command".to_string(),
132− )));
133− }
134− let line = line.trim_end_matches(['\r', '\n']);
135− if line == "OK" {
136− return Ok(lines);
137− }
138− if line.starts_with("ACK ") {
139− return Err(Box::new(MpdError(format!("MPD rejected `{name}`: {line}"))));
140− }
141− lines.push(line.to_string());
142− }
143− }
144−
145− async fn read_status(&mut self) -> AppResult<Status> {
146− let fields = fields(self.command("status", &[]).await?);
147− let time = fields.get("time").and_then(|s| {
148− let (elapsed, total) = s.split_once(':')?;
149− Some((parse_duration(elapsed), parse_duration(total)))
150− });
151− let elapsed = fields
152− .get("elapsed")
153− .map(|s| parse_duration(s))
154− .or_else(|| time.map(|(elapsed, _)| elapsed))
155− .unwrap_or_default();
156− let total = fields
157− .get("duration")
158− .map(|s| parse_duration(s))
159− .or_else(|| time.map(|(_, total)| total))
160− .unwrap_or_default();
161−
162− Ok(Status {
163− state: match fields.get("state").map(String::as_str) {
164− Some("play") => PlaybackState::Playing,
165− Some("pause") => PlaybackState::Paused,
166− _ => PlaybackState::Stopped,
167− },
168− volume: fields
169− .get("volume")
170− .and_then(|s| s.parse().ok())
171− .unwrap_or(-1),
172− repeat: bool_field(&fields, "repeat"),
173− random: bool_field(&fields, "random"),
174− song: fields
175− .get("song")
176− .and_then(|s| s.parse().ok())
177− .map(|pos| Place { pos }),
178− elapsed,
179− total,
180− updating_db: fields.get("updating_db").and_then(|s| s.parse().ok()),
181− })
182− }
183−
184− async fn read_stats(&mut self) -> AppResult<Stats> {
185− let fields = fields(self.command("stats", &[]).await?);
186− Ok(Stats {
187− songs: fields
188− .get("songs")
189− .and_then(|s| s.parse().ok())
190− .unwrap_or_default(),
191− })
192− }
193−
194− async fn read_current_song(&mut self) -> AppResult<Song> {
195− Ok(parse_songs(self.command("currentsong", &[]).await?)
196− .into_iter()
197− .next()
198− .unwrap_or_default())
199− }
200−
201− pub async fn queue(&mut self) -> AppResult<Vec<Song>> {
202− Ok(parse_songs(self.command("playlistinfo", &[]).await?))
203− }
204−
205− pub async fn snapshot(&mut self) -> AppResult<MpdSnapshot> {
206− let status = self.read_status().await?;
207− let stats = self.read_stats().await?;
208− let current_song = self.read_current_song().await?;
209− let queue = self.queue().await?;
210− Ok(MpdSnapshot {
211− status,
212− stats,
213− current_song,
214− queue,
215− })
216− }
217−
218− pub async fn list_files(&mut self, path: &str) -> AppResult<Vec<(String, String)>> {
219− let lines = if path.is_empty() || path == "." {
220− self.command("listfiles", &[]).await?
221− } else {
222− self.command("listfiles", &[path]).await?
223− };
224− Ok(lines
225− .into_iter()
226− .filter_map(|line| {
227− let (kind, value) = parse_field(&line)?;
228− matches!(kind.as_str(), "file" | "directory").then_some((kind, value))
229− })
230− .collect())
231− }
232−
233− pub async fn ls_info(&mut self, path: &str) -> AppResult<(Vec<String>, Vec<Song>)> {
234− let lines = if path.is_empty() || path == "." {
235− self.command("lsinfo", &[]).await?
236− } else {
237− self.command("lsinfo", &[path]).await?
238− };
239− let mut directories = Vec::new();
240− let mut songs = Vec::new();
241− let mut current: Option<Song> = None;
242− for line in lines {
243− let Some((key, value)) = parse_field(&line) else {
244− continue;
245− };
246− match key.as_str() {
247− "directory" => directories.push(value),
248− "file" => {
249− if let Some(song) = current.take() {
250− songs.push(song);
251− }
252− current = Some(Song {
253− file: value,
254− ..Song::default()
255− });
256− }
257− "playlist" => {}
258− _ => {
259− if let Some(song) = current.as_mut() {
260− add_metadata(song, key, value);
261− }
262− }
263− }
264− }
265− if let Some(song) = current {
266− songs.push(song);
267− }
268− Ok((directories, songs))
269− }
270−
271− pub async fn playlists(&mut self) -> AppResult<Vec<String>> {
272− Ok(self
273− .command("listplaylists", &[])
274− .await?
275− .into_iter()
276− .filter_map(|line| parse_field(&line))
277− .filter_map(|(key, value)| (key == "playlist").then_some(value))
278− .collect())
279− }
280−
281− pub async fn playlist(&mut self, name: &str) -> AppResult<Vec<Song>> {
282− Ok(parse_songs(
283− self.command("listplaylistinfo", &[name]).await?,
284− ))
285− }
286−
287− pub async fn add(&mut self, file: &str) -> AppResult<()> {
288− self.command("add", &[file]).await?;
289− Ok(())
290− }
291−
292− pub async fn play(&mut self, position: Option<u32>) -> AppResult<()> {
293− match position {
294− Some(position) => self.command("play", &[&position.to_string()]).await?,
295− None => self.command("play", &[]).await?,
296− };
297− Ok(())
298− }
299−
300− pub async fn set_repeat(&mut self, enabled: bool) -> AppResult<()> {
301− self.command("repeat", &[if enabled { "1" } else { "0" }])
302− .await?;
303− Ok(())
304− }
305−
306− pub async fn set_random(&mut self, enabled: bool) -> AppResult<()> {
307− self.command("random", &[if enabled { "1" } else { "0" }])
308− .await?;
309− Ok(())
310− }
311−
312− pub async fn set_volume(&mut self, volume: i8) -> AppResult<()> {
313− self.command("setvol", &[&volume.clamp(0, 100).to_string()])
314− .await?;
315− Ok(())
316− }
317−
318− pub async fn seek(&mut self, position: u32, elapsed: Duration) -> AppResult<()> {
319− self.command(
320− "seek",
321− &[&position.to_string(), &elapsed.as_secs().to_string()],
322− )
323− .await?;
324− Ok(())
325− }
326−
327− pub async fn next(&mut self) -> AppResult<()> {
328− self.command("next", &[]).await?;
329− Ok(())
330− }
331−
332− pub async fn previous(&mut self) -> AppResult<()> {
333− self.command("previous", &[]).await?;
334− Ok(())
335− }
336−
337− pub async fn clear(&mut self) -> AppResult<()> {
338− self.command("clear", &[]).await?;
339− Ok(())
340− }
341−
342− pub async fn delete(&mut self, position: u32) -> AppResult<()> {
343− self.command("delete", &[&position.to_string()]).await?;
344− Ok(())
345− }
346−
347− pub async fn swap(&mut self, first: u32, second: u32) -> AppResult<()> {
348− self.command("swap", &[&first.to_string(), &second.to_string()])
349− .await?;
350− Ok(())
351− }
352−
353− pub async fn append_playlist(&mut self, name: &str) -> AppResult<()> {
354− self.command("load", &[name]).await?;
355− Ok(())
356− }
357−
358− pub async fn delete_playlist_song(&mut self, playlist: &str, position: usize) -> AppResult<()> {
359− self.command("playlistdelete", &[playlist, &position.to_string()])
360− .await?;
361− Ok(())
362− }
363−
364− pub async fn move_playlist_song(
365− &mut self,
366− playlist: &str,
367− from: usize,
368− to: usize,
369− ) -> AppResult<()> {
370− self.command(
371− "playlistmove",
372− &[playlist, &from.to_string(), &to.to_string()],
373− )
374− .await?;
375− Ok(())
376− }
377−
378− pub async fn delete_playlist(&mut self, playlist: &str) -> AppResult<()> {
379− self.command("rm", &[playlist]).await?;
380− Ok(())
381− }
382−
383− pub async fn rename_playlist(&mut self, from: &str, to: &str) -> AppResult<()> {
384− self.command("rename", &[from, to]).await?;
385− Ok(())
386− }
387−
388− pub async fn update_database(&mut self) -> AppResult<()> {
389− self.command("rescan", &[]).await?;
390− Ok(())
391− }
392−
393− pub async fn idle(&mut self) -> AppResult<()> {
394− self.command(
395− "idle",
396− &[
397− "database",
398− "stored_playlist",
399− "player",
400− "playlist",
401− "mixer",
402− "options",
403− ],
404− )
405− .await?;
406− Ok(())
407− }
408−}
409−
410−impl Connection {
411− pub async fn connect(address: &str, password: Option<&str>) -> AppResult<Self> {
412− let mut connection = Self::open(address, password).await?;
413− connection.update_status().await?;
414− Ok(connection)
415− }
416−
417− pub fn apply_snapshot(&mut self, status: Status, stats: Stats, current_song: Song) {
418− self.status = status;
419− self.stats = stats;
420− self.current_song = current_song;
421− self.last_status_update = Instant::now();
422− }
423−
424− pub async fn update_status(&mut self) -> AppResult<()> {
425− self.status = self.read_status().await?;
426− self.stats = self.read_stats().await?;
427− self.current_song = self.read_current_song().await?;
428− self.last_status_update = Instant::now();
429− Ok(())
430− }
431−
432− pub fn get_progress_ratio(&self) -> f64 {
433− let total = self.status.total.as_secs_f64();
434− if total <= 0.0 {
435− 0.0
436− } else {
437− (self.current_elapsed().as_secs_f64() / total).clamp(0.0, 1.0)
438− }
439− }
440−
441− pub fn current_elapsed(&self) -> Duration {
442− let elapsed = if self.status.state == PlaybackState::Playing {
443− self.status
444− .elapsed
445− .saturating_add(self.last_status_update.elapsed())
446− } else {
447− self.status.elapsed
448− };
449− if self.status.total.is_zero() {
450− elapsed
451− } else {
452− elapsed.min(self.status.total)
453− }
454− }
455−
456− pub async fn push(&mut self, song: &Song) -> AppResult<()> {
457− self.add(&song.file).await?;
458− let queue = self.queue().await?;
459− if !queue.is_empty() {
460− self.play(Some(queue.len() as u32 - 1)).await?;
461− }
462− self.update_status().await
463− }
464−
465− pub async fn load_playlist(&mut self, playlist: &str) -> AppResult<()> {
466− self.append_playlist(playlist).await?;
467− self.play(None).await?;
468− self.update_status().await
469− }
470−
471− pub async fn add_to_playlist(&mut self, playlist: &str, song: &Song) -> AppResult<()> {
472− self.command("playlistadd", &[playlist, &song.file]).await?;
473− Ok(())
474− }
475−
476− pub fn song_from_file(filename: &str) -> Song {
477− Song {
478− file: filename.to_string(),
479− ..Song::default()
480− }
481− }
482−
483− pub fn now_playing(&self) -> String {
484− match (&self.current_song.title, &self.current_song.artist) {
485− (Some(title), Some(artist)) => format!("\"{title}\" By {artist}"),
486− (Some(title), None) => title.clone(),
487− _ if self.current_song.file.is_empty() => "No Song playing or in Queue".to_string(),
488− _ => self.current_song.file.clone(),
489− }
490− }
491−
492− pub async fn toggle_pause(&mut self) -> AppResult<()> {
493− self.command("pause", &[]).await?;
494− self.update_status().await
495− }
496−
497− pub async fn pause(&mut self) -> AppResult<()> {
498− self.command("pause", &["1"]).await?;
499− self.update_status().await
500− }
501−
502− pub async fn toggle_repeat(&mut self) -> AppResult<()> {
503− self.set_repeat(!self.status.repeat).await?;
504− self.update_status().await
505− }
506−
507− pub async fn toggle_random(&mut self) -> AppResult<()> {
508− self.set_random(!self.status.random).await?;
509− self.update_status().await
510− }
511−
512− pub async fn inc_volume(&mut self, amount: i8) -> AppResult<()> {
513− let volume = self.status.volume.saturating_add(amount).min(100);
514− self.volume_status = VolumeStatus::Unmuted;
515− self.set_volume(volume).await?;
516− self.update_status().await
517− }
518−
519− pub async fn dec_volume(&mut self, amount: i8) -> AppResult<()> {
520− let volume = self.status.volume.saturating_sub(amount).max(0);
521− self.volume_status = VolumeStatus::Unmuted;
522− self.set_volume(volume).await?;
523− self.update_status().await
524− }
525−
526− pub async fn refresh_song_list(&mut self) -> AppResult<()> {
527− self.stats = self.read_stats().await?;
528− Ok(())
529− }
530−}
531−
532−fn fields(lines: Vec<String>) -> HashMap<String, String> {
533− lines
534− .into_iter()
535− .filter_map(|line| parse_field(&line))
536− .collect()
537−}
538−
539−fn parse_songs(lines: Vec<String>) -> Vec<Song> {
540− let mut songs = Vec::new();
541− let mut current: Option<Song> = None;
542− for line in lines {
543− let Some((key, value)) = parse_field(&line) else {
544− continue;
545− };
546− if key == "file" {
547− if let Some(song) = current.take() {
548− songs.push(song);
549− }
550− current = Some(Song {
551− file: value,
552− ..Song::default()
553− });
554− } else if let Some(song) = current.as_mut() {
555− if key.eq_ignore_ascii_case("pos") {
556− song.place = value.parse().ok().map(|pos| Place { pos });
557− } else if key == "duration" || key == "Time" {
558− song.duration = Some(parse_duration(&value));
559− } else {
560− add_metadata(song, key, value);
561− }
562− }
563− }
564− if let Some(song) = current {
565− songs.push(song);
566− }
567− songs
568−}
569−
570−fn add_metadata(song: &mut Song, key: String, value: String) {
571− match key.to_ascii_lowercase().as_str() {
572− "title" => song.title = Some(value),
573− "artist" => song.artist = Some(value),
574− "duration" | "time" => song.duration = Some(parse_duration(&value)),
575− _ => song.tags.push((key, value)),
576− }
577−}
578−
579−fn parse_field(line: &str) -> Option<(String, String)> {
580− let (key, value) = line.split_once(": ")?;
581− Some((key.to_string(), unquote(value)))
582−}
583−
584−fn unquote(value: &str) -> String {
585− let value = value
586− .strip_prefix('"')
587− .and_then(|v| v.strip_suffix('"'))
588− .unwrap_or(value);
589− let mut result = String::with_capacity(value.len());
590− let mut escaped = false;
591− for character in value.chars() {
592− if escaped {
593− result.push(character);
594− escaped = false;
595− } else if character == '\\' {
596− escaped = true;
597− } else {
598− result.push(character);
599− }
600− }
601− if escaped {
602− result.push('\\');
603− }
604− result
605−}
606−
607−fn quote(value: &str) -> String {
608− format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
609−}
610−
611−fn bool_field(fields: &HashMap<String, String>, key: &str) -> bool {
612− fields.get(key).is_some_and(|value| value == "1")
613−}
614−
615−fn parse_duration(value: &str) -> Duration {
616− value
617− .parse::<f64>()
618− .ok()
619− .filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
620− .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok())
621− .unwrap_or_default()
622−}
623−
624−#[cfg(test)]
625−mod tests {
626− use super::*;
627− use tokio::{
628− io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
629− net::TcpListener,
630− };
631−
632− #[test]
633− fn parses_quoted_mpd_values_and_escapes() {
634− assert_eq!(
635− parse_field(r#"Title: "A \"quoted\" song\\file""#),
636− Some(("Title".to_string(), "A \"quoted\" song\\file".to_string()))
637− );
638− assert_eq!(quote(r#"A "song"\path"#), r#""A \"song\"\\path""#);
639− }
640−
641− #[test]
642− fn parses_songs_with_tags_and_queue_positions() {
643− let songs = parse_songs(vec![
644− "file: music/example.flac".to_string(),
645− "Title: Example".to_string(),
646− "Artist: Someone".to_string(),
647− "duration: 124.5".to_string(),
648− "Pos: 3".to_string(),
649− "file: music/another.ogg".to_string(),
650− ]);
651− assert_eq!(songs.len(), 2);
652− assert_eq!(songs[0].title.as_deref(), Some("Example"));
653− assert_eq!(songs[0].artist.as_deref(), Some("Someone"));
654− assert_eq!(songs[0].duration, Some(Duration::from_secs_f64(124.5)));
655− assert_eq!(songs[0].place, Some(Place { pos: 3 }));
656− assert_eq!(songs[1].file, "music/another.ogg");
657− }
658−
659− #[tokio::test]
660− async fn sends_quoted_commands_over_the_mpd_tcp_protocol() {
661− let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
662− let address = listener.local_addr().unwrap();
663− let server = tokio::spawn(async move {
664− let (stream, _) = listener.accept().await.unwrap();
665− let mut stream = BufReader::new(stream);
666− stream
667− .get_mut()
668− .write_all(b"OK MPD 0.23.0\n")
669− .await
670− .unwrap();
671− let mut command = String::new();
672− stream.read_line(&mut command).await.unwrap();
673− assert_eq!(command, "play \"0\"\n");
674− stream.get_mut().write_all(b"OK\n").await.unwrap();
675− });
676−
677− let mut client = Connection::open(&address.to_string(), None).await.unwrap();
678− client.command("play", &["0"]).await.unwrap();
679− server.await.unwrap();
680− }
681−
682− #[tokio::test]
683− async fn omits_empty_root_paths_from_mpd_commands() {
684− let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
685− let address = listener.local_addr().unwrap();
686− let server = tokio::spawn(async move {
687− let (stream, _) = listener.accept().await.unwrap();
688− let mut stream = BufReader::new(stream);
689− stream
690− .get_mut()
691− .write_all(b"OK MPD 0.23.0\n")
692− .await
693− .unwrap();
694− for expected in ["lsinfo\n", "listfiles\n"] {
695− let mut command = String::new();
696− stream.read_line(&mut command).await.unwrap();
697− assert_eq!(command, expected);
698− stream.get_mut().write_all(b"OK\n").await.unwrap();
699− }
700− });
701−
702− let mut client = Connection::open(&address.to_string(), None).await.unwrap();
703− client.ls_info("").await.unwrap();
704− client.list_files("").await.unwrap();
705− server.await.unwrap();
706− }
707−
708− #[tokio::test]
709− async fn sends_stored_playlist_edit_commands() {
710− let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
711− let address = listener.local_addr().unwrap();
712− let server = tokio::spawn(async move {
713− let (stream, _) = listener.accept().await.unwrap();
714− let mut stream = BufReader::new(stream);
715− stream
716− .get_mut()
717− .write_all(b"OK MPD 0.23.0\n")
718− .await
719− .unwrap();
720− for expected in [
721− "playlistdelete \"mix \\\"one\\\"\" \"2\"\n",
722− "playlistmove \"mix \\\"one\\\"\" \"1\" \"2\"\n",
723− "rm \"mix \\\"one\\\"\"\n",
724− ] {
725− let mut command = String::new();
726− stream.read_line(&mut command).await.unwrap();
727− assert_eq!(command, expected);
728− stream.get_mut().write_all(b"OK\n").await.unwrap();
729− }
730− });
731−
732− let mut client = Connection::open(&address.to_string(), None).await.unwrap();
733− client.delete_playlist_song("mix \"one\"", 2).await.unwrap();
734− client
735− .move_playlist_song("mix \"one\"", 1, 2)
736− .await
737− .unwrap();
738− client.delete_playlist("mix \"one\"").await.unwrap();
739− server.await.unwrap();
740− }
741−}
D src/event_handler/handler.rs View
@@ -1,473 +0,0 @@
1−use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
2−use rust_fuzzy_search::fuzzy_search_sorted;
3−
4−use crate::{
5− app::{App, PlaylistFocus, SelectedTab},
6− connection::VolumeStatus,
7− ui::InputMode,
8− AppResult,
9−};
10−
11−pub async fn handle_key_events(key: KeyEvent, app: &mut App) -> AppResult<()> {
12− if app.inputmode != InputMode::Normal {
13− if key
14− .modifiers
15− .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
16− && is_bound(app, "quit", key)
17− {
18− app.quit();
19− return Ok(());
20− }
21− return handle_input_key(key, app).await;
22− }
23− if app.playlist_popup {
24− if is_bound(app, "popup_close", key) {
25− app.playlist_popup = false;
26− } else if is_bound(app, "move_down", key) {
27− app.append_list.next();
28− } else if is_bound(app, "move_up", key) {
29− app.append_list.prev();
30− } else if is_bound(app, "popup_select", key) {
31− app.append_selected_to().await?;
32− }
33− return Ok(());
34− }
35− if app.selected_tab == SelectedTab::DirectoryBrowser && !app.browser_search_results.is_empty() {
36− if is_bound(app, "search_next", key) {
37− cycle_browser_search(app, true);
38− return Ok(());
39− } else if is_bound(app, "search_previous", key) {
40− cycle_browser_search(app, false);
41− return Ok(());
42− }
43− }
44−
45− if is_bound(app, "quit", key) {
46− app.quit();
47− } else if is_bound(app, "clear_queue", key) {
48− app.conn.clear().await?;
49− app.update_queue().await?;
50− app.conn.update_status().await?;
51− } else if is_bound(app, "toggle_pause", key) {
52− app.conn.toggle_pause().await?;
53− } else if is_bound(app, "pause", key) {
54− app.conn.pause().await?;
55− } else if is_bound(app, "toggle_repeat", key) {
56− app.conn.toggle_repeat().await?;
57− } else if is_bound(app, "toggle_random", key) {
58− app.conn.toggle_random().await?;
59− } else if is_bound(app, "open_playlist_menu", key) {
60− app.playlist_popup = true;
61− } else if is_bound(app, "seek_forward", key) || is_bound(app, "seek_backward", key) {
62− if let Some(position) = app.conn.status.song {
63− let current = app.conn.status.elapsed.as_secs();
64− let target = if is_bound(app, "seek_forward", key) {
65− current.saturating_add(app.config.playback.seek_seconds)
66− } else {
67− current.saturating_sub(app.config.playback.seek_seconds)
68− };
69− app.conn
70− .seek(position.pos, std::time::Duration::from_secs(target))
71− .await?;
72− app.conn.update_status().await?;
73− }
74− } else if is_bound(app, "cycle_tabs", key) {
75− app.cycle_tabs();
76− if app.selected_tab == SelectedTab::Playlists {
77− app.refresh_playlist_view().await?;
78− }
79− } else if is_bound(app, "select_queue", key) {
80− app.selected_tab = SelectedTab::Queue;
81− } else if is_bound(app, "select_browser", key) {
82− app.selected_tab = SelectedTab::DirectoryBrowser;
83− } else if is_bound(app, "select_playlists", key) {
84− app.selected_tab = SelectedTab::Playlists;
85− app.refresh_playlist_view().await?;
86− } else if is_bound(app, "next_track", key) {
87− if !app.queue_list.list.is_empty() {
88− app.conn.next().await?;
89− app.update_queue().await?;
90− app.conn.update_status().await?;
91− }
92− } else if is_bound(app, "previous_track", key) {
93− if !app.queue_list.list.is_empty() {
94− app.conn.previous().await?;
95− app.update_queue().await?;
96− app.conn.update_status().await?;
97− }
98− } else if is_bound(app, "volume_up", key) {
99− app.conn
100− .inc_volume(app.config.playback.volume_step as i8)
101− .await?;
102− } else if is_bound(app, "volume_down", key) {
103− app.conn
104− .dec_volume(app.config.playback.volume_step as i8)
105− .await?;
106− } else if is_bound(app, "toggle_mute", key) {
107− match app.conn.volume_status {
108− VolumeStatus::Muted(volume) => {
109− app.conn.set_volume(volume).await?;
110− app.conn.volume_status = VolumeStatus::Unmuted;
111− }
112− VolumeStatus::Unmuted => {
113− app.conn.set_volume(0).await?;
114− app.conn.volume_status = VolumeStatus::Muted(app.conn.status.volume);
115− }
116− }
117− app.conn.update_status().await?;
118− } else if is_bound(app, "update_database", key) {
119− app.conn.update_database().await?;
120− app.should_update_song_list = true;
121− app.conn.update_status().await?;
122− } else if is_bound(app, "search", key) {
123− app.inputmode = InputMode::Editing;
124− app.search_input.clear();
125− app.search_cursor_pos = 0;
126− app.browser_search_results.clear();
127− app.browser_search_index = 0;
128− } else if is_bound(app, "toggle_selected_item", key) {
129− app.toggle_selected_item().await?;
130− }
131−
132− match app.selected_tab {
133− SelectedTab::Queue => handle_queue_key(key, app).await?,
134− SelectedTab::DirectoryBrowser => handle_browser_key(key, app).await?,
135− SelectedTab::Playlists => handle_playlists_key(key, app).await?,
136− }
137− Ok(())
138−}
139−
140−fn is_bound(app: &App, action: &str, key: KeyEvent) -> bool {
141− app.config.keybindings.matches(action, key)
142−}
143−
144−async fn handle_queue_key(key: KeyEvent, app: &mut App) -> AppResult<()> {
145− if is_bound(app, "move_down", key) {
146− app.queue_list.next();
147− } else if is_bound(app, "move_up", key) {
148− app.queue_list.prev();
149− } else if is_bound(app, "activate", key) {
150− if app.queue_list.index < app.queue_list.list.len() {
151− app.conn.play(Some(app.queue_list.index as u32)).await?;
152− app.conn.update_status().await?;
153− }
154− } else if is_bound(app, "queue_delete", key) {
155− if app.queue_list.index < app.queue_list.list.len() {
156− app.conn.delete(app.queue_list.index as u32).await?;
157− app.update_queue().await?;
158− app.conn.update_status().await?;
159− }
160− } else if is_bound(app, "queue_move_down", key) {
161− let position = app.queue_list.index;
162− if position + 1 < app.queue_list.list.len() {
163− app.conn
164− .swap(position as u32, (position + 1) as u32)
165− .await?;
166− app.queue_list.index += 1;
167− app.update_queue().await?;
168− }
169− } else if is_bound(app, "queue_move_up", key) {
170− let position = app.queue_list.index;
171− if position > 0 && position < app.queue_list.list.len() {
172− app.conn
173− .swap(position as u32, (position - 1) as u32)
174− .await?;
175− app.queue_list.index -= 1;
176− app.update_queue().await?;
177− }
178− } else if is_bound(app, "go_to_top", key) {
179− app.queue_list.index = 0;
180− } else if is_bound(app, "go_to_bottom", key) {
181− app.queue_list.index = app.queue_list.list.len().saturating_sub(1);
182− }
183− Ok(())
184−}
185−
186−async fn handle_browser_key(key: KeyEvent, app: &mut App) -> AppResult<()> {
187− if is_bound(app, "move_down", key) {
188− app.browser.next();
189− } else if is_bound(app, "move_up", key) {
190− app.browser.prev();
191− } else if is_bound(app, "activate", key) {
192− let previous_path = app.browser.path.clone();
193− app.handle_enter().await?;
194− if app.browser.path != previous_path {
195− app.browser_search_results.clear();
196− app.search_input.clear();
197− }
198− } else if is_bound(app, "go_back", key) {
199− app.browser.handle_go_back(&mut app.conn).await?;
200− app.browser_search_results.clear();
201− app.search_input.clear();
202− } else if is_bound(app, "go_to_top", key) {
203− app.browser.selected = 0;
204− } else if is_bound(app, "go_to_bottom", key) {
205− app.browser.selected = app.browser.entries.len().saturating_sub(1);
206− }
207− Ok(())
208−}
209−
210−async fn handle_playlists_key(key: KeyEvent, app: &mut App) -> AppResult<()> {
211− if is_bound(app, "playlist_focus_contents", key) {
212− app.playlist_focus = PlaylistFocus::Songs;
213− return Ok(());
214− }
215− if is_bound(app, "playlist_focus_list", key) {
216− app.playlist_focus = PlaylistFocus::List;
217− return Ok(());
218− }
219−
220− if app.playlist_focus == PlaylistFocus::Songs {
221− if is_bound(app, "move_down", key) {
222− app.playlist_song_index = app
223− .playlist_song_index
224− .saturating_add(1)
225− .min(app.playlist_contents.len().saturating_sub(1));
226− } else if is_bound(app, "move_up", key) {
227− app.playlist_song_index = app.playlist_song_index.saturating_sub(1);
228− } else if is_bound(app, "go_to_top", key) {
229− app.playlist_song_index = 0;
230− } else if is_bound(app, "go_to_bottom", key) {
231− app.playlist_song_index = app.playlist_contents.len().saturating_sub(1);
232− } else if is_bound(app, "playlist_delete_song", key) {
233− app.delete_selected_playlist_song().await?;
234− } else if is_bound(app, "playlist_move_song_down", key) {
235− app.move_selected_playlist_song(true).await?;
236− } else if is_bound(app, "playlist_move_song_up", key) {
237− app.move_selected_playlist_song(false).await?;
238− } else if is_bound(app, "activate", key) {
239− app.play_selected_playlist_song().await?;
240− } else if is_bound(app, "playlist_delete", key) {
241− app.delete_selected_playlist().await?;
242− }
243− return Ok(());
244− }
245−
246− let previous_index = app.pl_list.index;
247− if is_bound(app, "move_down", key) {
248− app.pl_list.next();
249− } else if is_bound(app, "move_up", key) {
250− app.pl_list.prev();
251− } else if is_bound(app, "go_to_top", key) {
252− app.pl_list.index = 0;
253− } else if is_bound(app, "go_to_bottom", key) {
254− app.pl_list.index = app.pl_list.list.len().saturating_sub(1);
255− } else if is_bound(app, "playlist_rename", key) && !app.pl_list.list.is_empty() {
256− app.inputmode = InputMode::PlaylistRename;
257− app.pl_newname_input.clear();
258− app.pl_cursor_pos = 0;
259− } else if is_bound(app, "playlist_delete", key) {
260− app.delete_selected_playlist().await?;
261− } else if is_bound(app, "playlist_load", key) {
262− if let Some(playlist) = app.pl_list.list.get(app.pl_list.index) {
263− app.conn.load_playlist(playlist).await?;
264− app.update_queue().await?;
265− }
266− }
267− if previous_index != app.pl_list.index {
268− app.playlist_song_index = 0;
269− app.refresh_playlist_view().await?;
270− }
271− Ok(())
272−}
273−
274−async fn handle_input_key(key: KeyEvent, app: &mut App) -> AppResult<()> {
275− if is_bound(app, "input_cancel", key) {
276− match app.inputmode {
277− InputMode::Editing => {
278− app.search_input.clear();
279− app.browser_search_results.clear();
280− app.inputmode = InputMode::Normal;
281− }
282− InputMode::PlaylistRename => {
283− app.pl_newname_input.clear();
284− app.inputmode = InputMode::Normal;
285− }
286− InputMode::NewPlaylist => {
287− app.pl_new_pl_input.clear();
288− app.inputmode = InputMode::Normal;
289− }
290− InputMode::Normal => {}
291− }
292− app.reset_cursor();
293− } else if is_bound(app, "input_backspace", key) {
294− app.delete_char();
295− if app.inputmode == InputMode::Editing {
296− update_search_selection(app);
297− }
298− } else if is_bound(app, "input_cursor_left", key) {
299− app.move_cursor_left();
300− } else if is_bound(app, "input_cursor_right", key) {
301− app.move_cursor_right();
302− } else if is_bound(app, "input_submit", key) {
303− match app.inputmode {
304− InputMode::Editing => {
305− update_search_selection(app);
306− if app.selected_tab != SelectedTab::DirectoryBrowser {
307− app.search_input.clear();
308− }
309− if app.selected_tab == SelectedTab::Playlists {
310− app.refresh_playlist_view().await?;
311− }
312− app.reset_cursor();
313− app.inputmode = InputMode::Normal;
314− }
315− InputMode::PlaylistRename => {
316− if let Some(old_name) = app.pl_list.list.get(app.pl_list.index).cloned() {
317− if !app.pl_newname_input.trim().is_empty() {
318− app.conn
319− .rename_playlist(&old_name, &app.pl_newname_input)
320− .await?;
321− app.refresh_playlists().await?;
322− }
323− }
324− app.pl_newname_input.clear();
325− app.reset_cursor();
326− app.inputmode = InputMode::Normal;
327− }
328− InputMode::NewPlaylist => {
329− if !app.pl_new_pl_input.trim().is_empty() {
330− for song in &app.pl_new_pl_songs_buffer {
331− app.conn.add_to_playlist(&app.pl_new_pl_input, song).await?;
332− }
333− app.refresh_playlists().await?;
334− }
335− app.pl_new_pl_input.clear();
336− app.reset_cursor();
337− app.inputmode = InputMode::Normal;
338− }
339− InputMode::Normal => {}
340− }
341− } else if let KeyCode::Char(character) = key.code {
342− if !key
343− .modifiers
344− .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
345− {
346− app.enter_char(character);
347− if app.inputmode == InputMode::Editing {
348− update_search_selection(app);
349− }
350− }
351− }
352− Ok(())
353−}
354−
355−fn update_search_selection(app: &mut App) {
356− if app.search_input.is_empty() {
357− return;
358− }
359− match app.selected_tab {
360− SelectedTab::Queue => {
361− let items: Vec<&str> = app
362− .queue_list
363− .list
364− .iter()
365− .map(|song| song.file.as_str())
366− .collect();
367− if let Some((best, _)) = fuzzy_search_sorted(&app.search_input, &items).first() {
368− if let Some(index) = app
369− .queue_list
370− .list
371− .iter()
372− .position(|song| song.file == *best)
373− {
374− app.queue_list.index = index;
375− }
376− }
377− }
378− SelectedTab::DirectoryBrowser => {
379− let items: Vec<String> = app
380− .browser
381− .entries
382− .iter()
383− .map(|entry| entry.name().to_string())
384− .collect();
385− app.browser_search_results = fuzzy_indices(&app.search_input, &items);
386− app.browser_search_index = 0;
387− if let Some(index) = app.browser_search_results.first() {
388− app.browser.selected = *index;
389− }
390− }
391− SelectedTab::Playlists => {
392− let items: Vec<&str> = app.pl_list.list.iter().map(String::as_str).collect();
393− if let Some((best, _)) = fuzzy_search_sorted(&app.search_input, &items).first() {
394− if let Some(index) = app.pl_list.list.iter().position(|name| name == *best) {
395− app.pl_list.index = index;
396− }
397− }
398− }
399− }
400−}
401−
402−fn cycle_browser_search(app: &mut App, forward: bool) {
403− let len = app.browser_search_results.len();
404− if len == 0 {
405− return;
406− }
407− app.browser_search_index = if forward {
408− (app.browser_search_index + 1) % len
409− } else {
410− app.browser_search_index.checked_sub(1).unwrap_or(len - 1)
411− };
412− app.browser.selected = app.browser_search_results[app.browser_search_index];
413−}
414−
415−fn fuzzy_indices(query: &str, items: &[String]) -> Vec<usize> {
416− if query.is_empty() {
417− return Vec::new();
418− }
419− let candidates: Vec<&str> = items.iter().map(String::as_str).collect();
420− let ranked = fuzzy_search_sorted(query, &candidates);
421− let mut used = vec![false; items.len()];
422− ranked
423− .into_iter()
424− .filter(|(_, score)| *score > 0.0)
425− .filter_map(|(name, _)| {
426− let index = items
427− .iter()
428− .enumerate()
429− .find(|(index, item)| !used[*index] && item.as_str() == name)
430− .map(|(index, _)| index)?;
431− used[index] = true;
432− Some(index)
433− })
434− .collect()
435−}
436−
437−pub async fn handle_mouse_events(mouse: MouseEvent, app: &mut App) -> AppResult<()> {
438− let previous_playlist = app.pl_list.index;
439− match mouse.kind {
440− MouseEventKind::ScrollUp => app.handle_scroll_up(),
441− MouseEventKind::ScrollDown => app.handle_scroll_down(),
442− _ => {}
443− }
444− if app.selected_tab == SelectedTab::Playlists && app.pl_list.index != previous_playlist {
445− app.playlist_song_index = 0;
446− app.refresh_playlist_view().await?;
447− }
448− Ok(())
449−}
450−
451−#[cfg(test)]
452−mod tests {
453− use super::fuzzy_indices;
454−
455− #[test]
456− fn fuzzy_search_returns_all_matches_and_keeps_duplicate_names() {
457− let items = vec![
458− "mr-robot-vol1".to_string(),
459− "mr-robot-vol2".to_string(),
460− "mr-robot-vol3".to_string(),
461− "other-album".to_string(),
462− ];
463− let results = fuzzy_indices("robot", &items);
464− assert_eq!(results.len(), 3);
465− assert_eq!(
466− results
467− .iter()
468− .map(|index| items[*index].as_str())
469− .collect::<Vec<_>>(),
470− vec!["mr-robot-vol1", "mr-robot-vol2", "mr-robot-vol3"]
471− );
472− }
473−}
D src/event_handler/mod.rs View
@@ -1 +0,0 @@
1−pub mod handler;
M src/lib.rs View

Mode 100755 → 100644

@@ -1,15 +1,4 @@
1−//! An asynchronous terminal client for Music Player Daemon.
2−
3−pub mod app;
4−pub mod browser;
1+pub mod action;
52 pub mod config;
6−pub mod connection;
7−pub mod event_handler;
8−pub mod list;
9−pub mod logging;
10−pub mod tui;
11−pub mod ui;
12−pub mod utils;
13−
14−/// The application's error type.
15−pub type AppResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
3+pub mod mpd;
4+pub mod util;
D src/list.rs View
@@ -1,30 +0,0 @@
1−#[derive(Debug)]
2−pub struct ContentList<T> {
3− pub list: Vec<T>,
4− pub index: usize,
5−}
6−
7−impl<T> ContentList<T> {
8− pub fn new() -> Self {
9− Self {
10− list: Vec::new(),
11− index: 0,
12− }
13− }
14−
15− pub fn next(&mut self) {
16− if let Some(last) = self.list.len().checked_sub(1) {
17− self.index = self.index.saturating_add(1).min(last);
18− }
19− }
20−
21− pub fn prev(&mut self) {
22− self.index = self.index.saturating_sub(1);
23− }
24−}
25−
26−impl<T> Default for ContentList<T> {
27− fn default() -> Self {
28− Self::new()
29− }
30−}
D src/logging.rs View
@@ -1,86 +0,0 @@
1−use std::{
2− fs::{self, OpenOptions},
3− io::{self, Write},
4− path::{Path, PathBuf},
5− sync::{Mutex, OnceLock},
6− time::{SystemTime, UNIX_EPOCH},
7−};
8−
9−use crate::AppResult;
10−
11−static LOG_FILE: OnceLock<Mutex<fs::File>> = OnceLock::new();
12−
13−pub fn init(path: &Path) -> AppResult<PathBuf> {
14− if let Some(parent) = path.parent() {
15− fs::create_dir_all(parent)?;
16− }
17−
18− let mut options = OpenOptions::new();
19− options.create(true).append(true);
20− #[cfg(unix)]
21− {
22− use std::os::unix::fs::OpenOptionsExt;
23− options.mode(0o600);
24− }
25− let file = options.open(path)?;
26− let _ = LOG_FILE.set(Mutex::new(file));
27−
28− let panic_hook = std::panic::take_hook();
29− std::panic::set_hook(Box::new(move |panic| {
30− let message = panic
31− .payload()
32− .downcast_ref::<&str>()
33− .copied()
34− .or_else(|| panic.payload().downcast_ref::<String>().map(String::as_str))
35− .unwrap_or("non-string panic payload");
36− let location = panic
37− .location()
38− .map(|location| {
39− format!(
40− " at {}:{}:{}",
41− location.file(),
42− location.line(),
43− location.column()
44− )
45− })
46− .unwrap_or_default();
47− error(&format!("panic: {message}{location}"));
48− panic_hook(panic);
49− }));
50−
51− info(&format!("logging initialized: {}", path.display()));
52− Ok(path.to_path_buf())
53−}
54−
55−pub fn info(message: &str) {
56− write("INFO", message);
57−}
58−
59−pub fn error(message: &str) {
60− write("ERROR", message);
61−}
62−
63−fn write(level: &str, message: &str) {
64− let Some(file) = LOG_FILE.get() else {
65− return;
66− };
67−
68− let timestamp = SystemTime::now()
69− .duration_since(UNIX_EPOCH)
70− .unwrap_or_default();
71− let line = format!(
72− "[{}.{:03}] {level} {message}",
73− timestamp.as_secs(),
74− timestamp.subsec_millis()
75− );
76− let mut file = file
77− .lock()
78− .unwrap_or_else(std::sync::PoisonError::into_inner);
79− if let Err(error) = writeln!(file, "{line}") {
80− report_write_error(error);
81− }
82−}
83−
84−fn report_write_error(error: io::Error) {
85− eprintln!("failed to write rmptui log: {error}");
86−}
M src/main.rs View

Mode 100755 → 100644

@@ -1,188 +1 @@
1−use std::{env, io, net::Ipv6Addr, str::FromStr, time::Duration};
2−
3−use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind};
4−use futures_util::StreamExt;
5−use ratatui::prelude::*;
6−use rmptui::{
7− app::App,
8− config::Config,
9− connection::{Connection, MpdSnapshot},
10− event_handler::handler,
11− logging, tui, AppResult,
12−};
13−use tokio::{sync::mpsc, time};
14−
15−#[tokio::main]
16−async fn main() -> AppResult<()> {
17− let log_path = std::path::Path::new("/tmp/rmptui.log");
18− if let Err(error) = logging::init(log_path) {
19− eprintln!(
20− "Could not initialize logging at {}: {error}",
21− log_path.display()
22− );
23− }
24−
25− let result = run().await;
26− if let Err(error) = &result {
27− logging::error(&format!("application exited with error: {error}"));
28− }
29− result
30−}
31−
32−async fn run() -> AppResult<()> {
33− let (config, config_path) = Config::load_or_create()?;
34− eprintln!("Using configuration: {}", config_path.display());
35−
36− let configured_host = env::var("MPD_HOST").unwrap_or_else(|_| config.mpd.host.clone());
37− let (host, password) = configured_host
38− .split_once('@')
39− .map(|(password, host)| (host, Some(password.to_string())))
40− .unwrap_or_else(|| {
41− (
42− configured_host.as_str(),
43− (!config.mpd.password.is_empty()).then(|| config.mpd.password.clone()),
44− )
45− });
46− if host.is_empty() {
47− return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPD host cannot be empty").into());
48− }
49− let port = env::var("MPD_PORT")
50− .map(|port| {
51− port.parse::<u16>().map_err(|error| {
52− io::Error::new(
53− std::io::ErrorKind::InvalidInput,
54− format!("invalid MPD_PORT value `{port}`: {error}"),
55− )
56− })
57− })
58− .unwrap_or(Ok(config.mpd.port))?;
59− if port == 0 {
60− return Err(io::Error::new(
61− std::io::ErrorKind::InvalidInput,
62− "MPD port must be between 1 and 65535",
63− )
64− .into());
65− }
66− let address = mpd_address(host, port);
67− let mut app = App::new(&address, password.as_deref(), config).await?;
68−
69− let backend = CrosstermBackend::new(io::stderr());
70− let terminal = Terminal::new(backend)?;
71− let mut tui = tui::Tui::new(terminal);
72− tui.init()?;
73−
74− let (updates_tx, mut updates_rx) = mpsc::channel(16);
75− let watcher_address = address.clone();
76− let watcher_password = password.clone();
77− let watcher = tokio::spawn(async move {
78− let result = watch_mpd(watcher_address, watcher_password, updates_tx).await;
79− if let Err(error) = &result {
80− logging::error(&format!("MPD change watcher failed: {error}"));
81− }
82− result
83− });
84− let mut terminal_events = EventStream::new();
85− let mut redraw = time::interval(Duration::from_millis(50));
86− redraw.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
87−
88− let run_result: AppResult<()> = async {
89− while app.running {
90− tui.draw(&mut app)?;
91− tokio::select! {
92− event = terminal_events.next() => {
93− match event {
94− Some(Ok(CrosstermEvent::Key(key)))
95− if key.kind != KeyEventKind::Release =>
96− {
97− handler::handle_key_events(key, &mut app).await?;
98− }
99− Some(Ok(CrosstermEvent::Key(_))) => {}
100− Some(Ok(CrosstermEvent::Mouse(mouse))) => {
101− handler::handle_mouse_events(mouse, &mut app).await?;
102− }
103− Some(Ok(CrosstermEvent::Resize(_, _)))
104− | Some(Ok(CrosstermEvent::FocusGained))
105− | Some(Ok(CrosstermEvent::FocusLost))
106− | Some(Ok(CrosstermEvent::Paste(_))) => {}
107− Some(Err(error)) => {
108− return Err(Box::new(error) as Box<dyn std::error::Error + Send + Sync>);
109− }
110− None => {
111− return Err(std::io::Error::other("terminal event stream ended").into());
112− }
113− }
114− }
115− _ = redraw.tick() => {}
116− snapshot = updates_rx.recv() => {
117− match snapshot {
118− Some(snapshot) => app.apply_snapshot(snapshot).await?,
119− None => {
120− return Err(std::io::Error::other(
121− "MPD change watcher stopped unexpectedly",
122− )
123− .into());
124− }
125− }
126− }
127− }
128− }
129− Ok(())
130− }
131− .await;
132−
133− watcher.abort();
134− let exit_result = tui.exit();
135− run_result?;
136− exit_result?;
137− Ok(())
138−}
139−
140−async fn watch_mpd(
141− address: String,
142− password: Option<String>,
143− updates: mpsc::Sender<MpdSnapshot>,
144−) -> AppResult<()> {
145− let mut client = Connection::connect(&address, password.as_deref()).await?;
146− loop {
147− client.idle().await?;
148− let snapshot = client.snapshot().await?;
149− if updates.send(snapshot).await.is_err() {
150− return Ok(());
151− }
152− }
153−}
154−
155−fn mpd_address(host: &str, port: u16) -> String {
156− if host.starts_with('[') && host.contains("]:") {
157− host.to_string()
158− } else if let Some(ipv6) = host
159− .strip_prefix('[')
160− .and_then(|host| host.strip_suffix(']'))
161− .filter(|host| Ipv6Addr::from_str(host).is_ok())
162− {
163− format!("[{ipv6}]:{port}")
164− } else if host.contains(':') && Ipv6Addr::from_str(host).is_ok() {
165− format!("[{host}]:{port}")
166− } else if host
167− .rsplit_once(':')
168− .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok())
169− {
170− host.to_string()
171− } else {
172− format!("{host}:{port}")
173− }
174−}
175−
176−#[cfg(test)]
177−mod tests {
178− use super::mpd_address;
179−
180− #[test]
181− fn formats_mpd_host_addresses() {
182− assert_eq!(mpd_address("localhost", 6600), "localhost:6600");
183− assert_eq!(mpd_address("localhost:6601", 6600), "localhost:6601");
184− assert_eq!(mpd_address("[::1]", 6600), "[::1]:6600");
185− assert_eq!(mpd_address("[::1]:6601", 6600), "[::1]:6601");
186− assert_eq!(mpd_address("::1", 6600), "[::1]:6600");
187− }
188−}
1+fn main() {}
A src/mpd/commands.rs View
@@ -0,0 +1,162 @@
1+//! Constructors for the commands rmptui sends.
2+
3+use super::protocol::Command;
4+
5+fn bool_arg(value: bool) -> &'static str {
6+ if value { "1" } else { "0" }
7+}
8+
9+pub fn play() -> Command {
10+ Command::new("play")
11+}
12+
13+pub fn play_pos(pos: u32) -> Command {
14+ Command::new("play").arg(pos.to_string())
15+}
16+
17+pub fn play_id(id: u32) -> Command {
18+ Command::new("playid").arg(id.to_string())
19+}
20+
21+pub fn pause(paused: bool) -> Command {
22+ Command::new("pause").arg(bool_arg(paused))
23+}
24+
25+pub fn stop() -> Command {
26+ Command::new("stop")
27+}
28+
29+pub fn next() -> Command {
30+ Command::new("next")
31+}
32+
33+pub fn previous() -> Command {
34+ Command::new("previous")
35+}
36+
37+/// Seeks relative to the current position.
38+pub fn seek_relative(seconds: i64) -> Command {
39+ Command::new("seekcur").arg(format!("{seconds:+}"))
40+}
41+
42+pub fn seek_to(seconds: f64) -> Command {
43+ Command::new("seekcur").arg(format!("{seconds:.3}"))
44+}
45+
46+/// Changes the volume relative to the current level. The server applies the
47+/// change atomically, so fast repeated key presses are never lost.
48+pub fn change_volume(delta: i32) -> Command {
49+ Command::new("volume").arg(format!("{delta:+}"))
50+}
51+
52+pub fn set_volume(volume: u8) -> Command {
53+ Command::new("setvol").arg(volume.min(100).to_string())
54+}
55+
56+pub fn repeat(on: bool) -> Command {
57+ Command::new("repeat").arg(bool_arg(on))
58+}
59+
60+pub fn random(on: bool) -> Command {
61+ Command::new("random").arg(bool_arg(on))
62+}
63+
64+pub fn single(on: bool) -> Command {
65+ Command::new("single").arg(bool_arg(on))
66+}
67+
68+pub fn consume(on: bool) -> Command {
69+ Command::new("consume").arg(bool_arg(on))
70+}
71+
72+pub fn update(path: Option<&str>) -> Command {
73+ let command = Command::new("update");
74+ match path {
75+ Some(path) if !path.is_empty() => command.arg(path),
76+ _ => command,
77+ }
78+}
79+
80+pub fn clear() -> Command {
81+ Command::new("clear")
82+}
83+
84+pub fn shuffle() -> Command {
85+ Command::new("shuffle")
86+}
87+
88+/// Adds a song or (recursively) a directory to the queue.
89+pub fn add(uri: &str) -> Command {
90+ Command::new("add").arg(uri)
91+}
92+
93+pub fn add_id(uri: &str) -> Command {
94+ Command::new("addid").arg(uri)
95+}
96+
97+pub fn delete_id(id: u32) -> Command {
98+ Command::new("deleteid").arg(id.to_string())
99+}
100+
101+pub fn move_id(id: u32, to: u32) -> Command {
102+ Command::new("moveid")
103+ .arg(id.to_string())
104+ .arg(to.to_string())
105+}
106+
107+pub fn find_add(filter: &str) -> Command {
108+ Command::new("findadd").arg(filter)
109+}
110+
111+pub fn load(playlist: &str) -> Command {
112+ Command::new("load").arg(playlist)
113+}
114+
115+pub fn save(playlist: &str) -> Command {
116+ Command::new("save").arg(playlist)
117+}
118+
119+pub fn rename(from: &str, to: &str) -> Command {
120+ Command::new("rename").arg(from).arg(to)
121+}
122+
123+pub fn remove_playlist(playlist: &str) -> Command {
124+ Command::new("rm").arg(playlist)
125+}
126+
127+/// Adds a song or directory to a stored playlist, creating it if needed.
128+pub fn playlist_add(playlist: &str, uri: &str) -> Command {
129+ Command::new("playlistadd").arg(playlist).arg(uri)
130+}
131+
132+/// Adds every song matching `filter` to a stored playlist.
133+pub fn playlist_add_filter(playlist: &str, filter: &str) -> Command {
134+ Command::new("searchaddpl").arg(playlist).arg(filter)
135+}
136+
137+pub fn playlist_delete(playlist: &str, pos: u32) -> Command {
138+ Command::new("playlistdelete")
139+ .arg(playlist)
140+ .arg(pos.to_string())
141+}
142+
143+pub fn playlist_move(playlist: &str, from: u32, to: u32) -> Command {
144+ Command::new("playlistmove")
145+ .arg(playlist)
146+ .arg(from.to_string())
147+ .arg(to.to_string())
148+}
149+
150+#[cfg(test)]
151+mod tests {
152+ use super::*;
153+
154+ #[test]
155+ fn formats_relative_arguments() {
156+ assert_eq!(seek_relative(5).as_str(), r#"seekcur "+5""#);
157+ assert_eq!(seek_relative(-5).as_str(), r#"seekcur "-5""#);
158+ assert_eq!(change_volume(-2).as_str(), r#"volume "-2""#);
159+ assert_eq!(update(None).as_str(), "update");
160+ assert_eq!(set_volume(140).as_str(), r#"setvol "100""#);
161+ }
162+}
A src/mpd/connection.rs View
@@ -0,0 +1,156 @@
1+//! An asynchronous MPD connection over TCP or a Unix socket.
2+
3+use std::{fmt, path::PathBuf};
4+
5+use tokio::{
6+ io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufStream},
7+ net::TcpStream,
8+};
9+
10+use super::{
11+ error::{MpdError, MpdResult},
12+ protocol::{self, Command, Line, Pair},
13+};
14+
15+/// Where the server listens.
16+#[derive(Clone, Debug, PartialEq, Eq)]
17+pub enum Address {
18+ /// `host:port`, with IPv6 hosts in brackets.
19+ Tcp(String),
20+ Unix(PathBuf),
21+}
22+
23+impl fmt::Display for Address {
24+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25+ match self {
26+ Self::Tcp(addr) => f.write_str(addr),
27+ Self::Unix(path) => write!(f, "{}", path.display()),
28+ }
29+ }
30+}
31+
32+trait Socket: AsyncRead + AsyncWrite + Unpin + Send {}
33+impl<T: AsyncRead + AsyncWrite + Unpin + Send> Socket for T {}
34+
35+pub struct Connection {
36+ stream: BufStream<Box<dyn Socket>>,
37+ version: String,
38+ line: Vec<u8>,
39+}
40+
41+impl fmt::Debug for Connection {
42+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43+ f.debug_struct("Connection")
44+ .field("version", &self.version)
45+ .finish_non_exhaustive()
46+ }
47+}
48+
49+impl Connection {
50+ /// Connects, reads the greeting and authenticates if `password` is set.
51+ pub async fn connect(address: &Address, password: Option<&str>) -> MpdResult<Self> {
52+ let socket: Box<dyn Socket> = match address {
53+ Address::Tcp(addr) => {
54+ let stream = TcpStream::connect(addr).await?;
55+ stream.set_nodelay(true)?;
56+ Box::new(stream)
57+ }
58+ #[cfg(unix)]
59+ Address::Unix(path) => Box::new(tokio::net::UnixStream::connect(path).await?),
60+ #[cfg(not(unix))]
61+ Address::Unix(_) => {
62+ return Err(MpdError::Protocol(
63+ "Unix sockets are not supported on this platform".into(),
64+ ));
65+ }
66+ };
67+ let mut conn = Self {
68+ stream: BufStream::new(socket),
69+ version: String::new(),
70+ line: Vec::new(),
71+ };
72+ let greeting = conn.read_line().await?;
73+ conn.version = protocol::parse_greeting(&greeting)?;
74+ if let Some(password) = password.filter(|p| !p.is_empty()) {
75+ conn.execute(&Command::new("password").arg(password))
76+ .await?;
77+ }
78+ Ok(conn)
79+ }
80+
81+ /// The protocol version announced by the server.
82+ pub fn version(&self) -> &str {
83+ &self.version
84+ }
85+
86+ /// Runs one command and collects its response.
87+ pub async fn execute(&mut self, command: &Command) -> MpdResult<Vec<Pair>> {
88+ self.send(command.as_str()).await?;
89+ self.read_response().await
90+ }
91+
92+ /// Runs commands atomically as a command list, returning one response per
93+ /// command.
94+ pub async fn execute_list(&mut self, commands: &[Command]) -> MpdResult<Vec<Vec<Pair>>> {
95+ let mut request = String::from("command_list_ok_begin\n");
96+ for command in commands {
97+ request.push_str(command.as_str());
98+ request.push('\n');
99+ }
100+ request.push_str("command_list_end");
101+ self.send(&request).await?;
102+
103+ let mut responses = Vec::with_capacity(commands.len());
104+ let mut current = Vec::new();
105+ loop {
106+ let line = self.read_line().await?;
107+ match protocol::parse_line(&line)? {
108+ Line::Pair(pair) => current.push(pair),
109+ Line::ListOk => responses.push(std::mem::take(&mut current)),
110+ Line::Ok => return Ok(responses),
111+ }
112+ }
113+ }
114+
115+ /// Runs commands as a list when there is more than one, discarding output.
116+ pub async fn execute_all(&mut self, commands: &[Command]) -> MpdResult<()> {
117+ match commands {
118+ [] => Ok(()),
119+ [command] => self.execute(command).await.map(drop),
120+ _ => self.execute_list(commands).await.map(drop),
121+ }
122+ }
123+
124+ async fn send(&mut self, request: &str) -> MpdResult<()> {
125+ self.stream.write_all(request.as_bytes()).await?;
126+ self.stream.write_all(b"\n").await?;
127+ self.stream.flush().await?;
128+ Ok(())
129+ }
130+
131+ async fn read_response(&mut self) -> MpdResult<Vec<Pair>> {
132+ let mut pairs = Vec::new();
133+ loop {
134+ let line = self.read_line().await?;
135+ match protocol::parse_line(&line)? {
136+ Line::Pair(pair) => pairs.push(pair),
137+ Line::Ok => return Ok(pairs),
138+ Line::ListOk => {
139+ return Err(MpdError::Protocol("unexpected list_OK".into()));
140+ }
141+ }
142+ }
143+ }
144+
145+ async fn read_line(&mut self) -> MpdResult<String> {
146+ self.line.clear();
147+ if self.stream.read_until(b'\n', &mut self.line).await? == 0 {
148+ return Err(MpdError::Closed);
149+ }
150+ if self.line.last() == Some(&b'\n') {
151+ self.line.pop();
152+ }
153+ // Tags are UTF-8 by protocol; tolerate broken files instead of failing.
154+ Ok(String::from_utf8_lossy(&self.line).into_owned())
155+ }
156+}
A src/mpd/error.rs View
@@ -0,0 +1,42 @@
1+use std::{fmt, io};
2+
3+/// An `ACK` error returned by the server.
4+#[derive(Clone, Debug, PartialEq, Eq)]
5+pub struct Ack {
6+ pub code: u32,
7+ /// Index of the failing command inside a command list.
8+ pub index: u32,
9+ pub command: String,
10+ pub message: String,
11+}
12+
13+impl fmt::Display for Ack {
14+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15+ if self.command.is_empty() {
16+ write!(f, "{}", self.message)
17+ } else {
18+ write!(f, "{}: {}", self.command, self.message)
19+ }
20+ }
21+}
22+
23+#[derive(Debug, thiserror::Error)]
24+pub enum MpdError {
25+ #[error("connection error: {0}")]
26+ Io(#[from] io::Error),
27+ #[error("MPD error: {0}")]
28+ Ack(Ack),
29+ #[error("protocol error: {0}")]
30+ Protocol(String),
31+ #[error("connection closed by server")]
32+ Closed,
33+}
34+
35+impl MpdError {
36+ /// Whether the connection is unusable after this error.
37+ pub fn is_fatal(&self) -> bool {
38+ !matches!(self, Self::Ack(_))
39+ }
40+}
41+
42+pub type MpdResult<T> = Result<T, MpdError>;
A src/mpd/mod.rs View
@@ -0,0 +1,14 @@
1+//! MPD client: protocol, connection, typed models and background workers.
2+
3+pub mod commands;
4+mod connection;
5+mod error;
6+pub mod protocol;
7+mod types;
8+mod worker;
9+
10+pub use connection::{Address, Connection};
11+pub use error::{Ack, MpdError, MpdResult};
12+pub use protocol::Command;
13+pub use types::{Album, DirEntry, OnOff, PlayState, PlaylistInfo, Song, Status, Subsystem};
14+pub use worker::{MpdEvent, MpdHandle, Query, Reply, Request, spawn};
A src/mpd/protocol.rs View
@@ -0,0 +1,227 @@
1+//! Wire format of MPD's text protocol: command encoding and response parsing.
2+//!
3+//! Everything here is pure so it can be tested without a server.
4+
5+use std::fmt;
6+
7+use super::error::{Ack, MpdError};
8+
9+/// A single key/value line of an MPD response.
10+pub type Pair = (String, String);
11+
12+/// A command ready to be written to the socket.
13+///
14+/// Arguments are always quoted and escaped, so user-provided names and paths
15+/// can never inject additional commands.
16+#[derive(Clone, Debug, PartialEq, Eq)]
17+pub struct Command {
18+ line: String,
19+}
20+
21+impl Command {
22+ pub fn new(name: &str) -> Self {
23+ Self {
24+ line: name.to_owned(),
25+ }
26+ }
27+
28+ /// Appends a quoted argument.
29+ #[must_use]
30+ pub fn arg(mut self, value: impl AsRef<str>) -> Self {
31+ self.line.push(' ');
32+ push_quoted(&mut self.line, value.as_ref());
33+ self
34+ }
35+
36+ /// The encoded command line, without the trailing newline.
37+ pub fn as_str(&self) -> &str {
38+ &self.line
39+ }
40+
41+ /// The command name, used in error messages.
42+ pub fn name(&self) -> &str {
43+ self.line.split(' ').next().unwrap_or_default()
44+ }
45+}
46+
47+impl fmt::Display for Command {
48+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49+ f.write_str(&self.line)
50+ }
51+}
52+
53+fn push_quoted(out: &mut String, value: &str) {
54+ out.push('"');
55+ out.push_str(&escape(value));
56+ out.push('"');
57+}
58+
59+/// Escapes `\` and `"` so that `value` can be placed inside double quotes.
60+pub fn escape(value: &str) -> String {
61+ let mut escaped = String::with_capacity(value.len());
62+ for ch in value.chars() {
63+ if matches!(ch, '"' | '\\') {
64+ escaped.push('\\');
65+ }
66+ escaped.push(ch);
67+ }
68+ escaped
69+}
70+
71+/// Builds an MPD filter expression `(TAG == "VALUE")`.
72+pub fn filter_eq(tag: &str, value: &str) -> String {
73+ format!("({tag} == \"{}\")", escape(value))
74+}
75+
76+/// Combines filter expressions with `AND`.
77+pub fn filter_and<I, S>(filters: I) -> String
78+where
79+ I: IntoIterator<Item = S>,
80+ S: AsRef<str>,
81+{
82+ let parts: Vec<String> = filters.into_iter().map(|f| f.as_ref().to_owned()).collect();
83+ match parts.as_slice() {
84+ [single] => single.clone(),
85+ _ => format!("({})", parts.join(" AND ")),
86+ }
87+}
88+
89+/// One parsed response line.
90+#[derive(Debug, PartialEq, Eq)]
91+pub enum Line {
92+ /// `OK`: the response is complete.
93+ Ok,
94+ /// `list_OK`: one command of a `command_list_ok_begin` list is complete.
95+ ListOk,
96+ /// A `key: value` pair.
97+ Pair(Pair),
98+}
99+
100+/// Parses one response line (without its trailing newline).
101+pub fn parse_line(line: &str) -> Result<Line, MpdError> {
102+ match line {
103+ "OK" => Ok(Line::Ok),
104+ "list_OK" => Ok(Line::ListOk),
105+ _ if line.starts_with("ACK ") => Err(MpdError::Ack(parse_ack(line)?)),
106+ _ => line
107+ .split_once(": ")
108+ .map(|(key, value)| Line::Pair((key.to_owned(), value.to_owned())))
109+ .ok_or_else(|| MpdError::Protocol(format!("unexpected response line `{line}`"))),
110+ }
111+}
112+
113+/// Parses `ACK [error@command_listNum] {current_command} message_text`.
114+fn parse_ack(line: &str) -> Result<Ack, MpdError> {
115+ let malformed = || MpdError::Protocol(format!("malformed ACK line `{line}`"));
116+ let rest = line.strip_prefix("ACK [").ok_or_else(malformed)?;
117+ let (codes, rest) = rest.split_once("] {").ok_or_else(malformed)?;
118+ let (command, message) = rest.split_once('}').ok_or_else(malformed)?;
119+ let (code, index) = codes.split_once('@').ok_or_else(malformed)?;
120+ Ok(Ack {
121+ code: code.parse().map_err(|_| malformed())?,
122+ index: index.parse().map_err(|_| malformed())?,
123+ command: command.to_owned(),
124+ message: message.trim_start().to_owned(),
125+ })
126+}
127+
128+/// Parses the `OK MPD <version>` greeting.
129+pub fn parse_greeting(line: &str) -> Result<String, MpdError> {
130+ line.strip_prefix("OK MPD ")
131+ .map(str::to_owned)
132+ .ok_or_else(|| MpdError::Protocol(format!("not an MPD server (greeting `{line}`)")))
133+}
134+
135+/// Splits a flat list of pairs into groups, starting a new group whenever a
136+/// key from `starts` is encountered. Pairs before the first start key are
137+/// dropped.
138+pub fn split_groups<'a>(pairs: &'a [Pair], starts: &[&str]) -> Vec<&'a [Pair]> {
139+ let mut bounds: Vec<usize> = pairs
140+ .iter()
141+ .enumerate()
142+ .filter(|(_, (key, _))| starts.contains(&key.as_str()))
143+ .map(|(index, _)| index)
144+ .collect();
145+ bounds.push(pairs.len());
146+ bounds.windows(2).map(|w| &pairs[w[0]..w[1]]).collect()
147+}
148+
149+#[cfg(test)]
150+mod tests {
151+ use super::*;
152+
153+ #[test]
154+ fn quotes_and_escapes_arguments() {
155+ let cmd = Command::new("add").arg(r#"dir/a "quoted" \ name"#);
156+ assert_eq!(cmd.as_str(), r#"add "dir/a \"quoted\" \\ name""#);
157+ assert_eq!(cmd.name(), "add");
158+ }
159+
160+ #[test]
161+ fn newlines_cannot_start_a_new_command() {
162+ // Newlines stay inside the quoted argument; MPD rejects the argument
163+ // rather than executing a second command.
164+ let cmd = Command::new("load").arg("x\"\nclear");
165+ assert_eq!(cmd.as_str().matches('"').count(), 3);
166+ assert!(cmd.as_str().contains("\\\""));
167+ }
168+
169+ #[test]
170+ fn builds_filters() {
171+ assert_eq!(filter_eq("Artist", r#"A "B""#), r#"(Artist == "A \"B\"")"#);
172+ assert_eq!(
173+ filter_and([filter_eq("Artist", "x"), filter_eq("Album", "y")]),
174+ r#"((Artist == "x") AND (Album == "y"))"#
175+ );
176+ assert_eq!(filter_and([filter_eq("Album", "y")]), r#"(Album == "y")"#);
177+ // Filters are quoted once more when used as an argument.
178+ let cmd = Command::new("find").arg(filter_eq("Album", r"a\b"));
179+ assert_eq!(cmd.as_str(), r#"find "(Album == \"a\\\\b\")""#);
180+ }
181+
182+ #[test]
183+ fn parses_lines() {
184+ assert_eq!(parse_line("OK").unwrap(), Line::Ok);
185+ assert_eq!(parse_line("list_OK").unwrap(), Line::ListOk);
186+ assert_eq!(
187+ parse_line("Title: a: b").unwrap(),
188+ Line::Pair(("Title".into(), "a: b".into()))
189+ );
190+ assert!(matches!(parse_line("garbage"), Err(MpdError::Protocol(_))));
191+ }
192+
193+ #[test]
194+ fn parses_ack() {
195+ let Err(MpdError::Ack(ack)) = parse_line("ACK [50@1] {play} No such song") else {
196+ panic!("expected ACK");
197+ };
198+ assert_eq!(ack.code, 50);
199+ assert_eq!(ack.index, 1);
200+ assert_eq!(ack.command, "play");
201+ assert_eq!(ack.message, "No such song");
202+ }
203+
204+ #[test]
205+ fn parses_greeting() {
206+ assert_eq!(parse_greeting("OK MPD 0.24.0").unwrap(), "0.24.0");
207+ assert!(parse_greeting("HTTP/1.1 400").is_err());
208+ }
209+
210+ #[test]
211+ fn splits_groups() {
212+ let pairs: Vec<Pair> = [
213+ ("x", "0"),
214+ ("file", "a"),
215+ ("Title", "A"),
216+ ("directory", "d"),
217+ ("file", "b"),
218+ ]
219+ .iter()
220+ .map(|(k, v)| ((*k).into(), (*v).into()))
221+ .collect();
222+ let groups = split_groups(&pairs, &["file", "directory"]);
223+ assert_eq!(groups.len(), 3);
224+ assert_eq!(groups[0].len(), 2);
225+ assert_eq!(groups[1][0].1, "d");
226+ }
227+}
A src/mpd/types.rs View
@@ -0,0 +1,425 @@
1+//! Typed models built from MPD responses.
2+
3+use std::{path::Path, str::FromStr, time::Duration};
4+
5+use super::protocol::{Pair, split_groups};
6+
7+/// A song as reported by MPD (library entry or queue item).
8+#[derive(Clone, Debug, Default, PartialEq)]
9+pub struct Song {
10+ /// Library-relative path or URL.
11+ pub file: String,
12+ /// Queue id, present for queue items only.
13+ pub id: Option<u32>,
14+ /// Queue position, present for queue items only.
15+ pub pos: Option<u32>,
16+ pub duration: Option<Duration>,
17+ /// Tag values in server order. A tag may occur multiple times.
18+ pub tags: Vec<(String, String)>,
19+}
20+
21+impl Song {
22+ pub fn from_pairs(pairs: &[Pair]) -> Self {
23+ let mut song = Self::default();
24+ for (key, value) in pairs {
25+ match key.as_str() {
26+ "file" => song.file.clone_from(value),
27+ "Id" => song.id = value.parse().ok(),
28+ "Pos" => song.pos = value.parse().ok(),
29+ "duration" => song.duration = parse_seconds(value),
30+ // Older servers only report the integral `Time`.
31+ "Time" if song.duration.is_none() => song.duration = parse_seconds(value),
32+ "Last-Modified" | "Added" | "Format" | "Time" | "Range" | "Prio" => {}
33+ _ => song.tags.push((key.clone(), value.clone())),
34+ }
35+ }
36+ song
37+ }
38+
39+ /// The first value of `tag` (case-insensitive).
40+ pub fn tag(&self, tag: &str) -> Option<&str> {
41+ self.tags
42+ .iter()
43+ .find(|(key, _)| key.eq_ignore_ascii_case(tag))
44+ .map(|(_, value)| value.as_str())
45+ }
46+
47+ pub fn title(&self) -> Option<&str> {
48+ self.tag("Title")
49+ }
50+
51+ pub fn artist(&self) -> Option<&str> {
52+ self.tag("Artist")
53+ }
54+
55+ pub fn album(&self) -> Option<&str> {
56+ self.tag("Album")
57+ }
58+
59+ pub fn album_artist(&self) -> Option<&str> {
60+ self.tag("AlbumArtist").or_else(|| self.artist())
61+ }
62+
63+ /// The file name without its directory.
64+ pub fn file_name(&self) -> &str {
65+ self.file.rsplit('/').next().unwrap_or(&self.file)
66+ }
67+
68+ /// The title, falling back to the file name without extension.
69+ pub fn display_title(&self) -> &str {
70+ self.title().unwrap_or_else(|| {
71+ let name = self.file_name();
72+ Path::new(name)
73+ .file_stem()
74+ .and_then(|stem| stem.to_str())
75+ .unwrap_or(name)
76+ })
77+ }
78+
79+ /// Track number without a `/total` suffix.
80+ pub fn track(&self) -> Option<&str> {
81+ self.tag("Track").map(|t| t.split('/').next().unwrap_or(t))
82+ }
83+
84+ /// Sort key for album order: disc, then track number.
85+ pub fn disc_track(&self) -> (u32, u32) {
86+ let number = |tag: &str| {
87+ self.tag(tag)
88+ .and_then(|t| t.split('/').next())
89+ .and_then(|t| t.trim().parse().ok())
90+ .unwrap_or(0)
91+ };
92+ (number("Disc"), number("Track"))
93+ }
94+}
95+
96+pub fn parse_songs(pairs: &[Pair]) -> Vec<Song> {
97+ split_groups(pairs, &["file"])
98+ .into_iter()
99+ .map(Song::from_pairs)
100+ .collect()
101+}
102+
103+/// An entry of `lsinfo`.
104+#[derive(Clone, Debug, PartialEq)]
105+pub enum DirEntry {
106+ Directory(String),
107+ Song(Song),
108+ Playlist(String),
109+}
110+
111+impl DirEntry {
112+ /// The library path of the entry.
113+ pub fn path(&self) -> &str {
114+ match self {
115+ Self::Directory(path) | Self::Playlist(path) => path,
116+ Self::Song(song) => &song.file,
117+ }
118+ }
119+
120+ /// The last path component, as shown in the browser.
121+ pub fn name(&self) -> &str {
122+ let path = self.path();
123+ path.rsplit('/').next().unwrap_or(path)
124+ }
125+}
126+
127+pub fn parse_entries(pairs: &[Pair]) -> Vec<DirEntry> {
128+ split_groups(pairs, &["file", "directory", "playlist"])
129+ .into_iter()
130+ .filter_map(|group| {
131+ let (key, value) = group.first()?;
132+ Some(match key.as_str() {
133+ "directory" => DirEntry::Directory(value.clone()),
134+ "playlist" => DirEntry::Playlist(value.clone()),
135+ _ => DirEntry::Song(Song::from_pairs(group)),
136+ })
137+ })
138+ .collect()
139+}
140+
141+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
142+pub enum PlayState {
143+ #[default]
144+ Stop,
145+ Play,
146+ Pause,
147+}
148+
149+/// The tri-state used by `single` and `consume`.
150+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151+pub enum OnOff {
152+ #[default]
153+ Off,
154+ On,
155+ Oneshot,
156+}
157+
158+impl OnOff {
159+ fn parse(value: &str) -> Self {
160+ match value {
161+ "1" => Self::On,
162+ "oneshot" => Self::Oneshot,
163+ _ => Self::Off,
164+ }
165+ }
166+
167+ pub fn is_on(self) -> bool {
168+ self != Self::Off
169+ }
170+}
171+
172+#[derive(Clone, Debug, Default, PartialEq)]
173+pub struct Status {
174+ /// `None` when the server has no mixer.
175+ pub volume: Option<u8>,
176+ pub repeat: bool,
177+ pub random: bool,
178+ pub single: OnOff,
179+ pub consume: OnOff,
180+ /// The queue version, bumped on every queue change.
181+ pub playlist_version: u32,
182+ pub playlist_length: u32,
183+ pub state: PlayState,
184+ /// Queue position of the current song.
185+ pub song: Option<u32>,
186+ pub song_id: Option<u32>,
187+ pub elapsed: Duration,
188+ pub duration: Option<Duration>,
189+ /// Kilobits per second.
190+ pub bitrate: Option<u32>,
191+ /// `samplerate:bits:channels`.
192+ pub audio: Option<String>,
193+ pub updating_db: bool,
194+ pub error: Option<String>,
195+}
196+
197+impl Status {
198+ pub fn from_pairs(pairs: &[Pair]) -> Self {
199+ let mut status = Self::default();
200+ for (key, value) in pairs {
201+ match key.as_str() {
202+ "volume" => status.volume = value.parse().ok(),
203+ "repeat" => status.repeat = value == "1",
204+ "random" => status.random = value == "1",
205+ "single" => status.single = OnOff::parse(value),
206+ "consume" => status.consume = OnOff::parse(value),
207+ "playlist" => status.playlist_version = parse_or_default(value),
208+ "playlistlength" => status.playlist_length = parse_or_default(value),
209+ "state" => {
210+ status.state = match value.as_str() {
211+ "play" => PlayState::Play,
212+ "pause" => PlayState::Pause,
213+ _ => PlayState::Stop,
214+ }
215+ }
216+ "song" => status.song = value.parse().ok(),
217+ "songid" => status.song_id = value.parse().ok(),
218+ "elapsed" => status.elapsed = parse_seconds(value).unwrap_or_default(),
219+ "duration" => status.duration = parse_seconds(value),
220+ "bitrate" => status.bitrate = value.parse().ok().filter(|&b| b > 0),
221+ "audio" => status.audio = Some(value.clone()),
222+ "updating_db" => status.updating_db = true,
223+ "error" => status.error = Some(value.clone()),
224+ _ => {}
225+ }
226+ }
227+ status
228+ }
229+}
230+
231+/// A stored playlist.
232+#[derive(Clone, Debug, PartialEq, Eq)]
233+pub struct PlaylistInfo {
234+ pub name: String,
235+}
236+
237+pub fn parse_playlists(pairs: &[Pair]) -> Vec<PlaylistInfo> {
238+ let mut playlists: Vec<PlaylistInfo> = pairs
239+ .iter()
240+ .filter(|(key, _)| key == "playlist")
241+ .map(|(_, name)| PlaylistInfo { name: name.clone() })
242+ .collect();
243+ playlists.sort_by_key(|p| p.name.to_lowercase());
244+ playlists
245+}
246+
247+/// An album with its (optional) release date, from `list album group date`.
248+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
249+pub struct Album {
250+ pub date: String,
251+ pub name: String,
252+}
253+
254+pub fn parse_albums(pairs: &[Pair]) -> Vec<Album> {
255+ let mut albums = Vec::new();
256+ let mut date = String::new();
257+ for (key, value) in pairs {
258+ if key.eq_ignore_ascii_case("Date") {
259+ date.clone_from(value);
260+ } else if key.eq_ignore_ascii_case("Album") {
261+ albums.push(Album {
262+ date: date.clone(),
263+ name: value.clone(),
264+ });
265+ }
266+ }
267+ albums.sort();
268+ albums.dedup();
269+ albums
270+}
271+
272+/// Values of a single tag from `list <tag>`.
273+pub fn parse_tag_values(pairs: &[Pair], tag: &str) -> Vec<String> {
274+ let mut values: Vec<String> = pairs
275+ .iter()
276+ .filter(|(key, _)| key.eq_ignore_ascii_case(tag))
277+ .map(|(_, value)| value.clone())
278+ .collect();
279+ values.sort_by_cached_key(|v| v.to_lowercase());
280+ values.dedup();
281+ values
282+}
283+
284+/// An MPD idle subsystem.
285+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
286+pub enum Subsystem {
287+ Database,
288+ Update,
289+ StoredPlaylist,
290+ Playlist,
291+ Player,
292+ Mixer,
293+ Output,
294+ Options,
295+ Other,
296+}
297+
298+impl FromStr for Subsystem {
299+ type Err = std::convert::Infallible;
300+
301+ fn from_str(s: &str) -> Result<Self, Self::Err> {
302+ Ok(match s {
303+ "database" => Self::Database,
304+ "update" => Self::Update,
305+ "stored_playlist" => Self::StoredPlaylist,
306+ "playlist" => Self::Playlist,
307+ "player" => Self::Player,
308+ "mixer" => Self::Mixer,
309+ "output" => Self::Output,
310+ "options" => Self::Options,
311+ _ => Self::Other,
312+ })
313+ }
314+}
315+
316+pub fn parse_subsystems(pairs: &[Pair]) -> Vec<Subsystem> {
317+ pairs
318+ .iter()
319+ .filter(|(key, _)| key == "changed")
320+ .filter_map(|(_, value)| value.parse().ok())
321+ .collect()
322+}
323+
324+fn parse_seconds(value: &str) -> Option<Duration> {
325+ value
326+ .parse::<f64>()
327+ .ok()
328+ .filter(|s| s.is_finite() && *s >= 0.0)
329+ .map(Duration::from_secs_f64)
330+}
331+
332+fn parse_or_default<T: FromStr + Default>(value: &str) -> T {
333+ value.parse().unwrap_or_default()
334+}
335+
336+#[cfg(test)]
337+mod tests {
338+ use super::*;
339+
340+ fn pairs(items: &[(&str, &str)]) -> Vec<Pair> {
341+ items
342+ .iter()
343+ .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
344+ .collect()
345+ }
346+
347+ #[test]
348+ fn parses_queue_songs() {
349+ let songs = parse_songs(&pairs(&[
350+ ("file", "a/01 - One.flac"),
351+ ("Title", "One"),
352+ ("Artist", "X"),
353+ ("Track", "1/10"),
354+ ("duration", "61.5"),
355+ ("Pos", "0"),
356+ ("Id", "7"),
357+ ("file", "b/two.mp3"),
358+ ("Time", "30"),
359+ ]));
360+ assert_eq!(songs.len(), 2);
361+ assert_eq!(songs[0].id, Some(7));
362+ assert_eq!(songs[0].track(), Some("1"));
363+ assert_eq!(songs[0].duration, Some(Duration::from_millis(61_500)));
364+ assert_eq!(songs[1].display_title(), "two");
365+ assert_eq!(songs[1].duration, Some(Duration::from_secs(30)));
366+ assert_eq!(songs[1].artist(), None);
367+ }
368+
369+ #[test]
370+ fn parses_lsinfo_entries() {
371+ let entries = parse_entries(&pairs(&[
372+ ("directory", "music/rock"),
373+ ("Last-Modified", "2024"),
374+ ("file", "music/song.mp3"),
375+ ("Title", "S"),
376+ ("playlist", "mix"),
377+ ]));
378+ assert_eq!(entries.len(), 3);
379+ assert_eq!(entries[0], DirEntry::Directory("music/rock".into()));
380+ assert_eq!(entries[0].name(), "rock");
381+ assert!(matches!(&entries[1], DirEntry::Song(s) if s.title() == Some("S")));
382+ assert_eq!(entries[2], DirEntry::Playlist("mix".into()));
383+ }
384+
385+ #[test]
386+ fn parses_status() {
387+ let status = Status::from_pairs(&pairs(&[
388+ ("volume", "-1"),
389+ ("repeat", "1"),
390+ ("single", "oneshot"),
391+ ("playlist", "12"),
392+ ("playlistlength", "3"),
393+ ("state", "pause"),
394+ ("songid", "4"),
395+ ("elapsed", "1.250"),
396+ ("bitrate", "0"),
397+ ]));
398+ assert_eq!(status.volume, None);
399+ assert!(status.repeat && !status.random);
400+ assert_eq!(status.single, OnOff::Oneshot);
401+ assert_eq!(status.playlist_version, 12);
402+ assert_eq!(status.state, PlayState::Pause);
403+ assert_eq!(status.elapsed, Duration::from_millis(1250));
404+ assert_eq!(status.bitrate, None);
405+ }
406+
407+ #[test]
408+ fn parses_grouped_albums() {
409+ let albums = parse_albums(&pairs(&[
410+ ("Date", "2016"),
411+ ("Album", "B"),
412+ ("Date", "2001"),
413+ ("Album", "A"),
414+ ("Album", "C"),
415+ ]));
416+ let names: Vec<_> = albums.iter().map(|a| a.name.as_str()).collect();
417+ assert_eq!(names, ["A", "C", "B"]);
418+ }
419+
420+ #[test]
421+ fn parses_subsystems() {
422+ let subs = parse_subsystems(&pairs(&[("changed", "player"), ("changed", "mixer")]));
423+ assert_eq!(subs, [Subsystem::Player, Subsystem::Mixer]);
424+ }
425+}
A src/mpd/worker.rs View
@@ -0,0 +1,403 @@
1+//! Background tasks that own the MPD connections.
2+//!
3+//! * The **idle** task blocks on `idle` and reports changed subsystems.
4+//! * The **command** task executes [`Request`]s sent through an [`MpdHandle`]
5+//! and reports query results as [`Reply`]s.
6+//!
7+//! The UI never awaits the server: it sends requests and reacts to events.
8+
9+use std::{path::PathBuf, time::Duration};
10+
11+use tokio::{sync::mpsc, time::sleep};
12+use tracing::{debug, warn};
13+
14+use super::{
15+ commands,
16+ connection::{Address, Connection},
17+ error::{MpdError, MpdResult},
18+ protocol::Command,
19+ types::{
20+ Album, DirEntry, PlaylistInfo, Song, Status, Subsystem, parse_albums, parse_entries,
21+ parse_playlists, parse_songs, parse_subsystems, parse_tag_values,
22+ },
23+};
24+
25+/// Data the UI asks for.
26+#[derive(Clone, Debug, PartialEq, Eq)]
27+pub enum Query {
28+ /// Status and current song.
29+ Status,
30+ /// Queue changes since the given queue version (`0` for everything).
31+ QueueChanges {
32+ since: u32,
33+ },
34+ Playlists,
35+ Directory(String),
36+ PlaylistSongs(String),
37+ /// All values of a tag, e.g. every `AlbumArtist`.
38+ TagValues(String),
39+ /// Albums whose `tag` equals `value`, grouped by date.
40+ Albums {
41+ tag: String,
42+ value: String,
43+ },
44+ /// Songs matching a filter expression.
45+ Find(String),
46+ /// The server's music directory (only answered on local connections).
47+ MusicDirectory,
48+}
49+
50+/// The answer to a [`Query`].
51+#[derive(Clone, Debug)]
52+pub enum Reply {
53+ Status {
54+ status: Status,
55+ current: Option<Song>,
56+ },
57+ QueueChanges {
58+ status: Status,
59+ since: u32,
60+ changes: Vec<Song>,
61+ },
62+ Playlists(Vec<PlaylistInfo>),
63+ Directory {
64+ path: String,
65+ entries: Vec<DirEntry>,
66+ },
67+ PlaylistSongs {
68+ name: String,
69+ songs: Vec<Song>,
70+ },
71+ TagValues {
72+ tag: String,
73+ values: Vec<String>,
74+ },
75+ Albums {
76+ tag: String,
77+ value: String,
78+ albums: Vec<Album>,
79+ },
80+ Find {
81+ filter: String,
82+ songs: Vec<Song>,
83+ },
84+ MusicDirectory(Option<PathBuf>),
85+}
86+
87+#[derive(Clone, Debug, PartialEq, Eq)]
88+pub enum Request {
89+ /// Fire-and-forget commands, executed atomically as one command list.
90+ Exec(Vec<Command>),
91+ Query(Query),
92+ /// Appends songs and starts playing the first one.
93+ AddAndPlay(Vec<String>),
94+ /// Appends every song matching a filter and plays the first one.
95+ FindAddAndPlay(String),
96+ /// Appends a stored playlist and plays its song at `offset`.
97+ LoadAndPlay {
98+ name: String,
99+ offset: u32,
100+ },
101+}
102+
103+/// Notifications from the background tasks.
104+#[derive(Clone, Debug)]
105+pub enum MpdEvent {
106+ Connected { version: String },
107+ Disconnected(String),
108+ Changed(Vec<Subsystem>),
109+ Reply(Reply),
110+ Error(String),
111+}
112+
113+/// Cheap, cloneable sender for requests.
114+#[derive(Clone, Debug)]
115+pub struct MpdHandle {
116+ tx: mpsc::UnboundedSender<Request>,
117+}
118+
119+impl MpdHandle {
120+ pub fn request(&self, request: Request) {
121+ // The worker lives as long as the application, so a closed channel
122+ // only happens during shutdown.
123+ let _ = self.tx.send(request);
124+ }
125+
126+ pub fn exec(&self, command: Command) {
127+ self.request(Request::Exec(vec![command]));
128+ }
129+
130+ pub fn exec_all(&self, commands: Vec<Command>) {
131+ if !commands.is_empty() {
132+ self.request(Request::Exec(commands));
133+ }
134+ }
135+
136+ pub fn query(&self, query: Query) {
137+ self.request(Request::Query(query));
138+ }
139+
140+ /// A handle whose requests go to `tx`, for tests.
141+ pub fn from_sender(tx: mpsc::UnboundedSender<Request>) -> Self {
142+ Self { tx }
143+ }
144+}
145+
146+/// Starts both workers and returns the request handle.
147+pub fn spawn<E>(
148+ address: Address,
149+ password: Option<String>,
150+ events: mpsc::UnboundedSender<E>,
151+) -> MpdHandle
152+where
153+ E: From<MpdEvent> + Send + 'static,
154+{
155+ let (tx, rx) = mpsc::unbounded_channel();
156+ let emit = Emitter(events);
157+ tokio::spawn(idle_task(address.clone(), password.clone(), emit.clone()));
158+ tokio::spawn(command_task(address, password, rx, emit));
159+ MpdHandle { tx }
160+}
161+
162+struct Emitter<E>(mpsc::UnboundedSender<E>);
163+
164+impl<E> Clone for Emitter<E> {
165+ fn clone(&self) -> Self {
166+ Self(self.0.clone())
167+ }
168+}
169+
170+impl<E: From<MpdEvent>> Emitter<E> {
171+ /// Returns `false` once the application stopped listening.
172+ fn emit(&self, event: MpdEvent) -> bool {
173+ self.0.send(event.into()).is_ok()
174+ }
175+}
176+
177+const MAX_BACKOFF: Duration = Duration::from_secs(10);
178+
179+async fn idle_task<E: From<MpdEvent>>(
180+ address: Address,
181+ password: Option<String>,
182+ emit: Emitter<E>,
183+) {
184+ let mut backoff = Duration::from_millis(500);
185+ let mut reported_down = false;
186+ loop {
187+ match Connection::connect(&address, password.as_deref()).await {
188+ Ok(mut conn) => {
189+ backoff = Duration::from_millis(500);
190+ if !emit.emit(MpdEvent::Connected {
191+ version: conn.version().to_owned(),
192+ }) {
193+ return;
194+ }
195+ let error = loop {
196+ match conn.execute(&Command::new("idle")).await {
197+ Ok(pairs) => {
198+ if !emit.emit(MpdEvent::Changed(parse_subsystems(&pairs))) {
199+ return;
200+ }
201+ }
202+ Err(error) => break error,
203+ }
204+ };
205+ warn!("idle connection lost: {error}");
206+ if !emit.emit(MpdEvent::Disconnected(error.to_string())) {
207+ return;
208+ }
209+ reported_down = true;
210+ }
211+ Err(error) => {
212+ debug!("connecting to {address} failed: {error}");
213+ if !reported_down {
214+ reported_down = true;
215+ if !emit.emit(MpdEvent::Disconnected(format!(
216+ "cannot connect to {address}: {error}"
217+ ))) {
218+ return;
219+ }
220+ }
221+ }
222+ }
223+ sleep(backoff).await;
224+ backoff = (backoff * 2).min(MAX_BACKOFF);
225+ }
226+}
227+
228+async fn command_task<E: From<MpdEvent>>(
229+ address: Address,
230+ password: Option<String>,
231+ mut requests: mpsc::UnboundedReceiver<Request>,
232+ emit: Emitter<E>,
233+) {
234+ let mut conn: Option<Connection> = None;
235+ while let Some(request) = requests.recv().await {
236+ // MPD drops connections that stay silent for `connection_timeout`, so a
237+ // failure on a reused connection is retried once on a fresh one.
238+ let mut attempts = if conn.is_some() { 2 } else { 1 };
239+ let result = loop {
240+ let c = match conn.as_mut() {
241+ Some(c) => c,
242+ None => match Connection::connect(&address, password.as_deref()).await {
243+ Ok(c) => conn.insert(c),
244+ Err(error) => break Err(error),
245+ },
246+ };
247+ attempts -= 1;
248+ match execute(c, &request).await {
249+ Err(error) if error.is_fatal() => {
250+ conn = None;
251+ if attempts == 0 {
252+ break Err(error);
253+ }
254+ }
255+ other => break other,
256+ }
257+ };
258+ let event = match result {
259+ Ok(Some(reply)) => MpdEvent::Reply(reply),
260+ Ok(None) => continue,
261+ Err(error) => {
262+ warn!("request {request:?} failed: {error}");
263+ MpdEvent::Error(error.to_string())
264+ }
265+ };
266+ if !emit.emit(event) {
267+ return;
268+ }
269+ }
270+}
271+
272+async fn execute(conn: &mut Connection, request: &Request) -> MpdResult<Option<Reply>> {
273+ match request {
274+ Request::Exec(commands) => conn.execute_all(commands).await.map(|()| None),
275+ Request::Query(query) => run_query(conn, query).await.map(Some),
276+ Request::AddAndPlay(uris) => {
277+ let Some((first, rest)) = uris.split_first() else {
278+ return Ok(None);
279+ };
280+ let id = conn
281+ .execute(&commands::add_id(first))
282+ .await?
283+ .into_iter()
284+ .find(|(key, _)| key == "Id")
285+ .and_then(|(_, id)| id.parse().ok())
286+ .ok_or_else(|| MpdError::Protocol("addid returned no id".into()))?;
287+ let mut batch: Vec<Command> = rest.iter().map(|uri| commands::add(uri)).collect();
288+ batch.push(commands::play_id(id));
289+ conn.execute_all(&batch).await.map(|()| None)
290+ }
291+ Request::FindAddAndPlay(filter) => {
292+ let start = queue_length(conn).await?;
293+ conn.execute_all(&[commands::find_add(filter), commands::play_pos(start)])
294+ .await
295+ .map(|()| None)
296+ }
297+ Request::LoadAndPlay { name, offset } => {
298+ let start = queue_length(conn).await?;
299+ conn.execute_all(&[commands::load(name), commands::play_pos(start + offset)])
300+ .await
301+ .map(|()| None)
302+ }
303+ }
304+}
305+
306+async fn queue_length(conn: &mut Connection) -> MpdResult<u32> {
307+ let status = conn.execute(&Command::new("status")).await?;
308+ Ok(Status::from_pairs(&status).playlist_length)
309+}
310+
311+async fn run_query(conn: &mut Connection, query: &Query) -> MpdResult<Reply> {
312+ Ok(match query {
313+ Query::Status => {
314+ let responses = conn
315+ .execute_list(&[Command::new("status"), Command::new("currentsong")])
316+ .await?;
317+ let [status, current] = <[_; 2]>::try_from(responses)
318+ .map_err(|_| MpdError::Protocol("short command list response".into()))?;
319+ let current = (!current.is_empty()).then(|| Song::from_pairs(&current));
320+ Reply::Status {
321+ status: Status::from_pairs(&status),
322+ current,
323+ }
324+ }
325+ Query::QueueChanges { since } => {
326+ let responses = conn
327+ .execute_list(&[
328+ Command::new("status"),
329+ Command::new("plchanges").arg(since.to_string()),
330+ ])
331+ .await?;
332+ let [status, changes] = <[_; 2]>::try_from(responses)
333+ .map_err(|_| MpdError::Protocol("short command list response".into()))?;
334+ Reply::QueueChanges {
335+ status: Status::from_pairs(&status),
336+ since: *since,
337+ changes: parse_songs(&changes),
338+ }
339+ }
340+ Query::Playlists => Reply::Playlists(parse_playlists(
341+ &conn.execute(&Command::new("listplaylists")).await?,
342+ )),
343+ Query::Directory(path) => {
344+ let entries = parse_entries(&conn.execute(&Command::new("lsinfo").arg(path)).await?);
345+ Reply::Directory {
346+ path: path.clone(),
347+ entries,
348+ }
349+ }
350+ Query::PlaylistSongs(name) => {
351+ let pairs = conn
352+ .execute(&Command::new("listplaylistinfo").arg(name))
353+ .await?;
354+ Reply::PlaylistSongs {
355+ name: name.clone(),
356+ songs: parse_songs(&pairs),
357+ }
358+ }
359+ Query::TagValues(tag) => {
360+ let pairs = conn.execute(&Command::new("list").arg(tag)).await?;
361+ Reply::TagValues {
362+ tag: tag.clone(),
363+ values: parse_tag_values(&pairs, tag),
364+ }
365+ }
366+ Query::Albums { tag, value } => {
367+ let pairs = conn
368+ .execute(
369+ &Command::new("list")
370+ .arg("Album")
371+ .arg(super::protocol::filter_eq(tag, value))
372+ .arg("group")
373+ .arg("Date"),
374+ )
375+ .await?;
376+ Reply::Albums {
377+ tag: tag.clone(),
378+ value: value.clone(),
379+ albums: parse_albums(&pairs),
380+ }
381+ }
382+ Query::Find(filter) => {
383+ let mut songs = parse_songs(&conn.execute(&Command::new("find").arg(filter)).await?);
384+ songs.sort_by_key(Song::disc_track);
385+ Reply::Find {
386+ filter: filter.clone(),
387+ songs,
388+ }
389+ }
390+ Query::MusicDirectory => {
391+ let dir = match conn.execute(&Command::new("config")).await {
392+ Ok(pairs) => pairs
393+ .into_iter()
394+ .find(|(key, _)| key == "music_directory")
395+ .map(|(_, dir)| PathBuf::from(dir)),
396+ // `config` is only permitted on local (Unix socket) connections.
397+ Err(MpdError::Ack(_)) => None,
398+ Err(error) => return Err(error),
399+ };
400+ Reply::MusicDirectory(dir)
401+ }
402+ })
403+}
D src/tui.rs View
@@ -1,62 +0,0 @@
1−use crate::ui;
2−use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
3−use crossterm::terminal::{self, *};
4−use std::io;
5−use std::panic;
6−
7−use crate::{app::App, AppResult};
8−
9−pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>;
10−
11−#[derive(Debug)]
12−pub struct Tui {
13− terminal: CrosstermTerminal,
14−}
15−
16−impl Tui {
17− pub fn new(terminal: CrosstermTerminal) -> Self {
18− Self { terminal }
19− }
20−
21− pub fn init(&mut self) -> AppResult<()> {
22− terminal::enable_raw_mode()?;
23− crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
24−
25− let panic_hook = panic::take_hook();
26− panic::set_hook(Box::new(move |panic| {
27− if let Err(error) = Self::reset() {
28− eprintln!("failed to reset the terminal: {error}");
29− }
30− panic_hook(panic);
31− }));
32− Ok(())
33− }
34−
35− /// [`Draw`] the terminal interface by [`rendering`] the widgets.
36− ///
37− /// [`Draw`]: ratatui::Terminal::draw
38− /// [`rendering`]: crate::ui::render
39− pub fn draw(&mut self, app: &mut App) -> AppResult<()> {
40− self.terminal.draw(|frame| ui::render(app, frame))?;
41− Ok(())
42− }
43−
44− /// Resets the terminal interface.
45− ///
46− /// This function is also used for the panic hook to revert
47− /// the terminal properties if unexpected errors occur.
48− fn reset() -> AppResult<()> {
49− terminal::disable_raw_mode()?;
50− crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
51− Ok(())
52− }
53−
54− /// Exits the terminal interface.
55− ///
56− /// It disables the raw mode and reverts back the terminal properties.
57− pub fn exit(&mut self) -> AppResult<()> {
58− Self::reset()?;
59− self.terminal.show_cursor()?;
60− Ok(())
61− }
62−}
D src/ui.rs View
@@ -1,489 +0,0 @@
1−use std::time::Duration;
2−
3−use crate::{
4− app::{App, PlaylistFocus, SelectedTab},
5− browser::BrowserEntry,
6− connection::{PlaybackState, Song, VolumeStatus},
7−};
8−use ratatui::{prelude::*, widgets::*};
9−
10−#[derive(Debug, PartialEq, Clone, Copy)]
11−pub enum InputMode {
12− Editing,
13− Normal,
14− PlaylistRename,
15− NewPlaylist,
16−}
17−
18−fn song_row(song: &Song, playing: bool, queued: bool) -> Row<'static> {
19− let mut artist = song.artist.clone().unwrap_or_default();
20− let track = tag_value(song, "Track");
21− let title = song.title.clone().unwrap_or_else(|| song.file.clone());
22− let album = tag_value(song, "Album");
23− let time = App::format_time(song.duration.unwrap_or(Duration::ZERO));
24−
25− if playing {
26− artist.insert_str(0, "> ");
27− }
28− let row = Row::new(vec![
29− Cell::from(artist.cyan()),
30− Cell::from(if playing {
31− format!(" {track}").green()
32− } else {
33− track.green()
34− }),
35− Cell::from(if playing { format!(" {title}") } else { title }),
36− Cell::from(album.cyan()),
37− Cell::from(time.magenta()),
38− ]);
39− if queued {
40− row.bold()
41− } else {
42− row
43− }
44−}
45−
46−fn tag_value(song: &Song, name: &str) -> String {
47− song.tags
48− .iter()
49− .find(|(tag, _)| tag.eq_ignore_ascii_case(name))
50− .map(|(_, value)| value.clone())
51− .unwrap_or_default()
52−}
53−
54−/// Renders the user interface widgets
55−pub fn render(app: &mut App, frame: &mut Frame) {
56− // Layout
57− let layout = Layout::default()
58− .direction(Direction::Vertical)
59− .constraints(vec![Constraint::Percentage(93), Constraint::Min(3)])
60− .split(frame.area());
61−
62− match app.selected_tab {
63− SelectedTab::Queue => draw_queue(frame, app, layout[0]),
64− SelectedTab::Playlists => draw_playlist_viewer(frame, app, layout[0]),
65− SelectedTab::DirectoryBrowser => draw_directory_browser(frame, app, layout[0]),
66− }
67−
68− match app.inputmode {
69− InputMode::Normal => {
70− draw_progress_bar(frame, app, layout[1]);
71− }
72− InputMode::Editing => {
73− draw_search_bar(frame, app, layout[1]);
74− }
75− InputMode::PlaylistRename => {
76− draw_rename_playlist(frame, app, layout[1]);
77− }
78− InputMode::NewPlaylist => {
79− draw_new_playlist(frame, app, layout[1]);
80− }
81− }
82−
83− if app.playlist_popup {
84− draw_add_to_playlist(frame, app, layout[0]);
85− }
86−}
87−
88−/// Draws the directory
89−fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) {
90− let total_songs = app.conn.stats.songs.to_string();
91−
92− let rows = app.browser.entries.iter().map(|entry| match entry {
93− BrowserEntry::Song(song) => {
94− let queued =
95− app.queue_list.list.iter().any(|queued| {
96− queued.file.rsplit('/').next().unwrap_or_default() == entry.name()
97− });
98− song_row(song, false, queued)
99− }
100− BrowserEntry::Directory(name) => directory_row(name),
101− });
102−
103− let header = ["Artist", "Track", "Title", "Album", "Time"]
104− .into_iter()
105− .map(Cell::from)
106− .collect::<Row>()
107− .bold()
108− .height(1);
109− let table = Table::new(
110− rows,
111− [
112− Constraint::Percentage(34),
113− Constraint::Percentage(3),
114− Constraint::Min(30),
115− Constraint::Percentage(30),
116− Constraint::Percentage(3),
117− ],
118− )
119− .block(
120− Block::default()
121− .title(format!("Song Browser: {}", app.browser.path).bold())
122− .title(
123− Line::from(format!("Total Songs: {}", total_songs).green())
124− .alignment(Alignment::Center),
125− )
126− .title(match app.conn.volume_status {
127− VolumeStatus::Unmuted => {
128− Line::from(format!("Volume: {}%", app.conn.status.volume).green())
129− .alignment(Alignment::Right)
130− }
131− VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right),
132− })
133− .borders(Borders::ALL),
134− )
135− .row_highlight_style(
136− Style::default()
137− .add_modifier(Modifier::REVERSED)
138− .fg(Color::Cyan)
139− .bg(Color::Black),
140− )
141− .header(header)
142− .flex(layout::Flex::Legacy);
143−
144− app.browser_state.select(Some(app.browser.selected));
145− frame.render_stateful_widget(table, size, &mut app.browser_state);
146−}
147−
148−/// draws playing queue
149−fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) {
150− let rows = app.queue_list.list.iter().enumerate().map(|(i, song)| {
151− let playing = app
152− .conn
153− .current_song
154− .place
155− .is_some_and(|position| i == position.pos as usize);
156− song_row(song, playing, false)
157− });
158−
159− let header = ["Artist", "Track", "Title", "Album", "Time"]
160− .into_iter()
161− .map(Cell::from)
162− .collect::<Row>()
163− .bold()
164− .height(1);
165− let table = Table::new(
166− rows,
167− [
168− Constraint::Percentage(34),
169− Constraint::Percentage(3),
170− Constraint::Min(30),
171− Constraint::Percentage(30),
172− Constraint::Percentage(3),
173− ],
174− )
175− .block(
176− Block::default()
177− .title("Play Queue".green().bold())
178− .title(format!("({} items)", app.queue_list.list.len()).bold())
179− .title(match app.conn.volume_status {
180− VolumeStatus::Unmuted => {
181− Line::from(format!("Volume: {}%", app.conn.status.volume).green())
182− .alignment(Alignment::Right)
183− }
184− VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right),
185− })
186− .borders(Borders::ALL),
187− )
188− .row_highlight_style(
189− Style::default()
190− .add_modifier(Modifier::REVERSED)
191− .fg(Color::Cyan)
192− .bg(Color::Black),
193− )
194− .header(header)
195− .flex(layout::Flex::Legacy);
196−
197− app.queue_state.select(Some(app.queue_list.index));
198− frame.render_stateful_widget(table, size, &mut app.queue_state);
199−}
200−
201−// Draw search bar
202−fn draw_search_bar(frame: &mut Frame, app: &mut App, size: Rect) {
203− // Make the cursor visible and ask ratatui to put it at the specified coordinates after
204− // rendering
205− #[allow(clippy::cast_possible_truncation)]
206− frame.set_cursor_position((
207− // Draw the cursor at the current position in the input field.
208− // This position is can be controlled via the left and right arrow key
209− size.x + app.search_cursor_pos as u16 + 2,
210− // Move one line down, from the border to the input line
211− size.y + 1,
212− ));
213−
214− let input = Paragraph::new("/".to_string() + &app.search_input)
215− .style(Style::default())
216− .block(
217− Block::default()
218− .borders(Borders::ALL)
219− .title("Search: Enter to keep, n/N next/previous ".bold().green()),
220− );
221− frame.render_widget(input, size);
222−}
223−
224−/// Draws Progress Bar
225−fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) {
226− let song = app.conn.now_playing();
227−
228− // Get the current playing state
229− let mut state = String::new();
230− if !app.queue_list.list.is_empty() {
231− state.push_str(match app.conn.status.state {
232− PlaybackState::Playing => "Playing",
233− PlaybackState::Paused => "Paused",
234− PlaybackState::Stopped => "Stopped",
235− });
236− state.push(':');
237− }
238−
239− // Get the current modes
240− let mut modes_bottom: String = String::new();
241− // we do this to check if at least one mode is enabled so we can push "[]"
242− if app.conn.status.repeat | app.conn.status.random {
243− modes_bottom.push('r');
244− }
245−
246− if !modes_bottom.is_empty() {
247− modes_bottom.clear();
248− modes_bottom.push('[');
249− if app.conn.status.repeat {
250− modes_bottom.push('r');
251− }
252− if app.conn.status.random {
253− modes_bottom.push('z');
254− }
255− modes_bottom.push(']');
256− };
257−
258− // get the duration
259− let duration = if !app.conn.status.total.is_zero() {
260− format!(
261− "[{}/{}]",
262− App::format_time(app.conn.current_elapsed()),
263− App::format_time(app.conn.status.total)
264− )
265− } else {
266− "".to_string()
267− };
268−
269− // Define the title
270− let title = Block::default()
271− .title(state.red().bold())
272− .title(song.green())
273− .title(Line::from(duration.cyan().bold()).alignment(Alignment::Right))
274− .title_bottom(modes_bottom)
275− .borders(Borders::ALL);
276−
277− let progress_bar = LineGauge::default()
278− .block(title.borders(Borders::ALL))
279− .filled_style(
280− Style::default()
281− .fg(Color::Blue)
282− .add_modifier(Modifier::BOLD),
283− )
284− .unfilled_style(
285− Style::default()
286− .fg(Color::Black)
287− .add_modifier(Modifier::BOLD),
288− )
289− .filled_symbol(symbols::line::THICK_HORIZONTAL)
290− .unfilled_symbol(symbols::line::THICK_HORIZONTAL)
291− .ratio(app.conn.get_progress_ratio());
292−
293− frame.render_widget(progress_bar, size);
294−}
295−
296−fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) {
297− let layouts = Layout::default()
298− .direction(Direction::Horizontal)
299− .constraints(vec![Constraint::Percentage(40), Constraint::Percentage(60)])
300− .split(area);
301−
302− // Draw list of playlists
303− let list_focused = app.playlist_focus == PlaylistFocus::List;
304− let title = Block::default()
305− .title("Playlists".green().bold())
306− .borders(Borders::ALL)
307− .border_style(if list_focused {
308− Style::default().fg(Color::Cyan)
309− } else {
310− Style::default()
311− });
312− let list = List::new(app.pl_list.list.iter().map(String::as_str))
313− .block(title)
314− .highlight_style(Style::new().fg(Color::Cyan).bg(Color::Black).add_modifier(
315− if list_focused {
316− Modifier::BOLD | Modifier::REVERSED
317− } else {
318− Modifier::BOLD
319− },
320− ))
321− .repeat_highlight_symbol(true);
322− app.playlists_state.select(Some(app.pl_list.index));
323− frame.render_stateful_widget(list, layouts[0], &mut app.playlists_state);
324−
325− // Playlist viewer
326−
327− // Handle if there are no playlists in the mpd database
328− if app.pl_list.list.is_empty() {
329− let title = Block::default()
330− .title("No Playlists Found".red().bold())
331− .title_alignment(Alignment::Center)
332− .borders(Borders::ALL);
333− frame.render_widget(Clear, area); //this clears out the background
334− frame.render_widget(title, area);
335− return;
336− }
337−
338− let songs = &app.playlist_contents;
339− let rows = songs.iter().map(|song| {
340− let title = song.title.as_deref().unwrap_or_default().cyan();
341− let artist = song.artist.as_deref().unwrap_or(&song.file);
342− let time = App::format_time(song.duration.unwrap_or(Duration::ZERO));
343−
344− let row = Row::new(vec![
345− Cell::from(artist),
346− Cell::from(title),
347− Cell::from(time.to_string().green()),
348− ]);
349− row
350− });
351−
352− let title = Block::default()
353− .title(format!("Content: ({} items)", songs.len()).bold())
354− .borders(Borders::ALL)
355− .border_style(if app.playlist_focus == PlaylistFocus::Songs {
356− Style::default().fg(Color::Cyan)
357− } else {
358− Style::default()
359− });
360− let table = Table::new(
361− rows,
362− vec![
363− Constraint::Percentage(40),
364− Constraint::Percentage(50),
365− Constraint::Percentage(10),
366− ],
367− )
368− .block(title)
369− .row_highlight_style(
370− Style::default()
371− .add_modifier(Modifier::REVERSED)
372− .fg(Color::Cyan)
373− .bg(Color::Black),
374− )
375− .flex(layout::Flex::SpaceBetween);
376− app.playlist_songs_state
377− .select(Some(app.playlist_song_index));
378− frame.render_stateful_widget(table, layouts[1], &mut app.playlist_songs_state);
379−}
380−
381−fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
382− let area = centered_rect(40, 50, area);
383− let mut state = ListState::default();
384− let title = Block::default()
385− .title("Add Selected Item to: ")
386− .title(Line::from("<Esc> to Cancel".green().bold()).alignment(Alignment::Right));
387− let list = List::new(app.append_list.list.iter().map(String::as_str))
388− .block(title.borders(Borders::ALL))
389− .highlight_style(
390− Style::new()
391− .fg(Color::Cyan)
392− .bg(Color::Black)
393− .add_modifier(Modifier::BOLD)
394− .add_modifier(Modifier::REVERSED),
395− )
396− .repeat_highlight_symbol(true);
397−
398− state.select(Some(app.append_list.index));
399− frame.render_widget(Clear, area); //this clears out the background
400− frame.render_stateful_widget(list, area, &mut state);
401−}
402−
403−fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
404− #[allow(clippy::cast_possible_truncation)]
405− frame.set_cursor_position((
406− // Draw the cursor at the current position in the input field.
407− // This position is can be controlled via the left and right arrow key
408− area.x + app.pl_cursor_pos as u16 + 2,
409− // Move one line down, from the border to the input line
410− area.y + 1,
411− ));
412−
413− let input = Paragraph::new("/".to_string() + &app.pl_newname_input)
414− .style(Style::default())
415− .block(
416− Block::default()
417− .borders(Borders::ALL)
418− .title("Enter New Name: ".bold().green()),
419− );
420− frame.render_widget(input, area);
421−}
422−
423−fn draw_new_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
424− #[allow(clippy::cast_possible_truncation)]
425− frame.set_cursor_position((
426− // Draw the cursor at the current position in the input field.
427− // This position is can be controlled via the left and right arrow key
428− area.x + app.pl_new_pl_cursor_pos as u16 + 2,
429− // Move one line down, from the border to the input line
430− area.y + 1,
431− ));
432−
433− let input = Paragraph::new("/".to_string() + &app.pl_new_pl_input)
434− .style(Style::default())
435− .block(
436− Block::default()
437− .borders(Borders::ALL)
438− .title("Enter New Playlist's Name: ".bold().green()),
439− );
440− frame.render_widget(input, area);
441−}
442−
443−fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
444− let popup_layout = Layout::vertical([
445− Constraint::Percentage((100 - percent_y) / 2),
446− Constraint::Percentage(percent_y),
447− Constraint::Percentage((100 - percent_y) / 2),
448− ])
449− .split(r);
450−
451− Layout::horizontal([
452− Constraint::Percentage((100 - percent_x) / 2),
453− Constraint::Percentage(percent_x),
454− Constraint::Percentage((100 - percent_x) / 2),
455− ])
456− .split(popup_layout[1])[1]
457−}
458−
459−fn directory_row(name: &str) -> Row<'static> {
460− Row::new(vec![Cell::from(format!("[{name}]")).column_span(5)])
461−}
462−
463−#[cfg(test)]
464−mod tests {
465− use super::*;
466−
467− #[test]
468− fn directory_names_use_the_full_browser_row_width() {
469− let name = "mr-robot-vol1-long-name";
470− let area = Rect::new(0, 0, 80, 1);
471− let mut buffer = Buffer::empty(area);
472− let table = Table::new(
473− [directory_row(name)],
474− [
475− Constraint::Length(6),
476− Constraint::Length(2),
477− Constraint::Length(10),
478− Constraint::Length(10),
479− Constraint::Length(6),
480− ],
481− );
482− Widget::render(table, area, &mut buffer);
483−
484− let rendered = (0..area.width)
485− .map(|x| buffer[(x, 0)].symbol())
486− .collect::<String>();
487− assert!(rendered.contains(&format!("[{name}]")));
488− }
489−}
A src/util.rs View
@@ -0,0 +1,59 @@
1+//! Small helpers shared across modules.
2+
3+use std::{env, path::PathBuf, time::Duration};
4+
5+/// Replaces a leading `~` with the home directory.
6+pub fn expand_tilde(path: &str) -> PathBuf {
7+ match path.strip_prefix('~') {
8+ Some(rest) if rest.is_empty() || rest.starts_with('/') => {
9+ env::var_os("HOME").map(PathBuf::from).map_or_else(
10+ || PathBuf::from(path),
11+ |home| home.join(rest.trim_start_matches('/')),
12+ )
13+ }
14+ _ => PathBuf::from(path),
15+ }
16+}
17+
18+/// `$XDG_CACHE_HOME/rmptui`, falling back to `~/.cache/rmptui`.
19+pub fn cache_dir() -> PathBuf {
20+ env::var_os("XDG_CACHE_HOME")
21+ .filter(|dir| !dir.is_empty())
22+ .map(PathBuf::from)
23+ .unwrap_or_else(|| expand_tilde("~/.cache"))
24+ .join("rmptui")
25+}
26+
27+/// Formats a duration as `m:ss` or `h:mm:ss`.
28+pub fn format_duration(duration: Duration) -> String {
29+ let secs = duration.as_secs();
30+ let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
31+ if h > 0 {
32+ format!("{h}:{m:02}:{s:02}")
33+ } else {
34+ format!("{m}:{s:02}")
35+ }
36+}
37+
38+#[cfg(test)]
39+mod tests {
40+ use super::*;
41+
42+ #[test]
43+ fn formats_durations() {
44+ assert_eq!(format_duration(Duration::from_secs(5)), "0:05");
45+ assert_eq!(format_duration(Duration::from_secs(605)), "10:05");
46+ assert_eq!(format_duration(Duration::from_secs(3725)), "1:02:05");
47+ }
48+
49+ #[test]
50+ fn expands_tilde() {
51+ let home = env::var("HOME").unwrap();
52+ assert_eq!(
53+ expand_tilde("~/music"),
54+ PathBuf::from(format!("{home}/music"))
55+ );
56+ assert_eq!(expand_tilde("/abs"), PathBuf::from("/abs"));
57+ assert_eq!(expand_tilde("~user/x"), PathBuf::from("~user/x"));
58+ }
59+}
D src/utils.rs View
@@ -1,27 +0,0 @@
1−use std::ffi::OsStr;
2−use std::path::Path;
3−
4−/// Checks if a file has a given extension
5−// https://stackoverflow.com/questions/72392835/check-if-a-file-is-of-a-given-type
6−pub trait FileExtension {
7− fn has_extension<S: AsRef<str>>(&self, extensions: &[S]) -> bool;
8−}
9−
10−impl<P: AsRef<Path>> FileExtension for P {
11− fn has_extension<S: AsRef<str>>(&self, extensions: &[S]) -> bool {
12− if let Some(extension) = self.as_ref().extension().and_then(OsStr::to_str) {
13− return extensions
14− .iter()
15− .any(|x| x.as_ref().eq_ignore_ascii_case(extension));
16− }
17−
18− false
19− }
20−}
21−
22−pub fn is_audio_file(path: &str) -> bool {
23− Path::new(path).has_extension(&[
24− "mp3", "ogg", "flac", "m4a", "wav", "aac", "opus", "ape", "wma", "mpc", "aiff", "dff",
25− "mp2", "mka",
26− ])
27−}
A tests/mpd_client.rs View
@@ -0,0 +1,164 @@
1+//! Talks to a scripted fake MPD server over real TCP sockets.
2+
3+use std::time::Duration;
4+
5+use rmptui::mpd::{
6+ self, Address, Command, Connection, MpdError, MpdEvent, Query, Reply, Request, Subsystem,
7+};
8+use tokio::{
9+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
10+ net::TcpListener,
11+ sync::mpsc,
12+ time::timeout,
13+};
14+
15+/// Serves one connection: greets, then answers each request line with the
16+/// response produced by `respond`. Received lines are forwarded to `seen`.
17+async fn fake_server<F>(respond: F) -> (Address, mpsc::UnboundedReceiver<String>)
18+where
19+ F: Fn(&str) -> String + Send + Sync + Clone + 'static,
20+{
21+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
22+ let address = Address::Tcp(listener.local_addr().unwrap().to_string());
23+ let (seen_tx, seen_rx) = mpsc::unbounded_channel();
24+ tokio::spawn(async move {
25+ loop {
26+ let Ok((socket, _)) = listener.accept().await else {
27+ return;
28+ };
29+ let respond = respond.clone();
30+ let seen = seen_tx.clone();
31+ tokio::spawn(async move {
32+ let (read, mut write) = socket.into_split();
33+ write.write_all(b"OK MPD 0.24.0\n").await.unwrap();
34+ let mut lines = BufReader::new(read).lines();
35+ let mut list: Option<Vec<String>> = None;
36+ while let Ok(Some(line)) = lines.next_line().await {
37+ let _ = seen.send(line.clone());
38+ let response = match (line.as_str(), list.as_mut()) {
39+ ("command_list_ok_begin", _) => {
40+ list = Some(Vec::new());
41+ continue;
42+ }
43+ ("command_list_end", Some(commands)) => {
44+ let mut out = String::new();
45+ for command in commands.iter() {
46+ let body = respond(command);
47+ out.push_str(body.strip_suffix("OK\n").unwrap_or(&body));
48+ out.push_str("list_OK\n");
49+ }
50+ list = None;
51+ out + "OK\n"
52+ }
53+ (_, Some(commands)) => {
54+ commands.push(line);
55+ continue;
56+ }
57+ // Block forever on idle, like a quiet server.
58+ ("idle", None) => std::future::pending().await,
59+ (_, None) => respond(&line),
60+ };
61+ if write.write_all(response.as_bytes()).await.is_err() {
62+ return;
63+ }
64+ }
65+ });
66+ }
67+ });
68+ (address, seen_rx)
69+}
70+
71+fn responder(line: &str) -> String {
72+ match line {
73+ "status" => {
74+ "volume: 50\nstate: play\nplaylist: 3\nplaylistlength: 2\nsongid: 1\nOK\n".into()
75+ }
76+ "currentsong" => "file: a.flac\nTitle: A\nId: 1\nPos: 0\nOK\n".into(),
77+ r#"password "secret""# => "OK\n".into(),
78+ r#"addid "x.mp3""# => "Id: 42\nOK\n".into(),
79+ r#"play "nope""# => "ACK [2@0] {play} Bad song index\n".into(),
80+ _ => "OK\n".into(),
81+ }
82+}
83+
84+#[tokio::test]
85+async fn executes_commands_and_lists() {
86+ let (address, mut seen) = fake_server(responder).await;
87+ let mut conn = Connection::connect(&address, Some("secret")).await.unwrap();
88+ assert_eq!(conn.version(), "0.24.0");
89+ assert_eq!(seen.recv().await.unwrap(), r#"password "secret""#);
90+
91+ let pairs = conn.execute(&Command::new("status")).await.unwrap();
92+ assert!(pairs.contains(&("state".into(), "play".into())));
93+
94+ let responses = conn
95+ .execute_list(&[Command::new("status"), Command::new("currentsong")])
96+ .await
97+ .unwrap();
98+ assert_eq!(responses.len(), 2);
99+ assert_eq!(responses[1][0], ("file".into(), "a.flac".into()));
100+
101+ let error = conn
102+ .execute(&Command::new("play").arg("nope"))
103+ .await
104+ .unwrap_err();
105+ assert!(matches!(error, MpdError::Ack(ack) if ack.code == 2 && ack.command == "play"));
106+ // The connection stays usable after an ACK.
107+ conn.execute(&Command::new("ping")).await.unwrap();
108+}
109+
110+#[tokio::test]
111+async fn worker_answers_queries_and_runs_compound_requests() {
112+ let (address, mut seen) = fake_server(responder).await;
113+ let (tx, mut events) = mpsc::unbounded_channel::<MpdEvent>();
114+ let handle = mpd::spawn(address, None, tx);
115+
116+ assert!(matches!(
117+ next(&mut events).await,
118+ MpdEvent::Connected { .. }
119+ ));
120+
121+ handle.query(Query::Status);
122+ match next(&mut events).await {
123+ MpdEvent::Reply(Reply::Status { status, current }) => {
124+ assert_eq!(status.volume, Some(50));
125+ assert_eq!(current.unwrap().title(), Some("A"));
126+ }
127+ other => panic!("unexpected event {other:?}"),
128+ }
129+
130+ handle.request(Request::AddAndPlay(vec!["x.mp3".into(), "y.mp3".into()]));
131+ // Drain the requests seen by the server until the playid arrives.
132+ let mut lines = Vec::new();
133+ while !lines.iter().any(|l: &String| l.starts_with("playid")) {
134+ lines.push(
135+ timeout(Duration::from_secs(5), seen.recv())
136+ .await
137+ .unwrap()
138+ .unwrap(),
139+ );
140+ }
141+ assert!(lines.contains(&r#"addid "x.mp3""#.to_owned()));
142+ assert!(lines.contains(&r#"add "y.mp3""#.to_owned()));
143+ assert!(lines.contains(&r#"playid "42""#.to_owned()));
144+
145+ handle.exec(Command::new("play").arg("nope"));
146+ assert!(
147+ matches!(next(&mut events).await, MpdEvent::Error(msg) if msg.contains("Bad song index"))
148+ );
149+}
150+
151+async fn next(events: &mut mpsc::UnboundedReceiver<MpdEvent>) -> MpdEvent {
152+ timeout(Duration::from_secs(5), events.recv())
153+ .await
154+ .unwrap()
155+ .unwrap()
156+}
157+
158+#[test]
159+fn subsystem_names() {
160+ assert_eq!(
161+ "stored_playlist".parse::<Subsystem>().unwrap(),
162+ Subsystem::StoredPlaylist
163+ );
164+}