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 crossterm::{
12    execute, queue,
13    terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},
14};
15use ratatui::{backend::CrosstermBackend, Terminal};
16use std::io;
17use std::time::{Duration, Instant};
18
19const MAX_PENDING_EVENTS_PER_FRAME: usize = 64;
20const PERIODIC_RENDER_INTERVAL: Duration = Duration::from_millis(120);
21
22pub fn run_event_loop(
23    app: &mut App,
24    terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
25    terminal_session: &mut TerminalSessionGuard,
26    rx_main: &channel::DefaultReceiver<AppEvent>,
27    tx_preview_req: &channel::DefaultSender<PreviewRequest>,
28    tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
29    search_sessions: &mut Option<SearchController>,
30    log_max_entries: usize,
31) -> anyhow::Result<()> {
32    let mut item_limit = 100;
33
34    render_frame(app, terminal, terminal_session)?;
35    let mut last_render_at = Instant::now();
36
37    loop {
38        let event = match rx_main.recv() {
39            Ok(event) => event,
40            Err(channel::ChannelError::Disconnected) => return Ok(()),
41            Err(err) => return Err(err.into()),
42        };
43        let mut saw_tick = matches!(event, AppEvent::Input(InputEvent::Tick));
44        let mut render_requested = handle_app_event(
45            app,
46            event,
47            tx_preview_req,
48            tx_cmd_noop,
49            search_sessions,
50            log_max_entries,
51        );
52
53        for _ in 0..MAX_PENDING_EVENTS_PER_FRAME {
54            let event = match rx_main.try_recv() {
55                Ok(Some(event)) => event,
56                Ok(None) | Err(channel::ChannelError::Disconnected) => break,
57                Err(err) => return Err(err.into()),
58            };
59            saw_tick |= matches!(event, AppEvent::Input(InputEvent::Tick));
60            render_requested |= handle_app_event(
61                app,
62                event,
63                tx_preview_req,
64                tx_cmd_noop,
65                search_sessions,
66                log_max_entries,
67            );
68        }
69
70        if let Some(search_sessions) = search_sessions.as_mut() {
71            search_sessions.reconcile(app, &mut item_limit);
72        }
73
74        if let Some(search_sessions) = search_sessions.as_ref() {
75            if let Some(tx_cmd) = search_sessions.command_sender() {
76                handlers::check_infinite_scroll(app, &mut item_limit, tx_cmd);
77            }
78        }
79
80        if app.ui.should_quit {
81            return Ok(());
82        }
83
84        if saw_tick
85            && !render_requested
86            && should_render_periodically(app)
87            && last_render_at.elapsed() >= PERIODIC_RENDER_INTERVAL
88        {
89            render_requested = true;
90        }
91
92        if render_requested {
93            render_frame(app, terminal, terminal_session)?;
94            last_render_at = Instant::now();
95        }
96    }
97}
98
99fn render_frame(
100    app: &mut App,
101    terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
102    terminal_session: &mut TerminalSessionGuard,
103) -> anyhow::Result<()> {
104    app.refresh_viewports();
105    sync_cursor_style(app, terminal_session);
106
107    queue!(terminal.backend_mut(), BeginSynchronizedUpdate).ok();
108    terminal.draw(|f| ui::draw(f, app))?;
109    execute!(terminal.backend_mut(), EndSynchronizedUpdate).ok();
110
111    Ok(())
112}
113
114fn should_render_periodically(app: &App) -> bool {
115    app.search_session.search.working
116        || app
117            .preview_session
118            .preview
119            .state
120            .status_message
121            .as_ref()
122            .is_some_and(|(_, shown_at)| shown_at.elapsed().as_secs() < 3)
123}
124
125fn sync_cursor_style(app: &App, terminal_session: &mut TerminalSessionGuard) {
126    let should_be_bar =
127        app.ui.mode == Mode::Preview && app.preview_session.preview.state.mode == InputMode::Insert;
128    terminal_session.sync_cursor_style(should_be_bar);
129}
130
131fn handle_app_event(
132    app: &mut App,
133    event: AppEvent,
134    tx_preview_req: &channel::DefaultSender<PreviewRequest>,
135    tx_cmd_noop: &channel::DefaultSender<MatcherCommand>,
136    search_sessions: &Option<SearchController>,
137    log_max_entries: usize,
138) -> bool {
139    match event {
140        AppEvent::Input(input) => match input {
141            InputEvent::Key(key) => {
142                let tx_cmd = search_sessions
143                    .as_ref()
144                    .and_then(SearchController::command_sender)
145                    .unwrap_or(tx_cmd_noop);
146                handlers::handle_input(app, key, tx_cmd, tx_preview_req);
147                true
148            }
149            InputEvent::Resize(width, height) => {
150                app.set_terminal_size(width, height);
151                app.refresh_viewports();
152                true
153            }
154            InputEvent::Tick => false,
155        },
156        AppEvent::Matcher(state, epoch) => {
157            if search_sessions
158                .as_ref()
159                .is_some_and(|search_sessions| search_sessions.accepts_epoch(epoch))
160            {
161                apply_matcher_state(app, state, tx_preview_req);
162                true
163            } else {
164                false
165            }
166        }
167        AppEvent::Preview(source, text) => {
168            apply_preview_event(app, source, text);
169            true
170        }
171        AppEvent::LogAppend(path, entries) => {
172            structured_log::apply_append(app, &path, entries, log_max_entries);
173            true
174        }
175    }
176}
177
178fn apply_matcher_state(
179    app: &mut App,
180    state: crate::search::matcher::MatcherState,
181    tx_preview: &channel::DefaultSender<PreviewRequest>,
182) {
183    app.search_session.search.results = state.results;
184    app.search_session.search.total_matches = state.total_matches;
185    app.search_session.search.total_items = state.total_items;
186    app.search_session.search.working = state.working;
187    app.search_session.search.update_selection();
188    handlers::sync_preview(app, tx_preview);
189}
190
191pub fn apply_preview_event(app: &mut App, source: PreviewSource, text: preview::PreviewContent) {
192    if app.preview_session.preview.source.as_ref() != Some(&source) {
193        return;
194    }
195
196    let preview_is_rich_text = matches!(text, preview::PreviewContent::RichText(_))
197        && !matches!(source, PreviewSource::GitHistory { .. });
198    let preview_is_log = matches!(text, preview::PreviewContent::StructuredLog(_));
199    app.preview_session.preview.content = Some(text);
200
201    if app.ui.mode == Mode::Preview && !preview_is_rich_text && !preview_is_log {
202        app.ui.mode = Mode::Search;
203        app.preview_session.preview.state.search_active = false;
204        app.preview_session.preview.state.status_message = Some((
205            "Preview is read-only for this file type".to_string(),
206            std::time::Instant::now(),
207        ));
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::config::LoadedAppConfig;
215    use crate::infra::channel::{unbounded_default, Receiver};
216    use crate::runtime::config::RunConfig;
217    use crate::search::sources::git::{GitSearchMode, GitSearchScope};
218    use crate::search::types::{
219        MatcherMode, SearchConfig, SearchItem, SearchMode, SearchResult, SearchSettings,
220    };
221    use std::path::{Path, PathBuf};
222
223    fn run_config() -> RunConfig {
224        RunConfig {
225            headless: false,
226            output_format: crate::cli::args::OutputFormat::Plain,
227            output_file: None,
228            stdin: false,
229            log: false,
230            diff: None,
231            preview_command: None,
232            preview_delimiter: ":".to_string(),
233            split: None,
234            log_files: Vec::new(),
235        }
236    }
237
238    fn search_config() -> SearchConfig {
239        SearchConfig {
240            query: None,
241            locations: vec![],
242            search_pdf: false,
243            no_hidden: false,
244            no_git_ignore: false,
245            no_ignore: false,
246            no_default_ignore_dirs: false,
247            git_search_scope: None,
248            settings: SearchSettings {
249                mode: SearchMode::Path,
250                matcher: MatcherMode::Fuzzy,
251            },
252        }
253    }
254
255    fn app() -> App {
256        App::from_configs(run_config(), search_config(), LoadedAppConfig::default())
257    }
258
259    #[test]
260    fn periodic_render_runs_while_search_is_working() {
261        let mut app = app();
262        app.search_session.search.working = true;
263
264        assert!(should_render_periodically(&app));
265    }
266
267    #[test]
268    fn periodic_render_stops_after_status_message_expires() {
269        let mut app = app();
270        app.preview_session.preview.state.status_message =
271            Some(("Saved".to_string(), Instant::now() - Duration::from_secs(4)));
272
273        assert!(!should_render_periodically(&app));
274    }
275
276    #[test]
277    fn git_history_rich_text_preview_stays_read_only() {
278        let mut search_config = search_config();
279        search_config.git_search_scope = Some(GitSearchScope {
280            repo_root: PathBuf::from("/repo"),
281            mode: GitSearchMode::History {
282                file: PathBuf::from("Architecture.md"),
283            },
284            display_path: Some("Architecture.md".to_string()),
285        });
286        let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
287        app.ui.mode = Mode::Preview;
288        let source = PreviewSource::GitHistory {
289            commit: "HEAD".to_string(),
290            path: "Architecture.md".to_string(),
291            line: 2,
292        };
293        app.preview_session.preview.source = Some(source.clone());
294
295        apply_preview_event(
296            &mut app,
297            source,
298            preview::PreviewContent::RichText(preview::create_rich_text_document(
299                "fn main() {}\n".to_string(),
300                Path::new("Architecture.md"),
301            )),
302        );
303
304        assert_eq!(app.ui.mode, Mode::Search);
305        assert!(matches!(
306            app.preview_session.preview.content,
307            Some(preview::PreviewContent::RichText(_))
308        ));
309        assert!(app.preview_session.preview.state.status_message.is_some());
310    }
311
312    #[test]
313    fn grep_matcher_state_syncs_preview_request_and_highlight() {
314        let mut app = App::from_configs(run_config(), search_config(), LoadedAppConfig::default());
315        app.search_session.settings.mode = SearchMode::Grep;
316        app.set_terminal_size(120, 40);
317        app.refresh_viewports();
318
319        let (tx_preview, rx_preview) = unbounded_default();
320        apply_matcher_state(
321            &mut app,
322            crate::search::matcher::MatcherState {
323                results: vec![SearchResult {
324                    item: SearchItem::grep("src/main.rs", 24, "fn main()"),
325                    indices: vec![],
326                    column: Some(4),
327                }],
328                total_matches: 1,
329                total_items: 1,
330                working: false,
331            },
332            &tx_preview,
333        );
334
335        let request = rx_preview.recv().expect("preview request");
336        assert_eq!(
337            request,
338            PreviewRequest::Grep {
339                source: PreviewSource::SearchItem(SearchItem::grep("src/main.rs", 24, "fn main()")),
340                path: "src/main.rs".to_string(),
341                line: 24,
342                text: "fn main()".to_string(),
343            }
344        );
345        assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
346    }
347
348    #[test]
349    fn git_history_matcher_state_syncs_history_preview_request() {
350        let mut search_config = search_config();
351        search_config.git_search_scope = Some(GitSearchScope {
352            repo_root: Path::new("/repo").to_path_buf(),
353            mode: GitSearchMode::History {
354                file: Path::new("Architecture.md").to_path_buf(),
355            },
356            display_path: Some("Architecture.md".to_string()),
357        });
358        let mut app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
359        app.search_session.settings.mode = SearchMode::GitHistory;
360        app.set_terminal_size(120, 40);
361        app.refresh_viewports();
362
363        let (tx_preview, rx_preview) = unbounded_default();
364        apply_matcher_state(
365            &mut app,
366            crate::search::matcher::MatcherState {
367                results: vec![SearchResult {
368                    item: SearchItem::history_line("abc123", "Architecture.md", 24, "fn main()"),
369                    indices: vec![],
370                    column: Some(4),
371                }],
372                total_matches: 1,
373                total_items: 1,
374                working: false,
375            },
376            &tx_preview,
377        );
378
379        let request = rx_preview.recv().expect("preview request");
380        assert_eq!(
381            request,
382            PreviewRequest::GitHistory {
383                source: PreviewSource::GitHistory {
384                    commit: "abc123".to_string(),
385                    path: "Architecture.md".to_string(),
386                    line: 24,
387                },
388                repo_root: "/repo".to_string(),
389                commit: "abc123".to_string(),
390                path: "Architecture.md".to_string(),
391                line: 24,
392            }
393        );
394        assert_eq!(app.preview_session.preview.state.highlight_line, Some(24));
395    }
396}