use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use std::path::Path;
use crate::theme::Theme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
None,
Preview(String),
Accept,
Cancel,
}
const ROWS: usize = 12;
#[derive(Debug)]
pub struct ThemePicker {
names: Vec<String>,
bundled: usize,
selected: usize,
offset: usize,
}
impl ThemePicker {
pub fn new(current: Option<&str>, themes_dir: Option<&Path>) -> Self {
let mut names: Vec<String> = crate::themes::bundled_names()
.into_iter()
.map(str::to_string)
.collect();
names.sort_unstable();
let bundled = names.len();
let mut mine = themes_dir.map(user_themes).unwrap_or_default();
mine.retain(|name| !names.contains(name));
mine.sort_unstable();
names.extend(mine);
let selected = current
.and_then(|name| names.iter().position(|listed| listed == name))
.unwrap_or(0);
let mut picker = Self {
names,
bundled,
selected,
offset: 0,
};
picker.scroll_into_view();
picker
}
#[cfg(test)]
pub fn names(&self) -> &[String] {
&self.names
}
pub fn current(&self) -> Option<&str> {
self.names.get(self.selected).map(String::as_str)
}
#[cfg(test)]
pub fn selected(&self) -> usize {
self.selected
}
pub fn handle_key(&mut self, key: KeyEvent) -> Action {
let last = self.names.len().saturating_sub(1);
let moved = match key.code {
KeyCode::Enter | KeyCode::Char(' ') => return Action::Accept,
KeyCode::Esc | KeyCode::Char('q' | 't') => return Action::Cancel,
KeyCode::Down | KeyCode::Char('j') => self.selected.saturating_add(1).min(last),
KeyCode::Up | KeyCode::Char('k') => self.selected.saturating_sub(1),
KeyCode::Home => 0,
KeyCode::End => last,
KeyCode::PageDown => self.selected.saturating_add(ROWS).min(last),
KeyCode::PageUp => self.selected.saturating_sub(ROWS),
_ => return Action::None,
};
if moved == self.selected {
return Action::None;
}
self.selected = moved;
self.scroll_into_view();
self.current()
.map_or(Action::None, |n| Action::Preview(n.to_string()))
}
fn scroll_into_view(&mut self) {
if self.selected < self.offset {
self.offset = self.selected;
} else if self.selected >= self.offset + ROWS {
self.offset = self.selected + 1 - ROWS;
}
}
pub fn render(&self, frame: &mut ratatui::Frame, area: Rect, theme: &Theme) {
let width = 44u16.min(area.width);
let rows = u16::try_from(self.names.len().min(ROWS)).unwrap_or(u16::MAX);
let height = rows.saturating_add(4).min(area.height);
let rect = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
let mut lines: Vec<Line<'static>> = Vec::with_capacity(self.names.len() + 2);
for (index, name) in self.names.iter().enumerate().skip(self.offset).take(ROWS) {
let chosen = index == self.selected;
let marker = if chosen { "▸ " } else { " " };
let style = if chosen {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let mut spans = vec![
Span::styled(marker, Style::default().fg(theme.accent)),
Span::styled(name.clone(), style),
];
if index >= self.bundled {
spans.push(Span::styled(" yours", Style::default().fg(theme.muted)));
}
lines.push(Line::from(spans));
}
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled(
crate::glyphs::utility("enter"),
Style::default().fg(theme.key).add_modifier(Modifier::BOLD),
),
Span::styled(" keep ", Style::default().fg(theme.muted)),
Span::styled(
crate::glyphs::utility("esc"),
Style::default().fg(theme.key).add_modifier(Modifier::BOLD),
),
Span::styled(" put back", Style::default().fg(theme.muted)),
]));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border_focused))
.padding(Padding::horizontal(1))
.title(Line::from(vec![
Span::styled("┤", Style::default().fg(theme.border_focused)),
Span::styled(
crate::glyphs::utility("theme"),
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
),
Span::styled("├", Style::default().fg(theme.border_focused)),
]));
frame.render_widget(Clear, rect);
frame.render_widget(Paragraph::new(lines).block(block), rect);
}
}
fn user_themes(dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension()? != "toml" {
return None;
}
let name = path.file_stem()?.to_str()?.to_string();
crate::themes::is_listable(&name).then_some(name)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::path::PathBuf;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::from(code)
}
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn themes_dir(name: &str) -> TempDir {
let dir =
std::env::temp_dir().join(format!("mirador-picker-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("test directory");
TempDir(dir)
}
fn write(dir: &Path, name: &str) {
std::fs::write(dir.join(format!("{name}.toml")), "accent = \"red\"\n").expect("write");
}
#[test]
fn it_opens_on_the_theme_already_in_force() {
let picker = ThemePicker::new(Some("nord"), None);
assert_eq!(picker.current(), Some("nord"));
}
#[test]
fn an_inline_theme_table_has_no_name_to_land_on_and_starts_at_the_top() {
let picker = ThemePicker::new(None, None);
assert_eq!(picker.selected(), 0);
}
#[test]
fn moving_the_cursor_previews_rather_than_waiting_for_enter() {
let mut picker = ThemePicker::new(Some("ansi"), None);
let first = picker.current().expect("a name").to_string();
let action = picker.handle_key(key(KeyCode::Down));
let Action::Preview(name) = action else {
panic!("a cursor move must preview, got {action:?}");
};
assert_ne!(name, first, "the preview is the row moved to");
assert_eq!(picker.current(), Some(name.as_str()));
}
#[test]
fn the_cursor_stops_at_both_ends_without_previewing_again() {
let mut picker = ThemePicker::new(None, None);
assert_eq!(picker.handle_key(key(KeyCode::Up)), Action::None);
picker.handle_key(key(KeyCode::End));
assert_eq!(picker.handle_key(key(KeyCode::Down)), Action::None);
}
#[test]
fn enter_keeps_and_esc_puts_back() {
let mut picker = ThemePicker::new(None, None);
assert_eq!(picker.handle_key(key(KeyCode::Enter)), Action::Accept);
assert_eq!(picker.handle_key(key(KeyCode::Esc)), Action::Cancel);
assert_eq!(picker.handle_key(key(KeyCode::Char('t'))), Action::Cancel);
assert_eq!(picker.handle_key(key(KeyCode::Char('q'))), Action::Cancel);
}
#[test]
fn the_window_follows_the_cursor_past_the_bottom_of_the_list() {
let dir = themes_dir("scroll");
for i in 0..20 {
write(&dir.0, &format!("mine-{i:02}"));
}
let mut picker = ThemePicker::new(None, Some(&dir.0));
assert!(picker.names().len() > ROWS, "needs more rows than fit");
for _ in 0..picker.names().len() {
picker.handle_key(key(KeyCode::Down));
assert!(
picker.selected() >= picker.offset && picker.selected() < picker.offset + ROWS,
"cursor {} left the window at offset {}",
picker.selected(),
picker.offset
);
}
}
#[test]
fn a_user_theme_shadowing_a_bundled_name_is_listed_once() {
let dir = themes_dir("shadow");
write(&dir.0, "nord");
let picker = ThemePicker::new(None, Some(&dir.0));
let nords = picker.names().iter().filter(|n| *n == "nord").count();
assert_eq!(nords, 1, "the file on disk is the one that would load");
}
#[test]
fn a_file_the_resolver_could_never_load_is_not_offered() {
let dir = themes_dir("unlistable");
write(&dir.0, "has space");
write(&dir.0, "fine-one");
let picker = ThemePicker::new(None, Some(&dir.0));
assert!(picker.names().iter().any(|n| n == "fine-one"));
assert!(
!picker.names().iter().any(|n| n.contains(' ')),
"got: {:?}",
picker.names()
);
}
#[test]
fn a_missing_themes_directory_is_not_an_error() {
let picker = ThemePicker::new(None, Some(Path::new("/nope/not/here")));
assert_eq!(picker.names().len(), crate::themes::bundled_names().len());
}
#[test]
fn it_draws_without_panicking_in_a_small_terminal() {
for (w, h) in [(80, 24), (30, 10), (10, 5)] {
let mut terminal = Terminal::new(TestBackend::new(w, h)).expect("terminal");
let picker = ThemePicker::new(None, None);
terminal
.draw(|f| picker.render(f, f.area(), &Theme::default()))
.expect("draw");
}
}
}