diff --git a/Cargo.lock b/Cargo.lock index b42d79e..396acf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -153,6 +168,15 @@ dependencies = [ "serde", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -186,12 +210,36 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -221,9 +269,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.36" +version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" dependencies = [ "clap_builder", "clap_derive", @@ -231,9 +279,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.36" +version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" dependencies = [ "anstream", "anstyle", @@ -311,6 +359,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -407,6 +479,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.20.11" @@ -451,6 +533,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -523,6 +615,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -596,6 +698,16 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -658,6 +770,23 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "human-panic" version = "2.0.2" @@ -674,6 +803,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1007,6 +1160,57 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror 2.0.12", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1183,6 +1387,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roff" version = "0.2.2" @@ -1227,6 +1445,50 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.23.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -1295,6 +1557,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1304,6 +1577,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.17" @@ -1362,6 +1641,32 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_pipeline" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af3613597e31606b54dd5d62be86b8f50922b40d2b7d3d145146caf5c154c05" +dependencies = [ + "chrono", + "clap", + "clap_mangen", + "once_cell", + "pest", + "pest_derive", + "regex", + "serde_json", + "strip-ansi-escapes", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1390,6 +1695,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.100" @@ -1423,13 +1734,17 @@ dependencies = [ "ratatui", "rustc-hash", "serde", + "serde_json", "signal-hook", + "string_pipeline", "tempfile", "tokio", "toml", "tracing", "tracing-subscriber", "unicode-width 0.2.0", + "ureq", + "walkdir", "winapi-util", ] @@ -1630,6 +1945,18 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1665,6 +1992,48 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a3e9af6113ecd57b8c63d3cd76a385b2e3881365f1f489e54f49801d0c83ea" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq-proto" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadf18427d33828c311234884b7ba2afb57143e6e7e69fda7ee883b624661e36" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1686,6 +2055,21 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1779,6 +2163,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.0", +] + +[[package]] +name = "webpki-roots" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1810,6 +2212,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1909,3 +2370,9 @@ checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 78e8de7..60b6466 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,10 @@ lazy-regex = { version = "3.4.1", features = [ "lite", ], default-features = false } ansi-to-tui = "7.0.0" +walkdir = "2.5.0" +string_pipeline = "0.11.1" +ureq = "3.0.11" +serde_json = "1.0.140" # target specific dependencies diff --git a/benches/main/ui.rs b/benches/main/ui.rs index 73f6174..fc0c20f 100644 --- a/benches/main/ui.rs +++ b/benches/main/ui.rs @@ -8,12 +8,11 @@ use ratatui::layout::Rect; use ratatui::prelude::{Line, Style}; use ratatui::style::Color; use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding}; +use television::channels::prototypes::ChannelPrototype; use television::{ action::Action, - channels::{ - entry::{Entry, into_ranges}, - prototypes::Cable, - }, + cable::Cable, + channels::entry::{Entry, into_ranges}, config::{Config, ConfigEnv}, screen::{colors::ResultsColorscheme, results::build_results_list}, television::Television, @@ -27,10 +26,9 @@ pub fn draw_results_list(c: &mut Criterion) { // I don't know how exactly right now just having it here instead let entries = [ Entry { - name: "typeshed/LICENSE".to_string(), - value: None, + display: None, + raw: "typeshed/LICENSE".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{f016}', color: "#7e8e91", @@ -38,10 +36,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/README.md".to_string(), - value: None, + display: None, + raw: "typeshed/README.md".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{f48a}', color: "#dddddd", @@ -49,10 +46,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/re.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/re.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -60,10 +56,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/io.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/io.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -71,10 +66,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/gc.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/gc.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -82,10 +76,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/uu.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/uu.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -93,10 +86,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/nt.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/nt.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -104,10 +96,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/dis.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/dis.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -115,10 +106,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/imp.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/imp.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -126,10 +116,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/bdb.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/bdb.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -137,10 +126,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/abc.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/abc.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -148,10 +136,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/cgi.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/cgi.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -159,10 +146,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/bz2.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/bz2.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -170,10 +156,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/grp.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/grp.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -181,10 +166,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/ast.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/ast.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -192,10 +176,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/csv.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/csv.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -203,10 +186,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/pdb.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/pdb.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -214,10 +196,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/pwd.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/pwd.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -225,10 +206,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/ssl.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/ssl.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -236,10 +216,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/tty.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/tty.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -247,10 +226,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/nis.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/nis.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -258,10 +236,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/pty.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/pty.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -269,10 +246,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/cmd.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/cmd.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -280,10 +256,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/tests/utils.py".to_string(), - value: None, + display: None, + raw: "typeshed/tests/utils.py".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -291,10 +266,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/pyproject.toml".to_string(), - value: None, + display: None, + raw: "typeshed/pyproject.toml".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e6b2}', color: "#9c4221", @@ -302,10 +276,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/MAINTAINERS.md".to_string(), - value: None, + display: None, + raw: "typeshed/MAINTAINERS.md".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{f48a}', color: "#dddddd", @@ -313,10 +286,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/enum.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/enum.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -324,10 +296,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/hmac.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/hmac.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -335,10 +306,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/uuid.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/uuid.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -346,10 +316,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/glob.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/glob.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -357,10 +326,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/_ast.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/_ast.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -368,10 +336,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/_csv.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/_csv.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -379,10 +346,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/code.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/code.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -390,10 +356,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/spwd.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/spwd.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -401,10 +366,9 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/_msi.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/_msi.pyi".to_string(), name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, icon: Some(FileIcon { icon: '\u{e606}', color: "#ffbc03", @@ -412,8 +376,8 @@ pub fn draw_results_list(c: &mut Criterion) { line_number: None, }, Entry { - name: "typeshed/stdlib/time.pyi".to_string(), - value: None, + display: None, + raw: "typeshed/stdlib/time.pyi".to_string(), icon: Some(FileIcon { icon: '\u{e606}', @@ -421,7 +385,6 @@ pub fn draw_results_list(c: &mut Criterion) { }), line_number: None, name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])), - value_match_ranges: None, }, ]; @@ -464,7 +427,9 @@ pub fn draw(c: &mut Criterion) { let rt = Runtime::new().unwrap(); - let cable = Cable::default(); + let cable = Cable::from_prototypes(vec![ChannelPrototype::new( + "files", "fd -t f", + )]); c.bench_function("draw", |b| { b.to_async(&rt).iter_batched( @@ -478,13 +443,13 @@ pub fn draw(c: &mut Criterion) { // Wait for the channel to finish loading let mut tv = Television::new( tx, - &channel_prototype, + channel_prototype, config, None, false, false, false, - Cable::default(), + cable.clone(), ); tv.find("television"); for _ in 0..5 { diff --git a/cable/unix/files.toml b/cable/unix/files.toml new file mode 100644 index 0000000..e578ea7 --- /dev/null +++ b/cable/unix/files.toml @@ -0,0 +1,40 @@ +[metadata] +name = "files" +description = "A channel to select files and directories" +requirements = ["fd", "bat"] + +[source] +command = "fd -t f" +interactive = false +# env +display = "{}" # show the full path +output = "{}" # output the full path + +[preview] +command = "bat -n --color=always {}" +env = { "BAT_THEME" = "ansi" } + +[ui] +layout = "landscape" +ui_scale = 100 +show_help_bar = false +show_preview_panel = true +input_bar_position = "bottom" + +[keybindings] +quit = ["esc", "ctrl-c"] +select_next_entry = ["down", "ctrl-n", "ctrl-j"] +select_prev_entry = ["up", "ctrl-p", "ctrl-k"] +confirm_selection = "enter" + +# [actions.'open in $EDITOR'] +# command = "$EDITOR {}" +# mode = "become" # "become" / "spawn" / "transform" +# +# [actions.'remove'] +# command = "rm {}" +# mode = "spawn" +# +# [actions.'rename'] +# command = "read -p \"New name: \" new_name && mv {} $new_name" +# mode = "spawn" diff --git a/cable/unix/text.toml b/cable/unix/text.toml new file mode 100644 index 0000000..7bc5447 --- /dev/null +++ b/cable/unix/text.toml @@ -0,0 +1,37 @@ +[metadata] +name = "text" +description = "A channel to find and select text from files" +requirements = ["rg", "bat"] + +[source] +command = "rg . --no-heading --line-number" + +[preview] +command = "bat -n --color=always {split:\\::0}" +env = { "BAT_THEME" = "ansi" } +offset = "{split:\\::1}" + +[ui] +layout = "landscape" +ui_scale = 100 +show_help_bar = false +show_preview_panel = true +input_bar_position = "bottom" + +[keybindings] +quit = ["esc", "ctrl-c"] +select_next_entry = ["down", "ctrl-n", "ctrl-j"] +select_prev_entry = ["up", "ctrl-p", "ctrl-k"] +confirm_selection = "enter" + +# [actions.'open in $EDITOR'] +# command = "$EDITOR {}" +# mode = "become" # "become" / "spawn" / "transform" +# +# [actions.'remove'] +# command = "rm {}" +# mode = "spawn" +# +# [actions.'rename'] +# command = "read -p \"New name: \" new_name && mv {} $new_name" +# mode = "spawn" diff --git a/television/app.rs b/television/app.rs index fedff04..b221401 100644 --- a/television/app.rs +++ b/television/app.rs @@ -6,10 +6,8 @@ use tracing::{debug, trace}; use crate::{ action::Action, - channels::{ - entry::Entry, - prototypes::{Cable, ChannelPrototype}, - }, + cable::Cable, + channels::{entry::Entry, prototypes::ChannelPrototype}, config::{Config, default_tick_rate}, event::{Event, EventLoop, Key}, keymap::Keymap, @@ -137,11 +135,11 @@ const ACTION_BUF_SIZE: usize = 8; impl App { pub fn new( - channel_prototype: &ChannelPrototype, + channel_prototype: ChannelPrototype, config: Config, input: Option, options: AppOptions, - cable_channels: &Cable, + cable_channels: Cable, ) -> Self { let (action_tx, action_rx) = mpsc::unbounded_channel(); let (render_tx, render_rx) = mpsc::unbounded_channel(); @@ -159,7 +157,7 @@ impl App { options.no_remote, options.no_help, options.exact, - cable_channels.clone(), + cable_channels, ); Self { diff --git a/television/cable.rs b/television/cable.rs index f031313..ecc6aad 100644 --- a/television/cable.rs +++ b/television/cable.rs @@ -1,15 +1,53 @@ -use std::path::PathBuf; +use std::{ + ops::Deref, + path::{Path, PathBuf}, +}; use rustc_hash::FxHashMap; - -use anyhow::Result; use tracing::{debug, error}; +use walkdir::WalkDir; use crate::{ - channels::prototypes::{Cable, ChannelPrototype}, + channels::prototypes::ChannelPrototype, cli::unknown_channel_exit, config::get_config_dir, }; +/// A neat `HashMap` of channel prototypes indexed by their name. +/// +/// This is used to store cable channel prototypes throughout the application +/// in a way that facilitates answering questions like "what's the prototype +/// for `files`?" or "does this channel exist?". +#[derive(Debug, serde::Deserialize, Clone)] +pub struct Cable(pub FxHashMap); + +impl Deref for Cable { + type Target = FxHashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Cable { + pub fn get_channel(&self, name: &str) -> ChannelPrototype { + self.get(name) + .cloned() + .unwrap_or_else(|| unknown_channel_exit(name)) + } + + pub fn has_channel(&self, name: &str) -> bool { + self.contains_key(name) + } + + pub fn from_prototypes(prototypes: Vec) -> Self { + let mut map = FxHashMap::default(); + for prototype in prototypes { + map.insert(prototype.metadata.name.clone(), prototype); + } + Cable(map) + } +} + /// Just a proxy struct to deserialize prototypes #[derive(Debug, serde::Deserialize, Default)] pub struct CableSpec { @@ -17,106 +55,99 @@ pub struct CableSpec { pub prototypes: Vec, } -const CABLE_FILE_NAME_SUFFIX: &str = "channels"; -const CABLE_FILE_FORMAT: &str = "toml"; +pub const CHANNEL_FILE_FORMAT: &str = "toml"; +/// ```ignore +/// config_folder/ +/// ├── config.toml +/// └── cable/ +/// ├── channel_1.toml +/// ├── channel_2.toml +/// └── ... +/// ``` +pub const CABLE_DIR_NAME: &str = "cable"; -#[cfg(unix)] -const DEFAULT_CABLE_CHANNELS: &str = - include_str!("../cable/unix-channels.toml"); +fn get_cable_files

(cable_dir: P) -> Vec +where + P: AsRef, +{ + WalkDir::new(cable_dir) + .into_iter() + .map(|e| e.unwrap().path().to_owned()) + .filter(|p| { + p.is_file() + && p.extension().is_some() + && p.extension().unwrap() == CHANNEL_FILE_FORMAT + }) + .collect::>() +} -#[cfg(not(unix))] -const DEFAULT_CABLE_CHANNELS: &str = - include_str!("../cable/windows-channels.toml"); +fn load_prototypes(cable_files: I) -> Vec +where + I: IntoIterator, +{ + cable_files + .into_iter() + .filter_map(|p| match std::fs::read_to_string(&p) { + Ok(content) => { + match toml::from_str::(&content) { + Ok(prototype) => { + debug!( + "Loaded cable channel prototype from {:?}: {}", + p, prototype.metadata.name + ); + Some(prototype) + } + Err(e) => { + error!( + "Failed to parse cable channel file {:?}: {}", + p, e + ); + None + } + } + } + Err(e) => { + error!("Failed to read cable channel file {:?}: {}", p, e); + None + } + }) + .collect() +} -/// Load the cable configuration from the config directory. +/// Load cable channels from the config directory. /// -/// Cable is loaded by compiling all files that match the following -/// pattern in the config directory: `*channels.toml`. +/// Cable is loaded by compiling all files located in the `cable/` subdirectory +/// of the user's configuration directory (+ defaults) /// /// # Example: /// ```ignore /// config_folder/ -/// ├── cable_channels.toml -/// ├── my_channels.toml -/// └── windows_channels.toml +/// ├── config.toml +/// └── cable/ +/// ├── channel_1.toml +/// ├── channel_2.toml +/// └── ... /// ``` -pub fn load_cable() -> Result { - let config_dir = get_config_dir(); +pub fn load_cable() -> Option { + let cable_dir = get_config_dir().join(CABLE_DIR_NAME); + debug!("Cable directory: {}", cable_dir.to_string_lossy()); + let cable_files = get_cable_files(&cable_dir); + debug!("Found cable channel files: {:?}", cable_files); - // list all files in the config directory - let files = std::fs::read_dir(&config_dir)?; + if cable_files.is_empty() { + println!( + "It seems you don't have any cable channels configured yet. +Run `tv update-channels` to get the latest default cable channels and/or add your own in `{}`. - // filter the files that match the pattern - let file_paths: Vec = files - .filter_map(|f| f.ok().map(|f| f.path())) - .filter(|p| is_cable_file_format(p) && p.is_file()) - .collect(); - - debug!("Found cable channel files: {:?}", file_paths); - if file_paths.is_empty() { - debug!("No user defined cable channels found"); +More info: https://github.com/alexpasmantier/television/blob/main/README.md", + cable_dir.to_string_lossy() + ); + return None; } - let default_prototypes = - toml::from_str::(DEFAULT_CABLE_CHANNELS) - .expect("Failed to parse default cable channels"); + let prototypes = load_prototypes(cable_files); - let prototypes = file_paths.iter().fold( - Vec::::new(), - |mut acc, p| { - match toml::from_str::( - &std::fs::read_to_string(p) - .expect("Unable to read configuration file"), - ) { - Ok(pts) => acc.extend(pts.prototypes), - Err(e) => { - error!( - "Failed to parse cable channel file {:?}: {}", - p, e - ); - } - } - acc - }, - ); + debug!("Loaded {} cable channels", prototypes.len()); - debug!("Loaded {} custom cable channels", prototypes.len()); - if prototypes.is_empty() { - debug!("No custom cable channels found"); - } - - let mut cable_channels = FxHashMap::default(); - // custom prototypes take precedence over default ones - for prototype in default_prototypes - .prototypes - .into_iter() - .chain(prototypes.into_iter()) - { - cable_channels.insert(prototype.name.clone(), prototype); - } - Ok(Cable(cable_channels)) -} - -fn is_cable_file_format

(p: P) -> bool -where - P: AsRef, -{ - let p = p.as_ref(); - p.file_stem() - .and_then(|s| s.to_str()) - .map_or_else(|| false, |s| s.ends_with(CABLE_FILE_NAME_SUFFIX)) - && p.extension() - .and_then(|e| e.to_str()) - .map_or_else(|| false, |e| e.to_lowercase() == CABLE_FILE_FORMAT) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_cable_file() { - let path = std::path::Path::new("cable_channels.toml"); - assert!(is_cable_file_format(path)); - } + Some(Cable::from_prototypes(prototypes)) } diff --git a/television/channels/cable.rs b/television/channels/channel.rs similarity index 59% rename from television/channels/cable.rs rename to television/channels/channel.rs index 1534a34..ec2ae1c 100644 --- a/television/channels/cable.rs +++ b/television/channels/channel.rs @@ -5,47 +5,28 @@ use std::process::Stdio; use rustc_hash::{FxBuildHasher, FxHashSet}; use tracing::debug; -use crate::channels::{ - entry::Entry, preview::PreviewCommand, prototypes::ChannelPrototype, -}; +use crate::channels::prototypes::SourceSpec; +use crate::channels::{entry::Entry, prototypes::ChannelPrototype}; use crate::matcher::Matcher; use crate::matcher::{config::Config, injector::Injector}; use crate::utils::command::shell_command; -use crate::utils::strings::format_string; - pub struct Channel { - pub name: String, + pub prototype: ChannelPrototype, matcher: Matcher, - pub preview_command: Option, selected_entries: FxHashSet, crawl_handle: tokio::task::JoinHandle<()>, } -impl Default for Channel { - fn default() -> Self { - Self::new(&ChannelPrototype::new( - "files", - "find . -type f", - false, - Some(PreviewCommand::new("cat {}", ":", None)), - )) - } -} - impl Channel { - pub fn new(prototype: &ChannelPrototype) -> Self { + pub fn new(prototype: ChannelPrototype) -> Self { let matcher = Matcher::new(Config::default()); let injector = matcher.injector(); - let crawl_handle = tokio::spawn(load_candidates( - prototype.source_command.to_string(), - prototype.interactive, - injector, - )); + let crawl_handle = + tokio::spawn(load_candidates(prototype.source.clone(), injector)); Self { + prototype, matcher, - preview_command: prototype.preview_command.clone(), - name: prototype.name.to_string(), selected_entries: HashSet::with_hasher(FxBuildHasher), crawl_handle, } @@ -61,27 +42,25 @@ impl Channel { .results(num_entries, offset) .into_iter() .map(|item| { - let path = item.matched_string; - Entry::new(path).with_name_match_indices(&item.match_indices) + Entry::new(item.inner) + .with_display(item.matched_string) + .with_match_indices(&item.match_indices) }) .collect() } pub fn get_result(&self, index: u32) -> Option { self.matcher.get_result(index).map(|item| { - let name = item.matched_string; - if let Some(cmd) = &self.preview_command { - if let Some(offset_expr) = &cmd.offset_expr { - let offset_string = - format_string(offset_expr, &name, &cmd.delimiter); - let offset_str = { - offset_string - .strip_prefix('\'') - .and_then(|s| s.strip_suffix('\'')) - .unwrap_or(&offset_string) - }; + let mut entry = Entry::new(item.inner.clone()) + .with_display(item.matched_string) + .with_match_indices(&item.match_indices); + if let Some(p) = &self.prototype.preview { + if let Some(offset_expr) = &p.offset { + let offset_str = offset_expr + .format(&item.inner) + .unwrap_or_else(|_| panic!("Failed to format offset expression '{}' with name '{}'", offset_expr.template_string(), item.inner)); - return Entry::new(name).with_line_number( + entry = entry.with_line_number( offset_str.parse::().unwrap_or_else(|_| { panic!( "Failed to parse line number from {}", @@ -91,7 +70,7 @@ impl Channel { ); } } - Entry::new(name) + entry }) } @@ -122,23 +101,22 @@ impl Channel { pub fn shutdown(&self) {} pub fn supports_preview(&self) -> bool { - self.preview_command.is_some() + self.prototype.preview.is_some() } } #[allow(clippy::unused_async)] -async fn load_candidates( - command: String, - interactive: bool, - injector: Injector, -) { - debug!("Loading candidates from command: {:?}", command); - let mut child = shell_command(interactive) - .arg(command) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("failed to execute process"); +async fn load_candidates(source: SourceSpec, injector: Injector) { + debug!("Loading candidates from command: {:?}", source.command); + let mut child = shell_command( + source.command.inner.template_string(), + source.command.interactive, + &source.command.env, + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to execute process"); if let Some(out) = child.stdout.take() { let reader = BufReader::new(out); @@ -150,13 +128,25 @@ async fn load_candidates( if !l.trim().is_empty() { let () = injector.push(l, |e, cols| { // PERF: maybe we can avoid cloning here by using &Utf32Str - cols[0] = e.clone().into(); + if let Some(display) = &source.display { + let formatted = display.format(e).unwrap_or_else(|_| { + panic!( + "Failed to format display expression '{}' with entry '{}'", + display.template_string(), + e + ); + }); + cols[0] = formatted.into(); + } else { + cols[0] = e.clone().into(); + } }); produced_output = true; } } } + // if the command didn't produce any output, check stderr and display that instead if !produced_output { let reader = BufReader::new(child.stderr.take().unwrap()); for line in reader.lines() { diff --git a/television/channels/entry.rs b/television/channels/entry.rs index 8279bb2..d54e962 100644 --- a/television/channels/entry.rs +++ b/television/channels/entry.rs @@ -1,20 +1,15 @@ -use std::{ - fmt::Write, - hash::{Hash, Hasher}, -}; +use std::hash::{Hash, Hasher}; use devicons::FileIcon; #[derive(Clone, Debug, Eq)] pub struct Entry { - /// The name of the entry. - pub name: String, - /// An optional value associated with the entry. - pub value: Option, + /// The raw entry (as captured from the source) + pub raw: String, + /// The actual entry string that will be displayed in the UI. + pub display: Option, /// The optional ranges for matching characters in the name. pub name_match_ranges: Option>, - /// The optional ranges for matching characters in the value. - pub value_match_ranges: Option>, /// The optional icon associated with the entry. pub icon: Option, /// The optional line number associated with the entry. @@ -23,7 +18,7 @@ pub struct Entry { impl Hash for Entry { fn hash(&self, state: &mut H) { - self.name.hash(state); + self.raw.hash(state); if let Some(line_number) = self.line_number { line_number.hash(state); } @@ -32,7 +27,7 @@ impl Hash for Entry { impl PartialEq for &Entry { fn eq(&self, other: &Entry) -> bool { - self.name == other.name + self.raw == other.raw && (self.line_number.is_none() && other.line_number.is_none() || self.line_number == other.line_number) } @@ -40,7 +35,7 @@ impl PartialEq for &Entry { impl PartialEq for Entry { fn eq(&self, other: &Entry) -> bool { - self.name == other.name + self.raw == other.raw && (self.line_number.is_none() && other.line_number.is_none() || self.line_number == other.line_number) } @@ -82,9 +77,8 @@ impl Entry { /// use devicons::FileIcon; /// /// let entry = Entry::new("name".to_string()) - /// .with_value("value".to_string()) - /// .with_name_match_indices(&vec![0]) - /// .with_value_match_indices(&vec![0]) + /// .with_display("Display Name".to_string()) + /// .with_match_indices(&vec![0]) /// .with_icon(FileIcon::default()) /// .with_line_number(0); /// ``` @@ -95,32 +89,26 @@ impl Entry { /// # Returns /// A new entry with the given name and preview type. /// The other fields are set to `None` by default. - pub fn new(name: String) -> Self { + pub fn new(raw: String) -> Self { Self { - name, - value: None, + raw, + display: None, name_match_ranges: None, - value_match_ranges: None, icon: None, line_number: None, } } - pub fn with_value(mut self, value: String) -> Self { - self.value = Some(value); + pub fn with_display(mut self, display: String) -> Self { + self.display = Some(display); self } - pub fn with_name_match_indices(mut self, indices: &[u32]) -> Self { + pub fn with_match_indices(mut self, indices: &[u32]) -> Self { self.name_match_ranges = Some(into_ranges(indices)); self } - pub fn with_value_match_indices(mut self, indices: &[u32]) -> Self { - self.value_match_ranges = Some(into_ranges(indices)); - self - } - pub fn with_icon(mut self, icon: FileIcon) -> Self { self.icon = Some(icon); self @@ -131,12 +119,12 @@ impl Entry { self } + pub fn display(&self) -> &str { + self.display.as_deref().unwrap_or(&self.raw) + } + pub fn stdout_repr(&self) -> String { - let mut repr = self.name.clone(); - if let Some(line_number) = self.line_number { - write!(repr, ":{}", line_number).unwrap(); - } - repr + self.raw.clone() } } @@ -171,26 +159,12 @@ mod tests { #[test] fn test_leaves_name_intact() { let entry = Entry { - name: "test name with spaces".to_string(), - value: None, + raw: "test name with spaces".to_string(), + display: None, name_match_ranges: None, - value_match_ranges: None, icon: None, line_number: None, }; assert_eq!(entry.stdout_repr(), "test name with spaces"); } - #[test] - fn test_uses_line_number_information() { - let a: usize = 10; - let entry = Entry { - name: "test_file_name.rs".to_string(), - value: None, - name_match_ranges: None, - value_match_ranges: None, - icon: None, - line_number: Some(a), - }; - assert_eq!(entry.stdout_repr(), "test_file_name.rs:10"); - } } diff --git a/television/channels/mod.rs b/television/channels/mod.rs index 5d20f43..f143092 100644 --- a/television/channels/mod.rs +++ b/television/channels/mod.rs @@ -1,5 +1,4 @@ -pub mod cable; +pub mod channel; pub mod entry; -pub mod preview; pub mod prototypes; pub mod remote_control; diff --git a/television/channels/preview.rs b/television/channels/preview.rs deleted file mode 100644 index d6f9ca5..0000000 --- a/television/channels/preview.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::fmt::Display; - -use serde::Deserialize; - -use crate::{channels::entry::Entry, utils::strings::format_string}; - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Deserialize)] -pub struct PreviewCommand { - pub command: String, - #[serde(default = "default_delimiter")] - pub delimiter: String, - #[serde(rename = "offset")] - pub offset_expr: Option, -} - -pub const DEFAULT_DELIMITER: &str = " "; - -/// The default delimiter to use for the preview command to use to split -/// entries into multiple referenceable parts. -#[allow(clippy::unnecessary_wraps)] -fn default_delimiter() -> String { - DEFAULT_DELIMITER.to_string() -} - -impl PreviewCommand { - pub fn new( - command: &str, - delimiter: &str, - offset_expr: Option, - ) -> Self { - Self { - command: command.to_string(), - delimiter: delimiter.to_string(), - offset_expr, - } - } - - /// Format the command with the entry name and provided placeholders. - /// - /// # Example - /// ``` - /// use television::channels::{preview::PreviewCommand, entry::Entry}; - /// - /// let command = PreviewCommand { - /// command: "something {} {2} {0}".to_string(), - /// delimiter: ":".to_string(), - /// offset_expr: None, - /// }; - /// let entry = Entry::new("a:given:entry:to:preview".to_string()); - /// - /// let formatted_command = command.format_with(&entry); - /// - /// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'"); - /// ``` - pub fn format_with(&self, entry: &Entry) -> String { - format_string(&self.command, &entry.name, &self.delimiter) - } -} - -impl Display for PreviewCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::channels::entry::Entry; - - #[test] - fn test_format_command() { - let command = PreviewCommand { - command: "something {} {2} {0}".to_string(), - delimiter: ":".to_string(), - offset_expr: None, - }; - let entry = Entry::new("an:entry:to:preview".to_string()); - let formatted_command = command.format_with(&entry); - - assert_eq!( - formatted_command, - "something 'an:entry:to:preview' 'to' 'an'" - ); - } - - #[test] - fn test_format_command_no_placeholders() { - let command = PreviewCommand { - command: "something".to_string(), - delimiter: ":".to_string(), - offset_expr: None, - }; - let entry = Entry::new("an:entry:to:preview".to_string()); - let formatted_command = command.format_with(&entry); - - assert_eq!(formatted_command, "something"); - } - - #[test] - fn test_format_command_with_global_placeholder_only() { - let command = PreviewCommand { - command: "something {}".to_string(), - delimiter: ":".to_string(), - offset_expr: None, - }; - let entry = Entry::new("an:entry:to:preview".to_string()); - let formatted_command = command.format_with(&entry); - - assert_eq!(formatted_command, "something 'an:entry:to:preview'"); - } - - #[test] - fn test_format_command_with_positional_placeholders_only() { - let command = PreviewCommand { - command: "something {0} -t {2}".to_string(), - delimiter: ":".to_string(), - offset_expr: None, - }; - let entry = Entry::new("an:entry:to:preview".to_string()); - let formatted_command = command.format_with(&entry); - - assert_eq!(formatted_command, "something 'an' -t 'to'"); - } -} diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index 8d2efb0..4d8de54 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -1,149 +1,400 @@ -use rustc_hash::FxHashMap; -use std::{ - fmt::{self, Display, Formatter}, - ops::Deref, -}; +use std::fmt::{self, Display, Formatter}; use crate::{ - cable::CableSpec, channels::preview::PreviewCommand, - cli::unknown_channel_exit, + config::KeyBindings, + screen::layout::{InputPosition, Orientation}, }; +use rustc_hash::FxHashMap; +use string_pipeline::MultiTemplate; -/// A prototype for cable channels. -/// -/// This can be seen as a cable channel specification, which is used to -/// create a cable channel. -/// -/// The prototype contains the following fields: -/// - `name`: The name of the channel. This will be used to identify the -/// channel throughout the application and in UI menus. -/// - `source_command`: The command to run to get the source for the channel. -/// This is a shell command that will be run in the background. -/// - `interactive`: Whether the source command should be run in an interactive -/// shell. This is useful for commands that need the user's environment e.g. -/// `alias`. -/// - `preview_command`: The command to run on each entry to get the preview -/// for the channel. If this is not `None`, the channel will display a preview -/// pane with the output of this command. -/// - `preview_delimiter`: The delimiter to use to split an entry into -/// multiple parts that can then be referenced in the preview command (e.g. -/// `{1} + {2}`). -/// - `preview_offset`: a litteral expression that will be interpreted later on -/// in order to determine the vertical offset at which the preview should be -/// displayed. -/// -/// # Example -/// The default files channel might look something like this: -/// ```toml -/// [[cable_channel]] -/// name = "files" -/// source_command = "fd -t f" -/// preview_command = "cat {}" -/// ``` -#[derive(Clone, Debug, serde::Deserialize, PartialEq)] -pub struct ChannelPrototype { - pub name: String, - pub source_command: String, +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct CommandSpec { + #[serde( + rename = "command", + deserialize_with = "deserialize_template", + serialize_with = "serialize_template" + )] + pub inner: MultiTemplate, #[serde(default)] pub interactive: bool, - #[serde(rename = "preview")] - pub preview_command: Option, + #[serde(default)] + pub env: FxHashMap, } -const STDIN_CHANNEL_NAME: &str = "stdin"; -const STDIN_SOURCE_COMMAND: &str = "cat"; +impl Display for CommandSpec { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.inner.template_string()) + } +} -impl ChannelPrototype { +impl CommandSpec { pub fn new( - name: &str, - source_command: &str, + inner: MultiTemplate, interactive: bool, - preview_command: Option, + env: FxHashMap, ) -> Self { Self { - name: name.to_string(), - source_command: source_command.to_string(), + inner, interactive, - preview_command, + env, } } +} - pub fn stdin(preview: Option) -> Self { +fn serialize_template( + command: &MultiTemplate, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let raw = command.template_string(); + serializer.serialize_str(raw) +} + +#[allow(clippy::ref_option)] +fn serialize_maybe_template( + command: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match command { + Some(template) => serialize_template(template, serializer), + None => serializer.serialize_none(), + } +} + +fn deserialize_template<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw: String = serde::Deserialize::deserialize(deserializer)?; + MultiTemplate::parse(&raw).map_err(serde::de::Error::custom) +} + +fn deserialize_maybe_template<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw: Option = serde::Deserialize::deserialize(deserializer)?; + match raw { + Some(template) => MultiTemplate::parse(&template) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct ChannelPrototype { + pub metadata: Metadata, + #[serde(rename = "source")] + pub source: SourceSpec, + #[serde(default, rename = "preview")] + pub preview: Option, + #[serde(default, rename = "ui")] + pub ui: Option, + #[serde(default)] + pub keybindings: Option, + // actions: Vec, +} + +impl ChannelPrototype { + // FIXME: is this really needed? maybe tests should use a toml spec directly + pub fn new(name: &str, source_command: &str) -> Self { Self { - name: STDIN_CHANNEL_NAME.to_string(), - source_command: STDIN_SOURCE_COMMAND.to_string(), - interactive: false, - preview_command: preview, + metadata: Metadata { + name: name.to_string(), + description: Some(String::new()), + requirements: vec![], + }, + source: SourceSpec { + command: CommandSpec { + inner: MultiTemplate::parse(source_command).expect( + "Failed to parse source command MultiTemplate", + ), + interactive: false, + env: FxHashMap::default(), + }, + display: None, + output: None, + }, + preview: None, + ui: None, + keybindings: None, } } - pub fn set_preview(self, preview_command: Option) -> Self { - Self::new( - &self.name, - &self.source_command, - self.interactive, - preview_command, - ) + pub fn stdin(preview: Option) -> Self { + Self { + metadata: Metadata { + name: "stdin".to_string(), + description: Some( + "A channel that reads from stdin".to_string(), + ), + requirements: vec![], + }, + source: SourceSpec { + command: CommandSpec { + inner: MultiTemplate::parse("cat").unwrap(), + interactive: false, + env: FxHashMap::default(), + }, + display: None, + output: None, + }, + preview, + ui: None, + keybindings: None, + } } + + pub fn with_preview(mut self, preview: Option) -> Self { + self.preview = preview; + self + } +} + +impl Display for ChannelPrototype { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.metadata.name) + } +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct Metadata { + pub name: String, + pub description: Option, + requirements: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct SourceSpec { + #[serde(flatten)] + pub command: CommandSpec, + #[serde( + default, + deserialize_with = "deserialize_maybe_template", + serialize_with = "serialize_maybe_template" + )] + pub display: Option, + #[serde( + default, + deserialize_with = "deserialize_maybe_template", + serialize_with = "serialize_maybe_template" + )] + pub output: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct PreviewSpec { + #[serde(flatten)] + pub command: CommandSpec, + #[serde( + default, + deserialize_with = "deserialize_maybe_template", + serialize_with = "serialize_maybe_template" + )] + pub offset: Option, +} + +impl PreviewSpec { + pub fn new(command: CommandSpec, offset: Option) -> Self { + Self { command, offset } + } + + pub fn from_str_command(command: &str) -> Self { + Self { + command: CommandSpec { + inner: MultiTemplate::parse(command) + .expect("Failed to parse preview command"), + interactive: false, + env: FxHashMap::default(), + }, + offset: None, + } + } +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct UiSpec { + #[serde(default)] + pub ui_scale: Option, + #[serde(default)] + pub show_help_bar: Option, + #[serde(default)] + pub show_preview_panel: Option, + // `layout` is clearer for the user but collides with the overall `Layout` type. + #[serde(rename = "layout", default)] + pub orientation: Option, + #[serde(default)] + pub input_bar_position: Option, } pub const DEFAULT_PROTOTYPE_NAME: &str = "files"; -impl Display for ChannelPrototype { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "{}", self.name) - } -} - -/// A neat `HashMap` of channel prototypes indexed by their name. -/// -/// This is used to store cable channel prototypes throughout the application -/// in a way that facilitates answering questions like "what's the prototype -/// for `files`?" or "does this channel exist?". -#[derive(Debug, serde::Deserialize, Clone)] -pub struct Cable(pub FxHashMap); - -impl Deref for Cable { - type Target = FxHashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Cable { - pub fn get_channel(&self, name: &str) -> ChannelPrototype { - self.get(name) - .cloned() - .unwrap_or_else(|| unknown_channel_exit(name)) - } - - pub fn has_channel(&self, name: &str) -> bool { - self.contains_key(name) - } -} - -/// A default cable channels specification that is compiled into the -/// application. -#[cfg(unix)] -const DEFAULT_CABLE_CHANNELS_FILE: &str = - include_str!("../../cable/unix-channels.toml"); -/// A default cable channels specification that is compiled into the -/// application. -#[cfg(not(unix))] -const DEFAULT_CABLE_CHANNELS_FILE: &str = - include_str!("../../cable/windows-channels.toml"); - -impl Default for Cable { - /// Fallback to the default cable channels specification (the template file - /// included in the repo). - fn default() -> Self { - let s = toml::from_str::(DEFAULT_CABLE_CHANNELS_FILE) - .expect("Unable to parse default cable channels"); - let mut prototypes = FxHashMap::default(); - for prototype in s.prototypes { - prototypes.insert(prototype.name.clone(), prototype); - } - Cable(prototypes) +#[cfg(test)] +mod tests { + use crate::{action::Action, config::Binding, event::Key}; + + use super::*; + use toml::from_str; + + #[test] + fn test_channel_prototype_deserialization() { + let toml_data = r#" + [metadata] + name = "files" + description = "A channel to select files and directories" + requirements = ["fd", "bat"] + + [source] + command = "fd -t f" + interactive = false + env = {} + display = "{split:/:-1}" # only show the last path segment ('/a/b/c' -> 'c') + ansi = false + output = "{}" # output the full path + + [preview] + command = "bat -n --color=always {}" + env = { "BAT_THEME" = "ansi" } + interactive = false + + [ui] + layout = "landscape" + ui_scale = 100 + show_help_bar = false + show_preview_panel = true + input_bar_position = "bottom" + + [keybindings] + quit = ["esc", "ctrl-c"] + select_next_entry = ["down", "ctrl-n", "ctrl-j"] + select_prev_entry = ["up", "ctrl-p", "ctrl-k"] + confirm_selection = "enter" + "#; + + let prototype: ChannelPrototype = from_str(toml_data).unwrap(); + + assert_eq!(prototype.metadata.name, "files"); + assert_eq!( + prototype.metadata.description, + Some("A channel to select files and directories".to_string()) + ); + assert_eq!(format!("{}", prototype.source.command.inner), "fd -t f"); + assert!(!prototype.source.command.interactive); + assert_eq!( + prototype.source.display.unwrap().template_string(), + "{split:/:-1}" + ); + assert_eq!(prototype.source.output.unwrap().template_string(), "{}"); + assert_eq!( + format!("{}", prototype.preview.unwrap().command.inner), + "bat -n --color=always {}" + ); + let ui = prototype.ui.unwrap(); + assert_eq!(ui.orientation, Some(Orientation::Landscape)); + assert_eq!(ui.ui_scale, Some(100)); + assert!(!(ui.show_help_bar.unwrap())); + assert!(ui.show_preview_panel.unwrap()); + assert_eq!(ui.input_bar_position, Some(InputPosition::Bottom)); + let keybindings = prototype.keybindings.unwrap(); + assert_eq!( + keybindings.0.get(&Action::Quit), + Some(&Binding::MultipleKeys(vec![Key::Esc, Key::Ctrl('c')])) + ); + assert_eq!( + keybindings.0.get(&Action::SelectNextEntry), + Some(&Binding::MultipleKeys(vec![ + Key::Down, + Key::Ctrl('n'), + Key::Ctrl('j') + ])) + ); + assert_eq!( + keybindings.0.get(&Action::SelectPrevEntry), + Some(&Binding::MultipleKeys(vec![ + Key::Up, + Key::Ctrl('p'), + Key::Ctrl('k') + ])) + ); + assert_eq!( + keybindings.0.get(&Action::ConfirmSelection), + Some(&Binding::SingleKey(Key::Enter)) + ); + } + + #[test] + fn test_channel_prototype_deserialization_bare_minimum() { + let toml_data = r#" + [metadata] + name = "files" + description = "A channel to select files and directories" + requirements = ["fd"] + + [source] + command = "fd -t f" + "#; + + let prototype: ChannelPrototype = from_str(toml_data).unwrap(); + + assert_eq!(prototype.metadata.name, "files"); + assert_eq!( + prototype.metadata.description, + Some("A channel to select files and directories".to_string()) + ); + assert_eq!(format!("{}", prototype.source.command.inner), "fd -t f"); + assert!(!prototype.source.command.interactive); + assert!(prototype.source.command.env.is_empty()); + assert!(prototype.source.display.is_none()); + assert!(prototype.source.output.is_none()); + assert!(prototype.preview.is_none()); + assert!(prototype.ui.is_none()); + assert!(prototype.keybindings.is_none()); + } + + #[test] + fn test_channel_prototype_deserialization_partial_ui_options() { + let toml_data = r#" + [metadata] + name = "files" + description = "A channel to select files and directories" + requirements = ["fd"] + + [source] + command = "fd -t f" + + [ui] + layout = "landscape" + ui_scale = 40 + "#; + + let prototype: ChannelPrototype = from_str(toml_data).unwrap(); + + assert_eq!(prototype.metadata.name, "files"); + assert_eq!( + prototype.metadata.description, + Some("A channel to select files and directories".to_string()) + ); + assert_eq!(format!("{}", prototype.source.command.inner), "fd -t f"); + assert!(!prototype.source.command.interactive); + assert!(prototype.source.command.env.is_empty()); + assert!(prototype.source.display.is_none()); + assert!(prototype.source.output.is_none()); + + let ui = prototype.ui.unwrap(); + assert_eq!(ui.orientation, Some(Orientation::Landscape)); + assert_eq!(ui.ui_scale, Some(40)); + assert!(ui.show_help_bar.is_none()); + assert!(ui.show_preview_panel.is_none()); + assert!(ui.input_bar_position.is_none()); } } diff --git a/television/channels/remote_control.rs b/television/channels/remote_control.rs index 66cfae6..c38f7e6 100644 --- a/television/channels/remote_control.rs +++ b/television/channels/remote_control.rs @@ -1,25 +1,22 @@ use crate::{ - channels::{ - entry::Entry, - prototypes::{Cable, ChannelPrototype}, - }, + cable::Cable, + channels::{entry::Entry, prototypes::ChannelPrototype}, matcher::{Matcher, config::Config}, }; -use anyhow::Result; use devicons::FileIcon; pub struct RemoteControl { matcher: Matcher, - cable_channels: Option, + cable_channels: Cable, } const NUM_THREADS: usize = 1; impl RemoteControl { - pub fn new(cable_channels: Option) -> Self { + pub fn new(cable_channels: Cable) -> Self { let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS)); let injector = matcher.injector(); - for c in cable_channels.as_ref().unwrap_or(&Cable::default()).keys() { + for c in cable_channels.keys() { let () = injector.push(c.clone(), |e, cols| { cols[0] = e.to_string().into(); }); @@ -30,24 +27,8 @@ impl RemoteControl { } } - pub fn zap(&self, channel_name: &str) -> Result { - match self - .cable_channels - .as_ref() - .and_then(|channels| channels.get(channel_name).cloned()) - { - Some(prototype) => Ok(prototype), - None => Err(anyhow::anyhow!( - "No channel or cable channel prototype found for {}", - channel_name - )), - } - } -} - -impl Default for RemoteControl { - fn default() -> Self { - Self::new(None) + pub fn zap(&self, channel_name: &str) -> ChannelPrototype { + self.cable_channels.get_channel(channel_name) } } @@ -74,7 +55,7 @@ impl RemoteControl { .map(|item| { let path = item.matched_string; Entry::new(path) - .with_name_match_indices(&item.match_indices) + .with_match_indices(&item.match_indices) .with_icon(CABLE_ICON) }) .collect() diff --git a/television/cli/args.rs b/television/cli/args.rs index 100bbe2..4e101d1 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -37,13 +37,6 @@ pub struct Cli { #[arg(long, default_value = "false", verbatim_doc_comment)] pub no_preview: bool, - /// The delimiter used to extract fields from the entry to provide to the - /// preview command. - /// - /// See the `preview` option for more information. - #[arg(long, value_name = "STRING", default_value = " ", value_parser = delimiter_parser, verbatim_doc_comment)] - pub delimiter: String, - /// The application's tick rate. /// /// The tick rate is the number of times the application will update per @@ -168,6 +161,9 @@ pub enum Command { #[arg(value_enum)] shell: Shell, }, + /// Downloads the latest collection of default channel prototypes from github + /// and saves them to the local configuration directory. + UpdateChannels, } #[derive(Debug, Clone, Copy, PartialEq, ValueEnum)] @@ -178,12 +174,3 @@ pub enum Shell { PowerShell, Cmd, } - -#[allow(clippy::unnecessary_wraps)] -fn delimiter_parser(s: &str) -> Result { - Ok(match s { - "" => " ".to_string(), - "\\t" => "\t".to_string(), - _ => s.to_string(), - }) -} diff --git a/television/cli/mod.rs b/television/cli/mod.rs index bd72f59..c47de22 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -1,15 +1,13 @@ use rustc_hash::FxHashMap; use std::path::Path; +use string_pipeline::MultiTemplate; use anyhow::{Result, anyhow}; use tracing::debug; use crate::{ - cable, - channels::{ - preview::PreviewCommand, - prototypes::{Cable, ChannelPrototype}, - }, + cable::{self, Cable}, + channels::prototypes::{ChannelPrototype, CommandSpec, PreviewSpec}, cli::args::{Cli, Command}, config::{KeyBindings, get_config_dir, get_data_dir}, }; @@ -20,7 +18,7 @@ pub mod args; #[derive(Debug, Clone)] pub struct PostProcessedCli { pub channel: Option, - pub preview_command: Option, + pub preview_spec: Option, pub no_preview: bool, pub tick_rate: Option, pub frame_rate: Option, @@ -41,7 +39,7 @@ impl Default for PostProcessedCli { fn default() -> Self { Self { channel: None, - preview_command: None, + preview_spec: None, no_preview: false, tick_rate: None, frame_rate: None, @@ -60,35 +58,48 @@ impl Default for PostProcessedCli { } } -pub fn post_process(cli: Cli, cable: &Cable) -> PostProcessedCli { +pub fn post_process(cli: Cli) -> PostProcessedCli { // Parse literal keybindings passed through the CLI let keybindings = cli.keybindings.as_ref().map(|kb| { parse_keybindings_literal(kb, CLI_KEYBINDINGS_DELIMITER) .unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string())) }); - // Parse the preview command if provided - let preview_command = cli.preview.as_ref().map(|preview| PreviewCommand { - command: preview.clone(), - delimiter: cli.delimiter.clone(), - offset_expr: cli.preview_offset.clone(), + // Parse the preview spec if provided + let preview_spec = cli.preview.as_ref().map(|preview| { + let command_spec = CommandSpec::new( + MultiTemplate::parse(preview).unwrap_or_else(|e| { + cli_parsing_error_exit(&format!( + "Error parsing preview command: {e}" + )) + }), + false, + FxHashMap::default(), + ); + PreviewSpec::new( + command_spec, + cli.preview_offset.map(|offset_str| { + MultiTemplate::parse(&offset_str).unwrap_or_else(|e| { + cli_parsing_error_exit(&format!( + "Error parsing preview offset: {e}" + )) + }) + }), + ) }); // Determine channel and working_directory let (channel, working_directory) = match &cli.channel { - Some(c) if !cable.has_channel(c) => { - if cli.working_directory.is_none() && Path::new(c).exists() { - (None, Some(c.clone())) - } else { - unknown_channel_exit(c); - } + Some(c) if Path::new(c).exists() => { + // If the channel is a path, use it as the working directory + (None, Some(c.clone())) } _ => (cli.channel.clone(), cli.working_directory.clone()), }; PostProcessedCli { channel, - preview_command, + preview_spec, no_preview: cli.no_preview, tick_rate: cli.tick_rate, frame_rate: cli.frame_rate, @@ -138,7 +149,8 @@ fn parse_keybindings_literal( } pub fn list_channels() { - for c in cable::load_cable().unwrap_or_default().keys() { + let channels = cable::load_cable().expect("Failed to load cable channels"); + for c in channels.keys() { println!("\t{c}"); } } @@ -257,21 +269,20 @@ mod tests { let cli = Cli { channel: Some("files".to_string()), preview: Some("bat -n --color=always {}".to_string()), - delimiter: ":".to_string(), working_directory: Some("/home/user".to_string()), ..Default::default() }; - let cable = cable::load_cable().unwrap_or_default(); - let post_processed_cli = post_process(cli, &cable); + let post_processed_cli = post_process(cli); assert_eq!( - post_processed_cli.preview_command, - Some(PreviewCommand { - command: "bat -n --color=always {}".to_string(), - delimiter: ":".to_string(), - offset_expr: None, - }) + post_processed_cli + .preview_spec + .unwrap() + .command + .inner + .template_string(), + "bat -n --color=always {}".to_string(), ); assert_eq!(post_processed_cli.tick_rate, None); assert_eq!(post_processed_cli.frame_rate, None); @@ -286,12 +297,10 @@ mod tests { fn test_from_cli_no_args() { let cli = Cli { channel: Some(".".to_string()), - delimiter: ":".to_string(), ..Default::default() }; - let cable = cable::load_cable().unwrap_or_default(); - let post_processed_cli = post_process(cli, &cable); + let post_processed_cli = post_process(cli); assert_eq!( post_processed_cli.working_directory, @@ -305,7 +314,6 @@ mod tests { let cli = Cli { channel: Some("files".to_string()), preview: Some(":env_var:".to_string()), - delimiter: ":".to_string(), keybindings: Some( "quit=\"esc\";select_next_entry=[\"down\",\"ctrl-j\"]" .to_string(), @@ -313,8 +321,7 @@ mod tests { ..Default::default() }; - let cable = cable::load_cable().unwrap_or_default(); - let post_processed_cli = post_process(cli, &cable); + let post_processed_cli = post_process(cli); let mut expected = KeyBindings::default(); expected.insert(Action::Quit, Binding::SingleKey(Key::Esc)); @@ -336,7 +343,11 @@ mod tests { ( command_mapping, "env", - cable::load_cable().unwrap_or_default(), + Cable::from_prototypes(vec![ + ChannelPrototype::new("files", "fd -t f"), + ChannelPrototype::new("env", "env"), + ChannelPrototype::new("git", "git status"), + ]), ) } @@ -354,7 +365,7 @@ mod tests { &channels, ); - assert_eq!(channel.name, "files"); + assert_eq!(channel.metadata.name, "files"); } #[test] @@ -371,7 +382,7 @@ mod tests { &channels, ); - assert_eq!(channel.name, fallback); + assert_eq!(channel.metadata.name, fallback); } #[test] @@ -388,6 +399,6 @@ mod tests { &channels, ); - assert_eq!(channel.name, fallback); + assert_eq!(channel.metadata.name, fallback); } } diff --git a/television/config/mod.rs b/television/config/mod.rs index eb6e240..0abf246 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -15,7 +15,10 @@ pub use themes::Theme; use tracing::{debug, warn}; pub use ui::UiConfig; -use crate::channels::prototypes::DEFAULT_PROTOTYPE_NAME; +use crate::{ + cable::CABLE_DIR_NAME, + channels::prototypes::{DEFAULT_PROTOTYPE_NAME, UiSpec}, +}; mod keybindings; pub mod shell_integration; @@ -84,9 +87,12 @@ impl ConfigEnv { pub fn init() -> Result { let data_dir = get_data_dir(); let config_dir = get_config_dir(); + let cable_dir = config_dir.join(CABLE_DIR_NAME); std::fs::create_dir_all(&config_dir) .context("Failed creating configuration directory")?; + std::fs::create_dir_all(&cable_dir) + .context("Failed creating cable directory")?; std::fs::create_dir_all(&data_dir) .context("Failed creating data directory")?; @@ -128,8 +134,7 @@ impl Config { Self::load_user_config(&config_env.config_dir)?; // merge the user configuration with the default configuration - let final_cfg = - Self::merge_user_with_default(default_config, user_cfg); + let final_cfg = Self::merge_with_default(default_config, user_cfg); debug!( "Configuration: \n{}", @@ -162,23 +167,20 @@ impl Config { Ok(user_cfg) } - fn merge_user_with_default( - mut default: Config, - mut user: Config, - ) -> Config { + fn merge_with_default(mut default: Config, mut new: Config) -> Config { // use default fallback channel as a fallback if user hasn't specified one - if user.shell_integration.fallback_channel.is_empty() { - user.shell_integration + if new.shell_integration.fallback_channel.is_empty() { + new.shell_integration .fallback_channel .clone_from(&default.shell_integration.fallback_channel); } // merge shell integration triggers with commands default.shell_integration.merge_triggers(); - user.shell_integration.merge_triggers(); + new.shell_integration.merge_triggers(); // merge shell integration commands with default commands - if user.shell_integration.commands.is_empty() { - user.shell_integration + if new.shell_integration.commands.is_empty() { + new.shell_integration .commands .clone_from(&default.shell_integration.commands); } @@ -186,19 +188,41 @@ impl Config { // merge shell integration keybindings with default keybindings let mut merged_keybindings = default.shell_integration.keybindings.clone(); - merged_keybindings.extend(user.shell_integration.keybindings.clone()); - user.shell_integration.keybindings = merged_keybindings; + merged_keybindings.extend(new.shell_integration.keybindings.clone()); + new.shell_integration.keybindings = merged_keybindings; // merge keybindings with default keybindings let keybindings = - merge_keybindings(default.keybindings.clone(), &user.keybindings); - user.keybindings = keybindings; + merge_keybindings(default.keybindings.clone(), &new.keybindings); + new.keybindings = keybindings; Config { - application: user.application, - keybindings: user.keybindings, - ui: user.ui, - shell_integration: user.shell_integration, + application: new.application, + keybindings: new.keybindings, + ui: new.ui, + shell_integration: new.shell_integration, + } + } + + pub fn merge_keybindings(&mut self, other: &KeyBindings) { + self.keybindings = merge_keybindings(self.keybindings.clone(), other); + } + + pub fn apply_prototype_ui_spec(&mut self, ui_spec: &UiSpec) { + if let Some(ui_scale) = &ui_spec.ui_scale { + self.ui.ui_scale = *ui_scale; + } + if let Some(show_help_bar) = &ui_spec.show_help_bar { + self.ui.show_help_bar = *show_help_bar; + } + if let Some(show_preview_panel) = &ui_spec.show_preview_panel { + self.ui.show_preview_panel = *show_preview_panel; + } + if let Some(orientation) = &ui_spec.orientation { + self.ui.orientation = *orientation; + } + if let Some(input_position) = &ui_spec.input_bar_position { + self.ui.input_bar_position = *input_position; } } } diff --git a/television/gh.rs b/television/gh.rs new file mode 100644 index 0000000..701e25c --- /dev/null +++ b/television/gh.rs @@ -0,0 +1,88 @@ +use anyhow::Result; +use std::path::{Path, PathBuf}; +use tracing::debug; +use ureq::get; + +use crate::channels::prototypes::ChannelPrototype; + +#[derive(Debug, Clone, serde::Deserialize)] +struct ApiResponse { + nodes: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct Node { + _name: String, + _path: PathBuf, + kind: NodeType, + download_url: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +enum NodeType { + #[serde(rename = "file")] + File, + #[serde(rename = "dir")] + Directory, +} + +const GITHUB_API_BASE_URL: &str = + "https://api.github.com/repos/alexpasmantier/television/contents/"; + +fn make_gh_content_request(gh_dir: &Path) -> Result { + let url = format!("{}{}", GITHUB_API_BASE_URL, gh_dir.to_str().unwrap()); + debug!("Making GitHub API request to: {}", url); + get(&url) + .header("User-Agent", "television-client") + .header("Accept", "application/vnd.github+json") + .call() + .map(|response| { + if response.status().is_success() { + serde_json::from_str::( + &response.into_body().read_to_string()?, + ) + .map_err(|e| anyhow::anyhow!("Failed to parse JSON: {}", e)) + } else { + Err(anyhow::anyhow!("Failed to fetch data from GitHub API")) + } + })? +} + +fn fetch_raw_content_from_url(url: &str) -> Result { + let response = + get(url).header("User-Agent", "television-client").call()?; + + if response.status().is_success() { + Ok(response.into_body().read_to_string()?) + } else { + Err(anyhow::anyhow!( + "Failed to fetch raw content from URL: {}", + url + )) + } +} + +#[cfg(unix)] +const DEFAULT_CABLE_DIR_PATH: &str = "cable/unix"; +#[cfg(windows)] +const DEFAULT_CABLE_DIR_PATH: &str = "cable/windows"; + +pub fn get_default_prototypes_from_repo() -> Result> { + let response = make_gh_content_request(Path::new(DEFAULT_CABLE_DIR_PATH))?; + debug!("Response from GitHub API: {:?}", response); + Ok(response + .nodes + .iter() + .filter_map(|node| { + if let NodeType::File = node.kind { + node.download_url.clone() + } else { + None + } + }) + .filter_map(|url| fetch_raw_content_from_url(&url).ok()) + .filter_map(|content| { + toml::from_str::(&content).ok() + }) + .collect()) +} diff --git a/television/lib.rs b/television/lib.rs index 0f17a01..686b9ac 100644 --- a/television/lib.rs +++ b/television/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod draw; pub mod errors; pub mod event; +pub mod gh; pub mod input; pub mod keymap; pub mod logging; diff --git a/television/main.rs b/television/main.rs index fc7eda1..1556f56 100644 --- a/television/main.rs +++ b/television/main.rs @@ -5,10 +5,11 @@ use std::process::exit; use anyhow::Result; use clap::Parser; -use television::cable::load_cable; +use television::cable::{CABLE_DIR_NAME, CHANNEL_FILE_FORMAT, load_cable}; use television::cli::post_process; +use television::gh::get_default_prototypes_from_repo; use television::{ - channels::prototypes::{Cable, ChannelPrototype}, + cable::Cable, channels::prototypes::ChannelPrototype, utils::clipboard::CLIPBOARD, }; use tracing::{debug, error, info}; @@ -20,7 +21,9 @@ use television::cli::{ guess_channel_from_prompt, list_channels, }; -use television::config::{Config, ConfigEnv, merge_keybindings}; +use television::config::{ + Config, ConfigEnv, get_config_dir, merge_keybindings, +}; use television::utils::shell::render_autocomplete_script_template; use television::utils::{ shell::{Shell, completion_script}, @@ -38,21 +41,21 @@ async fn main() -> Result<()> { let cli = Cli::parse(); debug!("CLI: {:?}", cli); + let args = post_process(cli); + debug!("PostProcessedCli: {:?}", args); + // load the configuration file debug!("Loading configuration..."); let mut config = Config::new(&ConfigEnv::init()?)?; - debug!("Loading cable channels..."); - let cable = load_cable().unwrap_or_default(); - - let args = post_process(cli, &cable); - debug!("PostProcessedCli: {:?}", args); - - // optionally handle subcommands + // handle subcommands debug!("Handling subcommands..."); - args.command - .as_ref() - .map(|x| handle_subcommands(x, &config)); + if let Some(subcommand) = &args.command { + handle_subcommand(subcommand, &config)?; + } + + debug!("Loading cable channels..."); + let cable = load_cable().unwrap_or_else(|| exit(1)); // optionally change the working directory args.working_directory.as_ref().map(set_current_dir); @@ -77,7 +80,7 @@ async fn main() -> Result<()> { config.application.tick_rate, ); let mut app = - App::new(&channel_prototype, config, args.input, options, &cable); + App::new(channel_prototype, config, args.input, options, cable); stdout().flush()?; debug!("Running application..."); let output = app.run(stdout().is_terminal(), false).await?; @@ -127,7 +130,7 @@ pub fn set_current_dir(path: &String) -> Result<()> { Ok(()) } -pub fn handle_subcommands(command: &Command, config: &Config) -> Result<()> { +pub fn handle_subcommand(command: &Command, config: &Config) -> Result<()> { match command { Command::ListChannels => { list_channels(); @@ -146,6 +149,30 @@ pub fn handle_subcommands(command: &Command, config: &Config) -> Result<()> { println!("{script}"); exit(0); } + // TODO: better error handling + Command::UpdateChannels => { + println!("Fetching latest cable channels..."); + let cable = + Cable::from_prototypes(get_default_prototypes_from_repo()?); + println!("Writing channels locally..."); + let cable_path = get_config_dir().join(CABLE_DIR_NAME); + if !cable_path.exists() { + println!( + "Creating cable directory at {}", + cable_path.display() + ); + std::fs::create_dir_all(&cable_path)?; + } + for (name, prototype) in cable.iter() { + let file_path = + cable_path.join(name).with_extension(CHANNEL_FILE_FORMAT); + let content = toml::to_string(&prototype)?; + std::fs::write(&file_path, content)?; + println!("Saved channel {} to {}", name, file_path.display()); + } + info!("Cable channels updated successfully."); + exit(0); + } } } @@ -157,7 +184,7 @@ pub fn determine_channel( ) -> ChannelPrototype { if readable_stdin { debug!("Using stdin channel"); - ChannelPrototype::stdin(args.preview_command.clone()) + ChannelPrototype::stdin(args.preview_spec.clone()) } else if let Some(prompt) = &args.autocomplete_prompt { debug!("Using autocomplete prompt: {:?}", prompt); let channel_prototype = guess_channel_from_prompt( @@ -177,8 +204,8 @@ pub fn determine_channel( let mut prototype = cable.get_channel(&channel); // use cli preview command if any - if let Some(pc) = &args.preview_command { - prototype.preview_command = Some(pc.clone()); + if let Some(pc) = &args.preview_spec { + prototype.preview = Some(pc.clone()); } prototype @@ -188,9 +215,9 @@ pub fn determine_channel( #[cfg(test)] mod tests { use rustc_hash::FxHashMap; - use television::{ - cable::load_cable, - channels::{preview::PreviewCommand, prototypes::ChannelPrototype}, + use string_pipeline::MultiTemplate; + use television::channels::prototypes::{ + ChannelPrototype, CommandSpec, PreviewSpec, }; use super::*; @@ -203,14 +230,18 @@ mod tests { cable_channels: Option, ) { let channels: Cable = - cable_channels.unwrap_or_else(|| load_cable().unwrap_or_default()); + cable_channels.unwrap_or(Cable::from_prototypes(vec![ + ChannelPrototype::new("files", "fd -t f"), + ChannelPrototype::new("dirs", "ls"), + ChannelPrototype::new("git", "git status"), + ])); let channel = determine_channel(args, config, readable_stdin, &channels); assert_eq!( - channel.name, expected_channel.name, + channel.metadata.name, expected_channel.metadata.name, "Expected {:?} but got {:?}", - expected_channel.name, channel.name + expected_channel.metadata.name, channel.metadata.name ); } @@ -223,7 +254,7 @@ mod tests { &args, &config, true, - &ChannelPrototype::new("stdin", "cat", false, None), + &ChannelPrototype::new("stdin", "cat"), None, ); } @@ -231,8 +262,7 @@ mod tests { #[test] fn test_determine_channel_autocomplete_prompt() { let autocomplete_prompt = Some("cd".to_string()); - let expected_channel = - ChannelPrototype::new("dirs", "ls {}", false, None); + let expected_channel = ChannelPrototype::new("dirs", "ls {}"); let args = PostProcessedCli { autocomplete_prompt, ..Default::default() @@ -274,14 +304,13 @@ mod tests { &args, &config, false, - &ChannelPrototype::new("dirs", "", false, None), + &ChannelPrototype::new("dirs", "ls {}"), None, ); } #[test] fn test_determine_channel_config_fallback() { - let cable = Cable::default(); let args = PostProcessedCli { channel: None, ..Default::default() @@ -292,34 +321,38 @@ mod tests { &args, &config, false, - &cable.get_channel("dirs"), - Some(cable), + &ChannelPrototype::new("dirs", "ls"), + None, ); } #[test] fn test_determine_channel_with_cli_preview() { - let cable = Cable::default(); - - let preview_command = PreviewCommand::new("echo hello", ",", None); + let preview_spec = PreviewSpec::new( + CommandSpec::new( + MultiTemplate::parse("echo hello").unwrap(), + false, + FxHashMap::default(), + ), + None, + ); let args = PostProcessedCli { channel: Some(String::from("dirs")), - preview_command: Some(preview_command), + preview_spec: Some(preview_spec), ..Default::default() }; let config = Config::default(); - let expected_prototype = cable - .get_channel("dirs") - .set_preview(args.preview_command.clone()); + let expected_prototype = ChannelPrototype::new("dirs", "ls") + .with_preview(args.preview_spec.clone()); assert_is_correct_channel( &args, &config, false, &expected_prototype, - Some(cable), + None, ); } diff --git a/television/previewer/mod.rs b/television/previewer/mod.rs index 1e6e193..42b3f2e 100644 --- a/television/previewer/mod.rs +++ b/television/previewer/mod.rs @@ -11,7 +11,10 @@ use tokio::{ use tracing::debug; use crate::{ - channels::{entry::Entry, preview::PreviewCommand}, + channels::{ + entry::Entry, + prototypes::{CommandSpec, PreviewSpec}, + }, utils::{ command::shell_command, strings::{ReplaceNonPrintableConfig, replace_non_printable}, @@ -134,13 +137,13 @@ pub struct Previewer { // FIXME: maybe use a bounded channel here with a single slot requests: UnboundedReceiver, last_job_entry: Option, - preview_command: PreviewCommand, + preview_spec: PreviewSpec, results: UnboundedSender, } impl Previewer { pub fn new( - preview_command: PreviewCommand, + preview_command: &PreviewSpec, config: Config, receiver: UnboundedReceiver, sender: UnboundedSender, @@ -149,7 +152,7 @@ impl Previewer { config, requests: receiver, last_job_entry: None, - preview_command, + preview_spec: preview_command.clone(), results: sender, } } @@ -168,15 +171,15 @@ impl Previewer { continue; } let results_handle = self.results.clone(); - let command = - self.preview_command.format_with(&ticket.entry); self.last_job_entry = Some(ticket.entry.clone()); // try to execute the preview with a timeout + let preview_command = + self.preview_spec.command.clone(); match timeout( self.config.job_timeout, tokio::spawn(async move { try_preview( - &command, + &preview_command, &ticket.entry, &results_handle, ); @@ -210,16 +213,22 @@ impl Previewer { } pub fn try_preview( - command: &str, + command: &CommandSpec, entry: &Entry, results_handle: &UnboundedSender, ) { debug!("Preview command: {}", command); - let child = shell_command(false) - .arg(command) - .output() - .expect("failed to execute process"); + let child = shell_command( + &command + .inner + .format(&entry.raw) + .expect("Failed to format command"), + command.interactive, + &command.env, + ) + .output() + .expect("failed to execute process"); let preview: Preview = { if child.status.success() { @@ -230,7 +239,7 @@ pub fn try_preview( .keep_control_characters(), ); Preview::new( - &entry.name, + &entry.raw, content.to_string(), None, u16::try_from(content.lines().count()).unwrap_or(u16::MAX), @@ -243,7 +252,7 @@ pub fn try_preview( .keep_control_characters(), ); Preview::new( - &entry.name, + &entry.raw, content.to_string(), None, u16::try_from(content.lines().count()).unwrap_or(u16::MAX), diff --git a/television/screen/results.rs b/television/screen/results.rs index 23d2b2d..66b874a 100644 --- a/television/screen/results.rs +++ b/television/screen/results.rs @@ -20,43 +20,13 @@ const POINTER_SYMBOL: &str = "> "; const SELECTED_SYMBOL: &str = "● "; const DESELECTED_SYMBOL: &str = " "; -/// The max width for each part of the entry (name and value) depending on various factors. -fn max_widths( - entry: &Entry, - available_width: u16, - use_icons: bool, - is_selected: bool, -) -> (u16, u16) { - let available_width = available_width.saturating_sub( +fn max_width(available_width: u16, use_icons: bool, is_selected: bool) -> u16 { + available_width.saturating_sub( 2 // pointer and space + 2 * (u16::from(use_icons)) + 2 * (u16::from(is_selected)) - + 2 // borders - + entry - .line_number - // ":{line_number}: " - .map_or(0, |l| 1 + u16::try_from(l.checked_ilog10().unwrap_or(0)).unwrap() + 3), - ); - - if entry.value.is_none() { - return (available_width, 0); - } - - // otherwise, use up the available space for both name and value as nicely as possible - let name_len = - u16::try_from(entry.name.chars().count()).unwrap_or(u16::MAX); - let value_len = entry - .value - .as_ref() - .map_or(0, |v| u16::try_from(v.chars().count()).unwrap_or(u16::MAX)); - - if name_len < available_width / 2 { - (name_len, available_width - name_len) - } else if value_len < available_width / 2 { - (available_width - value_len, value_len) - } else { - (available_width / 2, available_width / 2) - } + + 2, // borders + ) } // TODO: could we not just iterate on chars here instead of using the indices? @@ -70,8 +40,7 @@ fn build_result_line<'a>( area_width: u16, ) -> Line<'a> { let mut spans = Vec::new(); - let (name_max_width, value_max_width) = max_widths( - entry, + let name_max_width = max_width( area_width, use_icons, selected_entries @@ -104,7 +73,7 @@ fn build_result_line<'a>( // entry name let (mut entry_name, mut name_match_ranges) = make_matched_string_printable( - &entry.name, + entry.display(), entry.name_match_ranges.as_deref(), ); // if the name is too long, we need to truncate it and add an ellipsis @@ -160,56 +129,6 @@ fn build_result_line<'a>( Style::default().fg(colorscheme.result_line_number_fg), )); } - // optional value - if let Some(value) = &entry.value { - spans.push(Span::raw(": ")); - - let (mut value, mut value_match_ranges) = - make_matched_string_printable( - value, - entry.value_match_ranges.as_deref(), - ); - // if the value is too long, we need to truncate it and add an ellipsis - if value.as_str().width() > value_max_width as usize { - (value, value_match_ranges) = truncate_highlighted_string( - &value, - &value_match_ranges, - value_max_width, - ); - } - - let mut last_match_end = 0; - let value_chars = value.chars(); - let value_len = value.chars().count(); - for (start, end) in value_match_ranges - .iter() - .map(|(s, e)| (*s as usize, *e as usize)) - { - spans.push(Span::styled( - value_chars - .clone() - .skip(last_match_end) - .take(start - last_match_end) - .collect::(), - Style::default().fg(colorscheme.result_preview_fg), - )); - spans.push(Span::styled( - value_chars - .clone() - .skip(start) - .take(end - start) - .collect::(), - Style::default().fg(colorscheme.match_foreground_color), - )); - last_match_end = end; - } - if last_match_end < value_len { - spans.push(Span::styled( - value_chars.skip(last_match_end).collect::(), - Style::default().fg(colorscheme.result_preview_fg), - )); - } - } Line::from(spans) } @@ -301,7 +220,7 @@ mod tests { #[test] fn test_build_result_line() { let entry = Entry::new(String::from("something nice")) - .with_name_match_indices( + .with_match_indices( // something nice // 012345678901234 // om ni @@ -331,7 +250,7 @@ mod tests { let entry = // See https://github.com/alexpasmantier/television/issues/439 Entry::new(String::from("ジェイムス下地 - REDLINE Original Soundtrack - 06 - ROBOWORLD TV.mp3")) - .with_name_match_indices(&[27, 28, 29, 30, 31]); + .with_match_indices(&[27, 28, 29, 30, 31]); let result_line = build_result_line( &entry, None, diff --git a/television/television.rs b/television/television.rs index 16d7c25..4b25b5b 100644 --- a/television/television.rs +++ b/television/television.rs @@ -1,10 +1,9 @@ use crate::{ action::Action, + cable::Cable, channels::{ - cable::Channel as CableChannel, - entry::Entry, - prototypes::{Cable, ChannelPrototype}, - remote_control::RemoteControl, + channel::Channel as CableChannel, entry::Entry, + prototypes::ChannelPrototype, remote_control::RemoteControl, }, config::{Config, Theme}, draw::{ChannelState, Ctx, TvState}, @@ -47,6 +46,7 @@ pub enum MatchingMode { pub struct Television { action_tx: UnboundedSender, + base_config: Config, pub config: Config, pub channel: CableChannel, pub remote_control: Option, @@ -73,21 +73,32 @@ impl Television { #[must_use] pub fn new( action_tx: UnboundedSender, - channel_prototype: &ChannelPrototype, - mut config: Config, + channel_prototype: ChannelPrototype, + mut base_config: Config, input: Option, no_remote: bool, no_help: bool, exact: bool, cable_channels: Cable, ) -> Self { + if no_help { + base_config.ui.show_help_bar = false; + base_config.ui.no_help = true; + } + + let config = Self::merge_base_config_with_prototype_specs( + &base_config, + &channel_prototype, + ); + debug!("Merged config: {:?}", config); + let mut results_picker = Picker::new(input.clone()); if config.ui.input_bar_position == InputPosition::Bottom { results_picker = results_picker.inverted(); } // previewer - let preview_handles = Self::setup_previewer(channel_prototype); + let preview_handles = Self::setup_previewer(&channel_prototype); let mut channel = CableChannel::new(channel_prototype); @@ -113,14 +124,9 @@ impl Television { let remote_control = if no_remote { None } else { - Some(RemoteControl::new(Some(cable_channels))) + Some(RemoteControl::new(cable_channels)) }; - if no_help { - config.ui.show_help_bar = false; - config.ui.no_help = true; - } - let matching_mode = if exact { MatchingMode::Substring } else { @@ -129,6 +135,7 @@ impl Television { Self { action_tx, + base_config, config, channel, remote_control, @@ -150,15 +157,31 @@ impl Television { } } + fn merge_base_config_with_prototype_specs( + base_config: &Config, + channel_prototype: &ChannelPrototype, + ) -> Config { + let mut config = base_config.clone(); + // keybindings + if let Some(keybindings) = &channel_prototype.keybindings { + config.merge_keybindings(keybindings); + } + // ui + if let Some(ui_spec) = &channel_prototype.ui { + config.apply_prototype_ui_spec(ui_spec); + } + config + } + fn setup_previewer( channel_prototype: &ChannelPrototype, ) -> Option<(UnboundedSender, UnboundedReceiver)> { - if channel_prototype.preview_command.is_some() { + if let Some(preview_spec) = &channel_prototype.preview { let (pv_request_tx, pv_request_rx) = unbounded_channel(); let (pv_preview_tx, pv_preview_rx) = unbounded_channel(); let previewer = Previewer::new( - channel_prototype.preview_command.clone().unwrap(), + preview_spec, PreviewerConfig::default(), pv_request_rx, pv_preview_tx, @@ -176,7 +199,7 @@ impl Television { pub fn dump_context(&self) -> Ctx { let channel_state = ChannelState::new( - self.channel.name.clone(), + self.channel.prototype.metadata.name.clone(), self.channel.selected_entries().clone(), self.channel.total_count(), self.channel.running(), @@ -202,13 +225,12 @@ impl Television { } pub fn current_channel(&self) -> String { - self.channel.name.clone() + self.channel.prototype.metadata.name.clone() } - pub fn change_channel(&mut self, channel_prototype: &ChannelPrototype) { + pub fn change_channel(&mut self, channel_prototype: ChannelPrototype) { self.preview_state.reset(); - self.preview_state.enabled = - channel_prototype.preview_command.is_some(); + self.preview_state.enabled = channel_prototype.preview.is_some(); self.reset_picker_selection(); self.reset_picker_input(); self.current_pattern = EMPTY_STRING.to_string(); @@ -218,9 +240,13 @@ impl Television { .send(PreviewRequest::Shutdown) .expect("Failed to send shutdown signal to previewer"); } - self.preview_handles = Self::setup_previewer(channel_prototype); + debug!("Changing channel to {:?}", channel_prototype); + self.preview_handles = Self::setup_previewer(&channel_prototype); + self.config = Self::merge_base_config_with_prototype_specs( + &self.base_config, + &channel_prototype, + ); self.channel = CableChannel::new(channel_prototype); - debug!("Changed channel to {:?}", channel_prototype); } pub fn find(&mut self, pattern: &str) { @@ -544,13 +570,13 @@ impl Television { .remote_control .as_ref() .unwrap() - .zap(entry.name.as_str())?; + .zap(entry.raw.as_str()); // this resets the RC picker self.reset_picker_selection(); self.reset_picker_input(); self.remote_control.as_mut().unwrap().find(EMPTY_STRING); self.mode = Mode::Channel; - self.change_channel(&new_channel); + self.change_channel(new_channel); } } } @@ -562,7 +588,7 @@ impl Television { if let Some(entries) = self.get_selected_entries(None) { let copied_string = entries .iter() - .map(|e| e.name.clone()) + .map(|e| e.raw.clone()) .collect::>() .join(" "); diff --git a/television/utils/command.rs b/television/utils/command.rs index 5a83bce..10922a7 100644 --- a/television/utils/command.rs +++ b/television/utils/command.rs @@ -1,11 +1,15 @@ -use std::process::Command; +use std::{collections::HashMap, process::Command}; #[cfg(not(unix))] use tracing::warn; use super::shell::Shell; -pub fn shell_command(interactive: bool) -> Command { +pub fn shell_command( + command: &str, + interactive: bool, + envs: &HashMap, +) -> Command { let shell = Shell::from_env().unwrap_or_default(); let mut cmd = Command::new(shell.executable()); @@ -25,5 +29,6 @@ pub fn shell_command(interactive: bool) -> Command { warn!("Interactive mode is not supported on Windows."); } + cmd.envs(envs).arg(command); cmd } diff --git a/television/utils/indices.rs b/television/utils/indices.rs index 585b54a1..82f39e8 100644 --- a/television/utils/indices.rs +++ b/television/utils/indices.rs @@ -1,37 +1,5 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -pub fn sep_name_and_value_indices( - indices: Vec, - name_len: u32, -) -> (Vec, Vec, bool, bool) { - let mut name_indices = Vec::new(); - let mut value_indices = Vec::new(); - let mut should_add_name_indices = false; - let mut should_add_value_indices = false; - - for i in indices { - if i < name_len { - name_indices.push(i); - should_add_name_indices = true; - } else { - value_indices.push(i - name_len); - should_add_value_indices = true; - } - } - - name_indices.sort_unstable(); - name_indices.dedup(); - value_indices.sort_unstable(); - value_indices.dedup(); - - ( - name_indices, - value_indices, - should_add_name_indices, - should_add_value_indices, - ) -} - const ELLIPSIS: &str = "…"; const ELLIPSIS_CHAR_WIDTH_U16: u16 = 1; const ELLIPSIS_CHAR_WIDTH_U32: u32 = 1; diff --git a/tests/app.rs b/tests/app.rs index 8172ec6..6fb1939 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -3,7 +3,8 @@ use std::{collections::HashSet, path::PathBuf, time::Duration}; use television::{ action::Action, app::{App, AppOptions}, - channels::prototypes::{Cable, ChannelPrototype}, + cable::Cable, + channels::prototypes::ChannelPrototype, config::default_config_from_file, }; use tokio::{task::JoinHandle, time::timeout}; @@ -28,13 +29,13 @@ fn setup_app( JoinHandle, tokio::sync::mpsc::UnboundedSender, ) { - let chan: ChannelPrototype = channel_prototype.unwrap_or_else(|| { - let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("target_dir"); - std::env::set_current_dir(&target_dir).unwrap(); - Cable::default().get("files").unwrap().clone() - }); + let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("target_dir"); + std::env::set_current_dir(&target_dir).unwrap(); + + let chan: ChannelPrototype = + channel_prototype.unwrap_or(ChannelPrototype::new("files", "fd -t f")); let mut config = default_config_from_file().unwrap(); // this speeds up the tests config.application.tick_rate = 100.0; @@ -47,7 +48,17 @@ fn setup_app( false, config.application.tick_rate, ); - let mut app = App::new(&chan, config, input, options, &Cable::default()); + let mut app = App::new( + chan, + config, + input, + options, + Cable::from_prototypes(vec![ + ChannelPrototype::new("files", "fd -t f"), + ChannelPrototype::new("dirs", "fd -t d"), + ChannelPrototype::new("env", "printenv"), + ]), + ); // retrieve the app's action channel handle in order to send a quit action let tx = app.action_tx.clone(); @@ -102,13 +113,7 @@ async fn test_app_basic_search() { assert!(output.selected_entries.is_some()); assert_eq!( - &output - .selected_entries - .unwrap() - .drain() - .next() - .unwrap() - .name, + &output.selected_entries.unwrap().drain().next().unwrap().raw, "file1.txt" ); } @@ -142,7 +147,7 @@ async fn test_app_basic_search_multiselect() { .as_ref() .unwrap() .iter() - .map(|e| &e.name) + .map(|e| &e.raw) .collect::>(), HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()]) ); @@ -169,10 +174,7 @@ async fn test_app_exact_search_multiselect() { assert!(selected_entries.is_some()); // should contain a single entry with the prompt assert!(!selected_entries.as_ref().unwrap().is_empty()); - assert_eq!( - selected_entries.unwrap().drain().next().unwrap().name, - "fie" - ); + assert_eq!(selected_entries.unwrap().drain().next().unwrap().raw, "fie"); } #[tokio::test(flavor = "multi_thread", worker_threads = 3)] @@ -204,7 +206,7 @@ async fn test_app_exact_search_positive() { .as_ref() .unwrap() .iter() - .map(|e| &e.name) + .map(|e| &e.raw) .collect::>(), HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()]) ); @@ -212,8 +214,7 @@ async fn test_app_exact_search_positive() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_exits_when_select_1_and_only_one_result() { - let prototype = - ChannelPrototype::new("some_channel", "echo file1.txt", false, None); + let prototype = ChannelPrototype::new("some_channel", "echo file1.txt"); let (f, tx) = setup_app(Some(prototype), true, false); // tick a few times to get the results @@ -231,25 +232,15 @@ async fn test_app_exits_when_select_1_and_only_one_result() { assert!(output.selected_entries.is_some()); assert_eq!( - &output - .selected_entries - .unwrap() - .drain() - .next() - .unwrap() - .name, + &output.selected_entries.unwrap().drain().next().unwrap().raw, "file1.txt" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() { - let prototype = ChannelPrototype::new( - "some_channel", - "echo 'file1.txt\nfile2.txt'", - false, - None, - ); + let prototype = + ChannelPrototype::new("some_channel", "echo 'file1.txt\nfile2.txt'"); let (f, tx) = setup_app(Some(prototype), true, false); // tick a few times to get the results