Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/completion_dropdown.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

418 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dropdown renderer for shell command completion suggestions.
//!
//! Mirrors `slash_dropdown.rs` layout: aligned label column, description,
//! selection highlight, mouse hover, and scrollbar when items exceed
//! `MAX_VISIBLE_ROWS`.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::render::SafeBuf;
use crate::render::line_utils::truncate_str;
use crate::render::scrollbar::render_scrollbar_styled;
use crate::theme::Theme;
use crate::views::suggestion_controller::{CompletionDropdownState, CompletionItemParsed};
/// Maximum visible rows in the completion dropdown.
pub const MAX_VISIBLE_ROWS: u16 = 6;
/// Hard cap on label column width.
const LABEL_CAP: usize = 40;
/// Gap between label and description columns.
const LABEL_DESC_GAP: usize = 2;
/// Prefix width (`"❯ "` or `" "`).
const PREFIX_W: usize = 2;
/// Height needed for the dropdown (separator + items), or 0 when hidden.
pub fn dropdown_height(state: &CompletionDropdownState) -> u16 {
if !state.open || state.items.is_empty() {
return 0;
}
let item_rows = (state.items.len() as u16).min(MAX_VISIBLE_ROWS);
// separator + items
1 + item_rows
}
/// Compute the scroll offset so the selected row stays centred.
pub fn scroll_offset(state: &CompletionDropdownState) -> usize {
let total = state.items.len();
let visible = MAX_VISIBLE_ROWS as usize;
let selected = state.selected.min(total.saturating_sub(1));
if total <= visible || selected < visible / 2 {
0
} else if selected + visible / 2 >= total {
total.saturating_sub(visible)
} else {
selected.saturating_sub(visible / 2)
}
}
fn compute_label_column_w(items: &[CompletionItemParsed], content_w: usize) -> usize {
let budget = (content_w * 3 / 5).min(LABEL_CAP);
let max_w = items
.iter()
.map(|r| r.display.width())
.filter(|&w| w <= LABEL_CAP)
.max()
.unwrap_or(0);
max_w.min(budget)
}
/// Render completion dropdown items into `area` (no borders — caller draws chrome).
pub fn render_dropdown(
buf: &mut Buffer,
area: Rect,
state: &CompletionDropdownState,
theme: &Theme,
) {
if area.height == 0 || area.width < 4 || !state.open {
return;
}
let items = &state.items;
let selected = state.selected.min(items.len().saturating_sub(1));
let hovered = state.hovered;
let content_w = area.width as usize;
let visible_rows = area.height as usize;
let needs_scrollbar = items.len() > visible_rows;
let row_w = if needs_scrollbar {
content_w.saturating_sub(2)
} else {
content_w
};
let label_col_w = compute_label_column_w(items, row_w.saturating_sub(PREFIX_W));
let scroll = scroll_offset(state);
for vis_row in 0..visible_rows {
let item_idx = scroll + vis_row;
if item_idx >= items.len() {
break;
}
let item = &items[item_idx];
let y = area.y + vis_row as u16;
let is_selected = item_idx == selected;
let is_hovered = hovered == Some(item_idx) && !is_selected;
let row_bg = match crate::views::modal_window::embedded_row_style(theme, is_selected) {
Some(e) => e.bg,
None if is_selected => theme.bg_visual,
None if is_hovered => theme.bg_hover,
None => theme.bg_light,
};
let line = build_item_line(item, is_selected, label_col_w, row_w, row_bg, theme);
// Skip rows that fall outside the buffer (resize race).
if y < buf.area.y || y >= buf.area.bottom() || area.x >= buf.area.right() {
continue;
}
let clamped_w = row_w.min(buf.area.right().saturating_sub(area.x) as usize) as u16;
let row_rect = Rect {
x: area.x,
y,
width: clamped_w,
height: 1,
};
buf.set_style(row_rect, Style::default().bg(row_bg));
buf.set_line_safe(area.x, y, &line, row_w as u16);
}
if needs_scrollbar {
let sb_x = area.x + area.width.saturating_sub(1);
let sb_y = area.y.max(buf.area.y);
let sb_bottom = (area.y.saturating_add(area.height)).min(buf.area.bottom());
if sb_x < buf.area.right() && sb_bottom > sb_y {
let sb_area = Rect {
x: sb_x,
y: sb_y,
width: 1,
height: sb_bottom - sb_y,
};
let track = Style::default().bg(theme.bg_dark);
let thumb = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
render_scrollbar_styled(
buf,
Some(sb_area),
items.len() as u16,
sb_area.height,
scroll as u16,
track,
thumb,
);
}
}
}
fn build_item_line(
item: &CompletionItemParsed,
is_selected: bool,
label_col_w: usize,
total_w: usize,
row_bg: ratatui::style::Color,
theme: &Theme,
) -> Line<'static> {
let bold = if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
};
let embed = crate::views::modal_window::embedded_row_style(theme, is_selected);
let primary_fg = embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary));
let desc_fg = embed.map_or(theme.gray, |e| e.fg(theme.gray));
let normal = Style::default()
.fg(primary_fg)
.bg(row_bg)
.add_modifier(bold);
let desc_style = Style::default().fg(desc_fg).bg(row_bg);
let bg_style = Style::default().bg(row_bg);
let prefix = if is_selected {
crate::glyphs::prompt_arrow()
} else {
" "
};
let prefix_span = Span::styled(
prefix.to_string(),
if is_selected { normal } else { bg_style },
);
let label = truncate_str(&item.display, label_col_w);
let label_w = label.width();
let padding = label_col_w.saturating_sub(label_w);
let label_span = Span::styled(label, normal);
let desc_indent = PREFIX_W + label_col_w + LABEL_DESC_GAP;
let desc_w = total_w.saturating_sub(desc_indent).max(1);
let desc = truncate_str(&item.description, desc_w);
let mut spans = vec![prefix_span, label_span];
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), bg_style));
}
if !desc.is_empty() {
spans.push(Span::styled(" ".to_string(), bg_style));
spans.push(Span::styled(desc, desc_style));
}
Line::from(spans).style(bg_style)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::views::suggestion_controller::SuggestionSource;
fn make_item(display: &str, desc: &str, insert: &str) -> CompletionItemParsed {
CompletionItemParsed {
display: display.into(),
description: desc.into(),
insert_text: insert.into(),
source: SuggestionSource::History,
priority: 0,
replace_range: None,
token_text: None,
truncated: false,
}
}
#[test]
fn height_zero_when_closed() {
let state = CompletionDropdownState::default();
assert_eq!(dropdown_height(&state), 0);
}
/// Items area past buffer bottom must not panic on resize races.
#[test]
fn render_dropdown_past_buffer_bottom_does_not_panic() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let items: Vec<CompletionItemParsed> = (0..12)
.map(|i| make_item(&format!("item{i}"), "desc", &format!("item{i}")))
.collect();
let state = CompletionDropdownState {
open: true,
items,
selected: 0,
..Default::default()
};
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
let area = Rect::new(2, 8, 76, 8);
render_dropdown(&mut buf, area, &state, &theme);
}
#[test]
fn height_zero_when_empty() {
let state = CompletionDropdownState {
open: true,
..Default::default()
};
assert_eq!(dropdown_height(&state), 0);
}
#[test]
fn height_with_items() {
let state = CompletionDropdownState {
open: true,
items: vec![
make_item("ls", "list", "ls"),
make_item("cd", "change dir", "cd"),
],
..Default::default()
};
// 1 separator + 2 items
assert_eq!(dropdown_height(&state), 3);
}
#[test]
fn height_capped_at_max() {
let items: Vec<_> = (0..20)
.map(|i| make_item(&format!("cmd{i}"), "", &format!("cmd{i}")))
.collect();
let state = CompletionDropdownState {
open: true,
items,
..Default::default()
};
assert_eq!(dropdown_height(&state), 1 + MAX_VISIBLE_ROWS);
}
#[test]
fn move_selection_wraps() {
let mut state = CompletionDropdownState {
open: true,
items: vec![
make_item("a", "", "a"),
make_item("b", "", "b"),
make_item("c", "", "c"),
],
selected: 0,
..Default::default()
};
state.move_selection(-1);
assert_eq!(state.selected, 2);
state.move_selection(1);
assert_eq!(state.selected, 0);
state.move_selection(1);
assert_eq!(state.selected, 1);
}
#[test]
fn scroll_selection_clamps_at_edges() {
let mut state = CompletionDropdownState {
open: true,
items: vec![
make_item("a", "", "a"),
make_item("b", "", "b"),
make_item("c", "", "c"),
],
selected: 0,
..Default::default()
};
// Scrolling up at the first item stays put (no wrap).
state.scroll_selection(-1);
assert_eq!(state.selected, 0);
state.scroll_selection(1);
assert_eq!(state.selected, 1);
// Scrolling down at the last item stays put (no wrap).
state.scroll_selection(1);
assert_eq!(state.selected, 2);
state.scroll_selection(1);
assert_eq!(state.selected, 2);
}
#[test]
fn accept_returns_item_and_closes() {
let mut state = CompletionDropdownState {
open: true,
items: vec![make_item("ls -la", "list all", "ls -la /tmp")],
selected: 0,
..Default::default()
};
let item = state.accept().expect("selected item accepted");
assert_eq!(item.insert_text, "ls -la /tmp");
assert!(!state.open);
}
#[test]
fn accept_without_items_returns_none() {
let mut state = CompletionDropdownState::default();
assert!(state.accept().is_none());
}
/// `accept` is independent of the `open` render flag: the
/// single-candidate insta-accept consumes an item that was never shown.
#[test]
fn accept_works_on_closed_dropdown_with_items() {
let mut state = CompletionDropdownState {
open: false,
items: vec![make_item("ls", "", "ls -la")],
..Default::default()
};
let item = state.accept().expect("item accepted while closed");
assert_eq!(item.insert_text, "ls -la");
}
#[test]
fn scroll_offset_no_scroll_needed() {
let state = CompletionDropdownState {
open: true,
items: vec![make_item("a", "", "a")],
selected: 0,
..Default::default()
};
assert_eq!(scroll_offset(&state), 0);
}
#[test]
fn scroll_offset_centres_selected() {
let items: Vec<_> = (0..20)
.map(|i| make_item(&format!("c{i}"), "", &format!("c{i}")))
.collect();
let state = CompletionDropdownState {
open: true,
items,
selected: 10,
..Default::default()
};
let offset = scroll_offset(&state);
let visible = MAX_VISIBLE_ROWS as usize;
assert!(offset <= 10);
assert!(offset + visible > 10);
}
#[test]
fn close_resets_state() {
let mut state = CompletionDropdownState {
open: true,
items: vec![make_item("a", "", "a")],
selected: 0,
hovered: Some(0),
generation: 5,
request_text: "a".into(),
request_cursor: 1,
};
state.close();
assert!(!state.open);
assert_eq!(state.selected, 0);
assert!(state.hovered.is_none());
// generation preserved
assert_eq!(state.generation, 5);
assert!(state.items.is_empty());
// Anchor left in place (inert without items); the next landing
// overwrites it atomically with the new items.
assert_eq!(state.request_text, "a");
assert_eq!(state.request_cursor, 1);
}
}