kimun_notes/components/preferences/
indexing_section.rs1use ratatui::Frame;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::{Modifier, Style};
4use ratatui::widgets::{Block, Borders, Paragraph};
5
6use crate::components::Component;
7use crate::components::event_state::EventState;
8use crate::components::events::{AppEvent, AppTx, InputEvent};
9use crate::settings::themes::Theme;
10
11use super::{ClickMap, SectionMouse, text_rect};
12use unicode_width::UnicodeWidthStr;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum IndexAction {
16 Fast,
17 Full,
18}
19
20pub struct IndexingSection {
21 pub selected: IndexAction,
22 vault_available: bool,
23 clicks: ClickMap,
24}
25
26impl IndexingSection {
27 pub fn new(vault_available: bool) -> Self {
28 Self {
29 selected: IndexAction::Fast,
30 vault_available,
31 clicks: ClickMap::default(),
32 }
33 }
34
35 pub fn handle_mouse(
38 &mut self,
39 m: &ratatui::crossterm::event::MouseEvent,
40 ) -> Option<ratatui::crossterm::event::KeyEvent> {
41 if !self.vault_available {
42 return None;
43 }
44 let row = |a: IndexAction| match a {
45 IndexAction::Fast => 0,
46 IndexAction::Full => 1,
47 };
48 match self.clicks.resolve(m, Some(row(self.selected)), None) {
49 SectionMouse::Key(r, key) => {
50 self.selected = if r == 0 {
51 IndexAction::Fast
52 } else {
53 IndexAction::Full
54 };
55 Some(key)
56 }
57 SectionMouse::Select(_) | SectionMouse::Wheel(_) | SectionMouse::None => None,
58 }
59 }
60
61 pub fn set_vault_available(&mut self, available: bool) {
62 self.vault_available = available;
63 }
64}
65
66impl Component for IndexingSection {
67 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
68 if !self.vault_available {
69 return EventState::NotConsumed;
70 }
71 let InputEvent::Key(key) = event else {
72 return EventState::NotConsumed;
73 };
74 match key.code {
75 ratatui::crossterm::event::KeyCode::Right
76 | ratatui::crossterm::event::KeyCode::Char('l') => {
77 self.selected = IndexAction::Full;
78 EventState::Consumed
79 }
80 ratatui::crossterm::event::KeyCode::Left
81 | ratatui::crossterm::event::KeyCode::Char('h') => {
82 self.selected = IndexAction::Fast;
83 EventState::Consumed
84 }
85 ratatui::crossterm::event::KeyCode::Enter => {
86 let msg = match self.selected {
87 IndexAction::Fast => AppEvent::TriggerFastReindex,
88 IndexAction::Full => AppEvent::TriggerFullReindex,
89 };
90 tx.send(msg).ok();
91 EventState::Consumed
92 }
93 _ => EventState::NotConsumed,
94 }
95 }
96
97 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
98 let border_style = theme.border_style(focused);
99 let block = Block::default()
100 .title("Reindex")
101 .borders(Borders::ALL)
102 .border_style(border_style)
103 .style(theme.base_style());
104 let inner = block.inner(rect);
105 f.render_widget(block, rect);
106
107 let fast_label = if self.selected == IndexAction::Fast {
108 "[ Fast Reindex ]"
109 } else {
110 " Fast Reindex "
111 };
112 let full_label = if self.selected == IndexAction::Full {
113 "[ Full Reindex ]"
114 } else {
115 " Full Reindex "
116 };
117 let dim = if self.vault_available {
118 Style::default()
119 .fg(theme.fg.to_ratatui())
120 .bg(theme.bg.to_ratatui())
121 } else {
122 Style::default()
123 .fg(theme.fg.to_ratatui())
124 .bg(theme.bg.to_ratatui())
125 .add_modifier(Modifier::DIM)
126 };
127
128 let cols = Layout::default()
129 .direction(Direction::Horizontal)
130 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
131 .split(inner);
132 let style = if self.vault_available {
134 dim.patch(theme.action())
135 } else {
136 dim
137 };
138 self.clicks.clear();
139 if self.vault_available {
140 for (i, (col, label)) in [(cols[0], fast_label), (cols[1], full_label)]
141 .into_iter()
142 .enumerate()
143 {
144 self.clicks.control(
145 text_rect(col, 0, label.width() as u16),
146 i,
147 ratatui::crossterm::event::KeyCode::Enter,
148 );
149 }
150 }
151 f.render_widget(Paragraph::new(fast_label).style(style), cols[0]);
152 f.render_widget(Paragraph::new(full_label).style(style), cols[1]);
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::components::events::AppEvent;
160 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
161
162 fn key(code: KeyCode) -> InputEvent {
163 InputEvent::Key(KeyEvent {
164 code,
165 modifiers: KeyModifiers::NONE,
166 kind: KeyEventKind::Press,
167 state: KeyEventState::NONE,
168 })
169 }
170
171 #[test]
172 fn not_consumed_when_vault_unavailable() {
173 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
174 let mut section = IndexingSection::new(false);
175 let enter_result = section.handle_input(&key(KeyCode::Enter), &tx);
176 assert!(matches!(
177 enter_result,
178 crate::components::event_state::EventState::NotConsumed
179 ));
180 let right_result = section.handle_input(&key(KeyCode::Right), &tx);
181 assert!(matches!(
182 right_result,
183 crate::components::event_state::EventState::NotConsumed
184 ));
185 let left_result = section.handle_input(&key(KeyCode::Left), &tx);
186 assert!(matches!(
187 left_result,
188 crate::components::event_state::EventState::NotConsumed
189 ));
190 let l_result = section.handle_input(&key(KeyCode::Char('l')), &tx);
191 assert!(matches!(
192 l_result,
193 crate::components::event_state::EventState::NotConsumed
194 ));
195 let h_result = section.handle_input(&key(KeyCode::Char('h')), &tx);
196 assert!(matches!(
197 h_result,
198 crate::components::event_state::EventState::NotConsumed
199 ));
200 assert!(
201 rx.try_recv().is_err(),
202 "No messages should be sent when vault_available == false"
203 );
204 }
205
206 #[test]
207 fn set_vault_available_enables_keys() {
208 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
209 let mut section = IndexingSection::new(false);
210 section.handle_input(&key(KeyCode::Enter), &tx);
211 assert!(
212 rx.try_recv().is_err(),
213 "Enter should be blocked when unavailable"
214 );
215 section.set_vault_available(true);
216 section.handle_input(&key(KeyCode::Enter), &tx);
217 let msg = rx
218 .try_recv()
219 .expect("Enter should send message after enabling");
220 assert!(matches!(msg, AppEvent::TriggerFastReindex));
221 }
222
223 #[test]
224 fn right_cycles_fast_to_full() {
225 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
226 let mut section = IndexingSection::new(true);
227 assert_eq!(section.selected, IndexAction::Fast);
228 section.handle_input(&key(KeyCode::Right), &tx);
229 assert_eq!(section.selected, IndexAction::Full);
230 }
231
232 #[test]
233 fn left_cycles_full_to_fast() {
234 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
235 let mut section = IndexingSection::new(true);
236 section.handle_input(&key(KeyCode::Right), &tx);
237 section.handle_input(&key(KeyCode::Left), &tx);
238 assert_eq!(section.selected, IndexAction::Fast);
239 }
240
241 #[test]
242 fn enter_on_fast_sends_trigger_fast_reindex() {
243 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
244 let mut section = IndexingSection::new(true);
245 section.handle_input(&key(KeyCode::Enter), &tx);
246 let msg = rx.try_recv().expect("message should be sent");
247 assert!(matches!(msg, AppEvent::TriggerFastReindex));
248 }
249
250 #[test]
251 fn enter_on_full_sends_trigger_full_reindex() {
252 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
253 let mut section = IndexingSection::new(true);
254 section.handle_input(&key(KeyCode::Right), &tx);
255 assert!(rx.try_recv().is_err(), "Right should not send any message");
256 section.handle_input(&key(KeyCode::Enter), &tx);
257 let msg = rx.try_recv().expect("message should be sent");
258 assert!(matches!(msg, AppEvent::TriggerFullReindex));
259 }
260
261 #[test]
262 fn right_is_idempotent_when_already_full() {
263 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
264 let mut section = IndexingSection::new(true);
265 section.handle_input(&key(KeyCode::Right), &tx);
266 section.handle_input(&key(KeyCode::Right), &tx);
267 assert_eq!(section.selected, IndexAction::Full);
268 }
269}