Skip to main content

binocular/runtime/interactive/
loop.rs

1use crate::app::{App, AppEvent, InputMode, Mode};
2use crate::infra::channel::{self, Receiver};
3use crate::infra::terminal::TerminalSessionGuard;
4use crate::preview::{self, structured_log};
5use crate::preview::{PreviewRequest, PreviewSource};
6use crate::runtime::interactive::handlers;
7use crate::runtime::interactive::input::InputEvent;
8use crate::search::controller::SearchController;
9use crate::search::matcher::MatcherCommand;
10use crate::ui;
11use ratatui::{backend::CrosstermBackend, Terminal};
12use std::io;
13
14pub fn run_event_loop(
15    app: &mut App,
16    terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
17    terminal_session: &mut TerminalSessionGuard,
18    rx_main: &channel::DefaultReceiver<AppEvent>,
19    tx_preview_req: &channel::DefaultSender<PreviewRequest>,
20    tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
21    search_sessions: &mut Option<SearchController>,
22    log_max_entries: usize,
23) -> anyhow::Result<()> {
24    let mut item_limit = 100;
25
26    loop {
27        app.refresh_viewports();
28        sync_cursor_style(app, terminal_session);
29        terminal.draw(|f| ui::draw(f, app))?;
30
31        if let Ok(event) = rx_main.recv() {
32            handle_app_event(
33                app,
34                event,
35                tx_preview_req,
36                tx_cmd_noop,
37                search_sessions,
38                log_max_entries,
39            );
40        }
41
42        if let Some(search_sessions) = search_sessions.as_mut() {
43            search_sessions.reconcile(app, &mut item_limit);
44        }
45
46        if let Some(search_sessions) = search_sessions.as_ref() {
47            if let Some(tx_cmd) = search_sessions.command_sender() {
48                handlers::check_infinite_scroll(app, &mut item_limit, tx_cmd);
49            }
50        }
51
52        if app.ui.should_quit {
53            return Ok(());
54        }
55    }
56}
57
58fn sync_cursor_style(app: &App, terminal_session: &mut TerminalSessionGuard) {
59    let should_be_bar =
60        app.ui.mode == Mode::Preview && app.preview_session.preview.state.mode == InputMode::Insert;
61    terminal_session.sync_cursor_style(should_be_bar);
62}
63
64fn handle_app_event(
65    app: &mut App,
66    event: AppEvent,
67    tx_preview_req: &channel::DefaultSender<PreviewRequest>,
68    tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
69    search_sessions: &Option<SearchController>,
70    log_max_entries: usize,
71) {
72    match event {
73        AppEvent::Input(input) => match input {
74            InputEvent::Key(key) => {
75                let tx_cmd = search_sessions
76                    .as_ref()
77                    .and_then(SearchController::command_sender)
78                    .unwrap_or(tx_cmd_noop);
79                handlers::handle_input(app, key, tx_cmd, tx_preview_req);
80            }
81            InputEvent::Resize(width, height) => {
82                app.set_terminal_size(width, height);
83                app.refresh_viewports();
84            }
85            InputEvent::Tick => {}
86        },
87        AppEvent::Matcher(state, epoch) => {
88            if search_sessions
89                .as_ref()
90                .is_some_and(|search_sessions| search_sessions.accepts_epoch(epoch))
91            {
92                apply_matcher_state(app, state, tx_preview_req);
93            }
94        }
95        AppEvent::Preview(source, text) => apply_preview_event(app, source, text),
96        AppEvent::LogAppend(path, entries) => {
97            structured_log::apply_append(app, &path, entries, log_max_entries);
98        }
99    }
100}
101
102fn apply_matcher_state(
103    app: &mut App,
104    state: crate::search::matcher::MatcherState,
105    tx_preview: &channel::DefaultSender<PreviewRequest>,
106) {
107    app.search_session.search.results = state.results;
108    app.search_session.search.total_matches = state.total_matches;
109    app.search_session.search.total_items = state.total_items;
110    app.search_session.search.working = state.working;
111    app.search_session.search.update_selection();
112    handlers::sync_preview(app, tx_preview);
113}
114
115pub fn apply_preview_event(app: &mut App, source: PreviewSource, text: preview::PreviewContent) {
116    if app.preview_session.preview.source.as_ref() != Some(&source) {
117        return;
118    }
119
120    let preview_is_rich_text = matches!(text, preview::PreviewContent::RichText(_))
121        && !matches!(source, PreviewSource::GitHistory { .. });
122    let preview_is_log = matches!(text, preview::PreviewContent::StructuredLog(_));
123    app.preview_session.preview.content = Some(text);
124
125    if app.ui.mode == Mode::Preview && !preview_is_rich_text && !preview_is_log {
126        app.ui.mode = Mode::Search;
127        app.preview_session.preview.state.search_active = false;
128        app.preview_session.preview.state.status_message = Some((
129            "Preview is read-only for this file type".to_string(),
130            std::time::Instant::now(),
131        ));
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::config::LoadedAppConfig;
139    use crate::infra::channel::{unbounded_default, Receiver};
140    use crate::runtime::config::RunConfig;
141    use crate::search::sources::git::{GitSearchMode, GitSearchScope};
142    use crate::search::types::{
143        MatcherMode, SearchConfig, SearchItem, SearchMode, SearchResult, SearchSettings,
144    };
145    use std::path::{Path, PathBuf};
146
147    fn run_config() -> RunConfig {
148        RunConfig {
149            headless: false,
150            output_format: crate::cli::args::OutputFormat::Plain,
151            stdin: false,
152            log: false,
153            diff: None,
154            preview_command: None,
155            preview_delimiter: ":".to_string(),
156            split: None,
157            log_files: Vec::new(),
158        }
159    }
160
161    fn search_config() -> SearchConfig {
162        SearchConfig {
163            query: None,
164            locations: vec![],
165            search_pdf: false,
166            no_hidden: false,
167            no_git_ignore: false,
168            no_ignore: false,
169            no_default_ignore_dirs: false,
170            git_search_scope: None,
171            settings: SearchSettings {
172                mode: SearchMode::Path,
173                matcher: MatcherMode::Fuzzy,
174            },
175        }
176    }
177
178    #[test]
179    fn git_history_rich_text_preview_stays_read_only() {
180        let mut search_config = search_config();
181        search_config.git_search_scope = Some(GitSearchScope {
182            repo_root: PathBuf::from("/repo"),
183            mode: GitSearchMode::History {
184                file: PathBuf::from("Architecture.md"),
185            },
186            display_path: Some("Architecture.md".to_string()),
187        });
188        let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
189        app.ui.mode = Mode::Preview;
190        let source = PreviewSource::GitHistory {
191            commit: "HEAD".to_string(),
192            path: "Architecture.md".to_string(),
193            line: 2,
194        };
195        app.preview_session.preview.source = Some(source.clone());
196
197        apply_preview_event(
198            &mut app,
199            source,
200            preview::PreviewContent::RichText(preview::create_rich_text_document(
201                "fn main() {}\n".to_string(),
202                Path::new("Architecture.md"),
203            )),
204        );
205
206        assert_eq!(app.ui.mode, Mode::Search);
207        assert!(matches!(
208            app.preview_session.preview.content,
209            Some(preview::PreviewContent::RichText(_))
210        ));
211        assert!(app.preview_session.preview.state.status_message.is_some());
212    }
213
214    #[test]
215    fn grep_matcher_state_syncs_preview_request_and_highlight() {
216        let mut app = App::from_configs(run_config(), search_config(), LoadedAppConfig::default());
217        app.search_session.settings.mode = SearchMode::Grep;
218        app.set_terminal_size(120, 40);
219        app.refresh_viewports();
220
221        let (tx_preview, rx_preview) = unbounded_default();
222        apply_matcher_state(
223            &mut app,
224            crate::search::matcher::MatcherState {
225                results: vec![SearchResult {
226                    item: SearchItem::grep("src/main.rs", 24, "fn main()"),
227                    indices: vec![],
228                    column: Some(4),
229                }],
230                total_matches: 1,
231                total_items: 1,
232                working: false,
233            },
234            &tx_preview,
235        );
236
237        let request = rx_preview.recv().expect("preview request");
238        assert_eq!(
239            request,
240            PreviewRequest::Grep {
241                source: PreviewSource::SearchItem(SearchItem::grep("src/main.rs", 24, "fn main()")),
242                path: "src/main.rs".to_string(),
243                line: 24,
244                text: "fn main()".to_string(),
245            }
246        );
247        assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
248    }
249
250    #[test]
251    fn git_history_matcher_state_syncs_history_preview_request() {
252        let mut search_config = search_config();
253        search_config.git_search_scope = Some(GitSearchScope {
254            repo_root: Path::new("/repo").to_path_buf(),
255            mode: GitSearchMode::History {
256                file: Path::new("Architecture.md").to_path_buf(),
257            },
258            display_path: Some("Architecture.md".to_string()),
259        });
260        let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
261        app.search_session.settings.mode = SearchMode::GitHistory;
262        app.set_terminal_size(120, 40);
263        app.refresh_viewports();
264
265        let (tx_preview, rx_preview) = unbounded_default();
266        apply_matcher_state(
267            &mut app,
268            crate::search::matcher::MatcherState {
269                results: vec![SearchResult {
270                    item: SearchItem::history_line("abc123", "Architecture.md", 24, "fn main()"),
271                    indices: vec![],
272                    column: Some(4),
273                }],
274                total_matches: 1,
275                total_items: 1,
276                working: false,
277            },
278            &tx_preview,
279        );
280
281        let request = rx_preview.recv().expect("preview request");
282        assert_eq!(
283            request,
284            PreviewRequest::GitHistory {
285                source: PreviewSource::GitHistory {
286                    commit: "abc123".to_string(),
287                    path: "Architecture.md".to_string(),
288                    line: 24,
289                },
290                repo_root: "/repo".to_string(),
291                commit: "abc123".to_string(),
292                path: "Architecture.md".to_string(),
293                line: 24,
294            }
295        );
296        assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
297    }
298}