fmtview 0.2.1

Fast terminal formatter and viewer for JSON, JSONL, XML-compatible markup, and formatted diffs
Documentation
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};

use super::super::{
    EVENT_DRAIN_BUDGET, EVENT_DRAIN_LIMIT, MOUSE_HORIZONTAL_COLUMNS, MOUSE_SCROLL_LINES,
};
use super::{
    jump::{handle_jump_input_key, push_jump_digit},
    keys::{accepts_jump_digit, plain_key},
    scroll::{
        page_down, page_up, reset_top_row_offset, scroll_down, scroll_down_by, scroll_up,
        scroll_up_by, scroll_x_by, set_file_end, set_top,
    },
    search::{
        SearchDirection, cancel_search_task, clear_search_message, handle_search_input_key,
        start_repeat_search, start_search_prompt,
    },
    state::{EventAction, ViewState},
};

pub(in crate::viewer) fn drain_events(
    state: &mut ViewState,
    line_count: usize,
    page: usize,
) -> Result<EventAction> {
    let started = Instant::now();
    let mut action = EventAction::default();
    let mut processed = 0;

    loop {
        let event = event::read().context("failed to read terminal event")?;
        let next = handle_event(event, state, line_count, page);
        let needs_layout = next.dirty && state.wrap_bounds_stale;
        action.merge(next);
        processed += 1;

        if action.quit
            || needs_layout
            || processed >= EVENT_DRAIN_LIMIT
            || started.elapsed() >= EVENT_DRAIN_BUDGET
            || !event::poll(Duration::ZERO).context("failed to poll terminal event")?
        {
            break;
        }
    }

    Ok(action)
}

pub(in crate::viewer) fn handle_event(
    event: Event,
    state: &mut ViewState,
    line_count: usize,
    page: usize,
) -> EventAction {
    match event {
        Event::Key(key) if key.kind == KeyEventKind::Release => EventAction::default(),
        Event::Key(key) => handle_key_event(key.code, key.modifiers, state, line_count, page),
        Event::Mouse(mouse) if !state.has_active_prompt() => {
            handle_mouse_event(mouse.kind, mouse.modifiers, state, line_count)
        }
        Event::Mouse(_) => EventAction::default(),
        Event::Resize(_, _) => EventAction {
            dirty: true,
            quit: false,
        },
        _ => EventAction::default(),
    }
}

pub(in crate::viewer) fn handle_key_event(
    code: KeyCode,
    modifiers: KeyModifiers,
    state: &mut ViewState,
    line_count: usize,
    page: usize,
) -> EventAction {
    if matches!(code, KeyCode::Char('c')) && modifiers.contains(KeyModifiers::CONTROL) {
        return EventAction {
            dirty: false,
            quit: true,
        };
    }

    if state.search_active {
        return EventAction {
            dirty: handle_search_input_key(code, modifiers, state, line_count),
            quit: false,
        };
    }

    if !state.jump_buffer.is_empty() {
        return EventAction {
            dirty: handle_jump_input_key(code, modifiers, state, line_count),
            quit: false,
        };
    }

    let dirty = match code {
        KeyCode::Char(ch) if accepts_jump_digit(ch, modifiers) => {
            push_jump_digit(state, ch);
            true
        }
        KeyCode::Char('/') if plain_key(modifiers) => start_search_prompt(state),
        KeyCode::Char('n') if plain_key(modifiers) => {
            start_repeat_search(state, line_count, SearchDirection::Forward)
        }
        KeyCode::Char('N') if plain_key(modifiers) => {
            start_repeat_search(state, line_count, SearchDirection::Backward)
        }
        KeyCode::Enter => false,
        KeyCode::Esc if state.search_task.is_some() => cancel_search_task(state),
        KeyCode::Esc if state.search_message.is_some() => clear_search_message(state),
        KeyCode::Char('q') | KeyCode::Esc => {
            return EventAction {
                dirty: false,
                quit: true,
            };
        }
        KeyCode::Char('w') => {
            state.wrap = !state.wrap;
            reset_top_row_offset(state);
            true
        }
        KeyCode::Down | KeyCode::Char('j') => scroll_down(state, line_count),
        KeyCode::Up | KeyCode::Char('k') => scroll_up(state, line_count),
        KeyCode::PageDown | KeyCode::Char(' ') | KeyCode::Char('f') => {
            page_down(state, line_count, page)
        }
        KeyCode::PageUp | KeyCode::Char('b') => page_up(state, line_count, page),
        KeyCode::Home | KeyCode::Char('g') => set_top(state, 0),
        KeyCode::End | KeyCode::Char('G') => set_file_end(state, line_count),
        KeyCode::Right | KeyCode::Char('l') if !state.wrap => {
            scroll_x_by(&mut state.x, MOUSE_HORIZONTAL_COLUMNS as isize)
        }
        KeyCode::Left | KeyCode::Char('h') if !state.wrap => {
            scroll_x_by(&mut state.x, -(MOUSE_HORIZONTAL_COLUMNS as isize))
        }
        _ => false,
    };

    EventAction { dirty, quit: false }
}

pub(in crate::viewer) fn handle_mouse_event(
    kind: MouseEventKind,
    modifiers: KeyModifiers,
    state: &mut ViewState,
    line_count: usize,
) -> EventAction {
    let dirty = match kind {
        MouseEventKind::ScrollDown if modifiers.contains(KeyModifiers::SHIFT) && !state.wrap => {
            scroll_x_by(&mut state.x, MOUSE_HORIZONTAL_COLUMNS as isize)
        }
        MouseEventKind::ScrollUp if modifiers.contains(KeyModifiers::SHIFT) && !state.wrap => {
            scroll_x_by(&mut state.x, -(MOUSE_HORIZONTAL_COLUMNS as isize))
        }
        MouseEventKind::ScrollDown => scroll_down_by(state, line_count, MOUSE_SCROLL_LINES),
        MouseEventKind::ScrollUp => scroll_up_by(state, line_count, MOUSE_SCROLL_LINES),
        MouseEventKind::ScrollRight if !state.wrap => {
            scroll_x_by(&mut state.x, MOUSE_HORIZONTAL_COLUMNS as isize)
        }
        MouseEventKind::ScrollLeft if !state.wrap => {
            scroll_x_by(&mut state.x, -(MOUSE_HORIZONTAL_COLUMNS as isize))
        }
        _ => false,
    };

    EventAction { dirty, quit: false }
}