Skip to main content

kimun_notes/components/settings/
appearance_section.rs

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