aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:46:43 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:46:43 +0100
commit90114cb5a3cb9ddf67b75f5a5c3c16841a6019ad (patch)
tree70577d64ca8730a04cca470603a52ce8224362ca
parenttodo: config caching is done (diff)
downloadrsplayer-90114cb5a3cb9ddf67b75f5a5c3c16841a6019ad.tar.gz
rsplayer-90114cb5a3cb9ddf67b75f5a5c3c16841a6019ad.zip
ctl: show the candidates when completion is ambiguousmain
Tab now behaves like bash: it extends the word to the longest common prefix and prints every candidate as a column list, instead of quietly cycling through them one per press. Above completion_prompt_limit (100) it asks before dumping the list. Candidates are inserted with their spaces escaped, so a completed track name stays one argument after Enter; the list itself shows the plain name.
-rw-r--r--src/ctl.rs59
1 files changed, 39 insertions, 20 deletions
diff --git a/src/ctl.rs b/src/ctl.rs
index 5cb2f3b..882982e 100644
--- a/src/ctl.rs
+++ b/src/ctl.rs
@@ -16,6 +16,7 @@
16use crate::cli::Cli; 16use crate::cli::Cli;
17use clap::{CommandFactory, Parser}; 17use clap::{CommandFactory, Parser};
18use rustyline::completion::{Completer, Pair}; 18use rustyline::completion::{Completer, Pair};
19use rustyline::config::{CompletionType, Config};
19use rustyline::error::ReadlineError; 20use rustyline::error::ReadlineError;
20use rustyline::highlight::Highlighter; 21use rustyline::highlight::Highlighter;
21use rustyline::hint::Hinter; 22use rustyline::hint::Hinter;
@@ -110,16 +111,31 @@ fn complete_music(prefix: &str) -> Vec<Pair> {
110 .lines() 111 .lines()
111 .map(str::trim) 112 .map(str::trim)
112 .filter(|candidate| !candidate.is_empty()) 113 .filter(|candidate| !candidate.is_empty())
113 // The CLI escapes spaces for shells; the shell's own reader needs the 114 // Show the real name, but insert it escaped: the editor replaces the
114 // real text back and re-quotes it below. 115 // word with this text, and an unescaped space would turn one argument
115 .map(unescape) 116 // into two on the next Enter.
116 .map(|candidate| Pair { 117 .map(|candidate| {
117 display: candidate.clone(), 118 let name = unescape(candidate);
118 replacement: quote(&candidate), 119 Pair {
120 display: name.clone(),
121 replacement: escape(&name),
122 }
119 }) 123 })
120 .collect() 124 .collect()
121} 125}
122 126
127/// Escape a candidate so it stays one argument when inserted into the line.
128fn escape(candidate: &str) -> String {
129 let mut out = String::with_capacity(candidate.len());
130 for c in candidate.chars() {
131 if matches!(c, ' ' | '\\' | '\'' | '"') {
132 out.push('\\');
133 }
134 out.push(c);
135 }
136 out
137}
138
123/// Undo the shell escaping `rsp db-complete` applies. 139/// Undo the shell escaping `rsp db-complete` applies.
124fn unescape(candidate: &str) -> String { 140fn unescape(candidate: &str) -> String {
125 let mut out = String::with_capacity(candidate.len()); 141 let mut out = String::with_capacity(candidate.len());
@@ -136,15 +152,6 @@ fn unescape(candidate: &str) -> String {
136 out 152 out
137} 153}
138 154
139/// Quote a candidate that contains spaces, so it stays one argument.
140fn quote(candidate: &str) -> String {
141 if candidate.contains(char::is_whitespace) {
142 format!("'{}'", candidate.replace('\'', r"'\''"))
143 } else {
144 candidate.to_string()
145 }
146}
147
148/// Split a command line into arguments, honouring quotes and backslash 155/// Split a command line into arguments, honouring quotes and backslash
149/// escapes so that track names with spaces survive. 156/// escapes so that track names with spaces survive.
150fn split_args(line: &str) -> Vec<String> { 157fn split_args(line: &str) -> Vec<String> {
@@ -257,7 +264,14 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
257 } 264 }
258 265
259 let history_path = crate::config::runtime_dir().join("ctl_history"); 266 let history_path = crate::config::runtime_dir().join("ctl_history");
260 let mut editor: Editor<CtlHelper, DefaultHistory> = Editor::default()?; 267 // Bash-style completion: extend to the longest common prefix and print the
268 // candidates when the word stays ambiguous, instead of silently cycling
269 // through them (which hides what is actually available).
270 let config = Config::builder()
271 .completion_type(CompletionType::List)
272 .completion_show_all_if_ambiguous(true)
273 .build();
274 let mut editor: Editor<CtlHelper, DefaultHistory> = Editor::with_default(config)?;
261 let _ = editor.load_history(&history_path); 275 let _ = editor.load_history(&history_path);
262 276
263 if !crate::config::Config::get_socket_path().exists() { 277 if !crate::config::Config::get_socket_path().exists() {
@@ -337,11 +351,16 @@ mod tests {
337 } 351 }
338 352
339 #[test] 353 #[test]
340 fn quotes_single_word_arguments_only_when_needed() { 354 fn escapes_candidates_before_inserting_them() {
341 assert_eq!(quote("AFX"), "AFX"); 355 assert_eq!(escape("AFX"), "AFX");
356 assert_eq!(escape("Analogue Bubblebath/"), r"Analogue\ Bubblebath/");
357 assert_eq!(escape(r"a\\b"), r"a\\\\b");
358
359 // What the editor inserts must come back as a single argument.
360 let line = format!("play {}", escape("AFX/Analogue Bubblebath, Vol 3.1/"));
342 assert_eq!( 361 assert_eq!(
343 quote("Analogue Bubblebath, Vol 3.1"), 362 split_args(&line),
344 "'Analogue Bubblebath, Vol 3.1'" 363 vec!["play", "AFX/Analogue Bubblebath, Vol 3.1/"]
345 ); 364 );
346 } 365 }
347 366