ratatui_toolkit/widgets/markdown_widget/widget/methods/
handle_key_event.rs

1//! Handle keyboard events for the markdown widget.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4
5use crate::widgets::markdown_widget::foundation::events::MarkdownEvent;
6use crate::widgets::markdown_widget::widget::enums::MarkdownWidgetMode;
7use crate::widgets::markdown_widget::widget::MarkdownWidget;
8
9impl<'a> MarkdownWidget<'a> {
10    /// Handle a keyboard event for navigation and actions.
11    ///
12    /// This method handles:
13    /// - `j` / `Down`: Move focused line down (scrolls when near edge)
14    /// - `k` / `Up`: Move focused line up (scrolls when near edge)
15    /// - `PageDown`: Scroll down by viewport height
16    /// - `PageUp`: Scroll up by viewport height
17    /// - `Home` / `gg`: Go to top
18    /// - `End` / `G`: Go to bottom
19    /// - `/`: Enter filter mode
20    /// - `Esc`: Exit selection mode or filter mode
21    /// - `y`: Copy selection to clipboard (when selection active)
22    /// - `Ctrl+Shift+C`: Copy selection to clipboard
23    ///
24    /// Returns a `MarkdownEvent` indicating what action was taken.
25    pub fn handle_key_event(&mut self, key: KeyEvent) -> MarkdownEvent {
26        // Handle filter mode first
27        if self.filter_mode {
28            return self.handle_filter_key(key);
29        }
30
31        // Handle selection-related keys first
32        if key.code == KeyCode::Esc && self.selection.is_active() {
33            self.selection.exit();
34            self.mode = MarkdownWidgetMode::Normal;
35            self.vim.clear_pending_g();
36            return MarkdownEvent::SelectionEnded;
37        }
38
39        // Copy selection with 'y' (vim-style)
40        if key.code == KeyCode::Char('y') && self.selection.has_selection() {
41            if let Some(text) = self.selection.get_selected_text() {
42                if !text.is_empty() {
43                    if let Ok(mut clipboard) = arboard::Clipboard::new() {
44                        if clipboard.set_text(&text).is_ok() {
45                            self.selection.exit();
46                            self.mode = MarkdownWidgetMode::Normal;
47                            self.vim.clear_pending_g();
48                            return MarkdownEvent::Copied { text };
49                        }
50                    }
51                }
52            }
53        }
54
55        // Copy selection with Ctrl+Shift+C
56        if key.code == KeyCode::Char('C')
57            && key.modifiers.contains(KeyModifiers::CONTROL)
58            && key.modifiers.contains(KeyModifiers::SHIFT)
59        {
60            if let Some(text) = self.selection.get_selected_text() {
61                if !text.is_empty() {
62                    if let Ok(mut clipboard) = arboard::Clipboard::new() {
63                        if clipboard.set_text(&text).is_ok() {
64                            self.selection.exit();
65                            self.mode = MarkdownWidgetMode::Normal;
66                            self.vim.clear_pending_g();
67                            return MarkdownEvent::Copied { text };
68                        }
69                    }
70                }
71            }
72        }
73
74        // Handle vim-style 'gg' for go to top
75        if key.code == KeyCode::Char('g') {
76            if self.vim.check_pending_gg() {
77                // Second 'g' within timeout - go to top
78                self.scroll.scroll_to_top();
79                return MarkdownEvent::FocusedLine {
80                    line: self.scroll.current_line,
81                };
82            }
83            // First 'g' or timeout expired - set pending
84            self.vim.set_pending_g();
85            return MarkdownEvent::None;
86        }
87
88        // Any other key clears pending 'g'
89        self.vim.clear_pending_g();
90
91        // Handle navigation keys
92        match key.code {
93            KeyCode::Char('/') => {
94                self.filter_mode = true;
95                self.filter = Some(String::new());
96                self.mode = MarkdownWidgetMode::Filter;
97                MarkdownEvent::FilterModeChanged {
98                    active: true,
99                    filter: String::new(),
100                }
101            }
102            KeyCode::Char('j') | KeyCode::Down => {
103                // Move focused line down (scrolls when near edge)
104                self.scroll.line_down();
105                MarkdownEvent::FocusedLine {
106                    line: self.scroll.current_line,
107                }
108            }
109            KeyCode::Char('k') | KeyCode::Up => {
110                // Move focused line up (scrolls when near edge)
111                self.scroll.line_up();
112                MarkdownEvent::FocusedLine {
113                    line: self.scroll.current_line,
114                }
115            }
116            KeyCode::PageDown => {
117                let old_offset = self.scroll.scroll_offset;
118                self.scroll.scroll_down(self.scroll.viewport_height);
119                MarkdownEvent::Scrolled {
120                    offset: self.scroll.scroll_offset,
121                    direction: (self.scroll.scroll_offset.saturating_sub(old_offset) as i32),
122                }
123            }
124            KeyCode::PageUp => {
125                let old_offset = self.scroll.scroll_offset;
126                self.scroll.scroll_up(self.scroll.viewport_height);
127                MarkdownEvent::Scrolled {
128                    offset: self.scroll.scroll_offset,
129                    direction: -(old_offset.saturating_sub(self.scroll.scroll_offset) as i32),
130                }
131            }
132            KeyCode::Home => {
133                self.scroll.scroll_to_top();
134                MarkdownEvent::FocusedLine {
135                    line: self.scroll.current_line,
136                }
137            }
138            KeyCode::End | KeyCode::Char('G') => {
139                self.scroll.scroll_to_bottom();
140                MarkdownEvent::FocusedLine {
141                    line: self.scroll.current_line,
142                }
143            }
144            _ => MarkdownEvent::None,
145        }
146    }
147
148    /// Handle a keyboard event in filter mode.
149    ///
150    /// This method handles:
151    /// - `Esc`: Exit filter mode and keep filter text
152    /// - `Enter`: Exit filter mode, clear filter, and jump to line
153    /// - `Backspace`: Remove last character from filter
154    /// - `Char(c)`: Add character to filter
155    /// - `j` / `Down` / `Ctrl+n`: Move to next filtered line
156    /// - `k` / `Up` / `Ctrl+p`: Move to previous filtered line
157    fn handle_filter_key(&mut self, key: KeyEvent) -> MarkdownEvent {
158        match key.code {
159            KeyCode::Esc => {
160                let focused_line = self.scroll.current_line;
161                // Clear filter and exit filter mode
162                self.filter_mode = false;
163                self.filter = None;
164                self.mode = MarkdownWidgetMode::Normal;
165                // Sync to ScrollState
166                self.scroll.filter_mode = false;
167                self.scroll.filter = None;
168                // Clear render cache so all content is shown again
169                self.cache.render = None;
170                MarkdownEvent::FilterModeExited { line: focused_line }
171            }
172            KeyCode::Enter => {
173                let focused_line = self.scroll.current_line;
174                // Clear filter and exit filter mode
175                self.filter_mode = false;
176                self.filter = None;
177                self.mode = MarkdownWidgetMode::Normal;
178                // Sync to ScrollState
179                self.scroll.filter_mode = false;
180                self.scroll.filter = None;
181                // Clear render cache so all content is shown again
182                self.cache.render = None;
183                MarkdownEvent::FilterModeExited { line: focused_line }
184            }
185            KeyCode::Backspace => {
186                if let Some(filter) = &mut self.filter {
187                    filter.pop();
188                    return MarkdownEvent::FilterModeChanged {
189                        active: true,
190                        filter: filter.clone(),
191                    };
192                }
193                MarkdownEvent::None
194            }
195            KeyCode::Char('j') | KeyCode::Down => {
196                let filter = self.filter.clone().unwrap_or_default();
197                let next_line = self.find_next_filter_match(filter);
198                if let Some(line) = next_line {
199                    self.scroll.current_line = line;
200                }
201                MarkdownEvent::FocusedLine {
202                    line: self.scroll.current_line,
203                }
204            }
205            KeyCode::Char('k') | KeyCode::Up => {
206                let filter = self.filter.clone().unwrap_or_default();
207                let prev_line = self.find_prev_filter_match(filter);
208                if let Some(line) = prev_line {
209                    self.scroll.current_line = line;
210                }
211                MarkdownEvent::FocusedLine {
212                    line: self.scroll.current_line,
213                }
214            }
215            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
216                let filter = self.filter.clone().unwrap_or_default();
217                let next_line = self.find_next_filter_match(filter);
218                if let Some(line) = next_line {
219                    self.scroll.current_line = line;
220                }
221                MarkdownEvent::FocusedLine {
222                    line: self.scroll.current_line,
223                }
224            }
225            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
226                let filter = self.filter.clone().unwrap_or_default();
227                let prev_line = self.find_prev_filter_match(filter);
228                if let Some(line) = prev_line {
229                    self.scroll.current_line = line;
230                }
231                MarkdownEvent::FocusedLine {
232                    line: self.scroll.current_line,
233                }
234            }
235            KeyCode::Char(c) => {
236                if let Some(filter) = &mut self.filter {
237                    filter.push(c);
238                    return MarkdownEvent::FilterModeChanged {
239                        active: true,
240                        filter: filter.clone(),
241                    };
242                }
243                MarkdownEvent::None
244            }
245            _ => MarkdownEvent::None,
246        }
247    }
248
249    /// Find the next line that matches the filter text (by original line number).
250    fn find_next_filter_match(&self, filter: String) -> Option<usize> {
251        if filter.is_empty() {
252            return None;
253        }
254        let filter_lower = filter.to_lowercase();
255        let elements =
256            crate::widgets::markdown_widget::foundation::parser::render_markdown_to_elements(
257                self.content,
258                true,
259            );
260        let current = self.scroll.current_line;
261
262        for (idx, element) in elements.iter().enumerate() {
263            let line_num = idx + 1;
264            if line_num <= current {
265                continue;
266            }
267            if !crate::widgets::markdown_widget::extensions::selection::should_render_line(
268                element,
269                idx,
270                self.collapse,
271            ) {
272                continue;
273            }
274            let text = super::super::helpers::element_to_plain_text_for_filter(&element.kind)
275                .to_lowercase();
276            if text.contains(&filter_lower) {
277                return Some(line_num);
278            }
279        }
280        None
281    }
282
283    /// Find the previous line that matches the filter text (by original line number).
284    fn find_prev_filter_match(&self, filter: String) -> Option<usize> {
285        if filter.is_empty() {
286            return None;
287        }
288        let filter_lower = filter.to_lowercase();
289        let elements =
290            crate::widgets::markdown_widget::foundation::parser::render_markdown_to_elements(
291                self.content,
292                true,
293            );
294        let current = self.scroll.current_line;
295
296        for (idx, element) in elements.iter().enumerate().rev() {
297            let line_num = idx + 1;
298            if line_num >= current {
299                continue;
300            }
301            if !crate::widgets::markdown_widget::extensions::selection::should_render_line(
302                element,
303                idx,
304                self.collapse,
305            ) {
306                continue;
307            }
308            let text = super::super::helpers::element_to_plain_text_for_filter(&element.kind)
309                .to_lowercase();
310            if text.contains(&filter_lower) {
311                return Some(line_num);
312            }
313        }
314        None
315    }
316}