1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
//! Floating completion popup for the `:` cmdline. Sits directly
//! above the cmdline bar, growing upward over the editor pane
//! content. Auto-shows on type so users discover completions
//! without knowing the Tab chord.
//!
//! Behavior:
//! - Renders when cmdline is open AND there are ≥2 matches for the
//! current token. Single match → no popup (the line itself is
//! the only candidate; Tab still works to complete it).
//! - Shows up to MAX_VISIBLE rows; if there are more matches, the
//! last row hints `(N more — Tab to cycle)`.
//! - Selected row highlights with bg3 + bold. Cycled by Tab / Down /
//! Up; mouse-click on a row sets selected + writes match into
//! cmdline.
//! - Layout: width = max(label_len) + 4 (padding + border), capped
//! at 60. Height = visible_rows + 2 (top + bottom border).
//! x-anchor: same as cmdline.x. y-anchor: cmdline.y - height.
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::app::App;
use crate::ui::theme;
use ratatui::style::Color;
/// Parse a `RRGGBB` hex string (no leading `#`) into a Color.
/// Empty or malformed returns None — caller falls back to theme.
fn parse_hex_color(hex: &str) -> Option<Color> {
let h = hex.trim().trim_start_matches('#');
if h.len() != 6 {
return None;
}
let r = u8::from_str_radix(&h[0..2], 16).ok()?;
let g = u8::from_str_radix(&h[2..4], 16).ok()?;
let b = u8::from_str_radix(&h[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
const MAX_VISIBLE: usize = 8;
const MAX_WIDTH: u16 = 110;
pub fn draw(frame: &mut Frame, app: &mut App, cmdline_bar: Rect) {
// Always clear the previous frame's rect registrations — the
// popup is render-time-computed, no stale state survives.
app.rects.cmdline_popup_items.clear();
if cmdline_bar.width == 0 || cmdline_bar.y < 1 {
return;
}
// Two paths can host an active `:` cmdline:
// 1. App.no_pane_cmdline — Ctrl+; from tree / empty-state.
// Stored as raw text (no `:` prefix, no caret marker).
// 2. The active editor's input handler `cmdline_get` — the
// vim `:` cmdline that the user just typed.
// Check (1) first to mirror `pending_display`'s precedence.
let line: String = if let Some(text) = app.no_pane_cmdline.clone() {
text
} else if let Some(text) = app.active_editor().and_then(|b| b.input.cmdline_get()) {
text
} else {
return;
};
// 2026-06-19 polish — empty cmdline (user just typed `:` or
// opened with Ctrl+;) shows recent commands so they can be
// re-fired with one keystroke. VS Code's palette behavior.
// Falls back to nothing if recent_commands is empty (fresh
// session).
let state = if line.trim().is_empty() {
if app.recent_commands.is_empty() {
return;
}
let matches: Vec<String> = app
.recent_commands
.iter()
.take(MAX_VISIBLE * 2)
.cloned()
.collect();
crate::app::CmdlineCompleteState {
head: String::new(),
matches,
idx: 0,
last_shown: String::new(),
}
} else {
// Compute matches fresh each frame. Cheap — N=~150 commands.
match crate::app::compute_cmdline_completions_for_app(app, &line) {
Some(s) if s.matches.len() >= 2 => s,
_ => return,
}
};
// Reset the popup-selected idx when the cmdline content has
// changed since the last render. Without this, typing in
// the cmdline leaves the highlight stranded on a row that's
// no longer first — visually disorienting. We piggyback off
// cmdline_complete_state.last_shown which the Tab cycle
// already tracks.
let prior_line = app
.cmdline_complete_state
.as_ref()
.map(|s| s.last_shown.clone())
.unwrap_or_default();
if prior_line != line {
app.cmdline_popup_selected = 0;
}
let total = state.matches.len();
let visible = total.min(MAX_VISIBLE);
// 2026-06-19 — polish: show command title alongside id, mark
// recent rows with ★. Recent-set + per-row title lookup
// (registry().get) are cheap — one HashMap probe per row.
let recent_set: std::collections::HashSet<&str> =
app.recent_commands.iter().map(|s| s.as_str()).collect();
let id_w = state
.matches
.iter()
.take(visible)
.map(|m| m.chars().count() as u16)
.max()
.unwrap_or(0);
let title_w = state
.matches
.iter()
.take(visible)
.filter_map(|m| {
crate::command::registry()
.get(m.as_str())
.map(|c| c.title.chars().count() as u16)
})
.max()
.unwrap_or(0);
let key_w = state
.matches
.iter()
.take(visible)
.filter_map(|m| crate::command::registry().get(m.as_str()))
.map(|c| c.key_hint().chars().count() as u16)
.max()
.unwrap_or(0);
// 2 (marker) + id + 3 (sep) + title + 3 (sep) + key hint
let needed = 2 + id_w + 3 + title_w + if key_w > 0 { 3 + key_w } else { 0 };
let inner_w = needed.clamp(20, MAX_WIDTH - 2);
let box_w = (inner_w + 2).min(cmdline_bar.width);
// +2 for top + bottom border; +1 if we need the "(N more)" row;
// +1 for the chord-hint footer (always shown).
let extra_row = if total > visible { 1 } else { 0 };
let footer_row = 1;
let box_h = (visible as u16) + 2 + extra_row + footer_row;
// Anchor: float UPward from cmdline. y = cmdline.y - box_h.
// If that goes negative (small terminal), clamp to row 0.
let box_y = cmdline_bar.y.saturating_sub(box_h);
let box_x = cmdline_bar.x;
let area = Rect {
x: box_x,
y: box_y,
width: box_w,
height: box_h,
};
let t = theme::cur();
// 2026-06-20 — border color is configurable via Settings →
// Color row `ui.cmdline_popup_border_color`. Empty string =
// theme yellow. Invalid hex falls back to theme yellow.
let border_color =
parse_hex_color(&app.config.ui.cmdline_popup_border_color).unwrap_or(t.yellow);
let block_style = Style::default().fg(t.fg).bg(t.bg_darker);
let block = Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(border_color)
.bg(t.bg_darker)
.add_modifier(Modifier::BOLD),
)
.style(block_style);
// Clear underlying cells then paint the bordered box.
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
// Clamp selected to current match count.
let selected = app.cmdline_popup_selected.min(total - 1);
// Window the visible slice around the selected row so it stays
// on screen as the user cycles past MAX_VISIBLE matches.
let start = if selected < visible {
0
} else {
selected + 1 - visible
};
let end = (start + visible).min(total);
for (offset, idx) in (start..end).enumerate() {
let match_text = &state.matches[idx];
let is_sel = idx == selected;
let title = crate::command::registry()
.get(match_text.as_str())
.map(|c| c.title);
let is_recent = recent_set.contains(match_text.as_str());
// Marker column: ▸ for selected, ★ for recent (only when
// not selected — selected wins visually), space otherwise.
let marker = if is_sel {
"▸ "
} else if is_recent {
"★ "
} else {
" "
};
let marker_style = if is_sel {
Style::default()
.fg(t.yellow)
.bg(t.bg3)
.add_modifier(Modifier::BOLD)
} else if is_recent {
Style::default().fg(t.yellow).bg(t.bg_darker)
} else {
Style::default().fg(t.comment).bg(t.bg_darker)
};
let id_style = if is_sel {
Style::default()
.fg(t.fg)
.bg(t.bg3)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.fg).bg(t.bg_darker)
};
let sep_style = Style::default()
.fg(t.bg3)
.bg(if is_sel { t.bg3 } else { t.bg_darker });
let title_style = if is_sel {
Style::default().fg(t.comment).bg(t.bg3)
} else {
Style::default().fg(t.comment).bg(t.bg_darker)
};
// Pad the id to id_w so the title column aligns.
let id_padded = format!("{:<width$}", match_text, width = id_w as usize);
let mut spans = vec![
Span::styled(marker.to_string(), marker_style),
Span::styled(id_padded, id_style),
];
if let Some(t_str) = title {
spans.push(Span::styled(" ".to_string(), sep_style));
// Pad title so the key column is right-aligned at
// inner.width (preserves visual alignment when the
// title is shorter than title_w).
let title_padded = format!("{:<width$}", t_str, width = title_w as usize);
spans.push(Span::styled(title_padded, title_style));
}
// Key chord column — right side, dim style. Shows the
// chord that fires this command if it has one bound.
let key_hint = crate::command::registry()
.get(match_text.as_str())
.map(|c| c.key_hint())
.unwrap_or_default();
if !key_hint.is_empty() {
spans.push(Span::styled(" ".to_string(), sep_style));
let key_style = if is_sel {
Style::default()
.fg(t.yellow)
.bg(t.bg3)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(t.yellow)
.bg(t.bg_darker)
.add_modifier(Modifier::DIM)
};
spans.push(Span::styled(key_hint.to_string(), key_style));
}
// Pad to inner width so the selected row's bg covers the
// entire line (no half-painted background on long titles).
let used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if used < inner.width as usize {
spans.push(Span::styled(
" ".repeat(inner.width as usize - used),
if is_sel {
Style::default().bg(t.bg3)
} else {
Style::default().bg(t.bg_darker)
},
));
}
let row_y = inner.y + offset as u16;
let row_rect = Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg_darker)),
row_rect,
);
app.rects.cmdline_popup_items.push((row_rect, idx));
}
// "(N more — Tab to cycle)" hint row if truncated.
if extra_row == 1 {
let hint = format!(" ({} more — Tab to cycle)", total - visible);
let truncated: String = hint.chars().take(inner.width as usize).collect();
let row_y = inner.y + visible as u16;
let row_rect = Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
truncated,
Style::default().fg(t.bg3).bg(t.bg_darker),
)))
.style(Style::default().bg(t.bg_darker)),
row_rect,
);
}
// 2026-06-20 — chord-hint footer at the bottom of the popup.
// Shows the keys that work here so first-time users don't
// have to guess. Right at the bottom-inside-the-border row.
let footer_y = inner.y + visible as u16 + extra_row;
let footer = " Tab/↓ next · Shift+Tab/↑ prev · Enter run · Esc cancel";
let truncated: String = footer.chars().take(inner.width as usize).collect();
let footer_rect = Rect {
x: inner.x,
y: footer_y,
width: inner.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
truncated,
Style::default()
.fg(t.comment)
.bg(t.bg_darker)
.add_modifier(Modifier::DIM),
)))
.style(Style::default().bg(t.bg_darker)),
footer_rect,
);
}