W40: Cmdline completion hint popup

When Tab in the cmdline completes to more than one candidate,
show a popup above the cmdline listing the candidates. Standard
shell completion behaviour (fish, zsh, bash). Helps users pick
the right file when many match.

- Cmdline::completions: Vec<String> field stores the candidates
- Cmdline::clear_completions() for cleanup on commit/cancel
- Cmdline::has_completions() returns true if >1 candidates
- Cmdline::render shows the popup when has_completions() is true
- Esc/Enter in handle_key clears completions

Added 1 unit test verifying completions are populated and
clear_completions works.

Tests: 1402 pass (was 1401), zero warnings.
This commit is contained in:
2026-07-07 05:59:05 +03:00
parent 0035aa65cc
commit dece1210c7
@@ -200,6 +200,21 @@ if matches.is_empty() {
self.base_dir = Some(path);
}
/// Clear the completion hint list. Call this when the cmdline
/// is committed or cancelled.
pub fn clear_completions(&mut self) {
self.completions.clear();
}
/// `true` when the last Tab completion produced multiple
/// candidates (so the renderer should show a hint popup).
#[must_use]
pub fn has_completions(&self) -> bool {
self.completions.len() > 1
}
/// Insert `text` at the cursor. Activates the cmdline if it
/// is not already active.
pub fn insert_text(&mut self, text: &str) {
if !self.active {
self.activate();
@@ -268,6 +283,7 @@ if matches.is_empty() {
}
match key {
Key::ESCAPE => {
self.clear_completions();
self.deactivate();
CmdlineResult::Cancelled
}
@@ -277,6 +293,7 @@ if matches.is_empty() {
return CmdlineResult::Cancelled;
}
self.push_history(&cmd);
self.clear_completions();
self.deactivate();
CmdlineResult::Execute(cmd)
}
@@ -409,6 +426,52 @@ if matches.is_empty() {
let line = Line::from(prompt_span);
frame.render_widget(Paragraph::new(line), area);
}
// Completion hint popup: shown above the cmdline when
// Tab produced multiple candidates.
if self.has_completions() && area.y >= self.completions.len() as u16 {
let hint_h = self.completions.len() as u16 + 2;
let hint_w = (self
.completions
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(0) as u16)
+ 4;
let hint_area = Rect {
x: area.x,
y: area.y.saturating_sub(hint_h),
width: hint_w.min(area.width),
height: hint_h,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
" Completions ",
Style::default()
.fg(theme.cursor_fg)
.bg(theme.accent)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(hint_area);
frame.render_widget(block, hint_area);
let mut y = inner.y;
for cand in &self.completions {
if y >= inner.y + inner.height {
break;
}
let line = Line::from(Span::styled(
format!(" {}", cand),
Style::default().fg(theme.foreground),
));
frame.render_widget(
Paragraph::new(line),
Rect::new(inner.x, y, inner.width, 1),
);
y += 1;
}
}
}
}
@@ -536,4 +599,24 @@ mod tests {
assert_eq!(c.input.value(), "foo_");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn complete_populates_completions_for_popup() {
let dir = std::env::temp_dir().join("tlc-clp-cmdline-completions");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(dir.join("alpha")).unwrap();
std::fs::create_dir_all(dir.join("alpine")).unwrap();
let mut c = Cmdline::new();
c.activate();
c.insert_text("al");
c.complete(Some(&dir));
// Two candidates should have populated completions.
assert!(c.has_completions());
assert!(c.completions.contains(&"alpha".to_string()));
assert!(c.completions.contains(&"alpine".to_string()));
c.clear_completions();
assert!(!c.has_completions());
let _ = std::fs::remove_dir_all(&dir);
}
}