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