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        &self.selected_theme().name
28    }
29
30    /// The currently highlighted theme. Cheaper than resolving the name
31    /// through `AppSettings::get_theme()`, which rebuilds the whole theme
32    /// list (including custom-theme disk reads) on every call.
33    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}