binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use crate::preview::types::LogPreview;
use crossterm::event::{KeyCode, KeyEvent};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LogViewerOutcome {
    None,
    ExitApp,
    FocusSearch,
}

pub(crate) enum LogViewerAction {
    Filter(FilterAction),
    Cursor(CursorAction),
    Column(ColumnAction),
    Modal(ModalAction),
    TogglePause,
    ToggleMark,
    Copy { raw: bool },
    ResetView,
    Exit,
}

pub(crate) enum FilterAction {
    StartEditing,
    StopEditing,
    Backspace,
    Insert(char),
}

pub(crate) enum CursorAction {
    ToNewest,
    ToOldest,
    Down(usize),
    Up(usize),
}

pub(crate) enum ColumnAction {
    MoveLeft,
    MoveRight,
    HideSelected,
    IsolateSelected,
    OpenPicker,
    Resize(i32),
}

pub(crate) enum ModalAction {
    Close,
    Apply,
    Down,
    Up,
    Toggle { advance: bool },
}

pub(crate) fn action_for_key(lp: &LogPreview, key: KeyEvent) -> Option<LogViewerAction> {
    if lp.filter_state.col_modal.is_some() {
        return modal_action_for_key(key).map(LogViewerAction::Modal);
    }

    if lp.filter_state.input_active {
        return filter_action_for_key(key).map(LogViewerAction::Filter);
    }

    match key.code {
        KeyCode::Char('/') => Some(LogViewerAction::Filter(FilterAction::StartEditing)),
        KeyCode::Char('r') => Some(LogViewerAction::ResetView),
        KeyCode::Char('G') => Some(LogViewerAction::Cursor(CursorAction::ToOldest)),
        KeyCode::Char('g') => Some(LogViewerAction::Cursor(CursorAction::ToNewest)),
        KeyCode::Char('j') | KeyCode::Down => Some(LogViewerAction::Cursor(CursorAction::Down(1))),
        KeyCode::Char('k') | KeyCode::Up => Some(LogViewerAction::Cursor(CursorAction::Up(1))),
        KeyCode::Char('d') => Some(LogViewerAction::Cursor(CursorAction::Down(20))),
        KeyCode::Char('u') => Some(LogViewerAction::Cursor(CursorAction::Up(20))),
        KeyCode::Char('h') | KeyCode::Left => Some(LogViewerAction::Column(ColumnAction::MoveLeft)),
        KeyCode::Char('l') | KeyCode::Right => {
            Some(LogViewerAction::Column(ColumnAction::MoveRight))
        }
        KeyCode::Char('H') => Some(LogViewerAction::Column(ColumnAction::HideSelected)),
        KeyCode::Char('o') => Some(LogViewerAction::Column(ColumnAction::IsolateSelected)),
        KeyCode::Char('a') => Some(LogViewerAction::Column(ColumnAction::OpenPicker)),
        KeyCode::Char('<') => Some(LogViewerAction::Column(ColumnAction::Resize(-5))),
        KeyCode::Char('>') => Some(LogViewerAction::Column(ColumnAction::Resize(5))),
        KeyCode::Char('p') => Some(LogViewerAction::TogglePause),
        KeyCode::Tab => Some(LogViewerAction::ToggleMark),
        KeyCode::Char('y') => Some(LogViewerAction::Copy { raw: false }),
        KeyCode::Char('Y') => Some(LogViewerAction::Copy { raw: true }),
        KeyCode::Esc | KeyCode::Char('q') => Some(LogViewerAction::Exit),
        _ => None,
    }
}

fn filter_action_for_key(key: KeyEvent) -> Option<FilterAction> {
    match key.code {
        KeyCode::Esc | KeyCode::Enter => Some(FilterAction::StopEditing),
        KeyCode::Backspace => Some(FilterAction::Backspace),
        KeyCode::Char(c) => Some(FilterAction::Insert(c)),
        _ => None,
    }
}

fn modal_action_for_key(key: KeyEvent) -> Option<ModalAction> {
    match key.code {
        KeyCode::Esc => Some(ModalAction::Close),
        KeyCode::Enter => Some(ModalAction::Apply),
        KeyCode::Char('j') | KeyCode::Down => Some(ModalAction::Down),
        KeyCode::Char('k') | KeyCode::Up => Some(ModalAction::Up),
        KeyCode::Char(' ') => Some(ModalAction::Toggle { advance: false }),
        KeyCode::Tab => Some(ModalAction::Toggle { advance: true }),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::preview::structured_log::{preview_content, LogEntry, LogFormat, StructuredLog};
    use crate::preview::PreviewContent;
    use crossterm::event::KeyModifiers;

    fn entry(fields: &[(&str, &str)], raw: &str) -> LogEntry {
        LogEntry {
            fields: fields
                .iter()
                .map(|(key, value)| (key.to_string(), value.to_string()))
                .collect(),
            raw: raw.to_string(),
        }
    }

    #[test]
    fn normal_mode_keys_map_to_explicit_actions() {
        let PreviewContent::StructuredLog(preview) = preview_content(StructuredLog {
            entries: vec![entry(&[("level", "info")], "level=info")],
            total_lines: 1,
            all_fields: vec!["level".to_string()],
            format: LogFormat::Logfmt,
        }) else {
            panic!("expected structured log preview");
        };

        let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
        assert!(matches!(
            action_for_key(&preview, key),
            Some(LogViewerAction::Cursor(CursorAction::Down(20)))
        ));

        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
        assert!(matches!(
            action_for_key(&preview, key),
            Some(LogViewerAction::Column(ColumnAction::OpenPicker))
        ));
    }
}