Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/picker.rs
T
ZacharyZhang-NY 5e4e24db99 M2 audit: excise managed connectors and xAI media-gen tools
Managed connectors (grok.com MCP admin) removed root-and-branch:
- The managed-MCP fetch/injection pipeline is gone, including the whole
  kigi-shell-session-support crate (managed-config fetch client, gateway
  tool catalog + dispatch, header injection, refresh task), reactive
  managed re-auth, mcp_doctor's grok.com-source discovery, and the
  [managed_mcps] config surface.
- TUI: the 'Managed by grok.com' section, connectors URL/deep-link,
  Action::OpenManagedConnectors, and session_team_id are gone. Local MCP
  management (list/toggle/add/remove/auth/tools) is fully intact.
- Kept as LOCAL policy: managed-settings.json MCP allow/deny enforcement,
  the multi-source local MCP merge, folder-trust gating. PluginOrigin
  Project/User labels kept (they tag locally discovered plugin dirs).

imagine/media-gen tools (xAI image/video generation) removed:
- image_gen, image_edit, video_gen, image_to_video, reference_to_video
  implementations, registrations, ToolKind/ToolInput/Output variants
  (serde-safe), config plumbing end to end, ZDR video machinery,
  /imagine + /imagine-video commands and guidance text, the bundled
  imagine skill (added to legacy cleanup so user installs delete it),
  and the media-gen render path.
