From 90114cb5a3cb9ddf67b75f5a5c3c16841a6019ad Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 2 Nov 2026 13:46:43 +0100 Subject: ctl: show the candidates when completion is ambiguous 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. --- src/ctl.rs | 59 +++++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 20 deletions(-) (limited to 'src/ctl.rs') 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 @@ use crate::cli::Cli; use clap::{CommandFactory, Parser}; use rustyline::completion::{Completer, Pair}; +use rustyline::config::{CompletionType, Config}; use rustyline::error::ReadlineError; use rustyline::highlight::Highlighter; use rustyline::hint::Hinter; @@ -110,16 +111,31 @@ fn complete_music(prefix: &str) -> Vec { .lines() .map(str::trim) .filter(|candidate| !candidate.is_empty()) - // The CLI escapes spaces for shells; the shell's own reader needs the - // real text back and re-quotes it below. - .map(unescape) - .map(|candidate| Pair { - display: candidate.clone(), - replacement: quote(&candidate), + // Show the real name, but insert it escaped: the editor replaces the + // word with this text, and an unescaped space would turn one argument + // into two on the next Enter. + .map(|candidate| { + let name = unescape(candidate); + Pair { + display: name.clone(), + replacement: escape(&name), + } }) .collect() } +/// Escape a candidate so it stays one argument when inserted into the line. +fn escape(candidate: &str) -> String { + let mut out = String::with_capacity(candidate.len()); + for c in candidate.chars() { + if matches!(c, ' ' | '\\' | '\'' | '"') { + out.push('\\'); + } + out.push(c); + } + out +} + /// Undo the shell escaping `rsp db-complete` applies. fn unescape(candidate: &str) -> String { let mut out = String::with_capacity(candidate.len()); @@ -136,15 +152,6 @@ fn unescape(candidate: &str) -> String { out } -/// Quote a candidate that contains spaces, so it stays one argument. -fn quote(candidate: &str) -> String { - if candidate.contains(char::is_whitespace) { - format!("'{}'", candidate.replace('\'', r"'\''")) - } else { - candidate.to_string() - } -} - /// Split a command line into arguments, honouring quotes and backslash /// escapes so that track names with spaces survive. fn split_args(line: &str) -> Vec { @@ -257,7 +264,14 @@ pub fn run() -> Result<(), Box> { } let history_path = crate::config::runtime_dir().join("ctl_history"); - let mut editor: Editor = Editor::default()?; + // Bash-style completion: extend to the longest common prefix and print the + // candidates when the word stays ambiguous, instead of silently cycling + // through them (which hides what is actually available). + let config = Config::builder() + .completion_type(CompletionType::List) + .completion_show_all_if_ambiguous(true) + .build(); + let mut editor: Editor = Editor::with_default(config)?; let _ = editor.load_history(&history_path); if !crate::config::Config::get_socket_path().exists() { @@ -337,11 +351,16 @@ mod tests { } #[test] - fn quotes_single_word_arguments_only_when_needed() { - assert_eq!(quote("AFX"), "AFX"); + fn escapes_candidates_before_inserting_them() { + assert_eq!(escape("AFX"), "AFX"); + assert_eq!(escape("Analogue Bubblebath/"), r"Analogue\ Bubblebath/"); + assert_eq!(escape(r"a\\b"), r"a\\\\b"); + + // What the editor inserts must come back as a single argument. + let line = format!("play {}", escape("AFX/Analogue Bubblebath, Vol 3.1/")); assert_eq!( - quote("Analogue Bubblebath, Vol 3.1"), - "'Analogue Bubblebath, Vol 3.1'" + split_args(&line), + vec!["play", "AFX/Analogue Bubblebath, Vol 3.1/"] ); } -- cgit v1.2.3