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.
1969 lines
68 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|