use std::borrow::Cow;
use std::mem;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::Frame;
use linesmith_core::theme::ThemeRegistry;
use crate::config::{Config, LayoutMode};
use super::app::{AppScreen, ScreenOutcome};
use super::install_screen::InstallScreenState;
use super::items_editor::{ItemsEditorPrev, ItemsEditorState, LineKey};
use super::line_picker::LinePickerState;
use super::list_screen::{
self, ListOutcome, ListRowData, ListScreenState, ListScreenView, VerbHint,
};
use super::placeholder::PlaceholderState;
use super::theme_picker::ThemePickerState;
#[derive(Debug, Default, Clone)]
pub(super) struct MainMenuState {
list: ListScreenState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MainMenuItem {
EditLines,
EditColors,
PowerlineSetup,
TerminalOptions,
GlobalOverrides,
InstallToClaudeCode,
Exit,
}
impl MainMenuItem {
fn label(self) -> &'static str {
match self {
Self::EditLines => "Edit Lines",
Self::EditColors => "Edit Colors",
Self::PowerlineSetup => "Powerline Setup",
Self::TerminalOptions => "Terminal Options",
Self::GlobalOverrides => "Global Overrides",
Self::InstallToClaudeCode => "Install to Claude Code",
Self::Exit => "Exit",
}
}
fn description(self) -> &'static str {
match self {
Self::EditLines => "Add, remove, and reorder segments",
Self::EditColors => "Customize colors per segment or via theme",
Self::PowerlineSetup => "Configure powerline-style separators",
Self::TerminalOptions => "Terminal width detection and color level",
Self::GlobalOverrides => "Engine knobs (Inherit Colors, Bold, etc.)",
Self::InstallToClaudeCode => "Wire linesmith into Claude Code settings",
Self::Exit => "Quit the configuration editor",
}
}
}
const MENU_ITEMS: &[MainMenuItem] = &[
MainMenuItem::EditLines,
MainMenuItem::EditColors,
MainMenuItem::PowerlineSetup,
MainMenuItem::TerminalOptions,
MainMenuItem::GlobalOverrides,
MainMenuItem::InstallToClaudeCode,
MainMenuItem::Exit,
];
pub(super) fn update(
state: &mut MainMenuState,
config: &Config,
theme_registry: &ThemeRegistry,
install_ctx: InstallContext<'_>,
key: KeyEvent,
) -> ScreenOutcome {
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
return ScreenOutcome::Quit;
}
match list_screen::handle_key(&mut state.list, key, MENU_ITEMS.len(), &[], false) {
ListOutcome::Activate => activate(state, config, theme_registry, install_ctx),
ListOutcome::Consumed | ListOutcome::Unhandled => ScreenOutcome::Stay,
outcome @ (ListOutcome::Action(_) | ListOutcome::MoveSwap { .. }) => {
unreachable!(
"main menu: list_screen returned {outcome:?} despite verbs=&[] \
and move_mode_supported=false; update this dispatch arm if \
those args changed",
)
}
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct InstallContext<'a> {
pub settings_path: Option<&'a std::path::Path>,
pub install_command: &'a str,
}
fn activate(
state: &mut MainMenuState,
config: &Config,
theme_registry: &ThemeRegistry,
install_ctx: InstallContext<'_>,
) -> ScreenOutcome {
debug_assert!(
state.list.cursor() < MENU_ITEMS.len(),
"list_screen::handle_key must clamp the cursor before Activate",
);
let item = MENU_ITEMS[state.list.cursor()];
if matches!(item, MainMenuItem::Exit) {
return ScreenOutcome::Quit;
}
let prev = mem::take(state);
match item {
MainMenuItem::EditLines => match config.layout {
LayoutMode::SingleLine if multi_line_auto_promotes(config) => {
linesmith_core::lsm_debug!(
"main menu: auto-promoted to multi-line picker — `[line].segments` is empty and `[line.N]` sub-tables are present",
);
ScreenOutcome::NavigateTo(AppScreen::LinePicker(LinePickerState::new(prev)))
}
LayoutMode::SingleLine => ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(
ItemsEditorState::new(LineKey::Single, ItemsEditorPrev::MainMenu(prev)),
)),
LayoutMode::MultiLine => {
ScreenOutcome::NavigateTo(AppScreen::LinePicker(LinePickerState::new(prev)))
}
_ => ScreenOutcome::NavigateTo(AppScreen::Placeholder(PlaceholderState::new(
MainMenuItem::EditLines.label(),
prev,
))),
},
MainMenuItem::EditColors => {
let current = config.theme.as_deref().unwrap_or("default");
ScreenOutcome::NavigateTo(AppScreen::ThemePicker(ThemePickerState::new(
prev,
theme_registry,
current,
)))
}
MainMenuItem::InstallToClaudeCode => {
if let Some(settings_path) = install_ctx.settings_path {
ScreenOutcome::NavigateTo(AppScreen::InstallToClaudeCode(InstallScreenState::new(
prev,
settings_path.to_path_buf(),
install_ctx.install_command.to_string(),
)))
} else {
ScreenOutcome::NavigateTo(AppScreen::Placeholder(PlaceholderState::new(
MainMenuItem::InstallToClaudeCode.label(),
prev,
)))
}
}
other => ScreenOutcome::NavigateTo(AppScreen::Placeholder(PlaceholderState::new(
other.label(),
prev,
))),
}
}
fn multi_line_auto_promotes(config: &Config) -> bool {
config
.line
.as_ref()
.is_some_and(|l| l.segments.is_empty() && !l.numbered.is_empty())
}
pub(super) fn view(state: &MainMenuState, frame: &mut Frame, area: Rect) {
let row_data: Vec<ListRowData<'static>> = MENU_ITEMS
.iter()
.map(|item| ListRowData {
label: Cow::Borrowed(item.label()),
description: Cow::Borrowed(item.description()),
})
.collect();
let verbs: [VerbHint<'_>; 0] = [];
let view = ListScreenView {
title: " linesmith config ",
rows: &row_data,
verbs: &verbs,
move_mode_supported: false,
};
list_screen::render(&state.list, &view, area, frame);
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn key_mod(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn registry() -> ThemeRegistry {
ThemeRegistry::with_built_ins()
}
fn no_install_ctx() -> InstallContext<'static> {
InstallContext {
settings_path: None,
install_command: "linesmith",
}
}
#[test]
fn esc_quits() {
let mut state = MainMenuState::default();
let outcome = update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Esc),
);
assert!(matches!(outcome, ScreenOutcome::Quit));
}
#[test]
fn esc_with_modifier_does_not_quit() {
for mods in [KeyModifiers::SHIFT, KeyModifiers::CONTROL] {
let mut state = MainMenuState::default();
let outcome = update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key_mod(KeyCode::Esc, mods),
);
assert!(
matches!(outcome, ScreenOutcome::Stay),
"mods={mods:?} should fall through to Stay, got {outcome:?}",
);
assert_eq!(state.list.cursor(), 0, "mods={mods:?} cursor moved");
}
}
#[test]
fn menu_items_matches_expected_layout() {
assert_eq!(
MENU_ITEMS,
&[
MainMenuItem::EditLines,
MainMenuItem::EditColors,
MainMenuItem::PowerlineSetup,
MainMenuItem::TerminalOptions,
MainMenuItem::GlobalOverrides,
MainMenuItem::InstallToClaudeCode,
MainMenuItem::Exit,
],
);
}
#[test]
fn cursor_preserved_across_activate_esc_activate_round_trip() {
use super::super::app::{update as app_update, AppScreen, Event};
let mut model = super::super::app::Model::new(
crate::config::Config::default(),
toml_edit::DocumentMut::new(),
String::new(),
None,
crate::theme::default_theme().clone(),
registry(),
crate::theme::Capability::None,
None,
None,
"linesmith".to_string(),
);
model = app_update(model, Event::Key(key(KeyCode::Down)));
model = app_update(model, Event::Key(key(KeyCode::Down)));
model = app_update(model, Event::Key(key(KeyCode::Enter)));
let cursor_first = match &model.screen {
AppScreen::Placeholder(p) => {
assert_eq!(p.name, "Powerline Setup");
p.prev.list.cursor()
}
other => panic!("expected Placeholder after first activate, got {other:?}"),
};
assert_eq!(cursor_first, 2);
model = app_update(model, Event::Key(key(KeyCode::Esc)));
match &model.screen {
AppScreen::MainMenu(state) => {
assert_eq!(state.list.cursor(), 2, "cursor must survive Esc back-nav",)
}
other => panic!("expected MainMenu after Esc, got {other:?}"),
}
model = app_update(model, Event::Key(key(KeyCode::Enter)));
match &model.screen {
AppScreen::Placeholder(p) => {
assert_eq!(p.name, "Powerline Setup");
assert_eq!(p.prev.list.cursor(), 2);
}
other => panic!("expected Placeholder on second activate, got {other:?}"),
}
}
#[test]
fn edit_lines_on_multi_line_layout_routes_to_line_picker() {
let mut state = MainMenuState::default();
let cfg = Config {
layout: LayoutMode::MultiLine,
..Config::default()
};
let outcome = update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::LinePicker(p)) => {
assert_eq!(
p.prev.list.cursor(),
0,
"prev MainMenu cursor must round-trip for Esc back-nav",
);
}
other => panic!("expected LinePicker(Edit Lines), got {other:?}"),
}
}
#[test]
fn enter_on_default_cursor_navigates_to_items_editor() {
let mut state = MainMenuState::default();
let cfg = Config {
layout: LayoutMode::SingleLine,
..Config::default()
};
let outcome = update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(s)) => {
assert_eq!(s.line(), super::super::items_editor::LineKey::Single);
}
other => panic!("expected ItemsEditor(Single), got {other:?}"),
}
}
#[test]
fn edit_lines_auto_promotes_to_line_picker_when_numbered_tables_present() {
use crate::config::{LineConfig, LineEntry};
use std::collections::BTreeMap;
let mut numbered = BTreeMap::new();
let mut line_one = toml::value::Table::new();
line_one.insert("segments".to_string(), toml::Value::Array(vec![]));
numbered.insert("1".to_string(), toml::Value::Table(line_one));
let cfg = Config {
layout: LayoutMode::SingleLine,
line: Some(LineConfig {
segments: Vec::<LineEntry>::new(),
numbered,
}),
..Config::default()
};
let mut state = MainMenuState::default();
let outcome = update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::LinePicker(_)) => {}
other => {
panic!("expected LinePicker for auto-promoted multi-line config, got {other:?}",)
}
}
}
#[test]
fn edit_colors_routes_to_theme_picker_regardless_of_layout_mode() {
for layout in [LayoutMode::SingleLine, LayoutMode::MultiLine] {
let cfg = Config {
layout,
..Config::default()
};
let mut state = MainMenuState::default();
update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
let outcome = update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
assert!(
matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::ThemePicker(_))
),
"layout={layout:?}: expected ThemePicker, got {outcome:?}",
);
}
}
#[test]
fn edit_colors_threads_config_theme_into_picker_initial_cursor() {
let cfg = Config {
theme: Some("dracula".into()),
..Config::default()
};
let mut state = MainMenuState::default();
update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
let outcome = update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::ThemePicker(p)) => {
assert_eq!(
p.cursor_theme().name(),
"dracula",
"MainMenu must thread `config.theme` into the picker's initial cursor",
);
}
other => panic!("expected ThemePicker, got {other:?}"),
}
}
#[test]
fn theme_picker_round_trips_main_menu_cursor_through_esc_back_nav() {
use super::super::app::{update as app_update, AppScreen, Event, Model};
let model = Model::new(
crate::config::Config::default(),
toml_edit::DocumentMut::new(),
String::new(),
None,
crate::theme::default_theme().clone(),
registry(),
crate::theme::Capability::None,
None,
None,
"linesmith".to_string(),
);
let model = app_update(model, Event::Key(key(KeyCode::Down)));
let model = app_update(model, Event::Key(key(KeyCode::Enter)));
match &model.screen {
AppScreen::ThemePicker(_) => {}
other => panic!("expected ThemePicker after Enter on EditColors, got {other:?}"),
}
let model = app_update(model, Event::Key(key(KeyCode::Esc)));
match &model.screen {
AppScreen::MainMenu(state) => {
assert_eq!(
state.list.cursor(),
1,
"MainMenu cursor must survive Esc back-nav from ThemePicker",
);
}
other => panic!("expected MainMenu after Esc, got {other:?}"),
}
}
#[test]
fn edit_lines_does_not_auto_promote_when_segments_array_is_populated() {
use crate::config::{LineConfig, LineEntry};
use std::collections::BTreeMap;
let mut numbered = BTreeMap::new();
let mut line_one = toml::value::Table::new();
line_one.insert("segments".to_string(), toml::Value::Array(vec![]));
numbered.insert("1".to_string(), toml::Value::Table(line_one));
let cfg = Config {
layout: LayoutMode::SingleLine,
line: Some(LineConfig {
segments: vec![LineEntry::Id("model".to_string())],
numbered,
}),
..Config::default()
};
let mut state = MainMenuState::default();
let outcome = update(
&mut state,
&cfg,
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(s)) => {
assert_eq!(s.line(), super::super::items_editor::LineKey::Single);
}
other => panic!(
"expected ItemsEditor(Single) when both segments AND numbered are populated, got {other:?}",
),
}
}
#[test]
fn enter_on_exit_row_quits() {
let mut state = MainMenuState::default();
for _ in 0..(MENU_ITEMS.len() - 1) {
let outcome = update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
assert!(matches!(outcome, ScreenOutcome::Stay));
}
assert_eq!(MENU_ITEMS[state.list.cursor()], MainMenuItem::Exit);
let outcome = update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
assert!(matches!(outcome, ScreenOutcome::Quit));
}
#[test]
fn enter_on_each_non_exit_row_routes_to_correct_screen() {
for (idx, item) in MENU_ITEMS.iter().enumerate() {
if matches!(item, MainMenuItem::Exit) {
continue;
}
let mut state = MainMenuState::default();
for _ in 0..idx {
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
}
assert_eq!(state.list.cursor(), idx);
let outcome = update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match (item, outcome) {
(MainMenuItem::EditLines, ScreenOutcome::NavigateTo(AppScreen::ItemsEditor(_))) => {
}
(
MainMenuItem::EditColors,
ScreenOutcome::NavigateTo(AppScreen::ThemePicker(_)),
) => {}
(other_item, ScreenOutcome::NavigateTo(AppScreen::Placeholder(p))) => {
assert_eq!(p.name, other_item.label(), "row {idx}");
}
(item, outcome) => panic!("row {idx} ({item:?}): unexpected outcome {outcome:?}",),
}
}
}
#[test]
fn enter_on_install_row_with_resolved_settings_path_opens_install_screen() {
let install_row = MENU_ITEMS
.iter()
.position(|i| matches!(i, MainMenuItem::InstallToClaudeCode))
.expect("install row present");
let mut state = MainMenuState::default();
for _ in 0..install_row {
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
}
let path = std::path::PathBuf::from("/tmp/test-settings.json");
let ctx = InstallContext {
settings_path: Some(&path),
install_command: "linesmith",
};
let outcome = update(
&mut state,
&Config::default(),
®istry(),
ctx,
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::InstallToClaudeCode(_)) => {}
other => panic!("expected InstallToClaudeCode, got {other:?}"),
}
}
#[test]
fn placeholder_carries_main_menu_state_for_back_nav() {
let mut state = MainMenuState::default();
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
let outcome = update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Enter),
);
match outcome {
ScreenOutcome::NavigateTo(AppScreen::Placeholder(p)) => {
assert_eq!(p.name, "Powerline Setup");
assert_eq!(p.prev.list.cursor(), 2);
}
other => panic!("expected Placeholder, got {other:?}"),
}
}
#[test]
fn down_advances_cursor() {
let mut state = MainMenuState::default();
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
assert_eq!(state.list.cursor(), 1);
}
#[test]
fn up_at_top_wraps_to_last() {
let mut state = MainMenuState::default();
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Up),
);
assert_eq!(state.list.cursor(), MENU_ITEMS.len() - 1);
}
fn render_to_string(state: &MainMenuState, width: u16, height: u16) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("backend");
terminal
.draw(|frame| view(state, frame, frame.area()))
.expect("draw");
crate::tui::buffer_to_string(terminal.backend().buffer())
}
#[test]
fn snapshot_main_menu_default_cursor() {
let state = MainMenuState::default();
insta::assert_snapshot!("main_menu_default_cursor", render_to_string(&state, 60, 14));
}
#[test]
fn snapshot_main_menu_cursor_on_install_row() {
let install_row = MENU_ITEMS
.iter()
.position(|i| matches!(i, MainMenuItem::InstallToClaudeCode))
.expect("install row present");
let mut state = MainMenuState::default();
for _ in 0..install_row {
update(
&mut state,
&Config::default(),
®istry(),
no_install_ctx(),
key(KeyCode::Down),
);
}
insta::assert_snapshot!(
"main_menu_cursor_on_install_row",
render_to_string(&state, 60, 14)
);
}
}