use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, StatefulWidget, Widget};
use unicode_width::UnicodeWidthStr;
use crate::builtins::ThemeInfo;
use crate::names::tokens;
use crate::theme::Theme;
use crate::{ThemeVariant, current, list_available_themes, set_theme};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThemeSelectorAction {
Navigate,
Select(String),
Cancel,
FilterChanged,
Noop,
}
pub struct ThemeSelectorState {
themes: Vec<ThemeInfo>,
theme_cache: Vec<Theme>,
search_cache: Vec<(String, String)>,
filter: String,
filtered_indices: Vec<usize>,
cursor: usize,
scroll: usize,
original_theme: Arc<Theme>,
derive_fn: Option<fn(&mut Theme)>,
}
impl ThemeSelectorState {
pub fn new() -> Self {
let mut themes = list_available_themes();
themes.sort_by(|a, b| {
let variant_ord = match (&a.variant, &b.variant) {
(ThemeVariant::Dark, ThemeVariant::Light) => std::cmp::Ordering::Less,
(ThemeVariant::Light, ThemeVariant::Dark) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
};
variant_ord.then_with(|| a.display_name.cmp(&b.display_name))
});
let theme_cache: Vec<Theme> = themes
.iter()
.map(|info| info.load().unwrap_or_default())
.collect();
let search_cache: Vec<(String, String)> = themes
.iter()
.map(|info| (info.display_name.to_lowercase(), info.author.to_lowercase()))
.collect();
let filtered_indices: Vec<usize> = (0..themes.len()).collect();
let original_theme = current();
Self {
themes,
theme_cache,
search_cache,
filter: String::new(),
filtered_indices,
cursor: 0,
scroll: 0,
original_theme,
derive_fn: None,
}
}
pub fn with_current_selected() -> Self {
let mut state = Self::new();
let current_name = state.original_theme.meta.name.clone();
if let Some(pos) = state
.themes
.iter()
.position(|info| info.display_name == current_name || info.name == current_name)
{
if let Some(cursor_pos) = state.filtered_indices.iter().position(|&i| i == pos) {
state.cursor = cursor_pos;
state.scroll = cursor_pos.saturating_sub(8);
}
}
state
}
#[must_use]
pub fn with_derive(mut self, derive: fn(&mut Theme)) -> Self {
self.derive_fn = Some(derive);
self
}
pub fn handle_key(&mut self, key: KeyEvent) -> ThemeSelectorAction {
match key.code {
KeyCode::Up => {
if self.filtered_indices.is_empty() {
return ThemeSelectorAction::Noop;
}
if self.cursor > 0 {
self.cursor -= 1;
if self.cursor < self.scroll {
self.scroll = self.cursor;
}
self.apply_preview();
ThemeSelectorAction::Navigate
} else {
ThemeSelectorAction::Noop
}
}
KeyCode::Down => {
if self.filtered_indices.is_empty() {
return ThemeSelectorAction::Noop;
}
if self.cursor + 1 < self.filtered_indices.len() {
self.cursor += 1;
self.apply_preview();
ThemeSelectorAction::Navigate
} else {
ThemeSelectorAction::Noop
}
}
KeyCode::Enter => {
if let Some(&idx) = self.filtered_indices.get(self.cursor) {
let id = self.themes[idx].name.clone();
ThemeSelectorAction::Select(id)
} else {
ThemeSelectorAction::Noop
}
}
KeyCode::Esc => {
set_theme((*self.original_theme).clone());
ThemeSelectorAction::Cancel
}
KeyCode::Char(c) => {
self.filter.push(c);
self.recompute_filter();
self.apply_preview();
ThemeSelectorAction::FilterChanged
}
KeyCode::Backspace => {
if self.filter.pop().is_some() {
self.recompute_filter();
self.apply_preview();
ThemeSelectorAction::FilterChanged
} else {
ThemeSelectorAction::Noop
}
}
_ => ThemeSelectorAction::Noop,
}
}
pub fn selected_theme(&self) -> Option<&ThemeInfo> {
self.filtered_indices
.get(self.cursor)
.map(|&idx| &self.themes[idx])
}
pub fn filter(&self) -> &str {
&self.filter
}
fn recompute_filter(&mut self) {
let query = self.filter.to_lowercase();
self.filtered_indices = if query.is_empty() {
(0..self.themes.len()).collect()
} else {
self.search_cache
.iter()
.enumerate()
.filter(|(_, (name, author))| name.contains(&query) || author.contains(&query))
.map(|(i, _)| i)
.collect()
};
self.cursor = 0;
self.scroll = 0;
}
fn apply_preview(&self) {
if let Some(&idx) = self.filtered_indices.get(self.cursor) {
let mut theme = self.theme_cache[idx].clone();
if let Some(derive) = self.derive_fn {
derive(&mut theme);
}
set_theme(theme);
}
}
fn clamp_scroll(&mut self, visible_items: usize) {
if visible_items == 0 {
return;
}
if self.cursor < self.scroll {
self.scroll = self.cursor;
}
if self.cursor >= self.scroll + visible_items {
self.scroll = self.cursor - visible_items + 1;
}
}
}
impl Default for ThemeSelectorState {
fn default() -> Self {
Self::new()
}
}
pub struct ThemeSelector<'a> {
title: &'a str,
}
impl<'a> ThemeSelector<'a> {
pub fn new() -> Self {
Self {
title: "Select Theme",
}
}
#[must_use]
pub fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
}
impl Default for ThemeSelector<'_> {
fn default() -> Self {
Self::new()
}
}
impl StatefulWidget for ThemeSelector<'_> {
type State = ThemeSelectorState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut ThemeSelectorState) {
let t = current();
let block = Block::default()
.title(Line::from(vec![
Span::raw(" "),
Span::styled(
self.title,
Style::default()
.fg(Color::from(t.color(tokens::TEXT_PRIMARY)))
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::from(t.color(tokens::ACCENT_PRIMARY))));
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 20 || inner.height < 6 {
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(inner);
render_theme_list(chunks[0], buf, state, &t);
render_theme_preview(chunks[1], buf, state, &t);
}
}
#[allow(clippy::too_many_lines)]
fn render_theme_list(area: Rect, buf: &mut Buffer, state: &mut ThemeSelectorState, t: &Theme) {
if area.height < 4 {
return;
}
let accent_primary: Color = t.color(tokens::ACCENT_PRIMARY).into();
let accent_secondary: Color = t.color(tokens::ACCENT_SECONDARY).into();
let text_primary: Color = t.color(tokens::TEXT_PRIMARY).into();
let text_secondary: Color = t.color(tokens::TEXT_SECONDARY).into();
let text_muted: Color = t.color(tokens::TEXT_MUTED).into();
let warning: Color = t.color(tokens::WARNING).into();
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
let cursor_char = if state.filter.is_empty() {
"\u{2502}" } else {
"\u{2588}" };
let filter_line = Line::from(vec![
Span::styled(" Filter: ", Style::default().fg(text_muted)),
Span::styled(&state.filter, Style::default().fg(accent_secondary)),
Span::styled(cursor_char, Style::default().fg(accent_primary)),
]);
filter_line.render(chunks[0], buf);
let list_area = chunks[1];
#[allow(clippy::as_conversions)]
let mut visible_items = list_area.height as usize;
let section_headers = count_section_headers(state);
visible_items = visible_items.saturating_sub(section_headers);
state.clamp_scroll(visible_items);
render_theme_entries(
list_area,
buf,
state,
accent_secondary,
text_primary,
text_muted,
warning,
);
let total = state.filtered_indices.len();
let position = if total > 0 {
format!(" ({}/{})", state.cursor + 1, total)
} else {
String::new()
};
let hints = Line::from(vec![
Span::styled(
" \u{2191}\u{2193} nav Enter select Esc cancel",
Style::default().fg(text_muted),
),
Span::styled(position, Style::default().fg(text_secondary)),
]);
hints.render(chunks[2], buf);
}
fn render_theme_entries(
list_area: Rect,
buf: &mut Buffer,
state: &ThemeSelectorState,
accent_secondary: Color,
text_primary: Color,
text_muted: Color,
warning: Color,
) {
let mut y = list_area.y;
let max_y = list_area.y + list_area.height;
let mut items_rendered = 0;
let mut last_variant: Option<ThemeVariant> = None;
for (filter_idx, &theme_idx) in state.filtered_indices.iter().enumerate() {
if y >= max_y {
break;
}
let info = &state.themes[theme_idx];
if last_variant != Some(info.variant) {
if (items_rendered >= state.scroll || last_variant.is_none()) && y < max_y {
let header_text = match info.variant {
ThemeVariant::Dark => " Dark Themes",
ThemeVariant::Light => " Light Themes",
};
let header = Line::from(Span::styled(
header_text,
Style::default()
.fg(text_muted)
.add_modifier(Modifier::ITALIC),
));
header.render(Rect::new(list_area.x, y, list_area.width, 1), buf);
y += 1;
}
last_variant = Some(info.variant);
}
if items_rendered < state.scroll {
items_rendered += 1;
continue;
}
if y >= max_y {
break;
}
let is_selected = filter_idx == state.cursor;
let mut spans = vec![];
if is_selected {
spans.push(Span::styled(
" > ",
Style::default()
.fg(accent_secondary)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
&info.display_name,
Style::default()
.fg(accent_secondary)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
spans.push(Span::styled(
&info.display_name,
Style::default().fg(text_primary),
));
}
if info.variant == ThemeVariant::Light {
spans.push(Span::styled(" \u{2600}", Style::default().fg(warning)));
}
Line::from(spans).render(Rect::new(list_area.x, y, list_area.width, 1), buf);
y += 1;
items_rendered += 1;
}
}
fn count_section_headers(state: &ThemeSelectorState) -> usize {
let mut count = 0;
let mut last_variant: Option<ThemeVariant> = None;
for &idx in &state.filtered_indices {
let variant = state.themes[idx].variant;
if last_variant != Some(variant) {
count += 1;
last_variant = Some(variant);
}
}
count
}
#[allow(clippy::too_many_lines)]
fn render_theme_preview(area: Rect, buf: &mut Buffer, state: &ThemeSelectorState, t: &Theme) {
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(Color::from(t.color(tokens::TEXT_DIM))));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 4 || inner.width < 10 {
return;
}
let Some(info) = state
.filtered_indices
.get(state.cursor)
.map(|&idx| &state.themes[idx])
else {
let empty = Line::from(Span::styled(
" No themes match",
Style::default().fg(Color::from(t.color(tokens::TEXT_MUTED))),
));
empty.render(
Rect::new(inner.x + 1, inner.y + 1, inner.width.saturating_sub(1), 1),
buf,
);
return;
};
let accent_primary: Color = t.color(tokens::ACCENT_PRIMARY).into();
let text_primary: Color = t.color(tokens::TEXT_PRIMARY).into();
let text_secondary: Color = t.color(tokens::TEXT_SECONDARY).into();
let text_muted: Color = t.color(tokens::TEXT_MUTED).into();
let x = inner.x + 1;
let w = inner.width.saturating_sub(2);
let mut y = inner.y + 1;
let max_y = inner.y + inner.height;
if y < max_y {
Line::from(Span::styled(
&info.display_name,
Style::default()
.fg(accent_primary)
.add_modifier(Modifier::BOLD),
))
.render(Rect::new(x, y, w, 1), buf);
y += 1;
}
if y < max_y && !info.author.is_empty() {
Line::from(vec![
Span::styled("by ", Style::default().fg(text_muted)),
Span::styled(&info.author, Style::default().fg(text_secondary)),
])
.render(Rect::new(x, y, w, 1), buf);
y += 1;
}
y += 1;
if y < max_y && !info.description.is_empty() {
#[allow(clippy::as_conversions)]
let lines = wrap_text(&info.description, w as usize);
for line_text in &lines {
if y >= max_y {
break;
}
Line::from(Span::styled(
line_text.as_str(),
Style::default().fg(text_primary),
))
.render(Rect::new(x, y, w, 1), buf);
y += 1;
}
}
y += 1;
if y < max_y {
let variant_label = match info.variant {
ThemeVariant::Dark => "Dark",
ThemeVariant::Light => "Light",
};
Line::from(Span::styled(
variant_label,
Style::default()
.fg(text_muted)
.add_modifier(Modifier::ITALIC),
))
.render(Rect::new(x, y, w, 1), buf);
y += 1;
}
y += 1;
if y < max_y {
let swatch_tokens = [
tokens::ACCENT_PRIMARY,
tokens::ACCENT_SECONDARY,
tokens::ACCENT_TERTIARY,
tokens::SUCCESS,
tokens::WARNING,
tokens::ERROR,
];
let swatches: Vec<Span> = swatch_tokens
.iter()
.flat_map(|token| {
[
Span::styled(
"\u{2588}\u{2588}",
Style::default().fg(Color::from(t.color(token))),
),
Span::raw(" "),
]
})
.collect();
Line::from(swatches).render(Rect::new(x, y, w, 1), buf);
y += 1;
}
#[cfg(feature = "gradients")]
render_gradient_bar(t, x, y, w, max_y, buf);
#[cfg(not(feature = "gradients"))]
let _ = y;
}
#[cfg(feature = "gradients")]
fn render_gradient_bar(t: &Theme, x: u16, y: u16, w: u16, max_y: u16, buf: &mut Buffer) {
use crate::names::gradients;
if y >= max_y {
return;
}
if let Some(gradient) = t.get_gradient(gradients::PRIMARY) {
#[allow(clippy::as_conversions)]
let bar_width = w.min(20) as usize;
let spans: Vec<Span> = (0..bar_width)
.map(|i| {
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
let t_val = if bar_width <= 1 {
0.0
} else {
i as f32 / (bar_width - 1) as f32
};
let color = gradient.at(t_val);
Span::styled("\u{2580}", Style::default().fg(Color::from(color)))
})
.collect();
Line::from(spans).render(Rect::new(x, y, w, 1), buf);
}
}
#[doc(hidden)]
pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = word.width();
if current_line.is_empty() {
current_line = word.to_string();
current_width = word_width;
} else if current_width + 1 + word_width > max_width {
lines.push(current_line);
current_line = word.to_string();
current_width = word_width;
} else {
current_line.push(' ');
current_line.push_str(word);
current_width += 1 + word_width;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
lines
}