Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/settings_modal.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

12480 lines
476 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Settings modal — opens via F2, `/settings`, command palette, and
//! shortcuts-help.
//!
//! ## State machine
//!
//! `SettingsModalState` carries a `UiConfig` snapshot plus a mode machine:
//!
//! - `Browse` — j/k navigates rows; Space toggles Bool; Enter opens
//! a chooser/editor for Enum/String/Int.
//! - `FilterFocused` — `/` enters filter mode; `invalidate_filter`
//! recomputes `filtered_cache` on every mutation.
//! - `PickingEnum { ... }` — enum chooser sub-pane.
//! - `EditingValue { ... }` — inline string/int editor.
//!
//! ## Keyboard ↔ mouse parity
//!
//! Every keyboard interaction has a mouse equivalent via `handle_mouse`.
//!
//! ## Close-key interception
//!
//! F2/Ctrl+,/Cmd+, are intercepted before mode-specific routing.
//! Esc-in-Browse is handled by the `ModalWindow` chrome (so
//! `is_close_key` does NOT match Esc); Esc-in-FilterFocused exits
//! filter mode without closing.
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::app::actions::Action;
use crate::render::line_utils::truncate_str;
use crate::settings::{
EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind,
SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for,
dynamic_enum_choices,
};
use crate::theme::Theme;
use crate::views::modal_window::{
self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
};
use kigi_shell::agent::config::UiConfig;
// ---------------------------------------------------------------------------
// Public constants
// ---------------------------------------------------------------------------
/// Public display title of the modal — also used by
/// `views/modal.rs::ActiveModal::message` so renames stay in one place.
pub const MODAL_TITLE: &str = "Settings";
/// Width of the `"─ "` leading decoration before the title in the
/// modal's top border. Used to compute the breadcrumb hit-rect x offset.
const TITLE_LEADING_DECORATION_W: u16 = 2; // `─ `: 1 cell box-drawing + 1 cell space.
// Descriptions are now expand-on-demand via Right/Left arrows;
// see `render_expanded_description`.
/// Below this width the row list is skipped (chrome renders empty).
const CONTENT_MIN_WIDTH: u16 = 10;
/// Default max width for the modal. Keeps the row list compact on wide terminals.
const STANDARD_MAX_WIDTH: u16 = 110;
/// Per-side margin when editing `max_thoughts_width` (modal widens
/// to `terminal_width - 2*margin` so the wrap preview is useful).
const MAX_THOUGHTS_WIDTH_WIDENED_MARGIN: u16 = 8;
/// Outcome of a key or mouse event. Separate from `InputOutcome`
/// because the modal doesn't own `agent.active_modal` — close is
/// the caller's responsibility.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum SettingsKeyOutcome {
/// Close the modal.
Close,
/// Forward to dispatch.
Action(Action),
/// Forward two actions in order (first must resolve before second).
/// Used by `d`-reset-in-picker to revert preview before opening
/// the reset-confirm overlay.
ActionPair(Action, Action),
/// Internal state mutation, no action.
Changed,
/// No-op.
Unchanged,
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// One row in the visible flat list — either a category header (non-
/// selectable) or a setting row (selectable, dispatchable).
#[derive(Debug, Clone)]
pub enum RowEntry {
Header { category: SettingCategory },
Setting { key: SettingKey, meta_index: usize },
}
/// Mode state for the modal.
#[derive(Debug, Clone)]
pub enum SettingsModalMode {
Browse,
/// `/` was pressed; chars filter the visible rows.
FilterFocused,
/// Enum chooser sub-pane. `supports_preview` is cached at open
/// time to avoid per-keystroke registry lookups.
PickingEnum {
key: SettingKey,
choices_idx: usize,
original_value: SettingValue,
supports_preview: bool,
},
/// Group sub-sheet: a list of the group's child Bool toggles. `child_idx`
/// is the focused child within the group. Space/Enter toggles in place
/// (the sheet stays open); Esc returns to Browse. Mirrors `PickingEnum`'s
/// open/render/commit flow but for independent toggles.
PickingGroup {
key: SettingKey,
child_idx: usize,
},
/// Inline string/int editor. `cursor_byte` is always on a char
/// boundary. `validation_error` shows live feedback; commit
/// re-validates before dispatching. No `original_value` — these
/// settings have no live preview, so Esc is a pure cancel.
EditingValue {
key: SettingKey,
buffer: String,
cursor_byte: usize,
validation_error: Option<String>,
},
}
/// Settings modal state. Boxed inside `ActiveModal::Settings` to
/// avoid clippy `large_enum_variant`.
pub struct SettingsModalState {
pub window: ModalWindowState,
pub registry: Arc<SettingsRegistry>,
/// `UiConfig` snapshot, refreshed by the dispatcher on mutations.
pub ui_snapshot: UiConfig,
pub pager_snapshot: PagerLocalSnapshot,
/// Computed row layout (headers + settings, in render order).
pub rows: Vec<RowEntry>,
/// Index into `rows` of the focused row.
pub selected: usize,
/// Vertical scroll offset (line-granular).
pub scroll_offset: usize,
pub mode: SettingsModalMode,
/// Filter query. Persists across FilterFocused→Browse on Enter; cleared by Esc.
pub query: String,
/// Byte offset of the editing cursor within `query`.
pub query_cursor: usize,
/// Row indices matching `query`, recomputed per mutation (not per frame).
filtered_cache: Vec<usize>,
// -- Mouse hit-test rects (populated by render) --
pub list_area: Rect,
/// Click-hit rect per row, parallel to `rows`.
pub row_rects: Vec<Rect>,
/// Click-hit rect for the value column on each row. Bool rows
/// toggle on click; Enum/String/Int rows open the sub-pane.
pub value_hit_rects: Vec<Rect>,
/// `(decrement_rect, increment_rect)` for the Int stepper's
/// ``/`` glyphs. Zero-sized when not in Int editing mode.
pub editor_adornment_rects: (Rect, Rect),
/// Click-hit rect per choice in `PickingEnum`. Each rect spans the
/// full height of a choice (including wrapped description lines).
pub picker_choice_rects: Vec<Rect>,
/// Hit-rect for the breadcrumb title in sub-pane modes
/// (`PickingEnum`/`EditingValue`). Clicking anywhere on
/// `Settings <label>` cancels back to Browse. `None` in
/// Browse/FilterFocused. Cleared on mode transitions.
pub settings_breadcrumb_rect: Option<Rect>,
/// Hover flag for the breadcrumb — adds underline affordance.
pub breadcrumb_hovered: bool,
/// Keys whose description is expanded (Right/l to expand, Left/h
/// to collapse). Multiple rows can be expanded simultaneously.
pub expanded_keys: std::collections::HashSet<&'static str>,
/// Row under the mouse cursor for hover highlighting. Indexes
/// `rows` in Browse, `picker_choice_rects` in PickingEnum,
/// always `None` in EditingValue.
pub hover_row: Option<usize>,
}
impl SettingsModalState {
/// Construct a new modal state from a registry + snapshots.
pub fn new(
registry: Arc<SettingsRegistry>,
ui_snapshot: UiConfig,
pager_snapshot: PagerLocalSnapshot,
) -> Self {
let rows = build_rows(&registry);
// Start on the first selectable (non-header) row.
let selected = rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { .. }))
.unwrap_or(0);
let filtered_cache = compute_filtered(&rows, &registry, "");
Self {
window: ModalWindowState::new(),
registry,
ui_snapshot,
pager_snapshot,
rows,
selected,
scroll_offset: 0,
mode: SettingsModalMode::Browse,
query: String::new(),
query_cursor: 0,
filtered_cache,
list_area: Rect::default(),
row_rects: Vec::new(),
value_hit_rects: Vec::new(),
editor_adornment_rects: (Rect::default(), Rect::default()),
picker_choice_rects: Vec::new(),
settings_breadcrumb_rect: None,
breadcrumb_hovered: false,
expanded_keys: std::collections::HashSet::new(),
hover_row: None,
}
}
/// The currently-focused setting row, if any.
pub fn focused_setting(&self) -> Option<(SettingKey, &SettingMeta)> {
match self.rows.get(self.selected)? {
RowEntry::Setting { key, meta_index } => {
let meta = self.registry.all().get(*meta_index)?;
Some((*key, meta))
}
RowEntry::Header { .. } => None,
}
}
/// Filtered row indices in render order.
pub fn filtered_indices(&self) -> &[usize] {
&self.filtered_cache
}
/// Rebuild rows from current process gates (minimal mode).
/// Keeps focus on the same key when possible; exits sub-panes if the key vanished.
pub fn rebuild_rows(&mut self) {
let prev_key = self.focused_setting().map(|(k, _)| k);
let subpane_key = match &self.mode {
SettingsModalMode::PickingEnum { key, .. }
| SettingsModalMode::PickingGroup { key, .. }
| SettingsModalMode::EditingValue { key, .. } => Some(*key),
SettingsModalMode::Browse | SettingsModalMode::FilterFocused => None,
};
self.rows = build_rows(&self.registry);
self.invalidate_filter();
if let Some(key) = subpane_key {
let still_visible = self
.rows
.iter()
.any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key));
if !still_visible {
self.mode = SettingsModalMode::Browse;
self.settings_breadcrumb_rect = None;
self.picker_choice_rects.clear();
}
}
if let Some(key) = prev_key {
if let Some(idx) = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
{
self.selected = idx;
} else {
self.selected = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { .. }))
.unwrap_or(0);
}
} else {
self.clamp_selected_to_visible();
}
}
/// Recompute `filtered_cache` from the current `query`.
fn invalidate_filter(&mut self) {
self.filtered_cache = compute_filtered(&self.rows, &self.registry, &self.query);
}
/// Snap `selected` to the first visible setting if filtered out.
fn clamp_selected_to_visible(&mut self) {
if self.filtered_cache.is_empty() {
return;
}
if self.filtered_cache.contains(&self.selected) {
return;
}
// Snap to first selectable row in the visible filter.
for &row_idx in &self.filtered_cache {
if matches!(self.rows[row_idx], RowEntry::Setting { .. }) {
self.selected = row_idx;
return;
}
}
}
/// Read the current value for a setting key.
pub fn value_for(&self, key: SettingKey) -> Option<SettingValue> {
current_value_for(key, &self.ui_snapshot, &self.pager_snapshot)
}
/// Move `selected` forward, skipping headers and filtered-out rows.
fn advance_next(&mut self) -> bool {
let cur_pos = self.filtered_cache.iter().position(|&i| i == self.selected);
let mut next = match cur_pos {
Some(p) => p + 1,
// Defensive: resume from top if `selected` is hidden.
None => 0,
};
while next < self.filtered_cache.len() {
let row_idx = self.filtered_cache[next];
if matches!(self.rows[row_idx], RowEntry::Setting { .. }) {
self.selected = row_idx;
return true;
}
next += 1;
}
false
}
/// Move `selected` backward, skipping headers and filtered-out rows.
fn advance_prev(&mut self) -> bool {
if self.filtered_cache.is_empty() {
return false;
}
let cur_pos = self.filtered_cache.iter().position(|&i| i == self.selected);
let mut prev = match cur_pos {
Some(p) if p > 0 => p - 1,
Some(_) => return false,
// Defensive: resume from bottom if `selected` is hidden.
None => self.filtered_cache.len() - 1,
};
loop {
let row_idx = self.filtered_cache[prev];
if matches!(self.rows[row_idx], RowEntry::Setting { .. }) {
self.selected = row_idx;
return true;
}
if prev == 0 {
break;
}
prev -= 1;
}
false
}
/// Set selection to `idx` if it's a selectable row.
pub fn select_at(&mut self, idx: usize) -> bool {
if idx >= self.rows.len() {
return false;
}
if !matches!(self.rows[idx], RowEntry::Setting { .. }) {
return false;
}
if self.selected == idx {
return false;
}
self.selected = idx;
true
}
/// Reset hit-test geometry so mouse handlers degrade gracefully
/// when render is aborted. Does NOT clear `hover_row` — that's
/// cleared on mode transitions instead to avoid per-frame flicker.
pub(crate) fn reset_hit_rects(&mut self) {
self.list_area = Rect::default();
self.row_rects.clear();
self.value_hit_rects.clear();
self.editor_adornment_rects = (Rect::default(), Rect::default());
self.picker_choice_rects.clear();
self.settings_breadcrumb_rect = None;
self.breadcrumb_hovered = false;
}
/// Transition to Browse, clearing sub-pane hover/breadcrumb state
/// to prevent stale hit-rects across mode changes.
pub(crate) fn transition_to_browse(&mut self) {
self.mode = SettingsModalMode::Browse;
self.hover_row = None;
self.settings_breadcrumb_rect = None;
self.breadcrumb_hovered = false;
}
/// Transition to `PickingEnum` if the focused row is Enum/DynamicEnum.
/// Returns `false` if the focused row is another kind.
pub fn try_enter_picking_enum(&mut self) -> bool {
let (key, first_canonical, current_value, supports_preview, resolved_choices) = {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
// Handles both static `Enum` and `DynamicEnum` catalogs.
let (supports_preview, resolved): (bool, Vec<OwnedEnumChoice>) = match &meta.kind {
SettingKind::Enum {
choices,
supports_preview,
..
} => (
*supports_preview,
effective_enum_choices(key, choices, &self.pager_snapshot)
.into_iter()
.map(|c| OwnedEnumChoice {
canonical: c.canonical.to_string(),
display: c.display.to_string(),
description: c.description.to_string(),
})
.collect(),
),
SettingKind::DynamicEnum {
source,
supports_preview,
..
} => (
*supports_preview,
dynamic_enum_choices(*source, &self.pager_snapshot),
),
_ => return false,
};
// Soft-fail if a static catalog exceeds the product cap. DynamicEnum
// (e.g. models) is exempt — those lists are runtime-sized and always
// scroll. The chooser itself scrolls static lists too; this assert is
// a design guard, not a render requirement.
debug_assert!(
resolved.len() <= MAX_PICKER_CHOICES
|| matches!(meta.kind, SettingKind::DynamicEnum { .. }),
"Static Enum setting `{}` has {} choices, exceeds MAX_PICKER_CHOICES ({}). \
Raise the cap deliberately if a larger curated catalog is required.",
key,
resolved.len(),
MAX_PICKER_CHOICES,
);
let first = resolved
.first()
.map(|c| c.canonical.clone())
.unwrap_or_default();
let cur = self.value_for(key);
(key, first, cur, supports_preview, resolved)
};
// Resolve choices_idx from current value. For DynamicEnum,
// if the current value no longer exists in the catalog,
// fall back to index 1 (first real entry past sentinel)
// to avoid accidentally wiping the user's preference.
let is_dynamic_enum = matches!(
self.registry.find(key).map(|m| &m.kind),
Some(SettingKind::DynamicEnum { .. })
);
let unknown_dynamic_fallback_idx = if is_dynamic_enum && resolved_choices.len() > 1 {
1
} else {
0
};
let choices_idx = match &current_value {
Some(SettingValue::Enum(cur)) => resolved_choices
.iter()
.position(|c| c.canonical == *cur)
.unwrap_or(0),
Some(SettingValue::String(cur)) if !cur.is_empty() => resolved_choices
.iter()
.position(|c| c.canonical == *cur)
.unwrap_or(unknown_dynamic_fallback_idx),
Some(SettingValue::String(_)) => 0,
_ => 0,
};
if is_dynamic_enum
&& choices_idx == unknown_dynamic_fallback_idx
&& unknown_dynamic_fallback_idx != 0
{
// Telemetry: log when a DynamicEnum value is stale.
tracing::warn!(
target: "settings",
key = key,
?current_value,
"DynamicEnum picker entered with a current value that no longer resolves \
in the live catalog — focusing first real choice instead of the \
(no override) sentinel to defend against accidental destructive Enter",
);
}
let original_value = current_value.unwrap_or_else(|| {
// Fallback to first choice, using the right value carrier.
match self.registry.find(key).map(|m| &m.kind) {
Some(SettingKind::DynamicEnum { .. }) => SettingValue::String(first_canonical),
Some(SettingKind::Enum { choices, .. }) => {
let first_static = choices.first().map(|c| c.canonical).unwrap_or("");
SettingValue::Enum(first_static)
}
_ => SettingValue::Enum(""),
}
});
self.mode = SettingsModalMode::PickingEnum {
key,
choices_idx,
supports_preview,
original_value,
};
self.hover_row = None;
true
}
/// Transition to `PickingGroup` if the focused row is a `Group`. Returns
/// `false` for any other kind so the caller can fall through to the
/// enum/editor entry points.
pub fn try_enter_picking_group(&mut self) -> bool {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
if !matches!(meta.kind, SettingKind::Group { .. }) {
return false;
}
self.mode = SettingsModalMode::PickingGroup { key, child_idx: 0 };
self.hover_row = None;
true
}
/// Transition to `EditingValue` if the focused row is String or Int.
pub fn try_enter_editing_value(&mut self) -> bool {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
let buffer = match (&meta.kind, self.value_for(key)) {
(SettingKind::String { .. }, Some(SettingValue::String(s))) => s,
(SettingKind::Int { .. }, Some(SettingValue::Int(i))) => i.to_string(),
// Fallback for registry skew — seed from default.
(SettingKind::String { default, .. }, _) => default.to_string(),
(SettingKind::Int { default, .. }, _) => default.to_string(),
_ => return false,
};
let cursor_byte = buffer.len();
// Validate the seed value upfront.
let validation_error = match &meta.kind {
SettingKind::String { validator, .. } => {
validate_string(*validator, &buffer, &self.pager_snapshot.available_models)
}
SettingKind::Int { min, max, .. } => validate_int(&buffer, *min, *max),
_ => None,
};
self.mode = SettingsModalMode::EditingValue {
key,
buffer,
cursor_byte,
validation_error,
};
self.hover_row = None;
true
}
/// Build the Action that toggles the focused Bool row. Returns
/// `None` with an error log on registry skew (caught by CI tests).
pub fn toggle_focused_bool(&self) -> Option<Action> {
let (key, meta) = self.focused_setting()?;
if !matches!(meta.kind, SettingKind::Bool { .. }) {
return None;
}
let cur = match self.value_for(key) {
Some(SettingValue::Bool(b)) => b,
Some(other) => {
tracing::error!(
target: "settings",
?key,
?other,
"Bool-kind setting resolved to non-Bool value — registry skew",
);
return None;
}
None => {
tracing::error!(
target: "settings",
?key,
"Bool-kind setting has no current_value_for arm — registry skew",
);
return None;
}
};
let action = action_for_bool(key, !cur);
if action.is_none() {
tracing::error!(
target: "settings",
?key,
"Bool-kind setting has no action_for_bool arm — registry skew",
);
}
action
}
}
/// Compute filtered row indices for a query. Headers are emitted only
/// when ≥1 setting in their section matches. Returns all indices when
/// `query` is empty.
fn compute_filtered(rows: &[RowEntry], registry: &SettingsRegistry, query: &str) -> Vec<usize> {
if query.is_empty() {
return (0..rows.len()).collect();
}
let matched_keys: Vec<SettingKey> = registry.search(query).iter().map(|m| m.key).collect();
let mut result = Vec::new();
let mut pending_header: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
match row {
RowEntry::Header { .. } => {
// Emit header only when section has a match.
pending_header = Some(i);
}
RowEntry::Setting { key, .. } => {
if matched_keys.contains(key) {
if let Some(h) = pending_header.take() {
result.push(h);
}
result.push(i);
}
}
}
}
result
}
/// Row visibility: `hidden_in_minimal` rows are dropped in minimal mode.
/// Pure for unit tests.
fn setting_row_visible(meta: &SettingMeta, minimal: bool) -> bool {
if minimal && meta.hidden_in_minimal {
return false;
}
true
}
fn build_rows(registry: &SettingsRegistry) -> Vec<RowEntry> {
let minimal = crate::app::minimal_mode_active();
// Keys that belong to a group sub-sheet are rendered only inside that
// sheet, never as their own top-level rows.
let group_children: std::collections::HashSet<SettingKey> = registry
.all()
.iter()
.filter_map(|m| match &m.kind {
SettingKind::Group { children } => Some(*children),
_ => None,
})
.flatten()
.copied()
.collect();
let mut rows = Vec::new();
for cat in SettingCategory::ALL {
let mut emitted_header = false;
for (meta_index, meta) in registry.all().iter().enumerate() {
if meta.category != *cat {
continue;
}
if !setting_row_visible(meta, minimal) {
continue;
}
if group_children.contains(meta.key) {
continue;
}
if !emitted_header {
rows.push(RowEntry::Header { category: *cat });
emitted_header = true;
}
rows.push(RowEntry::Setting {
key: meta.key,
meta_index,
});
}
}
rows
}
/// Construct the typed `Action::Set*` for a Bool setting.
fn action_for_bool(key: SettingKey, new: bool) -> Option<Action> {
match key {
"compact_mode" => Some(Action::SetCompactMode(new)),
"show_timestamps" => Some(Action::SetTimestamps(new)),
"show_timeline" => Some(Action::SetTimeline(new)),
"simple_mode" => Some(Action::SetSimpleMode(new)),
"contextual_hints.undo" => Some(Action::SetContextualHintUndo(new)),
"contextual_hints.plan_mode" => Some(Action::SetContextualHintPlanMode(new)),
"contextual_hints.image_input" => Some(Action::SetContextualHintImageInput(new)),
"contextual_hints.send_now" => Some(Action::SetContextualHintSendNow(new)),
"contextual_hints.small_screen" => Some(Action::SetContextualHintSmallScreen(new)),
"contextual_hints.word_select" => Some(Action::SetContextualHintWordSelect(new)),
"multiline_mode" => Some(Action::SetMultilineMode(new)),
"vim_mode" => Some(Action::SetVimMode(new)),
"remember_tool_approvals" => Some(Action::SetRememberToolApprovals(new)),
"toolset.ask_user_question.timeout_enabled" => {
Some(Action::SetAskUserQuestionTimeoutEnabled(new))
}
"show_thinking_blocks" => Some(Action::SetShowThinkingBlocks(new)),
"group_tool_verbs" => Some(Action::SetGroupToolVerbs(new)),
"collapsed_edit_blocks" => Some(Action::SetCollapsedEditBlocks(new)),
"prompt_suggestions" => Some(Action::SetPromptSuggestions(new)),
"respect_manual_folds" => Some(Action::SetRespectManualFolds(new)),
"invert_scroll" => Some(Action::SetInvertScroll(new)),
"show_tips" => Some(Action::SetShowTips(new)),
"auto_update" => Some(Action::SetAutoUpdate(new)),
"display_refresh_auto_cadence" => Some(Action::SetDisplayRefreshAutoCadence(new)),
_ => None,
}
}
/// Construct `Action::Preview*` for an Enum setting — used by the
/// picker's Up/Down (live preview) and Esc (revert). Preview actions
/// never persist; they only mutate the live visual.
fn action_for_enum(key: SettingKey, choice: &'static str) -> Option<Action> {
match key {
"theme" => Some(Action::PreviewTheme(choice.to_string())),
"auto_dark_theme" => Some(Action::PreviewAutoDarkTheme(choice.to_string())),
"auto_light_theme" => Some(Action::PreviewAutoLightTheme(choice.to_string())),
// No preview for settings with irreversible side effects.
"permission_mode" => None,
"coding_data_sharing" => None,
"plan_mode" => None,
"render_mermaid" => None,
"keep_text_selection" => None,
"scroll_mode" => None,
_ => None,
}
}
/// Construct `Action::Set*` commit variant for an Enum setting.
/// Commit actions persist to disk and fire a toast.
fn action_for_enum_commit(key: SettingKey, choice: &'static str) -> Option<Action> {
match key {
"theme" => Some(Action::SetTheme(choice.to_string())),
"auto_dark_theme" => Some(Action::SetAutoDarkTheme(choice.to_string())),
"auto_light_theme" => Some(Action::SetAutoLightTheme(choice.to_string())),
// Canonical strings from settings/defs.rs are the source of truth.
"permission_mode" => match choice {
"always-approve" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::AlwaysApprove,
)),
// Auto's feature gate is enforced in `set_permission_mode`
// (via `app.auto_mode_gate`, the same source the Shift+Tab cycle
// uses), so the modal and the cycle never disagree. Committing Auto
// when the gate is off degrades to Ask there.
"auto" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::Auto,
)),
"ask" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::Ask,
)),
"default" => Some(Action::SetPermissionMode(
crate::app::actions::PermissionModeKind::Default,
)),
_ => None,
},
"plan_mode" => match choice {
"on" => Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::On)),
"off" => Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::Off)),
_ => None,
},
"hunk_tracker_mode" => Some(Action::SetHunkTrackerMode(choice.to_string())),
"screen_mode" => Some(Action::SetScreenMode(choice.to_string())),
"render_mermaid" => {
crate::appearance::RenderMermaid::from_canonical(choice).map(Action::SetRenderMermaid)
}
"keep_text_selection" => crate::appearance::TextSelection::from_canonical(choice)
.map(Action::SetKeepTextSelection),
// Junk canonicals fold to None — Enter no-ops instead of mis-mapping.
"scroll_mode" => {
crate::appearance::ScrollMode::from_canonical(choice).map(Action::SetScrollMode)
}
"default_selected_permission" => {
Some(Action::SetDefaultSelectedPermission(choice.to_string()))
}
_ => None,
}
}
/// Construct `Action::Set*` commit variant for a String setting.
/// Resolves model names via the snapshot before producing the action.
/// Empty buffer maps to `Action::Clear*` for model settings.
fn action_for_string(
key: SettingKey,
value: String,
snapshot: &PagerLocalSnapshot,
) -> Option<Action> {
match key {
"default_model" => {
if value.is_empty() {
Some(Action::ClearDefaultModel)
} else {
snapshot
.resolve_model_name(&value)
.map(Action::SetDefaultModel)
}
}
"fork_secondary_model" => {
if value.is_empty() {
Some(Action::ClearForkSecondaryModel)
} else {
snapshot
.resolve_model_name(&value)
.map(Action::SetForkSecondaryModel)
}
}
_ => {
let _ = value;
let _ = snapshot;
None
}
}
}
/// Construct `Action::Set*` commit variant for an Int setting.
fn action_for_int(key: SettingKey, value: i64) -> Option<Action> {
match key {
"max_thoughts_width" => Some(Action::SetMaxThoughtsWidth(value)),
"scroll_speed" => Some(Action::SetScrollSpeed(value)),
"scroll_lines" => Some(Action::SetScrollLines(value)),
_ => None,
}
}
/// Validate a String buffer against the registered `StringValidator`.
/// Returns `Some(error_message)` on failure, `None` on success.
fn validate_string(
validator: StringValidator,
buffer: &str,
available_models: &[(String, agent_client_protocol::ModelId)],
) -> Option<String> {
match validator {
StringValidator::Any => None,
StringValidator::NonEmptyToken => {
if buffer.is_empty() {
Some("Value cannot be empty".to_string())
} else if buffer.chars().any(|c| c.is_whitespace()) {
Some("Value cannot contain whitespace".to_string())
} else {
None
}
}
StringValidator::KnownModel => {
// Empty = "clear default" sentinel.
if buffer.is_empty() {
return None;
}
// Reject if the model catalog hasn't loaded yet.
if available_models.is_empty() {
return Some("Model catalog still loading — try again".to_string());
}
let matched = available_models
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(buffer));
if matched {
None
} else {
Some(format!("Unknown model: \"{buffer}\""))
}
}
}
}
/// Validate an Int buffer against `(min, max)` bounds.
fn validate_int(buffer: &str, min: i64, max: i64) -> Option<String> {
if buffer.is_empty() {
return Some("Value cannot be empty".to_string());
}
match buffer.parse::<i64>() {
Ok(v) if v >= min && v <= max => None,
Ok(v) => Some(format!("Value out of range ({min}\u{2013}{max}): {v}")),
Err(_) => Some(format!("Not a valid integer: \"{buffer}\"")),
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Overlay for the reset-confirm dialog. Overrides chrome breadcrumb,
/// footer, and search bar with the confirmation prompt.
pub struct ResetConfirmOverlay<'a> {
pub prompt: &'a str,
pub breadcrumb_suffix: &'a str,
}
/// Render the settings modal. Returns `true` if the reset-confirm
/// overlay was rendered (caller suppresses row-list mouse events).
pub fn render_settings_modal(
buf: &mut Buffer,
full_area: Rect,
state: &mut SettingsModalState,
compact: bool,
overlay: Option<&ResetConfirmOverlay<'_>>,
) -> bool {
let theme = Theme::current();
let confirm_shortcuts = build_reset_confirm_shortcuts();
let normal_shortcuts = build_shortcuts(state);
let shortcuts: &[Shortcut<'_>] = if overlay.is_some() {
&confirm_shortcuts
} else {
&normal_shortcuts
};
// Breadcrumb title for sub-modes: "Settings <label>".
let breadcrumb_owned: String;
let title: &str = if let Some(o) = overlay {
breadcrumb_owned = format!(
"{MODAL_TITLE} {} {}",
crate::glyphs::chevron(),
o.breadcrumb_suffix
);
&breadcrumb_owned
} else {
match &state.mode {
SettingsModalMode::PickingEnum { key, .. } => {
if let Some(meta) = state.registry.find(key) {
breadcrumb_owned =
format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label);
&breadcrumb_owned
} else {
MODAL_TITLE
}
}
SettingsModalMode::EditingValue { key, .. } => {
if let Some(meta) = state.registry.find(key) {
breadcrumb_owned =
format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label);
&breadcrumb_owned
} else {
MODAL_TITLE
}
}
SettingsModalMode::PickingGroup { key, .. } => {
if let Some(meta) = state.registry.find(key) {
breadcrumb_owned =
format!("{MODAL_TITLE} {} {}", crate::glyphs::chevron(), meta.label);
&breadcrumb_owned
} else {
MODAL_TITLE
}
}
_ => MODAL_TITLE,
}
};
// Footer sizing: predict shortcut wrap rows, add a gap row above
// when the docs footer is present. EditingValue suppresses the
// docs footer. Widen the modal when editing `max_thoughts_width`
// so the wrap preview is useful at widths above STANDARD_MAX_WIDTH.
let widen_for_max_thoughts_width = matches!(
&state.mode,
SettingsModalMode::EditingValue { key, .. }
if *key == crate::settings::defs::MAX_THOUGHTS_WIDTH_KEY
);
let widened_candidate = full_area
.width
.saturating_sub(MAX_THOUGHTS_WIDTH_WIDENED_MARGIN);
let (max_width, width_pct) =
if widen_for_max_thoughts_width && widened_candidate > STANDARD_MAX_WIDTH {
(widened_candidate, 1.0)
} else {
(STANDARD_MAX_WIDTH, 0.70)
};
let sizing = ModalSizing {
width_pct,
max_width,
min_width: 44,
v_margin: 3,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact);
let has_tip_footer = !matches!(state.mode, SettingsModalMode::EditingValue { .. });
let footer_lines = if has_tip_footer {
modal_window::footer_lines_with_tip_gap(full_area, &sizing, shortcuts)
} else {
sizing.footer_lines
};
let sizing = ModalSizing {
footer_lines,
..sizing
};
let modal_config = ModalWindowConfig {
title,
tabs: None,
shortcuts,
sizing,
fold_info: None,
};
let Some(ModalContentArea {
content: content_area,
..
}) =
modal_window::render_modal_window(buf, full_area, &mut state.window, &modal_config, &theme)
else {
// Chrome refused — reset hit-rects for graceful degradation.
state.reset_hit_rects();
return overlay.is_some();
};
if content_area.height < 2 || content_area.width < CONTENT_MIN_WIDTH {
state.reset_hit_rects();
return overlay.is_some();
}
if let Some(o) = overlay {
// Confirmation overlay replaces the search bar; row list
// renders dimmed underneath. Hit-rects reset so clicks
// only route to the y/n footer buttons.
render_reset_confirm_overlay(buf, content_area, state, &theme, o);
return true;
}
let (inner_area, docs_footer_area) = match state.mode {
SettingsModalMode::EditingValue { .. } => (content_area, None),
_ => modal_window::split_content_for_tip_footer(content_area),
};
// Per-mode render dispatch (exhaustive to catch new variants).
let mode_is_sub_pane = matches!(
state.mode,
SettingsModalMode::PickingEnum { .. }
| SettingsModalMode::PickingGroup { .. }
| SettingsModalMode::EditingValue { .. }
);
match state.mode {
SettingsModalMode::PickingEnum { .. } => {
state.reset_hit_rects();
render_picking_enum(buf, inner_area, state, &theme);
state.picker_choice_rects = take_picker_choice_rects();
}
SettingsModalMode::PickingGroup { .. } => {
state.reset_hit_rects();
let rects = render_picking_group(buf, inner_area, state, &theme);
state.picker_choice_rects = rects;
}
SettingsModalMode::EditingValue { .. } => {
state.reset_hit_rects();
render_editing_value(buf, inner_area, state, &theme);
}
SettingsModalMode::Browse | SettingsModalMode::FilterFocused => {
// Clear sub-pane hit-rects from prior frames.
state.picker_choice_rects.clear();
state.editor_adornment_rects = (Rect::default(), Rect::default());
state.settings_breadcrumb_rect = None;
state.list_area = inner_area;
render_row_list_with_search_bar(buf, inner_area, state, &theme);
}
}
// Breadcrumb hit-rect for sub-pane back-navigation on click.
// Repaint with UNDERLINED (+ accent on hover) for affordance.
state.settings_breadcrumb_rect = if mode_is_sub_pane {
state.window.popup_area.map(|popup| {
let title_w = title.width() as u16;
// Clamp to not extend past the close button.
let max_w = popup.width.saturating_sub(2 + 2); // borders + " ─" trailing decoration
Rect {
x: popup.x + 1 + TITLE_LEADING_DECORATION_W,
y: popup.y,
width: title_w.min(max_w),
height: 1,
}
})
} else {
None
};
if let Some(rect) = state.settings_breadcrumb_rect {
let fg = if state.breadcrumb_hovered {
theme.accent_user
} else {
theme.text_primary
};
let style_mods = Modifier::BOLD | Modifier::UNDERLINED;
for offset in 0..rect.width {
let x = rect.x + offset;
if let Some(cell) = buf.cell_mut((x, rect.y)) {
let mut s = cell.style();
s.fg = Some(fg);
s = s.add_modifier(style_mods);
cell.set_style(s);
}
}
}
if let Some(footer_area) = docs_footer_area {
render_docs_footer(buf, footer_area, &theme);
}
false
}
/// Render the reset-confirm overlay: prompt replaces the search bar,
/// row list renders below with the target row at full intensity and
/// all other rows dimmed.
fn render_reset_confirm_overlay(
buf: &mut Buffer,
content_area: Rect,
state: &mut SettingsModalState,
theme: &Theme,
overlay: &ResetConfirmOverlay<'_>,
) {
// Clear hit-rects so clicks only route to y/n buttons.
state.reset_hit_rects();
if content_area.height == 0 || content_area.width == 0 {
return;
}
// Row 0: prompt (full width, bold + accent).
let prompt_area = Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
};
let prompt_bg_style = Style::default().bg(theme.bg_visual);
buf.set_style(prompt_area, prompt_bg_style);
let prompt_style = Style::default()
.fg(theme.accent_user)
.bg(theme.bg_visual)
.add_modifier(Modifier::BOLD);
let prompt_text: std::borrow::Cow<'_, str> =
if overlay.prompt.width() <= content_area.width as usize {
std::borrow::Cow::Borrowed(overlay.prompt)
} else {
std::borrow::Cow::Owned(truncate_str(overlay.prompt, content_area.width as usize))
};
let prompt_w = (prompt_text.width() as u16).min(content_area.width);
buf.set_span(
content_area.x,
content_area.y,
&Span::styled(prompt_text.as_ref(), prompt_style),
prompt_w,
);
// Row 1+: render rows, then dim all except the target.
if content_area.height < 2 {
return;
}
let list_area = Rect {
x: content_area.x,
y: content_area.y + 1,
width: content_area.width,
height: content_area.height - 1,
};
render_rows(buf, list_area, state, theme);
let target_rect = state.row_rects.get(state.selected).copied();
// Dim every line outside the target row's y-range (DIM + blend).
let target_y_start = target_rect.map(|r| r.y);
let target_y_end = target_rect.map(|r| r.y.saturating_add(r.height));
let area_y_end = list_area.y.saturating_add(list_area.height);
for y in list_area.y..area_y_end {
if let (Some(ys), Some(ye)) = (target_y_start, target_y_end)
&& y >= ys
&& y < ye
{
continue; // inside the target row's y range — stays full intensity
}
let strip = Rect {
x: list_area.x,
y,
width: list_area.width,
height: 1,
};
buf.set_style(strip, Style::default().add_modifier(Modifier::DIM));
crate::render::color::blend_area(buf, strip, Some((theme.bg_base, 0.55)), None);
}
}
/// Footer shortcuts for the reset-confirm dialog (y/n are clickable).
fn build_reset_confirm_shortcuts() -> Vec<Shortcut<'static>> {
use crate::views::modal::{RESET_CONFIRM_NO_ID, RESET_CONFIRM_YES_ID};
vec![
Shortcut {
label: "y reset",
clickable: true,
id: RESET_CONFIRM_YES_ID,
},
Shortcut {
label: "n cancel",
clickable: true,
id: RESET_CONFIRM_NO_ID,
},
Shortcut {
label: "Esc cancel",
clickable: false,
id: 0,
},
Shortcut {
label: "F2 cancel",
clickable: false,
id: 0,
},
]
}
/// Render the row list with a search bar at the top (Browse/FilterFocused).
fn render_row_list_with_search_bar(
buf: &mut Buffer,
content_area: Rect,
state: &mut SettingsModalState,
theme: &Theme,
) {
let filter_focused = matches!(state.mode, SettingsModalMode::FilterFocused);
if content_area.height >= 3 {
// row 0: search bar, row 1: divider, row 2+: list.
let search_area = Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
};
crate::views::picker::render_search_bar(
buf,
search_area.x,
search_area.y,
search_area.width,
theme,
&state.query,
filter_focused,
true,
state.query_cursor,
Some(theme.bg_base),
);
crate::views::picker::render_divider(
buf,
content_area.x,
content_area.y + 1,
content_area.width,
theme,
Some(theme.bg_base),
);
let list_area = Rect {
x: content_area.x,
y: content_area.y + 2,
width: content_area.width,
height: content_area.height - 2,
};
state.list_area = list_area;
render_rows(buf, list_area, state, theme);
} else if content_area.height >= 2 {
// Tight: search bar only, no divider.
let search_area = Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
};
crate::views::picker::render_search_bar(
buf,
search_area.x,
search_area.y,
search_area.width,
theme,
&state.query,
filter_focused,
true,
state.query_cursor,
Some(theme.bg_base),
);
let list_area = Rect {
x: content_area.x,
y: content_area.y + 1,
width: content_area.width,
height: content_area.height - 1,
};
state.list_area = list_area;
render_rows(buf, list_area, state, theme);
} else {
// Too narrow for a search bar; just render the rows.
render_rows(buf, content_area, state, theme);
}
}
fn render_docs_footer(buf: &mut Buffer, area: Rect, theme: &Theme) {
const LONG: &str =
"Tip · Ask Kigi: \"change theme to kigiday\" or \"what does compact mode do?\"";
const SHORT: &str = "Tip · Ask Kigi to change a setting";
let text = modal_window::fit_tip_line(&[LONG, SHORT], area.width as usize);
modal_window::render_centered_tip_footer(buf, area, theme, text.as_ref());
}
fn render_rows(buf: &mut Buffer, area: Rect, state: &mut SettingsModalState, theme: &Theme) {
let visible_h = area.height as usize;
if visible_h == 0 {
state.row_rects.clear();
state.row_rects.resize(state.rows.len(), Rect::default());
state.value_hit_rects.clear();
state
.value_hit_rects
.resize(state.rows.len(), Rect::default());
return;
}
state.row_rects.clear();
state.row_rects.resize(state.rows.len(), Rect::default());
state.value_hit_rects.clear();
state
.value_hit_rects
.resize(state.rows.len(), Rect::default());
let total_visible = state.filtered_cache.len();
// Empty filter — show "No matches for <query>".
if total_visible == 0 {
if !state.query.is_empty() {
let prefix = "No matches for ";
let suffix_quote_w = 2u16; // surrounding "" chars
let available_for_query = (area.width as usize)
.saturating_sub(prefix.width())
.saturating_sub(suffix_quote_w as usize);
let q_disp = if state.query.width() <= available_for_query {
state.query.clone()
} else {
truncate_str(&state.query, available_for_query)
};
let msg = format!("{prefix}\"{q_disp}\"");
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let msg_w = (msg.width() as u16).min(area.width);
let cx = area.x + area.width.saturating_sub(msg_w) / 2;
let cy = area.y + area.height / 2;
buf.set_span(cx, cy, &Span::styled(&msg, style), msg_w);
}
return;
}
// Translate `state.selected` (rows-space) → filtered position.
let selected_fpos = state
.filtered_cache
.iter()
.position(|&i| i == state.selected);
// Clamp scroll so selection stays in view, keeping the preceding
// section header visible when scrolling up. Row heights are
// variable (expanded descriptions, header gaps).
let row_heights = compute_filtered_row_heights(state, area.width);
if let Some(fpos) = selected_fpos {
if fpos < state.scroll_offset {
let new_offset = if fpos > 0 {
let prev_idx = state.filtered_cache[fpos - 1];
if matches!(state.rows[prev_idx], RowEntry::Header { .. }) {
fpos - 1
} else {
fpos
}
} else {
fpos
};
state.scroll_offset = new_offset;
}
let min_offset_for_visibility = compute_min_scroll_offset_for_visibility(
&state.filtered_cache,
&state.rows,
&row_heights,
fpos,
visible_h,
);
if state.scroll_offset < min_offset_for_visibility {
state.scroll_offset = min_offset_for_visibility;
}
}
// Final clamp — don't let scroll_offset past the end.
if total_visible > 0 {
let max_offset = compute_min_scroll_offset_for_visibility(
&state.filtered_cache,
&state.rows,
&row_heights,
total_visible - 1,
visible_h,
);
if state.scroll_offset > max_offset {
state.scroll_offset = max_offset;
}
}
let end = total_visible.min(state.scroll_offset + visible_h);
let max_label_w = compute_settings_max_label_w(state.registry.all(), area.width);
// Snapshot visible rows to avoid borrow conflicts in the render loop.
let visible_filtered: Vec<usize> = state.filtered_cache[state.scroll_offset..end].to_vec();
let hover_row_snapshot = state.hover_row;
let mut values: Vec<Option<SettingValue>> = Vec::with_capacity(visible_filtered.len());
for &row_idx in &visible_filtered {
let v = match state.rows.get(row_idx) {
Some(RowEntry::Setting { key, .. }) => state.value_for(key),
_ => None,
};
values.push(v);
}
// Track y-cursor: rows consume variable height when expanded.
let mut y_cursor = area.y;
let area_end = area.y + area.height;
let expanded_snapshot: std::collections::HashSet<&'static str> = state.expanded_keys.clone();
// Insert a blank line above non-first section headers.
let mut rendered_any = false;
for (row_pos, &row_idx) in visible_filtered.iter().enumerate() {
if y_cursor >= area_end {
break;
}
let Some(row) = state.rows.get(row_idx) else {
continue;
};
if matches!(row, RowEntry::Header { .. })
&& rendered_any
&& y_cursor.saturating_add(1) < area_end
{
y_cursor = y_cursor.saturating_add(1);
}
if y_cursor >= area_end {
break;
}
let label_rect = Rect {
x: area.x,
y: y_cursor,
width: area.width,
height: 1,
};
state.row_rects[row_idx] = label_rect;
rendered_any = true;
match row {
RowEntry::Header { category } => {
let label = category.label();
let header_style = Style::default()
.fg(theme.gray)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let title = format!(" {label} ");
let title_w = title.width();
let remaining = (area.width as usize).saturating_sub(title_w);
let sep: String = std::iter::repeat_n('\u{2500}', remaining).collect();
let line = Line::from(vec![
Span::styled(title, header_style),
Span::styled(sep, sep_style),
]);
buf.set_line(area.x, y_cursor, &line, area.width);
y_cursor = y_cursor.saturating_add(1);
}
RowEntry::Setting {
meta_index, key, ..
} => {
let Some(meta) = state.registry.all().get(*meta_index) else {
continue;
};
let value_opt = values.get(row_pos).and_then(|v| v.as_ref());
let is_selected = row_idx == state.selected;
let is_expanded = expanded_snapshot.contains(key);
// Group rows carry no scalar value; render a chevron row that
// opens the sub-sheet (skips the value/edited machinery below).
if matches!(meta.kind, SettingKind::Group { .. }) {
let is_hovered = hover_row_snapshot == Some(row_idx);
let value_rect = render_setting_group_row(
buf,
label_rect,
meta,
is_selected,
is_hovered,
is_expanded,
theme,
);
state.value_hit_rects[row_idx] = value_rect;
y_cursor = y_cursor.saturating_add(1);
// Mirror normal rows: render the description inline when the
// group's key is expanded (Right/l). The group has no value,
// so this is the only place its description can surface.
if is_expanded && y_cursor < area_end {
let desc_height = area_end - y_cursor;
let desc_rect = Rect {
x: area.x,
y: y_cursor,
width: area.width,
height: desc_height.min(8),
};
render_expanded_description(buf, desc_rect, meta, theme);
let consumed =
wrapped_description_height(meta, area.width, desc_rect.height);
y_cursor = y_cursor.saturating_add(consumed);
}
continue;
}
let value = match value_opt {
Some(v) => v,
None => {
render_setting_row_no_value(
buf,
label_rect,
meta,
max_label_w,
is_selected,
theme,
);
y_cursor = y_cursor.saturating_add(1);
continue;
}
};
// Decide 1 vs 2 line layout; fall back to 1 if viewport is tight.
let value_display = match value {
SettingValue::Bool(b) => {
if *b {
"on".to_string()
} else {
"off".to_string()
}
}
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)".to_string()
} else {
s.clone()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
SettingValue::Int(i) => i.to_string(),
};
let show_restart_pill_for_layout = meta.restart_required && is_expanded;
let layout_decision = row_layout(
area.width,
meta.label,
&value_display,
show_restart_pill_for_layout,
);
let want_two_lines = !matches!(layout_decision, RowLayout::OneLine);
// Only allocate 2 lines if the viewport has room.
let row_height: u16 = if want_two_lines && y_cursor.saturating_add(2) <= area_end {
2
} else {
1
};
let render_area = Rect {
x: area.x,
y: y_cursor,
width: area.width,
height: row_height,
};
// Hit-rect spans both lines for two-line rows.
state.row_rects[row_idx] = render_area;
let is_hovered = hover_row_snapshot == Some(row_idx);
let value_rect = render_setting_row(
buf,
render_area,
meta,
value,
max_label_w,
is_selected,
theme,
is_expanded,
is_hovered,
);
state.value_hit_rects[row_idx] = value_rect;
y_cursor = y_cursor.saturating_add(row_height);
if is_expanded && y_cursor < area_end {
let desc_height = area_end - y_cursor;
let desc_rect = Rect {
x: area.x,
y: y_cursor,
width: area.width,
height: desc_height.min(8), // cap at 8 lines per row to keep scroll sane
};
render_expanded_description(buf, desc_rect, meta, theme);
// Re-measure how many lines the wrapped description
// actually consumed, so y_cursor advances precisely.
let consumed = wrapped_description_height(meta, area.width, desc_rect.height);
y_cursor = y_cursor.saturating_add(consumed);
}
}
}
}
}
/// Compute the minimum scroll_offset that keeps filtered position
/// `fpos` visible within `visible_h` lines. Walks backward,
/// accounting for variable row heights and header gaps.
fn compute_min_scroll_offset_for_visibility(
filtered_cache: &[usize],
rows: &[RowEntry],
row_heights: &[u16],
fpos: usize,
visible_h: usize,
) -> usize {
if visible_h == 0 || fpos >= filtered_cache.len() {
return fpos;
}
// Visual lines consumed so far. `fpos` itself sits at the top of
// the viewport, so it doesn't earn a blank-above-header even if
// it IS a header.
let fpos_height = row_heights.get(fpos).copied().unwrap_or(1) as usize;
let mut lines_used: usize = fpos_height;
if lines_used > visible_h {
// Even the focused row alone doesn't fit; clamp to fpos so
// the down-stream renderer at least shows its label.
return fpos;
}
let mut offset = fpos;
while offset > 0 {
let candidate = offset - 1;
let candidate_height = row_heights.get(candidate).copied().unwrap_or(1) as usize;
// Cost of including `candidate` as the new top of the
// viewport: its own visual height, plus 1 line for the
// blank-above-header that the OLD top (`offset`) now
// earns (since it's no longer the first row rendered).
let old_first_idx = filtered_cache[offset];
let old_first_is_header = matches!(rows[old_first_idx], RowEntry::Header { .. });
let cost: usize = candidate_height + usize::from(old_first_is_header);
if lines_used.saturating_add(cost) > visible_h {
break;
}
lines_used += cost;
offset = candidate;
}
offset
}
/// Precompute the visual height (in terminal rows) of each entry in
/// `state.filtered_cache`, using the same `row_layout` /
/// `wrapped_description_height` math the forward render loop uses.
///
/// The cost passed to [`compute_min_scroll_offset_for_visibility`]
/// is the row's intrinsic height EXCLUDING the blank-line-above-
/// header gap — that gap is accounted for inside the scroll helper's
/// backward walk because it depends on the runtime position relative
/// to the viewport top.
///
/// Cost: O(visible filtered rows) per render, bounded by the
/// registry size (~15 entries today). Each row does at most one
/// `word_wrap_line` call (for expanded descriptions). Allocations
/// are confined to a single `Vec<u16>` per call; per-row layout
/// math is on the stack.
fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) -> Vec<u16> {
let mut heights = Vec::with_capacity(state.filtered_cache.len());
for &row_idx in &state.filtered_cache {
let Some(row) = state.rows.get(row_idx) else {
heights.push(1);
continue;
};
match row {
RowEntry::Header { .. } => heights.push(1),
RowEntry::Setting {
meta_index, key, ..
} => {
let Some(meta) = state.registry.all().get(*meta_index) else {
heights.push(1);
continue;
};
// Group rows carry no value; height = chevron row + the expanded
// description (cap 8), agreeing with the forward render loop.
if matches!(meta.kind, SettingKind::Group { .. }) {
let mut h: u16 = 1;
if state.expanded_keys.contains(key) {
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
}
heights.push(h);
continue;
}
let Some(value) = state.value_for(key) else {
heights.push(1);
continue;
};
let is_expanded = state.expanded_keys.contains(key);
let value_display = match &value {
SettingValue::Bool(b) => {
if *b {
"on".to_string()
} else {
"off".to_string()
}
}
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)".to_string()
} else {
s.clone()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
SettingValue::Int(i) => i.to_string(),
};
let show_restart_pill = meta.restart_required && is_expanded;
let layout = row_layout(area_width, meta.label, &value_display, show_restart_pill);
let mut h: u16 = match layout {
RowLayout::OneLine => 1,
RowLayout::TwoLine | RowLayout::TwoLineWithLabelTruncation => 2,
};
if is_expanded {
// Cap matches the forward render loop at line
// 2040 (`desc_rect.height = ... .min(8)`).
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
}
heights.push(h);
}
}
}
heights
}
/// Wrapped description height for scroll math (mirrors render path).
fn wrapped_description_height(meta: &SettingMeta, area_width: u16, cap: u16) -> u16 {
let indent = 4u16.min(area_width);
let wrap_w = area_width.saturating_sub(indent);
if wrap_w == 0 {
return 0;
}
let line = Line::from(Span::raw(meta.description));
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
(wrapped.len() as u16).min(cap)
}
// Picker prefix constants (hoisted to avoid per-frame allocation).
const PICKER_PREFIX_FOCUSED: &str = " \u{25CF} ";
const PICKER_PREFIX_UNFOCUSED: &str = " \u{25CB} ";
const PICKER_PREFIX_W: u16 = 4;
const PICKER_SEPARATOR: &str = " \u{00B7} ";
const PICKER_SEPARATOR_W: u16 = 3;
const PICKER_MARKER_W: u16 = 1;
/// Soft product cap on static Enum choices (settings unit tests enforce it).
///
/// The chooser already scrolls within the viewport when the focused choice
/// falls off-screen (`picker_scroll_offset`); this limit exists so catalogs
/// stay intentionally curated rather than unbounded. Sized to fit the full
/// Kigi STT language list (25 codes + client-only `auto` = 26) with headroom.
pub(crate) const MAX_PICKER_CHOICES: usize = 32;
/// Render the shared sub-pane header (bold title row + word-wrapped description)
/// used by all four sub-pane renderers — the enum chooser, the group sub-sheet,
/// and the string/int editors. Returns `header_rows`: the rows consumed (title +
/// optional description + the 1-row gap), so the caller positions its body at
/// `area.y + header_rows`. The bodies differ and stay in each caller.
///
/// `min_non_desc_rows` is the vertical budget (excluding the description rows
/// themselves) that must fit before the description renders at all: `2` for the
/// choosers (title + gap), `3` for the editors (title + gap + the input/stepper
/// row). Callers keep their own `if area.height <= header_rows { return; }`.
fn render_sub_pane_header(
buf: &mut Buffer,
area: Rect,
theme: &Theme,
title: &str,
description: &str,
min_non_desc_rows: u16,
) -> u16 {
// ── Row 0: title (truncated with `…`). ────────────────────────
let title_style = Style::default()
.fg(theme.text_primary)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
let title_text: std::borrow::Cow<'_, str> = if title.width() <= area.width as usize {
std::borrow::Cow::Borrowed(title)
} else {
std::borrow::Cow::Owned(truncate_str(title, area.width as usize))
};
let title_w = (title_text.width() as u16).min(area.width);
buf.set_span(
area.x,
area.y,
&Span::styled(title_text.as_ref(), title_style),
title_w,
);
// ── Row 1+: word-wrapped description ──────────────────────────
let description_wrapped = wrap_description(description, area.width);
let desc_rows: u16 = description_wrapped.len() as u16;
let has_description =
desc_rows > 0 && area.height >= min_non_desc_rows.saturating_add(desc_rows);
if has_description {
let desc_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
for (i, wrap_line) in description_wrapped.iter().enumerate() {
let y = area.y + 1 + i as u16;
if y >= area.y + area.height {
break;
}
let w = (wrap_line.width() as u16).min(area.width);
buf.set_span(area.x, y, &Span::styled(wrap_line.as_str(), desc_style), w);
}
}
if has_description { 2 + desc_rows } else { 2 }
}
/// Render the Enum chooser sub-pane. Title + description + radio-style
/// choice list with scrolling and `… N more` overflow indicator.
fn render_picking_enum(buf: &mut Buffer, area: Rect, state: &SettingsModalState, theme: &Theme) {
debug_assert_eq!(
PICKER_PREFIX_FOCUSED.width(),
PICKER_PREFIX_W as usize,
"PICKER_PREFIX_W drifted from PICKER_PREFIX_FOCUSED width",
);
debug_assert_eq!(
PICKER_PREFIX_UNFOCUSED.width(),
PICKER_PREFIX_W as usize,
"PICKER_PREFIX_W drifted from PICKER_PREFIX_UNFOCUSED width",
);
debug_assert_eq!(
PICKER_SEPARATOR.width(),
PICKER_SEPARATOR_W as usize,
"PICKER_SEPARATOR_W drifted from PICKER_SEPARATOR width",
);
let (setting_key, choices_idx) = match &state.mode {
SettingsModalMode::PickingEnum {
key, choices_idx, ..
} => (*key, *choices_idx),
_ => return,
};
let Some(meta) = state.registry.find(setting_key) else {
return;
};
let choices: Vec<OwnedEnumChoice> = match &meta.kind {
SettingKind::Enum { choices, .. } => {
effective_enum_choices(setting_key, choices, &state.pager_snapshot)
.into_iter()
.map(|c| OwnedEnumChoice {
canonical: c.canonical.to_string(),
display: c.display.to_string(),
description: c.description.to_string(),
})
.collect()
}
SettingKind::DynamicEnum { source, .. } => {
dynamic_enum_choices(*source, &state.pager_snapshot)
}
_ => return,
};
if area.width == 0 || area.height == 0 {
return;
}
// Choosers need title + gap (2) before the description renders.
let header_rows = render_sub_pane_header(buf, area, theme, meta.label, meta.description, 2);
if area.height <= header_rows {
return;
}
let choices_y = area.y + header_rows;
let max_choices_h = area.height.saturating_sub(header_rows) as usize;
if max_choices_h == 0 {
return;
}
// ── Per-choice wrapped layout ─────────────────────────────────
let layouts: Vec<PickerChoiceLayout> = choices
.iter()
.map(|choice| compute_picker_choice_layout(choice, area.width))
.collect();
let total_h: u16 = layouts.iter().map(|l| l.height).sum();
// ── Scroll offset (variable per-choice height) ────────────────
let needs_overflow = total_h as usize > max_choices_h;
let available_h: u16 = if needs_overflow {
(max_choices_h as u16).saturating_sub(1).max(1)
} else {
max_choices_h as u16
};
let scroll_offset = picker_scroll_offset(&layouts, choices_idx, available_h);
let mut visible_end = scroll_offset;
let mut consumed_h: u16 = 0;
for (i, layout) in layouts.iter().enumerate().skip(scroll_offset) {
let next = consumed_h.saturating_add(layout.height);
if next > available_h {
break;
}
consumed_h = next;
visible_end = i + 1;
}
// Defensive: always show the focused choice even if it's clipped.
if visible_end <= choices_idx {
visible_end = choices_idx + 1;
}
let _ = consumed_h; // height bookkeeping kept for future tuning
// ── Hit-rect bookkeeping ──────────────────────────────────────
let mut picker_choice_rects: Vec<Rect> = vec![Rect::default(); choices.len()];
// ── Choice rows ───────────────────────────────────────────────
let fg_primary = theme.text_primary;
let fg_gray = theme.gray;
let fg_accent = theme.accent_user;
let mut y_cursor = choices_y;
for (choice_i, layout) in layouts
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_end - scroll_offset)
{
let choice = &choices[choice_i];
let is_focused = choice_i == choices_idx;
let is_hovered = !is_focused && state.hover_row == Some(choice_i);
let bg = settings_list_row_bg(theme, is_focused, is_hovered);
let display_style = if is_focused {
Style::default()
.fg(fg_primary)
.bg(bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg_primary).bg(bg)
};
let desc_style = Style::default().fg(fg_gray).bg(bg);
let marker_style = if is_focused {
Style::default().fg(fg_accent).bg(bg)
} else {
Style::default().fg(fg_gray).bg(bg)
};
let marker = if is_focused {
crate::glyphs::filled_dot()
} else {
"\u{25CB}"
};
let block_rect = Rect {
x: area.x,
y: y_cursor,
width: area.width,
height: layout.height,
};
buf.set_style(block_rect, Style::default().bg(bg));
picker_choice_rects[choice_i] = block_rect;
// ── Line 1: prefix + display + (· + first wrap line) ──────
let y = y_cursor;
if area.width > 0 {
// Leading space (col 0 of the row).
buf.set_span(
area.x,
y,
&Span::styled(" ", display_style),
1.min(area.width),
);
}
if area.width > 1 {
// Marker glyph at col 1.
buf.set_span(
area.x + 1,
y,
&Span::styled(marker, marker_style),
PICKER_MARKER_W.min(area.width.saturating_sub(1)),
);
}
if area.width > 2 {
// Trailing two spaces at cols 2-3.
let pad_w = 2u16.min(area.width.saturating_sub(2));
buf.set_span(area.x + 2, y, &Span::styled(" ", display_style), pad_w);
}
// Display name (truncated).
let display_x = area.x.saturating_add(PICKER_PREFIX_W);
let display_room = (area.x + area.width).saturating_sub(display_x) as usize;
if display_room == 0 {
y_cursor = y_cursor.saturating_add(layout.height);
continue;
}
let display_text: std::borrow::Cow<'_, str> = if choice.display.width() <= display_room {
std::borrow::Cow::Borrowed(choice.display.as_str())
} else {
std::borrow::Cow::Owned(truncate_str(&choice.display, display_room))
};
let display_w =
(display_text.width() as u16).min(area.width.saturating_sub(PICKER_PREFIX_W));
buf.set_span(
display_x,
y,
&Span::styled(display_text.as_ref(), display_style),
display_w,
);
let has_choice_desc = !choice.description.trim().is_empty();
if !has_choice_desc {
y_cursor = y_cursor.saturating_add(layout.height);
continue;
}
let after_display_x = display_x.saturating_add(display_w);
let sep_room = (area.x + area.width).saturating_sub(after_display_x);
if sep_room == 0 {
y_cursor = y_cursor.saturating_add(layout.height);
continue;
}
let sep_w = PICKER_SEPARATOR_W.min(sep_room);
buf.set_span(
after_display_x,
y,
&Span::styled(PICKER_SEPARATOR, desc_style),
sep_w,
);
let desc_x = after_display_x + sep_w;
let desc_room = (area.x + area.width).saturating_sub(desc_x) as usize;
if desc_room == 0 {
y_cursor = y_cursor.saturating_add(layout.height);
continue;
}
// Narrow fallback: truncate on one line if wrapping fails.
if layout.wrap_lines.is_empty() {
let desc_text: std::borrow::Cow<'_, str> = if choice.description.width() <= desc_room {
std::borrow::Cow::Borrowed(choice.description.as_str())
} else {
std::borrow::Cow::Owned(truncate_str(&choice.description, desc_room))
};
let desc_w = (desc_text.width() as u16).min(area.x + area.width - desc_x);
buf.set_span(
desc_x,
y,
&Span::styled(desc_text.as_ref(), desc_style),
desc_w,
);
y_cursor = y_cursor.saturating_add(layout.height);
continue;
}
// Line 1: first wrap line at the description column.
let first_line = &layout.wrap_lines[0];
let first_w = (first_line.width() as u16).min(area.x + area.width - desc_x);
buf.set_span(
desc_x,
y,
&Span::styled(first_line.as_str(), desc_style),
first_w,
);
// Lines 2..N: continuation lines aligned under first_line.
for (cont_i, wrap_line) in layout.wrap_lines.iter().enumerate().skip(1) {
let cont_y = y + cont_i as u16;
if cont_y >= area.y + area.height {
break;
}
let cont_w = (wrap_line.width() as u16).min(area.x + area.width - desc_x);
buf.set_span(
desc_x,
cont_y,
&Span::styled(wrap_line.as_str(), desc_style),
cont_w,
);
}
y_cursor = y_cursor.saturating_add(layout.height);
}
// ── Overflow indicator: "… N more" on the row right below the
// last rendered choice. ─────────────────────────────────────
if needs_overflow && visible_end < choices.len() {
let more_count = choices.len() - visible_end;
let overflow_y = y_cursor;
if overflow_y < choices_y + max_choices_h as u16 && overflow_y < area.y + area.height {
let overflow_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let raw = format!("\u{2026} {more_count} more");
let overflow_text: std::borrow::Cow<'_, str> = if raw.width() <= area.width as usize {
std::borrow::Cow::Owned(raw)
} else {
std::borrow::Cow::Owned(truncate_str(&raw, area.width as usize))
};
let overflow_w = (overflow_text.width() as u16).min(area.width);
let overflow_rect = Rect {
x: area.x,
y: overflow_y,
width: area.width,
height: 1,
};
buf.set_style(overflow_rect, Style::default().bg(theme.bg_base));
buf.set_span(
area.x,
overflow_y,
&Span::styled(overflow_text.as_ref(), overflow_style),
overflow_w,
);
}
}
PICKER_RECTS_SCRATCH.with(|cell| {
*cell.borrow_mut() = picker_choice_rects;
});
let _ = total_h; // suppress unused-var warning on some builds
}
// Thread-local scratch to ferry hit-rects out of `render_picking_enum`
// (which takes `&state`) into `state.picker_choice_rects`.
thread_local! {
static PICKER_RECTS_SCRATCH: std::cell::RefCell<Vec<Rect>>
= const { std::cell::RefCell::new(Vec::new()) };
}
/// Read-and-clear the most recent per-choice hit-rects produced by
/// `render_picking_enum`. Returns an empty Vec when called before
/// the first picker render (or after a non-picker frame reset the
/// scratch).
pub(crate) fn take_picker_choice_rects() -> Vec<Rect> {
PICKER_RECTS_SCRATCH.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
/// Render the group sub-sheet: title + description + one row per child Bool
/// toggle (`<marker> <Label> … <on/off>`). Returns the per-child hit-rects
/// (parallel to the group's children) for mouse routing. Mirrors the enum
/// chooser's title/description/list shape but for independent toggles.
fn render_picking_group(
buf: &mut Buffer,
area: Rect,
state: &SettingsModalState,
theme: &Theme,
) -> Vec<Rect> {
let (group_key, child_idx) = match &state.mode {
SettingsModalMode::PickingGroup { key, child_idx } => (*key, *child_idx),
_ => return Vec::new(),
};
let Some(group_meta) = state.registry.find(group_key) else {
return Vec::new();
};
let children = group_children(state, group_key);
if area.width == 0 || area.height == 0 {
return Vec::new();
}
// Chooser shape: title + gap (2) before the description renders.
let header_rows = render_sub_pane_header(
buf,
area,
theme,
group_meta.label,
group_meta.description,
2,
);
if area.height <= header_rows {
return Vec::new();
}
let mut y = area.y + header_rows;
let area_end = area.y + area.height;
// ── Child toggle rows. ────────────────────────────────────────
let mut rects: Vec<Rect> = vec![Rect::default(); children.len()];
for (i, child_key) in children.iter().enumerate() {
if y >= area_end {
break;
}
let Some(child_meta) = state.registry.find(child_key) else {
continue;
};
let is_focused = i == child_idx;
let is_hovered = !is_focused && state.hover_row == Some(i);
let bg = settings_list_row_bg(theme, is_focused, is_hovered);
let row_rect = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
buf.set_style(row_rect, Style::default().bg(bg));
rects[i] = row_rect;
let marker = if is_focused {
crate::glyphs::filled_dot()
} else {
"\u{25CB}"
};
let marker_style = if is_focused {
Style::default().fg(theme.accent_user).bg(bg)
} else {
Style::default().fg(theme.gray).bg(bg)
};
let label_style = if is_focused {
Style::default()
.fg(theme.text_primary)
.bg(bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_primary).bg(bg)
};
// Value read live from the snapshot (refreshed after each toggle).
let on = matches!(state.value_for(child_key), Some(SettingValue::Bool(true)));
let value_text = if on { "on" } else { "off" };
let value_style = if on {
Style::default().fg(theme.accent_user).bg(bg)
} else {
Style::default().fg(theme.gray).bg(bg)
};
// " <marker> <label> … <value> " (value right-aligned with a pad).
buf.set_span(
area.x,
y,
&Span::styled(" ", label_style),
1.min(area.width),
);
if area.width > 1 {
buf.set_span(
area.x + 1,
y,
&Span::styled(marker, marker_style),
PICKER_MARKER_W.min(area.width - 1),
);
}
let label_x = area.x.saturating_add(PICKER_PREFIX_W);
let value_w = value_text.width() as u16;
let value_x = (area.x + area.width)
.saturating_sub(value_w + 1)
.max(label_x);
if value_x > label_x {
let label_room = (value_x - label_x).saturating_sub(1) as usize;
let label_text: std::borrow::Cow<'_, str> = if child_meta.label.width() <= label_room {
std::borrow::Cow::Borrowed(child_meta.label)
} else {
std::borrow::Cow::Owned(truncate_str(child_meta.label, label_room))
};
let label_w = (label_text.width() as u16).min((value_x - label_x).saturating_sub(1));
buf.set_span(
label_x,
y,
&Span::styled(label_text.as_ref(), label_style),
label_w,
);
}
if value_x + value_w <= area.x + area.width {
buf.set_span(value_x, y, &Span::styled(value_text, value_style), value_w);
}
y = y.saturating_add(1);
}
rects
}
/// Layout metadata for one picker choice.
struct PickerChoiceLayout {
height: u16,
wrap_lines: Vec<String>,
}
/// Compute layout for one picker choice (height + wrapped desc lines).
fn compute_picker_choice_layout(choice: &OwnedEnumChoice, area_width: u16) -> PickerChoiceLayout {
// No description → 1 line, symbol + display only.
if choice.description.trim().is_empty() {
return PickerChoiceLayout {
height: 1,
wrap_lines: Vec::new(),
};
}
// Compute the desc column = PICKER_PREFIX_W + display_width + PICKER_SEPARATOR_W.
// Display gets truncated if it'd overflow; mirror that for layout math.
let display_room = (area_width as usize).saturating_sub(PICKER_PREFIX_W as usize);
let display_w = choice.display.width().min(display_room) as u16;
let after_display = PICKER_PREFIX_W.saturating_add(display_w);
let after_sep = after_display.saturating_add(PICKER_SEPARATOR_W);
if after_sep >= area_width {
// No room for description — single-line fallback.
return PickerChoiceLayout {
height: 1,
wrap_lines: Vec::new(),
};
}
let desc_width = area_width.saturating_sub(after_sep) as usize;
if desc_width == 0 {
return PickerChoiceLayout {
height: 1,
wrap_lines: Vec::new(),
};
}
let line = Line::from(Span::raw(choice.description.as_str()));
let wrapped = crate::render::wrapping::word_wrap_line(&line, desc_width);
let wrap_lines: Vec<String> = wrapped
.into_iter()
.map(|l| {
l.spans
.into_iter()
.map(|s| s.content.into_owned())
.collect::<String>()
})
.collect();
// Defensive: treat empty wrap result as no-wrap.
if wrap_lines.is_empty() {
return PickerChoiceLayout {
height: 1,
wrap_lines: Vec::new(),
};
}
PickerChoiceLayout {
height: wrap_lines.len() as u16,
wrap_lines,
}
}
/// Smallest scroll offset that keeps the focused choice fully visible.
fn picker_scroll_offset(
layouts: &[PickerChoiceLayout],
choices_idx: usize,
available_h: u16,
) -> usize {
if layouts.is_empty() || available_h == 0 {
return 0;
}
let n = layouts.len();
let mut offset = 0usize;
loop {
// Sum heights starting at `offset` until adding the next
// choice would exceed `available_h`.
let mut consumed: u16 = 0;
let mut last_visible = offset;
for (i, layout) in layouts.iter().enumerate().skip(offset) {
let next = consumed.saturating_add(layout.height);
if next > available_h {
break;
}
consumed = next;
last_visible = i + 1;
}
// `last_visible` is the exclusive upper bound. If
// `choices_idx` is in `[offset, last_visible)`, this offset
// works.
if choices_idx < last_visible {
return offset;
}
// Otherwise advance. Stop when we've exhausted the list
// (defensive — shouldn't happen since `choices_idx < n`).
if offset + 1 >= n {
return offset;
}
offset += 1;
}
}
/// Min width to draw the Int stepper's ``/`` adornments.
const INT_STEPPER_ADORNMENT_MIN_WIDTH: u16 = 8;
/// Wide-range Int stepper defaults (span > 100): Up/Down small, Left/Right large.
const INT_STEPPER_WIDE_SMALL_STEP: i64 = 5;
const INT_STEPPER_WIDE_LARGE_STEP: i64 = 10;
/// Derive (small, large) step sizes from an Int setting's `[min, max]` span.
/// Narrow dials use unit fine-steps so every in-range value is reachable;
/// wide ranges keep the original ±5 / ±10 feel.
fn int_step_sizes(min: i64, max: i64) -> (i64, i64) {
let span = max.saturating_sub(min).max(0);
if span <= 20 {
// scroll_lines 1..=10 (span 9): unit steps on both small and large.
(1, (span / 5).max(1))
} else if span <= 100 {
// scroll_speed 1..=100 (span 99): unit fine, ±5 coarse.
(1, 5)
} else {
// max_thoughts_width 40..=500 (span 460).
(INT_STEPPER_WIDE_SMALL_STEP, INT_STEPPER_WIDE_LARGE_STEP)
}
}
/// Footer labels for the Int stepper (must be `'static` for `Shortcut`).
fn int_step_footer_labels(min: i64, max: i64) -> (&'static str, &'static str) {
let (small, large) = int_step_sizes(min, max);
match (small, large) {
(1, 1) => ("\u{2191}/\u{2193} +/-1", "\u{2190}/\u{2192} +/-1"),
(1, 5) => ("\u{2191}/\u{2193} +/-1", "\u{2190}/\u{2192} +/-5"),
(5, 10) => ("\u{2191}/\u{2193} +/-5", "\u{2190}/\u{2192} +/-10"),
// Defensive fallback if thresholds change without new static pairs.
(1, _) => ("\u{2191}/\u{2193} +/-1", "\u{2190}/\u{2192} step"),
(5, _) => ("\u{2191}/\u{2193} +/-5", "\u{2190}/\u{2192} step"),
_ => ("\u{2191}/\u{2193} step", "\u{2190}/\u{2192} step"),
}
}
// / (U+2039 / U+203A) — fall back to ASCII `<` / `>` on legacy ConHost.
fn int_stepper_left_glyph() -> &'static str {
crate::glyphs::chevron_left()
}
fn int_stepper_right_glyph() -> &'static str {
crate::glyphs::chevron()
}
/// Sample text for the `max_thoughts_width` live wrap preview.
const MAX_THOUGHTS_WIDTH_PREVIEW_SAMPLE: &str = "Let me trace through the call sites. First, \
I'll need to look at how the dispatch flow handles the new variant. Then I'll verify the \
rollback path preserves the previous state correctly.";
/// Min width to render the wrap preview.
const MAX_THOUGHTS_WIDTH_PREVIEW_MIN_WIDTH: u16 = 30;
/// Min remaining height to render the wrap preview below the stepper.
const MAX_THOUGHTS_WIDTH_PREVIEW_MIN_HEIGHT: u16 = 5;
/// Render the inline editor. Int settings use a stepper; String
/// settings use a text input with cursor and validation feedback.
fn render_editing_value(
buf: &mut Buffer,
area: Rect,
state: &mut SettingsModalState,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
state.editor_adornment_rects = (Rect::default(), Rect::default());
// Snapshot mode payload to avoid borrow conflicts with mut state.
let (setting_key, buffer_owned, cursor_byte, validation_error_owned, kind_is_int) = {
let (setting_key, buffer, cursor_byte, validation_error) = match &state.mode {
SettingsModalMode::EditingValue {
key,
buffer,
cursor_byte,
validation_error,
} => (*key, buffer.clone(), *cursor_byte, validation_error.clone()),
_ => return,
};
let kind_is_int = state
.registry
.find(setting_key)
.map(|m| matches!(m.kind, SettingKind::Int { .. }))
.unwrap_or(false);
(
setting_key,
buffer,
cursor_byte,
validation_error,
kind_is_int,
)
};
if kind_is_int {
let Some(meta) = state.registry.find(setting_key) else {
return;
};
// Snapshot meta fields to release registry borrow.
let label = meta.label;
let description = meta.description;
render_int_stepper(
buf,
area,
state,
setting_key,
label,
description,
&buffer_owned,
theme,
);
return;
}
let buffer = buffer_owned.as_str();
let validation_error = validation_error_owned.as_deref();
let Some(meta) = state.registry.find(setting_key) else {
return;
};
// Editors reserve title + gap + the input row (3) before the description.
let header_rows = render_sub_pane_header(buf, area, theme, meta.label, meta.description, 3);
if area.height <= header_rows {
return;
}
let input_y = area.y + header_rows;
// ── Row 3: input line. ────────────────────────────────────────
let has_error = validation_error.is_some();
let input_bg = theme.bg_visual;
let input_fg = if has_error {
theme.accent_error
} else {
theme.text_primary
};
let cursor_style = Style::default().fg(theme.accent_user).bg(input_bg);
let input_style = Style::default().fg(input_fg).bg(input_bg);
let input_row_rect = Rect {
x: area.x,
y: input_y,
width: area.width,
height: 1,
};
buf.set_style(input_row_rect, Style::default().bg(theme.bg_base));
let input_x = area.x;
let buffer_room_end_x = area.x + area.width;
let buffer_room = buffer_room_end_x.saturating_sub(input_x) as usize;
if buffer_room == 0 {
return; // No room to render the buffer.
}
let input_strip_rect = Rect {
x: input_x,
y: input_y,
width: buffer_room as u16,
height: 1,
};
buf.set_style(input_strip_rect, Style::default().bg(input_bg));
let cursor_reserve = 1usize;
let visible_buffer_w = buffer_room.saturating_sub(cursor_reserve);
// Empty-buffer placeholder.
if buffer.is_empty() {
let placeholder = match &meta.kind {
SettingKind::String { validator, .. } => match validator {
StringValidator::KnownModel => "<empty — use shell default>",
StringValidator::NonEmptyToken => "<type a value>",
StringValidator::Any => "<type a value>",
},
_ => "",
};
if !placeholder.is_empty() && visible_buffer_w > 0 {
let placeholder_text: std::borrow::Cow<'_, str> =
if placeholder.width() <= visible_buffer_w {
std::borrow::Cow::Borrowed(placeholder)
} else {
std::borrow::Cow::Owned(truncate_str(placeholder, visible_buffer_w))
};
let placeholder_w = (placeholder_text.width() as u16).min(visible_buffer_w as u16);
let placeholder_style = Style::default().fg(theme.gray_dim).bg(input_bg);
buf.set_span(
input_x,
input_y,
&Span::styled(placeholder_text.as_ref(), placeholder_style),
placeholder_w,
);
}
// Render the cursor at the start.
let cursor_x = input_x;
buf.set_span(
cursor_x,
input_y,
&Span::styled(crate::glyphs::selection_bar(), cursor_style),
1,
);
} else {
// Cursor-following pan.
let cursor_col = buffer[..cursor_byte.min(buffer.len())].width();
let buffer_w = buffer.width();
let view_offset = if buffer_w <= visible_buffer_w {
0
} else if cursor_col >= visible_buffer_w {
cursor_col + 1 - visible_buffer_w
} else {
// Cursor fits within the first window; no scroll.
0
};
let start_byte = if view_offset == 0 {
0
} else {
let mut acc = 0usize;
buffer
.char_indices()
.find_map(|(idx, ch)| {
if acc >= view_offset {
Some(idx)
} else {
acc += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
None
}
})
.unwrap_or(buffer.len())
};
// Render the visible tail.
let tail = &buffer[start_byte..];
// The visible portion may still be wider than the room when
// a wide grapheme straddles the right boundary; cap with
// `truncate_str` defensively.
let tail_text: std::borrow::Cow<'_, str> = if tail.width() <= visible_buffer_w {
std::borrow::Cow::Borrowed(tail)
} else {
std::borrow::Cow::Owned(truncate_str(tail, visible_buffer_w))
};
let tail_w = (tail_text.width() as u16).min(visible_buffer_w as u16);
buf.set_span(
input_x,
input_y,
&Span::styled(tail_text.as_ref(), input_style),
tail_w,
);
// Cursor lands at the logical column relative to the view.
let cursor_visual_col = cursor_col.saturating_sub(view_offset);
let cursor_x = input_x + (cursor_visual_col as u16).min(buffer_room as u16 - 1);
buf.set_span(
cursor_x,
input_y,
&Span::styled(crate::glyphs::selection_bar(), cursor_style),
1,
);
}
// ── Row 4: validation error. ──────────────────────────────────
if area.height > header_rows + 1
&& let Some(err) = validation_error
{
let err_y = input_y + 1;
let err_style = Style::default().fg(theme.accent_error).bg(theme.bg_base);
let err_text: std::borrow::Cow<'_, str> = if err.width() <= area.width as usize {
std::borrow::Cow::Borrowed(err)
} else {
std::borrow::Cow::Owned(truncate_str(err, area.width as usize))
};
let err_w = (err_text.width() as u16).min(area.width);
buf.set_span(
area.x,
err_y,
&Span::styled(err_text.as_ref(), err_style),
err_w,
);
}
}
/// Render the Int stepper: title + description + centered ` N `.
/// Populates `editor_adornment_rects` for mouse click targets.
#[allow(clippy::too_many_arguments)]
fn render_int_stepper(
buf: &mut Buffer,
area: Rect,
state: &mut SettingsModalState,
setting_key: SettingKey,
label: &'static str,
description: &'static str,
buffer: &str,
theme: &Theme,
) {
// Editors reserve title + gap + the stepper row (3) before the description.
let header_rows = render_sub_pane_header(buf, area, theme, label, description, 3);
if area.height <= header_rows {
return;
}
let stepper_y = area.y + header_rows;
// ── Row 3: centered stepper " N ". ────────────────────────
let value_text = if buffer.is_empty() {
// Defensive — try_enter_editing_value seeds buffer from the
// current value, so this branch should be unreachable, but
// a blank cell would be confusing if a future refactor
// dropped the seed.
"—".to_string()
} else {
buffer.to_string()
};
let value_style = Style::default()
.fg(theme.accent_user)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD);
let arrow_style = Style::default().fg(theme.accent_user).bg(theme.bg_base);
let left_w = int_stepper_left_glyph().width() as u16;
let right_w = int_stepper_right_glyph().width() as u16;
let value_w = value_text.width() as u16;
// Layout: " N " — 2 cells between each glyph for breathing
// room. Total width = left + 2 + value + 2 + right.
let inter_pad: u16 = 2;
let total_w = left_w + inter_pad + value_w + inter_pad + right_w;
let render_arrows = area.width >= INT_STEPPER_ADORNMENT_MIN_WIDTH;
if render_arrows && total_w <= area.width {
// Center the full layout.
let stepper_x = area.x + (area.width - total_w) / 2;
let left_x = stepper_x;
let value_x = left_x + left_w + inter_pad;
let right_x = value_x + value_w + inter_pad;
buf.set_span(
left_x,
stepper_y,
&Span::styled(int_stepper_left_glyph(), arrow_style),
left_w,
);
buf.set_span(
value_x,
stepper_y,
&Span::styled(value_text.as_str(), value_style),
value_w,
);
buf.set_span(
right_x,
stepper_y,
&Span::styled(int_stepper_right_glyph(), arrow_style),
right_w,
);
state.editor_adornment_rects = (
Rect {
x: left_x,
y: stepper_y,
width: left_w,
height: 1,
},
Rect {
x: right_x,
y: stepper_y,
width: right_w,
height: 1,
},
);
} else {
// Too narrow for arrows — render the value alone, centered.
let v_w = value_w.min(area.width);
let value_x = area.x + (area.width - v_w) / 2;
buf.set_span(
value_x,
stepper_y,
&Span::styled(value_text.as_str(), value_style),
v_w,
);
}
// **In-pane hint dropped.** Earlier revisions
// rendered a centered `↑/↓ +/-5 ←/→ +/-10 Enter commit · Esc
// cancel` strip here, but the chrome footer's
// `build_int_editor_shortcuts` already exposes the same content
// at the bottom of the modal. On tall viewports both rendered
// simultaneously — same keys, different separator (`·` vs `|`),
// duplicate visual noise. We rely on the chrome footer alone
// now; if the chrome ever fails to render its shortcut row (a
// future regression), the user can still discover the keys via
// the shortcuts cheatsheet (`?`).
// ── Live wrap preview for max_thoughts_width. ─────────────────
//
// When the user is stepping `max_thoughts_width`, render a
// sample thinking-text preview directly below the stepper that
// wraps live at the current pending value. The preview sits in
// the rows immediately after the stepper (1 blank row + title +
// N content rows); any rows below the last content row of the
// preview stay blank — the chrome footer sits below
// `inner_area`, not inside `area`.
//
// Gated on `setting_key == MAX_THOUGHTS_WIDTH_KEY` so future
// Int settings don't inherit the preview behavior implicitly.
// The string equality is sufficient because key uniqueness is
// enforced at registry-load time (see
// `SettingsRegistry::defaults` / `::from_entries`).
if setting_key == crate::settings::defs::MAX_THOUGHTS_WIDTH_KEY {
let stepper_end_y = stepper_y.saturating_add(1);
let area_end_y = area.y.saturating_add(area.height);
let preview_h = area_end_y.saturating_sub(stepper_end_y);
if preview_h >= MAX_THOUGHTS_WIDTH_PREVIEW_MIN_HEIGHT {
let preview_area = Rect {
x: area.x,
y: stepper_end_y,
width: area.width,
height: preview_h,
};
let pending_value = parse_max_thoughts_width_buffer(buffer);
render_max_thoughts_width_preview(buf, preview_area, pending_value, theme);
}
}
}
/// Parse the Int stepper's buffer back into a `u16` clamped to the
/// `max_thoughts_width` registered bounds. Defensive — the stepper's
/// step path keeps the buffer in range, but a synthetic test fixture
/// or a future code path could seed an out-of-range buffer.
///
/// Both `MIN = 40` and `MAX = 500` fit inside `u16`, so the `clamp`
/// result is always non-negative and ≤ `u16::MAX`. We use
/// `u16::try_from(...).unwrap_or(u16::MAX)` instead of `as u16` so
/// a future bump to `MAX > u16::MAX` saturates rather than silently
/// truncating mod 65536 (security suggestion).
fn parse_max_thoughts_width_buffer(buffer: &str) -> u16 {
let clamped = buffer
.parse::<i64>()
.unwrap_or(crate::settings::defs::MAX_THOUGHTS_WIDTH_MIN)
.clamp(
crate::settings::defs::MAX_THOUGHTS_WIDTH_MIN,
crate::settings::defs::MAX_THOUGHTS_WIDTH_MAX,
);
u16::try_from(clamped).unwrap_or(u16::MAX)
}
/// Render the `max_thoughts_width` live wrap preview block.
///
/// **Vertical layout inside `area`.** Top-anchored. Row 0 of `area`
/// is a 1-row blank gap separating the preview from whatever sits
/// directly above (in the live caller, the stepper row); row 1 is
/// the title; rows 2..(2+content_rows) hold the wrapped content.
/// When `pending_value > area.width` (the preview is clamped) and
/// there are at least two rows of vertical slack below the
/// content, a 1-row blank gap and then a note row carry the text
/// `note: clamped at N cols`. Any rows below the note
/// stay blank — that empty space is intentional and stays
/// unpainted (the chrome footer sits below `inner_area`).
///
/// **Edge cases (per spec).**
/// - `area.width < MAX_THOUGHTS_WIDTH_PREVIEW_MIN_WIDTH` (30): omit
/// the preview entirely — too narrow for readable wrapped text.
/// - `area.height < MAX_THOUGHTS_WIDTH_PREVIEW_MIN_HEIGHT` (5):
/// omit — insufficient vertical budget for the gap + title +
/// 2 content rows layout.
/// - `pending_value > area.width`: clamp the preview width to
/// `area.width`. The title stays plain `preview`; the clamp
/// amount is surfaced via a `note: clamped at N cols` row
/// rendered below the content when there's a row of slack
/// below it. Content takes priority over the note — if there's
/// no room for the note row, it's silently omitted.
/// - Active setting key gating happens at the call site
/// (`setting_key == "max_thoughts_width"`); this helper is pure
/// on the `pending_value` it receives.
///
/// **Theme tokens.**
/// - Title bg: `theme.bg_visual` — the heavier / more saturated of
/// the two "block" bg tokens; matches selection-bg saturation.
/// - Content bg: `theme.bg_highlight` — the lighter / less
/// saturated of the two; still distinguishable from
/// `theme.bg_base` so the preview reads as a contained block.
/// - Title fg + content fg: `theme.text_primary` — same color the
/// scrollback's thinking output renders in. Italic + bold +
/// underlined for the title (the UNDERLINED gives consistent
/// additional visual weight on themes where `bg_visual` vs
/// `bg_highlight` is mostly a hue shift rather than a luma shift,
/// e.g. TokyoNight); italic only for content.
fn render_max_thoughts_width_preview(
buf: &mut Buffer,
area: Rect,
pending_value: u16,
theme: &Theme,
) {
// Edge case 1: terminal area too narrow → omit.
if area.width < MAX_THOUGHTS_WIDTH_PREVIEW_MIN_WIDTH {
return;
}
// Edge case 2: terminal area too short → omit.
if area.height < MAX_THOUGHTS_WIDTH_PREVIEW_MIN_HEIGHT {
return;
}
// Defensive guard: catch future editors who add `\n` / `\t` (or
// any other control char that bypasses word_wrap_line's flow)
// to the sample. `wrap_description` has the same debug_assert
// for the same reason.
debug_assert!(
!MAX_THOUGHTS_WIDTH_PREVIEW_SAMPLE.contains('\n')
&& !MAX_THOUGHTS_WIDTH_PREVIEW_SAMPLE.contains('\t'),
"MAX_THOUGHTS_WIDTH_PREVIEW_SAMPLE must not contain `\\n` or `\\t`; \
word_wrap_line flattens spans byte-for-byte and would render control \
cells as glyphs",
);
// Effective preview content width = min(pending, available).
let pending_w = pending_value.max(1);
let effective_width = pending_w.min(area.width);
let clamped = pending_w > area.width;
// Wrap the sample text at the effective width.
let sample_line = Line::from(Span::raw(MAX_THOUGHTS_WIDTH_PREVIEW_SAMPLE));
let wrapped = crate::render::wrapping::word_wrap_line(&sample_line, effective_width as usize);
// Defensive: a degenerate wrap (zero lines) means we have no
// meaningful preview to show. The MIN_WIDTH=30 gate above makes
// this practically unreachable.
if wrapped.is_empty() {
return;
}
// Layout budget: 1 row blank gap (above title) + 1 row title +
// N content rows. Cap N at the available rows; the rest of
// `area` (below the last content row) stays blank. The
// MIN_HEIGHT=5 gate above guarantees `area.height >= 5`, so
// `available_content_rows >= 3` here — always enough room for
// the minimum 2 content rows. We cap at `wrapped.len()` so a
// short wrap doesn't leave us painting beyond the wrap shape.
let available_content_rows = area.height.saturating_sub(2) as usize;
let visible_content = wrapped.len().min(available_content_rows);
render_preview_block(
buf,
area,
effective_width,
clamped,
&wrapped[..visible_content],
theme,
);
}
/// Inner painter for the preview block — split out so the
/// caller's edge-case dispatch (omit / truncate / full-fit) stays
/// readable and the rendering logic isn't duplicated.
///
/// Caller guarantees:
/// - `effective_width <= area.width`.
/// - `wrapped.len() >= 1` AND `wrapped.len() + 2 <= area.height`
/// (1 row gap above + 1 row title + `wrapped.len()` content rows
/// must all fit inside `area`).
/// - `area.width >= MAX_THOUGHTS_WIDTH_PREVIEW_MIN_WIDTH`.
///
/// Clamped-state surfacing: when `clamped` is true and there are
/// at least 2 rows of vertical slack below the content (i.e.
/// `area.height >= wrapped.len() + 4`), a 1-row blank gap and
/// then a `note: clamped at N cols` row are rendered below the
/// last content row
/// in `theme.text_secondary` with no modifier and on
/// `theme.bg_base` (no block-tinted bg — the note lives in the
/// chrome strip below the preview, not inside the two-tone
/// preview block). When the slack is unavailable, the note is
/// silently omitted; content takes priority.
fn render_preview_block(
buf: &mut Buffer,
area: Rect,
effective_width: u16,
clamped: bool,
wrapped: &[Line<'_>],
theme: &Theme,
) {
debug_assert!(
area.height >= (wrapped.len() as u16).saturating_add(2),
"render_preview_block caller-guarantee violated: \
area.height={} < wrapped.len()+2={}",
area.height,
wrapped.len() + 2,
);
// Top-anchor: row 0 of `area` is a blank gap, row 1 holds the
// title, rows 2..(2+content_rows) hold the wrapped content.
// Any rows below the last content row stay blank, except for
// the optional clamped-note row described at the bottom of
// this function.
let title_y = area.y.saturating_add(1);
// ── Title row. ────────────────────────────────────────────────
let title_bg = theme.bg_visual;
let content_bg = theme.bg_highlight;
let title_fg = theme.text_primary;
let content_fg = theme.text_primary;
// Paint title bg first so partial-trailing-whitespace stays
// tinted (the bg extends to the FULL effective_width on every
// row, including any title columns past the text).
let title_rect = Rect {
x: area.x,
y: title_y,
width: effective_width,
height: 1,
};
buf.set_style(title_rect, Style::default().bg(title_bg));
// Title is always plain lowercase `preview`. The previous
// implementation appended ` · clamped to N cols` to the title
// when the preview clamped to a narrower terminal width; the
// clamp signal has been moved to a note row below the content
// (see the bottom of this function) so the title carries the
// same shape regardless of clamp state.
let title_text: &str = "preview";
let title_text_truncated: std::borrow::Cow<'_, str> =
if title_text.width() <= effective_width as usize {
std::borrow::Cow::Borrowed(title_text)
} else {
std::borrow::Cow::Owned(truncate_str(title_text, effective_width as usize))
};
let title_w = (title_text_truncated.width() as u16).min(effective_width);
// BOLD + ITALIC + UNDERLINED on the title. UNDERLINED gives
// additional visual weight independent of the bg luma — on
// TokyoNight `bg_visual` vs `bg_highlight` is
// mostly a hue shift, not a luma shift, so the underline
// carries the "this is the title" cue on its own.
let title_style = Style::default()
.fg(title_fg)
.bg(title_bg)
.add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED);
buf.set_span(
area.x,
title_y,
&Span::styled(title_text_truncated.as_ref(), title_style),
title_w,
);
// ── Content rows. ─────────────────────────────────────────────
let content_style = Style::default()
.fg(content_fg)
.bg(content_bg)
.add_modifier(Modifier::ITALIC);
for (i, wrap_line) in wrapped.iter().enumerate() {
let row_y = title_y + 1 + i as u16;
// Paint bg first across `effective_width` so trailing
// whitespace on a wrap line is still tinted.
let row_rect = Rect {
x: area.x,
y: row_y,
width: effective_width,
height: 1,
};
buf.set_style(row_rect, Style::default().bg(content_bg));
// Flatten the wrapped line's spans back to a plain string
// (the sample text has no inline styles, so we don't lose
// any styling). Then re-style with our italic + content_fg.
let text: String = wrap_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
let text_w = (text.width() as u16).min(effective_width);
if text_w > 0 {
buf.set_span(area.x, row_y, &Span::styled(text, content_style), text_w);
}
}
// ── Clamped note (optional, height-permitting). ───────────────
//
// When `clamped`, surface the clamp in a low-key note row
// immediately below the last content row. The note is
// height-aware: content takes priority, so if there's no row
// of slack below the content we omit the note entirely. The
// note sits OUTSIDE the two-tone preview bg block — it uses
// `theme.bg_base` so it visually reads as chrome/tip text,
// not as part of the wrap preview. Left-aligned at the same
// x-offset as content (`area.x`).
if clamped {
// One blank row sits between the last content row and the
// note so the note reads as a separate annotation, not a
// continuation of the wrap preview.
let note_y = title_y
.saturating_add(1)
.saturating_add(wrapped.len() as u16)
.saturating_add(1);
let area_end_y = area.y.saturating_add(area.height);
if note_y < area_end_y {
let note_text = format!("note: clamped at {effective_width} cols");
let note_text_truncated: std::borrow::Cow<'_, str> =
if note_text.width() <= area.width as usize {
std::borrow::Cow::Borrowed(note_text.as_str())
} else {
std::borrow::Cow::Owned(truncate_str(&note_text, area.width as usize))
};
let note_w = (note_text_truncated.width() as u16).min(area.width);
// No modifier, no bg tint — the note reads as chrome
// text aligned with the preview's left edge.
let note_style = Style::default().fg(theme.text_secondary).bg(theme.bg_base);
buf.set_span(
area.x,
note_y,
&Span::styled(note_text_truncated.as_ref(), note_style),
note_w,
);
}
}
}
/// `compute_max_label_w` equivalent for settings rows. Caps the column
/// at 24 cols (so a single outlier label can't push the value column
/// off-screen) and never exceeds half the content area width.
/// Mirrors `question_view::compute_max_label_w` semantics.
fn compute_settings_max_label_w(metas: &[SettingMeta], content_w: u16) -> u16 {
const MAX_LABEL_W: u16 = 24;
let half = content_w / 2;
let cap = MAX_LABEL_W.min(half);
metas
.iter()
.map(|m| m.label.width() as u16)
.max()
.unwrap_or(0)
.min(cap)
}
/// Look up the user-friendly display string for an Enum canonical
/// against the setting's own `EnumChoice` catalog. Falls back to the
/// canonical verbatim if the lookup misses (defense-in-depth: a
/// hand-edited corrupted config with an unknown canonical still
/// renders without an empty string, mirroring
/// `display_name_for_canonical`'s pattern).
///
/// Look up the display name for an Enum canonical via the registry.
fn display_for_enum_canonical<'a>(kind: &'a SettingKind, canonical: &'a str) -> &'a str {
if let SettingKind::Enum { choices, .. } = kind {
for c in *choices {
if c.canonical == canonical {
return c.display;
}
}
}
// Fallback: render the canonical verbatim. Defensive — catches a
// schema-vs-renderer drift without crashing the modal.
canonical
}
/// Word-wrap a description string. Returns owned lines for re-styling.
/// Asserts descriptions are single-line (no `\n`/`\t`).
fn wrap_description(description: &str, width: u16) -> Vec<String> {
if description.is_empty() || width == 0 {
return Vec::new();
}
debug_assert!(
!description.contains('\n') && !description.contains('\t'),
"SettingMeta::description is single-line / no tabs by contract: \
description={description:?}. Word-wrap doesn't split on \\n or \\t — \
such chars would render as control codes in a buffer cell. \
Pre-split + iterate if multi-line descriptions become useful.",
);
let line = Line::from(Span::raw(description));
crate::render::wrapping::word_wrap_line(&line, width as usize)
.into_iter()
.map(|l| {
l.spans
.into_iter()
.map(|s| s.content.into_owned())
.collect::<String>()
})
.collect()
}
// Row layout: triangle on left, value right-aligned. Two-line
// layout used when label + value exceed area width.
// Row chrome dimensions.
const ROW_TRIANGLE_PREFIX_W: u16 = 2;
const ROW_GAP_MIN_W: u16 = 1;
const ROW_RIGHT_PAD_W: u16 = 1;
const ROW_CHEVRON_W: u16 = 2;
/// Chevron column width — reserved for all rows for alignment.
const ROW_CHEVRON_COL_W: u16 = ROW_CHEVRON_W;
const ROW_RESTART_PILL_W: u16 = 10; // " · restart" — used for layout budgeting only.
/// Per-row layout decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RowLayout {
OneLine,
/// Value drops to line 2 (label too wide for single line).
TwoLine,
/// Even label alone exceeds width — truncate label, value on line 2.
TwoLineWithLabelTruncation,
}
/// Decide whether a setting row needs 1 or 2 logical lines.
fn row_layout(
area_width: u16,
label: &str,
value_display: &str,
show_restart_pill: bool,
) -> RowLayout {
let restart_w = if show_restart_pill {
ROW_RESTART_PILL_W
} else {
0
};
let label_w = label.width() as u16;
let value_w = value_display.width() as u16;
let one_line_total = ROW_TRIANGLE_PREFIX_W
.saturating_add(label_w)
.saturating_add(ROW_GAP_MIN_W)
.saturating_add(value_w)
.saturating_add(ROW_CHEVRON_COL_W)
.saturating_add(restart_w)
.saturating_add(ROW_RIGHT_PAD_W);
if one_line_total <= area_width {
return RowLayout::OneLine;
}
// Two-line: line 1 hosts the label + (optional) restart pill +
// right pad. If even that doesn't fit, fall back to label
// truncation on line 1.
let line1_full = ROW_TRIANGLE_PREFIX_W
.saturating_add(label_w)
.saturating_add(restart_w)
.saturating_add(ROW_RIGHT_PAD_W);
if line1_full <= area_width {
RowLayout::TwoLine
} else {
RowLayout::TwoLineWithLabelTruncation
}
}
/// Terminal-native themes collapse selection tokens to `Reset`; use ANSI
/// `DarkGray` (not silver `Gray`, which washes out default fg on dark profiles).
fn settings_list_row_bg(theme: &Theme, is_selected: bool, is_hovered: bool) -> Color {
if crate::theme::cache::terminal_native_locked() || matches!(theme.bg_visual, Color::Reset) {
return if is_selected || is_hovered {
Color::DarkGray
} else {
Color::Reset
};
}
if is_selected {
theme.bg_visual
} else if is_hovered {
theme.bg_hover
} else {
theme.bg_base
}
}
#[allow(clippy::too_many_arguments)]
fn render_setting_row(
buf: &mut Buffer,
area: Rect,
meta: &SettingMeta,
value: &SettingValue,
max_label_w: u16,
is_selected: bool,
theme: &Theme,
is_expanded: bool,
is_hovered: bool,
) -> Rect {
let bg = settings_list_row_bg(theme, is_selected, is_hovered);
buf.set_style(area, Style::default().bg(bg));
let mut label_style = Style::default().fg(theme.text_primary).bg(bg);
if is_selected {
label_style = label_style.add_modifier(Modifier::BOLD);
}
let value_style = Style::default().fg(theme.accent_user).bg(bg);
let chevron_style = Style::default().fg(theme.gray).bg(bg);
let restart_style = Style::default()
.fg(theme.gray_dim)
.bg(bg)
.add_modifier(Modifier::ITALIC);
let desc_style = Style::default().fg(theme.gray).bg(bg);
// Enum rows display the user-friendly name, not the canonical.
let value_text_owned;
let value_text: &str = match value {
SettingValue::Bool(b) => {
if *b {
"on"
} else {
"off"
}
}
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)"
} else {
s.as_str()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e),
SettingValue::Int(i) => {
value_text_owned = i.to_string();
&value_text_owned
}
};
let value_style = if matches!(value, SettingValue::Bool(false)) {
Style::default().fg(theme.gray).bg(bg)
} else {
value_style
};
// Chevron for Enum/String/DynamicEnum (opens picker/editor).
let show_chevron = matches!(
(&meta.kind, value),
(SettingKind::Enum { .. }, _)
| (SettingKind::String { .. }, _)
| (SettingKind::DynamicEnum { .. }, _)
);
let chevron_str = format!(" {}", crate::glyphs::chevron()); // → > on legacy ConHost
let chevron_w = if show_chevron {
chevron_str.width() as u16
} else {
0
};
let value_w = value_text.width() as u16;
// Pill only while expanded — change-time feedback is the toast's job, and
// a collapsed non-default row would misread as "restart pending" forever.
let show_restart_pill = meta.restart_required && is_expanded;
let restart_pill_text = " \u{00B7} restart";
let restart_w = if show_restart_pill {
restart_pill_text.width() as u16
} else {
0
};
// Triangle prefix: "▸" collapsed, "▾" expanded.
let triangle = if is_expanded { "\u{25BE}" } else { "\u{25B8}" };
debug_assert_eq!(
triangle.width(),
(ROW_TRIANGLE_PREFIX_W - 1) as usize,
"ROW_TRIANGLE_PREFIX_W = {ROW_TRIANGLE_PREFIX_W} assumes a 1-cell triangle; \
glyph `{triangle}` measures {} cells. A 2-cell triangle (e.g. ▶ / ▼ from \
fold_indicator_span) would shift the entire row column. Update the constant \
or pick a 1-cell glyph.",
triangle.width(),
);
// Fall back to one-line if only 1 line was allocated.
let layout_decision = row_layout(area.width, meta.label, value_text, show_restart_pill);
let layout = if area.height < 2 {
// Only 1 line available — collapse to a one-line render and
// accept that the label might collide with the value column.
RowLayout::OneLine
} else {
layout_decision
};
let _ = max_label_w;
// ── Compute right-side x positions (shared across layouts). ──
// Layout (right-to-left): [restart pill][space][chevron][space][value]
// The 1-cell right pad is baked into `restart_x`.
let restart_x_line1 = (area.x + area.width).saturating_sub(restart_w + 1);
match layout {
RowLayout::OneLine => {
// Chevron column reserved for all rows for alignment.
let chevron_x = restart_x_line1.saturating_sub(ROW_CHEVRON_COL_W);
let value_x = chevron_x.saturating_sub(value_w + 1);
let label_text = format!("{triangle} {}", meta.label);
let label_w = label_text.width() as u16;
let label_max_x = area.x.saturating_add(label_w);
// Cap label end at value_x to never collide with the value column.
let label_end = label_max_x.min(value_x.saturating_sub(1));
let label_used = label_end.saturating_sub(area.x);
if label_used > 0 {
buf.set_span(
area.x,
area.y,
&Span::styled(&label_text, label_style),
label_used,
);
}
if value_x > area.x.saturating_add(label_used) {
buf.set_span(
value_x,
area.y,
&Span::styled(value_text, value_style),
value_w,
);
}
if show_chevron && chevron_w > 0 && chevron_x >= area.x.saturating_add(label_used) {
buf.set_span(
chevron_x,
area.y,
&Span::styled(chevron_str.as_str(), chevron_style),
chevron_w,
);
}
if show_restart_pill && restart_w > 0 {
buf.set_span(
restart_x_line1,
area.y,
&Span::styled(restart_pill_text, restart_style),
restart_w,
);
}
let _ = desc_style;
let _ = is_selected;
// Hit-rect for the value column: spans the value text
// plus the (always-reserved) chevron column. Clicking
// the chevron column on a Bool row is a no-op (no
// glyph there) but still routes to the row, matching
// chevron rows.
Rect {
x: value_x,
y: area.y,
width: value_w.saturating_add(ROW_CHEVRON_COL_W),
height: 1,
}
}
RowLayout::TwoLine | RowLayout::TwoLineWithLabelTruncation => {
// ── Line 1: triangle + label + (restart pill) ──
// Compute how much horizontal space is available to the
// label before colliding with the restart pill.
let label_avail = area
.width
.saturating_sub(restart_w + 1) // restart pill + right pad
.saturating_sub(ROW_TRIANGLE_PREFIX_W);
let label_text_owned: String;
let label_text: &str = match layout {
RowLayout::TwoLineWithLabelTruncation => {
// Truncate the label so triangle + truncated label
// + restart_pill + right_pad fits on line 1.
if label_avail == 0 {
""
} else {
label_text_owned = truncate_str(meta.label, label_avail as usize);
&label_text_owned
}
}
_ => meta.label,
};
let full_label_text = format!("{triangle} {label_text}");
let full_label_w = full_label_text.width() as u16;
let label_used = full_label_w.min(area.width.saturating_sub(restart_w + 1));
if label_used > 0 {
buf.set_span(
area.x,
area.y,
&Span::styled(&full_label_text, label_style),
label_used,
);
}
if show_restart_pill && restart_w > 0 {
buf.set_span(
restart_x_line1,
area.y,
&Span::styled(restart_pill_text, restart_style),
restart_w,
);
}
// ── Line 2: right-aligned value + chevron column ──
//
// The chevron column is reserved
// for ALL rows so the `` glyph is at a constant
// offset; Bool rows leave it empty but the value
// still right-aligns to the column's left edge.
// An earlier version anchored Bool rows on line 2 to
// `area.right - value_w - 1` (no chevron column
// reserved), shifting their `on`/`off` text 2 cells
// to the right of chevron rows' values — a
// visual misalignment.
//
// Anchor line-2's
// chevron-column LEFT EDGE at the same column the
// one-line layout uses: `area.right - ROW_RIGHT_PAD_W
// - ROW_CHEVRON_COL_W` (i.e. `restart_x_line1 -
// ROW_CHEVRON_COL_W` when no restart pill is on
// line 2). The earlier version anchored at
// `area.right - ROW_CHEVRON_COL_W`, so on a row
// that flipped from one-line to two-line layout the
// `` glyph would jump 1 cell rightward — producing
// a staircase between mixed-layout rows. Subtracting
// `ROW_RIGHT_PAD_W` here brings line 2 into pixel
// parity with line 1.
let y2 = area.y + 1;
let chevron_x_line2 = (area.x + area.width)
.saturating_sub(ROW_RIGHT_PAD_W + ROW_CHEVRON_COL_W)
.max(area.x);
let value_x_line2 = chevron_x_line2.saturating_sub(value_w + 1).max(area.x);
// Render value, then chevron, on line 2. Clip if either
// would land off the left edge in a pathologically
// narrow row.
if value_w > 0 && value_x_line2 + value_w <= area.x + area.width {
buf.set_span(
value_x_line2,
y2,
&Span::styled(value_text, value_style),
value_w,
);
}
if show_chevron
&& chevron_w > 0
&& chevron_x_line2 + ROW_CHEVRON_COL_W <= area.x + area.width
{
buf.set_span(
chevron_x_line2,
y2,
&Span::styled(chevron_str.as_str(), chevron_style),
chevron_w,
);
}
let _ = desc_style;
let _ = is_selected;
// Hit-rect for the value column: covers the value text
// + the always-reserved chevron column on LINE 2 only.
// Width is `value_w + ROW_CHEVRON_COL_W`
// (not `value_w + chevron_w`) so the hit-rect spans the
// empty chevron column on Bool rows too.
Rect {
x: value_x_line2,
y: y2,
width: value_w.saturating_add(ROW_CHEVRON_COL_W),
height: 1,
}
}
}
}
/// Render the wrapped description for an expanded row.
fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta, theme: &Theme) {
if area.height == 0 || area.width == 0 {
return;
}
let desc_style = Style::default()
.fg(theme.gray)
.bg(theme.bg_base)
.add_modifier(Modifier::ITALIC);
let desc_src: &str = meta.description;
// Indent 4 cols to nest under the label.
let indent = 4u16.min(area.width);
let wrap_w = area.width.saturating_sub(indent);
if wrap_w == 0 {
return;
}
let line = Line::from(Span::styled(desc_src, desc_style));
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
for (i, wrapped_line) in wrapped.iter().enumerate() {
if (i as u16) >= area.height {
break;
}
let y = area.y + i as u16;
// Paint indent bg first so the wrapped text aligns visually.
for x in area.x..area.x + indent {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_bg(theme.bg_base);
}
}
buf.set_line(area.x + indent, y, wrapped_line, wrap_w);
}
}
/// Fallback render path for a row whose `current_value_for` returned
/// `None` (registry / dispatch skew). Shows the label without a value
/// column so the misconfiguration is visible at runtime; the
/// `every_setting_has_dispatch_arm` test catches the case at CI time.
fn render_setting_row_no_value(
buf: &mut Buffer,
area: Rect,
meta: &SettingMeta,
max_label_w: u16,
is_selected: bool,
theme: &Theme,
) {
let bg = settings_list_row_bg(theme, is_selected, false);
buf.set_style(area, Style::default().bg(bg));
let label_style = Style::default()
.fg(theme.accent_error)
.bg(bg)
.add_modifier(Modifier::BOLD);
let label_max_w = max_label_w;
let label_truncated: std::borrow::Cow<'_, str> = if meta.label.width() <= label_max_w as usize {
std::borrow::Cow::Borrowed(meta.label)
} else {
std::borrow::Cow::Owned(truncate_str(meta.label, label_max_w as usize))
};
let text = format!(" ! {label_truncated} (no read mapping)");
let w = text.width() as u16;
buf.set_span(
area.x,
area.y,
&Span::styled(&text, label_style),
w.min(area.width),
);
}
/// Render a `Group` row in the Browse list: a triangle-prefixed label with a
/// trailing chevron (opens the sub-sheet). Carries no value column. Returns the
/// chevron hit-rect so a click on it opens the sub-sheet like an Enum row.
fn render_setting_group_row(
buf: &mut Buffer,
area: Rect,
meta: &SettingMeta,
is_selected: bool,
is_hovered: bool,
is_expanded: bool,
theme: &Theme,
) -> Rect {
let bg = settings_list_row_bg(theme, is_selected, is_hovered);
buf.set_style(area, Style::default().bg(bg));
let mut label_style = Style::default().fg(theme.text_primary).bg(bg);
if is_selected {
label_style = label_style.add_modifier(Modifier::BOLD);
}
let chevron_style = Style::default().fg(theme.gray).bg(bg);
let chevron_str = format!(" {}", crate::glyphs::chevron());
let chevron_w = chevron_str.width() as u16;
let chevron_x = (area.x + area.width)
.saturating_sub(ROW_RIGHT_PAD_W)
.saturating_sub(ROW_CHEVRON_COL_W);
// Triangle prefix mirrors normal rows: "▾" expanded, "▸" collapsed
// (the group's description expands inline via Right/l like other rows).
let triangle = if is_expanded { "\u{25BE}" } else { "\u{25B8}" };
let label_text = format!("{triangle} {}", meta.label);
let label_cap = chevron_x.saturating_sub(area.x).saturating_sub(1);
let label_w = (label_text.width() as u16).min(label_cap);
if label_w > 0 {
buf.set_span(
area.x,
area.y,
&Span::styled(&label_text, label_style),
label_w,
);
}
if chevron_w > 0 && chevron_x >= area.x.saturating_add(label_w) {
buf.set_span(
chevron_x,
area.y,
&Span::styled(chevron_str.as_str(), chevron_style),
chevron_w,
);
}
// Hit-rect spans the chevron column (a click there opens the sub-sheet).
Rect {
x: chevron_x,
y: area.y,
width: ROW_CHEVRON_COL_W,
height: 1,
}
}
/// Browse footer is fixed (same wrap height on every focused row kind).
fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'static>> {
match state.mode {
SettingsModalMode::Browse => {
let mut shortcuts = vec![
Shortcut {
label: "\u{2191}/\u{2193}/j/k nav",
clickable: false,
id: 0,
},
Shortcut {
label: "g/G top/btm",
clickable: false,
id: 0,
},
Shortcut {
label: "Space/Enter",
clickable: false,
id: 0,
},
Shortcut {
label: "\u{2192} expand",
clickable: false,
id: 0,
},
Shortcut {
label: "/ search",
clickable: false,
id: 0,
},
Shortcut {
label: "d reset",
clickable: false,
id: 0,
},
Shortcut {
label: "F2/Esc close",
clickable: false,
id: 0,
},
];
// Browse is nav mode (filter inactive), so append `i search` last
// (matching the shared pickers).
modal_window::push_vim_nav_search_hint(&mut shortcuts, false);
shortcuts
}
SettingsModalMode::FilterFocused => vec![
Shortcut {
label: "type to filter",
clickable: false,
id: 0,
},
Shortcut {
label: "\u{2191}/\u{2193} nav",
clickable: false,
id: 0,
},
Shortcut {
label: "Backspace edit",
clickable: false,
id: 0,
},
Shortcut {
label: "Enter commit",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc clear",
clickable: false,
id: 0,
},
],
SettingsModalMode::PickingEnum {
supports_preview: sp,
..
} => {
// Labels depend on whether the Enum supports live preview.
let nav_label = if sp {
"\u{2191}/\u{2193} try"
} else {
"\u{2191}/\u{2193} nav"
};
let esc_label = if sp { "Esc revert" } else { "Esc cancel" };
vec![
Shortcut {
label: nav_label,
clickable: false,
id: 0,
},
Shortcut {
label: "Enter commit",
clickable: false,
id: 0,
},
Shortcut {
label: esc_label,
clickable: false,
id: 0,
},
Shortcut {
label: "d reset",
clickable: false,
id: 0,
},
]
}
SettingsModalMode::EditingValue { key, .. } => {
// Int stepper: step-only hints with range-aware deltas.
if let Some(SettingKind::Int { min, max, .. }) =
state.registry.find(key).map(|m| &m.kind)
{
let (small_label, large_label) = int_step_footer_labels(*min, *max);
return vec![
Shortcut {
label: small_label,
clickable: false,
id: 0,
},
Shortcut {
label: large_label,
clickable: false,
id: 0,
},
Shortcut {
label: "Enter commit",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc cancel",
clickable: false,
id: 0,
},
Shortcut {
label: "d reset",
clickable: false,
id: 0,
},
];
}
vec![
Shortcut {
label: "type to edit",
clickable: false,
id: 0,
},
Shortcut {
label: "\u{2190}/\u{2192} cursor",
clickable: false,
id: 0,
},
Shortcut {
label: "Enter commit",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc cancel",
clickable: false,
id: 0,
},
]
}
SettingsModalMode::PickingGroup { .. } => vec![
Shortcut {
label: "\u{2191}/\u{2193}/j/k nav",
clickable: false,
id: 0,
},
Shortcut {
label: "Space/Enter toggle",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc back",
clickable: false,
id: 0,
},
],
}
}
// ---------------------------------------------------------------------------
// Key handling
// ---------------------------------------------------------------------------
/// Handle a key event in the settings modal.
///
/// F2/Ctrl+,/Cmd+, always close regardless of mode. Esc behavior is
/// mode-dependent: Browse delegates to chrome, sub-modes handle it
/// locally (FilterFocused clears query, PickingEnum reverts preview,
/// EditingValue cancels). Space/Enter Repeat events are suppressed
/// to avoid per-tick disk writes.
pub fn handle_settings_key(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
if key.kind == KeyEventKind::Release {
return SettingsKeyOutcome::Unchanged;
}
// Suppress Repeat for toggle keys to avoid per-tick disk writes.
if key.kind == KeyEventKind::Repeat && matches!(key.code, KeyCode::Char(' ') | KeyCode::Enter) {
return SettingsKeyOutcome::Unchanged;
}
if is_close_key(key) {
return SettingsKeyOutcome::Close;
}
// Exhaustive per-mode dispatch.
match state.mode {
SettingsModalMode::Browse => handle_browse(state, key),
SettingsModalMode::FilterFocused => handle_filter_focused(state, key),
SettingsModalMode::PickingEnum { .. } => handle_picking_enum(state, key),
SettingsModalMode::PickingGroup { .. } => handle_picking_group(state, key),
SettingsModalMode::EditingValue { .. } => handle_editing_value(state, key),
}
}
/// Enum chooser key routing. Up/Down dispatches preview actions,
/// Enter commits current choice, Esc reverts to original value.
fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
// Snapshot the current picker state under an immutable borrow so
// the subsequent `state.mode = ...` writes are unambiguous.
let (setting_key, choices_idx, original_value, supports_preview) = match &state.mode {
SettingsModalMode::PickingEnum {
key,
choices_idx,
original_value,
supports_preview,
} => (
*key,
*choices_idx,
original_value.clone(),
*supports_preview,
),
_ => return SettingsKeyOutcome::Unchanged,
};
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
let len = picker_choices_len(state, setting_key);
if choices_idx + 1 >= len {
return SettingsKeyOutcome::Unchanged;
}
set_picker_idx(
state,
setting_key,
choices_idx + 1,
original_value,
supports_preview,
)
}
KeyCode::Up | KeyCode::Char('k') => {
if choices_idx == 0 {
return SettingsKeyOutcome::Unchanged;
}
set_picker_idx(
state,
setting_key,
choices_idx - 1,
original_value,
supports_preview,
)
}
KeyCode::Enter => {
// Commit: dispatch the typed COMMIT Action for the
// currently-focused choice. This is the single place
// per picker open → close cycle that fires
// `Effect::PersistSetting`, eliminating the per-keystroke
// disk write race. The most recent PREVIEW Action (from Up/Down) has
// already mutated the live visual; the commit's
// `set_X_inner` is idempotent on that.
//
// For
// `SettingKind::DynamicEnum` settings (e.g.
// `default_model`, `fork_secondary_model`), the picker
// commits through `action_for_string` rather than
// `action_for_enum_commit` — the canonical is a runtime
// string sourced from the model catalog, which
// `action_for_string` already knows how to resolve via
// `snapshot.resolve_model_name` AND treats the empty
// canonical as a `Clear*` sentinel.
let kind_is_dynamic = matches!(
state.registry.find(setting_key).map(|m| &m.kind),
Some(SettingKind::DynamicEnum { .. })
);
state.transition_to_browse();
if kind_is_dynamic {
let Some(canonical) = picker_choice_at_owned(state, setting_key, choices_idx)
else {
return SettingsKeyOutcome::Changed;
};
if let Some(action) =
action_for_string(setting_key, canonical, &state.pager_snapshot)
{
return SettingsKeyOutcome::Action(action);
}
return SettingsKeyOutcome::Changed;
}
let Some(current_canonical) = picker_choice_at(state, setting_key, choices_idx) else {
return SettingsKeyOutcome::Changed;
};
if let Some(action) = action_for_enum_commit(setting_key, current_canonical) {
return SettingsKeyOutcome::Action(action);
}
SettingsKeyOutcome::Changed
}
KeyCode::Esc => {
// Revert preview and return to Browse. Non-preview Enums
// skip the revert (no live visual was applied).
state.transition_to_browse();
if let SettingValue::Enum(orig) = &original_value
&& let Some(action) = action_for_enum(setting_key, orig)
{
return SettingsKeyOutcome::Action(action);
}
SettingsKeyOutcome::Changed
}
// `d` reset: close picker, revert preview if applicable,
// then open the reset-confirm overlay.
KeyCode::Char('d') if key.modifiers.is_empty() => {
state.transition_to_browse();
if supports_preview
&& let SettingValue::Enum(orig) = &original_value
&& let Some(revert) = action_for_enum(setting_key, orig)
{
return SettingsKeyOutcome::ActionPair(
revert,
Action::OpenResetConfirm { key: setting_key },
);
}
SettingsKeyOutcome::Action(Action::OpenResetConfirm { key: setting_key })
}
_ => SettingsKeyOutcome::Unchanged,
}
}
/// The children of a group setting, or an empty slice if `key` is not a group.
fn group_children(state: &SettingsModalState, key: SettingKey) -> &'static [SettingKey] {
match state.registry.find(key).map(|m| &m.kind) {
Some(SettingKind::Group { children }) => children,
_ => &[],
}
}
/// Group sub-sheet key routing. Up/Down moves between the child toggles;
/// Space/Enter toggles the focused child in place (the sheet stays open);
/// Esc returns to Browse.
fn handle_picking_group(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
let (group_key, child_idx) = match &state.mode {
SettingsModalMode::PickingGroup { key, child_idx } => (*key, *child_idx),
_ => return SettingsKeyOutcome::Unchanged,
};
let children = group_children(state, group_key);
if children.is_empty() {
// Defensive: a group with no children can't be navigated — back out.
state.transition_to_browse();
return SettingsKeyOutcome::Changed;
}
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
if child_idx + 1 >= children.len() {
return SettingsKeyOutcome::Unchanged;
}
state.mode = SettingsModalMode::PickingGroup {
key: group_key,
child_idx: child_idx + 1,
};
SettingsKeyOutcome::Changed
}
KeyCode::Up | KeyCode::Char('k') => {
if child_idx == 0 {
return SettingsKeyOutcome::Unchanged;
}
state.mode = SettingsModalMode::PickingGroup {
key: group_key,
child_idx: child_idx - 1,
};
SettingsKeyOutcome::Changed
}
// Space/Enter toggle the focused child Bool and stay in the sheet so the
// user can flip several tips in a row. The dispatcher refreshes the
// modal snapshot, so the new value paints on the next frame.
KeyCode::Char(' ') | KeyCode::Enter => {
let Some(child_key) = children.get(child_idx).copied() else {
return SettingsKeyOutcome::Unchanged;
};
let cur = match state.value_for(child_key) {
Some(SettingValue::Bool(b)) => b,
_ => return SettingsKeyOutcome::Unchanged,
};
match action_for_bool(child_key, !cur) {
Some(action) => SettingsKeyOutcome::Action(action),
None => SettingsKeyOutcome::Unchanged,
}
}
KeyCode::Esc => {
state.transition_to_browse();
SettingsKeyOutcome::Changed
}
_ => SettingsKeyOutcome::Unchanged,
}
}
/// Common nav body for Up/Down (and j/k aliases) in the picker:
/// update `choices_idx` in-place, look up the new canonical, fire
/// the preview dispatch via `action_for_enum`. Extracted from the
/// Update picker index and optionally dispatch a preview action.
/// `new_idx` must be in-bounds. Preview only fires for Enums with
/// `supports_preview: true`; side-effecting Enums skip preview.
fn set_picker_idx(
state: &mut SettingsModalState,
setting_key: SettingKey,
new_idx: usize,
original_value: SettingValue,
supports_preview: bool,
) -> SettingsKeyOutcome {
let in_bounds = new_idx < picker_choices_len(state, setting_key);
if !in_bounds {
// Caller bounds-checks before calling; belt-and-suspenders
// for refactor safety.
return SettingsKeyOutcome::Unchanged;
}
state.mode = SettingsModalMode::PickingEnum {
key: setting_key,
choices_idx: new_idx,
supports_preview,
original_value,
};
// Preview dispatch for static Enums with preview support.
if supports_preview
&& let Some(new_canonical) = picker_choice_at(state, setting_key, new_idx)
&& let Some(action) = action_for_enum(setting_key, new_canonical)
{
return SettingsKeyOutcome::Action(action);
}
SettingsKeyOutcome::Changed
}
/// Inline string/int editor key routing. Esc cancels, Enter commits.
/// String mode: free-form text with cursor. Int mode: range-aware stepper
/// (Up/Down small, Left/Right large; see [`int_step_sizes`]), clamped to [min,max].
fn handle_editing_value(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
// Snapshot mode payload under an immutable borrow.
let (setting_key, buffer, cursor_byte, validation_error) = match &state.mode {
SettingsModalMode::EditingValue {
key,
buffer,
cursor_byte,
validation_error,
} => (*key, buffer.clone(), *cursor_byte, validation_error.clone()),
_ => return SettingsKeyOutcome::Unchanged,
};
// Look up the registered kind so we know how to handle this
// edit. The lookup is `&self`-only.
let Some(meta) = state.registry.find(setting_key) else {
// Registry skew — log and exit. The CI guards catch this.
tracing::error!(
target: "settings",
key = setting_key,
"EditingValue mode references an unregistered key — exiting to Browse",
);
state.transition_to_browse();
return SettingsKeyOutcome::Changed;
};
let kind_snapshot = meta.kind.clone();
// Int settings dispatch through a stepper-only
// handler. All char-input / cursor-pan / Backspace / Delete /
// Home / End keys are rejected; only Up/Down/Left/Right (and
// j/k/h/l aliases), Enter, and Esc do anything.
if matches!(kind_snapshot, SettingKind::Int { .. }) {
return handle_int_stepper(state, key, setting_key, &buffer, &kind_snapshot);
}
match key.code {
KeyCode::Esc => {
state.transition_to_browse();
SettingsKeyOutcome::Changed
}
KeyCode::Enter => {
// Commit gate: re-validate against the current buffer.
// On failure, refresh the inline error and stay in
// EditingValue.
let error = match &kind_snapshot {
SettingKind::String { validator, .. } => {
validate_string(*validator, &buffer, &state.pager_snapshot.available_models)
}
_ => return SettingsKeyOutcome::Unchanged,
};
if error.is_some() {
update_editing_value_buffer(state, buffer, cursor_byte, error);
return SettingsKeyOutcome::Unchanged;
}
// Dispatch the typed Action and transition to Browse.
let action_opt = match &kind_snapshot {
SettingKind::String { .. } => {
action_for_string(setting_key, buffer.clone(), &state.pager_snapshot)
}
_ => None,
};
state.transition_to_browse();
match action_opt {
Some(action) => SettingsKeyOutcome::Action(action),
None => {
tracing::error!(
target: "settings",
key = setting_key,
"EditingValue commit has no action_for_string arm — registry skew",
);
SettingsKeyOutcome::Changed
}
}
}
KeyCode::Backspace => {
if cursor_byte == 0 {
return SettingsKeyOutcome::Unchanged;
}
let mut new_buf = buffer.clone();
// Find the prev char boundary.
let prev = (0..cursor_byte)
.rev()
.find(|&i| new_buf.is_char_boundary(i))
.unwrap_or(0);
new_buf.replace_range(prev..cursor_byte, "");
let new_cursor = prev;
let new_error = recompute_validation(&kind_snapshot, &new_buf, state);
update_editing_value_buffer(state, new_buf, new_cursor, new_error);
SettingsKeyOutcome::Changed
}
KeyCode::Delete => {
if cursor_byte >= buffer.len() {
return SettingsKeyOutcome::Unchanged;
}
let mut new_buf = buffer.clone();
// Find next char boundary.
let next = (cursor_byte + 1..=new_buf.len())
.find(|&i| new_buf.is_char_boundary(i))
.unwrap_or(new_buf.len());
new_buf.replace_range(cursor_byte..next, "");
let new_error = recompute_validation(&kind_snapshot, &new_buf, state);
update_editing_value_buffer(state, new_buf, cursor_byte, new_error);
SettingsKeyOutcome::Changed
}
KeyCode::Left => {
if cursor_byte == 0 {
return SettingsKeyOutcome::Unchanged;
}
let prev = (0..cursor_byte)
.rev()
.find(|&i| buffer.is_char_boundary(i))
.unwrap_or(0);
update_editing_value_buffer(state, buffer, prev, validation_error);
SettingsKeyOutcome::Changed
}
KeyCode::Right => {
if cursor_byte >= buffer.len() {
return SettingsKeyOutcome::Unchanged;
}
let next = (cursor_byte + 1..=buffer.len())
.find(|&i| buffer.is_char_boundary(i))
.unwrap_or(buffer.len());
update_editing_value_buffer(state, buffer, next, validation_error);
SettingsKeyOutcome::Changed
}
KeyCode::Home => {
update_editing_value_buffer(state, buffer, 0, validation_error);
SettingsKeyOutcome::Changed
}
KeyCode::End => {
let end = buffer.len();
update_editing_value_buffer(state, buffer, end, validation_error);
SettingsKeyOutcome::Changed
}
KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => {
// Defense-in-depth for the (currently unused) String editor path:
// reject control + bidi/format chars so a future `String` setting
// can't reintroduce the Trojan-Source surface.
let accept = match &kind_snapshot {
SettingKind::String { .. } => !crate::render::line_utils::is_unsafe_display_char(c),
_ => false,
};
if !accept {
return SettingsKeyOutcome::Unchanged;
}
let mut new_buf = buffer.clone();
new_buf.insert(cursor_byte, c);
let new_cursor = cursor_byte + c.len_utf8();
let new_error = recompute_validation(&kind_snapshot, &new_buf, state);
update_editing_value_buffer(state, new_buf, new_cursor, new_error);
SettingsKeyOutcome::Changed
}
_ => SettingsKeyOutcome::Unchanged,
}
}
/// Int stepper handler. Steps by range-aware small (Up/Down) or large
/// (Left/Right) deltas from [`int_step_sizes`], clamped to [min,max].
/// Non-stepper keys are rejected.
fn handle_int_stepper(
state: &mut SettingsModalState,
key: &KeyEvent,
setting_key: SettingKey,
buffer: &str,
kind: &SettingKind,
) -> SettingsKeyOutcome {
let SettingKind::Int { min, max, .. } = kind else {
// Caller pre-checked the kind; defensive bail.
return SettingsKeyOutcome::Unchanged;
};
let (small_step, large_step) = int_step_sizes(*min, *max);
let step_delta = |dir: i64, large: bool| -> i64 {
let magnitude = if large { large_step } else { small_step };
dir * magnitude
};
let apply_step = |state: &mut SettingsModalState, delta: i64| -> SettingsKeyOutcome {
let cur = buffer.parse::<i64>().unwrap_or(*min);
let new = cur.saturating_add(delta).clamp(*min, *max);
if new == cur {
// Already clamped — no visible change. Report
// Unchanged so the test for `clamps_to_min/max` can
// distinguish a no-op from a step.
return SettingsKeyOutcome::Unchanged;
}
let new_buf = new.to_string();
let new_cursor = new_buf.len();
update_editing_value_buffer(state, new_buf, new_cursor, None);
SettingsKeyOutcome::Changed
};
// Only modifier-free or SHIFT+arrow events should trigger the
// stepper; Ctrl/Alt/etc carry editor-unrelated semantics
// (selection extend, history, etc) that the stepper has no
// notion of.
if !(key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) {
return SettingsKeyOutcome::Unchanged;
}
match key.code {
KeyCode::Esc => {
state.transition_to_browse();
SettingsKeyOutcome::Changed
}
KeyCode::Enter => {
// Commit. Buffer is guaranteed in-range by the
// clamp on every step; parse + dispatch.
let action_opt = buffer
.parse::<i64>()
.ok()
.and_then(|i| action_for_int(setting_key, i));
state.transition_to_browse();
match action_opt {
Some(action) => SettingsKeyOutcome::Action(action),
None => {
tracing::error!(
target: "settings",
key = setting_key,
"Int stepper Enter has no action_for_int arm — registry skew",
);
SettingsKeyOutcome::Changed
}
}
}
// Up / k: small step up.
KeyCode::Up | KeyCode::Char('k') => apply_step(state, step_delta(1, false)),
// Down / j: small step down.
KeyCode::Down | KeyCode::Char('j') => apply_step(state, step_delta(-1, false)),
// Right / l: large step up.
KeyCode::Right | KeyCode::Char('l') => apply_step(state, step_delta(1, true)),
// Left / h: large step down.
KeyCode::Left | KeyCode::Char('h') => apply_step(state, step_delta(-1, true)),
// `d` in the Int stepper dispatches
// `OpenResetConfirm` like Browse mode does. Close the
// stepper first so dispatch finds `ActiveModal::Settings`
// (the dispatch arm panics in debug mode if it sees a
// non-Settings modal). The stepper has no `d` semantic
// otherwise — letters are intentionally rejected — so
// the interception is safe.
KeyCode::Char('d') if key.modifiers.is_empty() => {
state.transition_to_browse();
SettingsKeyOutcome::Action(Action::OpenResetConfirm { key: setting_key })
}
// Everything else (digits, letters, Backspace, Delete,
// Home, End, Tab, …) is silently ignored — the stepper is
// not a text input.
_ => SettingsKeyOutcome::Unchanged,
}
}
/// Helper: rewrite the EditingValue mode payload with new buffer +
/// cursor + validation. Centralised so future variants don't need
/// to repeat the pattern-construction boilerplate.
fn update_editing_value_buffer(
state: &mut SettingsModalState,
buffer: String,
cursor_byte: usize,
validation_error: Option<String>,
) {
let SettingsModalMode::EditingValue { key, .. } = state.mode else {
// Caller-provided key was lost on mode shift; this is the
// belt-and-suspenders fallback for a future refactor.
return;
};
state.mode = SettingsModalMode::EditingValue {
key,
buffer,
cursor_byte,
validation_error,
};
}
/// Helper: recompute the validation error for the current buffer
/// against the registered validator. Called on every buffer mutation
/// so the inline error indicator stays in sync.
fn recompute_validation(
kind: &SettingKind,
buffer: &str,
state: &SettingsModalState,
) -> Option<String> {
match kind {
SettingKind::String { validator, .. } => {
validate_string(*validator, buffer, &state.pager_snapshot.available_models)
}
SettingKind::Int { min, max, .. } => validate_int(buffer, *min, *max),
_ => None,
}
}
/// Whether `(key, canonical)` is gated off and must not be offered as a choice:
/// `permission_mode`'s "auto" when the auto gate is off. Pure (gates passed as
/// args) so it's unit-testable without touching process globals.
fn enum_choice_gated_off(key: SettingKey, canonical: &str, auto_mode_gate: bool) -> bool {
key == "permission_mode" && canonical == "auto" && !auto_mode_gate
}
/// The effective static Enum choices for a picker, hiding gated-off options so
/// the modal never offers a choice the setter would silently no-op. Every
/// index-based picker path (len / at / render / seed) routes through this.
fn effective_enum_choices<'a>(
key: SettingKey,
choices: &'a [EnumChoice],
snapshot: &PagerLocalSnapshot,
) -> Vec<&'a EnumChoice> {
choices
.iter()
.filter(|c| !enum_choice_gated_off(key, c.canonical, snapshot.auto_mode_gate))
.collect()
}
/// Number of choices for the picker. Handles both
/// `SettingKind::Enum` (static catalog) and `SettingKind::DynamicEnum`
/// (catalog built from the snapshot at picker-open time).
fn picker_choices_len(state: &SettingsModalState, key: SettingKey) -> usize {
state
.registry
.find(key)
.and_then(|m| match &m.kind {
SettingKind::Enum { choices, .. } => {
Some(effective_enum_choices(key, choices, &state.pager_snapshot).len())
}
SettingKind::DynamicEnum { source, .. } => {
Some(dynamic_enum_choices(*source, &state.pager_snapshot).len())
}
_ => None,
})
.unwrap_or(0)
}
/// Canonical value at index `idx` in the picker's choices, or `None`
/// if the key isn't a registered Enum/DynamicEnum or `idx` is out of
/// bounds.
///
/// Returns `Option<&'static str>` for static `SettingKind::Enum`
/// settings (zero allocation, since each `EnumChoice.canonical` is
/// itself `&'static str`).
fn picker_choice_at(
state: &SettingsModalState,
key: SettingKey,
idx: usize,
) -> Option<&'static str> {
let meta = state.registry.find(key)?;
let SettingKind::Enum { choices, .. } = &meta.kind else {
return None;
};
effective_enum_choices(key, choices, &state.pager_snapshot)
.get(idx)
.map(|c| c.canonical)
}
/// Owned-string variant of `picker_choice_at` for picker kinds whose
/// canonicals are runtime-built (`SettingKind::DynamicEnum`).
///
/// Returns the canonical at `idx` as an owned `String`. Allocates one
/// `String` per call — the picker calls this on commit + per-Up/Down
/// only when `supports_preview = true`, so the cost is bounded.
///
/// For static `SettingKind::Enum`, this also resolves correctly
/// (clones the `&'static str` into a `String`), giving the picker
/// a single unified read path when the caller doesn't need to
/// distinguish static vs. dynamic.
fn picker_choice_at_owned(
state: &SettingsModalState,
key: SettingKey,
idx: usize,
) -> Option<String> {
let meta = state.registry.find(key)?;
match &meta.kind {
SettingKind::Enum { choices, .. } => {
effective_enum_choices(key, choices, &state.pager_snapshot)
.get(idx)
.map(|c| c.canonical.to_string())
}
SettingKind::DynamicEnum { source, .. } => {
let resolved = dynamic_enum_choices(*source, &state.pager_snapshot);
resolved.get(idx).map(|c| c.canonical.clone())
}
_ => None,
}
}
/// F2 / Ctrl+, / Cmd+, are the modal-internal close keys.
///
/// Esc is intentionally NOT matched here: the `ModalWindow` chrome
/// (`views/modal_window.rs:handle_modal_key`) intercepts Esc and
/// returns `ModalWindowOutcome::CloseRequested` before this function
/// sees the event in Browse mode. `handle_filter_focused` has its own
/// Esc arm that exits filter mode without closing. Documented in the
/// module docstring.
fn is_close_key(key: &KeyEvent) -> bool {
if key.code == KeyCode::F(2) {
return true;
}
if key.code == KeyCode::Char(',')
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::SUPER))
{
return true;
}
false
}
fn changed_if(b: bool) -> SettingsKeyOutcome {
if b {
SettingsKeyOutcome::Changed
} else {
SettingsKeyOutcome::Unchanged
}
}
/// Helper for `handle_settings_mouse`: when a sub-pane mouse handler
/// returns `Unchanged` but the breadcrumb hover flipped, upgrade to
/// `Changed` so the renderer repaints the breadcrumb with the new
/// fg color. Non-`Unchanged` outcomes pass through unmodified so an
/// `Action` or `Changed` from the inner handler keeps its meaning.
fn upgrade_if_breadcrumb_flipped(
outcome: SettingsKeyOutcome,
breadcrumb_flipped: bool,
) -> SettingsKeyOutcome {
if breadcrumb_flipped && matches!(outcome, SettingsKeyOutcome::Unchanged) {
SettingsKeyOutcome::Changed
} else {
outcome
}
}
fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
match key.code {
KeyCode::Down | KeyCode::Char('j') => changed_if(state.advance_next()),
KeyCode::Up | KeyCode::Char('k') => changed_if(state.advance_prev()),
KeyCode::PageDown => {
let mut moved = false;
for _ in 0..10 {
moved |= state.advance_next();
}
changed_if(moved)
}
KeyCode::PageUp => {
let mut moved = false;
for _ in 0..10 {
moved |= state.advance_prev();
}
changed_if(moved)
}
KeyCode::Char('g') if key.modifiers.is_empty() => {
// First selectable row IN THE FILTERED SET. When no filter
// is active, `filtered_cache` is `(0..rows.len())` so this
// resolves to the first row.
let first = state
.filtered_cache
.iter()
.copied()
.find(|&idx| matches!(state.rows[idx], RowEntry::Setting { .. }))
.unwrap_or(state.selected);
if first != state.selected {
state.selected = first;
SettingsKeyOutcome::Changed
} else {
SettingsKeyOutcome::Unchanged
}
}
KeyCode::Char('G') => {
// Last selectable row IN THE FILTERED SET.
let last = state
.filtered_cache
.iter()
.rev()
.copied()
.find(|&idx| matches!(state.rows[idx], RowEntry::Setting { .. }))
.unwrap_or(state.selected);
if last != state.selected {
state.selected = last;
SettingsKeyOutcome::Changed
} else {
SettingsKeyOutcome::Unchanged
}
}
// User-feedback follow-up: Right/`l` expands the focused
// row's description inline; Left/`h` collapses it.
// Expansion is per-row + persists across selection moves
// (multiple rows can be expanded simultaneously).
KeyCode::Right | KeyCode::Char('l') if key.modifiers.is_empty() => {
if let Some((key, _meta)) = state.focused_setting()
&& state.expanded_keys.insert(key)
{
return SettingsKeyOutcome::Changed;
}
SettingsKeyOutcome::Unchanged
}
KeyCode::Left | KeyCode::Char('h') if key.modifiers.is_empty() => {
if let Some((key, _meta)) = state.focused_setting()
&& state.expanded_keys.remove(key)
{
return SettingsKeyOutcome::Changed;
}
SettingsKeyOutcome::Unchanged
}
KeyCode::Char(' ') | KeyCode::Enter => {
if state.try_enter_picking_group() {
return SettingsKeyOutcome::Changed;
}
if let Some(action) = state.toggle_focused_bool() {
return SettingsKeyOutcome::Action(action);
}
if state.try_enter_picking_enum() {
return SettingsKeyOutcome::Changed;
}
if state.try_enter_editing_value() {
return SettingsKeyOutcome::Changed;
}
SettingsKeyOutcome::Unchanged
}
// `i` aliases `/` (vim-nav "press i to search").
KeyCode::Char('/') | KeyCode::Char('i') if key.modifiers.is_empty() => {
state.mode = SettingsModalMode::FilterFocused;
SettingsKeyOutcome::Changed
}
KeyCode::Char('d') if key.modifiers.is_empty() => {
// Reset-to-default. Resolves the focused row's
// setting key and dispatches `Action::OpenResetConfirm`.
// The dispatch arm boxes the SettingsModalState into the
// `ActiveModal::ResetSettingsConfirm { settings_state, key, .. }`
// variant so cancel returns to this exact modal state
// (filter/scroll/selection preserved). Headers and
// unmapped rows are no-ops — `d` only acts on a focused
// setting row.
//
// We intentionally do NOT gate this on whether the
// current value already equals the default — the
// confirmation dialog gives the user a moment to back
// out either way, and the dispatch arm shows an
// "Already at default" toast on idempotent confirm.
match state.focused_setting() {
// Group rows have no scalar default to reset.
Some((_, meta)) if matches!(meta.kind, SettingKind::Group { .. }) => {
SettingsKeyOutcome::Unchanged
}
Some((key, _meta)) => SettingsKeyOutcome::Action(Action::OpenResetConfirm { key }),
// Focused row is a header (or out-of-bounds) — `d`
// has nothing to reset. Unchanged so the user can
// still navigate / type / etc.
None => SettingsKeyOutcome::Unchanged,
}
}
KeyCode::Backspace => {
// Continue editing the query from Browse mode (the commit
// path via Enter preserves the query, so Browse can be
// entered with a non-empty query). Pop one char and
// re-broaden the filter without switching modes. Mirrors
// `memory_modal::handle_browse`'s Backspace arm.
if state.query.pop().is_some() {
state.query_cursor = state.query.len();
state.invalidate_filter();
state.clamp_selected_to_visible();
SettingsKeyOutcome::Changed
} else {
SettingsKeyOutcome::Unchanged
}
}
_ => SettingsKeyOutcome::Unchanged,
}
}
fn handle_filter_focused(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyOutcome {
match key.code {
KeyCode::Esc => {
state.query.clear();
state.query_cursor = 0;
state.invalidate_filter();
state.clamp_selected_to_visible();
state.transition_to_browse();
SettingsKeyOutcome::Changed
}
KeyCode::Enter => {
// Commit the filter: exit FilterFocused, return to Browse,
// PRESERVE the query so the user can immediately Space /
// Enter to toggle the focused (filtered) setting. This is
// the standard TUI filter UX (fixes the
// dead-end where Esc-clear made post-filter toggling
// impossible without re-navigating the full list).
state.transition_to_browse();
SettingsKeyOutcome::Changed
}
KeyCode::Down => changed_if(state.advance_next()),
KeyCode::Up => changed_if(state.advance_prev()),
KeyCode::PageDown => {
// Match Browse mode's fast-scroll affordance (advance 10).
let mut moved = false;
for _ in 0..10 {
moved |= state.advance_next();
}
changed_if(moved)
}
KeyCode::PageUp => {
let mut moved = false;
for _ in 0..10 {
moved |= state.advance_prev();
}
changed_if(moved)
}
KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
// Clears entire query (not cursor-to-start) to match picker behavior.
if state.query.is_empty() {
return SettingsKeyOutcome::Unchanged;
}
state.query.clear();
state.query_cursor = 0;
state.invalidate_filter();
state.clamp_selected_to_visible();
SettingsKeyOutcome::Changed
}
KeyCode::Char(c) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => {
state.query.insert(state.query_cursor, c);
state.query_cursor += c.len_utf8();
state.invalidate_filter();
state.clamp_selected_to_visible();
SettingsKeyOutcome::Changed
}
KeyCode::Backspace => {
if state.query_cursor == 0 {
return SettingsKeyOutcome::Unchanged;
}
let prev = state.query[..state.query_cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
state.query.drain(prev..state.query_cursor);
state.query_cursor = prev;
state.invalidate_filter();
state.clamp_selected_to_visible();
SettingsKeyOutcome::Changed
}
KeyCode::Left => {
if state.query_cursor == 0 {
return SettingsKeyOutcome::Unchanged;
}
state.query_cursor = state.query[..state.query_cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
SettingsKeyOutcome::Changed
}
KeyCode::Right => {
if state.query_cursor >= state.query.len() {
return SettingsKeyOutcome::Unchanged;
}
state.query_cursor = state.query[state.query_cursor..]
.char_indices()
.nth(1)
.map_or(state.query.len(), |(i, _)| state.query_cursor + i);
SettingsKeyOutcome::Changed
}
_ => SettingsKeyOutcome::Unchanged,
}
}
// ---------------------------------------------------------------------------
// Mouse handling
// ---------------------------------------------------------------------------
/// Handle a mouse event in the modal content area.
///
/// Mirrors `memory_modal::handle_memory_mouse` for parity:
/// - Click on a row selects it; click on a Bool row toggles it.
/// - Click on the `[-]` / `[+]` adornments of an open Int editor
/// steps the value.
/// - Scroll wheel scrolls the row list by ~3 rows per tick.
///
/// **Picker short-circuit:** when the modal is in `PickingEnum`
/// mode, every mouse event is a no-op. `EditingValue` mode handles `[-]` / `[+]`
/// clicks AND treats everything else as a no-op.
pub fn handle_settings_mouse(
state: &mut SettingsModalState,
kind: MouseEventKind,
column: u16,
row: u16,
) -> SettingsKeyOutcome {
// Clicking anywhere on the chrome
// breadcrumb (the full `Settings <label>` title) in a
// sub-pane mode collapses back to Browse. Dispatched FIRST
// so it wins over the picker / editor mouse handlers (which
// would otherwise ignore the click as out-of-content). The
// synthetic Esc is routed through the active sub-pane handler
// so the same revert-preview / mode-transition logic runs as
// for keyboard Esc — `handle_picking_enum` reverts the
// preview action, and `handle_editing_value` just transitions
// back.
if matches!(
kind,
MouseEventKind::Down(crossterm::event::MouseButton::Left)
) && let Some(rect) = state.settings_breadcrumb_rect
&& rect_contains(rect, column, row)
{
let synthetic = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
match state.mode {
SettingsModalMode::PickingEnum { .. } => {
return handle_picking_enum(state, &synthetic);
}
SettingsModalMode::PickingGroup { .. } => {
return handle_picking_group(state, &synthetic);
}
SettingsModalMode::EditingValue { .. } => {
return handle_editing_value(state, &synthetic);
}
_ => {}
}
}
// Track hover state for the
// breadcrumb hit-rect so the renderer can repaint the title
// with the brighter `accent_user` fg when the user's mouse
// is over it. Without this cue the breadcrumb is visually
// indistinguishable from the rest of the modal title chrome.
// Tracked here so a hover transition is registered even when
// the row-list / picker / editor mouse handlers below short-
// circuit on the Moved event.
let breadcrumb_hover_flipped = if matches!(kind, MouseEventKind::Moved) {
let now_hovered = state
.settings_breadcrumb_rect
.map(|r| rect_contains(r, column, row))
.unwrap_or(false);
let flipped = now_hovered != state.breadcrumb_hovered;
state.breadcrumb_hovered = now_hovered;
// In sub-pane modes the row-list / picker / editor mouse
// handlers don't update hover_row, so a flipped breadcrumb
// hover is the only thing that could redraw. Force a
// Changed outcome for those modes; in Browse the row-list
// handler below already returns Changed when hover_row
// moves so we let it run.
flipped
} else {
false
};
// Handle `[-]` / `[+]` clicks
// when in EditingValue mode AND the row is an Int. All other
// events in EditingValue (scrolls, off-adornment clicks) are
// no-ops.
if matches!(state.mode, SettingsModalMode::EditingValue { .. }) {
let outcome = handle_editor_mouse(state, kind, column, row);
return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped);
}
// PickingEnum: click-to-pick on choice rects, scroll wheel is a
// no-op (the picker is bounded; scroll there could surprise).
if matches!(state.mode, SettingsModalMode::PickingEnum { .. }) {
let outcome = handle_picker_mouse(state, kind, column, row);
return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped);
}
// PickingGroup: hover tracks the child rects; a click toggles the clicked
// child in place (same bounded-viewport, scroll-is-a-no-op contract).
if matches!(state.mode, SettingsModalMode::PickingGroup { .. }) {
let outcome = handle_group_mouse(state, kind, column, row);
return upgrade_if_breadcrumb_flipped(outcome, breadcrumb_hover_flipped);
}
let on_list = rect_contains(state.list_area, column, row);
// Mouse hover highlight (parity with scrollback).
// Walks `state.row_rects` to find the row under the cursor and
// updates `state.hover_row`. Returns early so subsequent arms
// don't need to repeat the find. Clicks and scrolls fall
// through to the existing arms below.
if matches!(kind, MouseEventKind::Moved) {
let new_hover = state
.row_rects
.iter()
.position(|r| rect_contains(*r, column, row))
.filter(|&idx| matches!(state.rows.get(idx), Some(RowEntry::Setting { .. })));
if new_hover != state.hover_row {
state.hover_row = new_hover;
return SettingsKeyOutcome::Changed;
}
return SettingsKeyOutcome::Unchanged;
}
match kind {
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
if !on_list {
return SettingsKeyOutcome::Unchanged;
}
// Resolve the clicked row.
let clicked_idx = state
.row_rects
.iter()
.position(|r| rect_contains(*r, column, row));
let Some(idx) = clicked_idx else {
return SettingsKeyOutcome::Unchanged;
};
// Clicking a header is a no-op.
if !matches!(state.rows[idx], RowEntry::Setting { .. }) {
return SettingsKeyOutcome::Unchanged;
}
// Two-stage click semantics:
// - Click on a different row: only select (lets the user
// read the description first).
// - Click on the already-selected Bool row: toggle.
// - Click on the already-selected Enum row: open picker.
// - Click on the indicator cells of any Bool / Enum /
// String / Int row: select + activate in one click
// (Fitts's-law nudge — 5-col hit-rect around the
// small glyph).
//
// The per-kind dispatch is
// collapsed into a single `if` chain — the
// `toggle_focused_bool` / `try_enter_picking_enum` /
// `try_enter_editing_value` helpers all return falsy
// for non-matching kinds, so the per-kind predicates
// are redundant.
let row_rect = state.row_rects[idx];
// User-feedback follow-up: col 0 of the row is the
// `▸`/`▾` triangle glyph. A click there toggles
// expansion (no value mutation) — matching the
// keyboard's Right/Left arrow contract. The triangle
// is at exactly column `row_rect.x` and is 1 cell wide.
//
// Two-line rows have `row_rect.height = 2`,
// and the triangle stays on LINE 1 only — clicks on
// line 2's col 0 (which is empty padding) shouldn't
// toggle expansion. The y-check enforces this.
let on_triangle = column == row_rect.x && row == row_rect.y;
// The 5-col Fitts's-law
// indicator hit-rect sits on the
// value column on the right (rather than the left edge). Clicking the Bool's
// `on`/`off` text toggles the bool in one click; clicking
// an Enum/String/DynamicEnum/Int value opens the
// picker/editor in one click. The hit-rect is supplied
// by `render_setting_row` via
// `state.value_hit_rects[idx]`.
let value_rect = state.value_hit_rects.get(idx).copied().unwrap_or_default();
let on_value = rect_contains(value_rect, column, row);
let was_selected_already = state.selected == idx;
let _ = state.select_at(idx);
if on_triangle && let Some((key, _meta)) = state.focused_setting() {
// Toggle expansion. Mirrors the keyboard
// Right/Left arrow contract.
if state.expanded_keys.contains(key) {
state.expanded_keys.remove(key);
} else {
state.expanded_keys.insert(key);
}
return SettingsKeyOutcome::Changed;
}
if on_value || was_selected_already {
if state.try_enter_picking_group() {
return SettingsKeyOutcome::Changed;
}
if let Some(action) = state.toggle_focused_bool() {
return SettingsKeyOutcome::Action(action);
}
if state.try_enter_picking_enum() || state.try_enter_editing_value() {
return SettingsKeyOutcome::Changed;
}
}
// Selection moved (or was already on this row); the
// re-render reflects the new focus.
SettingsKeyOutcome::Changed
}
MouseEventKind::ScrollDown => {
if !on_list {
return SettingsKeyOutcome::Unchanged;
}
let mut moved = false;
for _ in 0..3 {
moved |= state.advance_next();
}
changed_if(moved)
}
MouseEventKind::ScrollUp => {
if !on_list {
return SettingsKeyOutcome::Unchanged;
}
let mut moved = false;
for _ in 0..3 {
moved |= state.advance_prev();
}
changed_if(moved)
}
_ => SettingsKeyOutcome::Unchanged,
}
}
/// Handle a mouse event while the modal is in `PickingEnum` mode.
///
/// Left-click on any line of a choice's multi-line hit-rect moves
/// the picker focus to that choice (and fires the matching preview
/// dispatch, mirroring keyboard Up/Down). Clicks outside any choice
/// rect are no-ops, as are scroll wheel events (the picker viewport
/// is bounded; in-picker scrolling could surprise).
///
/// Continuation lines of a word-wrapped description share the same
/// hit-rect as the choice they belong to — clicking the second line
/// of "Opt out" picks "Opt out", same as clicking its symbol.
fn handle_picker_mouse(
state: &mut SettingsModalState,
kind: MouseEventKind,
column: u16,
row: u16,
) -> SettingsKeyOutcome {
// Hover highlight for picker choices. Tracks the
// choice index under the cursor in `state.hover_row` (same
// field as the row-list path; the field is mode-aware via the
// active `state.mode`).
if matches!(kind, MouseEventKind::Moved) {
let new_hover = state
.picker_choice_rects
.iter()
.position(|r| r.height > 0 && rect_contains(*r, column, row));
if new_hover != state.hover_row {
state.hover_row = new_hover;
return SettingsKeyOutcome::Changed;
}
return SettingsKeyOutcome::Unchanged;
}
let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else {
return SettingsKeyOutcome::Unchanged;
};
// Snapshot the picker payload under the immutable borrow.
let (setting_key, current_idx, original_value, supports_preview) = match &state.mode {
SettingsModalMode::PickingEnum {
key,
choices_idx,
original_value,
supports_preview,
} => (
*key,
*choices_idx,
original_value.clone(),
*supports_preview,
),
_ => return SettingsKeyOutcome::Unchanged,
};
let clicked_idx = state
.picker_choice_rects
.iter()
.position(|r| r.height > 0 && rect_contains(*r, column, row));
let Some(target_idx) = clicked_idx else {
return SettingsKeyOutcome::Unchanged;
};
if target_idx == current_idx {
// Already focused — re-clicking the same choice is a no-op
// (kept for parity with the row-list's "already-focused
// click commits" semantics; commit fires on Enter, not on
// a re-click).
return SettingsKeyOutcome::Unchanged;
}
// Reuse the keyboard nav helper to update `choices_idx` AND
// fire the matching preview Action (when the kind supports it).
set_picker_idx(
state,
setting_key,
target_idx,
original_value,
supports_preview,
)
}
/// Handle a mouse event while the modal is in `PickingGroup` mode.
///
/// Hover tracks the child row under the cursor; a left-click moves focus to the
/// clicked child AND toggles it in one click (toggles, unlike the enum picker's
/// commit-on-Enter). Scroll wheel is a no-op (the sub-sheet is bounded).
fn handle_group_mouse(
state: &mut SettingsModalState,
kind: MouseEventKind,
column: u16,
row: u16,
) -> SettingsKeyOutcome {
if matches!(kind, MouseEventKind::Moved) {
let new_hover = state
.picker_choice_rects
.iter()
.position(|r| r.height > 0 && rect_contains(*r, column, row));
if new_hover != state.hover_row {
state.hover_row = new_hover;
return SettingsKeyOutcome::Changed;
}
return SettingsKeyOutcome::Unchanged;
}
let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else {
return SettingsKeyOutcome::Unchanged;
};
let group_key = match &state.mode {
SettingsModalMode::PickingGroup { key, .. } => *key,
_ => return SettingsKeyOutcome::Unchanged,
};
let children = group_children(state, group_key);
let clicked_idx = state
.picker_choice_rects
.iter()
.position(|r| r.height > 0 && rect_contains(*r, column, row));
let Some(idx) = clicked_idx else {
return SettingsKeyOutcome::Unchanged;
};
state.mode = SettingsModalMode::PickingGroup {
key: group_key,
child_idx: idx,
};
let Some(child_key) = children.get(idx).copied() else {
return SettingsKeyOutcome::Changed;
};
let cur = matches!(state.value_for(child_key), Some(SettingValue::Bool(true)));
match action_for_bool(child_key, !cur) {
Some(action) => SettingsKeyOutcome::Action(action),
None => SettingsKeyOutcome::Changed,
}
}
/// Handle a mouse event while in `EditingValue` mode. Dispatches
/// clicks on the Int editor's `[-]` / `[+]` adornments as
/// keyboard-equivalent Down/Up steps; everything else is a no-op.
fn handle_editor_mouse(
state: &mut SettingsModalState,
kind: MouseEventKind,
column: u16,
row: u16,
) -> SettingsKeyOutcome {
let MouseEventKind::Down(crossterm::event::MouseButton::Left) = kind else {
return SettingsKeyOutcome::Unchanged;
};
let (dec_rect, inc_rect) = state.editor_adornment_rects;
let step_dir = if rect_contains(dec_rect, column, row) {
StepDir::Down
} else if rect_contains(inc_rect, column, row) {
StepDir::Up
} else {
return SettingsKeyOutcome::Unchanged;
};
// Synthesize the equivalent keyboard event so the actual
// step + clamp + validation logic lives in one place
// (`handle_editing_value`). Mirrors the picker's choice-click
// approach.
let synthetic = KeyEvent::new(
match step_dir {
StepDir::Up => KeyCode::Up,
StepDir::Down => KeyCode::Down,
},
KeyModifiers::NONE,
);
handle_editing_value(state, &synthetic)
}
/// Direction tag for the Int editor's spinner mouse-click → key
/// synthesis. Internal to `handle_editor_mouse`.
#[derive(Clone, Copy)]
enum StepDir {
Up,
Down,
}
fn rect_contains(r: Rect, column: u16, row: u16) -> bool {
r.width > 0
&& r.height > 0
&& column >= r.x
&& column < r.x.saturating_add(r.width)
&& row >= r.y
&& row < r.y.saturating_add(r.height)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::{EnumChoice, SettingOwner, SettingsRegistry};
use kigi_shell::agent::config::UiConfig;
fn make_state() -> SettingsModalState {
SettingsModalState::new(
Arc::new(SettingsRegistry::defaults()),
UiConfig::default(),
PagerLocalSnapshot::default(),
)
}
/// The contextual-hints group renders as a single top-level row (children
/// hidden); Enter opens the sub-sheet, Space there toggles the focused
/// child via the typed action, and Esc returns to Browse.
#[test]
fn contextual_hints_group_sub_sheet_flow() {
let mut s = make_state();
// Group row present; child rows hidden from the top-level list.
let group_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
.expect("group row present");
assert!(
!s.rows.iter().any(|r| matches!(
r,
RowEntry::Setting { key, .. } if key.starts_with("contextual_hints.")
)),
"child rows must be hidden from the top-level list",
);
// Focus the group, Enter → PickingGroup on the first child.
s.selected = group_idx;
let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(out, SettingsKeyOutcome::Changed));
assert!(matches!(
s.mode,
SettingsModalMode::PickingGroup { child_idx: 0, .. }
));
// Space toggles the focused child (undo defaults ON → set false).
let out = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
);
assert!(
matches!(
out,
SettingsKeyOutcome::Action(Action::SetContextualHintUndo(false))
),
"Space must toggle the focused child via the typed action, got {out:?}",
);
// Esc returns to Browse.
let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(out, SettingsKeyOutcome::Changed));
assert!(matches!(s.mode, SettingsModalMode::Browse));
}
/// The permission_mode picker hides the "Auto" choice when the auto feature
/// gate is off (matching the Shift+Tab cycle, which skips Auto when gated),
/// and shows it when the gate is on. Other choices are unaffected.
#[test]
fn effective_enum_choices_hides_auto_for_permission_mode_when_gated_off() {
let reg = SettingsRegistry::defaults();
let meta = reg
.find("permission_mode")
.expect("permission_mode registered");
let SettingKind::Enum { choices, .. } = &meta.kind else {
panic!("permission_mode must be Enum");
};
let gated_off = PagerLocalSnapshot {
auto_mode_gate: false,
..PagerLocalSnapshot::default()
};
let filtered = effective_enum_choices("permission_mode", choices, &gated_off);
assert!(
!filtered.iter().any(|c| c.canonical == "auto"),
"Auto must be hidden from the permission_mode picker when the gate is off"
);
assert!(
filtered.iter().any(|c| c.canonical == "ask"),
"non-Auto choices must remain"
);
let gated_on = PagerLocalSnapshot {
auto_mode_gate: true,
..PagerLocalSnapshot::default()
};
let full = effective_enum_choices("permission_mode", choices, &gated_on);
assert!(
full.iter().any(|c| c.canonical == "auto"),
"Auto must be selectable when the gate is on"
);
// A non-gated key is never filtered.
let theme = reg.find("theme").expect("theme registered");
if let SettingKind::Enum {
choices: theme_choices,
..
} = &theme.kind
{
assert_eq!(
effective_enum_choices("theme", theme_choices, &gated_off).len(),
theme_choices.len(),
"non-permission_mode keys are never filtered"
);
}
}
/// Permission_mode's "auto" is gated iff the auto gate is off.
/// Pure — no process-global mutation.
#[test]
fn enum_choice_gated_off_covers_permission() {
assert!(enum_choice_gated_off("permission_mode", "auto", false));
assert!(!enum_choice_gated_off("permission_mode", "auto", true));
}
/// Look up a setting's registered metadata by key (test helper).
fn meta_for(reg: &SettingsRegistry, key: SettingKey) -> &SettingMeta {
reg.all()
.iter()
.find(|m| m.key == key)
.unwrap_or_else(|| panic!("`{key}` not registered"))
}
#[test]
fn setting_row_visible_hides_theme_rows_in_minimal() {
let reg = SettingsRegistry::defaults();
for key in [
"theme",
"auto_dark_theme",
"auto_light_theme",
"display_refresh_auto_cadence",
] {
let meta = meta_for(&reg, key);
assert!(meta.hidden_in_minimal, "{key} must declare the flag");
assert!(!setting_row_visible(meta, true), "{key} in minimal");
assert!(setting_row_visible(meta, false), "{key} in full TUI");
}
assert!(setting_row_visible(meta_for(&reg, "vim_mode"), true));
}
/// `action_for_bool` mirrors `current_value_for`: every registered
/// Bool setting must have an arm here too. Without this test, a
/// future PR could register a Bool setting that the modal can read
/// (via `current_value_for`) but not toggle (because `action_for_bool`
/// silently returns `None`).
#[test]
fn every_setting_has_action_for_bool_arm() {
let reg = SettingsRegistry::defaults();
for meta in reg.all() {
if !matches!(meta.kind, SettingKind::Bool { .. }) {
continue;
}
assert!(
action_for_bool(meta.key, true).is_some(),
"Bool setting `{}` has no action_for_bool arm — \
modal would toggle silently. Add an arm in views/settings_modal.rs::action_for_bool.",
meta.key,
);
assert!(
action_for_bool(meta.key, false).is_some(),
"action_for_bool(`{}`, false) returned None",
meta.key,
);
}
}
/// Mirror of `every_setting_has_action_for_bool_arm` for Enum
/// settings: every preview-supporting registered Enum + every one
/// of its canonical choices must have a matching `action_for_enum`
/// arm. Without this guard, a change could register `theme` in
/// `default_settings()` and forget to add the
/// `"theme" => Some(Action::SetTheme(...))` arm — the picker
/// would silently degrade (nav advances, no Action emitted,
/// preview never fires).
///
/// Non-preview Enums (e.g.
/// `permission_mode` where `supports_preview: false`) are
/// excluded from this check — their picker nav skips
/// `action_for_enum` entirely (gated by `supports_preview` in
/// `set_picker_idx`), so returning `None` is the correct
/// behaviour and would otherwise look like a missing arm. The
/// commit-arm test below covers them.
///
/// With no Enum entries registered the loop body never
/// executes and the check passes vacuously; registering an Enum
/// forces the matching arm in `action_for_enum` to land alongside it.
#[test]
fn every_preview_enum_setting_has_action_for_enum_arm() {
let reg = SettingsRegistry::defaults();
for meta in reg.all() {
let SettingKind::Enum {
choices,
supports_preview,
..
} = &meta.kind
else {
continue;
};
if !*supports_preview {
continue;
}
for c in *choices {
assert!(
action_for_enum(meta.key, c.canonical).is_some(),
"Preview-enabled Enum setting `{}` choice `{}` has no action_for_enum arm — \
picker would silently degrade (no preview/revert Action emitted). \
Add an arm in views/settings_modal.rs::action_for_enum.",
meta.key,
c.canonical,
);
}
}
}
/// Mirror of `every_setting_has_action_for_bool_arm` for
/// `SettingKind::String`. Every registered String setting must
/// have a matching arm in `action_for_string`, otherwise the
/// editor's commit path returns `None` and the Enter keystroke
/// silently no-ops (after exiting EditingValue mode).
///
/// The empty-string path is
/// the canonical "clear default" entry-point — every
/// registered String setting must produce SOME action for
/// empty input (even if it's `ClearDefaultModel` rather than a
/// `SetX` variant).
///
/// **Vacuous-passing note**: today no
/// production setting uses `SettingKind::String` — both
/// `default_model` and `fork_secondary_model` use
/// `DynamicEnum`, and `coding_data_sharing` / `permission_mode`
/// / `plan_mode` are `Enum`. The loop body skips every meta, so
/// this assertion passes vacuously today. It STILL fires as a
/// CI guard the first time a future change registers a String
/// setting without an action arm. Renamed in spirit to
/// `if_a_string_setting_is_added_it_has_an_action_arm` via the
/// panic message; the test fn name keeps `every_*` for
/// consistency with the sibling guards.
#[test]
fn every_string_setting_has_action_for_string_arm() {
let reg = SettingsRegistry::defaults();
let snapshot = PagerLocalSnapshot::default();
for meta in reg.all() {
if !matches!(meta.kind, SettingKind::String { .. }) {
continue;
}
// Empty buffer must produce some action (clear or
// similar) — not None.
assert!(
action_for_string(meta.key, String::new(), &snapshot).is_some(),
"If you just added a String setting, it has no \
action_for_string arm for empty input — editor would \
silently degrade on the user's first Enter. Add an arm \
in views/settings_modal.rs::action_for_string for `{}`.",
meta.key,
);
}
}
/// Every registered
/// `SettingKind::DynamicEnum` setting must have a matching arm
/// in `action_for_string` for the picker's Enter (commit) path,
/// including the empty-canonical sentinel (row 0 of the picker
/// is always "(no override)").
#[test]
fn every_dynamic_enum_setting_has_action_for_string_arm() {
let reg = SettingsRegistry::defaults();
// Seed a synthetic catalog so the resolver path can produce
// a non-empty SetX action — empty-only would mask a missing
// SetX arm.
use agent_client_protocol as acp;
use std::sync::Arc;
let snapshot = PagerLocalSnapshot {
available_models: vec![(
"Test Model".to_string(),
acp::ModelId::new(Arc::from("test-model")),
)],
..PagerLocalSnapshot::default()
};
for meta in reg.all() {
if !matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
continue;
}
// Discriminate on the Action variant, not
// just `is_some()`. A future refactor that swallowed the
// typed `SetDefaultModel` / `SetForkSecondaryModel` into
// a generic `Action::DynamicSettingChanged(...)` would
// pass `is_some()` while breaking the typed dispatch.
let empty_action = action_for_string(meta.key, String::new(), &snapshot);
let nonempty_action = action_for_string(meta.key, "Test Model".to_string(), &snapshot);
match meta.key {
"default_model" => {
assert!(
matches!(empty_action, Some(Action::ClearDefaultModel)),
"default_model empty canonical must produce ClearDefaultModel, \
got {empty_action:?}",
);
assert!(
matches!(nonempty_action, Some(Action::SetDefaultModel(_))),
"default_model non-empty canonical must produce \
SetDefaultModel(_), got {nonempty_action:?}",
);
}
"fork_secondary_model" => {
assert!(
matches!(empty_action, Some(Action::ClearForkSecondaryModel)),
"fork_secondary_model empty canonical must produce \
ClearForkSecondaryModel, got {empty_action:?}",
);
assert!(
matches!(nonempty_action, Some(Action::SetForkSecondaryModel(_))),
"fork_secondary_model non-empty canonical must produce \
SetForkSecondaryModel(_), got {nonempty_action:?}",
);
}
other => panic!(
"Unknown DynamicEnum key `{other}` — add a discriminating arm in \
every_dynamic_enum_setting_has_action_for_string_arm so future \
additions can't silently rely on the generic is_some() check.",
),
}
}
}
/// Mirror of `every_setting_has_action_for_bool_arm` for
/// `SettingKind::Int`. Every registered Int setting must have a
/// matching arm in `action_for_int`.
#[test]
fn every_int_setting_has_action_for_int_arm() {
let reg = SettingsRegistry::defaults();
for meta in reg.all() {
if !matches!(meta.kind, SettingKind::Int { .. }) {
continue;
}
assert!(
action_for_int(meta.key, 0).is_some(),
"Int setting `{}` has no action_for_int arm — \
editor would silently degrade. Add an arm in \
views/settings_modal.rs::action_for_int.",
meta.key,
);
}
}
/// Mirror of `every_preview_enum_setting_has_action_for_enum_arm`
/// for the COMMIT path: every registered Enum + every canonical
/// choice must have a matching `action_for_enum_commit` arm.
/// Unlike the preview arm, this applies to ALL Enums (preview
/// and non-preview) — Enter (commit) is the structural mutation
/// path regardless of preview support.
///
/// `permission_mode` has
/// `supports_preview: false`; this test ensures its commit arm
/// is wired.
#[test]
fn every_enum_setting_has_action_for_enum_commit_arm() {
let reg = SettingsRegistry::defaults();
for meta in reg.all() {
let SettingKind::Enum { choices, .. } = &meta.kind else {
continue;
};
for c in *choices {
assert!(
action_for_enum_commit(meta.key, c.canonical).is_some(),
"Enum setting `{}` choice `{}` has no action_for_enum_commit arm — \
Enter on the picker would no-op silently. Add an arm in \
views/settings_modal.rs::action_for_enum_commit.",
meta.key,
c.canonical,
);
}
}
}
/// The previous behaviour truncated labels
/// at `max_label_w` regardless of available area width. The new
/// behaviour prefers to render the FULL label whenever the row's
/// label + value will fit on one logical line; truncation is
/// reserved for the pathological case where even the label alone
/// is wider than the row (covered by
/// `pathologically_narrow_truncates_label_with_ellipsis`).
///
/// At an 80-col area, a 42-col label easily fits on one line so
/// the full label renders without an ellipsis — the regression
/// the user reported is gone.
#[test]
fn render_setting_row_shows_full_label_when_one_line_fits() {
let meta = SettingMeta {
key: "test-key",
category: SettingCategory::Appearance,
owner: crate::settings::SettingOwner::Shared,
label: "A very long label that exceeds the budget",
description: "Test description.",
keywords: &["test"],
kind: SettingKind::Bool { default: false },
restart_required: false,
hidden_in_minimal: false,
};
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(false),
15, // max_label_w — kept for API compatibility, no longer used.
false,
&theme,
false, // is_expanded
false, // is_hovered
);
let mut rendered = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 0)) {
rendered.push_str(cell.symbol());
}
}
assert!(
!rendered.contains('\u{2026}'),
"Commit 13: a row whose label + value fits on one line \
should NOT show a truncation ellipsis: {rendered:?}"
);
assert!(
rendered.contains("A very long label that exceeds the budget"),
"full label must be visible when one-line layout fits: {rendered:?}"
);
}
/// The default registry contains Appearance settings
/// (3 bools + 3 enums + 1 int = 7 entries), the Editor entry
/// `multiline_mode`, the Agent entries `permission_mode` and
/// `plan_mode`, the Models entry `default_model`, and the
/// Advanced entries `show_tips` and `auto_update`.
/// `default_reasoning_effort` and
/// `auto_compact_threshold_percent` are not exposed in the modal.
#[test]
fn rows_contain_categories_and_settings_through_pr_14() {
let s = make_state();
let headers: Vec<&SettingCategory> = s
.rows
.iter()
.filter_map(|r| {
if let RowEntry::Header { category } = r {
Some(category)
} else {
None
}
})
.collect();
assert_eq!(
headers,
vec![
&SettingCategory::Appearance,
&SettingCategory::Mouse,
&SettingCategory::Editor,
&SettingCategory::Agent,
&SettingCategory::Models,
// The Session category has no registered settings, so its
// header is not emitted.
// Advanced category (first entries:
// `show_tips`, `auto_update`).
&SettingCategory::Advanced,
]
);
let settings: Vec<SettingKey> = s
.rows
.iter()
.filter_map(|r| {
if let RowEntry::Setting { key, .. } = r {
Some(*key)
} else {
None
}
})
.collect();
assert_eq!(
settings,
vec![
// Booleans.
"compact_mode",
// SHELL-owned default screen mode (Appearance; after compact).
"screen_mode",
"show_timestamps",
// Timeline sidebar (Appearance, declared after timestamps).
"show_timeline",
"simple_mode",
// PAGER-owned vim_mode (Appearance,
// paired with simple_mode).
"vim_mode",
// Theme enums.
"theme",
"auto_dark_theme",
"auto_light_theme",
// SHELL-owned render_mermaid (Appearance,
// declared after the theme enums).
"render_mermaid",
// Int in Appearance category.
"max_thoughts_width",
// SHELL-owned show_thinking_blocks (Appearance; live cache).
"show_thinking_blocks",
// PAGER-owned respect_manual_folds (Appearance,
// persisted to pager.toml).
"respect_manual_folds",
// SHELL-owned group_tool_verbs (Appearance; live cache).
"group_tool_verbs",
// SHELL-owned collapsed_edit_blocks (Appearance; live cache,
// default OFF rollout flag).
"collapsed_edit_blocks",
// SHELL-owned display_refresh_auto_cadence (Appearance).
"display_refresh_auto_cadence",
// Mouse — scroll + drag selection. The scroll
// classification/lines/direction knobs follow scroll_speed.
"scroll_speed",
"scroll_mode",
"scroll_lines",
"invert_scroll",
"keep_text_selection",
// PAGER-owned multiline (Editor category).
"multiline_mode",
// SHELL-owned prompt_suggestions (Editor; tab autocomplete
// ghost text, live cache).
"prompt_suggestions",
// SHELL-owned permission_mode (Agent category).
"permission_mode",
// SHELL-owned remember_tool_approvals (Agent category,
// registered right after permission_mode).
"remember_tool_approvals",
// SHELL-owned default_selected_permission (Agent category,
// colocated with permission_mode / plan_mode).
"default_selected_permission",
// SHELL-owned ask_user_question timeout (Agent category,
// registered directly above plan_mode).
"toolset.ask_user_question.timeout_enabled",
// PAGER-owned plan_mode (Agent category).
"plan_mode",
// SHELL-owned default_model (Models category).
"default_model",
// Models category. `default_reasoning_effort` and
// `session_summary_model` are not exposed in the modal.
"fork_secondary_model",
// `auto_compact_threshold_percent` (Session category) is
// not exposed in the modal.
// Advanced category.
"show_tips",
// Per-tip contextual-hints GROUP row, repositioned right after
// `show_tips`. Its 3 child toggles
// (`contextual_hints.{undo,plan_mode,image_input}`) are hidden
// from the top-level list and reached via the sub-sheet.
"contextual_hints",
"auto_update",
// SHELL-owned hunk_tracker_mode (Advanced; `off` disables it).
"hunk_tracker_mode",
]
);
}
#[test]
fn initial_selection_skips_header() {
let s = make_state();
match &s.rows[s.selected] {
RowEntry::Setting { key, .. } => assert_eq!(*key, "compact_mode"),
RowEntry::Header { .. } => panic!("selection landed on a header"),
}
}
/// `j` advances through the row list one selectable entry at a
/// time and is a no-op at the last visible setting.
///
/// Resilient to future setting additions: the test walks `j` to the
/// end of the registry dynamically rather than hardcoding row
/// counts.
#[test]
fn j_advances_past_setting_rows() {
let mut s = make_state();
let key1 = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
// The initial selection is the first selectable row.
let setting_keys: Vec<SettingKey> = s
.rows
.iter()
.filter_map(|r| {
if let RowEntry::Setting { key, .. } = r {
Some(*key)
} else {
None
}
})
.collect();
assert!(setting_keys.len() >= 2, "test requires at least 2 settings");
// Walk j to the last setting; each step must be Changed.
for expected in setting_keys.iter().skip(1) {
assert!(matches!(
handle_settings_key(&mut s, &key1),
SettingsKeyOutcome::Changed
));
match &s.rows[s.selected] {
RowEntry::Setting { key, .. } => assert_eq!(*key, *expected),
_ => panic!("expected setting row after j"),
}
}
// At the last row, j is a no-op.
assert!(matches!(
handle_settings_key(&mut s, &key1),
SettingsKeyOutcome::Unchanged
));
}
#[test]
fn space_on_compact_mode_dispatches_set_compact_mode_true() {
let mut s = make_state();
// Default compact_mode is false → Space dispatches Set(true).
let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
let outcome = handle_settings_key(&mut s, &space);
match outcome {
SettingsKeyOutcome::Action(Action::SetCompactMode(true)) => {}
other => panic!("expected SetCompactMode(true), got {other:?}"),
}
}
#[test]
fn space_on_enum_row_opens_picker() {
let mut s = make_state();
let idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "screen_mode"))
.expect("screen_mode setting row");
s.selected = idx;
let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
let outcome = handle_settings_key(&mut s, &space);
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Space on enum must open picker, got {outcome:?}"
);
assert!(
matches!(s.mode, SettingsModalMode::PickingEnum { .. }),
"expected PickingEnum after Space on screen_mode, got {:?}",
s.mode
);
}
#[test]
fn browse_footer_shortcuts_stable_across_bool_and_enum_focus() {
let mut s = make_state();
let bool_labels: Vec<&str> = build_shortcuts(&s).iter().map(|sc| sc.label).collect();
assert!(
bool_labels.contains(&"Space/Enter"),
"Browse footer must advertise Space/Enter, got {bool_labels:?}"
);
let idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "screen_mode"))
.expect("screen_mode setting row");
s.selected = idx;
let enum_labels: Vec<&str> = build_shortcuts(&s).iter().map(|sc| sc.label).collect();
assert_eq!(
bool_labels, enum_labels,
"Browse footer must not change when focus moves Bool → Enum"
);
}
#[test]
fn enter_on_compact_mode_also_toggles() {
let mut s = make_state();
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let outcome = handle_settings_key(&mut s, &enter);
match outcome {
SettingsKeyOutcome::Action(Action::SetCompactMode(true)) => {}
other => panic!("expected SetCompactMode(true) from Enter, got {other:?}"),
}
}
#[test]
fn f2_closes_modal() {
let mut s = make_state();
let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
assert!(matches!(
handle_settings_key(&mut s, &f2),
SettingsKeyOutcome::Close
));
}
#[test]
fn esc_in_browse_mode_falls_through_to_chrome() {
// Esc is intercepted UPSTREAM by `ModalWindow::handle_modal_key`;
// `handle_settings_key` does not match Esc anymore. See module
// docstring.
let mut s = make_state();
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert!(matches!(
handle_settings_key(&mut s, &esc),
SettingsKeyOutcome::Unchanged
));
}
#[test]
fn ctrl_comma_closes_modal() {
let mut s = make_state();
let key = KeyEvent::new(KeyCode::Char(','), KeyModifiers::CONTROL);
assert!(matches!(
handle_settings_key(&mut s, &key),
SettingsKeyOutcome::Close
));
}
#[test]
fn cmd_comma_closes_modal_on_macos() {
let mut s = make_state();
let key = KeyEvent::new(KeyCode::Char(','), KeyModifiers::SUPER);
assert!(matches!(
handle_settings_key(&mut s, &key),
SettingsKeyOutcome::Close
));
}
#[test]
fn filter_mode_swallows_chars_into_query() {
let mut s = make_state();
let slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
assert!(matches!(
handle_settings_key(&mut s, &slash),
SettingsKeyOutcome::Changed
));
assert!(matches!(s.mode, SettingsModalMode::FilterFocused));
for c in "compact".chars() {
let k = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let _ = handle_settings_key(&mut s, &k);
}
assert_eq!(s.query, "compact");
// Esc exits filter, doesn't close modal.
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert!(matches!(
handle_settings_key(&mut s, &esc),
SettingsKeyOutcome::Changed
));
assert!(matches!(s.mode, SettingsModalMode::Browse));
}
/// `i` aliases `/` without modifiers: from Browse it enters FilterFocused
/// exactly like `/` (vim-nav "press i to search").
#[test]
fn i_key_enters_filter_like_slash() {
let mut s = make_state();
let i = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
assert!(matches!(
handle_settings_key(&mut s, &i),
SettingsKeyOutcome::Changed
));
assert!(matches!(s.mode, SettingsModalMode::FilterFocused));
}
/// The `modifiers.is_empty()` guard: Ctrl+i / Alt+i must NOT enter filter.
#[test]
fn modified_i_does_not_enter_filter() {
for mods in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
let mut s = make_state();
let k = KeyEvent::new(KeyCode::Char('i'), mods);
assert!(matches!(
handle_settings_key(&mut s, &k),
SettingsKeyOutcome::Unchanged
));
assert!(matches!(s.mode, SettingsModalMode::Browse));
}
}
/// Wiring check: the Browse footer carries the shared `i search` hint
/// under vim nav mode. The gate itself is covered centrally by
/// `modal_window::tests::vim_nav_search_hint_only_in_vim_nav_mode`. The
/// explicit `set_vim_mode` pin (a thread-local that, once set, blocks
/// disk-seeding) keeps this independent of the dev's on-disk `[ui].vim_mode`;
/// reset afterward since libtest reuses worker threads.
#[test]
fn browse_footer_advertises_i_search_under_vim() {
crate::appearance::cache::set_vim_mode(true);
let s = make_state();
assert!(matches!(s.mode, SettingsModalMode::Browse));
assert!(
build_shortcuts(&s).iter().any(|sc| sc.label == "i search"),
"vim-mode Browse footer must advertise `i search`"
);
crate::appearance::cache::set_vim_mode(false);
}
#[test]
fn mouse_click_on_bool_row_dispatches_toggle() {
// We can't render to a real Buffer here without a real terminal,
// but we can hand-set the row_rects to simulate the post-render
// state.
let mut s = make_state();
s.list_area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
s.row_rects.resize(s.rows.len(), Rect::default());
// Row 0 is the Appearance header.
s.row_rects[0] = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
// Row 1 is compact_mode.
s.row_rects[1] = Rect {
x: 0,
y: 1,
width: 80,
height: 1,
};
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
5,
1,
);
match outcome {
SettingsKeyOutcome::Action(Action::SetCompactMode(true)) => {}
other => panic!("expected SetCompactMode(true) from click, got {other:?}"),
}
}
#[test]
fn mouse_click_on_header_is_no_op() {
let mut s = make_state();
s.list_area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
s.row_rects.resize(s.rows.len(), Rect::default());
s.row_rects[0] = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
5,
0,
);
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
}
// ---------- mouse hover highlight ----------
#[test]
fn settings_list_row_bg_terminal_native_elevates_selection() {
let theme = Theme::terminal_default();
assert!(matches!(theme.bg_visual, Color::Reset));
assert_eq!(settings_list_row_bg(&theme, true, false), Color::DarkGray);
assert_eq!(settings_list_row_bg(&theme, false, true), Color::DarkGray);
assert_eq!(settings_list_row_bg(&theme, false, false), Color::Reset);
assert_eq!(settings_list_row_bg(&theme, true, true), Color::DarkGray);
}
#[test]
fn settings_list_row_bg_rgb_theme_uses_theme_tokens() {
let theme = Theme::current();
if matches!(theme.bg_visual, Color::Reset) {
return;
}
assert_eq!(settings_list_row_bg(&theme, true, false), theme.bg_visual);
assert_eq!(settings_list_row_bg(&theme, false, true), theme.bg_hover);
assert_eq!(settings_list_row_bg(&theme, false, false), theme.bg_base);
}
/// `MouseEventKind::Moved` over a setting row's hit-rect sets
/// `state.hover_row` to that row's index and reports `Changed`
/// so the next render paints the highlight. Mirrors the
/// scrollback's `hovered_entry` plumbing.
#[test]
fn mouse_moved_over_row_sets_hover_row() {
let mut s = make_state();
s.list_area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
s.row_rects.resize(s.rows.len(), Rect::default());
// Row 0 is a header (Appearance); row 1 is the first
// setting (compact_mode). Place row 1 at y=1 so the
// hover-row position math is unambiguous.
s.row_rects[0] = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
s.row_rects[1] = Rect {
x: 0,
y: 1,
width: 80,
height: 1,
};
assert_eq!(s.hover_row, None, "fresh state must have no hover");
let outcome = handle_settings_mouse(&mut s, MouseEventKind::Moved, 5, 1);
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Moved into a row's rect must report Changed, got {outcome:?}"
);
assert_eq!(
s.hover_row,
Some(1),
"Moved at (5,1) must land on row 1 (compact_mode)",
);
// A second Moved event at the same coordinates is a no-op
// (hover_row already matches → no Changed).
let outcome = handle_settings_mouse(&mut s, MouseEventKind::Moved, 5, 1);
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"repeat Moved at the same row must be Unchanged, got {outcome:?}"
);
}
/// `Moved` outside every row's hit-rect clears `state.hover_row`
/// to `None`. Mirrors the scrollback's behaviour when the mouse
/// drifts off the entry list.
#[test]
fn mouse_moved_outside_modal_clears_hover() {
let mut s = make_state();
s.list_area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
s.row_rects.resize(s.rows.len(), Rect::default());
s.row_rects[1] = Rect {
x: 0,
y: 1,
width: 80,
height: 1,
};
// Seed hover at row 1.
let _ = handle_settings_mouse(&mut s, MouseEventKind::Moved, 5, 1);
assert_eq!(s.hover_row, Some(1));
// Move far outside any row's rect.
let outcome = handle_settings_mouse(&mut s, MouseEventKind::Moved, 5, 50);
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(
s.hover_row, None,
"Moved outside all row rects must clear hover_row",
);
}
/// `Moved` over a header row's hit-rect does NOT set hover_row
/// (headers aren't selectable; painting hover on them would be
/// misleading).
#[test]
fn mouse_moved_over_header_does_not_set_hover() {
let mut s = make_state();
s.list_area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
s.row_rects.resize(s.rows.len(), Rect::default());
s.row_rects[0] = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let outcome = handle_settings_mouse(&mut s, MouseEventKind::Moved, 5, 0);
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"Moved over a header must be Unchanged",
);
assert_eq!(
s.hover_row, None,
"header rows must not register as hovered",
);
}
/// `state.hover_row = Some(idx)` paints the hovered row's bg
/// with the theme's `bg_hover` color. (Mirrors the existing
/// `picker_highlights_current_choice` test's pattern: the
/// `assert_eq` against `theme.bg_hover` survives both colored
/// and quantize-to-Reset color levels.)
#[test]
fn hover_row_renders_with_hover_style() {
let mut s = make_state();
let theme = Theme::current();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 20,
};
let mut buf = Buffer::empty(area);
// Pick a setting row that is NOT the selected row so the
// hover bg vs selection bg distinction is observable.
let setting_indices: Vec<usize> = s
.rows
.iter()
.enumerate()
.filter_map(|(i, r)| matches!(r, RowEntry::Setting { .. }).then_some(i))
.collect();
assert!(
setting_indices.len() >= 2,
"test requires at least 2 selectable rows",
);
let initial_selected = s.selected;
let hover_idx = *setting_indices
.iter()
.find(|&&i| i != initial_selected)
.expect("at least one non-selected setting row must exist");
s.hover_row = Some(hover_idx);
render_rows(&mut buf, area, &mut s, &theme);
// Read back the bg of the painted hover row from the
// first column of the row's allocated area.
let row_rect = s.row_rects[hover_idx];
assert!(
row_rect.width > 0 && row_rect.height > 0,
"hover row must have a non-zero rect after render",
);
let cell = buf
.cell((row_rect.x, row_rect.y))
.expect("rendered cell must exist");
assert_eq!(
cell.style().bg,
Some(settings_list_row_bg(&theme, false, true)),
"hover row must paint with settings list hover bg, got {:?}",
cell.style().bg,
);
}
/// In `PickingEnum` mode, a Moved event over a choice's hit-rect
/// sets `state.hover_row` (semantically: hovered choice index)
/// and the next render paints THAT choice with the hover bg
/// without affecting other choices.
#[test]
fn picker_choice_mouse_hover_highlights_choice() {
let mut s = picker_test_state();
// Populate per-choice rects by rendering once, then drain
// the thread-local stash into `state.picker_choice_rects`.
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 20,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
s.picker_choice_rects = take_picker_choice_rects();
assert!(
s.picker_choice_rects.len() >= 2,
"picker_test_state must produce at least 2 choice rects",
);
// Pre-condition: choices_idx = 0 (the initial focus). Hover
// over choice 1 — distinct from the focused choice.
let target_rect = s.picker_choice_rects[1];
assert!(
target_rect.height > 0,
"choice 1 must be visible after render",
);
let click_y = target_rect.y;
let click_x = target_rect.x + target_rect.width / 2;
let outcome = handle_settings_mouse(&mut s, MouseEventKind::Moved, click_x, click_y);
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Moved over choice 1 must be Changed, got {outcome:?}",
);
assert_eq!(
s.hover_row,
Some(1),
"Moved over choice 1 must set hover_row = Some(1)",
);
// Re-render and observe the hover bg on choice 1's row.
// (`bg_hover` may quantize to `Color::Reset` in tests with
// `NO_COLOR` set — same caveat as
// `picker_highlights_current_choice`. We assert tautologically
// against `theme.bg_hover`; the wiring assertion is the
// hover_row state mutation above plus the focused-choice
// bg_visual contrast.)
let mut buf2 = Buffer::empty(area);
render_picking_enum(&mut buf2, area, &s, &theme);
let new_rects = take_picker_choice_rects();
let rect1 = new_rects[1];
let cell1 = buf2
.cell((rect1.x, rect1.y))
.expect("choice 1 cell must exist");
assert_eq!(
cell1.style().bg,
Some(settings_list_row_bg(&theme, false, true)),
"hovered choice must paint list hover bg, got {:?}",
cell1.style().bg,
);
// Focused choice (index 0) keeps selection bg — selection wins
// over hover. Verifies the `is_focused` branch precedence.
let rect0 = new_rects[0];
let cell0 = buf2
.cell((rect0.x, rect0.y))
.expect("choice 0 cell must exist");
assert_eq!(
cell0.style().bg,
Some(settings_list_row_bg(&theme, true, false)),
"focused choice must keep selection bg even when hover is elsewhere",
);
}
/// Mode transitions (Browse → PickingEnum and Browse →
/// EditingValue) clear `hover_row` so a stale row-index from
/// the old mode doesn't paint a wrong choice/row in the new
/// mode before the next Moved event arrives.
#[test]
fn mode_transition_browse_to_picking_enum_clears_hover_row() {
let mut s = make_state();
navigate_to_enum_row(&mut s);
s.hover_row = Some(s.selected);
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(s.mode, SettingsModalMode::PickingEnum { .. }),
"Enter on enum row must transition to PickingEnum",
);
assert_eq!(
s.hover_row, None,
"Browse → PickingEnum transition must clear hover_row",
);
}
/// Sibling of `mode_transition_browse_to_picking_enum_clears_hover_row` —
/// the same hover-clear contract applies to the
/// `Browse → EditingValue` transition (Enter on an `Int`-kind
/// row). The prior `mode_transition_clears_hover_row`
/// docstring claimed to cover both transitions but only exercised
/// `PickingEnum`.
#[test]
fn mode_transition_browse_to_editing_value_clears_hover_row() {
let mut s = make_state();
navigate_to_int_row(&mut s);
s.hover_row = Some(s.selected);
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(s.mode, SettingsModalMode::EditingValue { .. }),
"Enter on Int row must transition to EditingValue, got {:?}",
s.mode,
);
assert_eq!(
s.hover_row, None,
"Browse → EditingValue transition must clear hover_row",
);
}
/// Sibling for the reverse direction: PickingEnum → Browse (Esc)
/// also clears any stale hover that landed during the picker.
#[test]
fn mode_transition_picking_enum_to_browse_clears_hover_row() {
let mut s = make_state();
navigate_to_enum_row(&mut s);
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(s.mode, SettingsModalMode::PickingEnum { .. }));
// Seed picker-mode hover (e.g. mouse moved over a non-focused
// choice while in the picker).
s.hover_row = Some(2);
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Esc in PickingEnum must transition to Browse",
);
assert_eq!(
s.hover_row, None,
"PickingEnum → Browse transition must clear hover_row",
);
}
/// Helper for `mode_transition_browse_to_editing_value_clears_hover_row`:
/// walks selection forward to the first `Int`-kind row registered
/// in defaults (currently `max_thoughts_width`).
fn navigate_to_int_row(s: &mut SettingsModalState) {
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
for _ in 0..s.rows.len() {
if let Some((_, meta)) = s.focused_setting()
&& matches!(meta.kind, SettingKind::Int { .. })
{
return;
}
let outcome = handle_settings_key(s, &key);
if matches!(outcome, SettingsKeyOutcome::Unchanged) {
break;
}
}
panic!("no Int-kind row found in default registry");
}
/// Helper for `mode_transition_clears_hover_row`: walks selection
/// forward to the first Enum-kind row registered in defaults.
fn navigate_to_enum_row(s: &mut SettingsModalState) {
let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
for _ in 0..s.rows.len() {
if let Some((_, meta)) = s.focused_setting()
&& matches!(meta.kind, SettingKind::Enum { .. })
{
return;
}
let outcome = handle_settings_key(s, &key);
if matches!(outcome, SettingsKeyOutcome::Unchanged) {
break;
}
}
panic!("no Enum-kind row found in default registry");
}
/// Scroll-wheel down emits 3 advances. From the initial selection
/// (first setting), 3 settings forward must land on whatever is at
/// position [first_setting + 3] in the registry. Resilient to PR-N
/// additions.
#[test]
fn scroll_wheel_advances_selection() {
let mut s = make_state();
s.list_area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
let setting_keys: Vec<SettingKey> = s
.rows
.iter()
.filter_map(|r| {
if let RowEntry::Setting { key, .. } = r {
Some(*key)
} else {
None
}
})
.collect();
let outcome = handle_settings_mouse(&mut s, MouseEventKind::ScrollDown, 5, 5);
// 3 advances from the first setting → setting at position 3
// (or the last setting if fewer than 4 are registered — the
// advance_next no-op at the boundary absorbs extra advances).
let expected = setting_keys
.get(3)
.copied()
.unwrap_or(*setting_keys.last().unwrap());
match &s.rows[s.selected] {
RowEntry::Setting { key, .. } => assert_eq!(*key, expected),
_ => panic!("expected setting after scroll"),
}
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
}
// -- routing scaffold tests --
//
// The enum chooser and string/int editor declare their
// mode variants alongside Browse and route Esc → Browse so the
// scaffold doesn't ship dead `unimplemented!()` panics. These
// tests pin the routing.
/// `render_setting_row` produces the "restart" pill when
/// `meta.restart_required == true` AND the row is expanded. When
/// no registered setting sets `restart_required`, this test still
/// exercises the render path via a synthetic setting with the flag
/// set.
///
/// The pill is a property
/// label, not a "restart pending" tracker — a collapsed
/// non-default row showing it forever misreads as pending, so
/// the old `is_edited` trigger is gone and only `is_expanded`
/// gates it (the "(restart to apply)" toast covers change-time
/// feedback). Arms: expanded (pill) and edited-but-collapsed
/// (no pill — the exact reported repro).
#[test]
fn render_setting_row_emits_restart_pill_when_required() {
let meta = SettingMeta {
key: "test-key",
category: SettingCategory::Appearance,
owner: crate::settings::SettingOwner::Shared,
label: "Test setting",
description: "Test description.",
keywords: &["test"],
kind: SettingKind::Bool { default: false },
restart_required: true,
hidden_in_minimal: false,
};
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
// Arm 1: expanded → pill renders even at default value.
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(false),
10, // max_label_w
false, // is_selected
&theme,
true, // is_expanded — gate on
false, // is_hovered
);
let mut rendered = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 0)) {
rendered.push_str(cell.symbol());
}
}
assert!(
rendered.contains("restart"),
"expanded row must contain the 'restart' pill: {rendered:?}"
);
// Arm 2: edited but collapsed → NO pill (the reported repro).
let mut buf = Buffer::empty(area);
render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(true), // edited from default `false`
10,
false,
&theme,
false, // is_expanded — off
false, // is_hovered
);
let mut rendered = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 0)) {
rendered.push_str(cell.symbol());
}
}
assert!(
!rendered.contains("restart"),
"edited-but-collapsed row must NOT contain the 'restart' pill: {rendered:?}"
);
}
/// Counterpart to `render_setting_row_emits_restart_pill_when_required`:
/// the pill is HIDDEN on any collapsed row. User-feedback
/// follow-up — keeps the modal uncluttered for the common
/// "I'm just browsing" case.
#[test]
fn render_setting_row_hides_restart_pill_when_at_default_and_collapsed() {
let meta = SettingMeta {
key: "test-key",
category: SettingCategory::Appearance,
owner: crate::settings::SettingOwner::Shared,
label: "Test setting",
description: "Test description.",
keywords: &["test"],
kind: SettingKind::Bool { default: false },
restart_required: true,
hidden_in_minimal: false,
};
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(false),
10,
false,
&theme,
false, // is_expanded
false, // is_hovered
);
let mut rendered = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 0)) {
rendered.push_str(cell.symbol());
}
}
assert!(
!rendered.contains("restart"),
"at-default, not-expanded row must NOT contain the 'restart' pill: {rendered:?}"
);
}
// -- render-buffer tests for the
// editor's cursor and validation-error display. --
/// Build an editor state pre-positioned at the start of a known
/// buffer for `default_model`. Catalog populated so the
/// `KnownModel` validator has data to validate against.
fn editor_render_fixture(buffer: &str, cursor_byte: usize) -> SettingsModalState {
use agent_client_protocol as acp;
use std::sync::Arc;
// An earlier fixture used `default_model` (a SHELL `String`
// setting) to exercise the inline editor. `default_model` is
// now a `SettingKind::DynamicEnum`, so the
// String editor no longer has a registered consumer in the
// production catalog. We construct a synthetic registry
// with a single `KnownModel`-validated String entry to keep
// the editor-render contract under test — the production
// editor code path stays exercised even though no live
// setting wires it up today.
let synthetic_meta = SettingMeta {
key: "default_model",
category: SettingCategory::Models,
owner: crate::settings::SettingOwner::Shell,
label: "Default model (synthetic)",
// Short description so it fits in 1 line even at the
// narrowest test width (the editor's word-wrap path
// would otherwise push the input
// row down on narrow widths, breaking the cursor-pan
// tests' hardcoded input-row y position).
description: "Test.",
keywords: &["test"],
kind: SettingKind::String {
default: "",
validator: StringValidator::KnownModel,
},
restart_required: false,
hidden_in_minimal: false,
};
let registry = SettingsRegistry::from_entries(vec![synthetic_meta]);
let snapshot = PagerLocalSnapshot {
available_models: vec![(
"Kigi Test".to_string(),
acp::ModelId::new(Arc::from("kigi-test")),
)],
..PagerLocalSnapshot::default()
};
let mut s = SettingsModalState::new(Arc::new(registry), UiConfig::default(), snapshot);
let validation_error = validate_string(
StringValidator::KnownModel,
buffer,
&s.pager_snapshot.available_models,
);
s.mode = SettingsModalMode::EditingValue {
key: "default_model",
buffer: buffer.to_string(),
cursor_byte,
validation_error,
};
s
}
/// Cursor lands at the visual column matching `cursor_byte` for
/// buffers that fit entirely within the visible window.
#[test]
fn render_editing_value_cursor_at_logical_position_when_buffer_fits() {
let mut s = editor_render_fixture("Kigi Test", 4); // cursor between "Kigi" and " Test"
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 6,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
// The input row is at y = header_rows = 3 (title + desc + gap).
// Cursor glyph is ▏ (left one-eighth block).
let row_y = 3u16;
let mut found_cursor_col: Option<u16> = None;
for x in 0..area.width {
if let Some(cell) = buf.cell((x, row_y))
&& cell.symbol() == "\u{258F}"
{
found_cursor_col = Some(x);
break;
}
}
// For a non-overflow buffer, cursor column == cursor_byte
// (the buffer is ASCII so byte == col).
assert_eq!(
found_cursor_col,
Some(4),
"cursor must render at column 4 (= cursor_byte) for in-window buffer",
);
}
/// When the buffer overflows the visible window AND the cursor
/// is at the start (Home or Left), the visible window pans so
/// the start of the buffer is visible. The cursor renders at
/// the LEFT edge, NOT the right.
#[test]
fn render_editing_value_cursor_pans_to_left_on_overflow_at_start() {
// Build a buffer wide enough to overflow a narrow window.
let buffer = "A".repeat(80);
let mut s = editor_render_fixture(&buffer, 0);
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 6,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let row_y = 3u16;
let mut found_cursor_col: Option<u16> = None;
for x in 0..area.width {
if let Some(cell) = buf.cell((x, row_y))
&& cell.symbol() == "\u{258F}"
{
found_cursor_col = Some(x);
break;
}
}
assert_eq!(
found_cursor_col,
Some(0),
"cursor must pan to column 0 when cursor_byte is at the start, even with overflow",
);
}
/// When the buffer overflows AND the cursor is at the
/// end, the visible window pans so the cursor sits at the
/// rightmost-but-one column (column = `buffer_room - 1` =
/// `visible_buffer_w`) — the last col is the cursor-reserve
/// space, the cursor itself renders just inside it.
#[test]
fn render_editing_value_cursor_pans_to_right_on_overflow_at_end() {
let buffer = "A".repeat(80);
let cursor = buffer.len();
let mut s = editor_render_fixture(&buffer, cursor);
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 6,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let row_y = 3u16;
let mut found_cursor_col: Option<u16> = None;
for x in 0..area.width {
if let Some(cell) = buf.cell((x, row_y))
&& cell.symbol() == "\u{258F}"
{
found_cursor_col = Some(x);
break;
}
}
// The cursor lands at `visible_buffer_w` = `buffer_room - 1`
// — one cell before the rightmost edge, which is the
// cursor-reserve column. Strictly greater than 0 is the
// important regression-prevention assertion (the pre-R2
// bug was the cursor being pinned at the right edge
// REGARDLESS of cursor_byte).
let col = found_cursor_col.expect("cursor must render");
assert!(
col > 0,
"cursor must be panned past the start when cursor is at end of overflowing buffer \
(got col {col})",
);
assert!(
col >= area.width - 2,
"cursor must be near the rightmost visible column for end-of-buffer position \
(got col {col}, area.width = {})",
area.width,
);
}
/// When the validator returns a
/// non-None error, the buffer foreground turns red
/// (`accent_error`), AND the validation-error row at y =
/// header_rows + 1 renders the error message in accent_error.
#[test]
fn render_editing_value_paints_validation_error_row_and_buffer_red() {
// Use a buffer that fails KnownModel ("xyz" not in catalog).
let mut s = editor_render_fixture("xyz", 3);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 6,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
// Row 3 is the input. The buffer "xyz" sits at cols 0..3.
// Each char's foreground must be accent_error.
for x in 0..3 {
let cell = buf.cell((x, 3)).expect("cell must exist");
assert_eq!(
cell.style().fg,
Some(theme.accent_error),
"input row col {x} must use accent_error fg, got {:?}",
cell.style().fg,
);
}
// Row 4 is the validation error.
let mut err_row = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 4)) {
err_row.push_str(cell.symbol());
}
}
assert!(
err_row.to_lowercase().contains("unknown model"),
"validation-error row must contain 'unknown model' (case-insensitive), got {err_row:?}",
);
}
/// Empty buffer renders a
/// low-contrast placeholder hint. The cursor block (`▏`) lands
/// at col 0 and overdraws the placeholder's leading `<`, so the
/// assertion targets the unique "empty — use shell default"
/// substring that survives the cursor overdraw.
#[test]
fn render_editing_value_empty_buffer_shows_placeholder() {
let mut s = editor_render_fixture("", 0);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 6,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let mut input_row = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 3)) {
input_row.push_str(cell.symbol());
}
}
// Placeholder for KnownModel ends with "use shell default>".
// The cursor at col 0 overdraws the leading `<`, so we match
// on the body substring.
assert!(
input_row.contains("use shell default"),
"empty buffer must render the KnownModel placeholder, got {input_row:?}",
);
}
/// Rendering an Int stepper wide enough for the
/// `` / `` arrow glyphs populates `editor_adornment_rects`
/// so `handle_settings_mouse` can hit-test arrow clicks. The
/// arrows flank a centered value, NOT the old `[-]` / `[+]`
/// adornments flush against the area edges.
#[test]
fn render_editing_value_int_populates_adornment_hit_rects() {
// Use a settings registry containing the real
// `max_thoughts_width` Int entry.
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::defaults()),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::EditingValue {
key: "max_thoughts_width",
buffer: "120".to_string(),
cursor_byte: 3,
validation_error: None,
};
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 6,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let (dec_rect, inc_rect) = s.editor_adornment_rects;
assert!(
dec_rect.width > 0 && dec_rect.height > 0,
"decrement (left arrow) rect must be non-zero (got {dec_rect:?})",
);
assert!(
inc_rect.width > 0 && inc_rect.height > 0,
"increment (right arrow) rect must be non-zero (got {inc_rect:?})",
);
// The arrows flank a centered value. The left
// arrow sits LEFT of the value, the right arrow RIGHT of
// it; both are strictly inside the area.
assert!(
dec_rect.x > 0 && dec_rect.x < inc_rect.x,
"left arrow must be inside the area, before the right arrow \
(got dec.x={}, inc.x={})",
dec_rect.x,
inc_rect.x,
);
assert!(
inc_rect.x + inc_rect.width < area.width,
"right arrow must fit inside the area",
);
// Both arrows live on the same stepper row. The
// description word-wraps, so the input
// row's y position depends on how many lines the
// description consumes. The previous hardcoded `== 3`
// assertion was a side-effect of the truncate-only render.
assert_eq!(
dec_rect.y, inc_rect.y,
"left + right arrow must share the same stepper row",
);
}
// ---------- Int stepper key + render contracts ----------
/// Helper: build a `SettingsModalState` directly in EditingValue
/// mode for a registered Int setting with the given starting value.
fn int_stepper_fixture_for(key: &'static str, value: i64) -> SettingsModalState {
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::defaults()),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
let buffer = value.to_string();
let cursor_byte = buffer.len();
s.mode = SettingsModalMode::EditingValue {
key,
buffer,
cursor_byte,
validation_error: None,
};
s
}
/// Wide-range Int fixture (`max_thoughts_width`, steps ±5/±10).
fn int_stepper_fixture(value: i64) -> SettingsModalState {
int_stepper_fixture_for("max_thoughts_width", value)
}
fn int_stepper_buffer(s: &SettingsModalState) -> String {
match &s.mode {
SettingsModalMode::EditingValue { buffer, .. } => buffer.clone(),
other => panic!("expected EditingValue, got {other:?}"),
}
}
#[test]
fn int_step_sizes_table_pins_range_policy() {
// (min, max, expected_small, expected_large)
let cases = [
(1, 10, 1, 1), // scroll_lines (span 9)
(1, 100, 1, 5), // scroll_speed (span 99)
(40, 500, 5, 10), // max_thoughts_width (span 460)
(0, 0, 1, 1), // degenerate span
(1, 21, 1, 4), // span 20 still narrow: large = span/5
(1, 22, 1, 5), // span 21 → mid band
(1, 101, 1, 5), // span 100 still mid
(1, 102, 5, 10), // span 101 → wide band
];
for (min, max, want_small, want_large) in cases {
assert_eq!(
int_step_sizes(min, max),
(want_small, want_large),
"int_step_sizes({min}, {max})",
);
}
}
/// Up arrow steps the wide-range Int by small step (+5).
#[test]
fn int_editing_value_up_arrow_increments_by_small_step() {
let mut s = int_stepper_fixture(50);
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "55");
}
/// Down arrow steps the wide-range Int by small step (5).
#[test]
fn int_editing_value_down_arrow_decrements_by_small_step() {
let mut s = int_stepper_fixture(50);
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "45");
}
/// Right arrow steps the wide-range Int by large step (+10).
#[test]
fn int_editing_value_right_arrow_increments_by_large_step() {
let mut s = int_stepper_fixture(50);
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "60");
}
/// Left arrow steps the wide-range Int by large step (10).
#[test]
fn int_editing_value_left_arrow_decrements_by_large_step() {
let mut s = int_stepper_fixture(50);
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "40");
}
/// Narrow-range Int (`scroll_lines` 1..=10) uses unit steps on all arrows.
#[test]
fn scroll_lines_int_stepper_uses_unit_steps() {
let mut s = int_stepper_fixture_for("scroll_lines", 3);
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "4");
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "3");
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "4");
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "3");
}
/// Mid-range Int (`scroll_speed` 1..=100): Up/Down ±1, Left/Right ±5.
#[test]
fn scroll_speed_int_stepper_uses_unit_fine_and_five_coarse() {
let mut s = int_stepper_fixture_for("scroll_speed", 50);
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "51");
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "56");
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "55");
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "50");
}
/// k / j vim aliases mirror Up / Down.
#[test]
fn int_editing_value_vim_k_j_step_by_small() {
let mut s = int_stepper_fixture(50);
let _ = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(int_stepper_buffer(&s), "55");
let _ = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(int_stepper_buffer(&s), "50");
}
/// l / h vim aliases mirror Right / Left.
#[test]
fn int_editing_value_vim_l_h_step_by_large() {
let mut s = int_stepper_fixture(50);
let _ = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert_eq!(int_stepper_buffer(&s), "60");
let _ = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
);
assert_eq!(int_stepper_buffer(&s), "50");
}
/// Stepping past `max` is clamped to `max`; the outcome is
/// `Unchanged` (no visible step) so a UI consumer can avoid
/// re-render churn.
#[test]
fn int_editing_value_clamps_to_max() {
// max_thoughts_width registered max = 500.
let mut s = int_stepper_fixture(500);
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"Up at max must be Unchanged (no movement), got {outcome:?}",
);
assert_eq!(int_stepper_buffer(&s), "500");
}
/// Stepping below `min` is clamped to `min`.
#[test]
fn int_editing_value_clamps_to_min() {
// max_thoughts_width registered min = 40.
let mut s = int_stepper_fixture(40);
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"Down at min must be Unchanged (no movement), got {outcome:?}",
);
assert_eq!(int_stepper_buffer(&s), "40");
}
/// Digit keys are silently dropped — the stepper is not a
/// text input.
#[test]
fn int_editing_value_ignores_digit_keys() {
let mut s = int_stepper_fixture(50);
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('7'), KeyModifiers::NONE),
);
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
assert_eq!(int_stepper_buffer(&s), "50");
}
/// Backspace is silently dropped — the stepper has no
/// editable buffer to backspace into.
#[test]
fn int_editing_value_ignores_backspace() {
let mut s = int_stepper_fixture(50);
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
assert_eq!(int_stepper_buffer(&s), "50");
}
/// Delete / Home / End / Tab are likewise dropped.
#[test]
fn int_editing_value_ignores_other_text_input_keys() {
let mut s = int_stepper_fixture(50);
for code in [KeyCode::Delete, KeyCode::Home, KeyCode::End, KeyCode::Tab] {
let outcome = handle_settings_key(&mut s, &KeyEvent::new(code, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"stepper must reject {code:?}, got {outcome:?}",
);
assert_eq!(int_stepper_buffer(&s), "50");
}
}
/// Render the stepper and assert the `` / `` arrow glyphs
/// AND the value text are present on the input row.
#[test]
fn int_editing_value_renders_stepper_ui() {
let mut s = int_stepper_fixture(125);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 8,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
// Find the stepper row dynamically — the description
// word-wraps so the input row's y is no
// longer fixed at 3. Scan rows top-down for the one that
// contains both stepper glyphs.
let mut stepper_row: Option<String> = None;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
row.push_str(cell.symbol());
}
}
if row.contains('\u{2039}') && row.contains('\u{203A}') {
stepper_row = Some(row);
break;
}
}
let row = stepper_row.expect("must find the stepper row");
assert!(
row.contains('\u{2039}'),
"stepper row must contain `` glyph, got {row:?}"
);
assert!(
row.contains('\u{203A}'),
"stepper row must contain `` glyph, got {row:?}"
);
assert!(
row.contains("125"),
"stepper row must contain the value text, got {row:?}"
);
}
/// Enter commits the typed Action and transitions back to
/// Browse. The `action_for_int` arm for `max_thoughts_width`
/// produces `Action::SetMaxThoughtsWidth(<i64>)`.
#[test]
fn int_editing_value_enter_commits() {
let mut s = int_stepper_fixture(75);
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match outcome {
SettingsKeyOutcome::Action(Action::SetMaxThoughtsWidth(75)) => {}
other => panic!("expected SetMaxThoughtsWidth(75) on Enter, got {other:?}"),
}
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Enter must return to Browse"
);
}
/// Esc cancels — no Action is emitted, and mode returns to
/// Browse. The user's in-progress step is dropped; the
/// underlying setting value (`UiConfig.max_thoughts_width`)
/// stays at whatever it was before the editor opened.
#[test]
fn int_editing_value_esc_reverts() {
let mut s = int_stepper_fixture(75);
// Take a step so the buffer diverges from the original.
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(int_stepper_buffer(&s), "80");
// Esc.
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Esc must be Changed (mode swap), got {outcome:?}"
);
assert!(
!matches!(outcome, SettingsKeyOutcome::Action(_)),
"Esc must NOT emit any Action — the underlying value was \
never live-previewed",
);
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Esc must return to Browse"
);
}
/// Mouse click on the `` left-arrow rect synthesizes a Down
/// step (small step down). The click maps to small steps to
/// match the spinner convention — repeated clicks let the
/// user fine-tune.
#[test]
fn int_editing_value_left_arrow_click_decrements_by_small_step() {
let mut s = int_stepper_fixture(120);
// Render once to populate `editor_adornment_rects`.
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 8,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let (dec_rect, _) = s.editor_adornment_rects;
assert!(dec_rect.width > 0, "left arrow rect must be populated");
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
dec_rect.x,
dec_rect.y,
);
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "115");
}
/// Mouse click on the `` right-arrow rect synthesizes an Up
/// step. Mirror of the left-arrow test.
#[test]
fn int_editing_value_right_arrow_click_increments_by_small_step() {
let mut s = int_stepper_fixture(120);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 8,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let (_, inc_rect) = s.editor_adornment_rects;
assert!(inc_rect.width > 0, "right arrow rect must be populated");
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
inc_rect.x,
inc_rect.y,
);
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert_eq!(int_stepper_buffer(&s), "125");
}
/// Mouse click on the value text (between the arrows) is a
/// no-op — clicks here shouldn't accidentally commit or step.
#[test]
fn int_editing_value_click_on_value_text_is_noop() {
let mut s = int_stepper_fixture(120);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 8,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
let (dec_rect, inc_rect) = s.editor_adornment_rects;
// Click between the two arrow rects.
let middle_x = (dec_rect.x + inc_rect.x) / 2;
let middle_y = dec_rect.y;
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
middle_x,
middle_y,
);
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"click on value text must be Unchanged, got {outcome:?}"
);
assert_eq!(int_stepper_buffer(&s), "120");
}
/// Esc in the theme picker dispatches a PREVIEW Action with the
/// original canonical AND returns to Browse:
/// preview-revert restores the live visual without persisting,
/// since the picker's preview navs never persisted in the first
/// place. Parameterised across all 3 theme enum keys.
#[test]
fn picking_enum_esc_dispatches_preview_revert_for_each_key() {
let cases: &[(&str, &str)] = &[
("theme", "kiginight"),
("auto_dark_theme", "kiginight"),
("auto_light_theme", "kigiday"),
];
for &(key, original) in cases {
let mut s = make_state();
s.mode = SettingsModalMode::PickingEnum {
key,
choices_idx: 0,
original_value: SettingValue::Enum(original),
supports_preview: true,
};
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
match (key, outcome) {
("theme", SettingsKeyOutcome::Action(Action::PreviewTheme(name))) => {
assert_eq!(name, original);
}
(
"auto_dark_theme",
SettingsKeyOutcome::Action(Action::PreviewAutoDarkTheme(name)),
) => {
assert_eq!(name, original);
}
(
"auto_light_theme",
SettingsKeyOutcome::Action(Action::PreviewAutoLightTheme(name)),
) => {
assert_eq!(name, original);
}
(k, other) => panic!(
"Esc on `{k}` picker should dispatch matching Preview Action, got {other:?}"
),
}
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Esc must transition back to Browse for key `{key}`",
);
}
}
/// Backwards-compat alias for an earlier test name. Asserts
/// the same behaviour for the `theme` key only — kept so a future
/// reader grep'ing the older test name still finds working
/// coverage. The parameterised version above is the canonical test.
#[test]
fn picking_enum_esc_returns_to_browse() {
let mut s = make_state();
s.mode = SettingsModalMode::PickingEnum {
key: "theme",
choices_idx: 0,
original_value: SettingValue::Enum("kiginight"),
supports_preview: true,
};
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
match outcome {
SettingsKeyOutcome::Action(Action::PreviewTheme(name)) => {
assert_eq!(
name, "kiginight",
"Esc revert must dispatch the original canonical"
);
}
other => panic!("expected Action::PreviewTheme(\"kiginight\") on Esc, got {other:?}"),
}
assert!(matches!(s.mode, SettingsModalMode::Browse));
}
// -- picker machinery tests --
//
// When the chooser sub-pane ships with no Enum entries in
// `default_settings()`, these tests build a
// synthetic registry containing one Enum entry and exercise the
// picker's render + keypress paths directly. When
// `action_for_enum` returns `None` for a key, the
// preview/revert dispatch returns `SettingsKeyOutcome::Changed`
// rather than `Action(_)`; a concrete `action_for_enum("theme", _)`
// arm makes it emit an Action.
/// Static synthetic Enum metadata for picker tests. Choice display
/// names are deliberately mid-length so the default-width (80) tests
/// cover the happy path and dedicated narrow-width tests cover the
/// truncation paths — sized close to the real theme catalog widths.
const TEST_ENUM_CHOICES: &[EnumChoice] = &[
EnumChoice {
canonical: "first",
display: "First Option",
description: "First option description.",
},
EnumChoice {
canonical: "second",
display: "Second Option",
description: "Second option description.",
},
EnumChoice {
canonical: "third",
display: "Third Option",
description: "Third option description.",
},
];
fn synthetic_enum_meta() -> SettingMeta {
SettingMeta {
key: "test_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Test enum",
description: "Synthetic Enum entry for PR 3 picker tests.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "first",
choices: TEST_ENUM_CHOICES,
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}
}
/// Build a registry containing exactly one synthetic Enum entry
/// and place the modal directly in PickingEnum mode at idx 0.
/// Useful for testing the picker handler in isolation —
/// `try_enter_picking_enum` is tested separately by
/// `picker_test_state_in_browse()` + Enter dispatch.
fn picker_test_state() -> SettingsModalState {
let entries = vec![synthetic_enum_meta()];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "test_enum",
choices_idx: 0,
original_value: SettingValue::Enum("first"),
supports_preview: true,
};
s
}
/// Same registry as `picker_test_state` but the modal stays in
/// Browse mode with the Enum row focused — used to exercise
/// `try_enter_picking_enum` via the normal Browse-Enter path.
fn picker_test_state_in_browse() -> SettingsModalState {
let entries = vec![synthetic_enum_meta()];
SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
)
// Selection lands on the first selectable row (the only
// setting, "test_enum") by `SettingsModalState::new`.
}
/// Up/Down (and j/k aliases) in the picker advance `choices_idx`
/// and clamp at list bounds. With no real Enum
/// `action_for_enum` arms, the outcome is `Changed` (state
/// change only); a sibling test
/// `picker_arrow_keys_emit_preview_actions` can assert Action
/// emission once `action_for_enum("theme", _)` lands.
///
/// Renamed from `picker_arrow_keys_emit_preview_actions` — the
/// previous name promised something the test cannot actually verify
/// without a real Enum arm.
#[test]
fn picker_arrow_keys_advance_choices_idx() {
let mut s = picker_test_state();
// Down: 0 → 1.
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Down should produce Changed (state mutation), got {outcome:?}"
);
match s.mode {
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 1),
ref other => panic!("expected PickingEnum mode after Down, got {other:?}"),
}
// j: 1 → 2 (last choice).
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
match s.mode {
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 2),
_ => panic!("expected PickingEnum mode after j"),
}
// Down past the last choice is Unchanged (clamp).
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"Down at last choice should be Unchanged"
);
// Up: 2 → 1.
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
match s.mode {
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 1),
_ => panic!("expected PickingEnum mode after Up"),
}
// k: 1 → 0.
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
match s.mode {
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 0),
_ => panic!("expected PickingEnum mode after k"),
}
// Up at first choice is Unchanged.
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
}
/// Enter commits the current preview by dispatching a typed
/// COMMIT Action AND transitioning back to Browse. The synthetic
/// `test_enum` key has no `action_for_enum_commit` arm, so the
/// outcome is `Changed` (state mutation only). The theme keys
/// land real `Action::SetTheme(...)` Action variants — exercised
/// by the e2e tests at `tests/settings_e2e.rs`.
///
/// Enter used to be a no-op
/// (relying on the most-recent preview being the committed
/// value); now it explicitly emits a commit Action so the
/// persist path runs once per picker open → close cycle.
#[test]
fn picker_enter_returns_to_browse() {
let mut s = picker_test_state();
// Navigate to the second choice so commit lands on a non-default.
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// The synthetic `test_enum` key has no commit Action arm in
// `action_for_enum_commit`, so the outcome is `Changed`. For
// real theme keys, the outcome is `Action(...)` — see the
// theme preview/commit e2e coverage in the e2e crate.
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Enter for synthetic-key must produce Changed (no commit arm), got {outcome:?}"
);
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Enter must return to Browse"
);
}
/// Esc transitions PickingEnum → Browse AND, once a real Enum arm
/// exists, will dispatch `action_for_enum(setting_key, original_canonical)`.
/// When `action_for_enum` returns None for every key, the
/// outcome is `Changed` rather than `Action(_)` — but the
/// revert *call site* is exercised, and the assertion can be
/// tightened to verify `Action::SetTheme("first")` once the arm
/// ships.
///
/// The test cannot currently verify that the
/// *original* canonical (not the *current* preview) is passed to
/// `action_for_enum`. A real theme arm would make the distinction
/// visible via the Action variant.
#[test]
fn picker_esc_returns_to_browse_after_preview_nav() {
let mut s = picker_test_state();
// Preview-navigate to a non-original choice so the revert
// path's "original vs current" distinction is meaningful.
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
match s.mode {
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 2),
_ => panic!("expected PickingEnum mode after 2x Down"),
}
// Esc: revert. Outcome is Changed when there are no Enum action
// arms — with a real theme arm this would unpack as Action::SetTheme("first").
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Esc revert outcome should be Changed (or Action when arms exist), got {outcome:?}"
);
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Esc must return to Browse"
);
}
/// The picker renders every choice in declaration order, top to
/// bottom. Asserts each choice's `display` string and description
/// appears on the expected row with the documented spacing.
/// Layout: row 0 = title, row 1 = description (subtitle), row 2 =
/// gap, rows 3..6 = choices.
#[test]
fn picker_renders_choices_in_order() {
let s = picker_test_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let row_text = |y: u16| -> String {
let mut s = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
s.push_str(cell.symbol());
}
}
s
};
// Row 0: title.
assert!(
row_text(0).contains("Test enum"),
"title row must contain 'Test enum', got: {:?}",
row_text(0)
);
// Row 1: setting description subtitle.
assert!(
row_text(1).contains("Synthetic Enum entry"),
"row 1 must contain setting description, got: {:?}",
row_text(1)
);
// Row 2: blank gap — strict assertion.
assert!(
row_text(2).trim().is_empty(),
"row 2 must be the blank gap, got: {:?}",
row_text(2)
);
// Rows 3..6: choices. Use full rendered layout for precise
// pinning (tighten substring match).
// " ○ First Option · First option description."
let r3 = row_text(3);
assert!(
r3.contains("First Option") && r3.contains("First option description"),
"row 3 must contain display+description for 'first', got: {r3:?}"
);
let r4 = row_text(4);
assert!(
r4.contains("Second Option") && r4.contains("Second option description"),
"row 4 must contain display+description for 'second', got: {r4:?}"
);
let r5 = row_text(5);
assert!(
r5.contains("Third Option") && r5.contains("Third option description"),
"row 5 must contain display+description for 'third', got: {r5:?}"
);
}
/// The currently-focused choice renders with the filled-disc
/// marker `●`, `accent_user` marker color, `bg_visual` row bg,
/// AND **BOLD** display text — three independent focus cues for
/// low-contrast theme compatibility (parity with `cancel_turn_panel`).
#[test]
fn picker_highlights_current_choice() {
let mut s = picker_test_state();
// Focus the second choice (index 1).
if let SettingsModalMode::PickingEnum {
ref mut choices_idx,
..
} = s.mode
{
*choices_idx = 1;
}
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
// Marker glyph at column `area.x + 1` of each choice row.
// Using `area.x + 1` rather than `1` so the helper survives
// a future renderer that passes a non-zero `area.x`.
let marker_at = |y: u16| -> String {
buf.cell((area.x + 1, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
};
// Layout: rows 3..6 are choices (with subtitle on row 1).
assert_eq!(marker_at(3), "\u{25CB}", "row 3 (unfocused) should be ○");
assert_eq!(marker_at(4), "\u{25CF}", "row 4 (focused) should be ●");
assert_eq!(marker_at(5), "\u{25CB}", "row 5 (unfocused) should be ○");
// Cell at the LAST column of each row carries the row bg
// independent of prefix-width tweaks.
let bg_at = |y: u16| -> Option<ratatui::style::Color> {
buf.cell((area.x + area.width - 1, y))
.and_then(|c| c.style().bg)
};
assert_eq!(
bg_at(4),
Some(settings_list_row_bg(&theme, true, false)),
"focused row must have selection background"
);
assert_eq!(
bg_at(3),
Some(settings_list_row_bg(&theme, false, false)),
"unfocused row must have idle background"
);
// Display text on focused row carries BOLD modifier
// (three focus cues). Display "Second
// Option" starts at col `PICKER_PREFIX_W` (= 4). The 'S' at
// col 4 should be bold.
let focused_modifier = buf
.cell((area.x + PICKER_PREFIX_W, 4))
.map(|c| c.style().add_modifier)
.unwrap_or_default();
assert!(
focused_modifier.contains(Modifier::BOLD),
"focused row's display must be BOLD, got modifiers {focused_modifier:?}"
);
let unfocused_modifier = buf
.cell((area.x + PICKER_PREFIX_W, 3))
.map(|c| c.style().add_modifier)
.unwrap_or_default();
assert!(
!unfocused_modifier.contains(Modifier::BOLD),
"unfocused row's display must NOT be BOLD, got modifiers {unfocused_modifier:?}"
);
}
// -- try_enter_picking_enum coverage --
/// Browse-mode Enter on an Enum row transitions to PickingEnum
/// mode with `choices_idx` seeded from the row's current value
/// (resolved by `current_value_for`).
///
/// Since the synthetic "test_enum" key has no
/// `current_value_for` arm, `value_for` returns None and the
/// fallback path picks `choices_idx = 0` + `original_value =
/// SettingValue::Enum(first_canonical)`. This pins the
/// fallback behavior.
#[test]
fn browse_enter_on_enum_row_transitions_to_picking_enum() {
let mut s = picker_test_state_in_browse();
// Sanity: initial state.
assert!(matches!(s.mode, SettingsModalMode::Browse));
match &s.rows[s.selected] {
RowEntry::Setting { key, .. } => assert_eq!(*key, "test_enum"),
_ => panic!("expected synthetic Enum row at initial selection"),
}
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Enter on Enum row should produce Changed, got {outcome:?}"
);
match s.mode {
SettingsModalMode::PickingEnum {
key,
choices_idx,
ref original_value,
..
} => {
assert_eq!(key, "test_enum");
// No current_value_for arm → fallback to idx 0.
assert_eq!(choices_idx, 0);
assert_eq!(original_value, &SettingValue::Enum("first"));
}
ref other => panic!("expected PickingEnum mode, got {other:?}"),
}
}
/// Browse-mode Enter on a non-Enum (Bool) row does NOT enter
/// PickingEnum — `try_enter_picking_enum` returns false and the
/// fallthrough Bool-toggle path takes over.
#[test]
fn browse_enter_on_bool_row_does_not_enter_picking_enum() {
let mut s = make_state(); // default registry — all Bool.
// compact_mode is the initial selection.
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Bool toggle dispatches an Action (not Changed).
match outcome {
SettingsKeyOutcome::Action(_) => {}
other => panic!("expected Action from Bool toggle, got {other:?}"),
}
// Mode must NOT have changed to PickingEnum.
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"Bool toggle must stay in Browse mode, got {:?}",
s.mode,
);
}
/// `try_enter_picking_enum` directly: with a synthetic Enum
/// where `value_for` returns the second choice's canonical, the
/// `choices_idx` must seed to 1 (the position of that canonical).
/// This exercises the `choices.iter().position(...)` resolution
/// branch that's structurally unreachable when no real Enum
/// entries are registered but is hit by the theme path.
#[test]
fn try_enter_picking_enum_seeds_choices_idx_from_current_value() {
// The synthetic Enum key "test_enum" isn't in
// `current_value_for`, so we can't drive this end-to-end via
// ui_snapshot. Instead, build a registry where the key
// matches `current_value_for("compact_mode", ...)` and override
// SettingKind to Enum. Practical: we directly verify the
// function returns false for non-Enum, and idx 0 + first
// canonical for the unknown-key fallback.
//
// The "value-recognized → idx > 0" case requires the
// canonical to be in `current_value_for`'s match arms; with
// no Enum keys in those arms, this case is
// unreachable. Adding `"theme" => SettingValue::Enum(...)`
// and a sibling test that uses a non-default theme would verify
// idx > 0 seeding.
let mut s = picker_test_state_in_browse();
assert!(s.try_enter_picking_enum());
match s.mode {
SettingsModalMode::PickingEnum {
key,
choices_idx,
ref original_value,
..
} => {
assert_eq!(key, "test_enum");
assert_eq!(choices_idx, 0, "fallback to idx 0 when no current value");
assert_eq!(
original_value,
&SettingValue::Enum("first"),
"original_value should be the first canonical fallback"
);
}
ref other => panic!("expected PickingEnum mode, got {other:?}"),
}
}
/// `try_enter_picking_enum` on a non-Enum focused row returns
/// false and leaves mode unchanged. The Bool case in
/// `make_state()` is the canonical non-Enum scenario.
#[test]
fn try_enter_picking_enum_returns_false_for_non_enum_row() {
let mut s = make_state();
assert!(matches!(s.mode, SettingsModalMode::Browse));
assert!(
!s.try_enter_picking_enum(),
"non-Enum focused row should return false"
);
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"mode must not change on non-Enum row"
);
}
// -- render_picking_enum narrow-terminal coverage --
#[test]
fn render_picker_with_zero_height_is_noop() {
let s = picker_test_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 0,
};
let mut buf = Buffer::empty(Rect {
x: 0,
y: 0,
width: 80,
height: 1,
});
let theme = Theme::current();
// Must not panic and must not touch the buffer.
render_picking_enum(&mut buf, area, &s, &theme);
}
#[test]
fn render_picker_with_zero_width_is_noop() {
let s = picker_test_state();
let area = Rect {
x: 0,
y: 0,
width: 0,
height: 10,
};
let mut buf = Buffer::empty(Rect {
x: 0,
y: 0,
width: 1,
height: 10,
});
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
}
/// At height=2, the description-skipped path (`area.height < 4`)
/// kicks in (header_rows=2 = title+gap), and the choice-render
/// early-return fires (`area.height <= header_rows`). The title
/// must render, no choices must render, no panic.
#[test]
fn render_picker_at_height_2_renders_title_no_choices() {
let s = picker_test_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 2,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let row_text = |y: u16| -> String {
let mut s = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
s.push_str(cell.symbol());
}
}
s
};
assert!(row_text(0).contains("Test enum"));
// Row 1 is the gap; must be blank.
assert!(
row_text(1).trim().is_empty(),
"no choices should render at height=2 (only title fits), got: {:?}",
row_text(1)
);
}
/// Narrow-viewport edge case
/// for the word-wrap path. When the picker is given a height
/// where a fully-wrapped description would exceed the area,
/// the `has_description = … >= 2 + desc_rows` gate skips the
/// description so the choices stay renderable. This test
/// pins that fallback at an intermediate height.
#[test]
fn render_picker_drops_description_when_wrap_block_exceeds_height() {
// Synthetic registry with a description that, at width=20,
// would wrap to ≥ 5 lines.
let long_desc = "This description is intentionally long enough \
that at narrow widths the wrap block will not \
fit alongside the choices.";
let synthetic_meta = SettingMeta {
key: "test-narrow-wrap",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "X",
description: long_desc,
keywords: &[],
kind: SettingKind::Enum {
default: "first",
choices: TEST_ENUM_CHOICES,
supports_preview: false,
},
restart_required: false,
hidden_in_minimal: false,
};
let registry = SettingsRegistry::from_entries(vec![synthetic_meta]);
let mut s = SettingsModalState::new(
Arc::new(registry),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
assert!(s.try_enter_picking_enum());
// Height=4 at narrow width: title (1) + desc_would_be (≥5)
// overflows; the gate drops the description. The choices
// should still render.
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 4,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let mut all_text = String::new();
for y in 0..area.height {
all_text.push_str(&buf_row_text(&buf, y, area.x, area.width));
all_text.push('\n');
}
// At minimum the title and the first choice must render.
assert!(
all_text.contains('X'),
"title `X` must render at narrow height: {all_text:?}",
);
// The fallback dropped the description, freeing space for
// the choice marker `\u{25CB}` or `\u{25CF}` (`○`/`●`).
let has_choice_marker = all_text.contains('\u{25CB}') || all_text.contains('\u{25CF}');
assert!(
has_choice_marker,
"at least one choice marker must render when desc is dropped: {all_text:?}",
);
}
/// Long descriptions WRAP across multiple lines (no `…`
/// truncation). User-feedback commit: word-wrap the description
/// in the picker so opinion-shaping choice copy stays readable
/// at typical terminal widths.
///
/// History note: this test originally asserted truncation
/// (`…` glyph at the right edge); the new behavior is
/// word-wrap, so the assertion inverts — `…` must NOT appear
/// and the full description text must be in the buffer.
#[test]
fn render_picker_long_description_wraps_no_ellipsis() {
let entries = vec![SettingMeta {
key: "long_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Long",
description: "Short.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "wide",
choices: &[EnumChoice {
canonical: "wide",
display: "Wide",
description: "A deliberately verbose description that will overflow the column budget.",
}],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "long_enum",
choices_idx: 0,
original_value: SettingValue::Enum("wide"),
supports_preview: true,
};
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
// Concatenate ALL rendered rows. The full description must
// be present (no `…` ellipsis anywhere on the choice rows).
let mut all_choice_text = String::new();
for y in 3..area.height {
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
all_choice_text.push_str(cell.symbol());
}
}
all_choice_text.push('\n');
}
assert!(
!all_choice_text.contains('\u{2026}'),
"wrapped description must NOT contain `…`, got:\n{all_choice_text}"
);
// Words appear across wrapped lines (with trailing-padding
// whitespace between them), so we check word-presence
// rather than substring contiguity.
for word in [
"A",
"deliberately",
"verbose",
"description",
"overflow",
"budget",
] {
assert!(
all_choice_text.contains(word),
"wrapped description must contain word {word:?}, got:\n{all_choice_text}"
);
}
}
/// Word-wrap detail check: the choice symbol + display name stay
/// on line 1, the description wraps across ≥2 lines, AND
/// continuation lines are indented to the description column
/// (column 0 holds whitespace, NOT a marker glyph).
///
/// Uses the production `coding_data_sharing` "Opt out" choice
/// (a long description that wraps at width=60). Pinning against
/// the real catalog keeps the test honest about the bug report
/// — the screenshot in the user-feedback PR showed exactly this
/// choice clipped with `…`.
/// Visual smoke debugging helper. Renders the wrap fixture and
/// prints the buffer so a human can eyeball the layout. Ignored
/// by default; run with `cargo test -- --ignored picker_visual_smoke_debug
/// --nocapture`.
#[test]
#[ignore]
fn picker_visual_smoke_debug() {
let entries = vec![SettingMeta {
key: "wrap_enum",
category: SettingCategory::Advanced,
owner: SettingOwner::Shared,
label: "Coding data sharing",
description: "Controls whether SpaceXAI may retain and train on coding data.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "opt-out",
choices: &[
EnumChoice {
canonical: "opt-in",
display: "Opt in",
description: "Allow SpaceXAI to retain and use coding session data for training and product improvement.",
},
EnumChoice {
canonical: "opt-out",
display: "Opt out",
description: "Do not retain coding session data. Code requests will not be used for training.",
},
],
supports_preview: false,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "wrap_enum",
choices_idx: 1,
original_value: SettingValue::Enum("opt-out"),
supports_preview: false,
};
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
println!("\nPicker visual smoke at width=60:");
println!("{}", "─".repeat(area.width as usize));
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
row.push_str(cell.symbol());
}
}
println!("{row}");
}
println!("{}", "─".repeat(area.width as usize));
}
#[test]
fn picker_long_description_wraps_to_multiple_lines() {
let entries = vec![SettingMeta {
key: "wrap_enum",
category: SettingCategory::Advanced,
owner: SettingOwner::Shared,
label: "Coding data sharing",
description: "Controls whether SpaceXAI may retain and train on coding data.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "opt-out",
choices: &[
EnumChoice {
canonical: "opt-in",
display: "Opt in",
description: "Allow SpaceXAI to retain and use coding session data for training and product improvement.",
},
EnumChoice {
canonical: "opt-out",
display: "Opt out",
description: "Do not retain coding session data. Code requests will not be used for training.",
},
],
supports_preview: false,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "wrap_enum",
choices_idx: 1,
original_value: SettingValue::Enum("opt-out"),
supports_preview: false,
};
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 16,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let row_text = |y: u16| -> String {
let mut s = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
s.push_str(cell.symbol());
}
}
s
};
// Line 1 of choice 0 ("Opt in"): symbol + display + sep +
// start-of-description. The setting-level description above the
// choices can wrap to a variable number of rows depending on copy
// length, so locate choice 0's line 1 dynamically instead of
// assuming a fixed row.
let opt_in_row = (0..area.height)
.find(|&y| row_text(y).contains("Opt in"))
.expect("choice 0 line 1 ('Opt in') must render within the picker area");
let r3 = row_text(opt_in_row);
assert!(
r3.contains("Opt in"),
"choice 0 line 1 must contain 'Opt in' display, got: {r3:?}"
);
assert!(
r3.contains('\u{00B7}'),
"choice 0 line 1 must contain the `·` separator, got: {r3:?}"
);
assert!(
r3.contains("Allow SpaceXAI"),
"choice 0 line 1 must start the description, got: {r3:?}"
);
// The Opt-in description wraps to ≥ 2 lines at width 60.
// Continuation rows live BELOW choice 0's line 1 until the
// next choice starts.
let cont_row = opt_in_row + 1;
let r4 = row_text(cont_row);
// Continuation line must be indented past the description
// column. Column 0 + column 1 (symbol cells on line 1)
// should be whitespace on the continuation line.
assert_eq!(
buf.cell((0u16, cont_row)).map(|c| c.symbol()),
Some(" "),
"continuation row col 0 must be whitespace, got row: {r4:?}"
);
assert_eq!(
buf.cell((1u16, cont_row)).map(|c| c.symbol()),
Some(" "),
"continuation row col 1 (where marker would sit on line 1) must be whitespace, got row: {r4:?}"
);
// The full Opt-in description must appear across the wrapped
// rows — no `…` truncation.
let mut opt_in_full = String::new();
for y in opt_in_row..area.height {
let line = row_text(y);
// Stop at the start of the Opt-out choice (line that
// contains the second display).
if y > opt_in_row && line.contains("Opt out") {
break;
}
opt_in_full.push_str(&line);
opt_in_full.push('\n');
}
assert!(
!opt_in_full.contains('\u{2026}'),
"wrapped Opt-in description must NOT contain `…`, got:\n{opt_in_full}"
);
for word in [
"Allow",
"SpaceXAI",
"retain",
"session",
"training",
"improvement",
] {
assert!(
opt_in_full.contains(word),
"Opt-in description must include word {word:?}, got:\n{opt_in_full}"
);
}
}
/// Short descriptions stay on ONE line — no continuation rows.
/// Asserts the row directly below a choice's line 1 is either
/// the next choice's line 1 (when there are more choices) or
/// blank.
#[test]
fn picker_short_description_stays_one_line() {
let entries = vec![SettingMeta {
key: "short_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Short",
description: "Short.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "a",
choices: &[
EnumChoice {
canonical: "a",
display: "Alpha",
description: "A.",
},
EnumChoice {
canonical: "b",
display: "Bravo",
description: "B.",
},
],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "short_enum",
choices_idx: 0,
original_value: SettingValue::Enum("a"),
supports_preview: true,
};
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let row_text = |y: u16| -> String {
let mut s = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
s.push_str(cell.symbol());
}
}
s
};
// Choice 0 on row 3, choice 1 on row 4 (one line each).
assert!(
row_text(3).contains("Alpha") && row_text(3).contains("A."),
"choice 0 must be one line, got row 3: {:?}",
row_text(3)
);
assert!(
row_text(4).contains("Bravo") && row_text(4).contains("B."),
"choice 1 must start at row 4 (no continuation rows from choice 0), got row 4: {:?}",
row_text(4)
);
}
/// Choices with an empty description render the symbol + display
/// ONLY — no `·` separator, no trailing stray cells.
#[test]
fn picker_no_description_renders_symbol_and_display_only() {
let entries = vec![SettingMeta {
key: "nodesc_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "No desc",
description: "Short.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "a",
choices: &[
EnumChoice {
canonical: "a",
display: "Alpha",
description: "",
},
EnumChoice {
canonical: "b",
display: "Bravo",
description: "",
},
],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "nodesc_enum",
choices_idx: 0,
original_value: SettingValue::Enum("a"),
supports_preview: true,
};
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let row_text = |y: u16| -> String {
let mut s = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
s.push_str(cell.symbol());
}
}
s
};
let r3 = row_text(3);
assert!(
r3.contains("Alpha"),
"choice 0 must render its display, got: {r3:?}"
);
assert!(
!r3.contains('\u{00B7}'),
"choice 0 with empty description must NOT render the `·` separator, got: {r3:?}"
);
}
/// Multi-line choice hit-rect spans ALL its lines. Clicking on
/// the continuation line of a wrapped choice moves the picker
/// focus to that choice (same as clicking line 1). Mirrors the
/// commit-13 fix for two-line row hit-rects in Browse mode.
#[test]
fn picker_multi_line_choice_hit_rect_spans_all_lines() {
// Reuse the wrap fixture: long descriptions on both choices.
let entries = vec![SettingMeta {
key: "wrap_enum",
category: SettingCategory::Advanced,
owner: SettingOwner::Shared,
label: "Coding data sharing",
description: "Controls whether SpaceXAI may retain coding data.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "opt-in",
choices: &[
EnumChoice {
canonical: "opt-in",
display: "Opt in",
description: "Allow SpaceXAI to retain and use coding session data for training and product improvement.",
},
EnumChoice {
canonical: "opt-out",
display: "Opt out",
description: "Do not retain coding session data. Code requests will not be used for training.",
},
],
supports_preview: false,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "wrap_enum",
choices_idx: 0,
original_value: SettingValue::Enum("opt-in"),
supports_preview: false,
};
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 16,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
// Drain the per-choice rects (the production caller does
// this via `render_settings_modal`; we mirror it here so the
// mouse handler sees the rects).
s.picker_choice_rects = take_picker_choice_rects();
// Sanity: each choice's rect has height ≥ 2 at width 60.
assert_eq!(
s.picker_choice_rects.len(),
2,
"expected 2 choice hit-rects, got {}",
s.picker_choice_rects.len()
);
let rect0 = s.picker_choice_rects[0];
let rect1 = s.picker_choice_rects[1];
assert!(
rect0.height >= 2,
"choice 0 should span ≥ 2 lines (wrapped description), got rect {rect0:?}"
);
assert!(
rect1.height >= 2,
"choice 1 should span ≥ 2 lines (wrapped description), got rect {rect1:?}"
);
// Rects must NOT overlap vertically.
assert!(
rect0.y + rect0.height <= rect1.y,
"choice rects must not overlap: rect0={rect0:?}, rect1={rect1:?}"
);
// The initial focus is on choice 0. Click on the last line
// (a continuation line) of choice 1 — the click should
// move focus to choice 1.
let click_y = rect1.y + rect1.height - 1;
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
10,
click_y,
);
match (outcome, &s.mode) {
(
SettingsKeyOutcome::Changed | SettingsKeyOutcome::Action(_),
SettingsModalMode::PickingEnum { choices_idx, .. },
) => {
assert_eq!(
*choices_idx, 1,
"click on continuation row of choice 1 must focus choice 1, got idx {}",
*choices_idx
);
}
(other, mode) => panic!(
"click on continuation line should change focus, got outcome {other:?} in mode {mode:?}"
),
}
}
/// Picker scroll math accounts for variable per-choice height.
/// With 5 choices each ~3 lines tall in a ~8-line viewport,
/// focusing the LAST choice scrolls so it's visible (and the
/// earlier choices may shift off the top).
#[test]
fn picker_scroll_offset_accounts_for_variable_height() {
// Each description is ≥ 3 wrap lines wide at width=40.
let entries = vec![SettingMeta {
key: "many_wrap",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Many",
description: "Many.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "c0",
choices: &[
EnumChoice {
canonical: "c0",
display: "C0",
description: "Choice zero description that is verbose enough to span three lines at width 40.",
},
EnumChoice {
canonical: "c1",
display: "C1",
description: "Choice one description that is verbose enough to span three lines at width 40.",
},
EnumChoice {
canonical: "c2",
display: "C2",
description: "Choice two description that is verbose enough to span three lines at width 40.",
},
EnumChoice {
canonical: "c3",
display: "C3",
description: "Choice three description that is verbose enough to span three lines at width 40.",
},
EnumChoice {
canonical: "c4",
display: "C4",
description: "Choice four description that is verbose enough to span three lines at width 40.",
},
],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let registry = Arc::new(SettingsRegistry::from_entries(entries));
// Focus the LAST choice (c4) — the scroll math must keep it
// in view.
let mut s = SettingsModalState::new(
registry.clone(),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "many_wrap",
choices_idx: 4,
original_value: SettingValue::Enum("c4"),
supports_preview: true,
};
// Viewport: title + desc + gap = 3 rows of chrome + 8 rows
// of choices = 11 total. With 5 choices × 3 lines = 15 total
// wrap-rows of content, only ~2 choices can fit per page.
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 11,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
s.picker_choice_rects = take_picker_choice_rects();
// The focused choice (c4) MUST have a non-zero hit rect (it
// got rendered).
let rect_c4 = s.picker_choice_rects[4];
assert!(
rect_c4.width > 0 && rect_c4.height > 0,
"focused choice c4 must be visible after scroll, got rect {rect_c4:?}"
);
// The focused choice's rect must fit inside the area's
// height bounds.
assert!(
rect_c4.y + rect_c4.height <= area.y + area.height,
"focused choice c4 must fit inside the viewport, got rect {rect_c4:?} vs area {area:?}"
);
// Choice 0 (c0) should be scrolled off the top (rect zero).
let rect_c0 = s.picker_choice_rects[0];
assert_eq!(
(rect_c0.width, rect_c0.height),
(0, 0),
"choice c0 must be scrolled off-screen, got rect {rect_c0:?}"
);
}
/// Long choice display names get truncated with `…`. The bug was
/// that `display` rendered via
/// raw `set_span` without `truncate_str`, producing mid-character
/// clips. Same shape as the description test above.
#[test]
fn render_picker_truncates_long_display_with_ellipsis() {
let entries = vec![SettingMeta {
key: "long_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Long",
description: "Short.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "wide",
choices: &[EnumChoice {
canonical: "wide",
display: "An absurdly long display name designed to overflow",
description: "Short desc.",
}],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "long_enum",
choices_idx: 0,
original_value: SettingValue::Enum("wide"),
supports_preview: true,
};
let area = Rect {
x: 0,
y: 0,
width: 24,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let mut row_text = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 3)) {
row_text.push_str(cell.symbol());
}
}
assert!(
row_text.contains('\u{2026}'),
"long display must truncate with `…`, got: {row_text:?}"
);
}
/// Long setting label in the title row truncates with `…`.
#[test]
fn render_picker_truncates_long_title_with_ellipsis() {
let entries = vec![SettingMeta {
key: "long_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "An exceptionally verbose setting label that overflows",
description: "Short.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "a",
choices: &[EnumChoice {
canonical: "a",
display: "A",
description: "A.",
}],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "long_enum",
choices_idx: 0,
original_value: SettingValue::Enum("a"),
supports_preview: true,
};
let area = Rect {
x: 0,
y: 0,
width: 20,
height: 12,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
let mut title_text = String::new();
for x in 0..area.width {
if let Some(cell) = buf.cell((x, 0)) {
title_text.push_str(cell.symbol());
}
}
assert!(
title_text.contains('\u{2026}'),
"long title must truncate with `…`, got: {title_text:?}"
);
}
/// When choices > visible_h, the picker renders an overflow
/// indicator `… N more` on the last visible row.
#[test]
fn render_picker_shows_more_indicator_when_choices_overflow() {
// Build a registry with 8 choices (exceeds 4-row viewport
// at height=8).
let entries = vec![SettingMeta {
key: "long_enum",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Many",
description: "Many choices.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "c0",
choices: &[
EnumChoice {
canonical: "c0",
display: "C0",
description: "0",
},
EnumChoice {
canonical: "c1",
display: "C1",
description: "1",
},
EnumChoice {
canonical: "c2",
display: "C2",
description: "2",
},
EnumChoice {
canonical: "c3",
display: "C3",
description: "3",
},
EnumChoice {
canonical: "c4",
display: "C4",
description: "4",
},
EnumChoice {
canonical: "c5",
display: "C5",
description: "5",
},
],
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}];
let mut s = SettingsModalState::new(
Arc::new(SettingsRegistry::from_entries(entries)),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::PickingEnum {
key: "long_enum",
choices_idx: 0,
original_value: SettingValue::Enum("c0"),
supports_preview: true,
};
// Total height 7 → header_rows=3 (title+desc+gap) + 4 choices
// rows. With 6 choices, 4 fit in viewport - 1 (reserved for
// overflow). So 3 visible, 3 hidden → "… 3 more".
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 7,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_picking_enum(&mut buf, area, &s, &theme);
// Scan all rows for the overflow indicator.
let mut all_text = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
all_text.push_str(cell.symbol());
}
}
all_text.push('\n');
}
assert!(
all_text.contains('\u{2026}') && all_text.contains("more"),
"overflow indicator '… N more' must render, got:\n{all_text}"
);
}
// -- render_settings_modal routing coverage --
/// `render_settings_modal` branches on mode → picker render path.
/// Verifies that the search-bar placeholder text is NOT present
/// (proves the picker branch fired and the Browse path was
/// skipped) AND that hit-test rects are reset on entry.
#[test]
fn render_settings_modal_routes_to_picker_when_mode_is_picking_enum() {
let mut s = picker_test_state();
// Pre-populate row_rects so we can verify reset_hit_rects().
s.row_rects = vec![
Rect {
x: 0,
y: 0,
width: 10,
height: 1,
};
s.rows.len()
];
s.list_area = Rect {
x: 0,
y: 0,
width: 10,
height: 10,
};
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 30,
};
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
// Scan the buffer for the Browse-mode search-bar placeholder.
// If the picker branch fired, this string should NOT appear.
let mut all_text = String::new();
for y in 0..area.height {
for x in 0..area.width {
if let Some(cell) = buf.cell((x, y)) {
all_text.push_str(cell.symbol());
}
}
all_text.push('\n');
}
assert!(
!all_text.contains("/ to search"),
"picker mode must not render the Browse-mode search bar"
);
// The picker's setting label should be visible.
assert!(
all_text.contains("Test enum"),
"picker mode must render the setting label"
);
// Hit-test rects must be reset.
assert!(
s.row_rects.iter().all(|r| r == &Rect::default()),
"row_rects should be reset to default on picker entry, got: {:?}",
s.row_rects
);
}
// -- mouse + catch-all coverage --
/// Scroll wheel during PickingEnum mode is a no-op AND does NOT
/// mutate `state.selected` (the underlying Browse selection).
/// Regression test.
#[test]
fn picker_mode_scroll_wheel_is_noop_and_preserves_browse_selection() {
let mut s = picker_test_state();
let selected_before = s.selected;
let outcome = handle_settings_mouse(&mut s, MouseEventKind::ScrollDown, 10, 5);
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"Scroll in picker mode must be Unchanged, got {outcome:?}"
);
assert_eq!(
s.selected, selected_before,
"scroll in picker mode must NOT mutate Browse selection"
);
let outcome = handle_settings_mouse(&mut s, MouseEventKind::ScrollUp, 10, 5);
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
assert_eq!(s.selected, selected_before);
}
/// Mouse click in PickingEnum mode is a no-op (click-to-pick is
/// handled elsewhere).
#[test]
fn picker_mode_mouse_click_is_noop() {
let mut s = picker_test_state();
let selected_before = s.selected;
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
5,
5,
);
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
assert_eq!(s.selected, selected_before);
}
/// Random keypresses in PickingEnum mode are Unchanged and don't
/// leak to other handlers (e.g., the filter query).
#[test]
fn picker_ignores_random_keypress() {
let mut s = picker_test_state();
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
assert!(
matches!(
s.mode,
SettingsModalMode::PickingEnum { choices_idx: 0, .. }
),
"mode must remain PickingEnum after random keypress"
);
}
/// EditingValue: char keys mutate the buffer (Changed),
/// Enter on an empty / invalid buffer is a no-op (the validator
/// gate refuses commit), and Esc returns to Browse.
///
/// **History note:** an earlier
/// scaffold test (`editing_value_ignores_non_esc_keys`) asserted
/// every non-Esc key returned `Unchanged`. The editor is now wired
/// for real, so chars mutate the buffer and the test
/// inverts: chars produce `Changed` (buffer mutation), Enter on
/// an invalid buffer produces `Unchanged` (validator refuses).
///
/// Uses a snapshot with a populated `available_models` list
/// so the `KnownModel` validator has data to validate against —
/// otherwise an empty catalog short-circuits to "valid" (defense
/// in depth — the dispatcher's resolution step is the
/// belt-and-suspenders backstop).
#[test]
fn editing_value_chars_mutate_buffer_and_invalid_enter_is_noop() {
// `default_model` is now a `SettingKind::DynamicEnum`, so the
// production catalog no
// longer wires the String editor. We construct a synthetic
// registry to keep the editor-mode contract under test —
// `editor_render_fixture` uses the same pattern.
let mut s = editor_render_fixture("", 0);
// Char 'a' goes into the buffer → Changed.
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"char insert in EditingValue must be Changed, got {outcome:?}"
);
match &s.mode {
SettingsModalMode::EditingValue {
buffer,
validation_error,
..
} => {
assert_eq!(buffer, "a");
assert!(
validation_error.is_some(),
"validation_error must be Some for unknown model 'a' \
(catalog has 'Kigi 4 Fast' only)",
);
}
_ => panic!("mode must remain EditingValue after char input"),
}
// Enter on a buffer that fails the KnownModel validator
// (catalog has 'Kigi 4 Fast'; "a" doesn't match) is
// Unchanged — commit refused.
let outcome =
handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"Enter on invalid buffer must be Unchanged, got {outcome:?}"
);
assert!(
matches!(s.mode, SettingsModalMode::EditingValue { .. }),
"Enter on invalid buffer must keep EditingValue mode (no commit)"
);
}
// -- helper-function coverage --
/// `picker_choices_len` returns 0 for an unknown key, a non-Enum
/// key, and a zero-choice Enum.
#[test]
fn picker_choices_len_handles_missing_and_non_enum() {
let s = picker_test_state_in_browse();
// Unknown key → 0.
assert_eq!(picker_choices_len(&s, "unknown-key-xyzzy"), 0);
// The synthetic registry contains only "test_enum" — there's
// no Bool to test against without rebuilding. Test the
// non-Enum case via the default registry instead.
let bool_state = make_state();
assert_eq!(picker_choices_len(&bool_state, "compact_mode"), 0);
}
#[test]
fn picker_choice_at_returns_none_for_oob_and_missing() {
let s = picker_test_state_in_browse();
assert_eq!(picker_choice_at(&s, "test_enum", 0), Some("first"));
assert_eq!(picker_choice_at(&s, "test_enum", 99), None);
assert_eq!(picker_choice_at(&s, "unknown-key", 0), None);
let bool_state = make_state();
assert_eq!(picker_choice_at(&bool_state, "compact_mode", 0), None);
}
#[test]
fn editing_value_esc_returns_to_browse() {
let mut s = make_state();
s.mode = SettingsModalMode::EditingValue {
key: "default_model",
buffer: "kigi-4".to_string(),
cursor_byte: "kigi-4".len(),
validation_error: None,
};
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
assert!(matches!(s.mode, SettingsModalMode::Browse));
}
// -- Direct unit tests for compute_filtered --
//
// The free function is module-private; integration tests can only
// reach it through the key-press surface. These unit tests pin
// structural cases that are not naturally reachable through that
// surface (e.g.,
// a category whose settings all fail the filter — header excluded).
#[test]
fn compute_filtered_empty_query_returns_identity() {
let rows = vec![
RowEntry::Header {
category: SettingCategory::Appearance,
},
RowEntry::Setting {
key: "compact_mode",
meta_index: 0,
},
RowEntry::Setting {
key: "show_timestamps",
meta_index: 1,
},
];
let registry = SettingsRegistry::defaults();
let result = compute_filtered(&rows, &registry, "");
assert_eq!(result, vec![0, 1, 2]);
}
#[test]
fn compute_filtered_excludes_header_when_no_children_match() {
// Synthetic rows: header + setting that doesn't match.
// Header MUST be excluded — emitting an orphan header is a
// visual bug.
let rows = vec![
RowEntry::Header {
category: SettingCategory::Appearance,
},
RowEntry::Setting {
key: "compact_mode",
meta_index: 0,
},
];
let registry = SettingsRegistry::defaults();
let result = compute_filtered(&rows, &registry, "xyzzy-no-match");
assert!(
result.is_empty(),
"header must be excluded when no settings in its section match, got {result:?}"
);
}
#[test]
fn compute_filtered_single_word_match_emits_header_then_setting() {
let rows = vec![
RowEntry::Header {
category: SettingCategory::Appearance,
},
RowEntry::Setting {
key: "compact_mode",
meta_index: 0,
},
RowEntry::Setting {
key: "show_timestamps",
meta_index: 1,
},
RowEntry::Setting {
key: "simple_mode",
meta_index: 2,
},
];
let registry = SettingsRegistry::defaults();
// "density" is a compact_mode keyword only.
let result = compute_filtered(&rows, &registry, "density");
assert_eq!(result, vec![0, 1], "header then compact_mode in order");
}
#[test]
fn compute_filtered_multi_word_and_match_narrows_further() {
let rows = vec![
RowEntry::Header {
category: SettingCategory::Appearance,
},
RowEntry::Setting {
key: "compact_mode",
meta_index: 0,
},
];
let registry = SettingsRegistry::defaults();
// Both "compact" and "density" are compact_mode keywords.
let result = compute_filtered(&rows, &registry, "compact density");
assert_eq!(result, vec![0, 1]);
}
/// `advance_next`'s `None`-arm defensive path: when `selected`
/// is manually mutated to a row hidden by the filter, the next
/// navigation jumps to the FIRST visible setting (Down → top).
/// This exercises the asymmetric-defensive-path documented in
/// `advance_next`/`advance_prev`.
#[test]
fn advance_next_recovers_when_selection_is_hidden() {
let mut s = make_state();
// Apply a filter that hides compact_mode.
s.query = "stamp".to_string();
s.query_cursor = s.query.len();
s.invalidate_filter();
// Manually corrupt selected to a HIDDEN row (compact_mode is
// row 1, hidden by "stamp"). This bypasses
// clamp_selected_to_visible and exercises the defensive arm.
let compact_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
.unwrap();
s.selected = compact_idx;
// Advance: lands on the first visible setting (show_timestamps).
let moved = s.advance_next();
assert!(moved);
let show_ts_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "show_timestamps"))
.unwrap();
assert_eq!(s.selected, show_ts_idx);
}
/// Counterpart to `advance_next_recovers_when_selection_is_hidden`:
/// when `selected` is on a hidden row, Up lands on the LAST
/// visible setting. Asymmetric by design (each picks the nearest
/// end of the filter from the user's perspective).
#[test]
fn advance_prev_recovers_when_selection_is_hidden() {
let mut s = make_state();
// Apply a filter matching only show_timestamps and simple_mode.
// "mode" matches both: compact_mode label, simple_mode label
// AND show_timestamps via... actually let's pick a more reliable
// filter — use individual keywords. "simple" matches simple_mode
// only. Let's use that and corrupt selected to compact_mode
// (hidden). Up should land on the LAST visible setting which
// is simple_mode.
s.query = "simple".to_string();
s.query_cursor = s.query.len();
s.invalidate_filter();
let compact_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
.unwrap();
s.selected = compact_idx;
let moved = s.advance_prev();
assert!(moved);
let simple_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "simple_mode"))
.unwrap();
assert_eq!(s.selected, simple_idx);
}
// -- blank line above category section headers --
//
// The renderer reserves one empty visual line ABOVE every section
// header EXCEPT the one that lands first in the viewport. These
// tests render the modal directly to a buffer and inspect the
// y-positions of the rendered category labels.
/// Scan one row of the buffer and return its text content (no
/// styles) with leading/trailing whitespace preserved.
fn buf_row_text(buf: &Buffer, y: u16, x: u16, width: u16) -> String {
let mut s = String::new();
for col in x..x.saturating_add(width) {
if let Some(cell) = buf.cell((col, y)) {
s.push_str(cell.symbol());
}
}
s
}
/// Find the absolute column index where `needle` begins on row
/// `y` of `buf`, scanning from `x_start` to `x_end - 1`. Walks
/// cells one at a time and compares each cell's symbol so the
/// returned column is the actual buffer position — not a byte
/// offset projected onto width as `text.find(needle)` would
/// give. Returns `None` if `needle` doesn't appear on the row.
///
/// Use this in any test that needs to assert per-cell style for
/// a text fragment. Earlier tests cast
/// `string.find(needle) as u16` which only matched the cell
/// position for ASCII content; once a unicode glyph entered the
/// row's label/value, the byte offset diverged from the column.
fn find_text_col(buf: &Buffer, y: u16, needle: &str) -> Option<u16> {
if needle.is_empty() {
return None;
}
// Sweep the row. For each starting column, compare the
// sequence of cell symbols against the needle's chars.
let area = buf.area;
let needle_chars: Vec<&str> =
unicode_segmentation::UnicodeSegmentation::graphemes(needle, true).collect();
let x_start = area.x;
let x_end = area.x.saturating_add(area.width);
for x in x_start..x_end {
let mut col = x;
let mut all_match = true;
for &grapheme in &needle_chars {
let Some(cell) = buf.cell((col, y)) else {
all_match = false;
break;
};
if cell.symbol() != grapheme {
all_match = false;
break;
}
col = col.saturating_add(grapheme.width() as u16);
if col >= x_end {
// Needle is partially past the right edge.
all_match = false;
break;
}
}
if all_match {
return Some(x);
}
}
None
}
/// Render the row list with default registry; assert that every
/// section header AFTER the first has a blank line immediately
/// above it.
#[test]
fn section_headers_have_blank_line_above_except_first() {
let mut s = make_state();
// Allocate a generous viewport so every category fits — the
// default registry contains 6 categories with 16 settings;
// the blank lines push us to ~23 lines, fits in 60.
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_rows(&mut buf, area, &mut s, &theme);
// Collect each category's expected label + the y position of
// its rendered label line. Headers render with their full
// label (e.g. "Appearance"); we locate each header by
// searching for its label as the row content.
let mut header_ys: Vec<(u16, &'static str)> = Vec::new();
for cat in SettingCategory::ALL {
// Skip categories the default registry doesn't populate
// (e.g. Session — no settings registered).
let has_setting = s
.rows
.iter()
.any(|r| matches!(r, RowEntry::Header { category } if category == cat));
if !has_setting {
continue;
}
let label = cat.label();
for y in 0..area.height {
let row_text = buf_row_text(&buf, y, area.x, area.width);
if row_text.trim_start().starts_with(label) {
header_ys.push((y, label));
break;
}
}
}
assert!(
header_ys.len() >= 2,
"this test requires ≥2 rendered headers, got: {header_ys:?}"
);
// First header has NO blank line above it — it hugs the top.
let (first_y, _) = header_ys[0];
assert_eq!(
first_y, area.y,
"first section header must sit at the top of the list area, got y={first_y}"
);
// Every subsequent header has an EMPTY visual line immediately above.
for &(y, label) in header_ys.iter().skip(1) {
assert!(
y > area.y,
"section header `{label}` rendered above the top of the area (y={y})"
);
let above = buf_row_text(&buf, y - 1, area.x, area.width);
assert!(
above.chars().all(|c| c == ' '),
"line above section header `{label}` must be blank, got: {above:?}"
);
}
}
/// When the viewport begins at a section header, we do NOT
/// reserve a leading blank line above it — the header hugs the
/// top of the row-list area.
#[test]
fn first_section_header_has_no_leading_gap() {
let mut s = make_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_rows(&mut buf, area, &mut s, &theme);
// The very first row of the area must contain the first
// category's label (Appearance is first in
// `SettingCategory::ALL` and is registered by
// `default_settings()`).
let first_row = buf_row_text(&buf, area.y, area.x, area.width);
let appearance = SettingCategory::Appearance.label();
assert!(
first_row.trim_start().starts_with(appearance),
"first rendered line must be the `{appearance}` header, got: {first_row:?}"
);
}
/// Row hit-rects must remain aligned with the actually-rendered
/// y positions when blank lines are inserted above section
/// headers. Click on a setting row's y-coordinate should match
/// the rect stored in `state.row_rects`.
#[test]
fn row_rects_shift_down_for_blank_lines_above_headers() {
let mut s = make_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_rows(&mut buf, area, &mut s, &theme);
// For every row, the stored rect's y must match the actual
// rendered y. We verify this by checking that the row's
// content (Header label or Setting label) appears at the
// rect's y position. Skip rows with default Rect (off-screen).
for (i, r) in s.rows.iter().enumerate() {
let rect = s.row_rects[i];
if rect.width == 0 {
continue;
}
let row_text = buf_row_text(&buf, rect.y, area.x, area.width);
let expected_substring = match r {
RowEntry::Header { category } => category.label().to_string(),
RowEntry::Setting { meta_index, .. } => {
s.registry.all()[*meta_index].label.to_string()
}
};
assert!(
row_text.contains(&expected_substring),
"row {i} rect.y={} should contain `{expected_substring}` but got: {row_text:?}",
rect.y,
);
}
}
// -- two-line layout when label + value don't fit --
//
// The `row_layout` helper decides one-line vs two-line vs
// two-line-with-label-truncation based on the full label width.
// These tests pin the behaviour at three width budgets that
// exercise each layout variant.
/// Unit test for the extracted
/// `wrap_description` helper — pins its behavior in one place
/// so each of the three callers doesn't have to assert it
/// indirectly through a modal-render check.
#[test]
fn wrap_description_empty_and_zero_width_return_empty() {
assert!(wrap_description("", 80).is_empty());
assert!(wrap_description("anything", 0).is_empty());
}
#[test]
fn wrap_description_single_line_when_fits() {
let wrapped = wrap_description("Short text.", 80);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0], "Short text.");
}
#[test]
fn wrap_description_splits_long_text_at_word_boundaries() {
let long = "alpha beta gamma delta epsilon zeta eta theta iota";
let wrapped = wrap_description(long, 15);
// Total visible chars (no `…`) reassembles the original.
assert!(wrapped.len() >= 2, "must wrap at narrow width: {wrapped:?}");
let joined: String = wrapped.join(" ");
for word in long.split_whitespace() {
assert!(
joined.contains(word),
"wrap must preserve word `{word}` (no mid-word truncation): {joined:?}",
);
}
// No `…` truncation marker in any line.
for line in &wrapped {
assert!(
!line.contains('\u{2026}'),
"wrap line must not contain `…`: {line:?}",
);
}
}
fn synthetic_long_label_meta() -> SettingMeta {
// Fixed 31-cell label kept for the two-line threshold tests
// below. Previously matched the literal `simple_mode` label
// (now renamed to "Disable vim input mode" — 22 cells); the
// longer literal stays to exercise the wrap path that the
// shorter rename no longer triggers organically.
SettingMeta {
key: "test-long-label",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Disable vim mode (experimental)",
description: "Long-label test for Commit 13 two-line layout.",
keywords: &["test"],
kind: SettingKind::Bool { default: false },
restart_required: false,
hidden_in_minimal: false,
}
}
fn synthetic_enum_chevron_meta() -> SettingMeta {
SettingMeta {
key: "test-enum-with-chevron",
category: SettingCategory::Advanced,
owner: SettingOwner::Shared,
label: "Coding data sharing",
description: "Enum row that opens a picker — chevron suffix applies.",
keywords: &["test"],
kind: SettingKind::Enum {
default: "opt-out",
choices: TEST_ENUM_CHOICES,
supports_preview: true,
},
restart_required: false,
hidden_in_minimal: false,
}
}
/// Render a long label at a narrow area; expect the value to drop
/// to line 2 right-aligned, while the full label stays on line 1.
///
/// "Disable vim mode (experimental)" = 31 cells. One-line total
/// (with `off` + chrome = 38 cells) doesn't fit at width=35, so
/// the row picks `TwoLine`. The label alone (with triangle +
/// right pad = 34 cells) DOES fit, so it stays on line 1
/// without truncation.
#[test]
fn narrow_terminal_drops_value_to_second_line() {
let meta = synthetic_long_label_meta();
let area = Rect {
x: 0,
y: 0,
width: 35,
height: 2,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let value_rect = render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(false),
24, // max_label_w — ignored for layout.
false,
&theme,
false,
false, // is_hovered
);
let line1 = buf_row_text(&buf, 0, area.x, area.width);
let line2 = buf_row_text(&buf, 1, area.x, area.width);
assert!(
line1.contains("Disable vim mode (experimental)"),
"line 1 must contain the FULL label (no truncation): {line1:?}"
);
assert!(
!line1.contains("off"),
"value `off` must NOT render on line 1 — it should drop to line 2. line1={line1:?}"
);
assert!(
line2.contains("off"),
"value `off` must render on line 2 (right-aligned): {line2:?}"
);
// Value should be right-aligned: the `off` text ends just
// before the (reserved-but-empty) chevron column AND the
// 1-cell right pad. Line-2
// reserves the same `ROW_RIGHT_PAD_W + ROW_CHEVRON_COL_W`
// suffix as line 1, so the chevron column is at a constant
// right offset from the area's right edge regardless of
// whether a row went one-line or two-line. We allow 1
// extra cell of slack for the gap between value and the
// chevron column.
let last_idx = line2.rfind("off").expect("line2 contains `off`");
let slack = (ROW_CHEVRON_COL_W as usize) + (ROW_RIGHT_PAD_W as usize) + 1;
assert!(
last_idx + "off".len() >= (area.width as usize).saturating_sub(slack),
"value must be right-aligned (within {slack} cells of right edge): \
last_idx={last_idx}, area.width={}",
area.width,
);
assert_eq!(
value_rect.y,
area.y + 1,
"value_rect.y must be on line 2 (area.y + 1), got y={}",
value_rect.y
);
}
/// At wide area widths, the row collapses to a single line — full
/// label + value on the same line.
#[test]
fn wide_terminal_keeps_value_on_first_line() {
let meta = synthetic_long_label_meta();
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 2,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let value_rect = render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(false),
24,
false,
&theme,
false,
false, // is_hovered
);
let line1 = buf_row_text(&buf, 0, area.x, area.width);
let line2 = buf_row_text(&buf, 1, area.x, area.width);
assert!(
line1.contains("Disable vim mode (experimental)") && line1.contains("off"),
"wide-area one-line layout must render label + value on line 1: {line1:?}"
);
assert!(
line2.chars().all(|c| c == ' '),
"line 2 must be blank in wide-area one-line layout: {line2:?}"
);
assert_eq!(
value_rect.y, area.y,
"value_rect.y must be line 1 (area.y) in one-line layout"
);
}
/// `row_layout`'s pathological-truncation branch: when even the
/// label alone exceeds the row width, the label gets truncated
/// with `…` on line 1 and the value still drops to line 2.
#[test]
fn pathologically_narrow_truncates_label_with_ellipsis() {
let meta = synthetic_long_label_meta();
let area = Rect {
x: 0,
y: 0,
width: 25,
height: 2,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_setting_row(
&mut buf,
area,
&meta,
&SettingValue::Bool(false),
24,
false,
&theme,
false,
false, // is_hovered
);
let line1 = buf_row_text(&buf, 0, area.x, area.width);
let line2 = buf_row_text(&buf, 1, area.x, area.width);
assert!(
line1.contains('\u{2026}'),
"line 1 must contain the `…` ellipsis when label is too wide: {line1:?}"
);
assert!(
line2.contains("off"),
"value `off` must still drop to line 2 even when label is truncated: {line2:?}"
);
}
/// Two-line rows expand `state.row_rects` to span BOTH lines so
/// mouse clicks on either line trigger the same default action.
///
/// `default_selected_permission`: label 27 + value "Always allow
/// on all sessions" 28 + chevron 2 + chrome 4 = 61 cells
/// one-line. We render at width=40 so the row drops to two lines.
#[test]
fn two_line_row_hit_rect_spans_both_lines() {
let mut s = make_state();
let row_idx = s
.rows
.iter()
.position(
|r| matches!(r, RowEntry::Setting { key, .. } if *key == "default_selected_permission"),
)
.expect("default_selected_permission must be registered");
// Render at a narrow width so default_selected_permission
// forces a two-line layout.
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
s.selected = row_idx;
render_rows(&mut buf, area, &mut s, &theme);
let rect = s.row_rects[row_idx];
assert!(
rect.height >= 2,
"two-line row hit-rect must span ≥2 lines, got height={}",
rect.height
);
// Synthesize a click on line 2 of the row. The mouse handler
// should fire the default action (open the enum picker for
// default_selected_permission).
s.list_area = area;
let click_y = rect.y + 1;
// Click somewhere in the middle of line 2.
let click_x = rect.x + rect.width / 2;
// First click: only selects (since selection might not match).
// Force selection on first to make it a direct activation.
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
click_x,
click_y,
);
// Selection already matches, so click activates: enum picker
// opens (mode flips to PickingEnum).
match outcome {
SettingsKeyOutcome::Changed => {
assert!(
matches!(s.mode, SettingsModalMode::PickingEnum { .. }),
"click on line 2 of a two-line Enum row must open the picker, \
got mode {:?}",
s.mode
);
}
other => panic!(
"click on line 2 must produce Changed (selection or activation), \
got {other:?}"
),
}
}
/// Expanded two-line rows render label (line 1), value (line 2),
/// and the wrapped description on subsequent lines.
#[test]
fn two_line_row_with_expansion_renders_three_segments() {
let mut s = make_state();
// Default selected permission's label + value (with chevron)
// won't fit on a 40-col line, forcing two-line layout.
let row_idx = s
.rows
.iter()
.position(
|r| matches!(r, RowEntry::Setting { key, .. } if *key == "default_selected_permission"),
)
.expect("default_selected_permission must be registered");
s.selected = row_idx;
s.expanded_keys.insert("default_selected_permission");
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_rows(&mut buf, area, &mut s, &theme);
let rect = s.row_rects[row_idx];
assert!(
rect.height >= 2,
"expanded two-line row must allocate ≥2 lines for the row itself, got height={}",
rect.height
);
// The row label is on line 1.
let label_line = buf_row_text(&buf, rect.y, area.x, area.width);
assert!(
label_line.contains("Default selected permission"),
"line 1 must contain the row label: {label_line:?}"
);
// The value is on line 2. It comes from the canonical →
// display mapping: `UiConfig::default()` resolves to the
// `always_allow_all_sessions` canonical, whose registered
// display is "Always allow on all sessions".
let value_line = buf_row_text(&buf, rect.y + 1, area.x, area.width);
assert!(
value_line.contains("Always allow"),
"line 2 must contain the value text: {value_line:?}"
);
// The expanded description renders on line 3 and below.
let desc_line = buf_row_text(&buf, rect.y + 2, area.x, area.width);
assert!(
!desc_line.chars().all(|c| c == ' '),
"line 3 must contain wrapped description text (non-blank): {desc_line:?}"
);
}
/// The contextual-hints group row carries no value, but when its key is in
/// `expanded_keys` (Right/l) it must still paint its description below the
/// chevron row — mirroring how normal rows surface an expanded description.
/// Regression guard: before the fix the group short-circuited out of both
/// the height + render loops, so Right/l set `expanded_keys` but painted
/// nothing.
#[test]
fn group_row_renders_expanded_description() {
let mut s = make_state();
let row_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
.expect("contextual_hints group must be registered");
s.selected = row_idx;
s.expanded_keys.insert("contextual_hints");
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_rows(&mut buf, area, &mut s, &theme);
let rect = s.row_rects[row_idx];
// Line 1 is the group's chevron row (its label).
let label_line = buf_row_text(&buf, rect.y, area.x, area.width);
assert!(
label_line.contains("Show contextual hints"),
"line 1 must contain the group label: {label_line:?}"
);
// The description renders on the line below the chevron row (non-blank).
let desc_line = buf_row_text(&buf, rect.y + 1, area.x, area.width);
assert!(
!desc_line.chars().all(|c| c == ' '),
"expanded group must paint its description below the chevron row \
(non-blank): {desc_line:?}"
);
// The painted text matches the registered description (derive a token
// from the live copy so this stays green across description edits).
let desc = s
.registry
.find("contextual_hints")
.expect("group registered")
.description;
let token = desc
.split_whitespace()
.nth(1)
.unwrap_or("")
.trim_matches(|c: char| !c.is_alphanumeric());
assert!(
!token.is_empty() && desc_line.contains(token),
"expanded group description must render its text (token `{token}`): {desc_line:?}"
);
}
/// `row_layout`'s width threshold: a label + value that exactly
/// fits picks `OneLine`; one cell narrower picks `TwoLine`.
#[test]
fn row_layout_threshold_is_exact() {
let label = "Coding data sharing"; // 19 cells
let value = "Opt out"; // 7 cells
// chrome (triangle + gap + chevron + right pad) = 2 + 1 + 2 + 1 = 6
// total = 19 + 7 + 6 = 32 cells (chevron-enabled).
assert_eq!(row_layout(32, label, value, false), RowLayout::OneLine);
assert_eq!(row_layout(31, label, value, false), RowLayout::TwoLine);
}
/// Sanity: `row_layout` handles bool-without-chevron rows
/// (Bool kind, no `` suffix). The chevron
/// column is reserved even for Bool rows, so the chrome cost
/// is the same with and without the glyph.
///
/// The dead
/// `has_chevron` parameter has been removed; `row_layout` now
/// always reserves the chevron column. The Bool / Enum
/// distinction at the renderer is purely whether to paint
/// the `` glyph in the (always-reserved) column.
#[test]
fn row_layout_bool_without_chevron() {
let label = "Disable vim mode (experimental)"; // 31 cells
let value = "off"; // 3 cells
// chrome (triangle + gap + reserved chevron col + right pad)
// = 2 + 1 + 2 + 1 = 6 cells, identical to the
// chevron-enabled case.
// total = 31 + 3 + 6 = 40 cells.
assert_eq!(row_layout(40, label, value, false), RowLayout::OneLine);
assert_eq!(row_layout(39, label, value, false), RowLayout::TwoLine);
}
/// Sanity: `_ = synthetic_enum_chevron_meta` reference so the test
/// helper isn't flagged unused while the two-line fixtures stabilise.
#[test]
fn synthetic_enum_chevron_meta_constructs() {
let m = synthetic_enum_chevron_meta();
assert_eq!(m.key, "test-enum-with-chevron");
}
// -- User-feedback follow-up: always reserve a blank line between
// the "Tip · Ask Kigi…" docs footer and the keybindings hints.
//
// Before this fix, when the hints wrapped to 2 lines (narrow modal
// widths) the chrome's 2-row footer was fully consumed by hint
// rows, so the tip sat directly above the first hint line with no
// gap. The fix bumps `footer_lines` to `predicted_hint_rows + 1`
// so the chrome footer always reserves a blank row above the
// hints — preserving the visual hierarchy at any width.
//
// The "don't wrap" fixture uses FilterFocused mode because its
// shortcut set is short enough (~76 cells) to fit on a single row
// at the modal's max_width=110. Browse-mode hints (~114 cells)
// wrap at every modal width supported by `render_settings_modal`,
// so they're useless as a "no wrap" fixture.
/// Find the y of the buffer row containing `needle` (first match,
/// scanning top to bottom). Returns `None` if no row matches.
fn find_row_y(buf: &Buffer, area: Rect, needle: &str) -> Option<u16> {
for y in area.y..area.y.saturating_add(area.height) {
let row = buf_row_text(buf, y, area.x, area.width);
if row.contains(needle) {
return Some(y);
}
}
None
}
/// Return true if every cell strictly INSIDE the modal popup's
/// vertical borders on the given row is whitespace. The modal
/// borders (`│` at popup_area.x and at popup_area.x + width - 1)
/// are excluded from the check so we test the gap-line interior,
/// not the chrome glyphs.
fn modal_interior_row_is_blank(buf: &Buffer, popup_area: Rect, y: u16) -> bool {
let left = popup_area.x.saturating_add(1);
let right = popup_area
.x
.saturating_add(popup_area.width)
.saturating_sub(1);
for x in left..right {
if let Some(cell) = buf.cell((x, y))
&& cell.symbol() != " "
{
return false;
}
}
true
}
/// Narrow modal: the Browse-mode hint string
/// `↑/↓/j/k nav | g/G top/btm | …` wraps to 2+ lines. Without
/// the fix the tip would sit directly above the first hint
/// line; with the fix there's exactly one blank row between
/// them.
#[test]
fn footer_has_blank_line_between_tip_and_hints_when_hints_wrap() {
let mut s = make_state();
// 70-col viewport caps modal_width at max(70*0.70, 44) = 49,
// so footer_width ≈ 45. Browse-mode hints are ~114 cells so
// they wrap to at least 2 rows.
let area = Rect {
x: 0,
y: 0,
width: 70,
height: 30,
};
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let popup_area = s.window.popup_area.expect("modal must have rendered");
let tip_y = find_row_y(&buf, area, "Tip").expect("tip row must render");
// Sanity-check that the hints actually wrap — if a future PR
// trims the hint string enough that they fit on one row at
// this width the test passes for the wrong reason. Look for
// the first hint label (`nav`) AND the last (`F2/Esc`); they
// must land on different y if the hints wrapped.
// Use `j/k nav` (hint-unique) rather than `nav` alone, which
// also matches the `vim_mode` row's "navigation" keyword.
let first_hint_y = find_row_y(&buf, area, "j/k nav").expect("first hint line must render");
let last_hint_y = find_row_y(&buf, area, "F2/Esc").expect("close hint must render");
assert!(
last_hint_y > first_hint_y,
"this test requires the hints to wrap to ≥2 lines; got first_hint_y={first_hint_y} \
last_hint_y={last_hint_y} — pick a narrower width if the hint string shrank"
);
// Tip → blank gap → first hint stacked contiguously at the
// bottom of the modal.
assert_eq!(
first_hint_y,
tip_y + 2,
"tip → gap → hints must stack with exactly one blank line between tip and the \
first hint line; tip_y={tip_y} first_hint_y={first_hint_y}"
);
let gap_y = tip_y + 1;
assert!(
modal_interior_row_is_blank(&buf, popup_area, gap_y),
"row between tip and hints must be entirely blank inside the modal interior, \
got: {:?}",
buf_row_text(&buf, gap_y, popup_area.x, popup_area.width)
);
}
/// Wide modal in FilterFocused mode: hints fit on a single line.
/// Same blank-gap contract as the wrap case — the chrome's
/// baseline `footer_lines: 2` already provides this gap, but the
/// test pins the contract so a future change that drops the
/// baseline (or shrinks `predicted_rows + 1` to `predicted_rows`)
/// is caught.
#[test]
fn footer_has_blank_line_between_tip_and_hints_when_hints_dont_wrap() {
let mut s = make_state();
// FilterFocused mode has 5 shortcuts totalling ~76 cells —
// fits on one row at any modal width supported by
// `render_settings_modal` (max_width=110).
s.mode = SettingsModalMode::FilterFocused;
let area = Rect {
x: 0,
y: 0,
width: 150,
height: 30,
};
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let popup_area = s.window.popup_area.expect("modal must have rendered");
let tip_y = find_row_y(&buf, area, "Tip").expect("tip row must render");
// FilterFocused-mode hints: `type to filter | ↑/↓ nav |
// Backspace edit | Enter commit | Esc clear`. Verify both
// ends land on the SAME row (proves no wrap).
let first_hint_y =
find_row_y(&buf, area, "type to filter").expect("first hint must render");
let last_hint_y = find_row_y(&buf, area, "Esc clear").expect("last hint must render");
assert_eq!(
first_hint_y, last_hint_y,
"this test requires the hints to fit on a single line; at width=150 + \
FilterFocused mode we expect one row, got first={first_hint_y} last={last_hint_y}"
);
// Same tip → blank gap → hint contract as the wrap case.
assert_eq!(
first_hint_y,
tip_y + 2,
"tip → gap → hints must stack with exactly one blank line between tip and the \
hint line; tip_y={tip_y} first_hint_y={first_hint_y}"
);
let gap_y = tip_y + 1;
assert!(
modal_interior_row_is_blank(&buf, popup_area, gap_y),
"row between tip and hints must be entirely blank inside the modal interior, \
got: {:?}",
buf_row_text(&buf, gap_y, popup_area.x, popup_area.width)
);
}
/// When the hints transition from 1 row to 2 rows (e.g. a width
/// reduction), the row-list area shrinks by exactly 1 row to
/// make room for the second hint row. Pinned so anyone
/// "reclaiming" the gap row later sees the spec violation.
///
/// Uses FilterFocused mode for both renders because Browse-mode
/// hints don't fit on a single row at any width supported by the
/// modal — there'd be no "1-row" baseline to compare against. The
/// narrow viewport (100 cols → modal_width=70, footer_width=64)
/// is tuned so FilterFocused hints (~76 cells incl. separators)
/// wrap to exactly 2 rows (not 3+) — a more-aggressive narrow
/// would split this into 3+ rows and the assertion below would
/// trip on the multi-row delta.
#[test]
fn footer_total_height_grows_when_hints_wrap() {
// Wide modal: FilterFocused-mode hints fit on 1 row.
// footer_lines = 1 (hints) + 1 (gap) = 2 → matches baseline.
let wide_area = Rect {
x: 0,
y: 0,
width: 150,
height: 30,
};
let mut s_wide = make_state();
s_wide.mode = SettingsModalMode::FilterFocused;
let mut buf_wide = Buffer::empty(wide_area);
render_settings_modal(&mut buf_wide, wide_area, &mut s_wide, false, None);
let wide_list_height = s_wide.list_area.height;
// Narrow modal: same mode, hints wrap to exactly 2 rows.
// footer_lines = 2 (hints) + 1 (gap) = 3 → 1 more than wide.
let narrow_area = Rect {
x: 0,
y: 0,
width: 100,
height: 30,
};
let mut s_narrow = make_state();
s_narrow.mode = SettingsModalMode::FilterFocused;
let mut buf_narrow = Buffer::empty(narrow_area);
render_settings_modal(&mut buf_narrow, narrow_area, &mut s_narrow, false, None);
let narrow_list_height = s_narrow.list_area.height;
// Verify the wrap actually happens at the narrow width AND
// doesn't over-wrap to 3+ rows (the assertion below would
// also fire if both renders had the same wrap count OR the
// narrow case wrapped further, which would be a silent test
// bug).
let narrow_first_hint =
find_row_y(&buf_narrow, narrow_area, "type to filter").expect("first hint");
let narrow_last_hint =
find_row_y(&buf_narrow, narrow_area, "Esc clear").expect("last hint");
assert_eq!(
narrow_last_hint,
narrow_first_hint + 1,
"narrow fixture must wrap the hints to exactly 2 rows; got \
first={narrow_first_hint} last={narrow_last_hint}"
);
let wide_first_hint = find_row_y(&buf_wide, wide_area, "type to filter").expect("first");
let wide_last_hint = find_row_y(&buf_wide, wide_area, "Esc clear").expect("last");
assert_eq!(
wide_first_hint, wide_last_hint,
"wide fixture must NOT wrap the hints; got first={wide_first_hint} \
last={wide_last_hint}"
);
// The narrow render reserves one extra row at the bottom for
// the wrapped hint, so the row-list area is exactly one row
// shorter. Equality (not "<=") rules out off-by-N
// regressions where future code reserves 2 rows instead of 1.
assert_eq!(
narrow_list_height + 1,
wide_list_height,
"row-list area must shrink by exactly 1 row when hints wrap (narrow={}, wide={})",
narrow_list_height,
wide_list_height,
);
}
// -- palette consistency --
/// Section headers render in the palette's style: ` {label} `
/// in `gray + BOLD` followed by `─` separator cells in
/// `gray_dim`. Asserts that (a) the header label cell carries
/// the gray foreground and BOLD modifier matching the
/// palette's `render_picker_entry` Header arm, and (b) at
/// least one trailing cell renders a `─` glyph.
#[test]
fn section_header_style_matches_palette() {
let mut s = make_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 60,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_rows(&mut buf, area, &mut s, &theme);
// Find the row containing the "Appearance" header.
let label = SettingCategory::Appearance.label();
let mut header_y: Option<u16> = None;
for y in 0..area.height {
let txt = buf_row_text(&buf, y, area.x, area.width);
if txt.trim_start().starts_with(label) {
header_y = Some(y);
break;
}
}
let header_y = header_y.expect("must find Appearance header");
// The label is rendered at col 1 (after the leading space)
// in the gray + BOLD style — matches the palette.
let cell = buf.cell((area.x + 1, header_y)).expect("cell at label col");
assert_eq!(
cell.fg, theme.gray,
"section header label fg must be theme.gray (palette parity)"
);
assert!(
cell.modifier.contains(Modifier::BOLD),
"section header label must be BOLD (palette parity)"
);
// At least one trailing `─` separator cell must render after
// the label — find the first `─` in the row after the label.
let row_text = buf_row_text(&buf, header_y, area.x, area.width);
assert!(
row_text.contains('\u{2500}'),
"section header row must contain `─` separator cells: {row_text:?}"
);
// The separator cells must
// render in `theme.gray_dim` for palette parity. Walk the
// row and find the first `─` cell; assert its fg color.
// Mirrors the `search_bar_renders_divider_below` pattern.
let mut sep_cell_fg = None;
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell((x, header_y))
&& cell.symbol() == "\u{2500}"
{
sep_cell_fg = Some(cell.fg);
break;
}
}
let sep_fg = sep_cell_fg.expect("must find at least one `─` separator cell");
assert_eq!(
sep_fg, theme.gray_dim,
"section header `─` separator must render in theme.gray_dim \
(palette parity)",
);
}
/// Search bar renders the palette's prefix + cursor style:
/// * ` search: ` prefix in `gray`.
/// * Inverse-video cursor (bg = text_primary, fg = bg_base)
/// at the next-input position when focused.
///
/// Hint path renders ` / to search` in `gray_dim`.
#[test]
fn search_bar_focused_style_matches_palette() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
crate::views::picker::render_search_bar(
&mut buf,
area.x,
area.y,
area.width,
&theme,
"",
true,
true,
0,
Some(theme.bg_base),
);
// First label cell carries the `gray` fg.
let first = buf.cell((area.x + 1, area.y)).expect("col 1 cell");
assert_eq!(
first.fg, theme.gray,
"search bar label prefix must use theme.gray"
);
// Cursor cell at the input position (label is ` search: ` =
// 9 cells; cursor lands at col 9) is inverse-video: bg =
// text_primary, fg = bg_base.
let cursor_x = " search: ".width() as u16;
let cursor_cell = buf.cell((area.x + cursor_x, area.y)).expect("cursor cell");
assert_eq!(
cursor_cell.bg, theme.text_primary,
"cursor cell bg must be text_primary (inverse-video)"
);
assert_eq!(
cursor_cell.fg, theme.bg_base,
"cursor cell fg must be bg_base (inverse-video)"
);
}
/// Empty + unfocused search bar shows ` / to search` in
/// `gray_dim` — same wording the palette uses.
///
/// Sample multiple cells of
/// the hint span (not just col 1) so a regression that styled
/// only the first few cells in gray_dim and left the rest at
/// default is caught.
#[test]
fn search_bar_placeholder_matches_palette() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
crate::views::picker::render_search_bar(
&mut buf,
area.x,
area.y,
area.width,
&theme,
"",
false,
true,
0,
Some(theme.bg_base),
);
let txt: String = (area.x..area.x + area.width)
.filter_map(|x| buf.cell((x, area.y)).map(|c| c.symbol().to_string()))
.collect();
assert!(
txt.contains("/ to search"),
"search bar placeholder must read `/ to search`, got: {txt:?}"
);
// Sample BOTH ends of the hint span. The hint is
// " / to search" (12 cells); the first slash is at col 1
// and the trailing `h` is at col 11.
let slash_cell = buf.cell((area.x + 1, area.y)).expect("col 1 cell (/)");
assert_eq!(
slash_cell.fg, theme.gray_dim,
"first hint cell (`/`) must render in theme.gray_dim"
);
let hint = " / to search";
let last_col = area.x + (hint.width() as u16 - 1);
let last_cell = buf.cell((last_col, area.y)).expect("last hint cell");
assert_eq!(
last_cell.fg, theme.gray_dim,
"LAST hint cell ({last_col}) must also be theme.gray_dim — \
a regression that styled only the prefix would slip past a \
single-cell sample",
);
}
// -- value color + chevron column + docs footer --
/// Bool `off` values render in the muted `gray` color while
/// Bool `on` values keep the active `accent_user`: the inactive
/// state should read as visually subordinate.
#[test]
fn bool_off_value_renders_in_dim_color() {
let meta = SettingMeta {
key: "test-bool-dim",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Test bool",
description: "",
keywords: &[],
kind: SettingKind::Bool { default: false },
restart_required: false,
hidden_in_minimal: false,
};
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let theme = Theme::current();
// Render with `off` — value cells must be styled with
// `theme.gray`.
let mut buf_off = Buffer::empty(area);
render_setting_row(
&mut buf_off,
area,
&meta,
&SettingValue::Bool(false),
15,
false,
&theme,
false,
false,
);
// Use `find_text_col` so the
// column index is the actual buffer position, not a byte
// offset cast to u16 (which only works for ASCII content).
let off_col = find_text_col(&buf_off, 0, "off").expect("must find `off` substring");
let off_cell = buf_off.cell((off_col, 0)).expect("off cell");
assert_eq!(
off_cell.fg, theme.gray,
"Bool(false) value must render in theme.gray (Misha/Kevin Fix 4)",
);
// Also assert the second `f` cell carries the same style —
// a regression that only styled the first cell would slip
// past a one-cell sample.
let off_col_2 = off_col + 2;
let off_cell_2 = buf_off.cell((off_col_2, 0)).expect("off second-f cell");
assert_eq!(
off_cell_2.fg, theme.gray,
"ALL cells of `off` must carry theme.gray (consistency check)",
);
// Render with `on` — value cells stay at `accent_user`.
let mut buf_on = Buffer::empty(area);
render_setting_row(
&mut buf_on,
area,
&meta,
&SettingValue::Bool(true),
15,
false,
&theme,
false,
false,
);
let on_col = find_text_col(&buf_on, 0, "on").expect("must find `on` substring");
let on_cell = buf_on.cell((on_col, 0)).expect("on cell");
assert_eq!(
on_cell.fg, theme.accent_user,
"Bool(true) value must keep theme.accent_user color (active state)",
);
// **Asymmetry assertion**: the
// active and inactive states must use distinct theme
// tokens. The PER-CELL asserts above already pin
// `on.fg == theme.accent_user` and `off.fg == theme.gray`
// — verifying the THEME tokens are also distinct catches
// the orthogonal regression where someone flips one of
// the tokens to match the other (rendering would then
// make on and off visually identical despite the
// per-cell asserts continuing to pass).
//
// Conditional on the test environment's color quantization
// exposing the distinction: in extreme low-color
// terminals both tokens can collapse to `Color::Reset`,
// in which case the asymmetry is unobservable. We
// assert-only when the tokens differ, matching the
// theme-rendered-distinct contract.
if theme.accent_user != theme.gray {
assert_ne!(
on_cell.fg, off_cell.fg,
"Bool(true) and Bool(false) must use DIFFERENT colors \
(asymmetry that Fix 4 introduced; theme tokens differ \
so the per-cell distinction should be observable)",
);
}
}
/// The chevron column is at the same right offset for ALL
/// row kinds — Bool rows leave it empty, Enum/String rows
/// fill it with `" "`, but the column position (and
/// therefore the value's right edge) is constant.
#[test]
fn chevron_column_is_at_constant_right_offset() {
let bool_meta = SettingMeta {
key: "test-bool-col",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Bool row",
description: "",
keywords: &[],
kind: SettingKind::Bool { default: false },
restart_required: false,
hidden_in_minimal: false,
};
let enum_meta = synthetic_enum_chevron_meta();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 1,
};
let theme = Theme::current();
// Bool row — chevron column is empty (no `` glyph).
let mut buf_bool = Buffer::empty(area);
let bool_rect = render_setting_row(
&mut buf_bool,
area,
&bool_meta,
&SettingValue::Bool(true),
10,
false,
&theme,
false,
false,
);
// Enum row — chevron column contains the `` glyph.
let mut buf_enum = Buffer::empty(area);
let enum_rect = render_setting_row(
&mut buf_enum,
area,
&enum_meta,
&SettingValue::Enum("choice_a"),
10,
false,
&theme,
false,
false,
);
// The chevron column is a 2-cell block at
// `area.right - ROW_RIGHT_PAD_W - ROW_CHEVRON_COL_W` (i.e.
// `area.right - 3` in this fixture). The `` glyph occupies
// the SECOND cell of the column (the first cell is a
// leading space that doubles as gap from the value). For
// Bool rows the column stays empty.
let glyph_x = area.x + area.width - ROW_RIGHT_PAD_W - 1;
let bool_cell = buf_bool.cell((glyph_x, 0)).expect("bool col cell");
assert_eq!(
bool_cell.symbol().trim(),
"",
"Bool row's chevron column must be empty (no `` glyph): \
cell symbol = {:?}",
bool_cell.symbol(),
);
let enum_cell = buf_enum.cell((glyph_x, 0)).expect("enum col cell");
assert_eq!(
enum_cell.symbol(),
"\u{203A}",
"Enum row's chevron column must contain the `` glyph at \
area.right - {} (constant right offset across rows), got: {:?}",
ROW_RIGHT_PAD_W + 1,
enum_cell.symbol(),
);
// The value hit-rect's right edge must be the same for
// both rows — that's the required visual alignment.
// Both rects end at `value_rect.x + value_rect.width`
// which equals `chevron_col_x + ROW_CHEVRON_COL_W`.
let bool_right = bool_rect.x + bool_rect.width;
let enum_right = enum_rect.x + enum_rect.width;
assert_eq!(
bool_right, enum_right,
"Bool and Enum value hit-rects must share the same right \
edge (bool_right={bool_right}, enum_right={enum_right})",
);
// Also exercise the
// SAME buffer with multiple rows stacked so we catch a
// regression where, e.g., only Bool rows compute the wrong
// chevron column. Render Bool then Enum on consecutive
// rows and assert the chevron column lands at the same
// column for both.
let multi_area = Rect {
x: 0,
y: 0,
width: 80,
height: 4,
};
let mut buf_multi = Buffer::empty(multi_area);
let bool_area = Rect {
height: 1,
..multi_area
};
let enum_area = Rect {
y: 2,
height: 1,
..multi_area
};
let _ = render_setting_row(
&mut buf_multi,
bool_area,
&bool_meta,
&SettingValue::Bool(false),
10,
false,
&theme,
false,
false,
);
let _ = render_setting_row(
&mut buf_multi,
enum_area,
&enum_meta,
&SettingValue::Enum("choice_a"),
10,
false,
&theme,
false,
false,
);
// Bool row's `off` ends at column N; Enum row's `` glyph
// lands at column M. The contract: N == M's column
// minus 1 (the gap between value and chevron column) — OR
// equivalently, the chevron-column glyph position is the
// same on both rows.
let glyph_x_multi = multi_area.x + multi_area.width - ROW_RIGHT_PAD_W - 1;
let enum_glyph_cell = buf_multi
.cell((glyph_x_multi, 2))
.expect("enum row chevron glyph cell");
assert_eq!(
enum_glyph_cell.symbol(),
"\u{203A}",
"Enum row's chevron glyph must land at glyph_x={glyph_x_multi}",
);
let bool_glyph_cell = buf_multi
.cell((glyph_x_multi, 0))
.expect("bool row chevron column cell");
assert_eq!(
bool_glyph_cell.symbol().trim(),
"",
"Bool row's chevron column must be empty at the SAME \
glyph_x as Enum's (constant right offset across rows)",
);
}
/// Two-line rows anchor the
/// chevron column at the SAME right offset as one-line rows.
/// Before the fix, line-2's chevron landed 1 cell further
/// right than line-1's, producing a staircase in mixed-layout
/// row lists.
#[test]
fn chevron_column_aligns_across_one_and_two_line_layouts() {
let theme = Theme::current();
// `synthetic_enum_chevron_meta` has label "Coding data
// sharing" (19 chars) + value "choice_a" (8 chars). At
// width=25 the one-line total (2 + 19 + 1 + 8 + 2 + 1 =
// 33) exceeds the width, so the layout flips to TwoLine.
// At width=60 the same row fits one-line.
let area_two = Rect {
x: 0,
y: 0,
width: 25,
height: 2,
};
let mut buf_two = Buffer::empty(area_two);
let _ = render_setting_row(
&mut buf_two,
area_two,
&synthetic_enum_chevron_meta(),
&SettingValue::Enum("choice_a"),
10,
false,
&theme,
false,
false,
);
let area_one = Rect {
x: 0,
y: 0,
width: 60,
height: 1,
};
let mut buf_one = Buffer::empty(area_one);
let _ = render_setting_row(
&mut buf_one,
area_one,
&synthetic_enum_chevron_meta(),
&SettingValue::Enum("choice_a"),
10,
false,
&theme,
false,
false,
);
// The column offset from the area's right edge is constant:
// `area.right - ROW_RIGHT_PAD_W - 1` is the `` glyph
// position (the chevron span " " is 2 cells; the second
// cell holds the glyph). Computed independently for each
// area so the offset semantics is what we're testing.
let glyph_x_two = area_two.x + area_two.width - ROW_RIGHT_PAD_W - 1;
let glyph_x_one = area_one.x + area_one.width - ROW_RIGHT_PAD_W - 1;
let two_line_cell = buf_two
.cell((glyph_x_two, 1))
.expect("two-line chevron cell on line 2");
assert_eq!(
two_line_cell.symbol(),
"\u{203A}",
"Two-line row's chevron must land at \
`area.right - ROW_RIGHT_PAD_W - 1` on LINE 2 (UX Issue 2)",
);
let one_line_cell = buf_one
.cell((glyph_x_one, 0))
.expect("one-line chevron cell");
assert_eq!(
one_line_cell.symbol(),
"\u{203A}",
"One-line row's chevron must land at `area.right - ROW_RIGHT_PAD_W - 1`",
);
// The offset from the right edge is the same — pin that
// explicitly so a future refactor that changes one anchor
// independently trips the test.
assert_eq!(
area_two.x + area_two.width - glyph_x_two,
area_one.x + area_one.width - glyph_x_one,
"chevron offset-from-right must be constant across layouts",
);
}
/// The docs-footer tip text centers itself horizontally
/// within its row.
///
/// Also exercise the SHORT-
/// fallback path (narrow widths where the LONG message
/// doesn't fit) and the truncation path (extreme narrow
/// where even SHORT doesn't fit), so a regression that moved
/// the centering math into the LONG branch only would fail.
#[test]
fn docs_footer_tip_is_centered() {
let theme = Theme::current();
// Helper: render at `width` and return (full row text,
// tip start col, leading_ws, trailing_ws).
let render = |width: u16| -> (String, usize, usize) {
let area = Rect {
x: 0,
y: 0,
width,
height: 1,
};
let mut buf = Buffer::empty(area);
render_docs_footer(&mut buf, area, &theme);
let row: String = (area.x..area.x + area.width)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect();
let tip_start = row.find("Tip").expect("docs footer must contain `Tip`");
let trailing_ws = row.chars().rev().take_while(|c| *c == ' ').count();
(row, tip_start, trailing_ws)
};
// LONG path: width=80 fits the full message.
let (row_long, tip_start_long, trailing_long) = render(80);
assert!(
tip_start_long > 0,
"LONG tip must be centered (start > col 0); row={row_long:?}",
);
assert!(
tip_start_long.abs_diff(trailing_long) <= 1,
"LONG tip leading_ws={tip_start_long} vs trailing_ws={trailing_long}",
);
// SHORT path: width that fits SHORT but not LONG.
// SHORT = "Tip · Ask Kigi to change a setting" (34 cells);
// LONG ≈ 73 cells. width=40 lands in the SHORT band.
let (row_short, tip_start_short, trailing_short) = render(40);
assert!(
row_short.contains("change a setting"),
"width=40 must render SHORT path (contains `change a setting`): {row_short:?}",
);
assert!(
!row_short.contains("kigiday"),
"width=40 must NOT render LONG path (no `kigiday`): {row_short:?}",
);
assert!(
tip_start_short.abs_diff(trailing_short) <= 1,
"SHORT tip must also be centered (Round-3 tests Issue 10): \
leading_ws={tip_start_short} vs trailing_ws={trailing_short}",
);
// Truncated path: width too narrow even for SHORT. The
// truncation prefix `Tip · …` should still render; the
// centering math operates on the truncated SHORT.
let (row_tiny, tip_start_tiny, _trailing_tiny) = render(15);
assert!(
row_tiny.contains("Tip"),
"even at width=15 the `Tip` prefix must render: {row_tiny:?}",
);
// At width=15, the truncated SHORT fills most/all of the
// row; leading_ws could be 0 if the truncation is exactly
// 15 cells. The contract: centering math doesn't crash
// and starts at a valid column (not negative).
assert!(
tip_start_tiny < 15,
"tip start must be inside the row at width=15: {tip_start_tiny}",
);
}
/// `render_settings_modal` reserves a 1-row blank gap above
/// the tip line — so the tip has air on both top (this gap)
/// and bottom (the chrome's `predicted_hint_rows + 1` gap).
#[test]
fn tip_line_has_blank_row_above() {
let mut s = make_state();
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 40,
};
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
// Find the tip row.
let mut tip_y: Option<u16> = None;
for y in 0..area.height {
let txt = buf_row_text(&buf, y, area.x, area.width);
if txt.contains("Tip") && txt.contains("Ask Kigi") {
tip_y = Some(y);
break;
}
}
let tip_y = tip_y.expect("must find tip row");
// The row immediately above the tip must be blank inside
// the modal's content area. Modal borders (`│`) at the
// left/right edges are expected; we strip leading/trailing
// border characters before checking that the interior is
// all spaces.
assert!(
tip_y > 0,
"tip row must not be at y=0 (no row above to check)",
);
let above = buf_row_text(&buf, tip_y - 1, area.x, area.width);
let interior: String = above
.trim_matches(|c: char| c == ' ' || c == '\u{2502}')
.to_string();
assert!(
interior.is_empty(),
"row above tip must be blank inside the modal interior \
(Misha/Kevin Fix 5): full row = {above:?}, interior = {interior:?}",
);
}
// -- sub-pane polish --
/// Helper: open the picker for the named enum/dyn-enum key in
/// `make_state()`. Returns the state with PickingEnum mode
/// armed. Panics if the key isn't found or isn't an enum.
fn enter_picker_for(key: &'static str) -> SettingsModalState {
let mut s = make_state();
let row_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
.unwrap_or_else(|| panic!("no row for key `{key}` in default registry"));
assert!(s.select_at(row_idx), "select_at({row_idx})");
assert!(
s.try_enter_picking_enum(),
"try_enter_picking_enum failed for {key} — non-enum?",
);
s
}
/// Long enum description word-wraps across multiple buffer
/// rows in the picker sub-pane — no `…` truncation.
///
/// The previous version of this
/// test used the live `theme` setting whose description
/// (`"Color theme for the pager UI."`) is 29 chars and fits on
/// a single line at width=30 — the word-wrap code path was
/// never engaged. Fixed by:
/// 1. Constructing a synthetic registry with a deliberately
/// long description that MUST wrap.
/// 2. Asserting the wrap produces ≥ 2 buffer rows containing
/// description fragments — proves multi-line rendering
/// actually occurred.
/// 3. Asserting the description's LAST word renders — proves
/// the wrap reached the end (no mid-sentence truncation).
/// 4. Replacing the brittle `s…`/`.…` substring check with
/// a clean "no `…` anywhere in the description region"
/// check.
#[test]
fn picker_description_word_wraps_no_ellipsis() {
// Synthetic enum setting with a description forced to wrap.
// The description is ~140 chars; at width=30 with a small
// amount of chrome on either side, it MUST produce ≥ 4
// description rows.
let long_desc = "This is a deliberately long description \
designed to force the word-wrap renderer \
across multiple rows so the test exercises \
the wrap logic instead of trivially fitting.";
let synthetic_meta = SettingMeta {
key: "test-wrap-desc",
category: SettingCategory::Appearance,
owner: SettingOwner::Shared,
label: "Wrap test",
description: long_desc,
keywords: &[],
kind: SettingKind::Enum {
default: "choice_a",
choices: TEST_ENUM_CHOICES,
supports_preview: false,
},
restart_required: false,
hidden_in_minimal: false,
};
let registry = SettingsRegistry::from_entries(vec![synthetic_meta]);
let mut s = SettingsModalState::new(
Arc::new(registry),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
// `SettingsModalState::new` snaps `selected` to the first
// non-header row; with our single-entry registry that's
// already the synthetic setting. Skip `select_at` since it
// would no-op (and return false) when called on the
// already-selected row.
assert!(
matches!(
s.rows.get(s.selected),
Some(RowEntry::Setting {
key: "test-wrap-desc",
..
})
),
"selected row must be the synthetic test entry",
);
assert!(
s.try_enter_picking_enum(),
"synthetic must be picker-eligible"
);
let theme = Theme::current();
let area = Rect {
x: 0,
y: 0,
width: 30,
height: 25,
};
let mut buf = Buffer::empty(area);
render_picking_enum(&mut buf, area, &s, &theme);
// Collect every row of buffer text.
let rows: Vec<String> = (0..area.height)
.map(|y| buf_row_text(&buf, y, area.x, area.width))
.collect();
let all_text: String = rows.join("\n");
// 1. First word of description renders somewhere.
let first_word = long_desc.split_whitespace().next().unwrap_or("");
assert!(
all_text.contains(first_word),
"picker must render the description's first word `{first_word}`",
);
// 2. LAST word of description renders too — proves wrap
// reached the end (no mid-sentence truncation). The
// description ends with "fitting." so we look for that.
let last_word = long_desc.split_whitespace().last().unwrap_or("");
assert!(
all_text.contains(last_word),
"picker must render the description's LAST word `{last_word}` \
(proves word-wrap reached the end): {all_text}",
);
// 3. Multiple description rows render. Find the title row
// (first row containing "Wrap test"), then count
// consecutive subsequent rows that contain non-empty text
// that's not a choice marker — those are the description
// rows. We expect ≥ 2.
let title_y = rows
.iter()
.position(|r| r.contains("Wrap test"))
.expect("must find title row");
let mut desc_row_count = 0usize;
for (i, row) in rows.iter().enumerate().skip(title_y + 1) {
// Choice markers are `\u{25CB}` (○) or `\u{25CF}` (●).
if row.contains('\u{25CB}') || row.contains('\u{25CF}') {
break;
}
let interior = row.trim();
if interior.is_empty() {
continue;
}
// Sanity: stop walking if we hit a row that has
// weirdly long whitespace runs without any description
// chars (defensive).
if i > title_y + 20 {
break;
}
desc_row_count += 1;
}
assert!(
desc_row_count >= 2,
"wrapped description must span ≥ 2 buffer rows; got {desc_row_count}\n{all_text}",
);
// 4. No `…` truncation marker anywhere in the buffer
// (replaced the
// ".\u{2026}" / "s\u{2026}" coupled-to-last-char check
// with a clean absence-of-ellipsis check).
assert!(
!all_text.contains('\u{2026}'),
"picker description must not contain any `…` truncation \
marker (Misha/Kevin Fix 6a): {all_text}",
);
}
/// `render_settings_modal` populates `settings_breadcrumb_rect`
/// in sub-pane modes; clears it in Browse / FilterFocused.
///
/// Exercises both
/// `PickingEnum` AND `EditingValue` since the field name says
/// "sub_pane_modes" (plural). Also pins the rect's x and y
/// so a future modal-chrome refactor that
/// shifts the title origin trips a test rather than silently
/// breaking the breadcrumb. The hit-rect
/// spans the FULL breadcrumb (`Settings <label>`) so any
/// click on the breadcrumb routes back to Browse.
#[test]
fn settings_breadcrumb_rect_set_in_sub_pane_modes() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 30,
};
let mut s = make_state();
let mut buf = Buffer::empty(area);
// Browse — no breadcrumb rect.
render_settings_modal(&mut buf, area, &mut s, false, None);
assert!(
s.settings_breadcrumb_rect.is_none(),
"Browse mode must NOT populate settings_breadcrumb_rect",
);
// Enter PickingEnum for `theme`.
let mut s = enter_picker_for("theme");
let mut buf2 = Buffer::empty(area);
render_settings_modal(&mut buf2, area, &mut s, false, None);
let rect = s
.settings_breadcrumb_rect
.expect("PickingEnum must populate settings_breadcrumb_rect");
let popup = s.window.popup_area.expect("popup_area must be set");
assert_eq!(
rect.height, 1,
"breadcrumb rect must be 1 row tall (sits on the chrome's top border)",
);
// Width = full breadcrumb `Settings <label>`. The leaf
// label varies by setting — assert it's strictly wider
// than `Settings` alone (proof that the rect extends past
// the prefix) AND at least MODAL_TITLE + " ".
let prefix_w = MODAL_TITLE.width() + " \u{203A} ".width();
assert!(
(rect.width as usize) > MODAL_TITLE.width(),
"rect width must extend past `Settings` alone for theme picker, got {}",
rect.width,
);
assert!(
(rect.width as usize) >= prefix_w,
"rect width must include the `Settings ` prefix at minimum, got {}",
rect.width,
);
// Pin x/y so a chrome refactor that shifts the title
// origin trips a test. Origin is
// `popup.x + 1 (left border) + 2 ("─ " title decoration)`.
assert_eq!(
rect.x,
popup.x + 3,
"breadcrumb x = popup.x + 1 (border) + 2 (`─ ` title decoration)",
);
assert_eq!(rect.y, popup.y, "breadcrumb sits on the top border row",);
// EditingValue mode — same shape.
let mut s2 = int_stepper_fixture(75);
let mut buf3 = Buffer::empty(area);
render_settings_modal(&mut buf3, area, &mut s2, false, None);
let rect2 = s2
.settings_breadcrumb_rect
.expect("EditingValue must populate settings_breadcrumb_rect");
assert_eq!(rect2.height, 1, "EditingValue rect height must be 1");
assert!(
(rect2.width as usize) > MODAL_TITLE.width(),
"EditingValue rect must extend past `Settings` alone, got {}",
rect2.width,
);
}
/// Clicking inside the breadcrumb rect while in PickingEnum
/// mode dispatches the preview-revert action AND transitions
/// back to Browse.
///
/// Two test variants pin both
/// (a) the no-preview path where original == current value, and
/// (b) the preview-then-click path where the user navigated to
/// a different choice — the revert Action MUST carry the
/// original value, not the navigated-to value. The previous
/// version accepted `Action(_) | Changed` which masked a
/// regression where the revert was forgotten entirely.
#[test]
fn click_settings_breadcrumb_collapses_picker_to_browse() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 30,
};
let mut s = enter_picker_for("theme");
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let rect = s
.settings_breadcrumb_rect
.expect("PickingEnum must populate breadcrumb rect");
// Synthesize a click at the rect's center.
let click_x = rect.x + rect.width / 2;
let click_y = rect.y;
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
click_x,
click_y,
);
// For preview-supporting enums (theme), the breadcrumb-
// click revert dispatches `Action::PreviewTheme(original)`.
// The original canonical for the default theme is
// `"kiginight"`. Tightened from the previous `Action(_) |
// Changed` to lock in the revert contract.
match outcome {
SettingsKeyOutcome::Action(Action::PreviewTheme(orig)) => {
assert_eq!(
orig, "kiginight",
"breadcrumb-click revert must carry the original canonical",
);
}
other => panic!(
"expected Action(PreviewTheme(\"kiginight\")) — the keyboard \
Esc-equivalent revert — got {other:?}",
),
}
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"after the breadcrumb click the mode must be Browse, got {:?}",
s.mode,
);
}
/// Sibling of `click_settings_breadcrumb_collapses_picker_to_browse`
/// that exercises the preview-then-click path: user navigates
/// to a different theme via Down arrow (Action::PreviewTheme
/// dispatched live), then clicks the breadcrumb. The revert
/// Action MUST carry the ORIGINAL value (default theme), not
/// the navigated-to value.
#[test]
fn click_settings_breadcrumb_after_nav_reverts_to_original() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 30,
};
let mut s = enter_picker_for("theme");
// Navigate to a different theme so original != current.
// The picker exposes `choices_idx`; the registry's theme
// choices include at least 2 entries so we can safely
// advance.
let (orig_canonical_owned, advanced_idx) = match &s.mode {
SettingsModalMode::PickingEnum {
choices_idx,
original_value,
..
} => {
let orig = match original_value {
SettingValue::Enum(c) => c.to_string(),
other => panic!("expected SettingValue::Enum, got {other:?}"),
};
(orig, *choices_idx)
}
other => panic!("expected PickingEnum, got {other:?}"),
};
// Pick a different index. The default theme is `kiginight`
// (index 1 per the registry); advance to index 0 to ensure
// we're navigating to a different value.
let target_idx = if advanced_idx == 0 { 1 } else { 0 };
match s.mode {
SettingsModalMode::PickingEnum {
ref mut choices_idx,
..
} => {
*choices_idx = target_idx;
}
_ => unreachable!(),
}
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let rect = s
.settings_breadcrumb_rect
.expect("PickingEnum must populate breadcrumb rect");
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
rect.x + rect.width / 2,
rect.y,
);
match outcome {
SettingsKeyOutcome::Action(Action::PreviewTheme(orig)) => {
assert_eq!(
orig, orig_canonical_owned,
"breadcrumb-click revert must carry the ORIGINAL canonical \
(not the navigated-to value)",
);
}
other => panic!("expected Action(PreviewTheme(<original>)), got {other:?}"),
}
assert!(matches!(s.mode, SettingsModalMode::Browse));
}
/// `d` in PickingEnum for a preview-supporting Enum dispatches
/// an `ActionPair` that (a) reverts the live preview and (b)
/// opens the reset confirm overlay. The modal transitions to
/// Browse so the dispatch arm finds an
/// `ActiveModal::Settings { state: Browse }`.
#[test]
fn d_key_in_picking_enum_dispatches_open_reset_confirm() {
let mut s = enter_picker_for("theme");
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
match outcome {
SettingsKeyOutcome::ActionPair(
Action::PreviewTheme(orig),
Action::OpenResetConfirm { key },
) => {
assert_eq!(
key, "theme",
"OpenResetConfirm key must be the active picker setting",
);
// Default theme is `kiginight`; entering the picker
// captures `original_value = current value = kiginight`,
// so the revert dispatches with that canonical.
assert_eq!(
orig, "kiginight",
"PreviewTheme revert must carry the original canonical",
);
}
other => {
panic!("expected ActionPair(PreviewTheme(_), OpenResetConfirm), got {other:?}")
}
}
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"picker must collapse to Browse before dispatching reset \
(dispatch arm panics in debug if it sees a sub-pane mode)",
);
}
/// `d` in the Int stepper dispatches `Action::OpenResetConfirm`
/// for the active setting AND transitions back to Browse.
///
/// Also verifies the pending
/// buffer is discarded — a user who stepped to a new value
/// then pressed `d` should not have the in-flight value leak
/// past the mode transition. Asserts the mode is structurally
/// `Browse` (not lingering `EditingValue { buffer: "80", … }`).
#[test]
fn d_key_in_int_stepper_dispatches_open_reset_confirm() {
let mut s = int_stepper_fixture(75);
assert!(
matches!(s.mode, SettingsModalMode::EditingValue { .. }),
"fixture must start in EditingValue",
);
// Step Up so the pending buffer diverges from the default
// (`max_thoughts_width` default is 100; this leaves the
// buffer at "80" — not at default, not at the seeded 75).
let _ = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(
int_stepper_buffer(&s),
"80",
"Up arrow must have stepped from 75 to 80",
);
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
match outcome {
SettingsKeyOutcome::Action(Action::OpenResetConfirm { key }) => {
assert_eq!(
key, "max_thoughts_width",
"OpenResetConfirm key must be the active stepper setting",
);
}
other => panic!("expected OpenResetConfirm action, got {other:?}"),
}
assert!(
matches!(s.mode, SettingsModalMode::Browse),
"stepper must collapse to Browse before dispatching reset",
);
// Pending buffer must be discarded — no lingering
// EditingValue payload. The matches! above checks the
// discriminant; this `!matches!` is the explicit
// assertion that we did NOT carry the in-flight buffer
// through the mode change.
assert!(
!matches!(&s.mode, SettingsModalMode::EditingValue { .. }),
"stepper's pending edit must NOT survive the d-reset \
transition, got {:?}",
s.mode,
);
}
/// `d` in the String editor is
/// a typeable character — NOT a reset shortcut (the picker
/// and Int stepper get `d reset`; the String editor doesn't,
/// a deliberate asymmetry). This test
/// guards against a future change that "adds `d` interception for
/// consistency" without realizing it would silently break
/// String editing of any value containing a `d`.
#[test]
fn d_key_in_string_editor_inserts_into_buffer() {
let mut s = editor_render_fixture("Gro", 3);
let outcome = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"`d` in String editor must mutate the buffer (Changed); \
got {outcome:?}",
);
assert!(
!matches!(
outcome,
SettingsKeyOutcome::Action(_) | SettingsKeyOutcome::ActionPair(_, _)
),
"`d` in String editor MUST NOT dispatch a reset (no Action)",
);
assert!(
matches!(s.mode, SettingsModalMode::EditingValue { .. }),
"mode must STILL be EditingValue (no transition); got {:?}",
s.mode,
);
match &s.mode {
SettingsModalMode::EditingValue {
buffer,
cursor_byte,
..
} => {
assert_eq!(
buffer, "Grod",
"`d` must be inserted after `Gro`; got {buffer:?}",
);
assert_eq!(*cursor_byte, 4, "cursor must advance past the inserted `d`",);
}
_ => unreachable!(),
}
}
/// Clicks OUTSIDE the
/// breadcrumb rect — to the immediate left of the leading
/// `─ ` decoration, or past the right edge — must be no-ops.
/// An earlier version tested "click outside
/// Settings" before the rect was widened to span
/// the FULL breadcrumb; the widening shifted the "outside"
/// boundaries but the no-op contract is unchanged.
#[test]
fn click_outside_settings_breadcrumb_is_noop() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 30,
};
let mut s = enter_picker_for("theme");
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let rect = s
.settings_breadcrumb_rect
.expect("PickingEnum must populate breadcrumb rect");
// Click 1 cell PAST the rect's right edge — on the
// trailing ` ─` decoration or the empty modal interior.
let past_right_x = rect.x + rect.width + 2;
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
past_right_x,
rect.y,
);
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"click past the right edge of breadcrumb must be Unchanged; \
got {outcome:?}",
);
assert!(
matches!(s.mode, SettingsModalMode::PickingEnum { .. }),
"mode must STILL be PickingEnum (no transition fired); \
got {:?}",
s.mode,
);
// Click 1 cell BEFORE the rect's left edge — on the
// leading `─ ` decoration.
let before_left_x = rect.x.saturating_sub(1);
let outcome2 = handle_settings_mouse(
&mut s,
MouseEventKind::Down(crossterm::event::MouseButton::Left),
before_left_x,
rect.y,
);
assert!(
matches!(outcome2, SettingsKeyOutcome::Unchanged),
"click before the left edge of breadcrumb must be Unchanged; \
got {outcome2:?}",
);
}
/// Hovering the breadcrumb flips `breadcrumb_hovered` and the
/// mouse handler returns `Changed` so the renderer repaints.
/// Color assertions removed — they depend on whichever theme is
/// loaded in the thread-local cache, which varies between local
/// runs and Bazel CI (theme preview for the "theme" picker can
/// swap the active theme mid-test).
#[test]
fn hover_breadcrumb_flips_state_and_returns_changed() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 30,
};
let mut s = enter_picker_for("theme");
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let rect = s
.settings_breadcrumb_rect
.expect("PickingEnum must populate breadcrumb rect");
assert!(!s.breadcrumb_hovered, "initially not hovered");
// Move onto breadcrumb.
let outcome = handle_settings_mouse(
&mut s,
MouseEventKind::Moved,
rect.x + rect.width / 2,
rect.y,
);
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Moved onto breadcrumb must return Changed; got {outcome:?}",
);
assert!(s.breadcrumb_hovered, "must be hovered after Moved");
// Move off.
let _ = handle_settings_mouse(&mut s, MouseEventKind::Moved, area.x, area.y);
assert!(
!s.breadcrumb_hovered,
"moving outside must clear breadcrumb_hovered",
);
}
/// The row-list-with-search-bar layout reserves row 1 (below
/// the search bar) for a `─` divider in `gray_dim` — palette
/// parity.
#[test]
fn search_bar_renders_divider_below() {
let mut s = make_state();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 30,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
// Use the full settings render so we exercise the same
// layout the user sees.
render_settings_modal(&mut buf, area, &mut s, false, None);
// Find the row containing ` search:` (the search bar) — the
// divider row is the next row.
let mut search_y: Option<u16> = None;
for y in 0..area.height {
let txt = buf_row_text(&buf, y, area.x, area.width);
if txt.contains("/ to search") || txt.contains(" search: ") {
search_y = Some(y);
break;
}
}
let search_y = search_y.expect("must find search bar row");
let divider_y = search_y + 1;
// The divider must span
// the full row width, not just the first cell. Count `─`
// cells across the row's interior (excluding the modal
// borders) and assert ≥ half the width is `─`. Also pin
// the color across multiple cells, not just the first.
let mut box_count = 0usize;
let mut wrong_color_cells = 0usize;
let mut first_box_cell_fg = None;
let mut last_box_cell_fg = None;
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell((x, divider_y))
&& cell.symbol() == "\u{2500}"
{
box_count += 1;
if cell.fg != theme.gray_dim {
wrong_color_cells += 1;
}
if first_box_cell_fg.is_none() {
first_box_cell_fg = Some(cell.fg);
}
last_box_cell_fg = Some(cell.fg);
}
}
assert!(
box_count > 0,
"row immediately below search bar must contain `─` divider cells"
);
// The divider spans the inner area between the modal
// borders; expect a substantial fraction of the row.
// A regression that only painted the first 5 cells as `─`
// would fail this — palette parity is "full-width divider".
assert!(
box_count >= (area.width as usize) / 4,
"divider must span ≥ 1/4 of the row width, got {box_count} cells \
of width {}",
area.width,
);
assert_eq!(
wrong_color_cells, 0,
"ALL `─` cells in divider must use theme.gray_dim (palette parity); \
{wrong_color_cells} cells diverged",
);
// First and last `─` cells share the same fg — defensive.
assert_eq!(
first_box_cell_fg, last_box_cell_fg,
"divider color must be consistent across the row",
);
}
// ---------- max_thoughts_width live wrap preview ----------
//
// The preview block renders below the Int stepper inside the
// EditingValue sub-pane when the active setting key is
// `max_thoughts_width`. Tests cover render presence, the
// title/content style contracts (bold-italic-lowercase title,
// italic content), the bg-tint distinction between title and
// content rows, the wrap-width contract (no content row wider
// than `pending_value`), the clamp behaviour when the terminal
// is narrower than the pending value, omission on too-narrow /
// too-short viewports, gating to the `max_thoughts_width` key
// alone, and the live re-wrap on stepper change.
/// Helper: render the EditingValue sub-pane for
/// `max_thoughts_width` at a given starting buffer value into a
/// fresh `Buffer` of the supplied area, and return `(buf, state)`.
/// The stepper renders at the top of `area`; the preview (if
/// rendered) anchors to the bottom of `area`.
fn render_max_thoughts_width_at(value: i64, area: Rect) -> (Buffer, SettingsModalState) {
let mut s = int_stepper_fixture(value);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
(buf, s)
}
/// Find the first row containing the exact `needle` substring.
fn find_text_row(buf: &Buffer, area: Rect, needle: &str) -> Option<u16> {
for y in area.y..area.y + area.height {
let row = buf_row_text(buf, y, area.x, area.width);
if row.contains(needle) {
return Some(y);
}
}
None
}
/// Test 1: the preview renders directly below the stepper with
/// exactly 1 blank row of separation. The implementation no
/// longer renders in-pane stepper hints, so the spec's
/// "1 blank row above preview" anchors to the stepper row
/// itself: `preview_title_y == stepper_y + 2`. This replaces a
/// prior bottom-anchor placement; the assertion locks the corrected
/// top-anchor in place.
#[test]
fn max_thoughts_width_preview_renders_below_stepper() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let (buf, s) = render_max_thoughts_width_at(85, area);
// Locate the stepper row (the row containing the `` arrow).
let stepper_y =
find_text_row(&buf, area, int_stepper_left_glyph()).expect("stepper row must render");
// Locate the preview title row.
let preview_y = find_text_row(&buf, area, "preview")
.expect("`preview` title row must render below the stepper");
// Exact placement: 1 blank row between stepper and preview
// title, regardless of `area.height` (no bottom-anchoring).
assert_eq!(
preview_y,
stepper_y + 2,
"preview title must sit exactly 1 blank row below the stepper \
(stepper_y={stepper_y}, preview_y={preview_y}); a multi-row gap \
indicates a regression to the prior bottom-anchor placement",
);
// Row between stepper and preview is blank — no leakage
// from either side.
let gap_row = buf_row_text(&buf, stepper_y + 1, area.x, area.width);
assert!(
gap_row.trim().is_empty(),
"the row between the stepper and the preview must be blank; \
got {gap_row:?}",
);
// And the adornment hit-rects should still be populated
// (the preview MUST NOT cannibalize the stepper render).
let (dec_rect, inc_rect) = s.editor_adornment_rects;
assert!(
dec_rect.width > 0 && inc_rect.width > 0,
"stepper arrows must still populate adornment rects after preview render",
);
}
/// Test 2: the title row is bold + italic + lowercase
/// `preview`. We sample the cell at the title row's `p`
/// (column 0 of the preview block, since the preview is
/// left-aligned within `area`) and assert the modifiers.
#[test]
fn max_thoughts_width_preview_title_is_bold_italic_lowercase() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
let preview_y = find_text_row(&buf, area, "preview").expect("preview title must render");
// Assert the exact lowercase substring (the row must NOT
// contain "Preview" or "PREVIEW").
let row = buf_row_text(&buf, preview_y, area.x, area.width);
assert!(
row.contains("preview"),
"title row must contain lowercase `preview`; row={row:?}",
);
assert!(
!row.contains("Preview") && !row.contains("PREVIEW"),
"title row must NOT contain capitalised forms; row={row:?}",
);
// Sample the first cell of the title text.
let cell = buf
.cell((area.x, preview_y))
.expect("preview title cell at column 0");
assert_eq!(cell.symbol(), "p", "expected `p` at title column 0");
assert!(
cell.modifier.contains(Modifier::BOLD),
"title cell must carry Modifier::BOLD; got {:?}",
cell.modifier,
);
assert!(
cell.modifier.contains(Modifier::ITALIC),
"title cell must carry Modifier::ITALIC; got {:?}",
cell.modifier,
);
}
/// Test 3: content rows carry `Modifier::ITALIC` (matches the
/// scrollback's thinking-text style convention).
#[test]
fn max_thoughts_width_preview_content_is_italic() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
let preview_y = find_text_row(&buf, area, "preview").expect("preview title must render");
// The first content row is the row immediately below the
// title. Sample column 0 — the first character of the
// wrapped sample text (`L` from "Let me trace through ...").
let content_y = preview_y + 1;
let cell = buf
.cell((area.x, content_y))
.expect("preview content cell at column 0");
assert_eq!(
cell.symbol(),
"L",
"expected `L` from sample text at column 0"
);
assert!(
cell.modifier.contains(Modifier::ITALIC),
"content cell must carry Modifier::ITALIC; got {:?}",
cell.modifier,
);
// And NOT bold — content is italic-only (bold is title-only).
assert!(
!cell.modifier.contains(Modifier::BOLD),
"content cell must NOT carry Modifier::BOLD (title-only); got {:?}",
cell.modifier,
);
}
/// Test 4: the title row visually distinguishes itself from the
/// content rows via two independent signals — (1) a different
/// `bg` token (`bg_visual` vs `bg_highlight`) and (2) a
/// `Modifier::UNDERLINED` that gives consistent visual weight
/// regardless of how much the bg tokens differ in luma.
///
/// The previous name `_title_bg_is_darker_than_content_bg` was
/// misleading: on dark themes (KigiNight, TokyoNight, RosePine
/// Moon) `bg_visual` is actually *lighter* than `bg_highlight`;
/// only on the KigiDay light theme is title darker. The
/// contract that the rendering code actually relies on is "title
/// uses the heavier / more-saturated `bg_visual` token, content
/// uses `bg_highlight`, plus an UNDERLINED title modifier for
/// themes where the luma delta is small (TokyoNight)". The test
/// now matches that contract.
#[test]
fn max_thoughts_width_preview_title_styling_distinguishes_from_content() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
let preview_y = find_text_row(&buf, area, "preview").expect("preview title must render");
let title_cell = buf
.cell((area.x, preview_y))
.expect("title cell at column 0");
let content_cell = buf
.cell((area.x, preview_y + 1))
.expect("content cell at column 0");
// Wiring assertion: rendered cells use the current-theme
// bg tokens (tautological under NO_COLOR; meaningful when
// truecolor is on).
let theme = Theme::current();
assert_eq!(
title_cell.bg, theme.bg_visual,
"title bg must be theme.bg_visual; got {:?}",
title_cell.bg,
);
assert_eq!(
content_cell.bg, theme.bg_highlight,
"content bg must be theme.bg_highlight; got {:?}",
content_cell.bg,
);
// The title carries UNDERLINED in addition to BOLD + ITALIC.
// This is the theme-neutral cue that demarcates the title
// when the bg luma delta is small (TokyoNight).
assert!(
title_cell.modifier.contains(Modifier::UNDERLINED),
"title cell must carry Modifier::UNDERLINED for theme-neutral \
visual weight; got {:?}",
title_cell.modifier,
);
assert!(
!content_cell.modifier.contains(Modifier::UNDERLINED),
"content cell must NOT carry Modifier::UNDERLINED (title-only); \
got {:?}",
content_cell.modifier,
);
// Contrast assertion: regardless of the active palette,
// the raw / un-quantized theme tokens differ. We use the
// raw theme directly so this assertion survives `NO_COLOR`
// / 256-color quantization.
let raw_theme = match crate::theme::Theme::current_kind() {
crate::theme::ThemeKind::KigiNight => crate::theme::Theme::kiginight(),
crate::theme::ThemeKind::TokyoNight => crate::theme::Theme::tokyonight(),
crate::theme::ThemeKind::KigiDay => crate::theme::Theme::kigiday(),
crate::theme::ThemeKind::RosePineMoon => crate::theme::Theme::rosepine_moon(),
// Resolved via `Theme::current()` rather than a constructor
// because `theme::oscura` is a private module.
crate::theme::ThemeKind::OscuraMidnight => crate::theme::Theme::current(),
crate::theme::ThemeKind::Auto => crate::theme::Theme::kiginight(),
};
assert_ne!(
raw_theme.bg_visual, raw_theme.bg_highlight,
"raw theme tokens bg_visual + bg_highlight must be distinct so the preview \
reads as a contained block with two-tone bg",
);
}
/// Test 5: content rows wrap at the pending value — no
/// rendered content row's text width exceeds the pending
/// stepper value.
#[test]
fn max_thoughts_width_preview_wraps_at_pending_value() {
// `area.width` is comfortably wider than the pending value
// so the clamp path doesn't fire — we want to exercise the
// pure-pending wrap path.
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(50, area);
let preview_y = find_text_row(&buf, area, "preview").expect("preview title must render");
// Walk content rows below the title until a blank row
// (signals end of preview block).
let mut content_lines: Vec<String> = Vec::new();
for y in (preview_y + 1)..area.height {
let row = buf_row_text(&buf, y, area.x, area.width);
let trimmed = row.trim_end();
if trimmed.is_empty() {
break;
}
content_lines.push(trimmed.to_string());
}
// Strengthen the wrap-shape assertion.
// A regression that disabled wrap and rendered a single
// truncated line at 50 cols would satisfy `w <= 50` but
// fail `len >= 2`. The ~189-char sample at pending=50
// wraps to ≥ 3 rows in practice; we assert ≥ 2 to leave
// headroom for word-boundary jitter.
assert!(
content_lines.len() >= 2,
"wrap must produce at least 2 content rows at pending=50 for the \
~189-char sample; got {} rows: {content_lines:?}",
content_lines.len(),
);
for line in &content_lines {
// `UnicodeWidthStr::width` counts display columns.
let w = line.width();
assert!(
w <= 50,
"content line {line:?} has display width {w} > pending_value 50",
);
}
}
/// Test 6: when the terminal area is narrower than the pending
/// value, the preview clamps to `area.width`. The title stays
/// plain `preview` (no suffix) — the clamp signal has been
/// moved to a note row rendered below the content; see
/// `clamped_preview_renders_note_below_content` for the note
/// assertion. This test focuses on the wrap shape itself.
#[test]
fn max_thoughts_width_preview_clamps_when_terminal_narrower_than_value() {
// 60-wide area, pending = 85 → clamp to 60.
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
let preview_y = find_text_row(&buf, area, "preview").expect("preview title must render");
let title_row = buf_row_text(&buf, preview_y, area.x, area.width);
// Title must NOT carry the legacy `clamped to N cols`
// suffix — that signal lives in the note row now.
assert!(
!title_row.contains("clamped"),
"title row must NOT contain the `clamped` suffix anymore — the clamp \
indicator moved to a note row below the content; title_row={title_row:?}",
);
// No content line may exceed area.width = 60 cols. Also
// assert ≥ 2 content rows to guard against a regression
// that swapped wrap for truncation. We
// stop scanning at the first non-content line — either a
// blank gap or the new `note: clamped at …` row.
let mut clamped_lines: Vec<String> = Vec::new();
for y in (preview_y + 1)..area.height {
let row = buf_row_text(&buf, y, area.x, area.width);
let trimmed = row.trim_end();
if trimmed.is_empty() {
break;
}
if trimmed.starts_with("note:") {
break;
}
let w = trimmed.width();
assert!(
w <= 60,
"clamped content line {trimmed:?} has display width {w} > clamp 60",
);
clamped_lines.push(trimmed.to_string());
}
assert!(
clamped_lines.len() >= 2,
"clamped wrap must produce ≥ 2 content rows at width=60; got {} rows: \
{clamped_lines:?}",
clamped_lines.len(),
);
}
/// When the preview is clamped (pending > terminal width) AND
/// the modal has enough vertical room, the title stays plain
/// `preview` (no suffix) AND a `note: clamped at N cols` row
/// renders immediately below the wrap content. The note uses
/// `theme.text_secondary` fg, no bg tint, no modifier — it
/// reads as chrome-level text aligned with the preview's
/// left edge.
#[test]
fn clamped_preview_renders_note_below_content() {
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
let preview_y = find_text_row(&buf, area, "preview").expect("preview title must render");
// Title row carries the lowercase `preview` text and no
// `clamped` suffix.
let title_row = buf_row_text(&buf, preview_y, area.x, area.width);
assert!(
title_row.contains("preview"),
"title row must contain lowercase `preview`; row={title_row:?}",
);
assert!(
!title_row.contains("clamped"),
"title row must NOT contain `clamped`; the clamp note lives below \
the wrap content now; row={title_row:?}",
);
// Walk forward to find the note. Wrap content runs on
// consecutive non-empty rows; then exactly one blank row
// serves as the visual gap; then the `note:` row sits
// below that gap. Stop at the first row that breaks the
// "content then gap then note" pattern.
let mut note_y: Option<u16> = None;
let mut last_content_y: Option<u16> = None;
let mut saw_blank_gap = false;
for y in (preview_y + 1)..(area.y + area.height) {
let row = buf_row_text(&buf, y, area.x, area.width);
let trimmed = row.trim_end();
if trimmed.starts_with("note:") {
note_y = Some(y);
break;
}
if trimmed.is_empty() {
if saw_blank_gap {
// Two consecutive blank rows — content ended
// and we're past the note's slot too. Bail.
break;
}
saw_blank_gap = true;
continue;
}
// Non-blank, non-note row. If we already saw the blank
// gap and now see content again, the layout violated
// the contract — bail without finding the note.
if saw_blank_gap {
break;
}
last_content_y = Some(y);
}
let note_y = note_y.expect("clamped preview must render a `note:` row below content");
let last_content_y =
last_content_y.expect("clamped preview must render ≥ 1 content row before the note");
assert_eq!(
note_y,
last_content_y + 2,
"the `note:` row must sit one blank row below the last content row; \
last_content_y={last_content_y} note_y={note_y}",
);
// The note text reports the actual clamp width (area.width = 60).
let note_row = buf_row_text(&buf, note_y, area.x, area.width);
assert!(
note_row.contains("note: clamped at 60 cols"),
"note row must read `note: clamped at 60 cols`; got {note_row:?}",
);
// Style assertions: the note cell at column 0 (the `n` of
// "note:") must carry `theme.text_secondary` fg, no bg
// tint past `theme.bg_base`, and no modifier. We sample
// the modifier directly; the fg/bg colors are theme-
// dependent but compared symbolically to the theme tokens
// in use.
let theme = Theme::current();
let cell = buf
.cell((area.x, note_y))
.expect("note cell at column 0 must exist");
assert_eq!(cell.symbol(), "n", "expected `n` at note column 0");
assert_eq!(
cell.fg, theme.text_secondary,
"note fg must be theme.text_secondary; got {:?}",
cell.fg,
);
assert_eq!(
cell.bg, theme.bg_base,
"note bg must be theme.bg_base (no block tint); got {:?}",
cell.bg,
);
assert!(
cell.modifier.is_empty(),
"note cell must carry no modifier; got {:?}",
cell.modifier,
);
}
/// Boundary: when the preview is clamped but the modal area
/// is too short to fit an extra row below the content for the
/// note, the note is omitted. The wrap content keeps rendering
/// at the full vertical budget — content takes priority.
#[test]
fn clamped_note_omitted_when_insufficient_height() {
// The stepper consumes the first ~5 rows (title + 1-line
// desc + gap + stepper). At width 60 the wrap of the
// sample text produces ≥ 6 wrap lines; we pick an area
// height that allows the stepper + gap + title + content
// to fill every remaining row with NO slack for the note.
//
// Concretely: total area height = stepper_header (~4) +
// preview_block (gap 1 + title 1 + content N). We want
// content_rows == area.height - 2 (no slack). Pick a
// height that's exactly tight enough.
//
// We sweep upward to find the largest height at which no
// note renders, and assert the area's wrap content fills
// every available content row.
let mut tight_height: Option<u16> = None;
for h in 5u16..30u16 {
let area = Rect {
x: 0,
y: 0,
width: 60,
height: h,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
// Skip heights at which the preview is omitted
// entirely (too short).
let Some(preview_y) = find_text_row(&buf, area, "preview") else {
continue;
};
let note_present = find_text_row(&buf, area, "note: clamped").is_some();
if !note_present {
tight_height = Some(h);
// Sanity: verify wrap content still rendered for
// ≥ 1 row below the title — i.e. the preview did
// render, the note was just omitted for room.
let content_y = preview_y + 1;
let content = buf_row_text(&buf, content_y, area.x, area.width);
assert!(
!content.trim().is_empty(),
"wrap content row directly below title must render even when the \
note is omitted; content={content:?}",
);
}
}
// Sanity-check: we found at least one height at which the
// note was omitted (otherwise the boundary fixture is
// useless — every height fits the note). At the tightest
// such height verify the note really IS absent (a final
// explicit assertion in case the loop's discovery state
// bit-rots).
let tight = tight_height.expect(
"the height sweep must find at least one short-but-rendered-preview height \
at which the note is omitted — adjust the sweep range if the fixture changes",
);
let area = Rect {
x: 0,
y: 0,
width: 60,
height: tight,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
assert!(
find_text_row(&buf, area, "note: clamped").is_none(),
"at the tight boundary height (h={tight}) the clamped note must NOT render",
);
}
/// When the preview is NOT clamped (modal width >= pending
/// value), there's no note row anywhere in the buffer.
#[test]
fn unclamped_preview_omits_note() {
// 120-col area, pending = 50 → no clamp.
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(50, area);
assert!(
find_text_row(&buf, area, "preview").is_some(),
"preview must render at this size",
);
assert!(
find_text_row(&buf, area, "note:").is_none(),
"unclamped preview must not render a `note:` row",
);
assert!(
find_text_row(&buf, area, "clamped").is_none(),
"unclamped preview must not contain the word `clamped` anywhere",
);
}
/// Test 7: when the modal area is too short to fit the
/// preview's minimum vertical budget (stepper header + 5 rows
/// below = gap + title + 2 content + gap), the preview is
/// omitted. The stepper still renders alone.
#[test]
fn max_thoughts_width_preview_omitted_when_modal_too_short() {
// The stepper alone consumes ~5 rows (title + wrapped desc
// + gap + stepper). Total area height = 7 leaves 2 rows
// remaining — below the 5-row preview minimum.
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 7,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
// The stepper still renders.
assert!(
find_text_row(&buf, area, int_stepper_left_glyph()).is_some(),
"stepper must still render at short heights",
);
// The preview is omitted.
assert!(
find_text_row(&buf, area, "preview").is_none(),
"preview must be omitted when remaining height < 5 rows",
);
}
/// Test 8: when the modal area is narrower than 30 cols, the
/// preview is omitted. The stepper still renders alone.
#[test]
fn max_thoughts_width_preview_omitted_when_modal_too_narrow() {
let area = Rect {
x: 0,
y: 0,
width: 28,
height: 24,
};
let (buf, _) = render_max_thoughts_width_at(85, area);
assert!(
find_text_row(&buf, area, "preview").is_none(),
"preview must be omitted when area.width < 30 cols",
);
}
/// Test 9: the preview is gated on
/// `setting_key == "max_thoughts_width"`. We build a
/// synthetic Int setting under a different key and assert no
/// preview renders even though the editor sub-pane opens
/// successfully.
///
/// This guards future Int settings from accidentally inheriting
/// the preview behaviour.
#[test]
fn max_thoughts_width_preview_only_renders_for_max_thoughts_width_key() {
let synthetic_meta = SettingMeta {
key: "synthetic_int",
category: SettingCategory::Advanced,
owner: crate::settings::SettingOwner::Shared,
label: "Synthetic Int",
description: "Test fixture.",
keywords: &["test"],
kind: SettingKind::Int {
default: 50,
min: 0,
max: 200,
},
restart_required: false,
hidden_in_minimal: false,
};
let registry = SettingsRegistry::from_entries(vec![synthetic_meta]);
let mut s = SettingsModalState::new(
Arc::new(registry),
UiConfig::default(),
PagerLocalSnapshot::default(),
);
s.mode = SettingsModalMode::EditingValue {
key: "synthetic_int",
buffer: "50".to_string(),
cursor_byte: 2,
validation_error: None,
};
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let mut buf = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf, area, &mut s, &theme);
// Stepper still renders (the key is a registered Int).
assert!(
find_text_row(&buf, area, int_stepper_left_glyph()).is_some(),
"stepper must render for synthetic Int setting",
);
// No preview because the key is NOT max_thoughts_width.
assert!(
find_text_row(&buf, area, "preview").is_none(),
"preview must be hidden for non-max_thoughts_width Int settings",
);
}
/// Test 10: the preview re-wraps when the stepper value
/// changes. We render at pending=50, capture the wrap shape
/// (the set of content row strings), dispatch an Up keystroke
/// to step to 55, re-render, and assert the wrap shape
/// differs.
#[test]
fn max_thoughts_width_preview_updates_when_stepper_changes() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 24,
};
// Capture wrap shape at pending = 50.
let (buf_50, _) = render_max_thoughts_width_at(50, area);
let preview_y_50 =
find_text_row(&buf_50, area, "preview").expect("preview must render at pending 50");
let mut wrap_50: Vec<String> = Vec::new();
for y in (preview_y_50 + 1)..area.height {
let row = buf_row_text(&buf_50, y, area.x, area.width);
let trimmed = row.trim_end().to_string();
if trimmed.is_empty() {
break;
}
wrap_50.push(trimmed);
}
// Step Up (small step: +5) → pending becomes 55.
let mut s = int_stepper_fixture(50);
let outcome = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(
matches!(outcome, SettingsKeyOutcome::Changed),
"Up arrow must step the stepper; got {outcome:?}",
);
assert_eq!(
int_stepper_buffer(&s),
"55",
"buffer must reflect the +5 step",
);
// Re-render at pending = 55.
let mut buf_55 = Buffer::empty(area);
let theme = Theme::current();
render_editing_value(&mut buf_55, area, &mut s, &theme);
let preview_y_55 =
find_text_row(&buf_55, area, "preview").expect("preview must render at pending 55");
let mut wrap_55: Vec<String> = Vec::new();
for y in (preview_y_55 + 1)..area.height {
let row = buf_row_text(&buf_55, y, area.x, area.width);
let trimmed = row.trim_end().to_string();
if trimmed.is_empty() {
break;
}
wrap_55.push(trimmed);
}
// The wrap shape must differ — at width 55 the line breaks
// land on different words than at width 50.
assert_ne!(
wrap_50, wrap_55,
"wrap shape at pending=50 must differ from wrap shape at pending=55",
);
// Assert both renders actually wrap (≥ 2
// content rows). Without this a stub that returned a
// single truncated row at each width would pass the
// `assert_ne!` above (different truncation points) yet
// skip the wrap mechanism entirely.
assert!(
wrap_50.len() >= 2,
"pending=50 wrap must produce ≥ 2 rows; got {}: {wrap_50:?}",
wrap_50.len(),
);
assert!(
wrap_55.len() >= 2,
"pending=55 wrap must produce ≥ 2 rows; got {}: {wrap_55:?}",
wrap_55.len(),
);
}
/// Test 7b (boundary companion to Test 7): the preview *renders*
/// at `area.height == header_rows + MAX_THOUGHTS_WIDTH_PREVIEW_MIN_HEIGHT`,
/// and *omits* one row below that threshold:
/// without the just-above-threshold companion, a regression
/// that bumped `MIN_HEIGHT` to 6 would leave Test 7 passing
/// silently.
#[test]
fn max_thoughts_width_preview_renders_at_just_fits_height() {
// The stepper header (title + 1-row desc + gap + stepper)
// is 4 rows at width=80, so total area height needs at
// least 4 + 5 = 9 rows for the preview to render. We test
// both sides of the boundary.
let just_fits = Rect {
x: 0,
y: 0,
width: 80,
height: 9,
};
let (buf_fit, _) = render_max_thoughts_width_at(85, just_fits);
assert!(
find_text_row(&buf_fit, just_fits, "preview").is_some(),
"preview must render at the just-fits boundary height (header_rows + 5)",
);
// One row below the threshold: preview omitted, stepper
// still renders.
let just_short = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let (buf_short, _) = render_max_thoughts_width_at(85, just_short);
assert!(
find_text_row(&buf_short, just_short, int_stepper_left_glyph()).is_some(),
"stepper must still render one row below the preview threshold",
);
assert!(
find_text_row(&buf_short, just_short, "preview").is_none(),
"preview must omit at one row below the just-fits boundary",
);
}
/// Test 8b (boundary companion to Test 8): the preview
/// *renders* at `area.width == MAX_THOUGHTS_WIDTH_PREVIEW_MIN_WIDTH`
/// (= 30), and *omits* at 29.
#[test]
fn max_thoughts_width_preview_renders_at_just_fits_width() {
let just_fits = Rect {
x: 0,
y: 0,
width: 30,
height: 24,
};
let (buf_fit, _) = render_max_thoughts_width_at(85, just_fits);
assert!(
find_text_row(&buf_fit, just_fits, "preview").is_some(),
"preview must render at the MIN_WIDTH (30 cols) boundary",
);
let just_narrow = Rect {
x: 0,
y: 0,
width: 29,
height: 24,
};
let (buf_narrow, _) = render_max_thoughts_width_at(85, just_narrow);
assert!(
find_text_row(&buf_narrow, just_narrow, "preview").is_none(),
"preview must omit one column below MIN_WIDTH",
);
}
// ──────────────────────────────────────────────────────────────
// Auto-widen tests for max_thoughts_width EditingValue mode.
// ──────────────────────────────────────────────────────────────
/// At a wide terminal (200 cols), entering EditingValue mode
/// for `max_thoughts_width` widens the rendered modal so that
/// its popup width is `terminal_width - MAX_THOUGHTS_WIDTH_WIDENED_MARGIN`
/// (i.e. 192). The default sizing would otherwise produce a
/// 70%-of-terminal = 140-wide modal.
#[test]
fn modal_widens_when_editing_max_thoughts_width() {
let area = Rect {
x: 0,
y: 0,
width: 200,
height: 40,
};
let mut s = int_stepper_fixture(120);
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let popup = s.window.popup_area.expect("modal must have rendered");
let expected = area.width - MAX_THOUGHTS_WIDTH_WIDENED_MARGIN;
assert_eq!(
popup.width, expected,
"widened modal width must be terminal_width - WIDENED_MARGIN (= {expected}); \
got {} at terminal_width={}",
popup.width, area.width,
);
// Sanity: the widened width is strictly greater than the
// standard cap.
assert!(
popup.width > STANDARD_MAX_WIDTH,
"widened modal must be strictly wider than STANDARD_MAX_WIDTH ({}); got {}",
STANDARD_MAX_WIDTH,
popup.width,
);
}
/// Transitioning from `EditingValue { max_thoughts_width }` back
/// to `Browse` snaps the modal back to its standard width on the
/// next render frame — the widening lives in the render-time
/// `state.mode` match, not in any persistent state.
#[test]
fn modal_returns_to_default_width_when_leaving_edit_mode() {
let area = Rect {
x: 0,
y: 0,
width: 200,
height: 40,
};
// Render once in widened mode to capture the wide width.
let mut s = int_stepper_fixture(120);
let mut buf_wide = Buffer::empty(area);
render_settings_modal(&mut buf_wide, area, &mut s, false, None);
let wide = s.window.popup_area.expect("wide modal must render").width;
assert!(
wide > STANDARD_MAX_WIDTH,
"preconditional sanity: widened path must produce a wider modal; got {wide}",
);
// Transition back to Browse — re-render at the same
// terminal size and assert the modal width capped at
// STANDARD_MAX_WIDTH (the standard width_pct=0.70 path
// produces 140, which is also above the cap, so the
// binding constraint is STANDARD_MAX_WIDTH).
s.transition_to_browse();
let mut buf_std = Buffer::empty(area);
render_settings_modal(&mut buf_std, area, &mut s, false, None);
let std_w = s
.window
.popup_area
.expect("standard modal must render")
.width;
assert_eq!(
std_w, STANDARD_MAX_WIDTH,
"Browse-mode modal must use STANDARD_MAX_WIDTH (= {STANDARD_MAX_WIDTH}); got {std_w}",
);
assert!(
std_w < wide,
"after exiting EditingValue, modal must SHRINK back to standard width; \
wide={wide} std={std_w}",
);
}
/// On a narrow terminal (100 cols < STANDARD_MAX_WIDTH +
/// WIDENED_MARGIN = 118), entering EditingValue for
/// max_thoughts_width must NOT widen the modal below the
/// standard sizing — the widening gate is conditional on
/// `widened_candidate > STANDARD_MAX_WIDTH`, otherwise we fall
/// through to the standard path. This preserves the
/// "never shrink" guarantee for narrow terminals.
#[test]
fn modal_widening_respects_terminal_width_minimum() {
let area = Rect {
x: 0,
y: 0,
width: 100,
height: 40,
};
// Reference render in Browse mode at the same terminal
// size — we want the widened path to produce the SAME
// width.
let mut s_browse = make_state();
let mut buf_browse = Buffer::empty(area);
render_settings_modal(&mut buf_browse, area, &mut s_browse, false, None);
let browse_w = s_browse
.window
.popup_area
.expect("browse render must produce a popup")
.width;
let mut s_edit = int_stepper_fixture(120);
let mut buf_edit = Buffer::empty(area);
render_settings_modal(&mut buf_edit, area, &mut s_edit, false, None);
let edit_w = s_edit
.window
.popup_area
.expect("edit render must produce a popup")
.width;
assert_eq!(
edit_w, browse_w,
"at narrow terminal width ({}) the EditingValue modal must match the Browse \
modal width — widening is disabled when it would shrink below the standard \
sizing; got edit={edit_w} browse={browse_w}",
area.width,
);
}
/// At a wide terminal (180 cols) with a small pending value
/// (85), the widened modal accommodates the full pending value
/// without clamping — the preview renders at `pending_value`
/// cells AND the `note: clamped at …` row is absent.
#[test]
fn preview_renders_at_full_width_when_modal_widened() {
let area = Rect {
x: 0,
y: 0,
width: 180,
height: 40,
};
let mut s = int_stepper_fixture(85);
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let popup = s.window.popup_area.expect("modal must have rendered");
// The widened modal must accommodate pending=85 with no
// clamp. The interior width = popup.width - 2 (borders),
// and the preview's effective width = min(pending,
// interior). With popup.width = 172 (180-8) the interior
// is 170 > 85, so no clamp.
assert!(
popup.width > 85 + 2,
"widened modal interior must be wider than pending=85 cells; \
popup.width={}",
popup.width,
);
// No `note:` row anywhere in the buffer.
assert!(
find_text_row(&buf, area, "note:").is_none(),
"widened modal must not render the clamped note when pending < interior width",
);
assert!(
find_text_row(&buf, area, "clamped").is_none(),
"widened modal must not contain the word `clamped` anywhere when not clamping",
);
// Sanity: the preview itself still renders.
assert!(
find_text_row(&buf, area, "preview").is_some(),
"preview must render at the wide terminal size",
);
}
/// Even with the modal widened, a pending value larger than
/// the widened interior still clamps — the widening doesn't
/// magically uncap the preview. On a 100-col terminal the
/// widening is disabled (Test 3) so the modal stays standard;
/// at the standard width the interior is ~94 cells, and a
/// pending of 200 still clamps.
#[test]
fn preview_remains_clamped_when_pending_exceeds_widened_width() {
let area = Rect {
x: 0,
y: 0,
width: 100,
height: 40,
};
// Pending = 200 (the registered MAX). Widening is disabled
// at terminal_width=100 (Test 3), so the modal stays at
// its standard width; either way, pending > interior so
// the preview clamps and the note must render.
let mut s = int_stepper_fixture(200);
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, &mut s, false, None);
let popup = s
.window
.popup_area
.expect("modal must have rendered at terminal_width=100");
let interior = popup.width.saturating_sub(2);
assert!(
(interior as i64) < 200,
"interior ({interior}) must be < pending(200) for this fixture to exercise \
the clamped path",
);
// Preview still renders, AND the clamped note is present.
assert!(
find_text_row(&buf, area, "preview").is_some(),
"preview must render even when clamping",
);
assert!(
find_text_row(&buf, area, "note: clamped").is_some(),
"clamped note must render when pending > interior, even after widening",
);
}
}