Skip to main content

kimun_notes/components/preferences/
indexing_section.rs

1use 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    /// Resolve a click: a button selects and runs its reindex (the Enter it
36    /// stands for). Inert without a vault, like the keys.
37    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        // Available buttons take the action colour and are click targets.
133        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}