use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use ratatui::Frame;
use crate::events::Command;
use crate::ui::Theme;
use super::{center_rect, Modal, ModalResult};
const MODAL_WIDTH: u16 = 50;
pub struct ThemeModal {
themes: Vec<String>,
selected: usize,
original: String,
}
impl ThemeModal {
pub fn new(themes: Vec<String>, original: String) -> Self {
let selected = themes.iter().position(|n| *n == original).unwrap_or(0);
Self {
themes,
selected,
original,
}
}
fn current(&self) -> Option<&str> {
self.themes.get(self.selected).map(String::as_str)
}
fn set_theme_cmd(&self, persist: bool) -> Option<Command> {
self.current().map(|name| Command::SetTheme {
name: name.to_string(),
persist,
})
}
fn move_up(&mut self) -> ModalResult {
if self.selected > 0 {
self.selected -= 1;
}
match self.set_theme_cmd(false) {
Some(cmd) => ModalResult::EmitCommand(cmd),
None => ModalResult::Consumed,
}
}
fn move_down(&mut self) -> ModalResult {
if self.selected + 1 < self.themes.len() {
self.selected += 1;
}
match self.set_theme_cmd(false) {
Some(cmd) => ModalResult::EmitCommand(cmd),
None => ModalResult::Consumed,
}
}
}
impl Modal for ThemeModal {
fn id(&self) -> &'static str {
"theme"
}
fn handle(&mut self, key: KeyEvent) -> ModalResult {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return ModalResult::Close(Some(Command::SetTheme {
name: self.original.clone(),
persist: false,
}));
}
match key.code {
KeyCode::Esc => ModalResult::Close(Some(Command::SetTheme {
name: self.original.clone(),
persist: false,
})),
KeyCode::Enter => match self.set_theme_cmd(true) {
Some(cmd) => ModalResult::Close(Some(cmd)),
None => ModalResult::Close(None),
},
KeyCode::Up | KeyCode::Char('k') => self.move_up(),
KeyCode::Down | KeyCode::Char('j') => self.move_down(),
KeyCode::Home => {
self.selected = 0;
match self.set_theme_cmd(false) {
Some(cmd) => ModalResult::EmitCommand(cmd),
None => ModalResult::Consumed,
}
}
KeyCode::End => {
if !self.themes.is_empty() {
self.selected = self.themes.len() - 1;
}
match self.set_theme_cmd(false) {
Some(cmd) => ModalResult::EmitCommand(cmd),
None => ModalResult::Consumed,
}
}
_ => ModalResult::Consumed,
}
}
fn render(&self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
let list_rows = self.themes.len() as u16;
let height = (list_rows + 6).clamp(10, 30);
let rect = center_rect(area, MODAL_WIDTH, height);
let body_bg = theme.panel_alt;
let buf = frame.buffer_mut();
if rect.x + rect.width < area.x + area.width && rect.y + rect.height < area.y + area.height
{
let shadow = Rect::new(rect.x + 1, rect.y + 1, rect.width, rect.height);
let style = Style::default().bg(theme.shadow);
for y in shadow.top()..shadow.bottom() {
for x in shadow.left()..shadow.right() {
buf[(x, y)].set_style(style);
}
}
}
let body_style = Style::default().bg(body_bg);
for y in rect.top()..rect.bottom() {
for x in rect.left()..rect.right() {
let cell = &mut buf[(x, y)];
cell.set_char(' ');
cell.set_style(body_style);
}
}
let accent_style = Style::default().bg(theme.accent);
for y in rect.top()..rect.bottom() {
let cell = &mut buf[(rect.left(), y)];
cell.set_char(' ');
cell.set_style(accent_style);
}
let inner = Rect::new(
rect.x + 3,
rect.y + 1,
rect.width.saturating_sub(4),
rect.height.saturating_sub(2),
);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(self.themes.len() + 4);
lines.push(Line::from(vec![
Span::styled(
"Theme",
Style::default()
.fg(theme.text)
.bg(body_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" ↑↓ preview · ↵ apply · esc revert",
Style::default().fg(theme.text_muted).bg(body_bg),
),
]));
lines.push(Line::from(""));
for (i, name) in self.themes.iter().enumerate() {
lines.push(render_row(name, i == self.selected, inner.width, theme));
}
Paragraph::new(lines)
.style(Style::default().bg(body_bg))
.render(inner, frame.buffer_mut());
}
}
fn render_row(name: &str, selected: bool, width: u16, theme: &Theme) -> Line<'static> {
let marker = if selected { "▸" } else { " " };
let row_bg = if selected {
theme.selection_bg
} else {
theme.panel_alt
};
let marker_style = if selected {
Style::default().fg(theme.accent).bg(row_bg)
} else {
Style::default().fg(row_bg).bg(row_bg)
};
let name_style = if selected {
Style::default()
.fg(theme.text)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted).bg(row_bg)
};
let name_field_width = (width as usize).saturating_sub(4);
let mut padded = name.to_string();
while padded.chars().count() < name_field_width {
padded.push(' ');
}
Line::from(vec![
Span::styled(format!(" {} ", marker), marker_style),
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(padded, name_style),
])
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn modal() -> ThemeModal {
ThemeModal::new(
vec!["opencode".into(), "dracula".into(), "nord".into()],
"opencode".into(),
)
}
#[test]
fn selection_starts_at_original() {
let m = modal();
assert_eq!(m.current(), Some("opencode"));
}
#[test]
fn down_previews_next_theme_without_closing() {
let mut m = modal();
match m.handle(key(KeyCode::Down)) {
ModalResult::EmitCommand(Command::SetTheme { name, persist }) => {
assert_eq!(name, "dracula");
assert!(!persist);
}
_ => panic!("expected EmitCommand(SetTheme)"),
}
assert_eq!(m.current(), Some("dracula"));
}
#[test]
fn up_previews_previous_theme() {
let mut m = modal();
m.handle(key(KeyCode::Down));
m.handle(key(KeyCode::Down));
assert_eq!(m.current(), Some("nord"));
match m.handle(key(KeyCode::Up)) {
ModalResult::EmitCommand(Command::SetTheme { name, .. }) => {
assert_eq!(name, "dracula");
}
_ => panic!("expected EmitCommand"),
}
}
#[test]
fn down_clamps_at_end() {
let mut m = modal();
m.handle(key(KeyCode::Down));
m.handle(key(KeyCode::Down));
m.handle(key(KeyCode::Down)); assert_eq!(m.current(), Some("nord"));
}
#[test]
fn up_clamps_at_start() {
let mut m = modal();
m.handle(key(KeyCode::Up)); assert_eq!(m.current(), Some("opencode"));
}
#[test]
fn enter_commits_with_persist() {
let mut m = modal();
m.handle(key(KeyCode::Down));
match m.handle(key(KeyCode::Enter)) {
ModalResult::Close(Some(Command::SetTheme { name, persist })) => {
assert_eq!(name, "dracula");
assert!(persist);
}
_ => panic!("expected Close with persist=true"),
}
}
#[test]
fn esc_reverts_to_original_without_persist() {
let mut m = modal();
m.handle(key(KeyCode::Down));
m.handle(key(KeyCode::Down));
match m.handle(key(KeyCode::Esc)) {
ModalResult::Close(Some(Command::SetTheme { name, persist })) => {
assert_eq!(name, "opencode");
assert!(!persist);
}
_ => panic!("expected Close reverting to original"),
}
}
#[test]
fn home_jumps_to_first() {
let mut m = modal();
m.handle(key(KeyCode::Down));
m.handle(key(KeyCode::Down));
m.handle(key(KeyCode::Home));
assert_eq!(m.current(), Some("opencode"));
}
#[test]
fn end_jumps_to_last() {
let mut m = modal();
m.handle(key(KeyCode::End));
assert_eq!(m.current(), Some("nord"));
}
}