diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:46:43 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:46:43 +0100 |
| commit | 90114cb5a3cb9ddf67b75f5a5c3c16841a6019ad (patch) | |
| tree | 70577d64ca8730a04cca470603a52ce8224362ca | |
| parent | todo: config caching is done (diff) | |
| download | rsplayer-main.tar.gz rsplayer-main.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.rs | 59 |
1 files changed, 39 insertions, 20 deletions
| @@ -16,6 +16,7 @@ | |||
| 16 | use crate::cli::Cli; | 16 | use crate::cli::Cli; |
| 17 | use clap::{CommandFactory, Parser}; | 17 | use clap::{CommandFactory, Parser}; |
| 18 | use rustyline::completion::{Completer, Pair}; | 18 | use rustyline::completion::{Completer, Pair}; |
| 19 | use rustyline::config::{CompletionType, Config}; | ||
| 19 | use rustyline::error::ReadlineError; | 20 | use rustyline::error::ReadlineError; |
| 20 | use rustyline::highlight::Highlighter; | 21 | use rustyline::highlight::Highlighter; |
| 21 | use rustyline::hint::Hinter; | 22 | use 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. | ||
| 128 | fn 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. |
| 124 | fn unescape(candidate: &str) -> String { | 140 | fn 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. | ||
| 140 | fn 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. |
| 150 | fn split_args(line: &str) -> Vec<String> { | 157 | fn 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 | ||