- Kept: image INPUT (paste/attach, [Image #N] meta, pdf/image fetch,
  clipboard wrap), generic media-ref rendering, and the generic tool
  401-retry machinery (tests renamed, assertions unweakened).
- deploy_app stays: it is a permanently-disabled local stub deploying
  nowhere.

121 files changed, 8 deleted. Gates: workspace check/clippy 0/0, fmt,
deny ok; suites green (tools 2554, shell 4862, tui 6608, workspace
1042). Remaining grok.com strings live only in the auth-method ids and
changelog archives (§9/M3 sweep).
2026-07-17 23:45:05 -04:00

3435 lines
125 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.
//! Shared picker rendering helpers.
//!
//! Three rendering paths exist:
//!
//! 1. **`render_picker()`** -- full-frame picker used by the welcome-screen
//! session picker. Handles frame, search bar, divider, entries, and
//! shortcuts. Two layout modes:
//! - **FullScreen** — centered content filling the available area (session picker).
//! - **Floating** — dimmed background with a bordered popup (command palette, arg picker).
//!
//! 2. **`render_picker_content()`** -- content-only rendering (entries +
//! scrollbar) into a provided area. Used by modal popups (command
//! palette, arg picker, session picker, doc picker) whose chrome is
//! handled by [`super::modal_window::render_modal_window`].
//!
//! 3. **Shared primitives** -- `render_search_bar`, `render_divider`,
//! `render_picker_row`, `render_picker_entry`, `handle_picker_input`,
//! etc. are used by both paths above.
use std::collections::HashSet;
use std::sync::LazyLock;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::render::line_utils::truncate_str;
use crate::render::wrapping::word_wrap_line;
use crate::theme::Theme;
use crate::views::shortcuts_bar::{HintItem, PendingHint, ShortcutsBar};
/// Effective base background for picker chrome. In minimal mode every UI element
/// is terminal-transparent (`Color::Reset`) — no opaque panels, no selection /
/// hover bands (selection is shown via accent label text instead). Otherwise the
/// caller-provided `bg` (or the default `bg_base`) is used.
fn picker_base_bg(bg: Option<Color>, theme: &Theme) -> Color {
if crate::views::modal_window::embedded() {
Color::Reset
} else {
bg.unwrap_or(theme.bg_base)
}
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// A single entry in a picker list — either a section header or a selectable row.
pub enum PickerEntry<'a> {
/// Non-selectable section header (rendered as `── label ──`).
Header { label: &'a str },
/// A selectable row.
Row(PickerRow<'a>),
}
/// A selectable row in a picker list.
pub struct PickerRow<'a> {
/// Primary label text (left-aligned).
pub label: &'a str,
/// Secondary text (right-aligned, e.g. shortcut, ID+time, description).
pub right_label: &'a str,
/// Whether this row is the selected/cursor row.
pub selected: bool,
/// Whether detail fields are shown below this row.
pub expanded: bool,
/// Detail fields shown when expanded (empty = not expandable).
pub fields: &'a [PickerField<'a>],
/// Additional lines rendered below the label (e.g., command details).
/// Shown only when expanded.
pub description_lines: &'a [&'a str],
/// Secondary lines rendered below the label while the row is collapsed
/// (e.g., an at-a-glance summary). Hidden when expanded, where
/// `description_lines` and `fields` take over.
pub summary_lines: &'a [&'a str],
/// Whether this row should be dimmed (e.g., disabled plugins/hooks).
pub dimmed: bool,
/// Indentation level (0 = top-level, 1 = nested under a group header, etc.).
/// Each level adds 2 spaces of left padding.
pub indent: u8,
/// Optional badge text shown right after the label (e.g., "[installed]").
pub badge: &'a str,
/// Color for the badge text. If `None`, uses `gray`.
pub badge_color: Option<ratatui::style::Color>,
/// Whether this row is a collapsible group header. When true, the
/// /◆ fold indicator is shown even if `fields` and
/// `description_lines` are empty. The `expanded` field controls
/// which glyph is rendered.
pub collapsible: bool,
}
/// A key-value pair shown in an expanded picker row.
pub struct PickerField<'a> {
pub label: &'a str,
pub value: &'a str,
}
/// Frame returned by both [`render_floating_frame`] and [`render_fullscreen_frame`].
pub struct PickerFrame {
/// Content area for search bar and entries.
pub content: Rect,
/// Close button `[\u{2717}]` rect for mouse hit-testing.
pub close_button: Rect,
}
/// How the picker is framed on screen.
///
/// Used by the welcome-screen session picker. Modal popups (command
/// palette, arg picker, etc.) use [`super::modal_window::ModalWindow`]
/// for chrome and [`render_picker_content`] for the entry list.
#[derive(Debug, Clone, Default)]
pub enum PickerMode {
/// Centered content filling the given area (no border, no dim).
FullScreen,
/// Floating popup with dimmed background and rounded border.
#[default]
Floating,
/// Centered popup with configurable dimensions.
Popup(PopupConfig),
}
impl PickerMode {
/// Toggle between modes.
pub fn toggle(self) -> Self {
match self {
Self::FullScreen => Self::Popup(PopupConfig::default()),
Self::Floating => Self::FullScreen,
Self::Popup(_) => Self::FullScreen,
}
}
}
/// Render a picker frame in the given mode.
///
/// Dispatches to [`render_floating_frame`] or [`render_fullscreen_frame`]
/// based on `mode`. Returns `None` if the area is too small.
pub fn render_picker_frame(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
mode: PickerMode,
title: Option<&str>,
close_hovered: bool,
) -> Option<PickerFrame> {
match mode {
PickerMode::Floating => render_floating_frame(buf, area, theme, close_hovered),
PickerMode::Popup(popup_cfg) => {
// Use the popup frame (configurable dimensions), then wrap in PickerFrame.
let inner = render_popup_frame(buf, area, theme, &popup_cfg)?;
// In Popup mode, the close button is rendered later (by tab bar or search bar),
// so return a default close_button rect that will be overwritten.
Some(PickerFrame {
content: inner,
close_button: Rect::default(),
})
}
PickerMode::FullScreen => render_fullscreen_frame(buf, area, theme, title, close_hovered),
}
}
// ---------------------------------------------------------------------------
// Scroll computation
// ---------------------------------------------------------------------------
/// Compute minimal scroll offset to keep `selected` visible in a window of `visible` items
/// out of `total`.
pub fn compute_scroll_offset(
selected: usize,
total: usize,
visible: usize,
num_headers: usize,
) -> usize {
let total_with_spacers = total.saturating_add(num_headers.saturating_sub(1));
if total_with_spacers <= visible {
0
} else {
// Center the selection, accounting for spacer rows before headers
let centered = selected.saturating_sub(visible / 2);
let max_scroll = total
.saturating_add(num_headers.saturating_sub(1))
.saturating_sub(visible);
centered.min(max_scroll)
}
}
// ---------------------------------------------------------------------------
// Search bar
// ---------------------------------------------------------------------------
/// Render a search bar row: ` search: {query}_` or ` / to search` hint.
///
/// - `active`: whether the cursor blinks (search mode is engaged).
/// - `show_hint`: show `/ to search` placeholder when query is empty and not active.
/// - `query_cursor`: byte offset of the editing cursor within `query`.
/// The visual cursor is placed after the display-width of `query[..query_cursor]`.
/// - `bg`: optional background color for the search text (used in Floating mode).
#[allow(clippy::too_many_arguments)]
pub fn render_search_bar(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
query: &str,
active: bool,
show_hint: bool,
query_cursor: usize,
bg: Option<ratatui::style::Color>,
) {
render_search_bar_with_label(
buf,
x,
y,
width,
theme,
" search: ",
query,
active,
show_hint,
query_cursor,
bg,
);
}
/// Like [`render_search_bar`] but with a caller-supplied prompt `label`
/// (e.g. `" path: "`) instead of the default `" search: "`. The label
/// width is measured in bytes (ASCII), matching the input-window math.
#[allow(clippy::too_many_arguments)]
pub fn render_search_bar_with_label(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
label: &str,
query: &str,
active: bool,
show_hint: bool,
query_cursor: usize,
bg: Option<ratatui::style::Color>,
) {
// Minimal mode renders every UI element background-free.
let bg = if crate::views::modal_window::embedded() {
None
} else {
bg
};
let bg_style = |style: Style| -> Style { if let Some(c) = bg { style.bg(c) } else { style } };
// "Always-active" mode: cursor always visible (Floating mode search).
let always_active = !active && !show_hint;
if active || !query.is_empty() || always_active {
let label_w = label.len() as u16;
buf.set_line(
x,
y,
&Line::from(Span::styled(
label,
bg_style(Style::default().fg(theme.gray)),
)),
width,
);
let input_x = x + label_w;
let input_max = width.saturating_sub(label_w + 1) as usize;
// Cursor-following window: find the visible slice of the query
// that keeps the cursor in view. When the cursor is at the end
// (the common case), this is equivalent to tail-scroll.
let mut cursor_byte = query_cursor.min(query.len());
while cursor_byte > 0 && !query.is_char_boundary(cursor_byte) {
cursor_byte -= 1;
}
let prefix_w = query[..cursor_byte].width();
let (start_byte, cursor_col) = if prefix_w <= input_max {
// Cursor fits from the start — no scrolling needed.
(0, prefix_w)
} else {
// Walk forward from byte 0, accumulating width, until the
// remaining prefix fits within `input_max`.
let mut skip_w = 0usize;
let mut sb = 0usize;
for (idx, ch) in query.char_indices() {
if idx >= cursor_byte {
break;
}
let cw = ch.width().unwrap_or(0);
if prefix_w - skip_w <= input_max {
break;
}
skip_w += cw;
sb = idx + ch.len_utf8();
}
(sb, prefix_w - skip_w)
};
if !query.is_empty() {
let visible = &query[start_byte..];
let displayed = truncate_str(visible, input_max);
buf.set_span(
input_x,
y,
&Span::styled(
&displayed,
bg_style(Style::default().fg(theme.text_primary)),
),
displayed.width() as u16,
);
}
let cursor_display_w = (cursor_col as u16).min(input_max as u16);
if active || always_active {
let cursor_x = input_x + cursor_display_w;
if cursor_x < x + width {
// Inverse-video the cell at the cursor position so the
// character underneath remains visible (matching the
// rename overlay's cursor style).
if let Some(cell) = buf.cell_mut((cursor_x, y)) {
let cursor_fg = if let Some(c) = bg { c } else { theme.bg_base };
cell.set_style(Style::default().fg(cursor_fg).bg(theme.text_primary));
}
}
}
} else if show_hint {
buf.set_line(
x,
y,
&Line::from(Span::styled(
" / to search",
bg_style(Style::default().fg(theme.gray_dim)),
)),
width,
);
}
}
// ---------------------------------------------------------------------------
// Divider
// ---------------------------------------------------------------------------
/// Render a horizontal `─` divider.
pub fn render_divider(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
bg: Option<ratatui::style::Color>,
) {
// In minimal mode every element is terminal-transparent, so ignore any
// caller-supplied opaque background (e.g. `bg_base`) and force `Reset`.
let bg = if crate::views::modal_window::embedded() {
Some(Color::Reset)
} else {
bg
};
let style = if let Some(c) = bg {
Style::default().fg(theme.gray_dim).bg(c)
} else {
Style::default().fg(theme.gray_dim)
};
for cx in x..x + width {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_char('\u{2500}');
cell.set_style(style);
}
}
}
// ---------------------------------------------------------------------------
// Tab bar (shared)
// ---------------------------------------------------------------------------
/// Hit areas returned by [`render_tab_bar`].
pub struct TabBarHitAreas {
/// One rect per tab label; `None` if the tab didn't fit.
pub tab_rects: Vec<Option<Rect>>,
/// Close button rect.
pub close_button: Rect,
}
/// Render a horizontal tab bar: `[ Label1 ] [ Label2 ] ...`
///
/// Active tab is bold with `bg_light`; inactive tabs are gray on `bg_base`.
/// The close button `[\u{2717}]` is rendered right-aligned on the same row.
/// Returns hit areas for mouse click and close button detection.
#[allow(clippy::too_many_arguments)]
pub fn render_tab_bar(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
labels: &[&str],
active: usize,
close_hovered: bool,
) -> TabBarHitAreas {
if width < 6 {
return TabBarHitAreas {
tab_rects: vec![None; labels.len()],
close_button: Rect::default(),
};
}
// Clear the tab row.
let clear_style = Style::default().fg(theme.text_primary).bg(theme.bg_base);
for cx in x..x + width {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.reset();
cell.set_style(clear_style);
}
}
// Close button right-aligned (reuse shared helper).
let close_rect = render_close_button(buf, x, y, width, theme, close_hovered, None);
// Tab labels.
let mut tab_rects = Vec::with_capacity(labels.len());
let mut cx = x + 1; // 1 char left padding
for (i, label) in labels.iter().enumerate() {
let label_w = label.width() as u16;
let tab_w = label_w + 2; // " Label "
if cx + tab_w > close_rect.x.saturating_sub(1) {
tab_rects.push(None);
continue;
}
let (fg, bg, mods) = if i == active {
(theme.text_primary, theme.bg_light, Modifier::BOLD)
} else {
(theme.gray, theme.bg_base, Modifier::empty())
};
let style = Style::default().fg(fg).bg(bg).add_modifier(mods);
let pad = Style::default().bg(bg);
buf.set_span(cx, y, &Span::styled(" ", pad), 1);
buf.set_span(cx + 1, y, &Span::styled(*label, style), label_w);
buf.set_span(cx + 1 + label_w, y, &Span::styled(" ", pad), 1);
tab_rects.push(Some(Rect::new(cx, y, tab_w, 1)));
cx += tab_w + 2; // 2 chars gap between tabs
}
TabBarHitAreas {
tab_rects,
close_button: close_rect,
}
}
// ---------------------------------------------------------------------------
// Popup frame (shared)
// ---------------------------------------------------------------------------
/// Configuration for a centered popup frame.
#[derive(Debug, Clone)]
pub struct PopupConfig {
/// Width as a fraction of the available area (0.0 - 1.0).
pub width_pct: f32,
/// Height as a fraction of the available area (0.0 - 1.0).
pub height_pct: f32,
/// Minimum popup dimensions.
pub min_width: u16,
pub min_height: u16,
}
impl Default for PopupConfig {
fn default() -> Self {
Self {
width_pct: 0.65,
height_pct: 0.60,
min_width: 40,
min_height: 12,
}
}
}
/// Render a centered popup frame with dimmed background and rounded border.
///
/// Returns the inner content area. The close button is NOT rendered here;
/// callers typically render it via [`render_tab_bar`] or [`render_close_button`]
/// on the first row of the content area.
///
/// Returns `None` if the area is too small.
pub fn render_popup_frame(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
config: &PopupConfig,
) -> Option<Rect> {
use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget};
if area.height < config.min_height || area.width < config.min_width {
return None;
}
// Compute centered popup area (before rendering anything).
let popup_w = ((area.width as f32 * config.width_pct) as u16)
.max(config.min_width)
.min(area.width);
let popup_h = ((area.height as f32 * config.height_pct) as u16)
.max(config.min_height)
.min(area.height.saturating_sub(2));
// Pre-check inner dimensions (border is 1 cell on each side) before any
// rendering so we never leave a half-drawn popup on screen.
if popup_h.saturating_sub(2) < 3 || popup_w.saturating_sub(2) < 10 {
return None;
}
let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_w, popup_h);
// Pre-compute inner area so we can bail before rendering side-effects.
let base_style = Style::default().fg(theme.text_primary).bg(theme.bg_base);
let border = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.gray_dim))
.style(base_style);
let inner = border.inner(popup_area);
if inner.height < 3 || inner.width < 10 {
return None;
}
// Safe to render: dim background, clear, and draw bordered popup.
crate::views::file_search::line_viewer::dim_area(buf, area, theme.bg_base, 0.5);
Clear.render(popup_area, buf);
buf.set_style(popup_area, base_style);
border.render(popup_area, buf);
Some(inner)
}
// ---------------------------------------------------------------------------
// Search bar filter indicator
// ---------------------------------------------------------------------------
/// Render a right-aligned filter indicator on a search bar row.
///
/// Draws e.g. `Enabled f` at the right edge. Used by plugin/hooks modal.
///
/// `active` controls the label styling: when `false` (default/"All" state),
/// the label renders in `gray_dim`; when `true` (filter active), in `gray`;
/// when `hovered`, in `text_primary + BOLD`.
#[allow(clippy::too_many_arguments)]
pub fn render_filter_indicator(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
label: &str,
key_hint: &str,
active: bool,
hovered: bool,
) -> Rect {
let label_w = label.width() as u16;
let hint_w = key_hint.width() as u16;
let total_w = label_w + 1 + hint_w + 1; // "Label k "
let start_x = x + width.saturating_sub(total_w + 1);
let label_fg = if hovered {
theme.text_primary
} else if active {
theme.gray
} else {
theme.gray_dim
};
let label_mods = if hovered {
Modifier::BOLD
} else {
Modifier::empty()
};
let label_style = Style::default()
.fg(label_fg)
.bg(theme.bg_base)
.add_modifier(label_mods);
let hint_style = Style::default()
.fg(label_fg)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
buf.set_span(start_x, y, &Span::styled(label, label_style), label_w);
buf.set_span(
start_x + label_w + 1,
y,
&Span::styled(key_hint, hint_style),
hint_w,
);
Rect::new(start_x, y, total_w, 1)
}
// ---------------------------------------------------------------------------
// Picker rows
// ---------------------------------------------------------------------------
/// Render a single picker row with unified visual style:
/// - Selected: `\u{276f} label` in `text_primary+BOLD`, `bg_visual` background.
/// - Normal: ` label` in `gray_bright`.
/// - Right text: right-aligned in `gray_dim` (or `gray+bg_visual` when selected).
///
/// Compute the exact number of visual rows a picker row will consume
/// at the given width. Used for scroll offset calculation.
/// Return the byte offset in `s` that covers at most `max_width` display columns.
/// Parse `[bracket]` highlight markers in a string into styled spans.
/// Text inside `[...]` gets `highlight_style`, the rest gets `base_style`.
/// Brackets are stripped from the output.
fn parse_highlight_spans<'a>(s: &'a str, base_style: Style, highlight_style: Style) -> Line<'a> {
let mut spans = Vec::new();
let mut rest = s;
while let Some(open) = rest.find('[') {
if open > 0 {
spans.push(Span::styled(&rest[..open], base_style));
}
rest = &rest[open + 1..];
if let Some(close) = rest.find(']') {
spans.push(Span::styled(&rest[..close], highlight_style));
rest = &rest[close + 1..];
} else {
// Unmatched '[' — render literally.
spans.push(Span::styled("[", base_style));
}
}
if !rest.is_empty() {
spans.push(Span::styled(rest, base_style));
}
Line::from(spans)
}
/// Render styled spans into a buffer, truncating to `max_width` columns.
fn render_styled_spans(buf: &mut Buffer, spans: &Line<'_>, x: u16, y: u16, max_width: usize) {
let mut cx = x;
let end_x = x + max_width as u16;
for span in &spans.spans {
if cx >= end_x {
break;
}
let avail = (end_x - cx) as usize;
let sw = span.content.width();
if sw <= avail {
buf.set_span(cx, y, span, sw as u16);
cx += sw as u16;
} else {
let end = byte_offset_for_width(&span.content, avail);
if end > 0 {
buf.set_span(
cx,
y,
&Span::styled(&span.content[..end], span.style),
avail as u16,
);
}
break;
}
}
}
fn byte_offset_for_width(s: &str, max_width: usize) -> usize {
let mut w = 0usize;
for (i, ch) in s.char_indices() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > max_width {
return i;
}
w += cw;
}
s.len()
}
/// Visual row count for a description line (matches [`render_picker_row`] wrapping).
fn description_visual_rows(desc: &str, max_w: usize) -> usize {
if max_w == 0 {
return 1;
}
let base = Style::default();
let line = parse_highlight_spans(desc, base, base);
word_wrap_line(&line, max_w).len().max(1)
}
/// Left indent (columns) for picker row description and detail lines.
const DESC_INDENT: u16 = 4;
pub fn compute_row_height(row: &PickerRow<'_>, width: u16) -> usize {
let mut rows = 1usize; // main label line
let indent = DESC_INDENT;
let max_w = width.saturating_sub(indent) as usize;
if !row.expanded {
if max_w > 0 {
for line in row.summary_lines {
rows += description_visual_rows(line, max_w);
}
}
return rows;
}
if max_w == 0 {
return rows;
}
// Description lines (word-wrapped; must match render_picker_row).
for desc in row.description_lines {
rows += description_visual_rows(desc, max_w);
}
// Expanded field values (word-wrapped).
let label_col = 13usize; // "{:<12} "
let val_w = max_w.saturating_sub(label_col).max(1);
for field in row.fields {
let val_len = field.value.width();
rows += if val_len <= val_w {
1
} else {
val_len.div_ceil(val_w)
};
}
rows
}
///
/// When `row.expanded && !row.fields.is_empty()`, renders key-value detail lines
/// below (indented, label in `gray`, value in `gray_bright`).
///
/// Rows consumed by a rendered picker row/entry.
pub struct RenderedRow {
pub rows: u16,
}
/// `max_rows` caps rendering to available vertical space; detail fields beyond
/// that limit are not drawn.
///
/// `bg` is the base background color (used in Floating mode popups).
#[allow(clippy::too_many_arguments)]
pub fn render_picker_row(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
row: &PickerRow,
hovered: bool,
bg: Option<ratatui::style::Color>,
max_rows: u16,
) -> RenderedRow {
if max_rows == 0 {
return RenderedRow { rows: 0 };
}
let base_bg = picker_base_bg(bg, theme);
let embed = crate::views::modal_window::embedded_row_style(theme, row.selected);
let row_bg = match embed {
Some(e) => e.bg,
None if hovered => theme.bg_hover,
None if row.selected => theme.bg_visual,
None => base_bg,
};
let meta_fg = embed.map_or(theme.gray, |e| e.fg(theme.gray));
// Fill row background.
let row_rect = Rect {
x,
y,
width,
height: 1,
};
buf.set_style(row_rect, Style::default().bg(row_bg));
// Left side: indent + cursor indicator + fold indicator + label.
let indent_str = if row.indent > 0 {
" ".repeat(row.indent as usize)
} else {
String::new()
};
// Show /◆ fold indicator for expandable rows (those with fields
// or description lines). Non-expandable rows show a ◆ diamond.
// Selection is indicated by bg_hover/bg_visual row highlight
// (no cursor glyph), matching the import-claude modal's style.
let is_expandable =
row.collapsible || !row.fields.is_empty() || !row.description_lines.is_empty();
let fold_width: u16 = 2; // " " or "◆ "
let label_style = if row.selected {
Style::default()
.fg(embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary)))
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else if row.dimmed {
Style::default().fg(theme.gray_dim).bg(row_bg)
} else {
Style::default().fg(theme.text_primary).bg(row_bg)
};
let prefix_width = indent_str.width() as u16 + fold_width;
let trailing_pad = 1u16; // space before border/scrollbar
let badge_width = if row.badge.is_empty() {
0u16
} else {
row.badge.width() as u16 + 1
}; // +1 space before badge
let right_width = row.right_label.width() as u16;
let gap = if right_width > 0 { 2u16 } else { 0 };
let max_label_width = width
.saturating_sub(right_width + gap + trailing_pad + badge_width + prefix_width)
as usize;
let truncated_label = truncate_str(row.label, max_label_width);
// Render as separate spans: indent, fold indicator (shared), label.
let mut cur_x = x;
if !indent_str.is_empty() {
buf.set_span(
cur_x,
y,
&Span::styled(&indent_str, label_style),
indent_str.width() as u16,
);
cur_x += indent_str.width() as u16;
}
if is_expandable {
if embed.is_some_and(|e| e.selected) {
let glyph = if row.expanded {
format!("{} ", crate::glyphs::diamond_filled())
} else {
format!("{} ", crate::glyphs::chevron())
};
buf.set_span(
cur_x,
y,
&Span::styled(glyph, Style::default().fg(meta_fg).bg(row_bg)),
fold_width,
);
cur_x += fold_width;
} else {
cur_x += crate::views::modal_window::render_fold_indicator(
buf,
cur_x,
y,
!row.expanded, // collapsed = !expanded
false, // picker rows don't track fold hover
Some(row_bg),
theme,
);
}
} else {
// Non-expandable: show ◆ diamond to indicate a leaf entry.
let diamond_fg = embed.map_or(theme.gray_dim, |e| e.fg(theme.gray_dim));
let style = Style::default().fg(diamond_fg).bg(row_bg);
buf.set_span(
cur_x,
y,
&Span::styled(format!("{} ", crate::glyphs::diamond_filled()), style),
fold_width,
);
cur_x += fold_width;
}
buf.set_span(
cur_x,
y,
&Span::styled(&truncated_label, label_style),
truncated_label.width() as u16,
);
let full_label_width = prefix_width + truncated_label.width() as u16;
// Badge (right after label).
if !row.badge.is_empty() {
let badge_x = x + full_label_width + 1;
let badge_fg = row.badge_color.unwrap_or(meta_fg);
let badge_style = Style::default().fg(badge_fg).bg(row_bg);
buf.set_span(
badge_x,
y,
&Span::styled(row.badge, badge_style),
row.badge.width() as u16,
);
}
// Right side (with trailing padding).
if right_width > 0 {
let right_style = Style::default().fg(meta_fg).bg(row_bg);
let right_x = x + width.saturating_sub(right_width + trailing_pad);
buf.set_span(
right_x,
y,
&Span::styled(row.right_label, right_style),
right_width,
);
}
// Description lines (shown when expanded) or summary lines (collapsed).
let mut rows = 1u16;
let secondary_lines: &[&str] = if row.expanded {
row.description_lines
} else {
row.summary_lines
};
if !secondary_lines.is_empty() {
let indent = DESC_INDENT;
let desc_style = Style::default().fg(theme.gray).bg(base_bg);
let highlight_style = Style::default()
.fg(theme.text_primary)
.bg(base_bg)
.add_modifier(Modifier::BOLD);
let max_w = width.saturating_sub(indent) as usize;
for desc in secondary_lines.iter() {
// Render with [bracket] highlight markers: text inside
// [...] is shown in bold/bright, brackets are stripped.
let line = parse_highlight_spans(desc, desc_style, highlight_style);
let wrapped = word_wrap_line(&line, max_w);
for wrap_line in wrapped {
if rows >= max_rows {
break;
}
render_styled_spans(buf, &wrap_line, x + indent, y + rows, max_w);
rows += 1;
}
}
}
// Expanded detail fields.
if row.expanded && !row.fields.is_empty() {
let indent = DESC_INDENT;
let field_label_style = Style::default().fg(theme.gray).bg(base_bg);
let field_value_style = Style::default().fg(theme.gray_bright).bg(base_bg);
for field in row.fields {
if rows >= max_rows {
break;
}
let fy = y + rows;
if field.label.is_empty() {
// Loading indicator or similar \u{2014} no label column.
let line = Line::from(Span::styled(
field.value,
Style::default().fg(theme.gray_dim).bg(base_bg),
));
buf.set_line(x + indent, fy, &line, width.saturating_sub(indent));
} else {
let label_text = format!("{:<12} ", field.label);
let label_col = label_text.len() as u16;
let max_val_w = width.saturating_sub(indent + label_col) as usize;
// First line: label + start of value.
let val = field.value;
let first_chunk_len = byte_offset_for_width(val, max_val_w);
let first_break = if val.width() <= max_val_w {
val.len()
} else if let Some(pos) = val[..first_chunk_len].rfind(' ') {
pos + 1
} else {
first_chunk_len
};
let line = Line::from(vec![
Span::styled(label_text, field_label_style),
Span::styled(&val[..first_break], field_value_style),
]);
buf.set_line(x + indent, fy, &line, width.saturating_sub(indent));
// Continuation lines (indented to align with value column).
let mut remaining = val[first_break..].trim_start();
while !remaining.is_empty() {
rows += 1;
if rows >= max_rows {
break;
}
let cy = y + rows;
let chunk_len = byte_offset_for_width(remaining, max_val_w);
let break_at = if remaining.width() <= max_val_w {
remaining.len()
} else if let Some(pos) = remaining[..chunk_len].rfind(' ') {
pos + 1
} else {
chunk_len
};
let chunk = &remaining[..break_at];
buf.set_span(
x + indent + label_col,
cy,
&Span::styled(chunk, field_value_style),
chunk.width() as u16,
);
remaining = remaining[break_at..].trim_start();
}
}
rows += 1;
}
}
RenderedRow { rows }
}
/// Render a single picker entry (header or row).
///
/// `max_rows` caps rendering to available vertical space.
#[allow(clippy::too_many_arguments)]
pub fn render_picker_entry(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
entry: &PickerEntry,
hovered: bool,
bg: Option<ratatui::style::Color>,
max_rows: u16,
) -> RenderedRow {
if max_rows == 0 {
return RenderedRow { rows: 0 };
}
match entry {
PickerEntry::Header { label } => {
let base_bg = picker_base_bg(bg, theme);
let header_style = Style::default()
.fg(theme.gray)
.bg(base_bg)
.add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.gray_dim).bg(base_bg);
let title = format!(" {} ", label);
let title_w = title.width();
let remaining = width as usize - title_w.min(width as usize);
let sep: String = std::iter::repeat_n('\u{2500}', remaining).collect();
let line = Line::from(vec![
Span::styled(title, header_style),
Span::styled(sep, sep_style),
]);
buf.set_line(x, y, &line, width);
RenderedRow { rows: 1 }
}
PickerEntry::Row(row) => {
render_picker_row(buf, x, y, width, theme, row, hovered, bg, max_rows)
}
}
}
// ---------------------------------------------------------------------------
// Close button
// ---------------------------------------------------------------------------
/// Render a `[\u{2717}]` close button right-aligned at `(x..x+width, y)`.
///
/// Returns the `Rect` for mouse hit-testing.
pub fn render_close_button(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
theme: &Theme,
hovered: bool,
bg: Option<ratatui::style::Color>,
) -> Rect {
let text = crate::glyphs::ballot_x_button();
let w: u16 = 3;
let bx = x + width.saturating_sub(w);
let style = if hovered {
let mut s = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
if let Some(c) = bg {
s = s.bg(c);
}
s
} else {
let mut s = Style::default().fg(theme.gray);
if let Some(c) = bg {
s = s.bg(c);
}
s
};
buf.set_span(bx, y, &Span::styled(text, style), w);
Rect::new(bx, y, w, 1)
}
// ---------------------------------------------------------------------------
// Floating frame
// ---------------------------------------------------------------------------
/// Render the floating popup frame: dim background, rounded border, close button.
///
/// Returns `None` if the area is too small to render anything.
pub fn render_floating_frame(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
close_hovered: bool,
) -> Option<PickerFrame> {
use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget};
if area.height < 8 || area.width < 30 {
return None;
}
// Dim background.
crate::views::file_search::line_viewer::dim_area(buf, area, theme.bg_base, 0.5);
// Compute popup area (65% width, fixed height for 20 entries).
let popup_w = ((area.width as f32 * 0.65) as u16).max(44).min(area.width);
let popup_h = (4 + 20).min(area.height.saturating_sub(2));
let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 3; // bias upward
let popup_area = Rect::new(popup_x, popup_y, popup_w, popup_h);
// Clear and draw bordered popup.
Clear.render(popup_area, buf);
let base_style = Style::default().fg(theme.text_primary).bg(theme.bg_base);
buf.set_style(popup_area, base_style);
let border = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.gray_dim))
.style(base_style);
let inner = border.inner(popup_area);
border.render(popup_area, buf);
if inner.height < 3 || inner.width < 10 {
return None;
}
let close_button = render_close_button(
buf,
inner.x,
inner.y,
inner.width,
theme,
close_hovered,
Some(theme.bg_base),
);
Some(PickerFrame {
content: inner,
close_button,
})
}
// ---------------------------------------------------------------------------
// Bordered frame primitive
// ---------------------------------------------------------------------------
/// Layout returned by [`render_bordered_frame`].
pub struct BorderedFrame {
/// Title row area (between top border and separator). Caller fills this.
pub title_row: Rect,
/// Content area (between separator and bottom border). Caller fills this.
pub content: Rect,
}
/// Render a bordered panel with a title row separated from content.
///
/// Draws:
/// ```text
/// \u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}
/// \u{2502} <title row> \u{2502}
/// \u{251c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2524}
/// \u{2502} <content> \u{2502}
/// \u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}
/// ```
///
/// The caller is responsible for rendering title and content into the
/// returned rects. This function only draws the frame chrome.
///
/// Returns `None` if the area is too small (< 5 rows or < 10 cols).
pub fn render_bordered_frame(
buf: &mut Buffer,
area: Rect,
border_color: ratatui::style::Color,
bg: ratatui::style::Color,
) -> Option<BorderedFrame> {
use ratatui::widgets::{Block, Borders, Clear, Widget};
if area.height < 5 || area.width < 10 {
return None;
}
let base_style = Style::default().fg(border_color).bg(bg);
let border_style = Style::default().fg(border_color).bg(bg);
// Clear area.
Clear.render(area, buf);
buf.set_style(area, Style::default().bg(bg));
// Header block: top border + title row (no bottom border).
let header_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 2,
};
let header_block = Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_style(base_style);
header_block.render(header_area, buf);
let title_row = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: 1,
};
// Content frame: all borders below the header.
let content_area = Rect {
x: area.x,
y: area.y + 2,
width: area.width,
height: area.height.saturating_sub(2),
};
let content_block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
let content = content_block.inner(content_area);
content_block.render(content_area, buf);
// Connect header to content frame with T-junctions.
if let Some(cell) = buf.cell_mut((area.x, content_area.y)) {
cell.set_char('\u{251c}');
cell.set_style(border_style);
}
if let Some(cell) = buf.cell_mut((area.x + area.width.saturating_sub(1), content_area.y)) {
cell.set_char('\u{2524}');
cell.set_style(border_style);
}
if content.width == 0 || content.height == 0 {
return None;
}
Some(BorderedFrame { title_row, content })
}
// ---------------------------------------------------------------------------
// Full-screen frame
// ---------------------------------------------------------------------------
/// Render a full-screen bordered picker panel using [`render_bordered_frame`].
///
/// Fills the title row with optional title text and a close button.
/// Returns `None` if the area is too small.
pub fn render_fullscreen_frame(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
title: Option<&str>,
close_hovered: bool,
) -> Option<PickerFrame> {
let frame = render_bordered_frame(buf, area, theme.gray_dim, theme.bg_base)?;
if title.is_some() {
// Fill title row.
if let Some(title_text) = title {
let title_line = Line::from(vec![
Span::styled(" ", Style::default().bg(theme.bg_base)),
Span::styled(
title_text,
Style::default()
.fg(theme.text_primary)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD),
),
]);
buf.set_line(
frame.title_row.x,
frame.title_row.y,
&title_line,
frame.title_row.width,
);
}
// Close button on the title row.
let close_button = render_close_button(
buf,
frame.title_row.x,
frame.title_row.y,
frame.title_row.width,
theme,
close_hovered,
Some(theme.bg_base),
);
Some(PickerFrame {
content: frame.content,
close_button,
})
} else {
// No title: reclaim the title row + separator for content.
let extra = frame.content.y.saturating_sub(frame.title_row.y);
let content = Rect {
x: frame.content.x + 1,
y: frame.title_row.y,
width: frame.content.width.saturating_sub(2),
height: frame.content.height + extra,
};
Some(PickerFrame {
content,
close_button: Rect::default(),
})
}
}
// ---------------------------------------------------------------------------
// Unified picker: state, config, outcome, render, input
// ---------------------------------------------------------------------------
/// Persistent picker state -- callers own this and pass `&mut` to input.
///
/// Fields used by both the `render_picker()` path (welcome screen) and
/// the `ModalWindow` + `render_picker_content()` path (modal popups):
/// `selected`, `query`, `search_active`, `expanded`, `hovered`,
/// `scroll_offset`, `hit_areas`.
///
/// Fields used **only** by the `render_picker()` path (welcome screen):
/// `mode`, `close_hovered`, `tab_hit_areas`, `filter_area`,
/// `filter_hovered`. Modal popups delegate these responsibilities to
/// [`super::modal_window::ModalWindowState`].
#[derive(Debug, Clone)]
pub struct PickerState {
/// Currently selected index in the filtered entries list.
pub selected: usize,
/// Search query string.
pub query: String,
/// Byte offset of the editing cursor within `query`. Invariant:
/// always on a char boundary in `[0, query.len()]`. Operations
/// that mutate `query` must keep this in sync (see helper methods
/// on `PickerState`).
pub query_cursor: usize,
/// Whether the search input is focused (only relevant when `show_search_hint` is true).
pub search_active: bool,
/// Indices of expanded entries in original data (empty = all collapsed). Caller-managed.
pub expanded: HashSet<usize>,
/// Display mode (Floating, FullScreen, or Popup). Only used by
/// `render_picker()` (welcome screen); modal popups ignore this.
pub mode: PickerMode,
/// Whether the mouse is hovering over the close button. Only used by
/// `render_picker()` (welcome screen); modal popups use
/// `ModalWindowState::close_hovered`.
pub close_hovered: bool,
/// Mouse-hover highlight (independent of selected). `None` when not hovering.
pub hovered: Option<usize>,
/// Mouse-scroll viewport offset (visual row). `None` = auto-center on `selected`.
/// Keyboard nav resets to `None`.
pub scroll_offset: Option<usize>,
/// Hit areas from the last render (for mouse hit-testing).
pub hit_areas: Option<PickerHitAreas>,
/// Hit areas for tab labels (one per tab, `None` if tab didn't fit).
pub tab_hit_areas: Option<Vec<Option<Rect>>>,
/// Hit area for the filter indicator in the search bar.
pub filter_area: Option<Rect>,
/// Whether the mouse is hovering over the filter indicator.
pub filter_hovered: bool,
/// Whether the tab bar region has keyboard focus. When true, Left/Right cycle tabs.
pub tabs_focused: bool,
/// Suppress the list selection highlight while keyboard focus is on the search bar (via edge navigation); cleared once the selection is meaningful again (typing, navigating back into the list, mouse click/hover). Session-picker rows gate their `selected` flag on `!selection_hidden`.
pub selection_hidden: bool,
}
impl Default for PickerState {
fn default() -> Self {
Self {
selected: 0,
query: String::new(),
query_cursor: 0,
search_active: false,
expanded: HashSet::new(),
mode: PickerMode::Floating,
close_hovered: false,
hovered: None,
scroll_offset: None,
hit_areas: None,
tab_hit_areas: None,
filter_area: None,
filter_hovered: false,
tabs_focused: false,
selection_hidden: false,
}
}
}
impl PickerState {
/// Create a new `PickerState` with the given display mode.
pub fn with_mode(mode: PickerMode) -> Self {
Self {
mode,
..Self::default()
}
}
/// Open directly in input mode (`search_active = true`) — for type-to-find
/// pickers (command palette, `/model` & `/theme` arg picker) so typing
/// filters immediately. Under `vim_normal_first`, Esc still drops to nav and
/// `i` re-enters input.
pub fn input_active() -> Self {
Self {
search_active: true,
..Self::default()
}
}
/// Reset transient state (query, selection, search, expanded) while
/// preserving the display mode and clearing hit areas.
pub fn reset(&mut self) {
self.selected = 0;
self.query.clear();
self.query_cursor = 0;
self.search_active = false;
self.expanded.clear();
self.close_hovered = false;
self.hit_areas = None;
self.tab_hit_areas = None;
self.filter_area = None;
self.filter_hovered = false;
self.tabs_focused = false;
self.selection_hidden = false;
}
/// Clear the search query and the selection/scroll state that tracks it,
/// returning to an empty-query view without touching focus flags
/// (`tabs_focused`) or hit areas. Used by the vim Esc-to-nav-mode path.
pub fn clear_query(&mut self) {
self.query.clear();
self.query_cursor = 0;
self.scroll_offset = None;
self.selected = 0;
self.selection_hidden = false;
self.expanded.clear();
}
/// When a search query is active on an expandable picker, force-expand
/// all entries so that matches inside collapsed groups/trees are
/// explicitly visible. Individual items can still be manually collapsed
/// by the user (those toggles are respected).
pub fn expand_all_for_search(&mut self, entry_count: usize) {
if entry_count > 0 {
for i in 0..entry_count {
self.expanded.insert(i);
}
}
}
}
/// Unified hit areas for all pickers.
#[derive(Debug, Clone)]
pub struct PickerHitAreas {
/// Close button rect.
pub close_button: Rect,
/// Search bar rect.
pub search_bar: Rect,
/// Per-item click rects (only selectable rows, not headers).
pub item_rects: Vec<Rect>,
/// Maps visual index → entry index: `item_rects[i]` corresponds to
/// `entries[entry_indices[i]]`.
pub entry_indices: Vec<usize>,
/// One rect per tab label; `None` if the tab didn't fit.
pub tab_rects: Vec<Option<Rect>>,
/// Hit rect for the filter indicator. `None` if no filter.
pub filter_rect: Option<Rect>,
}
/// Configuration for picker behavior (non-state, provided per-call).
pub struct PickerConfig<'a> {
/// Title shown in fullscreen mode's title row (e.g. "Resume session").
pub title: Option<&'a str>,
/// When true, show "/ to search" hint; user must activate search explicitly.
/// When false, search is always active (cursor always visible).
pub show_search_hint: bool,
/// Whether the picker supports e:expand and y:copy.
pub expandable: bool,
/// Whether Esc clears the query before closing.
pub esc_clears_query: bool,
/// Keybinding hints rendered at the bottom — inside the frame in fullscreen,
/// at the bottom of the full area in floating mode.
pub shortcuts: Option<&'a [HintItem]>,
/// Pending double-press confirmation (replaces all shortcuts with "press again").
pub pending_hint: Option<PendingHint>,
/// External area for shortcuts in floating mode. When provided, shortcuts
/// render here instead of at the bottom of `area`. Lets the caller align
/// shortcuts with the surrounding layout (e.g. `layout.shortcuts`).
pub shortcuts_area: Option<Rect>,
/// Per-entry selectability: `non_selectable[i]` is true if entry `i` should
/// be skipped during Up/Down navigation (e.g. section headers).
/// Can be shorter than entry count (missing entries are selectable).
pub non_selectable: &'a [bool],
/// Non-selectable rows that still accept clicks (e.g. MCP section labels).
/// Same length semantics as [`Self::non_selectable`].
pub non_selectable_clickable: &'a [bool],
/// Tab labels for a tab bar at the top. None = no tabs.
pub tabs: Option<&'a [&'a str]>,
/// Active tab index (only used if tabs is Some).
pub active_tab: usize,
/// Filter indicator in the search bar. None = no filter.
pub filter_label: Option<&'a str>,
/// Key hint for the filter (e.g., "f").
pub filter_key_hint: Option<&'a str>,
/// Whether the filter is active (not in default/All state).
pub filter_active: bool,
/// Custom action keys that produce `PickerOutcome::Action`.
/// Each entry is `(key_char, description)` shown in shortcuts.
pub action_keys: &'a [(char, &'a str)],
/// If true, suppress the search bar entirely (and any text input into
/// `state.query`). The first content row is replaced by `config.title`
/// rendered as a plain title. Useful for read-only cheatsheet modals.
pub disable_search: bool,
/// If true, render the bottom shortcuts bar using ONLY `config.shortcuts`
/// (no auto-added nav / e:expand / y:copy / action_keys entries). The
/// underlying key dispatch is unaffected — the keys still work, they
/// just aren't advertised in the bar. Useful for compact bars that
/// surface only the most important hints; the full list lives in the
/// shortcuts cheatsheet modal.
pub compact_bottom_bar: bool,
/// If true (and `show_search_hint` is also true), only `/` or a click
/// on the search bar activates search mode. Typing arbitrary
/// printable characters is ignored instead of auto-starting a query.
/// Default behavior (false) auto-activates search on any printable
/// character — useful for fuzzy pickers but disruptive for tabs
/// where letters double as action keys.
pub search_only_on_slash: bool,
/// When true, the picker opens/stays in nav mode (vim) whenever search is
/// not active: j/k navigate and printable chars don't type; `i`/`/` enter
/// search (unless search is disabled, or the char is bound as an action key
/// on the picker, which takes precedence). Type-to-find pickers still open in
/// input via [`PickerState::input_active`] and drop to nav on Esc. Callers
/// gate this to `load_vim_mode()`. Mirrors scrollback vim-mode.
pub vim_normal_first: bool,
}
/// Standard picker shortcuts: navigate, select, close.
///
/// Returns a static reference — no per-call allocation.
pub fn picker_shortcuts() -> &'static [HintItem] {
static SHORTCUTS: LazyLock<Vec<HintItem>> = LazyLock::new(|| {
vec![
HintItem {
keys: vec![],
label: "nav".into(),
custom_display: Some("\u{2191}/\u{2193}"),
description: None,
pinned: false,
},
HintItem::new(crate::key!(Enter), "select"),
HintItem::new(crate::key!(Esc), "close"),
]
});
&SHORTCUTS
}
/// What happened after processing an input event.
pub enum PickerOutcome {
/// User selected entry at this index (in the filtered entries list).
Selected(usize),
/// User pressed Esc / clicked close — caller decides how to handle.
Closed,
/// User wants to expand/toggle entry at this index (if config.expandable).
Expand(usize),
/// User wants to collapse entry at this index (Shift-E).
Collapse(usize),
/// User wants to copy entry at this index (if config.expandable).
Copy(usize),
/// User pressed Enter with a non-empty query but no matching entries.
/// Caller can use the query string (from `state.query`) to attempt a direct lookup.
SubmitQuery,
/// Visual state changed, needs redraw.
Changed,
/// Nothing changed.
Unchanged,
/// User switched to tab at given index.
TabChanged(usize),
/// User clicked/pressed the filter key — caller should cycle the filter.
FilterCycled,
/// Custom action key pressed.
Action(char),
/// Click on a non-selectable but clickable row (e.g. section label).
NonSelectableClick(usize),
}
/// Hit areas for picker content (entries + scrollbar, no frame/tabs/search chrome).
///
/// Returned by [`render_picker_content`] so callers that render their own
/// frame/chrome (via [`super::modal_window`]) can wire up mouse hit-testing
/// for the content portion independently of the picker's built-in frame.
#[derive(Debug, Clone)]
pub struct PickerContentHitAreas {
/// Per-item click rects (only selectable rows, not headers).
pub item_rects: Vec<Rect>,
/// Maps visual index → entry index: `item_rects[i]` corresponds to
/// `entries[entry_indices[i]]`.
pub entry_indices: Vec<usize>,
}
/// Render picker content (entries + scrollbar) into the given area.
///
/// This is the content-only portion of [`render_picker`], extracted so
/// callers that render their own frame/chrome (via [`super::modal_window`])
/// can draw entries into a provided content area without the picker's
/// built-in frame, tab bar, or search bar.
///
/// The existing [`render_picker`] calls this internally for backwards
/// compatibility with non-migrated callers.
#[allow(clippy::too_many_arguments)]
pub fn render_picker_content(
buf: &mut Buffer,
content_area: Rect,
theme: &Theme,
state: &mut PickerState,
entries: &[PickerEntry<'_>],
non_selectable: &[bool],
non_selectable_clickable: &[bool],
bg: Option<ratatui::style::Color>,
loading: bool,
) -> PickerContentHitAreas {
render_picker_content_inner(
buf,
content_area,
theme,
state,
entries,
non_selectable,
non_selectable_clickable,
bg,
loading,
None,
)
}
/// Like [`render_picker_content`] but with an explicit scrollbar
/// x-position. When `scrollbar_x` is `Some(x)`, the scrollbar is
/// rendered at that column instead of `content_area.x + content_area.width - 1`.
/// Used by modals with h_pad to place the scrollbar flush against the border.
#[allow(clippy::too_many_arguments)]
pub fn render_picker_content_with_scrollbar_x(
buf: &mut Buffer,
content_area: Rect,
theme: &Theme,
state: &mut PickerState,
entries: &[PickerEntry<'_>],
non_selectable: &[bool],
non_selectable_clickable: &[bool],
bg: Option<ratatui::style::Color>,
loading: bool,
scrollbar_x: u16,
) -> PickerContentHitAreas {
render_picker_content_inner(
buf,
content_area,
theme,
state,
entries,
non_selectable,
non_selectable_clickable,
bg,
loading,
Some(scrollbar_x),
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_picker_in_modal(
buf: &mut Buffer,
content_area: Rect,
inner_x: u16,
inner_width: u16,
theme: &Theme,
state: &mut PickerState,
entries: &[PickerEntry<'_>],
non_selectable: &[bool],
loading: bool,
) {
let search_active = state.search_active;
render_picker_in_modal_inner(
buf,
content_area,
inner_x,
inner_width,
theme,
state,
entries,
non_selectable,
loading,
search_active,
true,
);
}
/// Explicit search cursor/hint (ShortcutsHelp).
#[allow(clippy::too_many_arguments)]
pub fn render_picker_in_modal_inner(
buf: &mut Buffer,
content_area: Rect,
inner_x: u16,
inner_width: u16,
theme: &Theme,
state: &mut PickerState,
entries: &[PickerEntry<'_>],
non_selectable: &[bool],
loading: bool,
search_active: bool,
show_search_hint: bool,
) {
render_search_bar(
buf,
content_area.x,
content_area.y,
content_area.width,
theme,
&state.query,
search_active,
show_search_hint,
state.query_cursor,
Some(theme.bg_base),
);
let sep_y = content_area.y + 1;
if sep_y < content_area.y + content_area.height {
render_divider(buf, inner_x, sep_y, inner_width, theme, Some(theme.bg_base));
}
let entries_start_y = sep_y + 1;
let search_bar_rect = Rect::new(content_area.x, content_area.y, content_area.width, 1);
let entries_area = Rect {
x: content_area.x,
y: entries_start_y,
width: content_area.width,
height: content_area
.height
.saturating_sub(entries_start_y.saturating_sub(content_area.y)),
};
let content_hit = render_picker_content_with_scrollbar_x(
buf,
entries_area,
theme,
state,
entries,
non_selectable,
&[],
Some(theme.bg_base),
loading,
inner_x + inner_width - 1,
);
state.hit_areas = Some(PickerHitAreas {
close_button: Rect::default(),
search_bar: search_bar_rect,
item_rects: content_hit.item_rects,
entry_indices: content_hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
}
#[allow(clippy::too_many_arguments)]
fn render_picker_content_inner(
buf: &mut Buffer,
content_area: Rect,
theme: &Theme,
state: &mut PickerState,
entries: &[PickerEntry<'_>],
non_selectable: &[bool],
non_selectable_clickable: &[bool],
bg: Option<ratatui::style::Color>,
loading: bool,
scrollbar_x_override: Option<u16>,
) -> PickerContentHitAreas {
// Cleared each paint; set below if a row underlines its last description line.
let is_clickable_non_sel = |i: usize| non_selectable_clickable.get(i).copied().unwrap_or(false);
let empty_hit = PickerContentHitAreas {
item_rects: vec![],
entry_indices: vec![],
};
if content_area.height == 0 || content_area.width == 0 {
return empty_hit;
}
// Loading state — centered in the content area.
if loading {
let msg = "Loading...";
let msg_style = Style::default().fg(theme.gray);
let cx = content_area.x + content_area.width.saturating_sub(msg.len() as u16) / 2;
let cy = content_area.y + content_area.height / 2;
buf.set_string(cx, cy, msg, msg_style);
return empty_hit;
}
// Empty state.
if entries.is_empty() {
let msg_style = Style::default()
.fg(theme.gray_dim)
.bg(picker_base_bg(bg, theme));
buf.set_string(content_area.x, content_area.y, " No matches", msg_style);
return empty_hit;
}
let is_non_sel = |i: usize| non_selectable.get(i).copied().unwrap_or(false);
// Ensure state.selected doesn't point to a non-selectable entry.
{
if is_non_sel(state.selected) {
let mut s = state.selected;
while is_non_sel(s) && s < entries.len().saturating_sub(1) {
s += 1;
}
if !is_non_sel(s) {
state.selected = s;
}
}
}
let max_visible = content_area.height as usize;
let area_w = content_area.width;
// Heights must use the same wrap width as rendering (narrower when scrollbar shown).
let compute_heights = |wrap_w: u16| -> Vec<usize> {
entries
.iter()
.enumerate()
.map(|(idx, e)| match e {
PickerEntry::Header { .. } => {
if idx == 0 {
1
} else {
2
}
}
PickerEntry::Row(row) => compute_row_height(row, wrap_w),
})
.collect()
};
let heights_full = compute_heights(area_w);
let total_full: usize = heights_full.iter().sum();
let needs_scroll = total_full > max_visible;
let content_w = if needs_scroll {
area_w.saturating_sub(1)
} else {
area_w
};
let entry_heights = if needs_scroll {
compute_heights(content_w)
} else {
heights_full
};
let total_visual_rows: usize = entry_heights.iter().sum();
// Find the visual row offset of the selected entry.
let selected_visual_row: usize = entry_heights[..state.selected.min(entries.len())]
.iter()
.sum();
// Compute scroll offset in visual rows.
let scroll_visual = if let Some(offset) = state.scroll_offset {
let max_scroll = total_visual_rows.saturating_sub(max_visible);
offset.min(max_scroll)
} else {
let centered = selected_visual_row.saturating_sub(max_visible / 2);
let max_scroll = total_visual_rows.saturating_sub(max_visible);
centered.min(max_scroll)
};
state.scroll_offset = Some(scroll_visual);
let content_w = content_w as usize;
let mut item_rects = Vec::new();
let mut entry_indices = Vec::new();
// Skip entries until we've consumed scroll_visual visual rows.
let mut visual_rows_consumed = 0usize;
let mut start_entry = 0usize;
for (idx, &h) in entry_heights.iter().enumerate() {
if visual_rows_consumed + h > scroll_visual {
start_entry = idx;
break;
}
visual_rows_consumed += h;
start_entry = idx + 1;
}
let rows_above = scroll_visual.saturating_sub(visual_rows_consumed);
if rows_above > 0 {
start_entry += 1;
}
let mut y = content_area.y;
let mut first_visible = start_entry == 0;
for (entry_idx, entry) in entries.iter().enumerate().skip(start_entry) {
if y >= content_area.y + content_area.height {
break;
}
let is_header = matches!(entry, PickerEntry::Header { .. });
if is_header && !first_visible {
y += 1;
if y >= content_area.y + content_area.height {
break;
}
}
first_visible = false;
let is_hovered = !is_header && state.hovered == Some(entry_idx);
let remaining = (content_area.y + content_area.height).saturating_sub(y);
let rendered = render_picker_entry(
buf,
content_area.x,
y,
content_w as u16,
theme,
entry,
is_hovered,
bg,
remaining,
);
let rows_consumed = rendered.rows;
if !is_header && (!is_non_sel(entry_idx) || is_clickable_non_sel(entry_idx)) {
let row_rect = Rect {
x: content_area.x,
y,
width: content_area.width,
height: rows_consumed,
};
item_rects.push(row_rect);
entry_indices.push(entry_idx);
}
y += rows_consumed;
}
// Scrollbar. (Globally suppressed in minimal mode via
// `render::scrollbar::set_scrollbars_hidden`.)
if needs_scroll {
let sb_x = scrollbar_x_override.unwrap_or(content_area.x + content_area.width - 1);
let sb_area = Rect::new(sb_x, content_area.y, 1, max_visible as u16);
crate::render::scrollbar::render_scrollbar_styled(
buf,
Some(sb_area),
total_visual_rows as u16,
max_visible as u16,
scroll_visual as u16,
Style::default().bg(bg.unwrap_or(theme.bg_base)),
Style::default()
.fg(theme.gray_dim)
.bg(bg.unwrap_or(theme.bg_base)),
);
}
PickerContentHitAreas {
item_rects,
entry_indices,
}
}
/// Render the unified picker. Caller provides filtered entries.
///
/// Returns hit areas for mouse interaction. The caller stores these in
/// `state.hit_areas` for use by `handle_picker_input`.
#[allow(clippy::too_many_arguments)]
pub fn render_picker(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
state: &mut PickerState,
entries: &[PickerEntry<'_>],
config: &PickerConfig<'_>,
loading: bool,
) -> PickerHitAreas {
let empty_hit = PickerHitAreas {
close_button: Rect::default(),
search_bar: Rect::default(),
item_rects: vec![],
entry_indices: vec![],
tab_rects: vec![],
filter_rect: None,
};
let frame = match render_picker_frame(
buf,
area,
theme,
state.mode.clone(),
config.title,
state.close_hovered,
) {
Some(f) => f,
None => return empty_hit,
};
let bg = match state.mode {
PickerMode::Floating | PickerMode::Popup(_) => Some(theme.bg_base),
PickerMode::FullScreen => None,
};
let is_fullscreen = matches!(state.mode, PickerMode::FullScreen);
let raw_content = frame.content;
// Reserve last row for shortcuts in fullscreen mode (rendered inside frame).
// In floating mode, use shortcuts_area if provided, otherwise area bottom.
let (shortcuts_y, shortcuts_x, shortcuts_w) = if config.shortcuts.is_some() {
if is_fullscreen {
(
Some(raw_content.y + raw_content.height.saturating_sub(1)),
raw_content.x,
raw_content.width,
)
} else if let Some(sa) = config.shortcuts_area {
(Some(sa.y), sa.x, sa.width)
} else {
(
Some(area.y + area.height.saturating_sub(1)),
area.x,
area.width,
)
}
} else {
(None, 0, 0)
};
let content = if is_fullscreen && config.shortcuts.is_some() {
Rect {
height: raw_content.height.saturating_sub(1),
..raw_content
}
} else {
raw_content
};
// ── Tab bar (optional) ──
// When tabs are configured, render a tab bar on the first row of the content
// area and advance the content origin downward.
let mut close_button = frame.close_button;
let mut tab_rects_out: Vec<Option<Rect>> = vec![];
let content = if let Some(tabs) = config.tabs {
let tab_hit = render_tab_bar(
buf,
content.x,
content.y,
content.width,
theme,
tabs,
config.active_tab,
state.close_hovered,
);
close_button = tab_hit.close_button;
tab_rects_out = tab_hit.tab_rects;
// Advance content below tab bar + divider.
let tab_rows = 1u16; // tab bar
let div_y = content.y + tab_rows;
if div_y < content.y + content.height {
render_divider(buf, content.x, div_y, content.width, theme, bg);
}
let used = tab_rows + 1; // tab bar + divider
Rect {
y: content.y + used,
height: content.height.saturating_sub(used),
..content
}
} else {
content
};
state.tab_hit_areas = if tab_rects_out.is_empty() {
None
} else {
Some(tab_rects_out.clone())
};
let search_bar_rect = Rect::new(content.x, content.y, content.width, 1);
// Search bar width: floating/popup mode subtracts space for close button in same row.
// When tabs are present, close button is in the tab bar row, not the search row.
let search_width = if config.tabs.is_some() {
content.width // close button already in tab bar
} else {
match state.mode {
PickerMode::Floating | PickerMode::Popup(_) => content.width.saturating_sub(4),
PickerMode::FullScreen => content.width,
}
};
if config.disable_search {
// Replace the search bar row with a plain title (e.g. "Keyboard
// Shortcuts" for the cheatsheet modal). Mirrors the search-bar
// padding for visual consistency. Renders a [\u{2717}] close button at
// the right edge so popup-mode pickers (which don't draw one in
// the frame) still have a discoverable close affordance.
let title_text = config.title.unwrap_or("");
let title_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let title_style = if let Some(c) = bg {
title_style.bg(c)
} else {
title_style
};
let pad_style = if let Some(c) = bg {
Style::default().bg(c)
} else {
Style::default()
};
// Leading 1-char pad to match search bar's " search:" alignment.
buf.set_span(content.x, content.y, &Span::styled(" ", pad_style), 1);
let title_w = title_text.width() as u16;
let max_title_w = search_width.saturating_sub(1);
let title_w = title_w.min(max_title_w);
if title_w > 0 {
buf.set_span(
content.x + 1,
content.y,
&Span::styled(title_text, title_style),
title_w,
);
}
// Pad rest of the row with bg so the dim doesn't bleed through.
let used = 1 + title_w;
if used < search_width {
let rest = search_width - used;
let pad: String = " ".repeat(rest as usize);
buf.set_span(
content.x + used,
content.y,
&Span::styled(pad, pad_style),
rest,
);
}
// Render [\u{2717}] close button on the right edge of the title row,
// matching the floating-frame close-button style. Stash its rect
// in close_button so handle_picker_input can hit-test clicks.
if config.tabs.is_none() {
close_button = render_close_button(
buf,
content.x,
content.y,
content.width,
theme,
state.close_hovered,
bg,
);
}
} else {
// Cursor tracks focus (`search_active`) for every picker — like the Settings pane; `show_search_hint` is input-only and no longer forces an always-on cursor.
render_search_bar(
buf,
content.x,
content.y,
search_width,
theme,
&state.query,
state.search_active,
true,
state.query_cursor,
bg,
);
}
// ── Filter indicator (optional) ──
let filter_rect_out = if let Some(filter_label) = config.filter_label {
let key_hint = config.filter_key_hint.unwrap_or("f");
let rect = render_filter_indicator(
buf,
content.x,
content.y,
search_width,
theme,
filter_label,
key_hint,
config.filter_active,
state.filter_hovered,
);
state.filter_area = Some(rect);
Some(rect)
} else {
state.filter_area = None;
None
};
// Divider below search.
let sep_y = content.y + 1;
if sep_y < content.y + content.height {
render_divider(buf, content.x, sep_y, content.width, theme, bg);
}
let entries_start_y = sep_y + 1;
// Delegate entry rendering + scrollbar to render_picker_content.
let entries_area = Rect {
x: content.x,
y: entries_start_y,
width: content.width,
height: (content.y + content.height).saturating_sub(entries_start_y),
};
let content_hit = render_picker_content(
buf,
entries_area,
theme,
state,
entries,
config.non_selectable,
config.non_selectable_clickable,
bg,
loading,
);
let item_rects = content_hit.item_rects;
let entry_indices = content_hit.entry_indices;
// Shortcuts rendering (both modes).
// Combine base shortcuts + expandable hints + action keys, OR — when
// compact_bottom_bar is set — render only `config.shortcuts`.
if let Some(sy) = shortcuts_y {
let mut all_hints: Vec<HintItem> = Vec::new();
// Base shortcuts (nav, select, close).
if let Some(shortcuts) = config.shortcuts {
all_hints.extend_from_slice(shortcuts);
}
// Surface `i` in vim nav mode so users discover how to start typing.
if config.vim_normal_first && !state.search_active {
all_hints.push(HintItem::new(crate::key!('i'), "search"));
}
// Expandable: add e=expand and y=copy hints.
if config.expandable && !config.compact_bottom_bar {
all_hints.push(HintItem {
keys: vec![],
label: "expand".into(),
custom_display: Some("e/Shift+e"),
description: None,
pinned: false,
});
all_hints.push(HintItem {
keys: vec![],
label: "copy".into(),
custom_display: Some("y"),
description: None,
pinned: false,
});
}
// Action keys (per-tab: toggle, reload, add, etc.).
if !config.compact_bottom_bar {
for &(ch, desc) in config.action_keys {
let display: &'static str = match ch {
' ' => "space",
'a' => "a",
'd' => "d",
'e' => "e",
'f' => "f",
'i' => "i",
'r' => "r",
'u' => "u",
'x' => "x",
_ => "",
};
if !display.is_empty() {
all_hints.push(HintItem {
keys: vec![],
label: std::borrow::Cow::Owned(desc.to_string()),
custom_display: Some(display),
description: None,
pinned: false,
});
}
}
}
if !all_hints.is_empty() {
// Inset by 1 cell so hints don't hug the left border.
let shortcuts_rect = Rect::new(shortcuts_x + 1, sy, shortcuts_w.saturating_sub(1), 1);
ShortcutsBar::new(&all_hints)
.with_pending(config.pending_hint)
.render(shortcuts_rect, buf);
}
}
PickerHitAreas {
close_button,
search_bar: search_bar_rect,
item_rects,
entry_indices,
tab_rects: tab_rects_out,
filter_rect: filter_rect_out,
}
}
/// Handle input events for the picker. Returns what happened.
///
/// The caller provides the number of filtered entries for bounds checking.
/// After receiving `Changed`, the caller should re-filter entries based on
/// `state.query` and call `render_picker()` with the updated entries.
pub fn handle_picker_input(
ev: &crossterm::event::Event,
state: &mut PickerState,
entry_count: usize,
config: &PickerConfig<'_>,
) -> PickerOutcome {
use crossterm::event::{Event, KeyCode, KeyEventKind, MouseEventKind};
let is_non_sel = |i: usize| config.non_selectable.get(i).copied().unwrap_or(false);
let is_clickable_non_sel = |i: usize| {
config
.non_selectable_clickable
.get(i)
.copied()
.unwrap_or(false)
};
// Clamp selected to valid range — entries may have changed since last input
// (e.g., query filter reduced the list).
if entry_count > 0 {
// Clamp selected into valid range first — entries may have shrunk.
state.selected = state.selected.min(entry_count.saturating_sub(1));
// Skip non-selectable items (e.g., section headers)
while is_non_sel(state.selected) && state.selected < entry_count - 1 {
state.selected += 1;
}
if is_non_sel(state.selected) {
state.selected = 0;
while is_non_sel(state.selected) && state.selected < entry_count - 1 {
state.selected += 1;
}
}
} else {
state.selected = 0;
}
// Precompute first/last selectable for boundary-aware Up/Down navigation
// (search focus at edges, skipping any non-selectable headers).
let first_selectable = if entry_count == 0 {
0
} else {
let mut s = 0usize;
while is_non_sel(s) && s + 1 < entry_count {
s += 1;
}
if is_non_sel(s) { 0 } else { s }
};
let last_selectable = if entry_count == 0 {
0
} else {
let mut s = entry_count - 1;
while is_non_sel(s) && s > 0 {
s -= 1;
}
if is_non_sel(s) { entry_count - 1 } else { s }
};
// ── Mouse handling (hit area based) ──
if let Event::Mouse(mouse) = ev
&& let Some(ref hit) = state.hit_areas
{
let pos = ratatui::layout::Position::new(mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
if hit.close_button.contains(pos) {
return PickerOutcome::Closed;
}
if hit.search_bar.contains(pos) {
state.search_active = true;
state.tabs_focused = false;
state.selection_hidden = true;
return PickerOutcome::Changed;
}
// Tab click.
for (i, tab_rect) in hit.tab_rects.iter().enumerate() {
if let Some(rect) = tab_rect
&& rect.contains(pos)
&& i != config.active_tab
{
return PickerOutcome::TabChanged(i);
}
}
// Filter click.
if let Some(filter_rect) = hit.filter_rect
&& filter_rect.contains(pos)
{
return PickerOutcome::FilterCycled;
}
for (i, rect) in hit.item_rects.iter().enumerate() {
if rect.contains(pos)
&& let Some(&entry_idx) = hit.entry_indices.get(i)
{
state.tabs_focused = false;
if is_non_sel(entry_idx) && is_clickable_non_sel(entry_idx) {
return PickerOutcome::NonSelectableClick(entry_idx);
}
state.selected = entry_idx;
state.selection_hidden = false;
return PickerOutcome::Selected(entry_idx);
}
}
return PickerOutcome::Changed; // consume click
}
MouseEventKind::Moved => {
let mut changed = false;
let on_close = hit.close_button.contains(pos);
if on_close != state.close_hovered {
state.close_hovered = on_close;
changed = true;
}
// Filter hover.
if let Some(filter_area) = state.filter_area {
let on_filter = filter_area.contains(pos);
if on_filter != state.filter_hovered {
state.filter_hovered = on_filter;
changed = true;
}
}
// Row hover.
let mut on_item = None;
for (vis_idx, rect) in hit.item_rects.iter().enumerate() {
if rect.contains(pos) {
let entry_idx = hit.entry_indices.get(vis_idx).copied().unwrap_or(vis_idx);
on_item = Some(entry_idx);
break;
}
}
if on_item != state.hovered {
state.hovered = on_item;
if let Some(idx) = on_item
&& !is_non_sel(idx)
{
state.selected = idx;
state.tabs_focused = false;
state.selection_hidden = false;
}
changed = true;
}
return if changed {
PickerOutcome::Changed
} else {
PickerOutcome::Unchanged
};
}
MouseEventKind::ScrollDown => {
let current = state.scroll_offset.unwrap_or(0);
state.scroll_offset = Some(current + 3);
state.hovered = None;
return PickerOutcome::Changed;
}
MouseEventKind::ScrollUp => {
let current = state.scroll_offset.unwrap_or(0);
state.scroll_offset = Some(current.saturating_sub(3));
state.hovered = None;
return PickerOutcome::Changed;
}
_ => return PickerOutcome::Changed,
}
}
// Helper to deduplicate paste logic between is_paste_key and Event::Paste.
// Also ensures scroll_offset=None on all paste-driven query mutations, for
// consistency with every other query-mutating arm.
//
// Implemented as a local `fn` (not a closure) so we can legitimately use
// `impl AsRef<str>` in argument position (allowed for fn parameters, not
// for closure parameters). This gives us a single, flexible implementation
// that accepts String, &String, &str, etc. without explicit borrows at the
// call sites, avoiding both the type error and any needless_borrow lint.
fn handle_paste(
state: &mut PickerState,
text: impl AsRef<str>,
config: &PickerConfig<'_>,
) -> PickerOutcome {
// vim_normal_first: printables don't type until search is entered, so a
// paste while in nav mode is ignored.
if config.vim_normal_first && !state.search_active {
return PickerOutcome::Unchanged;
}
let cleaned: String = text
.as_ref()
.chars()
.filter(|c| *c != '\n' && *c != '\r')
.collect();
if cleaned.is_empty() {
return PickerOutcome::Unchanged;
}
state.query.insert_str(state.query_cursor, &cleaned);
state.query_cursor += cleaned.len();
if config.show_search_hint {
state.search_active = true;
}
state.selected = 0;
state.selection_hidden = false;
state.expanded.clear();
state.scroll_offset = None;
state.tabs_focused = false;
PickerOutcome::Changed
}
// ── Key handling ──
if let Event::Key(key) = ev {
if key.kind == KeyEventKind::Release {
return PickerOutcome::Unchanged;
}
if crate::input::key::is_paste_key(key) || crate::input::key::is_inline_paste_key(key) {
if let Some(text) = crate::clipboard::system_clipboard_get() {
if !crate::clipboard::clipboard_text_is_pasteable(Some(&text)) {
crate::clipboard::log_paste_key_empty_host_clipboard("picker");
}
return handle_paste(state, text, config);
}
crate::clipboard::log_paste_key_empty_host_clipboard("picker");
return PickerOutcome::Unchanged;
}
// ── Left/Right cursor movement (only when search input is focused) ──
let search_input_active =
!config.disable_search && (state.search_active || !config.show_search_hint);
if search_input_active && !state.query.is_empty() {
if key.code == KeyCode::Left {
if state.query_cursor > 0 {
let new = state.query[..state.query_cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
state.query_cursor = new;
}
return PickerOutcome::Changed;
}
if key.code == KeyCode::Right {
if state.query_cursor < state.query.len() {
let rest = &state.query[state.query_cursor..];
let ch_len = rest.chars().next().map_or(0, |c| c.len_utf8());
state.query_cursor += ch_len;
}
return PickerOutcome::Changed;
}
}
// Search mode (currently active). Also reachable for vim_normal_first
// pickers without a search hint, so typing/Esc/Backspace work once
// search is entered via `i`/`/`.
if (config.show_search_hint || config.vim_normal_first) && state.search_active {
if key.code == KeyCode::Esc {
state.search_active = false;
// vim_normal_first: Esc leaves search for nav mode and clears the
// query in one step (mirrors scrollback vim-mode).
if config.vim_normal_first {
state.clear_query();
}
return PickerOutcome::Changed;
}
if key.code == KeyCode::Char('u')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
state.query.clear();
state.query_cursor = 0;
state.scroll_offset = None;
state.selected = 0;
state.selection_hidden = false;
state.expanded.clear();
state.tabs_focused = false;
return PickerOutcome::Changed;
}
if key.code == KeyCode::Backspace {
if state.query_cursor > 0 {
let prev = state.query[..state.query_cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
state.query.drain(prev..state.query_cursor);
state.query_cursor = prev;
}
state.scroll_offset = None;
state.selected = 0;
state.selection_hidden = false;
state.tabs_focused = false;
if config.expandable && !state.query.is_empty() {
state.expand_all_for_search(entry_count);
} else {
state.expanded.clear();
}
return PickerOutcome::Changed;
}
if let Some(tabs) = config.tabs {
let tab_count = tabs.len();
if tab_count > 1 {
let shift = key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT);
if key.code == KeyCode::Tab && !shift {
return PickerOutcome::TabChanged((config.active_tab + 1) % tab_count);
}
if key.code == KeyCode::BackTab || (key.code == KeyCode::Tab && shift) {
return PickerOutcome::TabChanged(
(config.active_tab + tab_count - 1) % tab_count,
);
}
}
}
// Up/Down/Enter exit search mode, then (for arrows) navigate to the
// adjacent "edge" item to support cycling between search and list.
if key.code == KeyCode::Up || key.code == KeyCode::Down || key.code == KeyCode::Enter {
state.search_active = false;
state.selection_hidden = false;
if entry_count > 0 {
state.selected = state.selected.min(entry_count - 1);
}
if key.code == KeyCode::Up || key.code == KeyCode::Down {
if key.code == KeyCode::Down {
if entry_count > 0 {
state.selected = first_selectable;
}
} else {
// Up from search: if tabs configured, move focus to the
// tab bar region so Left/Right can cycle tabs.
if config.tabs.is_some() {
state.tabs_focused = true;
} else if entry_count > 0 {
state.selected = last_selectable;
}
}
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
} else if let KeyCode::Char(c) = key.code
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
state.query.insert_str(state.query_cursor, s);
state.query_cursor += s.len();
state.scroll_offset = None;
state.selected = 0;
state.selection_hidden = false;
if config.expandable {
state.expand_all_for_search(entry_count);
} else {
state.expanded.clear();
}
return PickerOutcome::Changed;
} else {
return PickerOutcome::Unchanged;
}
}
// j/k alias to Down/Up for nav when a search hint is shown (hint mode)
// or under vim_normal_first (always-active pickers included).
let jk_navigates = config.show_search_hint || config.vim_normal_first;
// When the tabs region is focused (reached by Up arrow from search
// or top of list when tabs are configured), handle arrows for tab
// cycling (delegated to the tabs block above), boundary Down/Up to
// move focus back into content/search, and printable chars to start
// a query (exiting tabs focus).
if state.tabs_focused {
let is_down_j = jk_navigates
&& !state.search_active
&& key.code == KeyCode::Char('j')
&& key.modifiers.is_empty();
let is_down = key.code == KeyCode::Down || is_down_j;
if is_down {
state.tabs_focused = false;
// Down from the tabs region goes to search (so the full
// arrow cycle is: list bottom → tabs → search → list top).
// Under vim_normal_first nav never opens search, so j/Down from
// the tabs region moves into the list instead.
if config.show_search_hint && !config.vim_normal_first {
state.search_active = true;
state.selection_hidden = true;
} else if entry_count > 0 {
state.selected = first_selectable;
state.selection_hidden = false;
}
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
let is_up_k = jk_navigates
&& !state.search_active
&& key.code == KeyCode::Char('k')
&& key.modifiers.is_empty();
let is_up = key.code == KeyCode::Up || is_up_k;
if is_up {
state.tabs_focused = false;
// Wrap "up" from tabs to the bottom of the list (if any).
if entry_count > 0 {
state.selected = last_selectable;
}
state.selection_hidden = false;
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
if key.code == KeyCode::Enter {
// "Activating" the tab bar does nothing special; leave focus
// or drop it. Drop it to return to content.
state.tabs_focused = false;
return PickerOutcome::Changed;
}
// Printable characters while tabs focused: exit focus and start
// a search query (mirrors behavior from non-active search hint).
if config.show_search_hint && !state.search_active {
if key.code == KeyCode::Char('/') && key.modifiers.is_empty() {
state.tabs_focused = false;
state.search_active = true;
return PickerOutcome::Changed;
}
if !config.search_only_on_slash
&& !config.vim_normal_first
&& let KeyCode::Char(c) = key.code
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
state.tabs_focused = false;
state.search_active = true;
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
state.query.insert_str(state.query_cursor, s);
state.query_cursor += s.len();
state.scroll_offset = None;
state.selected = 0;
state.selection_hidden = false;
if config.expandable {
state.expand_all_for_search(entry_count);
} else {
state.expanded.clear();
}
return PickerOutcome::Changed;
}
}
// For other keys (including action keys, Esc, etc.) while tabs
// focused we fall through so the normal paths (Tab switch,
// action keys, Esc close, etc.) can still apply. L/R are
// handled in the tabs block later and will have returned already.
}
// Ctrl+F: toggle mode.
if key.code == KeyCode::Char('f')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
state.mode = state.mode.clone().toggle();
return PickerOutcome::Changed;
}
// Esc.
if key.code == KeyCode::Esc {
if config.esc_clears_query && !state.query.is_empty() {
state.query.clear();
state.query_cursor = 0;
state.scroll_offset = None;
state.selected = 0;
state.selection_hidden = false;
state.expanded.clear(); // back to default collapsed when search ends
state.tabs_focused = false;
return PickerOutcome::Changed;
}
state.tabs_focused = false;
return PickerOutcome::Closed;
}
// Enter.
if key.code == KeyCode::Enter {
if entry_count > 0 && !is_non_sel(state.selected) {
return PickerOutcome::Selected(state.selected);
}
if entry_count == 0 && !state.query.is_empty() {
return PickerOutcome::SubmitQuery;
}
return PickerOutcome::Changed;
}
// Navigation: Down (+ j in hint or vim_normal_first mode).
let is_down_j = jk_navigates
&& !state.search_active
&& key.code == KeyCode::Char('j')
&& key.modifiers.is_empty();
let is_down = key.code == KeyCode::Down || is_down_j;
if is_down {
let at_bottom = entry_count == 0 || state.selected == last_selectable;
// Under vim_normal_first, j/Down clamp at the list bottom (the field
// doc promises only `i`/`/` open search; never auto-jump to tabs).
if at_bottom && !state.search_active && !config.vim_normal_first {
// When a tab bar is present, Down from the last list item
// moves focus to the tabs region (so Left/Right can cycle
// tabs) instead of the search bar.
if config.tabs.is_some() {
state.tabs_focused = true;
state.search_active = false;
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
if config.show_search_hint {
state.search_active = true;
state.selection_hidden = true;
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
}
if entry_count == 0 {
return PickerOutcome::Changed;
}
let mut new_sel = (state.selected + 1).min(entry_count - 1);
while is_non_sel(new_sel) && new_sel < entry_count - 1 {
new_sel += 1;
}
if is_non_sel(new_sel) {
new_sel = state.selected;
}
state.selected = new_sel;
state.selection_hidden = false;
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
// Navigation: Up (+ k in hint or vim_normal_first mode).
let is_up_k = jk_navigates
&& !state.search_active
&& key.code == KeyCode::Char('k')
&& key.modifiers.is_empty();
let is_up = key.code == KeyCode::Up || is_up_k;
if is_up {
let at_top = entry_count == 0 || state.selected == first_selectable;
// Under vim_normal_first, k/Up clamp at the list top (only `i`/`/`
// open search).
if at_top && config.show_search_hint && !state.search_active && !config.vim_normal_first
{
state.search_active = true;
state.selection_hidden = true;
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
if entry_count == 0 {
return PickerOutcome::Changed;
}
let mut new_sel = state.selected.saturating_sub(1);
while is_non_sel(new_sel) && new_sel > 0 {
new_sel = new_sel.saturating_sub(1);
}
if is_non_sel(new_sel) {
new_sel = state.selected;
}
state.selected = new_sel;
state.selection_hidden = false;
state.hovered = None;
state.scroll_offset = None;
return PickerOutcome::Changed;
}
// ── Custom action keys (checked first — override built-in expand/copy) ──
// Only when not in search mode.
if !state.search_active {
for &(action_char, _) in config.action_keys {
if key.code == KeyCode::Char(action_char) && key.modifiers.is_empty() {
return PickerOutcome::Action(action_char);
}
}
}
// Expandable actions (y: copy, e/→: expand, E/←: collapse, space: toggle).
if config.expandable && entry_count > 0 && !state.search_active && !state.tabs_focused {
if key.code == KeyCode::Char('y') && key.modifiers.is_empty() {
return PickerOutcome::Copy(state.selected);
}
if key.code == KeyCode::Char('e') && key.modifiers.is_empty() {
return PickerOutcome::Expand(state.selected);
}
if key.code == KeyCode::Char('E') {
return PickerOutcome::Collapse(state.selected);
}
// Left/Right arrows: collapse/expand when query is empty
// (when query is non-empty, Left/Right handle cursor movement
// earlier in this function and never reach here).
// Only when the list content has focus (not the tabs region).
if key.code == KeyCode::Right {
return PickerOutcome::Expand(state.selected);
}
if key.code == KeyCode::Left {
return PickerOutcome::Collapse(state.selected);
}
if key.code == KeyCode::Char(' ') && key.modifiers.is_empty() {
// Space triggers the 'toggle' action if one is configured,
// otherwise falls through.
if config.action_keys.iter().any(|&(_, desc)| desc == "toggle") {
return PickerOutcome::Action(' ');
}
}
}
// ── Tab switching ──
// Tab/Shift-Tab (and BackTab) always cycle tabs when configured
// (and not in search).
//
// When the tab bar region has been focused via Up/Down arrows
// (`tabs_focused`), Left/Right (and h/l) also cycle tabs. This
// lets callers make L/R expand/collapse the *selected list item*
// by default, while still allowing arrow nav of the tab bar after
// the user explicitly arrows "up" to it.
if let Some(tabs) = config.tabs {
let tab_count = tabs.len();
if tab_count > 1 && !state.search_active {
// Dedicated L/R handling only when the tabs list itself is selected.
if state.tabs_focused {
if key.code == KeyCode::Left || key.code == KeyCode::Char('h') {
return PickerOutcome::TabChanged(
(config.active_tab + tab_count - 1) % tab_count,
);
}
if key.code == KeyCode::Right || key.code == KeyCode::Char('l') {
return PickerOutcome::TabChanged((config.active_tab + 1) % tab_count);
}
}
let forward = key.code == KeyCode::Tab
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT);
let backward = (key.code == KeyCode::Tab
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::SHIFT))
|| key.code == KeyCode::BackTab;
if forward {
return PickerOutcome::TabChanged((config.active_tab + 1) % tab_count);
}
if backward {
return PickerOutcome::TabChanged(
(config.active_tab + tab_count - 1) % tab_count,
);
}
}
}
// ── Filter cycling ──
// 'f' key (not in search mode) toggles the filter.
if config.filter_label.is_some()
&& !state.search_active
&& key.code == KeyCode::Char('f')
&& key.modifiers.is_empty()
{
return PickerOutcome::FilterCycled;
}
// vim_normal_first opens the picker in nav mode; `/` or `i` is the only
// way into search (printable chars don't type until then). Mirrors
// scrollback vim-mode. This intentionally shadows the hint `/` handler
// below under vim and also covers always-active pickers (no search hint).
if config.vim_normal_first
&& !state.search_active
&& !config.disable_search
&& key.modifiers.is_empty()
&& matches!(key.code, KeyCode::Char('i') | KeyCode::Char('/'))
{
state.search_active = true;
state.tabs_focused = false;
return PickerOutcome::Changed;
}
// Always-active search (no hint). Suppressed entirely when the caller
// asked to disable search (e.g. read-only cheatsheet), and under
// vim_normal_first (where typing instead flows through the search-active
// block once `i`/`/` enters search).
if !config.show_search_hint && !config.disable_search && !config.vim_normal_first {
if key.code == KeyCode::Char('u')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
state.query.clear();
state.query_cursor = 0;
state.scroll_offset = None;
state.selected = 0;
state.expanded.clear();
state.tabs_focused = false;
return PickerOutcome::Changed;
}
if key.code == KeyCode::Backspace {
if state.query_cursor > 0 {
let prev = state.query[..state.query_cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
state.query.drain(prev..state.query_cursor);
state.query_cursor = prev;
}
state.scroll_offset = None;
state.selected = 0;
state.expanded.clear();
state.tabs_focused = false;
return PickerOutcome::Changed;
}
// `/` activates search instead of self-inserting when it can't
// plausibly be query text. The "/ to search" placeholder renders
// exactly while `!search_active` with an empty query, even for
// always-active pickers (e.g. the `/docs` how-to picker), so
// this condition mirrors the renderer's: the advertised chord
// must not type a literal `/` into the query. A `/` typed
// mid-query (non-empty) or while the search bar is already
// focused (`input_active()` pickers, the dashboard location
// picker's leading-`/` paths) still inserts, so path-like
// queries keep working.
if key.code == KeyCode::Char('/')
&& key.modifiers.is_empty()
&& state.query.is_empty()
&& !state.search_active
{
state.search_active = true;
state.tabs_focused = false;
return PickerOutcome::Changed;
}
if let KeyCode::Char(c) = key.code
&& (key.modifiers.is_empty()
|| key.modifiers == crossterm::event::KeyModifiers::SHIFT)
{
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
state.query.insert_str(state.query_cursor, s);
state.query_cursor += s.len();
state.scroll_offset = None;
state.selected = 0;
state.expanded.clear();
state.tabs_focused = false;
return PickerOutcome::Changed;
}
}
// Hint-based search, not active.
if config.show_search_hint && !state.search_active {
if key.code == KeyCode::Char('/') && key.modifiers.is_empty() {
state.search_active = true;
return PickerOutcome::Changed;
}
// Auto-activate search on any printable char — opt-out via
// `search_only_on_slash` for tabs where letters are action
// keys (e.g. extensions modal Skills tab), and via
// `vim_normal_first` where bare letters never type.
if !config.search_only_on_slash
&& !config.vim_normal_first
&& let KeyCode::Char(c) = key.code
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
state.search_active = true;
state.tabs_focused = false;
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
state.query.insert_str(state.query_cursor, s);
state.query_cursor += s.len();
state.selected = 0;
state.expanded.clear();
return PickerOutcome::Changed;
}
}
return PickerOutcome::Unchanged; // unhandled key — no state change
}
// ── Paste ──
if let Event::Paste(text) = ev {
return handle_paste(state, text, config);
}
if matches!(ev, crossterm::event::Event::Resize(_, _)) {
return PickerOutcome::Changed;
}
PickerOutcome::Unchanged
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
// Minimal picker config for input tests. Flip `vim_normal_first` per-test to
// exercise the dormant nav mode; everything else mirrors a basic picker.
fn cfg(show_search_hint: bool, vim_normal_first: bool) -> PickerConfig<'static> {
PickerConfig {
title: None,
show_search_hint,
expandable: false,
esc_clears_query: true,
shortcuts: None,
pending_hint: None,
shortcuts_area: None,
non_selectable: &[],
non_selectable_clickable: &[],
tabs: None,
active_tab: 0,
filter_label: None,
filter_key_hint: None,
filter_active: false,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first,
}
}
fn press(c: char) -> Event {
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
fn press_esc() -> Event {
Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
}
fn press_up() -> Event {
Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
}
#[test]
fn flag_off_always_active_char_types() {
// show_search_hint=false: always-active search still types as today.
let config = cfg(false, false);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('a'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "a");
}
#[test]
fn flag_off_hint_char_auto_activates_search() {
// show_search_hint=true: a non-jk char auto-activates search as today.
let config = cfg(true, false);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('a'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert!(state.search_active);
assert_eq!(state.query, "a");
}
#[test]
fn always_active_slash_on_empty_query_focuses_search_without_typing() {
// show_search_hint=false picker (e.g. the `/docs` how-to picker) shows
// the "/ to search" placeholder while unfocused: pressing the
// advertised `/` must focus search, not type a literal `/`.
let config = cfg(false, false);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('/'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert!(state.search_active);
assert!(state.query.is_empty());
// Typing after the activation chord filters normally.
let outcome = handle_picker_input(&press('t'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "t");
}
#[test]
fn always_active_slash_mid_query_inserts_literal_slash() {
// A `/` typed into a non-empty query is plausible query text
// (path-like searches) and must keep inserting.
let config = cfg(false, false);
let mut state = PickerState::default();
handle_picker_input(&press('a'), &mut state, 3, &config);
handle_picker_input(&press('b'), &mut state, 3, &config);
assert_eq!(state.query, "ab");
let outcome = handle_picker_input(&press('/'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "ab/");
assert!(!state.search_active);
}
#[test]
fn always_active_slash_inserts_when_search_already_focused() {
// Pickers that open input-focused (`input_active()`: command palette,
// arg picker; the dashboard location picker) never show the
// "/ to search" placeholder, so a leading `/` there is query text
// (e.g. an absolute path) and must insert even on an empty query.
let config = cfg(false, false);
let mut state = PickerState::input_active();
let outcome = handle_picker_input(&press('/'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "/");
}
#[test]
fn hint_slash_still_activates_search_without_typing() {
// show_search_hint=true pickers keep their existing `/` handling.
let config = cfg(true, false);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('/'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert!(state.search_active);
assert!(state.query.is_empty());
let outcome = handle_picker_input(&press('t'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "t");
}
#[test]
fn search_bar_cursor_visible_only_when_search_active() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
// A `show_search_hint: false` picker (command palette / arg-picker family):
// the cursor must track focus (`search_active`), not render always-on.
let theme = Theme::current();
let config = cfg(false, false);
let area = Rect::new(0, 0, 60, 16);
// Render the picker; report whether the search row drew a cursor (an
// inverse-video cell whose bg == text_primary) plus its visible text.
let render_search_row = |search_active: bool| -> (bool, String) {
let mut state = PickerState::with_mode(PickerMode::FullScreen);
state.search_active = search_active;
let mut buf = Buffer::empty(area);
let hit = render_picker(&mut buf, area, &theme, &mut state, &[], &config, false);
let y = hit.search_bar.y;
let mut has_cursor = false;
let mut text = String::new();
for x in hit.search_bar.x..hit.search_bar.x + hit.search_bar.width {
if let Some(cell) = buf.cell((x, y)) {
text.push_str(cell.symbol());
if cell.bg == theme.text_primary {
has_cursor = true;
}
}
}
(has_cursor, text)
};
let (focused_cursor, _) = render_search_row(true);
assert!(
focused_cursor,
"focused search bar (search_active) should render a cursor",
);
let (unfocused_cursor, unfocused_text) = render_search_row(false);
assert!(
!unfocused_cursor,
"unfocused search bar must not render a cursor",
);
assert!(
unfocused_text.contains("/ to search"),
"unfocused search bar should show the `/ to search` placeholder, got {unfocused_text:?}",
);
}
#[test]
fn vim_not_searching_char_does_not_type() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('a'), &mut state, 3, &config);
assert!(state.query.is_empty(), "hint={hint}");
assert!(!state.search_active, "hint={hint}");
assert!(matches!(outcome, PickerOutcome::Unchanged), "hint={hint}");
}
}
#[test]
fn vim_i_enters_search_without_typing() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('i'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}");
assert!(state.search_active, "hint={hint}");
assert!(state.query.is_empty(), "hint={hint}");
}
}
/// `input_active()` opens directly in input mode; `default()` does not.
/// Used by type-to-find pickers (command palette, `/model` & `/theme` arg
/// picker) so typing filters immediately on open.
#[test]
fn input_active_starts_in_search_mode() {
assert!(PickerState::input_active().search_active);
assert!(!PickerState::default().search_active);
}
#[test]
fn vim_char_types_after_entering_search() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
handle_picker_input(&press('i'), &mut state, 3, &config);
let outcome = handle_picker_input(&press('a'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}");
assert_eq!(state.query, "a", "hint={hint}");
}
}
#[test]
fn vim_esc_while_searching_exits_search_and_clears_query() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
handle_picker_input(&press('i'), &mut state, 3, &config);
handle_picker_input(&press('a'), &mut state, 3, &config);
assert_eq!(state.query, "a", "hint={hint}");
let outcome = handle_picker_input(&press_esc(), &mut state, 3, &config);
assert!(!matches!(outcome, PickerOutcome::Closed), "hint={hint}");
assert!(!state.search_active, "hint={hint}");
assert!(state.query.is_empty(), "hint={hint}");
}
}
#[test]
fn vim_esc_while_not_searching_closes() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press_esc(), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Closed), "hint={hint}");
}
}
#[test]
fn vim_j_k_navigate_when_not_searching() {
// Works for both hint and always-active (show_search_hint=false) pickers.
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
handle_picker_input(&press('j'), &mut state, 3, &config);
assert_eq!(state.selected, 1, "hint={hint} after j");
handle_picker_input(&press('k'), &mut state, 3, &config);
assert_eq!(state.selected, 0, "hint={hint} after k");
}
}
#[test]
fn vim_j_at_bottom_clamps_without_opening_search() {
// Edge-cycle is disabled under vim: j at the last item stays put and
// never opens search or jumps to the tabs region.
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState {
selected: 2,
..PickerState::default()
};
let outcome = handle_picker_input(&press('j'), &mut state, 3, &config);
assert_eq!(state.selected, 2, "hint={hint}");
assert!(!state.search_active, "hint={hint}");
assert!(!state.tabs_focused, "hint={hint}");
assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}");
}
}
#[test]
fn vim_k_at_top_clamps_without_opening_search() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default(); // selected = 0 (top)
let outcome = handle_picker_input(&press('k'), &mut state, 3, &config);
assert_eq!(state.selected, 0, "hint={hint}");
assert!(!state.search_active, "hint={hint}");
assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}");
}
}
#[test]
fn vim_slash_enters_search_without_typing() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('/'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed), "hint={hint}");
assert!(state.search_active, "hint={hint}");
assert!(state.query.is_empty(), "hint={hint}");
}
}
#[test]
fn vim_action_key_takes_precedence_over_i_entry() {
// A picker binding `i` as an action key (e.g. extensions install/auth)
// triggers the action; the vim `i` search-entry does not shadow it.
let mut config = cfg(true, true);
config.action_keys = &[('i', "x")];
let mut state = PickerState::default();
let outcome = handle_picker_input(&press('i'), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Action('i')));
assert!(!state.search_active);
}
#[test]
fn vim_paste_suppressed_when_not_searching() {
for hint in [true, false] {
let config = cfg(hint, true);
let mut state = PickerState::default();
let outcome =
handle_picker_input(&Event::Paste("hello".to_string()), &mut state, 3, &config);
assert!(state.query.is_empty(), "hint={hint}");
assert!(matches!(outcome, PickerOutcome::Unchanged), "hint={hint}");
}
}
#[test]
fn vim_paste_types_after_entering_search() {
// Paste is only suppressed in nav mode; once search is entered it pastes.
let config = cfg(true, true);
let mut state = PickerState::default();
handle_picker_input(&press('i'), &mut state, 3, &config);
let outcome = handle_picker_input(&Event::Paste("hi".to_string()), &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "hi");
}
#[test]
fn vim_disable_search_makes_i_and_slash_inert() {
// disable_search pickers (read-only cheatsheets) use no search hint;
// under vim, `i` and `/` must not enter search.
let mut config = cfg(false, true);
config.disable_search = true;
for c in ['i', '/'] {
let mut state = PickerState::default();
let outcome = handle_picker_input(&press(c), &mut state, 3, &config);
assert!(!state.search_active, "c={c}");
assert!(state.query.is_empty(), "c={c}");
assert!(matches!(outcome, PickerOutcome::Unchanged), "c={c}");
}
}
#[test]
fn vim_with_tabs_jk_navigate_and_char_does_not_type() {
let mut config = cfg(true, true);
config.tabs = Some(&["a", "b"]);
let mut state = PickerState::default();
// j navigates the list without opening search or jumping to the tab bar.
handle_picker_input(&press('j'), &mut state, 3, &config);
assert_eq!(state.selected, 1);
assert!(!state.search_active);
assert!(!state.tabs_focused);
// A bare printable char does not type while in nav mode.
let outcome = handle_picker_input(&press('a'), &mut state, 3, &config);
assert!(state.query.is_empty());
assert!(matches!(outcome, PickerOutcome::Unchanged));
}
#[test]
fn vim_i_up_j_does_not_reopen_search_with_tabs() {
// i (enter search) → Up (exit into tabs region) → j must move into the
// list, not reopen search (only `i`/`/` start search under vim).
let mut config = cfg(true, true);
config.tabs = Some(&["a", "b"]);
let mut state = PickerState::default();
handle_picker_input(&press('i'), &mut state, 3, &config);
assert!(state.search_active);
handle_picker_input(&press_up(), &mut state, 3, &config);
assert!(state.tabs_focused);
assert!(!state.search_active);
handle_picker_input(&press('j'), &mut state, 3, &config);
assert!(!state.search_active);
assert!(!state.tabs_focused);
}
}