use std::borrow::Cow;
use std::mem;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::Frame;
use toml_edit::{value, DocumentMut};
use linesmith_core::theme::{Theme, ThemeRegistry};
use crate::config;
use super::app::{AppScreen, ScreenOutcome};
use super::list_screen::{
self, ListOutcome, ListRowData, ListScreenState, ListScreenView, VerbHint,
};
use super::main_menu::MainMenuState;
#[derive(Debug)]
pub(super) struct ThemePickerState {
list: ListScreenState,
prev: MainMenuState,
themes: Vec<Theme>,
unknown_current: Option<String>,
}
impl ThemePickerState {
pub(super) fn new(prev: MainMenuState, registry: &ThemeRegistry, current_name: &str) -> Self {
let themes: Vec<Theme> = registry.iter().map(|rt| rt.theme.clone()).collect();
debug_assert!(
!themes.is_empty(),
"theme registry must contain at least the built-in default theme; \
constructed via `ThemeRegistry::default()` instead of `with_built_ins()`?",
);
let position = themes.iter().position(|theme| theme.name() == current_name);
let unknown_current = if position.is_none() {
Some(current_name.to_string())
} else {
None
};
let cursor = position.unwrap_or(0);
let mut list = ListScreenState::default();
list.set_cursor(cursor, themes.len());
Self {
list,
prev,
themes,
unknown_current,
}
}
#[must_use]
pub(super) fn cursor_theme(&self) -> &Theme {
&self.themes[self.list.cursor()]
}
fn take_prev(&mut self) -> MainMenuState {
mem::take(&mut self.prev)
}
}
const VERBS: &[VerbHint<'static>] = &[];
pub(super) fn update(
state: &mut ThemePickerState,
document: &mut DocumentMut,
config: &mut config::Config,
model_theme: &mut Theme,
key: KeyEvent,
) -> ScreenOutcome {
if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
return ScreenOutcome::NavigateTo(AppScreen::MainMenu(state.take_prev()));
}
let row_count = state.themes.len();
match list_screen::handle_key(&mut state.list, key, row_count, &[], false) {
ListOutcome::Activate => {
let cursor = state.list.cursor();
if let Some(theme) = state.themes.get(cursor) {
set_theme_in_document(document, theme.name());
refresh_config(document, config);
*model_theme = theme.clone();
return ScreenOutcome::CommitAndNavigate(AppScreen::MainMenu(state.take_prev()));
}
linesmith_core::lsm_warn!(
"theme picker: cursor {cursor} out of range (themes len {row_count}); Enter ignored",
);
}
ListOutcome::Action(_)
| ListOutcome::MoveSwap { .. }
| ListOutcome::Consumed
| ListOutcome::Unhandled => {}
}
ScreenOutcome::Stay
}
pub(super) fn view(state: &ThemePickerState, frame: &mut Frame, area: Rect) {
let cursor_idx = state.list.cursor();
let row_data: Vec<ListRowData<'_>> = state
.themes
.iter()
.enumerate()
.map(|(idx, theme)| {
let description = if idx == cursor_idx {
"• press Enter to apply"
} else {
""
};
ListRowData {
label: Cow::Borrowed(theme.name()),
description: Cow::Borrowed(description),
}
})
.collect();
let title: Cow<'_, str> = match &state.unknown_current {
Some(name) => Cow::Owned(format!(
" pick theme — config references unknown theme `{name}` "
)),
None => Cow::Borrowed(" pick theme "),
};
let view = ListScreenView {
title: title.as_ref(),
rows: &row_data,
verbs: VERBS,
move_mode_supported: false,
};
list_screen::render(&state.list, &view, area, frame);
}
fn set_theme_in_document(document: &mut DocumentMut, name: &str) {
match document.get_mut("theme") {
None => {
if name == "default" {
return;
}
document["theme"] = value(name);
}
Some(item) => {
if let Some(existing) = item.as_value_mut() {
let decor = existing.decor().clone();
*existing = toml_edit::Value::from(name);
*existing.decor_mut() = decor;
return;
}
linesmith_core::lsm_warn!(
"theme picker: existing `theme` entry is not a scalar value; \
refusing to overwrite the structured form. Edit the config \
manually or remove the `theme` entry to use the picker.",
);
}
}
}
fn refresh_config(document: &DocumentMut, config: &mut config::Config) {
match config::Config::from_str_validated(&document.to_string(), |_| {}) {
Ok(new_config) => *config = new_config,
Err(err) => linesmith_core::lsm_warn!(
"theme picker: reparse failed, preview frozen at last-good state: {err}",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn document(toml: &str) -> DocumentMut {
toml.parse().expect("test toml must parse")
}
fn registry() -> ThemeRegistry {
ThemeRegistry::with_built_ins()
}
fn state_for(current: &str) -> ThemePickerState {
ThemePickerState::new(MainMenuState::default(), ®istry(), current)
}
#[test]
fn esc_back_navigates_to_main_menu_without_committing() {
let mut s = state_for("default");
let raw = "theme = \"default\"\n";
let mut doc = document(raw);
let mut cfg = config::Config::default();
let mut theme = registry()
.lookup("default")
.expect("default theme exists")
.clone();
update(&mut s, &mut doc, &mut cfg, &mut theme, key(KeyCode::Down));
let outcome = update(&mut s, &mut doc, &mut cfg, &mut theme, key(KeyCode::Esc));
assert!(matches!(
outcome,
ScreenOutcome::NavigateTo(AppScreen::MainMenu(_))
));
assert_eq!(doc.to_string(), raw, "Esc must not mutate the document");
}
#[test]
fn enter_commits_cursor_theme_to_document_and_model() {
let mut s = state_for("default");
let mut doc = document("");
let mut cfg = config::Config::default();
let mut theme = registry().lookup("default").expect("present").clone();
let initial_theme_name = theme.name().to_string();
update(&mut s, &mut doc, &mut cfg, &mut theme, key(KeyCode::Down));
let chosen_name = s.cursor_theme().name().to_string();
assert_ne!(
chosen_name, initial_theme_name,
"test invariant: picker has at least 2 themes",
);
let outcome = update(&mut s, &mut doc, &mut cfg, &mut theme, key(KeyCode::Enter));
assert!(matches!(
outcome,
ScreenOutcome::CommitAndNavigate(AppScreen::MainMenu(_))
));
let serialized = doc.to_string();
assert!(
serialized.contains(&format!("theme = \"{chosen_name}\"")),
"document must record `theme = \"{chosen_name}\"`: {serialized}",
);
assert_eq!(theme.name(), chosen_name);
}
#[test]
fn cursor_starts_on_currently_configured_theme() {
let names: Vec<String> = registry()
.iter()
.map(|rt| rt.theme.name().to_string())
.collect();
let target = names
.last()
.expect("registry has at least one theme")
.clone();
let s = state_for(&target);
assert_eq!(
s.cursor_theme().name(),
target,
"picker cursor must land on the configured theme",
);
}
#[test]
fn cursor_falls_back_to_first_theme_when_current_unknown() {
let s = state_for("drakula");
let first_registered = registry()
.iter()
.next()
.expect("at least one theme registered")
.theme
.name()
.to_string();
assert_eq!(s.cursor_theme().name(), first_registered);
assert_eq!(
s.unknown_current.as_deref(),
Some("drakula"),
"unknown current theme name must round-trip into state for the banner",
);
}
#[test]
fn unknown_current_is_none_when_configured_theme_resolves() {
let s = state_for("default");
assert_eq!(s.unknown_current, None);
}
#[test]
fn picker_iterates_full_registry_so_user_themes_surface_through_with_user_themes() {
use linesmith_core::theme::ThemeSource;
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let theme_toml = r##"name = "custom"
[roles]
foreground = "#ffffff"
background = "#000000"
muted = "#666666"
primary = "#ff8800"
accent = "#00ffaa"
success = "#00ff00"
warning = "#ffff00"
error = "#ff0000"
info = "#0088ff"
"##;
std::fs::write(tmp.path().join("custom.toml"), theme_toml).expect("write fixture");
let mut warns: Vec<String> = Vec::new();
let registry = ThemeRegistry::with_built_ins().with_user_themes(tmp.path(), |m| {
warns.push(m.to_string());
});
assert!(
warns.is_empty(),
"fixture must parse without warnings: {warns:?}",
);
let picker = ThemePickerState::new(MainMenuState::default(), ®istry, "default");
let names: Vec<&str> = picker.themes.iter().map(Theme::name).collect();
assert!(
names.contains(&"custom"),
"user theme must appear in the picker: {names:?}",
);
let custom = registry
.iter()
.find(|rt| rt.theme.name() == "custom")
.expect("custom registered");
assert!(
matches!(custom.source, ThemeSource::UserFile(_)),
"user theme must carry UserFile source",
);
}
#[test]
fn set_theme_replaces_existing_root_value_in_place_preserving_decor() {
let raw = "# my linesmith config\ntheme = \"default\"\n\n[line]\nsegments = [\"model\"]\n";
let mut doc = document(raw);
set_theme_in_document(&mut doc, "dracula");
let serialized = doc.to_string();
assert!(
serialized.contains("# my linesmith config"),
"leading comment must survive: {serialized}",
);
assert!(
serialized.contains("theme = \"dracula\""),
"value must update in place: {serialized}",
);
assert!(
!serialized.contains("theme = \"default\""),
"old value must be replaced, not duplicated: {serialized}",
);
let reparsed: DocumentMut = serialized.parse().expect("reparses");
assert_eq!(
reparsed
.get("theme")
.and_then(|i| i.as_value())
.and_then(|v| v.as_str()),
Some("dracula"),
);
}
#[test]
fn set_theme_preserves_trailing_inline_comment_on_existing_value() {
let raw = "theme = \"default\" # before tables\n\n[line]\nsegments = [\"model\"]\n";
let mut doc = document(raw);
set_theme_in_document(&mut doc, "dracula");
let serialized = doc.to_string();
assert!(
serialized.contains("# before tables"),
"trailing inline comment must survive: {serialized}",
);
assert!(
serialized.contains("theme = \"dracula\""),
"value must update in place: {serialized}",
);
}
#[test]
fn set_theme_default_is_noop_when_document_omits_theme_key() {
let raw = "[line]\nsegments = [\"model\"]\n";
let mut doc = document(raw);
set_theme_in_document(&mut doc, "default");
assert_eq!(
doc.to_string(),
raw,
"implicit-default re-selection must leave the document unchanged",
);
}
#[test]
fn set_theme_default_writes_explicit_value_when_document_already_has_theme_key() {
let raw = "theme = \"dracula\"\n";
let mut doc = document(raw);
set_theme_in_document(&mut doc, "default");
let serialized = doc.to_string();
assert!(
serialized.contains("theme = \"default\""),
"must write explicit default when prior key existed: {serialized}",
);
}
#[test]
fn set_theme_refuses_to_clobber_non_scalar_theme_entry() {
let raw = "[theme]\npalette = \"dracula\"\n";
let mut doc = document(raw);
set_theme_in_document(&mut doc, "default");
let serialized = doc.to_string();
assert_eq!(
serialized, raw,
"non-scalar `theme` entry must survive untouched: {serialized}",
);
}
#[test]
fn set_theme_same_name_on_existing_value_is_idempotent() {
let raw = "theme = \"default\" # explicit\n";
let mut doc = document(raw);
set_theme_in_document(&mut doc, "default");
assert_eq!(doc.to_string(), raw);
}
#[test]
fn enter_on_default_with_absent_theme_key_does_not_dirty_document() {
let mut s = state_for("default");
let raw = "[line]\nsegments = [\"model\"]\n";
let mut doc = document(raw);
let mut cfg = config::Config::default();
let mut theme = registry().lookup("default").expect("present").clone();
let outcome = update(&mut s, &mut doc, &mut cfg, &mut theme, key(KeyCode::Enter));
assert!(matches!(
outcome,
ScreenOutcome::CommitAndNavigate(AppScreen::MainMenu(_))
));
assert_eq!(
doc.to_string(),
raw,
"Enter on implicit default must not dirty the document",
);
}
#[test]
fn set_theme_lands_at_root_when_inserted_into_doc_with_existing_tables() {
let raw = "[line]\nsegments = [\"model\"]\n";
let mut doc = document(raw);
doc["theme"] = value("dracula");
let serialized = doc.to_string();
let theme_pos = serialized.find("theme = ").expect("theme written");
let line_pos = serialized.find("[line]").expect("line preserved");
assert!(
theme_pos < line_pos,
"theme key must land before [line] header in serialized form: {serialized}",
);
let reparsed: DocumentMut = serialized.parse().expect("reparses");
assert!(
reparsed.get("theme").and_then(|i| i.as_value()).is_some(),
"reparse must see theme at root, not as a child of [line]: {serialized}",
);
}
fn render_to_string(state: &ThemePickerState, 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_theme_picker_default_selection() {
let s = state_for("default");
insta::assert_snapshot!("theme_picker_default", render_to_string(&s, 60, 20));
}
#[test]
fn snapshot_theme_picker_unknown_current_banner() {
let s = state_for("drakula");
insta::assert_snapshot!("theme_picker_unknown_current", render_to_string(&s, 80, 20));
}
}