fmtview 0.3.5

Fast CLI viewer for highlighting, search, and diffs across JSON, JSONL, markup, Markdown, TOML, text, and Jinja
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},
    structure::{StructureDirection, start_structure_navigation},
};

pub(in crate::viewer) fn drain_events(
    state: &mut ViewState,
    line_count: usize,
    line_count_exact: bool,
    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_with_count(event, state, line_count, line_count_exact, 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)
}

#[cfg(test)]
pub(in crate::viewer) fn handle_event(
    event: Event,
    state: &mut ViewState,
    line_count: usize,
    page: usize,
) -> EventAction {
    handle_event_with_count(event, state, line_count, true, page)
}

pub(in crate::viewer) fn handle_event_with_count(
    event: Event,
    state: &mut ViewState,
    line_count: usize,
    line_count_exact: bool,
    page: usize,
) -> EventAction {
    match event {
        Event::Key(key) if key.kind == KeyEventKind::Release => EventAction::default(),
        Event::Key(key) => handle_key_event_with_count(
            key.code,
            key.modifiers,
            state,
            line_count,
            line_count_exact,
            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,
            mouse_capture: None,
        },
        _ => EventAction::default(),
    }
}

#[cfg(test)]
pub(in crate::viewer) fn handle_key_event(
    code: KeyCode,
    modifiers: KeyModifiers,
    state: &mut ViewState,
    line_count: usize,
    page: usize,
) -> EventAction {
    handle_key_event_with_count(code, modifiers, state, line_count, true, page)
}

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

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

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

    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::Char(']') if plain_key(modifiers) => start_structure_navigation(
            state,
            line_count,
            line_count_exact,
            StructureDirection::Forward,
        ),
        KeyCode::Char('[') if plain_key(modifiers) => start_structure_navigation(
            state,
            line_count,
            line_count_exact,
            StructureDirection::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,
                mouse_capture: None,
            };
        }
        KeyCode::Char('m') if plain_key(modifiers) => {
            state.mouse_capture = !state.mouse_capture;
            return EventAction {
                dirty: true,
                quit: false,
                mouse_capture: Some(state.mouse_capture),
            };
        }
        KeyCode::Char('w') => {
            state.wrap = !state.wrap;
            reset_top_row_offset(state);
            true
        }
        KeyCode::Down | KeyCode::Char('j') => {
            let dirty = scroll_down(state, line_count);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        KeyCode::Up | KeyCode::Char('k') => {
            let dirty = scroll_up(state, line_count);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        KeyCode::PageDown | KeyCode::Char(' ') | KeyCode::Char('f') => {
            let dirty = page_down(state, line_count, page);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        KeyCode::PageUp | KeyCode::Char('b') => {
            let dirty = page_up(state, line_count, page);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        KeyCode::Home | KeyCode::Char('g') => {
            let dirty = set_top(state, 0);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        KeyCode::End | KeyCode::Char('G') => {
            let dirty = set_file_end(state, line_count);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        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,
        mouse_capture: None,
    }
}

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 => {
            let dirty = scroll_down_by(state, line_count, MOUSE_SCROLL_LINES);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        MouseEventKind::ScrollUp => {
            let dirty = scroll_up_by(state, line_count, MOUSE_SCROLL_LINES);
            clear_structure_cursor_if_dirty(state, dirty)
        }
        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,
        mouse_capture: None,
    }
}

fn clear_structure_cursor_if_dirty(state: &mut ViewState, dirty: bool) -> bool {
    if dirty {
        state.structure_cursor = None;
        state.preserve_tail_on_next_draw = false;
    }
    dirty
}