binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use super::preview::{scroll_preview_page, sync_preview, toggle_window_mode};
use super::{handle_help_modal_input, handle_preview_mode_input, handle_search_mode_input};
use crate::app::{App, AppAction, Mode};
use crate::config::kb_matches;
use crate::infra::channel::Sender;
use crate::preview::PreviewRequest;
use crate::search::matcher::MatcherCommand;
use crate::search::types::SearchMode;
use crossterm::event::KeyEvent;

pub fn handle_input(
    app: &mut App,
    key: KeyEvent,
    tx_cmd: &impl Sender<MatcherCommand>,
    tx_preview: &impl Sender<PreviewRequest>,
) {
    if kb_matches(&app.keybindings().quit, &key) {
        app.apply_action(AppAction::Quit);
        return;
    }

    if kb_matches(&app.keybindings().toggle_help, &key) {
        app.apply_action(AppAction::ToggleHelp);
        return;
    }

    if app.ui.help.visible {
        handle_help_modal_input(app, key);
        return;
    }

    if kb_matches(&app.keybindings().toggle_preview_focus, &key) {
        if app.show_preview() {
            toggle_window_mode(app);
        }
        return;
    }

    if app.show_preview() {
        if kb_matches(&app.keybindings().toggle_preview_fullscreen, &key) {
            app.apply_action(AppAction::TogglePreviewFullscreen);
            return;
        }
        if kb_matches(&app.keybindings().swap_panes, &key) {
            app.apply_action(AppAction::SwapPanes);
            return;
        }
        if kb_matches(&app.keybindings().preview_wider, &key) {
            app.apply_action(AppAction::AdjustPreviewWidth(5));
            return;
        }
        if kb_matches(&app.keybindings().preview_narrower, &key) {
            app.apply_action(AppAction::AdjustPreviewWidth(-5));
            return;
        }
    }
    if kb_matches(&app.keybindings().toggle_search_bar_position, &key) {
        app.apply_action(AppAction::ToggleSearchBarPosition);
        return;
    }
    if kb_matches(&app.keybindings().toggle_preview_visibility, &key) {
        let naturally_visible = !app.runtime.run.stdin || app.runtime.run.has_preview_command();
        if naturally_visible {
            app.apply_action(AppAction::TogglePreviewVisibility);
            if !app.ui.layout.preview_hidden {
                sync_preview(app, tx_preview);
            }
        }
        return;
    }

    if kb_matches(&app.keybindings().toggle_exact, &key) {
        app.apply_action(AppAction::ToggleExactMatcher);
        return;
    }

    if !app.runtime.run.stdin {
        if kb_matches(&app.keybindings().mode_path, &key) {
            app.apply_action(AppAction::SetSearchMode(SearchMode::Path));
            return;
        }
        if kb_matches(&app.keybindings().mode_files, &key) {
            app.apply_action(AppAction::SetSearchMode(SearchMode::Files));
            return;
        }
        if kb_matches(&app.keybindings().mode_grep, &key) {
            app.apply_action(AppAction::SetSearchMode(SearchMode::Grep));
            return;
        }
        if kb_matches(&app.keybindings().mode_dirs, &key) {
            app.apply_action(AppAction::SetSearchMode(SearchMode::Dirs));
            return;
        }
    }

    if app.show_preview() && app.ui.mode != Mode::Preview {
        if kb_matches(&app.keybindings().scroll_preview_up, &key) {
            scroll_preview_page(app, false);
            return;
        }
        if kb_matches(&app.keybindings().scroll_preview_down, &key) {
            scroll_preview_page(app, true);
            return;
        }
    }

    if app.ui.mode == Mode::Preview {
        handle_preview_mode_input(app, key);
    } else {
        handle_search_mode_input(app, key, tx_cmd, tx_preview);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::LoadedAppConfig;
    use crate::infra::channel::unbounded_default;
    use crate::preview::{create_rich_text_document, PreviewContent, PreviewSource};
    use crate::runtime::config::RunConfig;
    use crate::search::types::{MatcherMode, SearchConfig, SearchItem, SearchMode, SearchSettings};
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use std::path::Path;

    fn app() -> App {
        App::from_configs(
            RunConfig {
                headless: false,
                output_format: crate::cli::args::OutputFormat::Plain,
                output_file: None,
                stdin: false,
                log: false,
                diff: None,
                preview_command: None,
                preview_delimiter: ":".to_string(),
                split: None,
                log_files: Vec::new(),
            },
            SearchConfig {
                query: None,
                locations: vec![],
                search_pdf: false,
                no_hidden: false,
                no_git_ignore: false,
                no_ignore: false,
                no_default_ignore_dirs: false,
                git_search_scope: None,
                settings: SearchSettings {
                    mode: SearchMode::Path,
                    matcher: MatcherMode::Fuzzy,
                },
            },
            LoadedAppConfig::default(),
        )
    }

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn ctrl(ch: char) -> KeyEvent {
        KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
    }

    #[test]
    fn mode_shortcuts_switch_search_mode_and_request_restart() {
        let mut app = app();
        let (tx_cmd, _rx_cmd) = unbounded_default();
        let (tx_preview, _rx_preview) = unbounded_default();

        handle_input(&mut app, key(KeyCode::F(3)), &tx_cmd, &tx_preview);
        assert_eq!(app.search_session.settings.mode, SearchMode::Grep);
        assert!(app.ui.restart_search);

        app.ui.restart_search = false;
        handle_input(&mut app, key(KeyCode::F(4)), &tx_cmd, &tx_preview);
        assert_eq!(app.search_session.settings.mode, SearchMode::Dirs);
        assert!(app.ui.restart_search);
    }

    #[test]
    fn preview_focus_stays_on_search_for_read_only_preview() {
        let mut app = app();
        app.preview_session.preview.content = Some(PreviewContent::PlainText("binary".into()));

        let (tx_cmd, _rx_cmd) = unbounded_default();
        let (tx_preview, _rx_preview) = unbounded_default();
        handle_input(&mut app, ctrl('w'), &tx_cmd, &tx_preview);

        assert_eq!(app.ui.mode, Mode::Search);
        assert!(app.preview_session.preview.state.status_message.is_some());
    }

    #[test]
    fn preview_focus_moves_to_text_preview() {
        let mut app = app();
        app.preview_session.preview.state.highlight_line = Some(3);
        app.preview_session.preview.content =
            Some(PreviewContent::RichText(create_rich_text_document(
                "first\nsecond\nthird\nfourth\n".to_string(),
                Path::new("test.txt"),
            )));
        app.preview_session.preview.source =
            Some(PreviewSource::SearchItem(SearchItem::path("test.txt")));

        let (tx_cmd, _rx_cmd) = unbounded_default();
        let (tx_preview, _rx_preview) = unbounded_default();
        handle_input(&mut app, ctrl('w'), &tx_cmd, &tx_preview);

        assert_eq!(app.ui.mode, Mode::Preview);
        assert_eq!(app.preview_session.preview.state.cursor_line, 2);
        assert_eq!(app.preview_session.preview.state.cursor_char, 0);
    }
}