mdfried 0.20.3

A markdown viewer for the terminal that renders images and big headers
use std::{
    num::{NonZero, NonZeroU16},
    time::Duration,
};

use ratatui::{
    crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind},
    layout::Size,
};

use crate::{
    Error,
    cursor::{Cursor, CursorPointer},
    document::{LineExtra, SectionContent},
    model::{CursorPositioning, InputQueue, Model},
};

pub enum PollResult {
    None,
    HadInput,
    /// Input was handled but don't render yet (e.g., reload triggered, wait for events)
    SkipRender,
    Quit,
}

pub fn poll(had_events: bool, model: &mut Model) -> Result<PollResult, Error> {
    if event::poll(if had_events {
        Duration::ZERO
    } else {
        Duration::from_millis(100)
    })? {
        match event::read()? {
            event::Event::Key(key) => {
                if key.kind == KeyEventKind::Press {
                    return match_keycode(key, model);
                }
            }
            event::Event::Resize(new_width, new_height) => {
                log::debug!("Resize {new_width},{new_height}");
                if model.screen_size.width != new_width || model.screen_size.height != new_height {
                    let screen_size = Size::new(new_width, new_height);
                    model.reload(screen_size)?;
                    return Ok(PollResult::SkipRender);
                }
                return Ok(PollResult::None);
            }
            event::Event::Mouse(mouse) => match mouse.kind {
                MouseEventKind::ScrollUp => {
                    model.scroll_by(-2);
                    return Ok(PollResult::HadInput);
                }
                MouseEventKind::ScrollDown => {
                    model.scroll_by(2);
                    return Ok(PollResult::HadInput);
                }
                _ => {}
            },
            _ => {}
        }
    }
    Ok(PollResult::None)
}

