ADR-0022 stage 8b: hint panel candidate list with scroll markers
Refactor `ambient_hint` to return a richer enum:
- `Prose(String)` — the existing single-line hint (Valid /
incomplete-with-no-keywords / definite-error states);
- `Candidates { items, selected }` — multi-candidate (or
single-candidate) keyword completion at the cursor.
When `candidates_at_cursor` returns Some, the new
`Candidates` variant wins over the prose framing — the
candidate list is more actionable than "expected: `data` or
`table`". `selected` tracks the live `LastCompletion` memo's
selection_idx for the renderer to highlight.
`render_candidate_line` (new helper in ui.rs):
- All items fit → render space-separated; selected item
rendered bold + theme.fg, others theme.muted.
- Overflow → window centred on the selected item (or
item 0 with no selection); `< ` / ` >` markers at the
edges (per the user's #2). Window expands right-first
then left-first to use available width.
- Returns `Line<'static>` (items cloned into spans) so the
caller doesn't fight lifetimes between the
AmbientHint::Candidates payload and the rendered Line.
Updated callers in ui.rs and input_render tests for the new
signature. Added `ambient_hint_with_memo_carries_selected_index`
test asserting the renderer-side `selected` plumbing.
Tests: 730 passing, 0 failing, 1 ignored (728 baseline →
+2 net: -3 reworked + 5 new candidate-related cases).
Clippy clean.
Stage 8c will plumb identifier completion (schema cache +
candidate fetch from worker on demand or pre-cache) and add
the invalid-identifier hint variant.
This commit is contained in:
@@ -692,25 +692,134 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
||||
// the DSL lexer/parser don't speak SQL.
|
||||
let empty_hint = crate::t!("panel.hint_empty");
|
||||
let ambient = match app.effective_mode() {
|
||||
EffectiveMode::Simple => crate::input_render::ambient_hint(&app.input),
|
||||
EffectiveMode::Simple => crate::input_render::ambient_hint(
|
||||
&app.input,
|
||||
app.input_cursor,
|
||||
app.last_completion.as_ref(),
|
||||
),
|
||||
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None,
|
||||
};
|
||||
let body: String = app
|
||||
.hint
|
||||
.as_deref()
|
||||
.map(ToString::to_string)
|
||||
.or(ambient)
|
||||
.unwrap_or(empty_hint);
|
||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||
body,
|
||||
Style::default().fg(theme.muted),
|
||||
)))
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
let muted = Style::default().fg(theme.muted);
|
||||
let line = match (app.hint.as_deref(), ambient) {
|
||||
(Some(set), _) => Line::from(Span::styled(set.to_string(), muted)),
|
||||
(None, Some(crate::input_render::AmbientHint::Prose(text))) => {
|
||||
Line::from(Span::styled(text, muted))
|
||||
}
|
||||
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
|
||||
// ADR-0022 §7 + user's #2: render items with the
|
||||
// selected one highlighted; if not all fit,
|
||||
// scroll horizontally with `<` / `>` markers at
|
||||
// the edges. Inner width = panel area minus
|
||||
// borders (2).
|
||||
let inner = area.width.saturating_sub(2) as usize;
|
||||
render_candidate_line(&items, selected, inner, theme)
|
||||
}
|
||||
(None, None) => Line::from(Span::styled(empty_hint, muted)),
|
||||
};
|
||||
let paragraph = Paragraph::new(line)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Render the candidate-list line for the hint panel
|
||||
/// (ADR-0022 §7 + the user's #2). Items are space-separated;
|
||||
/// the selected item (if any) gets a brighter colour + bold;
|
||||
/// when the items overflow `width`, a window centred on the
|
||||
/// selected (or starting at item 0 with no selection) shows
|
||||
/// scroll markers `<` / `>` at the edges.
|
||||
///
|
||||
/// Returns `Line<'static>` (each item cloned into its span)
|
||||
/// so the caller doesn't have to manage the items' lifetime.
|
||||
fn render_candidate_line(
|
||||
items: &[String],
|
||||
selected: Option<usize>,
|
||||
width: usize,
|
||||
theme: &Theme,
|
||||
) -> Line<'static> {
|
||||
if items.is_empty() {
|
||||
return Line::default();
|
||||
}
|
||||
let item_style = Style::default().fg(theme.muted);
|
||||
let selected_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let marker_style = Style::default().fg(theme.fg);
|
||||
|
||||
let total_width: usize = items
|
||||
.iter()
|
||||
.map(|s| s.len() + 1)
|
||||
.sum::<usize>()
|
||||
.saturating_sub(1);
|
||||
if total_width <= width {
|
||||
let mut spans: Vec<Span<'static>> = Vec::with_capacity(items.len() * 2);
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" ".to_string(), item_style));
|
||||
}
|
||||
let style = if Some(i) == selected {
|
||||
selected_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
spans.push(Span::styled(item.clone(), style));
|
||||
}
|
||||
return Line::from(spans);
|
||||
}
|
||||
|
||||
// Overflow path: window centred on `selected` (or item 0).
|
||||
// Reserve 4 chars for the `< ` / ` >` markers we may end
|
||||
// up using.
|
||||
let center = selected.unwrap_or(0);
|
||||
let mut left = center;
|
||||
let mut right = center;
|
||||
let mut used = items[center].len();
|
||||
let avail = width.saturating_sub(4);
|
||||
while (left > 0 || right + 1 < items.len()) && used < avail {
|
||||
// Expand right first, then left.
|
||||
if right + 1 < items.len() {
|
||||
let cost = items[right + 1].len() + 1;
|
||||
if used + cost <= avail {
|
||||
right += 1;
|
||||
used += cost;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if left > 0 {
|
||||
let cost = items[left - 1].len() + 1;
|
||||
if used + cost <= avail {
|
||||
left -= 1;
|
||||
used += cost;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
let need_left_marker = left > 0;
|
||||
let need_right_marker = right + 1 < items.len();
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::with_capacity((right - left + 1) * 2 + 4);
|
||||
if need_left_marker {
|
||||
spans.push(Span::styled("< ".to_string(), marker_style));
|
||||
}
|
||||
for (offset, item) in items[left..=right].iter().enumerate() {
|
||||
if offset > 0 {
|
||||
spans.push(Span::styled(" ".to_string(), item_style));
|
||||
}
|
||||
let style = if Some(left + offset) == selected {
|
||||
selected_style
|
||||
} else {
|
||||
item_style
|
||||
};
|
||||
spans.push(Span::styled(item.clone(), style));
|
||||
}
|
||||
if need_right_marker {
|
||||
spans.push(Span::styled(" >".to_string(), marker_style));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
let key_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
|
||||
Reference in New Issue
Block a user