refactor(cable)!: cable format redesign

This commit is contained in:
alexandre pasmantier 2025-05-28 00:46:39 +02:00
parent 2e99fba9c0
commit fe931519a9
25 changed files with 1584 additions and 910 deletions

475
Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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 {

40
cable/unix/files.toml Normal file
View File

@ -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"

37
cable/unix/text.toml Normal file
View File

@ -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"

View File

@ -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<String>,
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 {

View File

@ -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<String, ChannelPrototype>);
impl Deref for Cable {
type Target = FxHashMap<String, ChannelPrototype>;
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<ChannelPrototype>) -> 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<ChannelPrototype>,
}
const CABLE_FILE_NAME_SUFFIX: &str = "channels";
const CABLE_FILE_FORMAT: &str = "toml";
#[cfg(unix)]
const DEFAULT_CABLE_CHANNELS: &str =
include_str!("../cable/unix-channels.toml");
#[cfg(not(unix))]
const DEFAULT_CABLE_CHANNELS: &str =
include_str!("../cable/windows-channels.toml");
/// Load the cable configuration from the config directory.
///
/// Cable is loaded by compiling all files that match the following
/// pattern in the config directory: `*channels.toml`.
///
/// # Example:
pub const CHANNEL_FILE_FORMAT: &str = "toml";
/// ```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<Cable> {
let config_dir = get_config_dir();
pub const CABLE_DIR_NAME: &str = "cable";
// list all files in the config directory
let files = std::fs::read_dir(&config_dir)?;
// filter the files that match the pattern
let file_paths: Vec<PathBuf> = 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");
fn get_cable_files<P>(cable_dir: P) -> Vec<PathBuf>
where
P: AsRef<Path>,
{
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::<Vec<_>>()
}
let default_prototypes =
toml::from_str::<CableSpec>(DEFAULT_CABLE_CHANNELS)
.expect("Failed to parse default cable channels");
let prototypes = file_paths.iter().fold(
Vec::<ChannelPrototype>::new(),
|mut acc, p| {
match toml::from_str::<CableSpec>(
&std::fs::read_to_string(p)
.expect("Unable to read configuration file"),
) {
Ok(pts) => acc.extend(pts.prototypes),
fn load_prototypes<I>(cable_files: I) -> Vec<ChannelPrototype>
where
I: IntoIterator<Item = PathBuf>,
{
cable_files
.into_iter()
.filter_map(|p| match std::fs::read_to_string(&p) {
Ok(content) => {
match toml::from_str::<ChannelPrototype>(&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
}
}
acc
},
}
Err(e) => {
error!("Failed to read cable channel file {:?}: {}", p, e);
None
}
})
.collect()
}
/// Load cable channels from the config directory.
///
/// Cable is loaded by compiling all files located in the `cable/` subdirectory
/// of the user's configuration directory (+ defaults)
///
/// # Example:
/// ```ignore
/// config_folder/
/// ├── config.toml
/// └── cable/
/// ├── channel_1.toml
/// ├── channel_2.toml
/// └── ...
/// ```
pub fn load_cable() -> Option<Cable> {
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);
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 `{}`.
More info: https://github.com/alexpasmantier/television/blob/main/README.md",
cable_dir.to_string_lossy()
);
debug!("Loaded {} custom cable channels", prototypes.len());
if prototypes.is_empty() {
debug!("No custom cable channels found");
return None;
}
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))
}
let prototypes = load_prototypes(cable_files);
fn is_cable_file_format<P>(p: P) -> bool
where
P: AsRef<std::path::Path>,
{
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)
}
debug!("Loaded {} cable channels", prototypes.len());
#[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))
}

View File