fn match_keycode(key: KeyEvent, model: &mut Model) -> Result<PollResult, Error> {
    let page_scroll_count = model.inner_height() as i32 - 1;

    match key.code {
        // Search-input mode captures any `KeyCode::Char(_)`.
        KeyCode::Char(c) if matches!(model.input_queue, InputQueue::Search(_)) => {
            let InputQueue::Search(needle) = &mut model.input_queue else {
                panic!("invariant InputQueue::Search");
            };
            needle.push(c);
            let clone = needle.clone();
            model.add_searches(Some(&clone));
            model.cursor = Cursor::Search(clone, None);
        }
        // Command-input mode captures any `KeyCode::Char(_)`.
        KeyCode::Char(c) if matches!(model.input_queue, InputQueue::Command(_)) => {
            let InputQueue::Command(command) = &mut model.input_queue else {
                panic!("invariant InputQueue::Command");
            };
            command.push(c);
        }
        // Digits start a movement-count.
        KeyCode::Char(x)
            if x.is_ascii_digit() && (model.input_queue != InputQueue::None || x != '0') =>
        {
            let x = x as u16 - '0' as u16;
            match &mut model.input_queue {
                InputQueue::None => {
                    model.input_queue =
                        InputQueue::MovementCount(NonZero::new(x).expect("is_ascii_digit"));
                }
                InputQueue::MovementCount(count) => {
                    *count = count
                        .saturating_mul(NonZero::new(10).expect("10 != 0"))
                        .saturating_add(x);
                }
                InputQueue::CursorPositioningCommands => {
                    model.input_queue = InputQueue::None;
                }
                InputQueue::Search(_) | InputQueue::Command(_) => {
                    panic!("invariant is_ascii_digit while in invalid InputQueue mode");
                }
            }
        }
        // z starts "cursor positioning commands", like vim.
        KeyCode::Char(x)
            if x == 'z'
                && model.input_queue == InputQueue::None
                && model.cursor != Cursor::None =>
        {
            model.input_queue = InputQueue::CursorPositioningCommands;
        }
        KeyCode::Char(x)
            if (x == 'z' || x == 't' || x == 'b')
                && model.input_queue == InputQueue::CursorPositioningCommands =>
        {
            model.position_cursor(CursorPositioning::from(x));
            model.input_queue = InputQueue::None;
        }
        // Ways to quit
        KeyCode::Char('q') => {
            return Ok(PollResult::Quit);
        }
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
            return Ok(PollResult::Quit);
        }
        // Movements
        KeyCode::Char('j') | KeyCode::Down => {
            let count = model.input_queue.take_count_or_unit_i32();
            if !model.scroll_by(count) {
                return Ok(PollResult::None);
            }
        }
        KeyCode::Char('k') | KeyCode::Up => {
            let count = model.input_queue.take_count_or_unit_i32();
            if !model.scroll_by(-count) {
                return Ok(PollResult::None);
            }
        }
        KeyCode::Char('d') => {
            let count = model.input_queue.take_count_or_unit_i32();
            if !model.scroll_by(((page_scroll_count + 1) / 2).saturating_mul(count)) {
                return Ok(PollResult::None);
            }
        }
        KeyCode::Char('u') => {
            let count = model.input_queue.take_count_or_unit_i32();
            if !model.scroll_by((-(page_scroll_count + 1) / 2).saturating_mul(count)) {
                return Ok(PollResult::None);
            }
        }
        KeyCode::Char('f' | ' ') | KeyCode::PageDown => {
            let count = model.input_queue.take_count_or_unit_i32();
            if !model.scroll_by(page_scroll_count.saturating_mul(count)) {
                return Ok(PollResult::None);
            }
        }
        KeyCode::Char('b') | KeyCode::PageUp => {
            let count = model.input_queue.take_count_or_unit_i32();
            if !model.scroll_by((-page_scroll_count).saturating_mul(count)) {
                return Ok(PollResult::None);
            }
        }
        KeyCode::Char('g') => {
            let scroll = if let InputQueue::MovementCount(count) = model.input_queue {
                model.input_queue = InputQueue::None;
                count.get()
            } else {
                0
            };
            if scroll == model.scroll {
                return Ok(PollResult::None);
            }
            model.scroll = scroll;
        }
        KeyCode::Char('G') => {
            let scroll = if let InputQueue::MovementCount(count) = model.input_queue {
                model.input_queue = InputQueue::None;
                count.get()
            } else {
                model.total_lines().saturating_sub(
                    page_scroll_count as u16 + 1, // Why +1?
                )
            };
            if scroll == model.scroll {
                return Ok(PollResult::None);
            }
            model.scroll = scroll;
        }
        // Cursor movements
        KeyCode::Char('n') => {
            let count = model.input_queue.take_count_or_unit_u16();
            model.cursor_next(count);
        }
        KeyCode::Char('N') => {
            let count = model.input_queue.take_count_or_unit_u16();
            model.cursor_prev(count);
        }
        // Others
        KeyCode::Char('r') => {
            model.reload(model.screen_size)?;
            model.input_queue = InputQueue::None;
            return Ok(PollResult::SkipRender);
        }
        KeyCode::Char('/') => {
            model.input_queue = InputQueue::Search(String::new());
            model.cursor = Cursor::Search(String::new(), None);
        }
        KeyCode::Char(':') => {
            model.input_queue = InputQueue::Command(String::new());
        }
        KeyCode::Enter if matches!(model.input_queue, InputQueue::Search(_)) => {
            // Exit search...
            model.input_queue = InputQueue::None;
            // ...and jump to first match.
            model.cursor_next(1);
        }
        KeyCode::Enter if matches!(model.input_queue, InputQueue::Command(_)) => {
            let InputQueue::Command(command) =
                std::mem::replace(&mut model.input_queue, InputQueue::None)
            else {
                panic!("invariant InputQueue::Command");
            };

            if let Err(err) = model.user_command_str(command) {
                // Once we display error messages, let this return and be catched higher up.
                log::error!("{err}");
            }
        }
        KeyCode::Enter => {
            // Open links with xdg-open
            if let Cursor::Links(CursorPointer { id, index }) = model.cursor {
                let url = model.sections().find_map(|section| {
                    if section.id == id {
                        let SectionContent::Lines(lines) = &section.content else {
                            return None;
                        };
                        let mut remaining = index;
                        for (_, extras) in lines {
                            if remaining < extras.len() {
                                return match &extras[remaining] {
                                    LineExtra::Link { source: url, .. } => Some(url.clone()),
                                    _ => None,
                                };
                            }
                            remaining -= extras.len();
                        }
                        None
                    } else {
                        None
                    }
                });
                if let Some(url) = url {
                    log::debug!("open link_cursor {}", *url);
                    if let Err(err) = model.open_link(url.to_string()) {
                        log::error!("open_link failed: {err}");
                    }
                }
            }
        }
        KeyCode::Esc if model.is_help_screen()? => {
            model.history_pop()?;
        }
        KeyCode::Esc => match model.input_queue {
            InputQueue::None => {
                // This is not vim-canon: Esc is the equivalent of `:noh`. This works because we
                // don't have any real "modes".
                match &model.cursor {
                    Cursor::Search(_, _) | Cursor::Links(_) => {
                        model.cursor = Cursor::None;
                    }
                    _ => {}
                }
            }
            InputQueue::Search(_) => {
                // Abort search input.
                model.input_queue = InputQueue::None;
                model.cursor = Cursor::None;
            }
            InputQueue::MovementCount(_)
            | InputQueue::CursorPositioningCommands
            | InputQueue::Command(_) => {
                model.input_queue = InputQueue::None;
            }
        },
        KeyCode::Backspace => match &mut model.input_queue {
            // Edit input queue.
            InputQueue::None | InputQueue::CursorPositioningCommands => {}
            InputQueue::MovementCount(count) => {
                let value = count.get();
                if value > 10 {
                    *count = NonZeroU16::new(count.get() / 10).expect("checked >10");
                }
            }
            InputQueue::Search(needle) => {
                if needle.is_empty() {
                    model.input_queue = InputQueue::None;
                    model.cursor = Cursor::None;
                } else {
                    needle.pop();
                    let clone = needle.clone();
                    model.add_searches(Some(&clone));
                }
            }
            InputQueue::Command(command) => {
                if command.is_empty() {
                    model.input_queue = InputQueue::None;
                } else {
                    command.pop();
                }
            }
        },
        _ => {
            return Ok(PollResult::None);
        }
    }
    Ok(PollResult::HadInput)
}