use std::any::Any;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::Frame;
use super::types::{Modal, ModalOutcome, ModalRenderCtx};
use crate::app::App;
use crate::config::theme::{list_theme_names_for_mode, resolve_theme_for_mode_switch};
use crate::config::AppearanceMode;
use crate::ui::searchable_list::{ListEvent, SearchableList};
use crate::ui::theme_picker::{build_theme_list, render_theme_picker};
pub struct ThemePickerModal {
list: SearchableList<String>,
current: String,
mode: AppearanceMode,
original_theme: String,
original_mode: AppearanceMode,
remembered_dark: Option<String>,
remembered_light: Option<String>,
esc_button_rect: Option<Rect>,
toggle_rect: Option<Rect>,
}
impl ThemePickerModal {
pub fn new(themes: Vec<String>, current: String, mode: AppearanceMode) -> Self {
let (remembered_dark, remembered_light) = match mode {
AppearanceMode::Dark => (Some(current.clone()), None),
AppearanceMode::Light => (None, Some(current.clone())),
};
Self {
list: build_theme_list(themes, ¤t),
current: current.clone(),
mode,
original_theme: current,
original_mode: mode,
remembered_dark,
remembered_light,
esc_button_rect: None,
toggle_rect: None,
}
}
fn remembered_for(&self, mode: AppearanceMode) -> Option<&str> {
match mode {
AppearanceMode::Dark => self.remembered_dark.as_deref(),
AppearanceMode::Light => self.remembered_light.as_deref(),
}
}
fn remember(&mut self, mode: AppearanceMode, theme: String) {
match mode {
AppearanceMode::Dark => self.remembered_dark = Some(theme),
AppearanceMode::Light => self.remembered_light = Some(theme),
}
}
fn current_theme(&self) -> &str {
self.list
.focused_item()
.map(String::as_str)
.unwrap_or(&self.original_theme)
}
fn switch_mode(&mut self, target: AppearanceMode) -> String {
let prev_mode = self.mode;
let prev_theme = self.current_theme().to_owned();
self.remember(prev_mode, prev_theme.clone());
let preview = self
.remembered_for(target)
.map(str::to_owned)
.unwrap_or_else(|| resolve_theme_for_mode_switch(&prev_theme, target));
self.list.set_items(list_theme_names_for_mode(target));
let want = preview.clone();
self.list.focus_matching(|t| *t == want);
self.mode = target;
preview
}
fn toggle_hit(&self, col: u16, row: u16) -> Option<AppearanceMode> {
let r = self.toggle_rect?;
let inside = col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height;
inside.then(|| self.mode.opposite())
}
fn revert_outcome(&self) -> ModalOutcome {
let original_theme = self.original_theme.clone();
let original_mode = self.original_mode;
ModalOutcome::CloseAnd(Box::new(move |app| {
let changed =
app.config.theme != original_theme || app.config.appearance != original_mode;
app.config.theme = original_theme;
app.config.appearance = original_mode;
if changed {
app.apply_active_theme();
}
}))
}
fn select_outcome(&self, name: String) -> ModalOutcome {
let original_theme = self.original_theme.clone();
let original_mode = self.original_mode;
let selected_mode = self.mode;
ModalOutcome::CloseAnd(Box::new(move |app| {
let theme_changed = app.config.theme != name;
let mode_changed = app.config.appearance != selected_mode;
let downgrade_cleared = app.config.theme_downgraded_from.is_some();
app.config.set_theme(name.clone());
app.config.appearance = selected_mode;
if theme_changed || mode_changed {
app.apply_active_theme();
}
if name != original_theme || selected_mode != original_mode || downgrade_cleared {
app.save_config_with_flash("failed to persist theme change");
}
}))
}
}
impl Modal for ThemePickerModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let layout = render_theme_picker(
&mut self.list,
area,
frame.buffer_mut(),
ctx.theme,
ctx.cursor_visible,
self.mode,
&self.current,
);
self.esc_button_rect = layout.esc_rect;
self.toggle_rect = layout.toggle_rect;
}
fn handle_key(
&mut self,
key: KeyEvent,
app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
match key.code {
KeyCode::Left | KeyCode::Right => {
let preview = self.switch_mode(self.mode.opposite());
if app.config.theme != preview {
app.config.theme = preview;
}
app.config.appearance = self.mode;
app.apply_active_theme();
return ModalOutcome::Continue;
}
KeyCode::Tab | KeyCode::BackTab => return ModalOutcome::Continue,
_ => {}
}
match self.list.handle_key(&key) {
ListEvent::Continue => ModalOutcome::Continue,
ListEvent::FocusChanged(i) => {
let name = self.list.items()[i].clone();
if app.config.theme != name {
app.config.theme = name;
app.apply_active_theme();
}
ModalOutcome::Continue
}
ListEvent::Cancelled => self.revert_outcome(),
ListEvent::Submitted(i) => {
let name = self.list.items()[i].clone();
self.select_outcome(name)
}
}
}
fn handle_paste(&mut self, text: &str) -> ModalOutcome {
if let ListEvent::FocusChanged(i) = self.list.paste(text) {
let name = self.list.items()[i].clone();
return ModalOutcome::ContinueAnd(Box::new(move |app| {
if app.config.theme != name {
app.config.theme = name;
app.apply_active_theme();
}
}));
}
ModalOutcome::Continue
}
fn handle_wheel(&mut self, delta: i32) {
self.list.scroll_by(delta);
}
fn handle_click(&mut self, col: u16, row: u16, _app: &mut App) -> ModalOutcome {
if let Some(target) = self.toggle_hit(col, row) {
let preview = self.switch_mode(target);
return ModalOutcome::ContinueAnd(Box::new(move |app| {
if app.config.theme != preview {
app.config.theme = preview;
}
app.config.appearance = target;
app.apply_active_theme();
}));
}
if super::types::esc_rect_hit(self.esc_button_rect, col, row) {
return self.revert_outcome();
}
match self.list.handle_click(col, row) {
ListEvent::Submitted(i) => {
let name = self.list.items()[i].clone();
self.select_outcome(name)
}
_ => ModalOutcome::Continue,
}
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_utils::make_app;
use crossterm::event::KeyModifiers;
#[test]
fn open_theme_picker_seeds_state() {
let mut app = make_app();
app.open_theme_picker();
assert!(app.modal_stack.contains::<ThemePickerModal>());
}
#[test]
fn switch_mode_round_trip_restores_original_theme() {
let dark_list = vec!["Edamame".to_owned(), "256 Dark".to_owned()];
let mut modal = ThemePickerModal::new(dark_list, "Edamame".into(), AppearanceMode::Dark);
let _ = modal.switch_mode(AppearanceMode::Light);
let back = modal.switch_mode(AppearanceMode::Dark);
assert_eq!(back, "Edamame");
}
#[test]
fn switch_mode_remembers_intermediate_previews() {
let dark_list = vec!["Edamame".to_owned(), "256 Dark".to_owned()];
let mut modal = ThemePickerModal::new(dark_list, "Edamame".into(), AppearanceMode::Dark);
modal
.list
.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(modal.current_theme(), "256 Dark");
let _ = modal.switch_mode(AppearanceMode::Light);
let back = modal.switch_mode(AppearanceMode::Dark);
assert_eq!(back, "256 Dark");
}
}