Skip to main content

kimun_notes/components/preferences/
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
10use super::ClickMap;
11
12pub struct AppearanceSection {
13    themes: Vec<Theme>,
14    list_state: ListState,
15    clicks: ClickMap,
16}
17
18impl AppearanceSection {
19    pub fn new(themes: Vec<Theme>, active_name: &str) -> Self {
20        let idx = themes
21            .iter()
22            .position(|t| t.name == active_name)
23            .unwrap_or(0);
24        let mut list_state = ListState::default();
25        list_state.select(Some(idx));
26        Self {
27            themes,
28            list_state,
29            clicks: ClickMap::default(),
30        }
31    }
32
33    /// Resolve a click: a theme row selects (and so previews) it; the wheel
34    /// steps through themes. Returns the key a wheel notch stands for; a
35    /// row click changes the selection directly (the screen syncs it).
36    pub fn handle_mouse(
37        &mut self,
38        m: &ratatui::crossterm::event::MouseEvent,
39    ) -> Option<ratatui::crossterm::event::KeyEvent> {
40        // No controls and no re-click action: a row click only selects.
41        self.clicks
42            .resolve(m, self.list_state.selected(), None)
43            .into_key(|row| self.list_state.select(Some(row)))
44    }
45
46    pub fn selected_theme_name(&self) -> &str {
47        &self.selected_theme().name
48    }
49
50    /// The currently highlighted theme. Cheaper than resolving the name
51    /// through `AppSettings::get_theme()`, which rebuilds the whole theme
52    /// list (including custom-theme disk reads) on every call.
53    pub fn selected_theme(&self) -> &Theme {
54        debug_assert!(
55            !self.themes.is_empty(),
56            "AppearanceSection requires at least one theme"
57        );
58        let idx = self.list_state.selected().unwrap_or(0);
59        &self.themes[idx]
60    }
61}
62
63impl Component for AppearanceSection {
64    fn handle_input(&mut self, event: &InputEvent, _tx: &AppTx) -> EventState {
65        let InputEvent::Key(key) = event else {
66            return EventState::NotConsumed;
67        };
68        let count = self.themes.len();
69        match key.code {
70            ratatui::crossterm::event::KeyCode::Down
71            | ratatui::crossterm::event::KeyCode::Char('j') => {
72                let cur = self.list_state.selected().unwrap_or(0);
73                self.list_state.select(Some((cur + 1) % count));
74                EventState::Consumed
75            }
76            ratatui::crossterm::event::KeyCode::Up
77            | ratatui::crossterm::event::KeyCode::Char('k') => {
78                let cur = self.list_state.selected().unwrap_or(0);
79                self.list_state.select(Some((cur + count - 1) % count));
80                EventState::Consumed
81            }
82            _ => EventState::NotConsumed,
83        }
84    }
85
86    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
87        let border_style = theme.border_style(focused);
88        let block = Block::default()
89            .title("Appearance")
90            .borders(Borders::ALL)
91            .border_style(border_style)
92            .style(theme.base_style());
93        let items: Vec<ListItem> = self
94            .themes
95            .iter()
96            .enumerate()
97            .map(|(i, t)| {
98                let selected = self.list_state.selected() == Some(i);
99                let prefix = if selected { "● " } else { "  " };
100                ListItem::new(format!("{}{}", prefix, t.name))
101            })
102            .collect();
103        // Rows are hit-tested inside the block actually drawn.
104        let inner = block.inner(rect);
105        let list = List::new(items)
106            .block(block)
107            .style(theme.base_style())
108            .highlight_style(
109                ratatui::style::Style::default()
110                    .fg(theme.selection_fg.to_ratatui())
111                    .bg(theme.selection_bg.to_ratatui()),
112            );
113        f.render_stateful_widget(list, rect, &mut self.list_state);
114        // After rendering: the list may have scrolled to keep the selection
115        // in view, and rows map from the offset it settled on.
116        self.clicks.clear();
117        self.clicks
118            .list_rows(inner, self.list_state.offset(), self.themes.len());
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn make_themes() -> Vec<Theme> {
127        vec![
128            Theme::gruvbox_dark(),
129            Theme::gruvbox_light(),
130            Theme::catppuccin_mocha(),
131        ]
132    }
133
134    #[test]
135    fn selected_theme_name_returns_initial() {
136        let section = AppearanceSection::new(make_themes(), "Gruvbox Light");
137        assert_eq!(section.selected_theme_name(), "Gruvbox Light");
138    }
139
140    #[test]
141    fn down_moves_selection() {
142        use ratatui::crossterm::event::{
143            KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
144        };
145        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
146        let mut section = AppearanceSection::new(make_themes(), "Gruvbox Dark");
147        let key = crate::components::events::InputEvent::Key(KeyEvent {
148            code: KeyCode::Down,
149            modifiers: KeyModifiers::NONE,
150            kind: KeyEventKind::Press,
151            state: KeyEventState::NONE,
152        });
153        section.handle_input(&key, &tx);
154        assert_eq!(section.selected_theme_name(), "Gruvbox Light");
155    }
156
157    #[test]
158    fn renders_without_panic() {
159        use ratatui::Terminal;
160        use ratatui::backend::TestBackend;
161        let backend = TestBackend::new(40, 20);
162        let mut terminal = Terminal::new(backend).unwrap();
163        let mut section = AppearanceSection::new(make_themes(), "Gruvbox Dark");
164        let theme = Theme::gruvbox_dark();
165        terminal
166            .draw(|f| section.render(f, f.area(), &theme, false))
167            .unwrap();
168    }
169}