kimun_notes/components/preferences/
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
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 pub fn handle_mouse(
37 &mut self,
38 m: &ratatui::crossterm::event::MouseEvent,
39 ) -> Option<ratatui::crossterm::event::KeyEvent> {
40 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 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 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 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}