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 => {
167                if GENERAL_CONFIG.help_menu {
168                    app.help_box.toggle();
169                }
170            }
171
172            Action::Escape => {
173                file_tree.unselect();
174                file_tree.search(None);
175            }
176
177            Action::Sort => {
178                file_tree.sort_name();
179            }
180            _ => {}
181        },
182        Boxes::LinkPreview => {
183            if key == KeyCode::Esc {
184                app.boxes = Boxes::None;
185            }
186        }
187    }
188
189    KeyBoardAction::Continue
190}
191
192fn keyboard_mode_view(
193    key: KeyCode,
194    app: &mut App,
195    markdown: &mut ComponentRoot,
196    height: u16,
197    watcher: &mut PollWatcher,
198) -> KeyBoardAction {
199    match app.boxes {
200        Boxes::Error => match key {
201            KeyCode::Enter | KeyCode::Esc => {
202                app.boxes = Boxes::None;
203            }
204            _ => {}
205        },
206        Boxes::Search => match key {
207            KeyCode::Esc => {
208                app.search_box.clear();
209                app.boxes = Boxes::None;
210            }
211            KeyCode::Enter => {
212                let query = app.search_box.content_str();
213
214                markdown.deselect();
215
216                markdown.find_and_mark(query);
217
218                let heights = markdown.search_results_heights();
219
220                if heights.is_empty() {
221                    app.message_box
222                        .set_message(format!("No results found for\n {query}"));
223                    app.boxes = Boxes::Error;
224                    return KeyBoardAction::Continue;
225                }
226
227                let next = heights
228                    .iter()
229                    .find(|row| **row >= (app.vertical_scroll as usize + height as usize / 2));
230
231                if let Some(index) = next {
232                    app.vertical_scroll = cmp::min(
233                        (*index as u16).saturating_sub(height / 2),
234                        markdown.height().saturating_sub(height / 2),
235                    );
236                }
237
238                app.boxes = Boxes::None;
239            }
240            KeyCode::Char(c) => {
241                app.search_box.insert(c);
242            }
243            KeyCode::Backspace => {
244                app.search_box.delete();
245            }
246            _ => {}
247        },
248        Boxes::None => match key_to_action(key) {
249            Action::Down => {
250                if app.selected {
251                    app.select_index = cmp::min(app.select_index + 1, markdown.num_links() - 1);
252                    app.vertical_scroll = if let Ok(scroll) = markdown.select(app.select_index) {
253                        app.selected = true;
254                        scroll.saturating_sub(height / 3)
255                    } else {
256                        app.vertical_scroll
257                    };
258                } else {
259                    app.vertical_scroll = cmp::min(
260                        app.vertical_scroll + 1,
261                        markdown.height().saturating_sub(height / 2),
262                    );
263                }
264            }
265            Action::Up => {
266                if app.selected {
267                    app.select_index = app.select_index.saturating_sub(1);
268                    app.vertical_scroll = if let Ok(scroll) = markdown.select(app.select_index) {
269                        app.selected = true;
270                        scroll.saturating_sub(height / 3)
271                    } else {
272                        app.vertical_scroll
273                    };
274                } else {
275                    app.vertical_scroll = app.vertical_scroll.saturating_sub(1);
276                }
277            }
278            Action::ToTop => {
279                app.vertical_scroll = 0;
280            }
281            Action::ToBottom => {
282                app.vertical_scroll = markdown.height().saturating_sub(height / 2);
283            }
284
285            Action::HalfPageDown => {
286                app.vertical_scroll += height / 2;
287                app.vertical_scroll = cmp::min(
288                    app.vertical_scroll,
289                    markdown.height().saturating_sub(height / 2),
290                );
291            }
292            Action::HalfPageUp => {
293                app.vertical_scroll = app.vertical_scroll.saturating_sub(height / 2);
294            }
295
296            Action::PageDown => {
297                app.vertical_scroll = cmp::min(
298                    app.vertical_scroll + height,
299                    markdown.height().saturating_sub(height / 2),
300                );
301            }
302
303            Action::PageUp => {
304                app.vertical_scroll = app.vertical_scroll.saturating_sub(height);
305            }
306
307            Action::Hover => {
308                if app.selected {
309                    let link = markdown.selected();
310
311                    let prev_type = markdown.selected_underlying_type();
312
313                    if prev_type == WordType::FootnoteInline {
314                        app.link_box
315                            .set_message(format!("Footnote: {}", markdown.find_footnote(link)));
316                        app.boxes = Boxes::LinkPreview;
317                        return KeyBoardAction::Continue;
318                    }
319
320                    let message = match LinkType::from(link) {
321                        LinkType::Internal(e) => format!("Internal link: {e}"),
322                        LinkType::External(e) => format!("External link: {e}"),
323                        LinkType::MarkdownFile(e) => format!("Markdown file: {e}"),
324                    };
325
326                    app.link_box.set_message(message);
327                    app.boxes = Boxes::LinkPreview;
328                } else {
329                    app.message_box.set_message("No link selected".to_string());
330                    app.boxes = Boxes::Error;
331                }
332            }
333
334            // Find the link closest to the middle, searching both ways
335            Action::SelectLinkAlt => {
336                let links = markdown.link_index_and_height();
337                if links.is_empty() {
338                    app.message_box.set_message("No links found".to_string());
339                    app.boxes = Boxes::Error;
340                    return KeyBoardAction::Continue;
341                }
342
343                let next = links
344                    .iter()
345                    .min_by_key(|(_, row)| (*row).abs_diff(app.vertical_scroll + height / 3));
346
347                if let Some((index, _)) = next {
348                    app.vertical_scroll = if let Ok(scroll) = markdown.select(*index) {
349                        app.select_index = *index;
350                        scroll.saturating_sub(height / 3)
351                    } else {
352                        app.vertical_scroll
353                    };
354                    app.selected = true;
355                } else {
356                    // Something weird must have happened at this point
357                    markdown.deselect();
358                }
359            }
360
361            // Find the link closest to the to the top, searching downwards
362            Action::SelectLink => {
363                let mut links = markdown.link_index_and_height();
364                if links.is_empty() {
365                    app.message_box.set_message("No links found".to_string());
366                    app.boxes = Boxes::Error;
367                    return KeyBoardAction::Continue;
368                }
369
370                let mut index = usize::MAX;
371                while let Some(top) = links.pop() {
372                    if top.1 >= app.vertical_scroll || index == usize::MAX {
373                        index = top.0;
374                    } else {
375                        break;
376                    }
377                }
378
379                app.select_index = index;
380                app.selected = true;
381                app.vertical_scroll = if let Ok(scroll) = markdown.select(app.select_index) {
382                    scroll.saturating_sub(height / 3)
383                } else {
384                    app.vertical_scroll
385                };
386            }
387
388            Action::Search => {
389                app.search_box.clear();
390                app.search_box.set_position(2, height - 3);
391                app.search_box.set_width(GENERAL_CONFIG.width - 3);
392                app.boxes = Boxes::Search;
393                app.help_box.close();
394            }
395
396            Action::ToFileTree => {
397                app.mode = Mode::FileTree;
398                app.help_box.set_mode(Mode::FileTree);
399                if let Some(file) = markdown.file_name() {
400                    app.history.push(Jump::File(file.to_string()));
401                }
402                app.reset();
403            }
404
405            Action::SearchNext => {
406                let heights = markdown.search_results_heights();
407
408                let next = heights
409                    .iter()
410                    .find(|row| **row > (app.vertical_scroll as usize + height as usize / 2));
411
412                if let Some(index) = next {
413                    app.vertical_scroll = cmp::min(
414                        (*index as u16).saturating_sub(height / 2),
415                        markdown.height().saturating_sub(height / 2),
416                    );
417                }
418            }
419
420            Action::SearchPrevious => {
421                let heights = markdown.search_results_heights();
422
423                let next = heights
424                    .iter()
425                    .rev()
426                    .find(|row| **row < (app.vertical_scroll as usize + height as usize / 2));
427
428                if let Some(index) = next {
429                    app.vertical_scroll = cmp::min(
430                        (*index as u16).saturating_sub(height / 2),
431                        markdown.height().saturating_sub(height / 2),
432                    );
433                }
434            }
435
436            Action::Edit => return KeyBoardAction::Edit,
437
438            Action::Escape => {
439                app.selected = false;
440                markdown.deselect();
441            }
442
443            Action::Enter => {
444                if !app.selected {
445                    return KeyBoardAction::Continue;
446                }
447                let link = markdown.selected();
448                let prev_type = markdown.selected_underlying_type();
449
450                if prev_type == WordType::FootnoteInline {
451                    app.message_box.set_message(markdown.find_footnote(link));
452                    app.boxes = Boxes::Error;
453                    markdown.deselect();
454                    app.selected = false;
455                    return KeyBoardAction::Continue;
456                }
457
458                match LinkType::from(link) {
459                    LinkType::Internal(heading) => {
460                        app.vertical_scroll = if let Ok(index) = markdown.heading_offset(heading) {
461                            cmp::min(index, markdown.height().saturating_sub(height / 2))
462                        } else {
463                            app.message_box
464                                .set_message(format!("Could not find heading {heading}"));
465                            app.boxes = Boxes::Error;
466                            markdown.deselect();
467                            return KeyBoardAction::Continue;
468                        };
469                    }
470                    LinkType::External(url) => {
471                        let _ = open::that(url);
472                    }
473                    LinkType::MarkdownFile(url) => {
474                        // Remove the first character, which is a '/'
475                        let url = if let Some(url) = url.strip_prefix('/') {
476                            url
477                        } else {
478                            url
479                        };
480
481                        let (url, heading) = if let Some((url, heading)) = url.split_once('#') {
482                            (url.to_string(), Some(heading.to_string().to_lowercase()))
483                        } else {
484                            (url.to_string(), None)
485                        };
486
487                        let url = if url.ends_with(".md") {
488                            url
489                        } else {
490                            format!("{url}.md")
491                        };
492
493                        let text = if let Ok(file) = read_to_string(&url) {
494                            app.vertical_scroll = 0;
495                            file
496                        } else {
497                            app.message_box
498                                .set_message(format!("Could not open file {url}"));
499                            app.boxes = Boxes::Error;
500                            return KeyBoardAction::Continue;
501                        };
502
503                        if let Some(file_name) = markdown.file_name() {
504                            app.history.push(Jump::File(file_name.to_string()));
505                        }
506
507                        let path = std::path::Path::new(&url);
508                        let _ = watcher.watch(path, notify::RecursiveMode::NonRecursive);
509                        *markdown = parse_markdown(Some(&url), &text, app.width() - 2);
510                        let index = if let Some(heading) = heading {
511                            if let Ok(index) = markdown.heading_offset(&format!("#{heading}")) {
512                                cmp::min(index, markdown.height().saturating_sub(height / 2))
513                            } else {
514                                app.message_box
515                                    .set_message(format!("Could not find heading {heading}"));
516                                app.boxes = Boxes::Error;
517                                0
518                            }
519                        } else {
520                            0
521                        };
522
523                        app.reset();
524                        app.vertical_scroll = index;
525                    }
526                }
527                markdown.deselect();
528                app.selected = false;
529            }
530
531            Action::Back => match app.history.pop() {
532                Jump::File(e) => {
533                    let text = if let Ok(file) = read_to_string(&e) {
534                        app.vertical_scroll = 0;
535                        file
536                    } else {
537                        app.message_box
538                            .set_message(format!("Could not open file {e}"));
539                        app.boxes = Boxes::Error;
540                        return KeyBoardAction::Continue;
541                    };
542                    *markdown = parse_markdown(Some(&e), &text, app.width() - 2);
543                    let path = std::path::Path::new(&e);
544                    let _ = watcher.watch(path, notify::RecursiveMode::NonRecursive);
545                    app.reset();
546                    app.mode = Mode::View;
547                    app.help_box.set_mode(Mode::View);
548                }
549                Jump::FileTree => {
550                    markdown.clear();
551                    app.mode = Mode::FileTree;
552                    app.help_box.set_mode(Mode::FileTree);
553                }
554            },
555
556            Action::Help => {
557                if GENERAL_CONFIG.help_menu {
558                    app.help_box.toggle();
559                }
560            }
561            _ => {}
562        },
563        Boxes::LinkPreview => {
564            if key == KeyCode::Esc {
565                app.boxes = Boxes::None;
566            }
567        }
568    }
569    KeyBoardAction::Continue
570}