use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use super::presets;
use super::theme::{self, Theme};
use super::user_themes;
use crate::tui::events::keys;
#[derive(Debug)]
pub struct ThemePickerItem {
pub name: &'static str,
pub theme: Option<&'static Theme>,
pub reason: Option<String>,
pub is_user: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum PickerAction {
None,
Preview(&'static Theme),
Apply(&'static Theme),
Cancel,
}
#[derive(Debug)]
pub struct ThemePickerState {
pub items: Vec<ThemePickerItem>,
pub selected: usize,
pub origin: &'static Theme,
pub scroll_offset: usize,
}
impl ThemePickerState {
pub fn open() -> Self {
let report = user_themes::reload();
let origin = theme::active();
let (items, selected) = build_items(presets::built_ins(), &report, origin.name);
Self {
items,
selected,
origin,
scroll_offset: 0,
}
}
fn move_selection(&mut self, step: isize) {
if self.items.is_empty() {
return;
}
let mut next = self.selected as isize + step;
while next >= 0
&& (next as usize) < self.items.len()
&& self.items[next as usize].theme.is_none()
{
next += step;
}
if next >= 0 && (next as usize) < self.items.len() {
self.selected = next as usize;
}
}
fn scroll_into_view(&mut self, visible: usize) {
if visible == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + visible {
self.scroll_offset = self.selected + 1 - visible;
}
}
pub fn handle_key(&mut self, event: &KeyEvent, visible_rows: usize) -> PickerAction {
if keys::is_cancel(event) || event.code == KeyCode::Char('q') {
return PickerAction::Cancel;
}
if keys::is_enter(event) {
return match self.items.get(self.selected).and_then(|i| i.theme) {
Some(t) => PickerAction::Apply(t),
None => PickerAction::None, };
}
let step = if keys::is_up(event) || event.code == KeyCode::Char('k') {
-1
} else if keys::is_down(event) || event.code == KeyCode::Char('j') {
1
} else if keys::is_page_up(event) {
-8
} else if keys::is_page_down(event) {
8
} else {
return PickerAction::None;
};
let prev = self.selected;
self.move_selection(step);
if self.selected == prev {
return PickerAction::None;
}
self.scroll_into_view(visible_rows.max(1));
if self.selected == prev {
return PickerAction::None;
}
match self.items.get(self.selected).and_then(|i| i.theme) {
Some(t) => PickerAction::Preview(t),
None => PickerAction::None,
}
}
}
pub(crate) fn build_items(
builtins: &[&'static Theme],
report: &user_themes::LoadReport,
active_name: &str,
) -> (Vec<ThemePickerItem>, usize) {
let mut items: Vec<ThemePickerItem> = Vec::new();
let mut selected = 0;
for t in builtins {
if t.name == active_name {
selected = items.len();
}
items.push(ThemePickerItem {
name: t.name,
theme: Some(t),
reason: None,
is_user: false,
});
}
for t in &report.themes {
if t.name == active_name {
selected = items.len();
}
items.push(ThemePickerItem {
name: t.name,
theme: Some(t),
reason: None,
is_user: true,
});
}
for r in &report.rejected {
items.push(ThemePickerItem {
name: "",
theme: None,
reason: Some(format!("{} — {}", r.file, r.reason)),
is_user: true,
});
}
(items, selected)
}
pub fn draw(f: &mut Frame, area: Rect, state: &ThemePickerState) {
let width = 48u16;
let height = (state.items.len() as u16 + 6)
.min(area.height.saturating_sub(4))
.max(9);
let popup = centered_rect(area, width, height);
f.render_widget(Clear, popup);
let block = Block::default()
.title(" Themes ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::role(theme::Role::Accent)));
let inner = block.inner(popup);
f.render_widget(block, popup);
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
let visible = rows[0].height as usize;
let active = theme::active().name;
let mut list_state = ListState::default();
let items: Vec<ListItem> = state
.items
.iter()
.enumerate()
.map(|(idx, item)| {
let cursor = if idx == state.selected { "▶ " } else { " " };
let line = match (&item.theme, &item.reason) {
(Some(t), _) => {
let applied = if t.name == state.origin.name {
"● "
} else {
" "
};
let tag = if item.is_user { " (user)" } else { "" };
let style = if idx == state.selected {
Style::default().fg(theme::role(theme::Role::Accent))
} else if t.name == active {
Style::default().fg(theme::role(theme::Role::AccentTeal))
} else {
Style::default().fg(theme::role(theme::Role::TextPrimary))
};
Line::from(Span::styled(
format!("{cursor}{applied}{}{tag}", t.name),
style,
))
}
(None, Some(reason)) => Line::from(Span::styled(
format!(" ✗ {reason}"),
Style::default().fg(theme::role(theme::Role::TextMuted)),
)),
(None, None) => Line::from(" ✗"),
};
ListItem::new(line)
})
.collect();
let mut offset = state.scroll_offset.min(visible.saturating_sub(1));
if state.selected < offset {
offset = state.selected;
} else if visible > 0 && state.selected >= offset + visible {
offset = state.selected + 1 - visible;
}
list_state.select(Some(state.selected));
let list = List::new(items).style(Style::default().fg(theme::role(theme::Role::TextPrimary)));
*list_state.offset_mut() = offset;
f.render_stateful_widget(list, rows[0], &mut list_state);
let hints = Paragraph::new("↑/↓ preview · Enter apply · Esc cancel")
.alignment(Alignment::Center)
.style(Style::default().fg(theme::role(theme::Role::TextMuted)));
f.render_widget(hints, rows[1]);
}
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
Rect {
x: area.x + x,
y: area.y + y,
width: width.min(area.width),
height: height.min(area.height),
}
}