use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::app::App;
use crate::theme::Theme;
const MODAL_WIDTH: u16 = 46;
const MODAL_HEIGHT: u16 = 15;
pub fn draw(f: &mut Frame, app: &App) {
let area = centered_rect(MODAL_WIDTH, MODAL_HEIGHT, f.area());
f.render_widget(Clear, area);
let p = &app.palette;
let block = Block::default()
.title(" Theme ")
.title_style(Style::default().fg(p.accent).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.help_bg));
let lines = build_lines(app);
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn build_lines(app: &App) -> Vec<Line<'static>> {
let p = &app.palette;
let cursor = app.theme_picker_cursor;
let active_theme = app.config.theme;
let cursor_style = Style::default().fg(p.accent).add_modifier(Modifier::BOLD);
let active_style = Style::default().fg(p.accent_alt).add_modifier(Modifier::BOLD);
let text_style = Style::default().fg(p.foreground);
let dim_style = p.dim_style();
let mut lines: Vec<Line<'static>> = Vec::with_capacity(Theme::ALL.len() + 4);
lines.push(Line::from(""));
for (idx, &theme) in Theme::ALL.iter().enumerate() {
let is_cursor = idx == cursor;
let is_active = theme == active_theme;
lines.push(theme_row(
is_cursor,
is_active,
theme.label(),
cursor_style,
active_style,
text_style,
dim_style,
));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" ", text_style),
Span::styled("\u{2191}\u{2193} move", cursor_style),
Span::styled(" ", dim_style),
Span::styled("Enter", cursor_style),
Span::styled(" apply ", dim_style),
Span::styled("Esc", cursor_style),
Span::styled(" cancel", dim_style),
]));
lines
}
fn theme_row(
is_cursor: bool,
is_active: bool,
label: &'static str,
cursor_style: Style,
active_style: Style,
text_style: Style,
dim_style: Style,
) -> Line<'static> {
let arrow = if is_cursor { "> " } else { " " };
let bullet = if is_active { "\u{25cf}" } else { "\u{25cb}" };
let bullet_style = if is_active { active_style } else { dim_style };
let label_style = if is_cursor { cursor_style } else { text_style };
Line::from(vec![
Span::styled(" ", text_style),
Span::styled(arrow, cursor_style),
Span::styled(bullet, bullet_style),
Span::styled(" ", text_style),
Span::styled(label, label_style),
])
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).split(area);
Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).split(vertical[0])[0]
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn make_app_at_theme(theme: Theme) -> crate::app::App {
let config = crate::config::Config { theme, ..Default::default() };
let session = crate::state::AppSession::default();
let mut app = crate::app::App::new(config, session);
app.theme_picker_cursor = Theme::ALL.iter().position(|&t| t == theme).unwrap_or(0);
app.focus = crate::app::Focus::ThemePicker;
app
}
#[test]
fn draw_shows_all_theme_labels() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
let app = make_app_at_theme(Theme::Default);
terminal.draw(|f| draw(f, &app)).expect("draw");
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content.iter().map(ratatui::buffer::Cell::symbol).collect();
for theme in Theme::ALL {
assert!(
rendered.contains(theme.label()),
"label '{}' must appear in buffer",
theme.label()
);
}
}
#[test]
fn draw_shows_title() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
let app = make_app_at_theme(Theme::Nord);
terminal.draw(|f| draw(f, &app)).expect("draw");
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content.iter().map(ratatui::buffer::Cell::symbol).collect();
assert!(rendered.contains("Theme"), "modal title must be rendered");
}
}