kimun_notes/components/settings/
appearance_section.rs1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
4
5use crate::components::Component;
6use crate::components::event_state::EventState;
7use crate::components::events::{AppTx, InputEvent};
8use crate::settings::themes::Theme;
9
10pub struct AppearanceSection {
11 themes: Vec<Theme>,
12 list_state: ListState,
13}
14
15impl AppearanceSection {
16 pub fn new(themes: Vec<Theme>, active_name: &str) -> Self {
17 let idx = themes
18 .iter()
19 .position(|t| t.name == active_name)
20 .unwrap_or(0);
21 let mut list_state = ListState::default();
22 list_state.select(Some(idx));
23 Self { themes, list_state }
24 }
25
26 pub fn selected_theme_name(&self) -> &str {
27 &self.selected_theme().name
28 }
29
30 pub fn selected_theme(&self) -> &Theme {
34 debug_assert!(
35 !self.themes.is_empty(),
36 "AppearanceSection requires at least one theme"
37 );
38 let idx = self.list_state.selected().unwrap_or(0);
39 &self.themes[idx]
40 }
41}
42
43impl Component for AppearanceSection {
44 fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
45 let InputEvent::Key(key) = event else {
46 return EventState::NotConsumed;
47 };
48 let count = self.themes.len();
49 match key.code {
50 ratatui::crossterm::event::KeyCode::Down
51 | ratatui::crossterm::event::KeyCode::Char('j') => {
52 let cur = self.list_state.selected().unwrap_or(0);
53 self.list_state.select(Some((cur + 1) % count));
54 EventState::Consumed
55 }
56 ratatui::crossterm::event::KeyCode::Up
57 | ratatui::crossterm::event::KeyCode::Char('k') => {
58 let cur = self.list_state.selected().unwrap_or(0);
59 self.list_state.select(Some((cur + count - 1) % count));
60 EventState::Consumed
61 }
62 _ => EventState::NotConsumed,
63 }
64 }
65
66 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
67 let border_style = theme.border_style(focused);
68 let block = Block::default()
69 .title("Appearance")
70 .borders(Borders::ALL)
71 .border_style(border_style)
72 .style(theme.base_style());
73 let items: Vec<ListItem> = self
74 .themes
75 .iter()
76 .enumerate()
77 .map(|(i, t)| {
78 let selected = self.list_state.selected() == Some(i);
79 let prefix = if selected { "● " } else { " " };
80 ListItem::new(format!("{}{}", prefix, t.name))
81 })
82 .collect();
83 let list = List::new(items)
84 .block(block)
85 .style(theme.base_style())
86 .highlight_style(
87 ratatui::style::Style::default()
88 .fg(theme.fg_selected.to_ratatui())
89 .bg(theme.bg_selected.to_ratatui()),
90 );
91 f.render_stateful_widget(list, rect, &mut self.list_state);
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 fn make_themes() -> Vec<Theme> {
100 vec![
101 Theme::gruvbox_dark(),
102 Theme::gruvbox_light(),
103 Theme::catppuccin_mocha(),
104 ]
105 }
106
107 #[test]
108 fn selected_theme_name_returns_initial() {
109 let section = AppearanceSection::new(make_themes(), "Gruvbox Light");
110 assert_eq!(section.selected_theme_name(), "Gruvbox Light");
111 }
112
113 #[test]
114 fn down_moves_selection() {
115 use ratatui::crossterm::event::{
116 KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
117 };
118 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
119 let mut section = AppearanceSection::new(make_themes(), "Gruvbox Dark");
120 let key = crate::components::events::InputEvent::Key(KeyEvent {
121 code: KeyCode::Down,
122 modifiers: KeyModifiers::NONE,
123 kind: KeyEventKind::Press,
124 state: KeyEventState::NONE,
125 });
126 section.handle_input(&key, &tx);
127 assert_eq!(section.selected_theme_name(), "Gruvbox Light");
128 }
129
130 #[test]
131 fn renders_without_panic() {
132 use ratatui::Terminal;
133 use ratatui::backend::TestBackend;
134 let backend = TestBackend::new(40, 20);
135 let mut terminal = Terminal::new(backend).unwrap();
136 let mut section = AppearanceSection::new(make_themes(), "Gruvbox Dark");
137 let theme = Theme::gruvbox_dark();
138 terminal
139 .draw(|f| section.render(f, f.area(), &theme, false))
140 .unwrap();
141 }
142}