Skip to main content

md_tui/
event_handler.rs

1use std::{cmp, fs::read_to_string};
2
3use crossterm::event::KeyCode;
4use notify::{PollWatcher, Watcher};
5
6use crate::{
7    nodes::{root::ComponentRoot, word::WordType},
8    pages::file_explorer::FileTree,
9    parser::parse_markdown,
10    util::{
11        App, Boxes, Jump, LinkType, Mode,
12        general::GENERAL_CONFIG,
13        keys::{Action, key_to_action},
14    },
15};
16
17pub enum KeyBoardAction {
18    Continue,
19    Edit,
20    Exit,
21}
22
23pub fn handle_keyboard_input(
24    key: KeyCode,
25    app: &mut App,
26    markdown: &mut ComponentRoot,
27    file_tree: &mut FileTree,
28    height: u16,
29    watcher: &mut PollWatcher,
30) -> KeyBoardAction {
31    if key == KeyCode::Char('q') && app.boxes != Boxes::Search {
32        return KeyBoardAction::Exit;
33    }
34    match app.mode {
35        Mode::FileTree => keyboard_mode_file_tree(key, app, markdown, file_tree, height, watcher),
36        Mode::View => keyboard_mode_view(key, app, markdown, height, watcher),
37    }
38}
39
40pub fn keyboard_mode_file_tree(
41    key: KeyCode,
42    app: &mut App,
43    markdown: &mut ComponentRoot,
44    file_tree: &mut FileTree,
45    height: u16,
46    watcher: &mut PollWatcher,
47) -> KeyBoardAction {
48    match app.boxes {
49        Boxes::Error => match key {
50            KeyCode::Enter | KeyCode::Esc => {
51                app.boxes = Boxes::None;
52            }
53            _ => {}
54        },
55        Boxes::Search => match key {
56            KeyCode::Esc => {
57                app.search_box.clear();
58                file_tree.search(None);
59                app.boxes = Boxes::None;
60            }
61            KeyCode::Enter => {
62                let query = app.search_box.consume();
63                file_tree.search(Some(&query));
64                app.boxes = Boxes::None;
65            }
66
67            KeyCode::Char(c) => {
68                app.search_box.insert(c);
69                file_tree.search(app.search_box.content());
70                let file_height = file_tree.height(height);
71                app.search_box.set_position(10, file_height as u16 + 2);
72            }
73
74            KeyCode::Backspace => {
75                if app.search_box.content().is_none() {
76                    app.boxes = Boxes::None;
77                }
78                app.search_box.delete();
79                file_tree.search(app.search_box.content());
80                let file_height = file_tree.height(height);
81                app.search_box.set_position(10, file_height as u16 + 2);
82            }
83            _ => {}
84        },
85        Boxes::None => match key_to_action(key) {
86            Action::Down => {
87                file_tree.next(height);
88            }
89
90            Action::Up => {
91                file_tree.previous(height);
92            }
93
94            Action::PageDown => {
95                file_tree.next_page(height);
96            }
97
98            Action::PageUp => {
99                file_tree.previous_page(height);
100            }
101
102            Action::ToTop => {
103                file_tree.first();
104            }
105
106            Action::ToBottom => {
107                file_tree.last(height);
108            }
109
110            Action::Enter => {
111                let file = if let Some(file) = file_tree.selected() {
112                    file
113                } else {
114                    app.message_box.set_message("No file selected".to_string());
115                    app.boxes = Boxes::Error;
116                    return KeyBoardAction::Continue;
117                };
118                let text = if let Ok(file) = read_to_string(file.path_str()) {
119                    app.reset();
120                    file
121                } else {
122                    app.message_box
123                        .set_message(format!("Could not open file {}", file.path_str()));
124                    app.boxes = Boxes::Error;
125                    return KeyBoardAction::Continue;
126                };
127
128                *markdown = parse_markdown(Some(file.path_str()), &text, app.width() - 2);
129                let _ = watcher.watch(file.path(), notify::RecursiveMode::NonRecursive);
130                app.mode = Mode::View;
131                app.help_box.set_mode(Mode::View);
132                app.select_index = 0;
133            }
134            Action::Search => {
135                let file_height = file_tree.height(height);
136                app.search_box.set_position(10, file_height as u16 + 2);
137                app.search_box.set_width(20);
138                app.boxes = Boxes::Search;
139                app.help_box.close();
140            }
141
142            Action::Back => match app.history.pop() {
143                Jump::File(e) => {
144                    let text = if let Ok(file) = read_to_string(&e) {
145                        app.vertical_scroll = 0;
146                        file
147                    } else {
148                        app.message_box
149                            .set_message(format!("Could not open file {e}"));
150                        app.boxes = Boxes::Error;
151                        return KeyBoardAction::Continue;
152                    };
153                    *markdown = parse_markdown(Some(&e), &text, app.width() - 2);
154                    let path = std::path::Path::new(&e);
155                    let _ = watcher.watch(path, notify::RecursiveMode::NonRecursive);
156                    app.reset();
157                    app.mode = Mode::View;
158                    app.help_box.set_mode(Mode::View);
159                }
160                Jump::FileTree => {
161                    markdown.clear();
162                    app.mode = Mode::FileTree;
163                    app.help_box.set_mode(Mode::FileTree);
164                }
165            },
166            Action::Help if GENERAL_CONFIG.help_menu => {
167                app.help_box.toggle();
168            }
169            Action::Help => {}
170
171            Action::Escape => {
172                file_tree.unselect();
173                file_tree.search(None);
174            }
175
176            Action::Sort => {
177                file_tree.sort_name();
178            }
179            _ => {}
180        },
181        Boxes::LinkPreview => {
182            if key == KeyCode::Esc {
183                app.boxes = Boxes::None;
184            }
185        }
186    }
187
188    KeyBoardAction::Continue
189}
190
191fn keyboard_mode_view(
192    key: KeyCode,
193    app: &mut App,
194    markdown: &mut ComponentRoot,
195    height: u16,
196    watcher: &mut PollWatcher,
197) -> KeyBoardAction {
198    match app.boxes {
199        Boxes::Error => match key {
200            KeyCode::Enter | KeyCode::Esc => {
201                app.boxes = Boxes::None;
202            }
203            _ => {}
204        },
205        Boxes::Search => match key {
206            KeyCode::Esc => {
207                app.search_box.clear();
208                app.boxes = Boxes::None;
209            }
210            KeyCode::Enter => {
211                let query = app.search_box.content_str();
212
213                markdown.deselect();
214
215                markdown.find_and_mark(query);
216
217                let heights = markdown.search_results_heights();
218
219                if heights.is_empty() {
220                    app.message_box
221                        .set_message(format!("No results found for\n {query}"));
222                    app.boxes = Boxes::Error;
223                    return KeyBoardAction::Continue;
224                }
225
226                let next = heights
227                    .iter()
228                    .find(|row| **row >= (app.vertical_scroll as usize + height as usize / 2));
229
230                if let Some(index) = next {
231                    app.vertical_scroll = cmp::min(
232                        (*index as u16).saturating_sub(height / 2),
233                        markdown.height().saturating_sub(height / 2),
234                    );
235                }
236
237                app.boxes = Boxes::None;
238            }
239            KeyCode::Char(c) => {
240                app.search_box.insert(c);
241            }
242            KeyCode::Backspace => {
243                app.search_box.delete();
244            }
245            _ => {}
246        },
247        Boxes::None => match key_to_action(key) {
248            Action::Down => {
249                if app.selected {
250                    app.select_index = cmp::min(app.select_index + 1, markdown.num_links() - 1);
251                    app.vertical_scroll = if let Ok(scroll) = markdown.select(app.select_index) {
252                        app.selected = true;
253                        scroll.saturating_sub(height / 3)
254                    } else {
255                        app.vertical_scroll
256                    };
257                } else if app.details_selected {
258                    let max_idx = markdown.num_details().saturating_sub(1);
259                    app.details_select_index = cmp::min(app.details_select_index + 1, max_idx);
260                    app.vertical_scroll =
261                        if let Ok(scroll) = markdown.select_details(app.details_select_index) {
262                            app.details_selected = true;
263                            scroll.saturating_sub(height / 3)
264                        } else {
265                            app.vertical_scroll
266                        };
267                } else {
268                    app.vertical_scroll = cmp::min(
269                        app.vertical_scroll + 1,
270                        markdown.height().saturating_sub(height / 2),
271                    );
272                }
273            }
274            Action::Up => {
275                if app.selected {
276                    app.select_index = app.select_index.saturating_sub(1);
277                    app.vertical_scroll = if let Ok(scroll) = markdown.select(app.select_index) {
278                        app.selected = true;
279                        scroll.saturating_sub(height / 3)
280                    } else {
281                        app.vertical_scroll
282                    };
283                } else if app.details_selected {
284                    app.details_select_index = app.details_select_index.saturating_sub(1);
285                    app.vertical_scroll =
286                        if let Ok(scroll) = markdown.select_details(app.details_select_index) {
287                            app.details_selected = true;
288                            scroll.saturating_sub(height / 3)
289                        } else {
290                            app.vertical_scroll
291                        };
292                } else {
293                    app.vertical_scroll = app.vertical_scroll.saturating_sub(1);
294                }
295            }
296            Action::ToTop => {
297                app.vertical_scroll = 0;
298            }
299            Action::ToBottom => {
300                app.vertical_scroll = markdown.height().saturating_sub(height / 2);
301            }
302
303            Action::HalfPageDown => {
304                app.vertical_scroll += height / 2;
305                app.vertical_scroll = cmp::min(
306                    app.vertical_scroll,
307                    markdown.height().saturating_sub(height / 2),
308                );
309            }
310            Action::HalfPageUp => {
311                app.vertical_scroll = app.vertical_scroll.saturating_sub(height / 2);
312            }
313
314            Action::PageDown => {
315                app.vertical_scroll = cmp::min(
316                    app.vertical_scroll + height,
317                    markdown.height().saturating_sub(height / 2),
318                );
319            }
320
321            Action::PageUp => {
322                app.vertical_scroll = app.vertical_scroll.saturating_sub(height);
323            }
324
325            Action::Hover => {
326                if app.selected {
327                    let link = markdown.selected();
328
329                    let prev_type = markdown.selected_underlying_type();
330
331                    if prev_type == WordType::FootnoteInline {
332                        app.link_box
333                            .set_message(format!("Footnote: {}", markdown.find_footnote(link)));
334                        app.boxes = Boxes::LinkPreview;
335                        return KeyBoardAction::Continue;
336                    }
337
338                    let message = match LinkType::from(link) {
339                        LinkType::Internal(e) => format!("Internal link: {e}"),
340                        LinkType::External(e) => format!("External link: {e}"),
341                        LinkType::MarkdownFile(e) => format!("Markdown file: {e}"),
342                    };
343
344                    app.link_box.set_message(message);
345                    app.boxes = Boxes::LinkPreview;
346                } else {
347                    app.message_box.set_message("No link selected".to_string());
348                    app.boxes = Boxes::Error;
349                }
350            }
351
352            // Find the link closest to the middle, searching both ways
353            Action::SelectLinkAlt => {
354                let links = markdown.link_index_and_height();
355                if links.is_empty() {
356                    app.message_box.set_message("No links found".to_string());
357                    app.boxes = Boxes::Error;
358                    return KeyBoardAction::Continue;
359                }
360
361                let next = links
362                    .iter()
363                    .min_by_key(|(_, row)| (*row).abs_diff(app.vertical_scroll + height / 3));
364
365                if let Some((index, _)) = next {
366                    app.vertical_scroll = if let Ok(scroll) = markdown.select(*index) {
367                        app.select_index = *index;
368                        scroll.saturating_sub(height / 3)
369                    } else {
370                        app.vertical_scroll
371                    };
372                    app.selected = true;
373                    app.details_selected = false;
374                    markdown.deselect_details();
375                } else {
376                    // Something weird must have happened at this point
377                    markdown.deselect();
378                }
379            }
380
381            // Find the link closest to the to the top, searching downwards
382            Action::SelectLink => {
383                let mut links = markdown.link_index_and_height();
384                if links.is_empty() {
385                    app.message_box.set_message("No links found".to_string());
386                    app.boxes = Boxes::Error;
387                    return KeyBoardAction::Continue;
388                }
389
390                let mut index = usize::MAX;
391                while let Some(top) = links.pop() {
392                    if top.1 >= app.vertical_scroll || index == usize::MAX {
393                        index = top.0;
394                    } else {
395                        break;
396                    }
397                }
398
399                app.select_index = index;
400                app.selected = true;
401                app.details_selected = false;
402                markdown.deselect_details();
403                app.vertical_scroll = if let Ok(scroll) = markdown.select(app.select_index) {
404                    scroll.saturating_sub(height / 3)
405                } else {
406                    app.vertical_scroll
407                };
408            }
409
410            // Cycle to the details summary nearest (and at-or-below) the
411            // current scroll position. Mirrors `SelectLink` but for
412            // `<details>` blocks. Mutually exclusive with link selection.
413            Action::SelectDetails => {
414                let details = markdown.details_index_and_height();
415                if details.is_empty() {
416                    app.message_box
417                        .set_message("No details blocks found".to_string());
418                    app.boxes = Boxes::Error;
419                    return KeyBoardAction::Continue;
420                }
421
422                // Clear any link selection first — the two modes are
423                // mutually exclusive.
424                app.selected = false;
425                markdown.deselect();
426
427                let next_idx = if app.details_selected {
428                    // Already in details mode — advance to the next.
429                    cmp::min(app.details_select_index + 1, details.len() - 1)
430                } else {
431                    // Pick the first summary at or below the current
432                    // scroll position, else the last one above.
433                    details
434                        .iter()
435                        .find(|(_, y)| *y >= app.vertical_scroll)
436                        .map(|(i, _)| *i)
437                        .unwrap_or(details.last().map(|(i, _)| *i).unwrap_or(0))
438                };
439
440                app.details_select_index = next_idx;
441                app.details_selected = true;
442                app.vertical_scroll = if let Ok(scroll) = markdown.select_details(next_idx) {
443                    scroll.saturating_sub(height / 3)
444                } else {
445                    app.vertical_scroll
446                };
447            }
448
449            Action::Search => {
450                app.search_box.clear();
451                app.search_box.set_position(2, height - 3);
452                app.search_box.set_width(GENERAL_CONFIG.width - 3);
453                app.boxes = Boxes::Search;
454                app.help_box.close();
455            }
456
457            Action::ToFileTree => {
458                app.mode = Mode::FileTree;
459                app.help_box.set_mode(Mode::FileTree);
460                if let Some(file) = markdown.file_name() {
461                    app.history.push(Jump::File(file.to_string()));
462                }
463                app.reset();
464            }
465
466            Action::SearchNext => {
467                let heights = markdown.search_results_heights();
468
469                let next = heights
470                    .iter()
471                    .find(|row| **row > (app.vertical_scroll as usize + height as usize / 2));
472
473                if let Some(index) = next {
474                    app.vertical_scroll = cmp::min(
475                        (*index as u16).saturating_sub(height / 2),
476                        markdown.height().saturating_sub(height / 2),
477                    );
478                }
479            }
480
481            Action::SearchPrevious => {
482                let heights = markdown.search_results_heights();
483
484                let next = heights
485                    .iter()
486                    .rev()
487                    .find(|row| **row < (app.vertical_scroll as usize + height as usize / 2));
488
489                if let Some(index) = next {
490                    app.vertical_scroll = cmp::min(
491                        (*index as u16).saturating_sub(height / 2),
492                        markdown.height().saturating_sub(height / 2),
493                    );
494                }
495            }
496
497            Action::Edit => return KeyBoardAction::Edit,
498
499            Action::Escape => {
500                app.selected = false;
501                markdown.deselect();
502                app.details_selected = false;
503                markdown.deselect_details();
504            }
505
506            Action::Enter => {
507                // A focused `<details>` summary toggles its fold state
508                // and stays in selection mode so the user can chain
509                // multiple toggles without re-pressing `D`.
510                if app.details_selected {
511                    if markdown.toggle_selected_details().is_ok() {
512                        markdown.set_scroll(app.vertical_scroll);
513                    }
514                    return KeyBoardAction::Continue;
515                }
516
517                if !app.selected {
518                    return KeyBoardAction::Continue;
519                }
520                let link = markdown.selected();
521                let prev_type = markdown.selected_underlying_type();
522
523                if prev_type == WordType::FootnoteInline {
524                    app.message_box.set_message(markdown.find_footnote(link));
525                    app.boxes = Boxes::Error;
526                    markdown.deselect();
527                    app.selected = false;
528                    return KeyBoardAction::Continue;
529                }
530
531                match LinkType::from(link) {
532                    LinkType::Internal(heading) => {
533                        app.vertical_scroll = if let Ok(index) = markdown.heading_offset(heading) {
534                            cmp::min(index, markdown.height().saturating_sub(height / 2))
535                        } else {
536                            app.message_box
537                                .set_message(format!("Could not find heading {heading}"));
538                            app.boxes = Boxes::Error;
539                            markdown.deselect();
540                            return KeyBoardAction::Continue;
541                        };
542                    }
543                    LinkType::External(url) => {
544                        let _ = open::that(url);
545                    }
546                    LinkType::MarkdownFile(url) => {
547                        // Remove the first character, which is a '/'
548                        let url = if let Some(url) = url.strip_prefix('/') {
549                            url
550                        } else {
551                            url
552                        };
553
554                        let (url, heading) = if let Some((url, heading)) = url.split_once('#') {
555                            (url.to_string(), Some(heading.to_string().to_lowercase()))
556                        } else {
557                            (url.to_string(), None)
558                        };
559
560                        let url = if url.ends_with(".md") {
561                            url
562                        } else {
563                            format!("{url}.md")
564                        };
565
566                        let text = if let Ok(file) = read_to_string(&url) {
567                            app.vertical_scroll = 0;
568                            file
569                        } else {
570                            app.message_box
571                                .set_message(format!("Could not open file {url}"));
572                            app.boxes = Boxes::Error;
573                            return KeyBoardAction::Continue;
574                        };
575
576                        if let Some(file_name) = markdown.file_name() {
577                            app.history.push(Jump::File(file_name.to_string()));
578                        }
579
580                        let path = std::path::Path::new(&url);
581                        let _ = watcher.watch(path, notify::RecursiveMode::NonRecursive);
582                        *markdown = parse_markdown(Some(&url), &text, app.width() - 2);
583                        let index = if let Some(heading) = heading {
584                            if let Ok(index) = markdown.heading_offset(&format!("#{heading}")) {
585                                cmp::min(index, markdown.height().saturating_sub(height / 2))
586                            } else {
587                                app.message_box
588                                    .set_message(format!("Could not find heading {heading}"));
589                                app.boxes = Boxes::Error;
590                                0
591                            }
592                        } else {
593                            0
594                        };
595
596                        app.reset();
597                        app.vertical_scroll = index;
598                    }
599                }
600                markdown.deselect();
601                app.selected = false;
602            }
603
604            Action::Back => match app.history.pop() {
605                Jump::File(e) => {
606                    let text = if let Ok(file) = read_to_string(&e) {
607                        app.vertical_scroll = 0;
608                        file
609                    } else {
610                        app.message_box
611                            .set_message(format!("Could not open file {e}"));
612                        app.boxes = Boxes::Error;
613                        return KeyBoardAction::Continue;
614                    };
615                    *markdown = parse_markdown(Some(&e), &text, app.width() - 2);
616                    let path = std::path::Path::new(&e);
617                    let _ = watcher.watch(path, notify::RecursiveMode::NonRecursive);
618                    app.reset();
619                    app.mode = Mode::View;
620                    app.help_box.set_mode(Mode::View);
621                }
622                Jump::FileTree => {
623                    markdown.clear();
624                    app.mode = Mode::FileTree;
625                    app.help_box.set_mode(Mode::FileTree);
626                }
627            },
628
629            Action::Help if GENERAL_CONFIG.help_menu => {
630                app.help_box.toggle();
631            }
632            _ => {}
633        },
634        Boxes::LinkPreview => {
635            if key == KeyCode::Esc {
636                app.boxes = Boxes::None;
637            }
638        }
639    }
640    KeyBoardAction::Continue
641}