pub mod controller;
pub mod layout;
pub mod option;
pub use controller::{PickerNavResult, SettingsPickerController};
pub use layout::SettingsPickerLayout;
#[allow(unused_imports)] pub use option::{
SettingAvailability, SettingItemAction, SettingOption, SettingOptionBuilder, SettingValues,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub fn handle_nav_key(
controller: &mut SettingsPickerController,
key: KeyEvent,
allow_search_typing: bool,
) -> PickerNavResult {
match key.code {
KeyCode::Esc => controller.request_cancel(),
KeyCode::Enter => controller.request_commit(),
KeyCode::BackTab => {
controller.prev_tab();
PickerNavResult::Preview
}
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
controller.prev_tab();
PickerNavResult::Preview
}
KeyCode::Tab => {
controller.next_tab();
PickerNavResult::Preview
}
KeyCode::Up | KeyCode::Char('k')
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
controller.move_up()
}
KeyCode::Down | KeyCode::Char('j')
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
controller.move_down()
}
KeyCode::Home => controller.jump_home(),
KeyCode::End => controller.jump_end(),
KeyCode::Backspace if allow_search_typing => {
controller.pop_query_char();
PickerNavResult::None
}
KeyCode::Char('u')
if key.modifiers.contains(KeyModifiers::CONTROL) && allow_search_typing =>
{
controller.clear_query();
PickerNavResult::None
}
KeyCode::Char(c)
if allow_search_typing
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT)
&& !matches!(c, '1'..='9' | 'j' | 'k') =>
{
controller.push_query_char(c);
PickerNavResult::None
}
KeyCode::Char(c)
if matches!(c, '1'..='9')
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
controller.jump_digit(c as u8 - b'0')
}
KeyCode::Char(' ') => controller.request_item_action(),
_ => PickerNavResult::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use std::borrow::Cow;
fn sample_options() -> Vec<SettingOption> {
vec![
SettingOption::builder("system", "System")
.summary("Follow the terminal")
.detail("System resolves from COLORFGBG at session start.")
.help("Default theme selection")
.values(SettingValues::new(
Cow::Borrowed("system"),
Cow::Borrowed("system"),
Cow::Borrowed("system"),
))
.tab("core")
.build(),
SettingOption::builder("terminal", "Terminal")
.summary("Terminal-owned background")
.detail("Terminal owns the background; ombre is unavailable.")
.help("No painted ocean field")
.values(SettingValues::new(
Cow::Borrowed("terminal"),
Cow::Borrowed("system"),
Cow::Borrowed("terminal"),
))
.tab("core")
.build(),
SettingOption::builder("locked", "Locked Theme")
.summary("Unavailable in this build")
.detail("Disabled for matrix coverage.")
.help("Shows disabled reason in detail")
.values(SettingValues::new(
Cow::Borrowed("locked"),
Cow::Borrowed("system"),
Cow::Borrowed("system"),
))
.availability(SettingAvailability::Disabled {
reason: Cow::Borrowed("requires fancy_animations"),
})
.tab("extra")
.prefer_list_when_narrow(true)
.build(),
SettingOption::builder("dracula", "Dracula")
.summary("Purple night")
.detail("Classic Dracula palette.")
.help("Popular dark theme")
.values(SettingValues::new(
Cow::Borrowed("dracula"),
Cow::Borrowed("system"),
Cow::Borrowed("dracula"),
))
.tab("extra")
.action(SettingItemAction {
id: Cow::Borrowed("swatch"),
label: Cow::Borrowed("Show swatch"),
})
.build(),
]
}
fn matrix_snapshot(controller: &SettingsPickerController, area: Rect) -> String {
let focused = controller.selected_option();
let layout = SettingsPickerLayout::resolve(area, 34, focused);
let mut lines = Vec::new();
lines.push(format!(
"tab={} query={:?} selected={:?} visible={} narrow={} stacked={} detail={}",
controller.active_tab_name(),
controller.query(),
controller.selected_id(),
controller.visible().len(),
layout.narrow,
layout.stacked,
layout.detail.is_some(),
));
for (visible_idx, &source) in controller.visible().iter().enumerate() {
let option = &controller.options()[source];
let marker = if visible_idx == controller.selected_visible() {
">"
} else {
" "
};
let disabled = option
.availability
.disabled_reason()
.map(|reason| format!(" [disabled: {reason}]"))
.unwrap_or_default();
lines.push(format!(
"{marker}{}. {} ({}){}",
visible_idx + 1,
option.label,
option.id,
disabled
));
}
if let Some(option) = focused {
lines.push(format!(
"detail: current={} default={} effective={}",
option.values.current, option.values.default, option.values.effective
));
lines.push(format!("help: {}", option.help));
if let Some(reason) = option.availability.disabled_reason() {
lines.push(format!("reason: {reason}"));
}
}
lines.join("\n")
}
#[test]
fn matrix_normal_layout_is_side_by_side() {
let controller = SettingsPickerController::new(sample_options(), "system");
let snap = matrix_snapshot(&controller, Rect::new(0, 0, 120, 30));
assert!(snap.contains("narrow=false"));
assert!(snap.contains("detail=true"));
assert!(snap.contains(">1. System (system)"));
assert!(snap.contains("detail: current=system"));
}
#[test]
fn matrix_narrow_falls_back_to_list_only_when_preferred() {
let mut controller = SettingsPickerController::new(sample_options(), "system");
controller.set_active_tab(
controller
.tabs()
.iter()
.position(|tab| tab == "extra")
.expect("extra tab"),
);
let _ = controller.jump_home();
let snap = matrix_snapshot(&controller, Rect::new(0, 0, 60, 16));
assert!(snap.contains("narrow=true"));
assert!(
snap.contains("detail=false"),
"narrow + prefer_list should drop detail: {snap}"
);
}
#[test]
fn matrix_disabled_row_blocks_preview_and_commit() {
let mut controller = SettingsPickerController::new(sample_options(), "system");
controller.set_query("locked");
assert_eq!(controller.visible().len(), 1);
assert_eq!(controller.move_down(), PickerNavResult::None);
assert_eq!(controller.request_commit(), PickerNavResult::None);
let snap = matrix_snapshot(&controller, Rect::new(0, 0, 100, 24));
assert!(snap.contains("[disabled: requires fancy_animations]"));
assert!(snap.contains("reason: requires fancy_animations"));
}
#[test]
fn matrix_filtered_preserves_selection_identity() {
let mut controller = SettingsPickerController::new(sample_options(), "system");
assert_eq!(controller.move_down(), PickerNavResult::Preview);
assert_eq!(controller.selected_id(), Some("terminal"));
controller.set_query("owns the background");
assert_eq!(controller.selected_id(), Some("terminal"));
assert_eq!(controller.visible().len(), 1);
let snap = matrix_snapshot(&controller, Rect::new(0, 0, 100, 24));
assert!(snap.contains("query=\"owns the background\""));
assert!(snap.contains(">1. Terminal (terminal)"));
}
#[test]
fn matrix_preview_commit_and_revert_sequence() {
let mut controller = SettingsPickerController::new(sample_options(), "system");
let preview = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
false,
);
assert_eq!(preview, PickerNavResult::Preview);
assert_eq!(controller.selected_id(), Some("terminal"));
let commit = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
false,
);
assert_eq!(commit, PickerNavResult::Commit);
assert_eq!(controller.selected_id(), Some("terminal"));
let mut controller = SettingsPickerController::new(sample_options(), "system");
let _ = controller.move_down();
let cancel = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
false,
);
assert_eq!(cancel, PickerNavResult::Cancel);
assert_eq!(controller.original_id(), "system");
}
#[test]
fn digit_zero_does_not_jump() {
let mut controller = SettingsPickerController::new(sample_options(), "dracula");
let before = controller.selected_id().map(str::to_string);
let result = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE),
false,
);
assert_eq!(result, PickerNavResult::None);
assert_eq!(controller.selected_id().map(str::to_string), before);
}
#[test]
fn backtab_uses_the_same_previous_tab_path_as_shift_tab() {
let mut controller = SettingsPickerController::new(sample_options(), "system");
assert_eq!(controller.active_tab(), 0);
let result = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
false,
);
assert_eq!(result, PickerNavResult::Preview);
assert_eq!(controller.active_tab(), controller.tabs().len() - 1);
}
#[test]
fn item_action_fires_on_space() {
let mut controller = SettingsPickerController::new(sample_options(), "system");
controller.set_query("dracula");
let result = handle_nav_key(
&mut controller,
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
false,
);
assert_eq!(result, PickerNavResult::ItemAction);
let option = controller.selected_option().expect("dracula selected");
assert_eq!(option.id, "dracula");
assert_eq!(
option.action.as_ref().map(|action| action.id.as_ref()),
Some("swatch")
);
}
}