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).
3435 lines
125 KiB
Rust
3435 lines
125 KiB
Rust
//! 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);
|
||
}
|
||
}
|