@ -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<String>,
pub preview_command: Option<PreviewCommand>,
selected_entries: FxHashSet<Entry>,
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<Entry> {
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::<usize>().unwrap_or_else(|_| {
panic!(
"Failed to parse line number from {}",
@ -91,7 +70,7 @@ impl Channel {
);
}
}
Entry::new(name)
entry
})
}
@ -122,19 +101,18 @@ 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<String>,
) {
debug!("Loading candidates from command: {:?}", command);
let mut child = shell_command(interactive)
.arg(command)
async fn load_candidates(source: SourceSpec, injector: Injector<String>) {
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()
@ -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
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() {

View File

@ -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<String>,
/// 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<String>,
/// The optional ranges for matching characters in the name.
pub name_match_ranges: Option<Vec<(u32, u32)>>,
/// The optional ranges for matching characters in the value.
pub value_match_ranges: Option<Vec<(u32, u32)>>,
/// The optional icon associated with the entry.
pub icon: Option<FileIcon>,
/// The optional line number associated with the entry.
@ -23,7 +18,7 @@ pub struct Entry {
impl Hash for Entry {
fn hash<H: Hasher>(&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<Entry> 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<Entry> for &Entry {
impl PartialEq<Entry> 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 stdout_repr(&self) -> String {
let mut repr = self.name.clone();
if let Some(line_number) = self.line_number {
write!(repr, ":{}", line_number).unwrap();
pub fn display(&self) -> &str {
self.display.as_deref().unwrap_or(&self.raw)
}
repr
pub fn stdout_repr(&self) -> String {
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");
}
}

View File

@ -1,5 +1,4 @@
pub mod cable;
pub mod channel;
pub mod entry;
pub mod preview;
pub mod prototypes;
pub mod remote_control;

View File

@ -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<String>,
}
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<String>,
) -> 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'");
}
}

View File

@ -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<PreviewCommand>,
#[serde(default)]
pub env: FxHashMap<String, String>,
}
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<PreviewCommand>,
env: FxHashMap<String, String>,
) -> Self {
Self {
name: name.to_string(),
source_command: source_command.to_string(),
inner,
interactive,
preview_command,
env,
}
}
}
pub fn stdin(preview: Option<PreviewCommand>) -> Self {
fn serialize_template<S>(
command: &MultiTemplate,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let raw = command.template_string();
serializer.serialize_str(raw)
}
#[allow(clippy::ref_option)]
fn serialize_maybe_template<S>(
command: &Option<MultiTemplate>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match command {
Some(template) => serialize_template(template, serializer),
None => serializer.serialize_none(),
}
}
fn deserialize_template<'de, D>(
deserializer: D,
) -> Result<MultiTemplate, D::Error>
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<Option<MultiTemplate>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<String> = 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<PreviewSpec>,
#[serde(default, rename = "ui")]
pub ui: Option<UiSpec>,
#[serde(default)]
pub keybindings: Option<KeyBindings>,
// actions: Vec<Action>,
}
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(),
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,
preview_command: preview,
env: FxHashMap::default(),
},
display: None,
output: None,
},
preview: None,
ui: None,
keybindings: None,
}
}
pub fn set_preview(self, preview_command: Option<PreviewCommand>) -> Self {
Self::new(
&self.name,
&self.source_command,
self.interactive,
preview_command,
)
pub fn stdin(preview: Option<PreviewSpec>) -> 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<PreviewSpec>) -> 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<String>,
requirements: Vec<String>,
}
#[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<MultiTemplate>,
#[serde(
default,
deserialize_with = "deserialize_maybe_template",
serialize_with = "serialize_maybe_template"
)]
pub output: Option<MultiTemplate>,
}
#[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<MultiTemplate>,
}
impl PreviewSpec {
pub fn new(command: CommandSpec, offset: Option<MultiTemplate>) -> 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<u16>,
#[serde(default)]
pub show_help_bar: Option<bool>,
#[serde(default)]
pub show_preview_panel: Option<bool>,
// `layout` is clearer for the user but collides with the overall `Layout` type.
#[serde(rename = "layout", default)]
pub orientation: Option<Orientation>,
#[serde(default)]
pub input_bar_position: Option<InputPosition>,
}
pub const DEFAULT_PROTOTYPE_NAME: &str = "files";
impl Display for ChannelPrototype {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{}", self.name)
}
#[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))
);
}
/// 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<String, ChannelPrototype>);
#[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"]
impl Deref for Cable {
type Target = FxHashMap<String, ChannelPrototype>;
[source]
command = "fd -t f"
"#;
fn deref(&self) -> &Self::Target {
&self.0
}
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());
}
impl Cable {
pub fn get_channel(&self, name: &str) -> ChannelPrototype {
self.get(name)
.cloned()
.unwrap_or_else(|| unknown_channel_exit(name))
}
#[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"]
pub fn has_channel(&self, name: &str) -> bool {
self.contains_key(name)
}
}
[source]
command = "fd -t f"
/// 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");
[ui]
layout = "landscape"
ui_scale = 40
"#;
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::<CableSpec>(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)
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());
}
}

