use crossterm::event::{KeyCode, KeyEvent, MouseEvent};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use ratatui_themes::ThemeName;
use super::select_list::SelectList;
use super::{Component, EventResult, OverlayContent, OverlayRequest};
use crate::theme::UiColors;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThemePickerAction {
PreviewTheme(ThemeName),
Confirmed,
Cancelled(ThemeName),
}
pub struct ThemePickerComponent {
state: Option<ThemePickerInner>,
viewport: Rect,
mouse_position: Option<(u16, u16)>,
}
struct ThemePickerInner {
original_theme: ThemeName,
selected_index: usize,
}
impl ThemePickerComponent {
pub fn new() -> Self {
Self {
state: None,
viewport: Rect::ZERO,
mouse_position: None,
}
}
pub fn is_open(&self) -> bool {
self.state.is_some()
}
pub fn open(&mut self, current_theme: ThemeName) {
let all = ThemeName::all();
let selected_index = all.iter().position(|t| *t == current_theme).unwrap_or(0);
self.state = Some(ThemePickerInner {
original_theme: current_theme,
selected_index,
});
}
pub fn close(&mut self) {
self.state = None;
}
pub fn set_viewport(&mut self, viewport: Rect) {
self.viewport = viewport;
}
pub fn set_mouse_position(&mut self, pos: Option<(u16, u16)>) {
self.mouse_position = pos;
}
#[cfg(test)]
pub fn selected_index(&self) -> Option<usize> {
self.state.as_ref().map(|s| s.selected_index)
}
#[cfg(test)]
pub fn original_theme(&self) -> Option<ThemeName> {
self.state.as_ref().map(|s| s.original_theme)
}
pub fn click_at(
&mut self,
col: u16,
row: u16,
overlay_rect: Option<Rect>,
) -> Option<ThemePickerAction> {
if !self.is_open() {
return None;
}
if let Some(rect) = overlay_rect {
let inner_top = rect.y + 1;
let inner_bottom = rect.y + rect.height.saturating_sub(1);
if col >= rect.x
&& col < rect.x + rect.width
&& row >= inner_top
&& row < inner_bottom
{
let clicked_index = (row - inner_top) as usize;
let all = ThemeName::all();
if clicked_index < all.len() {
let theme = all[clicked_index];
self.close();
return Some(ThemePickerAction::PreviewTheme(theme));
}
}
}
let original = self.state.as_ref().unwrap().original_theme;
self.close();
Some(ThemePickerAction::Cancelled(original))
}
fn overlay_size(&self) -> (u16, u16) {
let all = ThemeName::all();
let max_name_len = all
.iter()
.map(|t: &ThemeName| t.display_name().len())
.max()
.unwrap_or(10) as u16;
let width = max_name_len + 6; let num_themes = all.len() as u16;
let height = (num_themes + 2).min(self.viewport.height.saturating_sub(2)); (width, height)
}
}
impl Component for ThemePickerComponent {
type Action = ThemePickerAction;
fn handle_key(&mut self, key: KeyEvent) -> EventResult<Self::Action> {
let Some(ref mut inner) = self.state else {
return EventResult::NotHandled;
};
let all = ThemeName::all();
let len = all.len();
match key.code {
KeyCode::Esc => {
let original = inner.original_theme;
self.close();
EventResult::Action(ThemePickerAction::Cancelled(original))
}
KeyCode::Enter => {
self.close();
EventResult::Action(ThemePickerAction::Confirmed)
}
KeyCode::Up | KeyCode::Char('k') => {
if inner.selected_index > 0 {
inner.selected_index -= 1;
} else {
inner.selected_index = len - 1;
}
EventResult::Action(ThemePickerAction::PreviewTheme(all[inner.selected_index]))
}
KeyCode::Down | KeyCode::Char('j') => {
if inner.selected_index + 1 < len {
inner.selected_index += 1;
} else {
inner.selected_index = 0;
}
EventResult::Action(ThemePickerAction::PreviewTheme(all[inner.selected_index]))
}
_ => EventResult::Consumed,
}
}
fn handle_mouse(&mut self, _event: MouseEvent, _area: Rect) -> EventResult<Self::Action> {
EventResult::NotHandled
}
fn collect_overlays(&mut self) -> Vec<OverlayRequest> {
let Some(ref inner) = self.state else {
return vec![];
};
let (width, height) = self.overlay_size();
let anchor_x = self.viewport.right().saturating_sub(width);
let help_bar_y = self.viewport.bottom().saturating_sub(1);
let anchor_y = help_bar_y.saturating_sub(height);
let anchor = Rect::new(anchor_x, anchor_y, 0, 0);
let labels: Vec<String> = ThemeName::all()
.iter()
.map(|t: &ThemeName| t.display_name().to_string())
.collect();
vec![OverlayRequest {
anchor,
size: (width, height),
content: Box::new(ThemePickerOverlay {
labels,
selected_index: inner.selected_index,
mouse_position: self.mouse_position,
}),
}]
}
}
struct ThemePickerOverlay {
labels: Vec<String>,
selected_index: usize,
mouse_position: Option<(u16, u16)>,
}
impl ThemePickerOverlay {
fn hovered_index(&self, area: Rect) -> Option<usize> {
let (col, row) = self.mouse_position?;
let inner_top = area.y + 1; let inner_bottom = area.y + area.height.saturating_sub(1);
if col >= area.x
&& col < area.x + area.width
&& row >= inner_top
&& row < inner_bottom
{
let idx = (row - inner_top) as usize;
if idx < self.labels.len() {
Some(idx)
} else {
None
}
} else {
None
}
}
}
impl OverlayContent for ThemePickerOverlay {
fn render(&self, area: Rect, buf: &mut Buffer, colors: &UiColors) {
let hovered = self.hovered_index(area);
let widget = SelectList::new(
" Theme ".to_string(),
&self.labels,
Some(self.selected_index),
colors.choice,
colors.value,
colors,
)
.with_cursor()
.with_hovered(hovered);
Widget::render(widget, area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_open_close_lifecycle() {
let mut tp = ThemePickerComponent::new();
assert!(!tp.is_open());
tp.open(ThemeName::Dracula);
assert!(tp.is_open());
assert_eq!(tp.selected_index(), Some(0));
assert_eq!(tp.original_theme(), Some(ThemeName::Dracula));
tp.close();
assert!(!tp.is_open());
assert_eq!(tp.selected_index(), None);
}
#[test]
fn test_navigate_down_up() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Dracula);
let result = tp.handle_key(key(KeyCode::Down));
assert!(matches!(result, EventResult::Action(ThemePickerAction::PreviewTheme(_))));
assert_eq!(tp.selected_index(), Some(1));
let result = tp.handle_key(key(KeyCode::Up));
assert!(matches!(result, EventResult::Action(ThemePickerAction::PreviewTheme(_))));
assert_eq!(tp.selected_index(), Some(0));
}
#[test]
fn test_wrap_around() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Dracula);
let result = tp.handle_key(key(KeyCode::Up));
let all = ThemeName::all();
assert_eq!(tp.selected_index(), Some(all.len() - 1));
assert!(matches!(result, EventResult::Action(ThemePickerAction::PreviewTheme(t)) if t == *all.last().unwrap()));
let result = tp.handle_key(key(KeyCode::Down));
assert_eq!(tp.selected_index(), Some(0));
assert!(matches!(result, EventResult::Action(ThemePickerAction::PreviewTheme(t)) if t == ThemeName::Dracula));
}
#[test]
fn test_jk_navigation() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Dracula);
tp.handle_key(key(KeyCode::Char('j')));
assert_eq!(tp.selected_index(), Some(1));
tp.handle_key(key(KeyCode::Char('k')));
assert_eq!(tp.selected_index(), Some(0));
}
#[test]
fn test_enter_confirms() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Dracula);
tp.handle_key(key(KeyCode::Down));
let result = tp.handle_key(key(KeyCode::Enter));
assert_eq!(result, EventResult::Action(ThemePickerAction::Confirmed));
assert!(!tp.is_open());
}
#[test]
fn test_esc_cancels_with_original() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Nord);
let result = tp.handle_key(key(KeyCode::Esc));
assert_eq!(
result,
EventResult::Action(ThemePickerAction::Cancelled(ThemeName::Nord))
);
assert!(!tp.is_open());
}
#[test]
fn test_not_handled_when_closed() {
let mut tp = ThemePickerComponent::new();
let result = tp.handle_key(key(KeyCode::Down));
assert_eq!(result, EventResult::NotHandled);
}
#[test]
fn test_unknown_key_consumed() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Dracula);
let result = tp.handle_key(key(KeyCode::Char('x')));
assert_eq!(result, EventResult::Consumed);
assert!(tp.is_open()); }
#[test]
fn test_collect_overlays_empty_when_closed() {
let mut tp = ThemePickerComponent::new();
assert!(tp.collect_overlays().is_empty());
}
#[test]
fn test_collect_overlays_returns_one_when_open() {
let mut tp = ThemePickerComponent::new();
tp.set_viewport(Rect::new(0, 0, 100, 24));
tp.open(ThemeName::Dracula);
let overlays = tp.collect_overlays();
assert_eq!(overlays.len(), 1);
}
#[test]
fn test_click_inside_overlay() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Dracula);
let result = tp.click_at(85, 7, Some(Rect::new(80, 5, 18, 12)));
assert!(matches!(result, Some(ThemePickerAction::PreviewTheme(_))));
assert!(!tp.is_open()); }
#[test]
fn test_click_outside_overlay_cancels() {
let mut tp = ThemePickerComponent::new();
tp.open(ThemeName::Nord);
let result = tp.click_at(5, 5, Some(Rect::new(80, 5, 18, 12)));
assert_eq!(result, Some(ThemePickerAction::Cancelled(ThemeName::Nord)));
assert!(!tp.is_open());
}
#[test]
fn test_hover_highlight_on_theme_item() {
use crate::theme::UiColors;
use ratatui::buffer::Buffer;
let mut tp = ThemePickerComponent::new();
tp.set_viewport(Rect::new(0, 0, 100, 24));
tp.open(ThemeName::Dracula);
let overlay_area = Rect::new(80, 10, 18, 12);
let hovered_row = 12u16;
tp.set_mouse_position(Some((85, hovered_row)));
let overlays = tp.collect_overlays();
assert_eq!(overlays.len(), 1);
let palette = ratatui_themes::Theme::default().palette();
let colors = UiColors::from_palette(&palette);
let mut buf = Buffer::empty(overlay_area);
overlays[0].content.render(overlay_area, &mut buf, &colors);
let cell = buf.cell((81, hovered_row)).unwrap();
assert_ne!(
cell.bg,
ratatui::style::Color::Reset,
"Hovered theme item row should have a background highlight"
);
let non_hover_row = hovered_row + 1;
let cell_below = buf.cell((81, non_hover_row)).unwrap();
assert_eq!(
cell_below.bg,
ratatui::style::Color::Reset,
"Non-hovered theme item should not have a background highlight"
);
}
}