use std::sync::Arc;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::widgets::{ListItem, Paragraph};
use super::ModalShell;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx};
use crate::components::hint_row::HintRow;
use crate::components::rich_row::RichRow;
use crate::components::search_list::{
Filter, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
};
use crate::settings::AppSettings;
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
#[derive(Clone)]
struct ThemeRow {
name: String,
index: usize,
current: bool,
}
impl SearchRow for ThemeRow {
fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
let glyph = if self.current { "●" } else { " " };
RichRow::new(glyph, self.name.clone())
.glyph_style(Style::default().fg(theme.gray.to_ratatui()))
.into_list_item(theme)
}
fn match_text(&self) -> Option<&str> {
Some(&self.name)
}
fn visual_height(&self) -> u16 {
1
}
}
pub struct ThemePickerDialog {
themes: Vec<Theme>,
list: SearchList<ThemeRow>,
original: usize,
previewed: usize,
shell: ModalShell,
hints: HintRow,
}
impl ThemePickerDialog {
pub fn new(settings: &AppSettings) -> Self {
let themes = settings.theme_list();
let current = settings.effective_theme_name();
let original = themes.iter().position(|t| t.name == current).unwrap_or(0);
let rows: Vec<ThemeRow> = themes
.iter()
.enumerate()
.map(|(index, t)| ThemeRow {
name: t.name.clone(),
index,
current: index == original,
})
.collect();
let mut list = SearchList::builder(StaticRowSource, Arc::new(|| {}))
.filter(Filter::Fuzzy)
.build_with_rows(rows);
list.select(original);
Self {
themes,
list,
original,
previewed: original,
shell: ModalShell::default(),
hints: HintRow::new(&[
(KeyCode::Null, "type", "Filter"),
(KeyCode::Null, "↑↓", "Move"),
(KeyCode::Enter, "⏎", "Apply"),
(KeyCode::Esc, "Esc", "Cancel"),
])
.with_indent(0),
}
}
pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
if let Some(key) = self.shell.pointer_key(m, &[&self.hints]) {
return self.handle_key(key, tx);
}
match m.kind {
MouseEventKind::ScrollUp => return self.handle_key(KeyEvent::from(KeyCode::Up), tx),
MouseEventKind::ScrollDown => {
return self.handle_key(KeyEvent::from(KeyCode::Down), tx);
}
_ => {}
}
match self.list.handle_mouse(m) {
SearchMouse::Activated(_) | SearchMouse::DoubleClicked { repeat: false, .. } => {
return self.handle_key(KeyEvent::from(KeyCode::Enter), tx);
}
_ => self.sync_preview(tx),
}
EventState::Consumed
}
fn apply(&self, index: usize, persist: bool, tx: &AppTx) {
if let Some(theme) = self.themes.get(index) {
tx.send(AppEvent::ApplyTheme {
theme: Box::new(theme.clone()),
persist,
})
.ok();
}
}
fn sync_preview(&mut self, tx: &AppTx) {
let target = self.list.selected_row().map_or(self.original, |r| r.index);
if target != self.previewed {
self.previewed = target;
self.apply(target, false, tx);
}
}
pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
match self.list.handle_key(&key) {
KeyReaction::Submit => {
if let Some(index) = self.list.selected_row().map(|r| r.index) {
self.apply(index, true, tx);
tx.send(AppEvent::CloseOverlay).ok();
}
}
KeyReaction::Cancel => {
if self.previewed != self.original {
self.apply(self.original, false, tx);
}
tx.send(AppEvent::CloseOverlay).ok();
}
_ => self.sync_preview(tx),
}
EventState::Consumed
}
pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
let width = 52u16.min(rect.width);
let height = (self.themes.len() as u16 + 4)
.min(rect.height.saturating_sub(4))
.max(7);
let area = super::fixed_centered_rect(width, height, rect);
self.shell.set(area);
let inner = crate::components::panel::modal_chrome(
f,
area,
theme,
crate::components::panel::ModalSpec {
title: Some("─ Theme "),
border: Some(theme.border_style(focused)),
bg: crate::components::panel::ModalBg::Base,
},
);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner);
f.render_widget(
Paragraph::new("› ").style(Style::default().fg(theme.yellow.to_ratatui())),
rows[0],
);
let input_rect = Rect {
x: rows[0].x + 2,
width: rows[0].width.saturating_sub(2),
..rows[0]
};
self.list.render_query(f, input_rect, theme, true);
self.list.render(f, rows[1], theme, true);
self.list.set_panel_rect(area);
self.hints.render(
f,
rows[2],
Style::default().fg(theme.gray.to_ratatui()),
theme,
);
}
}
impl_dialog!(ThemePickerDialog);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enter_applies_persisted_and_closes() {
let settings = AppSettings::default();
let mut picker = ThemePickerDialog::new(&settings);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
picker.handle_key(
KeyEvent::new(KeyCode::Down, ratatui::crossterm::event::KeyModifiers::NONE),
&tx,
);
picker.handle_key(
KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
),
&tx,
);
let mut applied = Vec::new();
let mut closed = false;
while let Ok(ev) = rx.try_recv() {
match ev {
AppEvent::ApplyTheme { theme, persist } => applied.push((theme.name, persist)),
AppEvent::CloseOverlay => closed = true,
_ => {}
}
}
assert_eq!(applied.len(), 2);
assert!(!applied[0].1);
assert!(applied[1].1);
assert_eq!(applied[0].0, applied[1].0);
assert!(closed);
}
#[test]
fn esc_reverts_to_original() {
let mut settings = AppSettings::default();
settings.theme = "Gruvbox Dark".to_string();
let mut picker = ThemePickerDialog::new(&settings);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
picker.handle_key(
KeyEvent::new(KeyCode::Down, ratatui::crossterm::event::KeyModifiers::NONE),
&tx,
);
picker.handle_key(
KeyEvent::new(KeyCode::Esc, ratatui::crossterm::event::KeyModifiers::NONE),
&tx,
);
let mut last_applied = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::ApplyTheme { theme, persist } = ev {
last_applied = Some((theme.name, persist));
}
}
assert_eq!(
last_applied,
Some(("Gruvbox Dark".to_string(), false)),
"Esc must re-apply the original theme"
);
}
fn type_str(picker: &mut ThemePickerDialog, text: &str, tx: &AppTx) {
for c in text.chars() {
picker.handle_key(KeyEvent::from(KeyCode::Char(c)), tx);
}
}
#[test]
fn typing_filters_and_previews_the_match() {
let settings = AppSettings::default();
let mut picker = ThemePickerDialog::new(&settings);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
type_str(&mut picker, "flexoki lig", &tx);
let visible: Vec<_> = picker
.list
.visible_rows()
.iter()
.map(|r| r.name.clone())
.collect();
assert!(
visible.contains(&"Flexoki Light".to_string()),
"{visible:?}"
);
assert!(visible.len() < picker.themes.len(), "list must be filtered");
picker.handle_key(KeyEvent::from(KeyCode::Enter), &tx);
let mut last = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::ApplyTheme { theme, persist } = ev {
last = Some((theme.name, persist));
}
}
assert_eq!(last, Some(("Flexoki Light".to_string(), true)));
}
#[test]
fn no_match_reverts_preview_to_original() {
let settings = AppSettings::default();
let mut picker = ThemePickerDialog::new(&settings);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
type_str(&mut picker, "flex", &tx);
type_str(&mut picker, "zzzz", &tx);
let mut last = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::ApplyTheme { theme, persist } = ev {
last = Some((theme.name, persist));
}
}
assert_eq!(
last,
Some((settings.effective_theme_name().to_string(), false))
);
}
#[test]
fn wheel_steps_the_selection() {
use ratatui::crossterm::event::KeyModifiers;
let settings = AppSettings::default();
let mut picker = ThemePickerDialog::new(&settings);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
type_str(&mut picker, "flexoki", &tx);
while rx.try_recv().is_ok() {}
let wheel = |kind| MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
let before = picker.list.selected_row().map(|r| r.index);
picker.handle_mouse(&wheel(MouseEventKind::ScrollDown), &tx);
picker.handle_mouse(&wheel(MouseEventKind::ScrollUp), &tx);
picker.handle_mouse(&wheel(MouseEventKind::ScrollUp), &tx);
assert_ne!(picker.list.selected_row().map(|r| r.index), before);
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::ApplyTheme { persist: false, .. })
));
}
#[test]
fn enter_with_no_match_stays_open() {
let settings = AppSettings::default();
let mut picker = ThemePickerDialog::new(&settings);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
type_str(&mut picker, "zzzzqqqq", &tx);
picker.handle_key(KeyEvent::from(KeyCode::Enter), &tx);
while let Ok(ev) = rx.try_recv() {
assert!(!matches!(ev, AppEvent::CloseOverlay), "must not close");
if let AppEvent::ApplyTheme { persist, .. } = ev {
assert!(!persist, "must not persist anything");
}
}
}
}