View File

@ -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<String>,
cable_channels: Option<Cable>,
cable_channels: Cable,
}
const NUM_THREADS: usize = 1;
impl RemoteControl {
pub fn new(cable_channels: Option<Cable>) -> 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<ChannelPrototype> {
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()

View File

@ -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<String, String> {
Ok(match s {
"" => " ".to_string(),
"\\t" => "\t".to_string(),
_ => s.to_string(),
})
}

View File

@ -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<String>,
pub preview_command: Option<PreviewCommand>,
pub preview_spec: Option<PreviewSpec>,
pub no_preview: bool,
pub tick_rate: Option<f64>,
pub frame_rate: Option<f64>,
@ -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() {
Some(c) if Path::new(c).exists() => {
// If the channel is a path, use it as the working directory
(None, Some(c.clone()))
} else {
unknown_channel_exit(c);
}
}
_ => (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);
}
}

View File

@ -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<Self> {
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;
}
}
}

88
television/gh.rs Normal file
View File

@ -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<Node>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct Node {
_name: String,
_path: PathBuf,
kind: NodeType,
download_url: Option<String>,
}
#[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<ApiResponse> {
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::<ApiResponse>(
&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<String> {
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<Vec<ChannelPrototype>> {
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::<ChannelPrototype>(&content).ok()
})
.collect())
}

View File

@ -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;

View File

@ -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<Cable>,
) {
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,
);
}

View File

@ -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<Request>,
last_job_entry: Option<Entry>,
preview_command: PreviewCommand,
preview_spec: PreviewSpec,
results: UnboundedSender<Preview>,
}
impl Previewer {
pub fn new(
preview_command: PreviewCommand,
preview_command: &PreviewSpec,
config: Config,
receiver: UnboundedReceiver<Request>,
sender: UnboundedSender<Preview>,
@ -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,14 +213,20 @@ impl Previewer {
}
pub fn try_preview(
command: &str,
command: &CommandSpec,
entry: &Entry,
results_handle: &UnboundedSender<Preview>,
) {
debug!("Preview command: {}", command);
let child = shell_command(false)
.arg(command)
let child = shell_command(
&command
.inner
.format(&entry.raw)
.expect("Failed to format command"),
command.interactive,
&command.env,
)
.output()
.expect("failed to execute process");
@ -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),

View File

@ -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::<String>(),
Style::default().fg(colorscheme.result_preview_fg),
));
spans.push(Span::styled(
value_chars
.clone()
.skip(start)
.take(end - start)
.collect::<String>(),
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::<String>(),
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,

View File

@ -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<Action>,
base_config: Config,
pub config: Config,
pub channel: CableChannel,
pub remote_control: Option<RemoteControl>,
@ -73,21 +73,32 @@ impl Television {
#[must_use]
pub fn new(
action_tx: UnboundedSender<Action>,
channel_prototype: &ChannelPrototype,
mut config: Config,
channel_prototype: ChannelPrototype,
mut base_config: Config,
input: Option<String>,
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<PreviewRequest>, UnboundedReceiver<Preview>)>
{
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::<Vec<_>>()
.join(" ");

View File

@ -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<S>(
command: &str,
interactive: bool,
envs: &HashMap<String, String, S>,
) -> 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
}

View File

@ -1,37 +1,5 @@
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn sep_name_and_value_indices(
indices: Vec<u32>,
name_len: u32,
) -> (Vec<u32>, Vec<u32>, 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;

View File

@ -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<television::app::AppOutput>,
tokio::sync::mpsc::UnboundedSender<Action>,
) {
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 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<_>>(),
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<_>>(),
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