Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/modal_window.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1969 lines
68 KiB
Rust

//! Shared modal window chrome component.
//!
//! Provides a single `ModalWindow` that handles the visual frame (border,
//! title, close button, optional tab bar, footer shortcuts) and common
//! input routing (Esc to close, tab switching, shortcut clicks). Each
//! popup modal in the pager becomes an instance of `ModalWindow` with
//! different features enabled via [`ModalWindowConfig`].
//!
//! The visual style follows the import-claude modal's design: accent-colored
//! square border, bold title on top border, generous inner padding, inline
//! centered footer shortcuts with hover highlights, and a `Clear` background.
use crossterm::event::{KeyCode, KeyEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Clear, Widget};
use unicode_width::UnicodeWidthStr;
use crate::render::line_utils::byte_offset_at_width;
use crate::theme::Theme;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
pub use crate::modal_window_state::{ModalWindowState, ShortcutHitArea};
use std::sync::atomic::{AtomicBool, Ordering};
/// When set, [`render_modal_window`] renders **borderless** ("embedded"): no
/// centered popup box, no border, no close button — the modal's content fills
/// the given `area` directly. Minimal mode sets this once at startup so it never
/// shows a floating modal frame (design: "never render modals in minimal mode");
/// the full TUI leaves it off and keeps the bordered popup.
static EMBEDDED: AtomicBool = AtomicBool::new(false);
/// Enable/disable borderless ("embedded") modal rendering. Set `true` for
/// minimal mode (called once at terminal init).
pub fn set_embedded(on: bool) {
EMBEDDED.store(on, Ordering::Relaxed);
}
/// Whether modals should render borderless (minimal mode). See [`set_embedded`].
pub fn embedded() -> bool {
EMBEDDED.load(Ordering::Relaxed)
}
/// Resolved list-row styling for the embedded ("resume-list") look shared by
/// the dropdown / picker widgets: rows stay transparent and the selected row
/// recolors its text with the selection accent (fzf-style) — a painted
/// selection band is not expressible on the terminal-native palette.
#[derive(Clone, Copy)]
pub struct EmbeddedRowStyle {
/// Row background: always transparent (`Color::Reset`).
pub bg: Color,
/// True when this row is the selected row.
pub selected: bool,
selected_fg: Color,
}
impl EmbeddedRowStyle {
/// Foreground for text/glyphs on this row: the selection accent on the
/// selected row, otherwise the caller's `normal` color.
pub fn fg(&self, normal: Color) -> Color {
if self.selected {
self.selected_fg
} else {
normal
}
}
}
/// Embedded (minimal) list-row styling, or `None` in the full TUI — in which
/// case the caller keeps its own selected / hovered / normal styling. See
/// [`embedded`] and [`EmbeddedRowStyle`].
pub fn embedded_row_style(theme: &Theme, is_selected: bool) -> Option<EmbeddedRowStyle> {
embedded().then_some(EmbeddedRowStyle {
bg: Color::Reset,
selected: is_selected,
selected_fg: theme.fuzzy_accent,
})
}
/// Per-render configuration for a modal window (rebuilt each frame).
pub struct ModalWindowConfig<'a> {
/// Title displayed bold on the top border.
pub title: &'a str,
/// Tab labels. `None` = no tab bar.
pub tabs: Option<&'a [&'a str]>,
/// Footer shortcuts to render inline at the bottom.
pub shortcuts: &'a [Shortcut<'a>],
/// Sizing parameters.
pub sizing: ModalSizing,
/// Fold state of the currently focused entry. When provided,
/// Left/Right/h/l return specific fold outcomes instead of
/// [`ModalWindowOutcome::Unhandled`].
pub fold_info: Option<FoldInfo>,
}
/// Sizing parameters for the modal popup.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModalSizing {
/// Fraction of screen width to use (0.0..=1.0). Default: 0.9.
pub width_pct: f32,
/// Maximum width in columns. Default: 140.
pub max_width: u16,
/// Minimum width in columns. Default: 60.
pub min_width: u16,
/// Vertical margin (top and bottom) in rows. Default: 7.
pub v_margin: u16,
/// Horizontal inner padding in columns (applied on both sides). Default: 2.
pub h_pad: u16,
/// Vertical inner padding above the content area (below the tab bar,
/// if present). Not applied at the bottom — the footer occupies that
/// space. Default: 2.
pub v_pad: u16,
/// Lines reserved at the bottom for footer shortcuts. Default: 2.
pub footer_lines: u16,
}
impl Default for ModalSizing {
fn default() -> Self {
Self {
width_pct: 0.9,
max_width: 140,
min_width: 60,
v_margin: 7,
h_pad: 2,
v_pad: 2,
footer_lines: 2,
}
}
}
impl ModalSizing {
/// Medium popup: ~60% width, standard padding. Good for picker lists.
/// Used by: cloud_modal and other pickers (verified: values match exactly).
pub fn medium() -> Self {
Self {
width_pct: 0.60,
max_width: 120,
min_width: 44,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
}
/// Large popup: ~90% width, generous padding. Good for forms/detail views.
/// Used by: import_claude_modal. Same as Default.
pub fn large() -> Self {
Self::default()
}
/// Returns adjusted sizing for compact mode with *very little margins*.
///
/// Goal: maximize usable content area inside every popup (command palette,
/// /resume sessions, plugins/hooks/mcps, import-claude, docs, etc.).
pub fn with_compact(mut self, compact: bool) -> Self {
if compact {
// Almost no outer centering margin — the popup can nearly touch
// the top and bottom of the terminal.
self.v_margin = 0;
// Keep 1 column so the left accent line + selection border have room.
self.h_pad = 1;
// No extra vertical breathing room above the first content row
// (search bar, tab content, or first picker row).
self.v_pad = 0;
}
self
}
}
/// Fold state of the currently focused entry, provided by the caller
/// so [`handle_modal_key`] can make fold decisions generically.
///
/// When present in [`ModalWindowConfig`], Left/Right/h/l keys return
/// specific fold outcomes ([`ModalWindowOutcome::CollapseGroup`], etc.)
/// instead of the default [`ModalWindowOutcome::Unhandled`]. Note that
/// `Unhandled` is still returned when no fold action applies (e.g.
/// Left on a top-level collapsed header with no parent).
#[derive(Debug, Clone, Copy)]
pub struct FoldInfo {
/// Whether the focused entry is a collapsible group header.
pub collapsible: bool,
/// Whether the focused entry is currently expanded (children visible).
pub expanded: bool,
/// Whether the focused entry has expandable detail fields (e.g. leaf
/// items with description lines or config fields).
pub has_details: bool,
/// Whether detail fields are currently shown.
pub details_expanded: bool,
/// Index of the parent group header. `None` for top-level entries.
pub parent_index: Option<usize>,
}
/// A single footer shortcut definition.
pub struct Shortcut<'a> {
/// Display label (e.g. "Enter import 3" or "Esc cancel").
pub label: &'a str,
/// Whether clicking this shortcut dispatches `ShortcutActivated`.
/// All shortcuts get the same visual style and hover highlights
/// regardless of this flag.
pub clickable: bool,
/// Caller-defined identifier, returned in `ModalWindowOutcome::ShortcutActivated`.
pub id: usize,
}
/// Append an `i search` footer hint when a vim-nav picker is in NAV mode
/// (vim-mode on and search not yet active), so users discover how to start
/// typing. No-op otherwise. Shared by the picker modals' footer builders.
pub fn push_vim_nav_search_hint<'a>(shortcuts: &mut Vec<Shortcut<'a>>, search_active: bool) {
if !search_active && crate::appearance::cache::load_vim_mode() {
shortcuts.push(Shortcut {
label: "i search",
clickable: false,
id: 0,
});
}
}
/// The content area returned by [`render_modal_window`] so the caller
/// knows where to draw their domain-specific content.
pub struct ModalContentArea {
/// Rect for the main content (between top padding and footer),
/// inset by `h_pad` on each side.
pub content: Rect,
/// Rect for the footer shortcut row.
pub footer: Rect,
/// Full inner width (border to border, no h_pad). Use this for
/// rendering full-width dividers.
pub inner_x: u16,
pub inner_width: u16,
}
/// Outcome of a modal window processing an input event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModalWindowOutcome {
/// Chrome consumed the event (hover update, etc.).
Handled,
/// Close requested (Esc, click `[✗]`, or click outside).
CloseRequested,
/// Tab changed to the given index.
TabChanged(usize),
/// A clickable footer shortcut was activated (by caller-defined ID).
ShortcutActivated(usize),
/// Collapse the focused group header (Left on expanded collapsible
/// header).
CollapseGroup,
/// Expand the focused group header (Right on collapsed collapsible
/// header).
ExpandGroup,
/// Collapse detail fields on the focused leaf (Left on expanded
/// details).
CollapseDetails,
/// Expand detail fields on the focused leaf (Right on collapsed
/// details).
ExpandDetails,
/// Jump focus to the parent group header at the given index (Left on
/// collapsed header or leaf without expanded details).
JumpToParent(usize),
/// Event not consumed by the chrome -- caller should handle content
/// interaction (row clicks, scroll, key navigation, etc.).
Unhandled,
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Render the modal window chrome and return the content area for the
/// caller to render into.
///
/// Draws:
/// 1. `Clear` over the popup rect (no dimming).
/// 2. Accent-colored square border with bold title on the top border.
/// 3. `[✗]` close button on the top-right border with hover brightening.
/// 4. Optional tab bar below the top border.
/// 5. Inner padding (`h_pad` x `v_pad`).
/// 6. Footer shortcut row (inline centered, clickable bold, hints dim,
/// hover highlight).
///
/// Returns `None` if the area is too small to render a meaningful popup.
pub fn render_modal_window(
buf: &mut Buffer,
area: Rect,
state: &mut ModalWindowState,
config: &ModalWindowConfig<'_>,
theme: &Theme,
) -> Option<ModalContentArea> {
let sizing = &config.sizing;
let is_embedded = embedded();
// Popup rect: embedded (minimal) fills the given area; otherwise a centered
// popup box.
let (modal_width, modal_height) = if is_embedded {
(area.width, area.height)
} else {
compute_modal_dims(area, sizing)
};
if modal_width < 20 || modal_height < 6 {
// Clear stale hit-test rects so mouse handlers don't act on
// positions from a previous (larger) render.
state.popup_area = None;
state.close_button_rect = None;
state.shortcut_hits.clear();
state.tab_rects.clear();
return None;
}
let modal_area = if is_embedded {
area
} else {
Rect {
x: area.x + (area.width.saturating_sub(modal_width)) / 2,
y: area.y + (area.height.saturating_sub(modal_height)) / 2,
width: modal_width,
height: modal_height,
}
};
state.popup_area = Some(modal_area);
// Clear cells under the modal so content behind doesn't bleed through.
Clear.render(modal_area, buf);
// The decorative ─ dashes around the title use the border color (gray_dim)
// so they blend with the border, while the title text itself stays bold.
let border_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let title_style = Style::default()
.fg(theme.text_primary)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
let inner = if is_embedded {
// Borderless (minimal): no popup box, border, or close button — the
// modal must not look like a floating window. An optional bold title
// takes the first row; content fills the full width below it.
if config.title.is_empty() {
modal_area
} else {
// Background-free title (minimal renders every element transparent).
let embedded_title_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let title = ratatui::text::Line::from(Span::styled(config.title, embedded_title_style));
buf.set_line(
modal_area.x + sizing.h_pad,
modal_area.y,
&title,
modal_area.width.saturating_sub(sizing.h_pad),
);
Rect {
x: modal_area.x,
y: modal_area.y + 1,
width: modal_area.width,
height: modal_area.height.saturating_sub(1),
}
}
} else {
// Border block, optionally with a bold title on the top border. An empty
// title suppresses the decoration so the top border is a continuous line.
let mut block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(theme.bg_base).fg(theme.text_primary))
.border_style(border_style);
if !config.title.is_empty() {
let title = ratatui::text::Line::from(vec![
Span::styled("\u{2500} ", border_style),
Span::styled(config.title, title_style),
Span::styled(" \u{2500}", border_style),
]);
block = block.title(title);
}
let inner = block.inner(modal_area);
block.render(modal_area, buf);
// Top-right [✗] close button rendered ON the border.
render_close_button(buf, modal_area, state, theme);
inner
};
// Optional tab bar below the top border, with a full-width divider
// separating tabs from content. Tab bar wraps onto multiple rows
// when tabs don't fit on a single line.
let tab_bar_height;
let tab_divider_height;
if let Some(tabs) = config.tabs {
tab_bar_height = render_tab_bar(buf, inner, state, tabs, theme);
tab_divider_height = 1;
// Full-width divider below tab bar.
let div_y = inner.y + tab_bar_height;
if div_y < inner.y + inner.height {
let div_bg = if is_embedded {
ratatui::style::Color::Reset
} else {
theme.bg_base
};
let div_style = Style::default().fg(theme.gray_dim).bg(div_bg);
let line: String = std::iter::repeat_n('\u{2500}', inner.width as usize).collect();
buf.set_string(inner.x, div_y, &line, div_style);
}
} else {
tab_bar_height = 0;
tab_divider_height = 0;
}
// Compute content area (inside padding, above footer).
// When tabs are present, the divider replaces vertical padding between
// tabs and content (so content starts right after the divider).
let effective_v_pad = if config.tabs.is_some() {
0
} else {
sizing.v_pad
};
// Footer height: at least footer_lines, but expands if shortcuts
// need more rows at the current width.
let footer_width = inner.width.saturating_sub(sizing.h_pad * 2);
let needed_footer = shortcuts_rows_needed(config.shortcuts, footer_width);
let footer_lines = sizing.footer_lines.max(needed_footer);
let content_top = inner.y + effective_v_pad + tab_bar_height + tab_divider_height;
let content_height = inner
.height
.saturating_sub(effective_v_pad + tab_bar_height + tab_divider_height + footer_lines);
let content_area = Rect {
x: inner.x + sizing.h_pad,
y: content_top,
width: inner.width.saturating_sub(sizing.h_pad * 2),
height: content_height,
};
// Footer: spans all footer rows at the bottom. Shortcuts render
// bottom-aligned within this area (single row stays on the last
// line, additional rows wrap upward).
let footer_height = footer_lines.min(inner.height);
let footer_y = inner.y + inner.height.saturating_sub(footer_height);
let footer_area = Rect {
x: inner.x + sizing.h_pad,
y: footer_y,
width: footer_width,
height: footer_height,
};
// Render footer shortcuts.
state.shortcut_hits = render_modal_shortcuts(
buf,
footer_area,
config.shortcuts,
state.hovered_shortcut,
theme,
);
Some(ModalContentArea {
content: content_area,
footer: footer_area,
inner_x: inner.x,
inner_width: inner.width,
})
}
/// Render the `[✗]` close button on the top-right border of the modal.
pub(crate) fn render_close_button(
buf: &mut Buffer,
modal_area: Rect,
state: &mut ModalWindowState,
theme: &Theme,
) {
// Padding spaces around a bracketed close mark (`✗`, or `x` on legacy
// ConHost where the Dingbats glyph renders as tofu). Each cell is one
// column, so the 5-cell width is platform-independent.
let close_cells: [&str; 5] = [" ", "[", crate::glyphs::ballot_x(), "]", " "];
let close_width = close_cells.len() as u16;
let close_rect = Rect {
x: modal_area.x + modal_area.width.saturating_sub(close_width + 2),
y: modal_area.y,
width: close_width,
height: 1,
};
for (offset, sym) in close_cells.iter().enumerate() {
let col = close_rect.x + offset as u16;
if let Some(cell) = buf.cell_mut((col, close_rect.y)) {
cell.set_symbol(sym);
// On hover, brighten the bracketed mark so it reads as a click
// target (the padding spaces stay unstyled).
if !sym.trim().is_empty() && state.close_hovered {
let mut s = cell.style();
s.fg = Some(theme.text_primary);
s = s.add_modifier(Modifier::BOLD);
cell.set_style(s);
}
}
}
state.close_button_rect = Some(close_rect);
}
/// Render a tab bar below the top border. Returns the number of rows used.
///
/// Tabs wrap onto additional rows when they don't fit on a single line.
fn render_tab_bar(
buf: &mut Buffer,
inner: Rect,
state: &mut ModalWindowState,
tabs: &[&str],
theme: &Theme,
) -> u16 {
state.tab_count = tabs.len();
state.tab_rects = vec![None; tabs.len()];
let left_margin = 2u16;
let available = (inner.width as usize).saturating_sub(left_margin as usize);
if available == 0 {
return 0;
}
let separator = " ";
let sep_w = separator.width();
// Break tabs into rows greedily.
let mut rows: Vec<Vec<usize>> = vec![vec![]];
let mut cur_row_w: usize = 0;
for (i, label) in tabs.iter().enumerate() {
let label_w = label.width();
let needed = if rows.last().unwrap().is_empty() {
label_w
} else {
cur_row_w + sep_w + label_w
};
if needed > available && !rows.last().unwrap().is_empty() {
rows.push(vec![i]);
cur_row_w = label_w;
} else {
rows.last_mut().unwrap().push(i);
cur_row_w = needed;
}
}
let right_edge = inner.x + inner.width;
let num_rows = rows.len() as u16;
for (row_idx, row_indices) in rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
if y >= inner.y + inner.height {
// Mark remaining tabs as not rendered.
for &tab_idx in row_indices {
state.tab_rects[tab_idx] = None;
}
break;
}
let mut cur_x = inner.x + left_margin;
for (local_idx, &tab_idx) in row_indices.iter().enumerate() {
let label = tabs[tab_idx];
let remaining = right_edge.saturating_sub(cur_x) as usize;
if remaining == 0 {
state.tab_rects[tab_idx] = None;
continue;
}
let is_active = tab_idx == state.active_tab;
// Minimal renders every element background-free, so the focused
// active tab uses accent text instead of a highlight band.
let is_embedded = embedded();
let display = &label[..byte_offset_at_width(label, remaining)];
let label_w = display.width();
// Inactive tab labels use `theme.gray` (secondary-text tier),
// not `theme.gray_dim`. At ANSI16 `gray_dim` collapses to the
// softer slot (silver on White) which leaves text at ~1.2:1
// contrast — fine for the modal frame's one-cell border line
// but unreadable as text glyphs on kigiday.
let style = if is_active {
if state.tabs_focused && !is_embedded {
Style::default()
.fg(theme.text_primary)
.bg(theme.bg_visual)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD)
}
} else {
Style::default().fg(theme.gray)
};
buf.set_string(cur_x, y, display, style);
state.tab_rects[tab_idx] = Some(Rect {
x: cur_x,
y,
width: label_w as u16,
height: 1,
});
cur_x += label_w as u16;
if local_idx + 1 < row_indices.len() {
let sep_remaining = right_edge.saturating_sub(cur_x) as usize;
if sep_remaining > 0 {
let sep_display = &separator[..byte_offset_at_width(separator, sep_remaining)];
buf.set_string(cur_x, y, sep_display, Style::default().fg(theme.gray));
cur_x += sep_display.width() as u16;
}
}
}
}
num_rows
}
/// Predict footer shortcut row count at the given area + sizing.
/// Returns 0 when the modal is too small to render.
pub(crate) fn predict_shortcut_rows(
area: Rect,
sizing: &ModalSizing,
shortcuts: &[Shortcut<'_>],
) -> u16 {
let (modal_width, modal_height) = compute_modal_dims(area, sizing);
if modal_width < 20 || modal_height < 6 {
return 0;
}
// Border on each side eats 1 column → inner_width = modal_width - 2.
let inner_width = modal_width.saturating_sub(2);
let footer_width = inner_width.saturating_sub(sizing.h_pad * 2);
shortcuts_rows_needed(shortcuts, footer_width)
}
/// Compute the modal's `(width, height)`. Shared by `render_modal_window`
/// and `predict_shortcut_rows`.
fn compute_modal_dims(area: Rect, sizing: &ModalSizing) -> (u16, u16) {
let max_width = area.width.saturating_sub(4).min(sizing.max_width);
let preferred_width = (area.width as f32 * sizing.width_pct) as u16;
// Clamp to the buffer last: the min_width floor can exceed a narrow terminal, sending Clear/Block out of bounds.
let modal_width = preferred_width
.min(max_width)
.max(sizing.min_width)
.min(area.width);
let modal_height = area.height.saturating_sub(sizing.v_margin * 2);
(modal_width, modal_height)
}
/// Compute how many rows a set of shortcuts needs at a given width.
///
/// Uses the same greedy line-breaking logic as [`render_modal_shortcuts`]
/// so the layout prediction matches the actual render.
pub(crate) fn shortcuts_rows_needed(shortcuts: &[Shortcut<'_>], width: u16) -> u16 {
if width == 0 || shortcuts.is_empty() {
return 0;
}
let avail = width as usize;
let sep_w = " | ".width(); // 5
let mut rows = 1u16;
let mut cur_row_w: usize = 0;
for shortcut in shortcuts {
let label_w = shortcut.label.width();
let needed = if cur_row_w == 0 {
label_w
} else {
cur_row_w + sep_w + label_w
};
if needed > avail && cur_row_w > 0 {
rows += 1;
cur_row_w = label_w;
} else {
cur_row_w = needed;
}
}
rows
}
/// Split a shortcut label into its `(key, label)` parts at the first
/// ASCII space character.
///
/// Returns `(full_label, "")` for single-token labels (no space).
/// The leading space stays attached to the label half so the gap
/// between key and label is rendered with the label style — this keeps
/// the spacing visually contiguous when the label has a hover background.
///
/// We intentionally split on ASCII `' '` rather than `char::is_whitespace`
/// so that interpolated labels containing tabs, NBSP (`\u{00A0}`), or
/// other Unicode whitespace don't produce a surprising split point that
/// would style part of the label text as the bold "key".
fn split_shortcut_label(label: &str) -> (&str, &str) {
match label.find(' ') {
Some(i) => label.split_at(i),
None => (label, ""),
}
}
/// Render inline centered footer shortcuts with multi-row wrapping.
///
/// Returns hit-test areas for all rendered shortcuts (both clickable
/// and hint-only). When shortcuts don't fit on a single row, they
/// wrap onto additional rows within the `area` height. Rows are
/// placed bottom-aligned so a single row sits on the last line.
///
/// Each shortcut label is split at the first whitespace: the leading
/// token (the "key", e.g. `Esc`, `Enter`, `↑/↓`) is rendered bold in
/// `text_secondary`, and the trailing label text (e.g. `cancel`,
/// `import 3`) is rendered in `gray` (the codebase's tertiary text
/// shade — see `shortcuts_bar.rs` for the same hierarchy).
/// Single-token labels render entirely as the key. Hovered shortcuts
/// get a `bg_highlight` underlay across the whole label.
pub fn render_modal_shortcuts(
buf: &mut Buffer,
area: Rect,
shortcuts: &[Shortcut<'_>],
hovered: Option<usize>,
theme: &Theme,
) -> Vec<ShortcutHitArea> {
if area.width == 0 || area.height == 0 || shortcuts.is_empty() {
return Vec::new();
}
let avail = area.width as usize;
let separator = " | ";
let sep_w = separator.width();
// Break shortcuts into rows greedily: each row holds as many
// shortcuts as fit within the available width.
let mut rows: Vec<Vec<usize>> = vec![vec![]];
let mut cur_row_w: usize = 0;
for (idx, shortcut) in shortcuts.iter().enumerate() {
let label_w = shortcut.label.width();
let needed = if rows.last().unwrap().is_empty() {
label_w
} else {
cur_row_w + sep_w + label_w
};
if needed > avail && !rows.last().unwrap().is_empty() {
rows.push(vec![idx]);
cur_row_w = label_w;
} else {
rows.last_mut().unwrap().push(idx);
cur_row_w = needed;
}
}
// Limit to available height.
rows.truncate(area.height as usize);
// Render rows bottom-aligned: last row at the bottom of the area.
let num_rows = rows.len() as u16;
let mut hits = Vec::new();
let row_end = area.x + area.width;
for (row_idx, row_indices) in rows.iter().enumerate() {
let y = area.y + area.height - num_rows + row_idx as u16;
// Compute this row's total width for centering.
let row_total: usize = row_indices
.iter()
.map(|&i| shortcuts[i].label.width())
.sum::<usize>()
+ sep_w * row_indices.len().saturating_sub(1);
let start_x = if row_total > avail {
area.x
} else {
area.x + (area.width.saturating_sub(row_total as u16)) / 2
};
let mut cur_x = start_x;
for (local_idx, &shortcut_idx) in row_indices.iter().enumerate() {
let shortcut = &shortcuts[shortcut_idx];
let remaining = row_end.saturating_sub(cur_x) as usize;
if remaining == 0 {
break;
}
let display = &shortcut.label[..byte_offset_at_width(shortcut.label, remaining)];
let visible_w = display.width() as u16;
let is_hovered = hovered == Some(shortcut_idx);
// Underlay: fill cell bg with bg_highlight on hover.
if is_hovered {
let hover_bg = Style::default().bg(theme.bg_highlight);
for x in cur_x..cur_x + visible_w {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(hover_bg);
}
}
}
// Split the label at the first whitespace: the leading token
// is the "key" (rendered bold in text_secondary) and the rest
// is the descriptive label (rendered in gray, the tertiary
// shade). Single-token labels render entirely as the key.
let (key_part, label_part) = split_shortcut_label(display);
let mut key_style = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
if is_hovered {
key_style = key_style.bg(theme.bg_highlight);
}
buf.set_string(cur_x, y, key_part, key_style);
if !label_part.is_empty() {
let key_w = key_part.width() as u16;
let mut label_style = Style::default().fg(theme.gray);
if is_hovered {
label_style = label_style.bg(theme.bg_highlight);
}
buf.set_string(cur_x + key_w, y, label_part, label_style);
}
hits.push(ShortcutHitArea {
rect: Rect {
x: cur_x,
y,
width: visible_w,
height: 1,
},
id: shortcut.id,
shortcuts_idx: shortcut_idx,
clickable: shortcut.clickable,
});
cur_x += visible_w;
// Separator after every shortcut except the last in this row.
if local_idx + 1 < row_indices.len() {
let sep_remaining = row_end.saturating_sub(cur_x) as usize;
if sep_remaining == 0 {
break;
}
let sep_display = &separator[..byte_offset_at_width(separator, sep_remaining)];
buf.set_string(cur_x, y, sep_display, Style::default().fg(theme.gray_dim));
cur_x += sep_display.width() as u16;
}
}
}
hits
}
// ---------------------------------------------------------------------------
// Centered tip footer (Settings / How-to Guides)
// ---------------------------------------------------------------------------
/// First candidate that fits `width`, else truncate the last.
pub(crate) fn fit_tip_line<'a>(candidates: &[&'a str], width: usize) -> std::borrow::Cow<'a, str> {
if width == 0 {
return std::borrow::Cow::Borrowed("");
}
for &c in candidates {
if c.width() <= width {
return std::borrow::Cow::Borrowed(c);
}
}
match candidates.last().copied().filter(|s| !s.is_empty()) {
Some(last) => std::borrow::Cow::Owned(crate::render::line_utils::truncate_str(last, width)),
None => std::borrow::Cow::Borrowed(""),
}
}
pub(crate) fn render_centered_tip_footer(buf: &mut Buffer, area: Rect, theme: &Theme, text: &str) {
if area.width == 0 || area.height == 0 {
return;
}
let style = Style::default()
.fg(theme.gray_dim)
.bg(theme.bg_base)
.add_modifier(Modifier::ITALIC);
buf.set_style(area, Style::default().bg(theme.bg_base));
let area_w = area.width as usize;
let rendered: std::borrow::Cow<'_, str> = if text.width() <= area_w {
std::borrow::Cow::Borrowed(text)
} else {
std::borrow::Cow::Owned(crate::render::line_utils::truncate_str(text, area_w))
};
let width = (rendered.width() as u16).min(area.width);
let start_x = area.x + (area.width.saturating_sub(width) / 2);
buf.set_span(
start_x,
area.y,
&Span::styled(rendered.as_ref(), style),
width,
);
}
/// No tip if height < 3; blank gap above tip when height >= 6.
pub(crate) fn split_content_for_tip_footer(content: Rect) -> (Rect, Option<Rect>) {
if content.height < 3 {
return (content, None);
}
let gap = u16::from(content.height >= 6);
let body = Rect {
height: content.height - 1 - gap,
..content
};
let tip = Rect {
y: content.y + content.height - 1,
height: 1,
..content
};
(body, Some(tip))
}
/// Reserve a blank chrome row between tip and shortcut hints.
pub(crate) fn footer_lines_with_tip_gap(
area: Rect,
sizing: &ModalSizing,
shortcuts: &[Shortcut<'_>],
) -> u16 {
predict_shortcut_rows(area, sizing, shortcuts)
.saturating_add(1)
.max(2)
}
// ---------------------------------------------------------------------------
// Fold indicator
// ---------------------------------------------------------------------------
/// Render a fold indicator glyph at position `(x, y)`.
///
/// Draws `▶ ` (collapsed) or `▼ ` (expanded) in `gray_dim` with optional
/// hover brightening (bold + `text_primary`). The style matches both the
/// import-claude modal's header rows and the picker's expandable rows.
///
/// - `collapsed`: which glyph to show.
/// - `hovered`: brighten the glyph for hover feedback.
/// - `bg`: optional background color (e.g. `bg_highlight` for focused rows).
///
/// Returns a [`Span`] containing the indicator and trailing space (2 columns
/// wide). The caller is responsible for placing it via `buf.set_span` or
/// including it in a [`Line`].
pub fn fold_indicator_span(
collapsed: bool,
hovered: bool,
bg: Option<ratatui::style::Color>,
theme: &Theme,
) -> Span<'static> {
let glyph = if collapsed {
format!("{} ", crate::glyphs::chevron())
} else {
format!("{} ", crate::glyphs::diamond_filled())
};
let fg = if hovered {
theme.text_primary
} else {
theme.gray_dim
};
let mods = if hovered {
Modifier::BOLD
} else {
Modifier::empty()
};
let mut style = Style::default().fg(fg).add_modifier(mods);
if let Some(c) = bg {
style = style.bg(c);
}
Span::styled(glyph, style)
}
/// Render a fold indicator directly into a buffer at `(x, y)`.
///
/// Convenience wrapper around [`fold_indicator_span`] that writes the
/// glyph into `buf` and returns the number of columns consumed (always 2).
pub fn render_fold_indicator(
buf: &mut Buffer,
x: u16,
y: u16,
collapsed: bool,
hovered: bool,
bg: Option<ratatui::style::Color>,
theme: &Theme,
) -> u16 {
let span = fold_indicator_span(collapsed, hovered, bg, theme);
let width = 2u16;
buf.set_span(x, y, &span, width);
width
}
// ---------------------------------------------------------------------------
// Input handling
// ---------------------------------------------------------------------------
/// Process a key event against the modal chrome.
///
/// Returns:
/// - `CloseRequested` for Esc.
/// - When tab bar is focused (`state.tabs_focused`): Left/Right/h/l return
/// `Unhandled` (picker/caller handles tab switching).
/// - When `config.fold_info` is `Some` and tabs not focused: Left/Right/h/l
/// return specific fold outcomes based on the focused entry's state.
/// - Otherwise `Unhandled` for Left/Right etc so the caller can handle.
/// - `Unhandled` for everything else.
pub fn handle_modal_key(
state: &mut ModalWindowState,
key: &KeyEvent,
config: &ModalWindowConfig<'_>,
) -> ModalWindowOutcome {
match key.code {
KeyCode::Esc => ModalWindowOutcome::CloseRequested,
KeyCode::Left | KeyCode::Char('h') => {
if state.tabs_focused {
ModalWindowOutcome::Unhandled
} else if let Some(ref fold) = config.fold_info {
if fold.collapsible && fold.expanded {
ModalWindowOutcome::CollapseGroup
} else if fold.has_details && fold.details_expanded {
ModalWindowOutcome::CollapseDetails
} else if let Some(parent) = fold.parent_index {
ModalWindowOutcome::JumpToParent(parent)
} else {
ModalWindowOutcome::Unhandled
}
} else {
ModalWindowOutcome::Unhandled
}
}
KeyCode::Right | KeyCode::Char('l') => {
if state.tabs_focused {
ModalWindowOutcome::Unhandled
} else if let Some(ref fold) = config.fold_info {
if fold.collapsible && !fold.expanded {
ModalWindowOutcome::ExpandGroup
} else if fold.has_details && !fold.details_expanded {
ModalWindowOutcome::ExpandDetails
} else {
ModalWindowOutcome::Unhandled
}
} else {
ModalWindowOutcome::Unhandled
}
}
_ => ModalWindowOutcome::Unhandled,
}
}
/// Process a mouse event against the modal chrome. Returns `Unhandled` if
/// the event should be passed to the caller's content handler.
pub fn handle_modal_mouse(
state: &mut ModalWindowState,
kind: MouseEventKind,
column: u16,
row: u16,
) -> ModalWindowOutcome {
let in_rect = |r: Rect| -> bool {
column >= r.x && column < r.x + r.width && row >= r.y && row < r.y + r.height
};
let on_close = state.close_button_rect.is_some_and(&in_rect);
// Check if on a tab.
let on_tab: Option<usize> = state
.tab_rects
.iter()
.enumerate()
.find_map(|(i, r)| r.filter(|r| in_rect(*r)).map(|_| i));
// Check if on a clickable shortcut (for click dispatch).
let on_shortcut: Option<usize> = state
.shortcut_hits
.iter()
.find(|hit| hit.clickable && in_rect(hit.rect))
.map(|hit| hit.id);
// Check which shortcut index is hovered (for hover state tracking).
// Uses `shortcuts_idx` (the index into the full shortcuts slice) so
// the hover index matches the render loop's `idx` in
// `render_modal_shortcuts`.
let shortcut_hover_idx: Option<usize> = state
.shortcut_hits
.iter()
.find(|hit| in_rect(hit.rect))
.map(|hit| hit.shortcuts_idx);
match kind {
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
if on_close {
return ModalWindowOutcome::CloseRequested;
}
if let Some(tab_idx) = on_tab {
if tab_idx != state.active_tab {
state.active_tab = tab_idx;
return ModalWindowOutcome::TabChanged(tab_idx);
}
return ModalWindowOutcome::Handled;
}
if let Some(id) = on_shortcut {
return ModalWindowOutcome::ShortcutActivated(id);
}
// Click outside the popup area = close.
if let Some(popup) = state.popup_area
&& !in_rect(popup)
{
return ModalWindowOutcome::CloseRequested;
}
ModalWindowOutcome::Unhandled
}
MouseEventKind::Moved => {
let mut changed = false;
if state.close_hovered != on_close {
state.close_hovered = on_close;
changed = true;
}
if state.hovered_shortcut != shortcut_hover_idx {
state.hovered_shortcut = shortcut_hover_idx;
changed = true;
}
if on_close || shortcut_hover_idx.is_some() || on_tab.is_some() {
// Cursor is on chrome -- consume the event so the caller
// doesn't interpret it as content interaction.
return ModalWindowOutcome::Handled;
}
if changed {
// Chrome hover state changed (e.g. cursor left a shortcut)
// but is now in the content area. Return Handled to ensure
// a redraw clears the stale highlight. The content hover
// will update on the next mouse move.
return ModalWindowOutcome::Handled;
}
ModalWindowOutcome::Unhandled
}
_ => ModalWindowOutcome::Unhandled,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
fn dummy_config<'a>() -> ModalWindowConfig<'a> {
ModalWindowConfig {
title: "Test",
tabs: None,
shortcuts: &[],
sizing: ModalSizing::default(),
fold_info: None,
}
}
/// The `i search` footer hint appears only in vim NAV mode (vim-mode on,
/// search inactive) — not when typing, not when vim-mode is off.
#[test]
fn vim_nav_search_hint_only_in_vim_nav_mode() {
crate::appearance::cache::set_vim_mode(true);
let mut nav: Vec<Shortcut<'static>> = vec![];
push_vim_nav_search_hint(&mut nav, false);
assert!(
nav.iter().any(|s| s.label == "i search"),
"vim + nav must surface the i hint"
);
let mut searching: Vec<Shortcut<'static>> = vec![];
push_vim_nav_search_hint(&mut searching, true);
assert!(searching.is_empty(), "no i hint while already searching");
crate::appearance::cache::set_vim_mode(false);
let mut off: Vec<Shortcut<'static>> = vec![];
push_vim_nav_search_hint(&mut off, false);
assert!(off.is_empty(), "no i hint when vim-mode is off");
}
/// Embedded mode (minimal) fills the given area with no centered popup box;
/// the default (full TUI) renders a smaller, centered popup.
#[test]
#[serial_test::serial]
fn embedded_fills_area_without_centering() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
let area = Rect::new(0, 0, 80, 24);
let config = dummy_config();
// Default (full TUI): a centered popup strictly smaller than the area.
set_embedded(false);
let mut buf = Buffer::empty(area);
let mut state = ModalWindowState::new();
let centered =
render_modal_window(&mut buf, area, &mut state, &config, &theme).expect("renders");
assert_ne!(
state.popup_area,
Some(area),
"full TUI centers a smaller popup"
);
// Embedded (minimal): fills the full area, borderless — so content is
// wider than the centered popup's content.
set_embedded(true);
let mut buf = Buffer::empty(area);
let mut state = ModalWindowState::new();
let embedded =
render_modal_window(&mut buf, area, &mut state, &config, &theme).expect("renders");
assert_eq!(
state.popup_area,
Some(area),
"embedded fills the whole area (no centered box)"
);
assert!(
embedded.content.width > centered.content.width,
"embedded content ({}) spans wider than the centered popup ({})",
embedded.content.width,
centered.content.width
);
// Reset the process-global flag for other tests.
set_embedded(false);
}
#[test]
fn centered_tip_footer_centers_and_clips() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = Theme::current();
const TEXT: &str = "Tip · Ask Kigi about the docs";
let row_text = |width: u16| {
let area = Rect {
x: 0,
y: 0,
width,
height: 1,
};
let mut buf = Buffer::empty(area);
render_centered_tip_footer(&mut buf, area, &theme, TEXT);
(0..width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect::<String>()
};
let wide = row_text(80);
let start = wide.find("Tip").expect("Tip");
let trailing = wide.chars().rev().take_while(|c| *c == ' ').count();
assert!(start.abs_diff(trailing) <= 1, "not centered: {wide:?}");
assert!(wide.contains("Ask Kigi about the docs"));
let tiny = row_text(10);
assert!(tiny.contains("Tip"));
assert!(tiny.trim_end().chars().count() <= 10);
}
#[test]
fn split_content_for_tip_footer_thresholds() {
let (body, tip) = split_content_for_tip_footer(Rect {
x: 2,
y: 3,
width: 40,
height: 8,
});
assert_eq!(body.height, 6);
assert_eq!(tip.expect("tip").y, 3 + 8 - 1);
let (body, tip) = split_content_for_tip_footer(Rect {
x: 0,
y: 0,
width: 20,
height: 4,
});
assert_eq!(body.height, 3);
assert!(tip.is_some());
let tiny = Rect {
x: 0,
y: 0,
width: 20,
height: 2,
};
let (body, tip) = split_content_for_tip_footer(tiny);
assert!(tip.is_none());
assert_eq!(body, tiny);
}
#[test]
fn fit_tip_line_picks_first_fit_then_truncates() {
assert_eq!(fit_tip_line(&["abcdef", "xy"], 10).as_ref(), "abcdef");
assert_eq!(fit_tip_line(&["abcdef", "xy"], 4).as_ref(), "xy");
assert!(fit_tip_line(&["abcdef", "xy"], 1).as_ref().width() <= 1);
}
// -- ModalSizing::with_compact tests --
#[test]
fn modal_sizing_with_compact_reduces_margins_aggressively() {
let base = ModalSizing {
width_pct: 0.8,
max_width: 120,
min_width: 50,
v_margin: 5,
h_pad: 3,
v_pad: 2,
footer_lines: 2,
};
// Default (compact=false) keeps original values
assert_eq!(base.v_margin, 5);
assert_eq!(base.h_pad, 3);
assert_eq!(base.v_pad, 2);
let compact = base.with_compact(true);
assert_eq!(
compact.v_margin, 0,
"v_margin must be 0 in compact mode for max height"
);
assert_eq!(
compact.h_pad, 1,
"h_pad clamped to MIN 1 for accent/selection border"
);
assert_eq!(
compact.v_pad, 0,
"v_pad must be 0 so search bar / first row sits tight"
);
// Other fields unchanged
assert_eq!(compact.width_pct, 0.8);
assert_eq!(compact.max_width, 120);
// Calling with false is a no-op
let unchanged = base.with_compact(false);
assert_eq!(unchanged.v_margin, 5);
assert_eq!(unchanged.h_pad, 3);
}
// -- compute_modal_dims --
#[test]
fn modal_width_never_exceeds_narrow_terminal() {
// Regression: the min_width floor used to re-inflate the modal past a narrow buffer.
for sizing in [ModalSizing::medium(), ModalSizing::large()] {
for width in 0..=70 {
let (w, _) = compute_modal_dims(Rect::new(0, 0, width, 85), &sizing);
assert!(w <= width, "modal width {w} overflows {width}-col terminal");
}
}
}
// -- ModalWindowState construction --
#[test]
fn new_defaults() {
let s = ModalWindowState::new();
assert!(!s.close_hovered);
assert_eq!(s.close_button_rect, None);
assert_eq!(s.popup_area, None);
assert_eq!(s.active_tab, 0);
assert_eq!(s.tab_count, 0);
assert!(s.tab_rects.is_empty());
assert!(s.shortcut_hits.is_empty());
assert_eq!(s.hovered_shortcut, None);
}
#[test]
fn with_tabs_initialises_rects() {
let s = ModalWindowState::with_tabs(3);
assert_eq!(s.tab_count, 3);
assert_eq!(s.tab_rects.len(), 3);
assert!(s.tab_rects.iter().all(|r| r.is_none()));
}
#[test]
fn default_matches_new() {
let a = ModalWindowState::new();
let b = ModalWindowState::default();
assert_eq!(a.tab_count, b.tab_count);
assert_eq!(a.close_hovered, b.close_hovered);
}
// -- handle_modal_key --
#[test]
fn key_esc_returns_close_requested() {
let mut state = ModalWindowState::new();
let config = dummy_config();
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &esc, &config),
ModalWindowOutcome::CloseRequested
);
}
#[test]
fn key_other_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = dummy_config();
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &j, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn key_left_without_fold_info_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = dummy_config();
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn key_h_without_fold_info_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = dummy_config();
let h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &h, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn key_right_without_fold_info_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = dummy_config();
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn key_l_without_fold_info_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = dummy_config();
let l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &l, &config),
ModalWindowOutcome::Unhandled
);
}
// -- handle_modal_key with FoldInfo --
fn config_with_fold<'a>(fold_info: FoldInfo) -> ModalWindowConfig<'a> {
ModalWindowConfig {
title: "Test",
tabs: None,
shortcuts: &[],
sizing: ModalSizing::default(),
fold_info: Some(fold_info),
}
}
#[test]
fn left_on_expanded_collapsible_returns_collapse_group() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: true,
has_details: false,
details_expanded: false,
parent_index: None,
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::CollapseGroup
);
}
#[test]
fn right_on_collapsed_collapsible_returns_expand_group() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: false,
has_details: false,
details_expanded: false,
parent_index: None,
});
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::ExpandGroup
);
}
#[test]
fn left_on_collapsed_collapsible_with_parent_returns_jump() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: false,
has_details: false,
details_expanded: false,
parent_index: Some(3),
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::JumpToParent(3)
);
}
#[test]
fn left_on_collapsed_collapsible_without_parent_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: false,
has_details: false,
details_expanded: false,
parent_index: None,
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn left_on_expanded_details_returns_collapse_details() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: false,
expanded: false,
has_details: true,
details_expanded: true,
parent_index: Some(0),
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::CollapseDetails
);
}
#[test]
fn right_on_collapsed_details_returns_expand_details() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: false,
expanded: false,
has_details: true,
details_expanded: false,
parent_index: Some(0),
});
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::ExpandDetails
);
}
#[test]
fn left_on_leaf_without_details_returns_jump_to_parent() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: false,
expanded: false,
has_details: false,
details_expanded: false,
parent_index: Some(5),
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::JumpToParent(5)
);
}
#[test]
fn right_on_expanded_collapsible_returns_unhandled() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: true,
has_details: false,
details_expanded: false,
parent_index: None,
});
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn h_key_uses_fold_info_same_as_left() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: true,
has_details: false,
details_expanded: false,
parent_index: None,
});
let h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &h, &config),
ModalWindowOutcome::CollapseGroup
);
}
#[test]
fn l_key_uses_fold_info_same_as_right() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: false,
has_details: false,
details_expanded: false,
parent_index: None,
});
let l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &l, &config),
ModalWindowOutcome::ExpandGroup
);
}
// -- FoldInfo precedence & edge cases --
#[test]
fn left_collapse_group_wins_over_collapse_details() {
// When both collapsible+expanded AND has_details+details_expanded
// are true, CollapseGroup takes priority (group collapse first).
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: true,
has_details: true,
details_expanded: true,
parent_index: Some(0),
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::CollapseGroup
);
}
#[test]
fn right_expand_group_wins_over_expand_details() {
// When both collapsible+collapsed AND has_details+!details_expanded,
// ExpandGroup takes priority.
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: false,
has_details: true,
details_expanded: false,
parent_index: Some(0),
});
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::ExpandGroup
);
}
#[test]
fn right_on_expanded_collapsible_with_unexpanded_details_returns_expand_details() {
// Expanded group header still has unexpanded detail fields:
// Right falls through to ExpandDetails.
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: true,
has_details: true,
details_expanded: false,
parent_index: None,
});
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::ExpandDetails
);
}
#[test]
fn left_on_bare_leaf_no_parent_returns_unhandled() {
// Leaf with no details and no parent: nothing to do.
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: false,
expanded: false,
has_details: false,
details_expanded: false,
parent_index: None,
});
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &left, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn right_on_fully_expanded_details_returns_unhandled() {
// Details already expanded, not collapsible: nothing to expand.
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: false,
expanded: false,
has_details: true,
details_expanded: true,
parent_index: Some(0),
});
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &right, &config),
ModalWindowOutcome::Unhandled
);
}
#[test]
fn esc_with_fold_info_returns_close_requested() {
let mut state = ModalWindowState::new();
let config = config_with_fold(FoldInfo {
collapsible: true,
expanded: true,
has_details: false,
details_expanded: false,
parent_index: None,
});
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(
handle_modal_key(&mut state, &esc, &config),
ModalWindowOutcome::CloseRequested
);
}
// -- handle_modal_mouse --
#[test]
fn click_on_close_button_returns_close_requested() {
let mut state = ModalWindowState::new();
state.close_button_rect = Some(Rect {
x: 90,
y: 5,
width: 5,
height: 1,
});
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 100,
height: 30,
});
let outcome =
handle_modal_mouse(&mut state, MouseEventKind::Down(MouseButton::Left), 92, 5);
assert_eq!(outcome, ModalWindowOutcome::CloseRequested);
}
#[test]
fn click_outside_popup_returns_close_requested() {
let mut state = ModalWindowState::new();
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 80,
height: 30,
});
let outcome = handle_modal_mouse(&mut state, MouseEventKind::Down(MouseButton::Left), 5, 5);
assert_eq!(outcome, ModalWindowOutcome::CloseRequested);
}
#[test]
fn click_inside_popup_no_chrome_returns_unhandled() {
let mut state = ModalWindowState::new();
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 80,
height: 30,
});
let outcome =
handle_modal_mouse(&mut state, MouseEventKind::Down(MouseButton::Left), 50, 20);
assert_eq!(outcome, ModalWindowOutcome::Unhandled);
}
#[test]
fn click_on_shortcut_returns_shortcut_activated() {
let mut state = ModalWindowState::new();
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 80,
height: 30,
});
state.shortcut_hits = vec![ShortcutHitArea {
rect: Rect {
x: 30,
y: 32,
width: 10,
height: 1,
},
id: 42,
shortcuts_idx: 3,
clickable: true,
}];
let outcome =
handle_modal_mouse(&mut state, MouseEventKind::Down(MouseButton::Left), 35, 32);
assert_eq!(outcome, ModalWindowOutcome::ShortcutActivated(42));
}
#[test]
fn hover_over_close_sets_hovered_and_returns_handled() {
let mut state = ModalWindowState::new();
state.close_button_rect = Some(Rect {
x: 90,
y: 5,
width: 5,
height: 1,
});
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 100,
height: 30,
});
assert!(!state.close_hovered);
let outcome = handle_modal_mouse(&mut state, MouseEventKind::Moved, 92, 5);
assert_eq!(outcome, ModalWindowOutcome::Handled);
assert!(state.close_hovered);
}
#[test]
fn hover_leaving_shortcut_returns_handled_for_redraw() {
let mut state = ModalWindowState::new();
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 80,
height: 30,
});
state.shortcut_hits = vec![ShortcutHitArea {
rect: Rect {
x: 30,
y: 32,
width: 10,
height: 1,
},
id: 1,
shortcuts_idx: 4,
clickable: true,
}];
state.hovered_shortcut = Some(4);
let outcome = handle_modal_mouse(&mut state, MouseEventKind::Moved, 50, 20);
assert_eq!(outcome, ModalWindowOutcome::Handled);
assert_eq!(state.hovered_shortcut, None);
}
#[test]
fn hover_no_change_returns_unhandled() {
let mut state = ModalWindowState::new();
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 80,
height: 30,
});
let outcome = handle_modal_mouse(&mut state, MouseEventKind::Moved, 50, 20);
assert_eq!(outcome, ModalWindowOutcome::Unhandled);
}
#[test]
fn hover_shortcut_uses_shortcuts_idx_not_position_in_hits() {
// Regression: when non-clickable hint shortcuts precede clickable
// ones, the hover index must match the full shortcuts-array index
// (shortcuts_idx), not the position within shortcut_hits. Otherwise
// the renderer's `hovered == Some(idx)` comparison never matches
// and hover highlights never appear.
let mut state = ModalWindowState::new();
state.popup_area = Some(Rect {
x: 10,
y: 5,
width: 80,
height: 30,
});
// Simulate 3 non-clickable hints before this clickable shortcut.
// The clickable shortcut is at shortcuts_idx=3 in the full array
// but at position 0 in shortcut_hits.
state.shortcut_hits = vec![ShortcutHitArea {
rect: Rect {
x: 40,
y: 32,
width: 8,
height: 1,
},
id: 99,
shortcuts_idx: 3,
clickable: true,
}];
let outcome = handle_modal_mouse(&mut state, MouseEventKind::Moved, 44, 32);
assert_eq!(outcome, ModalWindowOutcome::Handled);
// hovered_shortcut must be 3 (shortcuts_idx), not 0 (position in hits)
assert_eq!(state.hovered_shortcut, Some(3));
}
// -- ModalSizing presets --
#[test]
fn modal_sizing_medium_has_expected_values() {
let m = ModalSizing::medium();
assert_eq!(m.width_pct, 0.60);
assert_eq!(m.max_width, 120);
assert_eq!(m.min_width, 44);
assert_eq!(m.v_margin, 4);
assert_eq!(m.h_pad, 2);
assert_eq!(m.v_pad, 1);
assert_eq!(m.footer_lines, 2);
}
#[test]
fn modal_sizing_large_matches_default() {
assert_eq!(ModalSizing::large(), ModalSizing::default());
}
// -- split_shortcut_label --
#[test]
fn split_shortcut_label_basic_ascii() {
assert_eq!(split_shortcut_label("Esc cancel"), ("Esc", " cancel"));
assert_eq!(split_shortcut_label("Enter select"), ("Enter", " select"));
}
#[test]
fn split_shortcut_label_multi_word_label() {
// Only the first whitespace splits — remaining spaces stay in label.
assert_eq!(
split_shortcut_label("Enter import 3"),
("Enter", " import 3")
);
assert_eq!(
split_shortcut_label("x confirm delete"),
("x", " confirm delete")
);
}
#[test]
fn split_shortcut_label_unicode_arrows() {
// Arrow characters are multi-byte but split_at uses the byte
// offset returned by char_indices, so the slice is valid UTF-8.
let (k, l) = split_shortcut_label("\u{2191}/\u{2193} nav");
assert_eq!(k, "\u{2191}/\u{2193}");
assert_eq!(l, " nav");
let (k, l) = split_shortcut_label("\u{2191}\u{2193} nav");
assert_eq!(k, "\u{2191}\u{2193}");
assert_eq!(l, " nav");
}
#[test]
fn split_shortcut_label_no_whitespace_is_key_only() {
assert_eq!(split_shortcut_label("Esc"), ("Esc", ""));
assert_eq!(split_shortcut_label(""), ("", ""));
}
#[test]
fn split_shortcut_label_only_splits_on_ascii_space() {
// Tabs, NBSP, and other non-space whitespace must NOT split — only
// ASCII ' ' is a separator. This keeps interpolated labels safe
// from accidental key/label boundary surprises.
assert_eq!(split_shortcut_label("Esc\tcancel"), ("Esc\tcancel", ""));
assert_eq!(
split_shortcut_label("Esc\u{00A0}cancel"),
("Esc\u{00A0}cancel", "")
);
// ASCII space takes precedence even when other whitespace appears later.
assert_eq!(
split_shortcut_label("Esc cancel\twith\ttabs"),
("Esc", " cancel\twith\ttabs")
);
}
}