Skip to main content

binocular/app/
state.rs

1use super::{AppAction, HelpState, HelpTab, InputMode, LayoutState, Mode};
2use crate::config::{Keybindings, LoadedAppConfig};
3use crate::output::SelectionOutput;
4use crate::preview::rich_text::TextUndoFrame;
5use crate::preview::{PreviewContent, PreviewSource};
6use crate::runtime::config::RunConfig;
7use crate::search::types::{
8    MatcherMode, SearchConfig, SearchItem, SearchMode, SearchResult, SearchSettings,
9};
10use ratatui::layout::Rect;
11use ratatui::widgets::ListState;
12use std::collections::HashMap;
13
14use super::layout::ViewportMetrics;
15
16pub struct PreviewState {
17    pub scroll: usize,
18    pub scroll_char: usize,
19    pub highlight_line: Option<usize>,
20    pub cursor_line: usize,
21    pub cursor_char: usize,
22    pub search_query: String,
23    pub search_active: bool,
24    pub input_buffer: String,
25    pub command_buffer: String,
26    pub waiting_for_char_search: Option<(bool, usize)>,
27    pub last_char_search: Option<(char, bool)>,
28    pub mode: InputMode,
29    pub selection_start: Option<(usize, usize)>,
30    pub pending_object_modifier: Option<char>,
31    pub pending_operator: Option<char>,
32    pub status_message: Option<(String, std::time::Instant)>,
33    pub undo_stack: Vec<TextUndoFrame>,
34    pub redo_stack: Vec<TextUndoFrame>,
35}
36
37impl Default for PreviewState {
38    fn default() -> Self {
39        Self {
40            scroll: 0,
41            scroll_char: 0,
42            highlight_line: None,
43            cursor_line: 0,
44            cursor_char: 0,
45            search_query: String::new(),
46            search_active: false,
47            input_buffer: String::new(),
48            command_buffer: String::new(),
49            waiting_for_char_search: None,
50            last_char_search: None,
51            mode: InputMode::Normal,
52            selection_start: None,
53            pending_object_modifier: None,
54            pending_operator: None,
55            status_message: None,
56            undo_stack: Vec::new(),
57            redo_stack: Vec::new(),
58        }
59    }
60}
61
62pub struct Query {
63    pub text: String,
64    /// Cursor position (char index) in the search query.
65    pub cursor: usize,
66    /// Count prefix buffer for normal-mode commands (e.g. `3w`, `2x`).
67    pub count_buffer: String,
68    /// Vim mode for the search bar (Insert or Normal).
69    pub mode: InputMode,
70    /// Pending operator for search bar vim commands (e.g., 'd', 'c', 'y').
71    pub pending_op: Option<char>,
72    /// Pending text object modifier (e.g., 'i' for inner, 'a' for around).
73    pub pending_modifier: Option<char>,
74}
75
76impl Default for Query {
77    fn default() -> Self {
78        Self {
79            text: String::new(),
80            cursor: 0,
81            count_buffer: String::new(),
82            mode: InputMode::Insert,
83            pending_op: None,
84            pending_modifier: None,
85        }
86    }
87}
88
89pub struct Search {
90    pub results: Vec<SearchResult>,
91    pub total_matches: u64,
92    pub total_items: u64,
93    pub selection: usize,
94    pub selected_item: Option<SearchItem>,
95    pub marked_items: HashMap<SearchItem, Option<usize>>,
96    pub diff_marked_items: std::collections::HashSet<SearchItem>,
97    pub scroll_state: ListState,
98    pub working: bool,
99}
100
101impl Default for Search {
102    fn default() -> Self {
103        let mut scroll_state = ListState::default();
104        scroll_state.select(Some(0));
105        Self {
106            results: Vec::new(),
107            total_matches: 0,
108            total_items: 0,
109            selection: 0,
110            selected_item: None,
111            marked_items: HashMap::new(),
112            diff_marked_items: std::collections::HashSet::new(),
113            scroll_state,
114            working: false,
115        }
116    }
117}
118
119impl Search {
120    pub fn update_selection(&mut self) {
121        if self.results.is_empty() {
122            self.selection = 0;
123            self.selected_item = None;
124            self.scroll_state.select(None);
125            return;
126        }
127
128        if let Some(ref selected) = self.selected_item {
129            if let Some(new_idx) = self
130                .results
131                .iter()
132                .position(|result| &result.item == selected)
133            {
134                self.selection = new_idx;
135                self.scroll_state.select(Some(new_idx));
136                return;
137            }
138        }
139
140        if self.selection >= self.results.len() {
141            self.selection = self.results.len().saturating_sub(1);
142        }
143        self.scroll_state.select(Some(self.selection));
144
145        if let Some(result) = self.results.get(self.selection) {
146            self.selected_item = Some(result.item.clone());
147        }
148    }
149
150    pub fn next(&mut self) {
151        if self.total_matches > 0 && self.selection < self.total_matches as usize - 1 {
152            self.selection += 1;
153            self.scroll_state.select(Some(self.selection));
154            if let Some(result) = self.results.get(self.selection) {
155                self.selected_item = Some(result.item.clone());
156            }
157        }
158    }
159
160    pub fn previous(&mut self) {
161        if self.selection > 0 {
162            self.selection -= 1;
163            self.scroll_state.select(Some(self.selection));
164            if let Some(result) = self.results.get(self.selection) {
165                self.selected_item = Some(result.item.clone());
166            }
167        }
168    }
169}
170
171pub struct Preview {
172    pub content: Option<PreviewContent>,
173    pub source: Option<PreviewSource>,
174    pub state: PreviewState,
175}
176
177impl Default for Preview {
178    fn default() -> Self {
179        Self {
180            content: None,
181            source: None,
182            state: PreviewState::default(),
183        }
184    }
185}
186
187pub struct RuntimeConfig {
188    pub run: RunConfig,
189    pub search: SearchConfig,
190    pub app_config: LoadedAppConfig,
191}
192
193pub struct SearchSessionState {
194    pub settings: SearchSettings,
195    pub query: Query,
196    pub search: Search,
197}
198
199pub struct PreviewSessionState {
200    pub preview: Preview,
201}
202
203pub struct UiState {
204    pub help: HelpState,
205    pub layout: LayoutState,
206    pub mode: Mode,
207    pub should_quit: bool,
208    pub restart_search: bool,
209    pub(crate) viewport: ViewportMetrics,
210}
211
212pub struct App {
213    pub runtime: RuntimeConfig,
214    pub search_session: SearchSessionState,
215    pub preview_session: PreviewSessionState,
216    pub ui: UiState,
217    selected_output: Vec<SelectionOutput>,
218}
219
220impl App {
221    pub fn from_configs(
222        run_config: RunConfig,
223        search_config: SearchConfig,
224        app_config: LoadedAppConfig,
225    ) -> Self {
226        let log_mode = run_config.log;
227        let direct_diff_mode = run_config.diff.is_some();
228        let search_settings = search_config.settings;
229        let query = if let Some(ref initial) = search_config.query {
230            let len = initial.chars().count();
231            Query {
232                text: initial.clone(),
233                cursor: len,
234                ..Query::default()
235            }
236        } else {
237            Query::default()
238        };
239        Self {
240            runtime: RuntimeConfig {
241                run: run_config,
242                search: search_config,
243                app_config,
244            },
245            search_session: SearchSessionState {
246                settings: search_settings,
247                query,
248                search: Search::default(),
249            },
250            preview_session: PreviewSessionState {
251                preview: Preview::default(),
252            },
253            ui: UiState {
254                help: HelpState::default(),
255                layout: LayoutState::default(),
256                should_quit: false,
257                mode: if log_mode || direct_diff_mode {
258                    Mode::Preview
259                } else {
260                    Mode::Search
261                },
262                restart_search: false,
263                viewport: ViewportMetrics::default(),
264            },
265            selected_output: Vec::new(),
266        }
267    }
268
269    pub fn apply_action(&mut self, action: AppAction) {
270        match action {
271            AppAction::Quit => self.ui.should_quit = true,
272            AppAction::FocusSearch => self.ui.mode = Mode::Search,
273            AppAction::FocusPreview => self.ui.mode = Mode::Preview,
274            AppAction::ToggleHelp => {
275                self.ui.help.visible = !self.ui.help.visible;
276                if self.ui.help.visible {
277                    self.ui.help.tab = if self.ui.mode == Mode::Preview {
278                        HelpTab::Preview
279                    } else {
280                        HelpTab::Overview
281                    };
282                }
283            }
284            AppAction::CloseHelp => self.ui.help.visible = false,
285            AppAction::ShowHelpTab(tab) => self.ui.help.tab = tab,
286            AppAction::NextHelpTab => self.ui.help.tab = self.ui.help.tab.next(),
287            AppAction::PreviousHelpTab => self.ui.help.tab = self.ui.help.tab.previous(),
288            AppAction::TogglePreviewFullscreen => {
289                self.ui.layout.preview_fullscreen = !self.ui.layout.preview_fullscreen;
290                if self.ui.layout.preview_fullscreen && self.ui.mode == Mode::Search {
291                    self.ui.mode = Mode::Preview;
292                }
293            }
294            AppAction::SwapPanes => {
295                self.ui.layout.panes_swapped = !self.ui.layout.panes_swapped;
296            }
297            AppAction::AdjustPreviewWidth(delta) => {
298                if delta.is_positive() {
299                    self.ui.layout.preview_percent =
300                        (self.ui.layout.preview_percent + delta as u16).min(80);
301                } else {
302                    self.ui.layout.preview_percent = self
303                        .ui
304                        .layout
305                        .preview_percent
306                        .saturating_sub(delta.unsigned_abs())
307                        .max(20);
308                }
309            }
310            AppAction::ToggleSearchBarPosition => {
311                self.ui.layout.search_bar_at_bottom = !self.ui.layout.search_bar_at_bottom;
312            }
313            AppAction::TogglePreviewVisibility => {
314                self.ui.layout.preview_hidden = !self.ui.layout.preview_hidden;
315                if self.ui.layout.preview_hidden && self.ui.mode == Mode::Preview {
316                    self.ui.mode = Mode::Search;
317                }
318            }
319            AppAction::ToggleExactMatcher => {
320                self.search_session.settings.matcher =
321                    self.search_session.settings.matcher.toggle();
322                self.ui.restart_search = true;
323            }
324            AppAction::SetSearchMode(mode) => {
325                self.search_session.settings.mode = mode;
326                self.ui.restart_search = true;
327            }
328            AppAction::RequestSearchRestart => self.ui.restart_search = true,
329        }
330    }
331
332    pub fn show_preview(&self) -> bool {
333        if self.runtime.run.diff.is_some() || self.runtime.search.git_search_scope.is_some() {
334            return true;
335        }
336        let naturally_visible = !self.runtime.run.stdin || self.runtime.run.has_preview_command();
337        naturally_visible && !self.ui.layout.preview_hidden
338    }
339
340    pub fn keybindings(&self) -> &Keybindings {
341        &self.runtime.app_config.keybindings
342    }
343
344    pub fn log_max_entries(&self) -> usize {
345        self.runtime.app_config.log.max_entries
346    }
347
348    pub fn is_content_mode(&self) -> bool {
349        self.search_session.settings.mode == SearchMode::Grep
350            || self.search_session.settings.mode == SearchMode::GitHistory
351    }
352
353    pub fn is_git_mode(&self) -> bool {
354        matches!(
355            self.search_session.settings.mode,
356            SearchMode::GitHistory | SearchMode::GitBranches | SearchMode::GitCommits
357        )
358    }
359
360    pub fn is_dir_mode(&self) -> bool {
361        self.search_session.settings.mode == SearchMode::Dirs
362    }
363
364    pub fn is_file_name_mode(&self) -> bool {
365        self.search_session.settings.mode == SearchMode::Files
366    }
367
368    pub fn is_exact_mode(&self) -> bool {
369        self.search_session.settings.matcher == MatcherMode::Exact
370    }
371
372    pub fn search_config(&self) -> SearchConfig {
373        let mut search_config = self
374            .runtime
375            .search
376            .with_settings(self.search_session.settings);
377        search_config.query = Some(self.search_session.query.text.clone());
378        search_config
379    }
380
381    pub fn preview_file_path(&self) -> Option<&str> {
382        self.preview_session
383            .preview
384            .source
385            .as_ref()
386            .and_then(PreviewSource::file_path)
387    }
388
389    pub fn set_selected_output(&mut self, output: Vec<SelectionOutput>) {
390        self.selected_output = output;
391    }
392
393    pub fn take_selected_output(&mut self) -> Vec<SelectionOutput> {
394        std::mem::take(&mut self.selected_output)
395    }
396
397    pub fn set_terminal_area(&mut self, area: Rect) {
398        self.ui.viewport.terminal_width = area.width;
399        self.ui.viewport.terminal_height = area.height;
400    }
401
402    pub fn set_terminal_size(&mut self, width: u16, height: u16) {
403        self.ui.viewport.terminal_width = width;
404        self.ui.viewport.terminal_height = height;
405    }
406
407    pub fn refresh_viewports(&mut self) {
408        let (preview_width, preview_height) = if self.runtime.run.log
409            || self.runtime.run.diff.is_some()
410            || self.runtime.search.git_search_scope.is_some()
411        {
412            (
413                self.ui.viewport.terminal_width,
414                self.ui.viewport.terminal_height,
415            )
416        } else if !self.show_preview() {
417            (0, 0)
418        } else if self.ui.layout.preview_fullscreen {
419            (
420                self.ui.viewport.terminal_width,
421                self.ui.viewport.terminal_height,
422            )
423        } else {
424            (
425                self.ui.viewport.terminal_width * self.ui.layout.preview_percent / 100,
426                self.ui.viewport.terminal_height,
427            )
428        };
429
430        self.ui.viewport.preview_width = preview_width;
431        self.ui.viewport.preview_height = preview_height;
432    }
433
434    pub fn preview_width(&self) -> u16 {
435        self.ui.viewport.preview_width
436    }
437
438    pub fn preview_height(&self) -> u16 {
439        self.ui.viewport.preview_height
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::cli::args::OutputFormat;
447    use crate::config::LoadedAppConfig;
448    use crate::runtime::config::RunConfig;
449
450    fn run_config() -> RunConfig {
451        RunConfig {
452            headless: false,
453            output_format: OutputFormat::Plain,
454            output_file: None,
455            stdin: false,
456            log: false,
457            diff: None,
458            preview_command: None,
459            preview_delimiter: ":".to_string(),
460            split: None,
461            log_files: Vec::new(),
462        }
463    }
464
465    fn search_config() -> SearchConfig {
466        SearchConfig {
467            query: None,
468            locations: vec![],
469            search_pdf: false,
470            no_hidden: false,
471            no_git_ignore: false,
472            no_ignore: false,
473            no_default_ignore_dirs: false,
474            git_search_scope: None,
475            settings: SearchSettings {
476                mode: SearchMode::Path,
477                matcher: MatcherMode::Fuzzy,
478            },
479        }
480    }
481
482    #[test]
483    fn file_mode_starts_in_search_with_preview_visible() {
484        let app = App::from_configs(run_config(), search_config(), LoadedAppConfig::default());
485        assert_eq!(app.ui.mode, Mode::Search);
486        assert!(app.show_preview());
487        assert_eq!(app.search_session.settings.mode, SearchMode::Path);
488    }
489
490    #[test]
491    fn grep_mode_sets_content_state() {
492        let mut search_config = search_config();
493        search_config.settings.mode = SearchMode::Grep;
494
495        let app = App::from_configs(run_config(), search_config, LoadedAppConfig::default());
496        assert!(app.is_content_mode());
497        assert_eq!(app.search_session.settings.mode, SearchMode::Grep);
498    }
499
500    #[test]
501    fn stdin_mode_without_preview_command_hides_preview() {
502        let mut run_config = run_config();
503        run_config.stdin = true;
504
505        let app = App::from_configs(run_config, search_config(), LoadedAppConfig::default());
506        assert!(!app.show_preview());
507    }
508
509    #[test]
510    fn log_mode_starts_in_preview() {
511        let mut run_config = run_config();
512        run_config.log = true;
513
514        let app = App::from_configs(run_config, search_config(), LoadedAppConfig::default());
515        assert_eq!(app.ui.mode, Mode::Preview);
516    }
517
518    #[test]
519    fn direct_diff_mode_starts_in_preview() {
520        let mut run_config = run_config();
521        run_config.diff = Some(["left.txt".into(), "right.txt".into()]);
522
523        let app = App::from_configs(run_config, search_config(), LoadedAppConfig::default());
524        assert_eq!(app.ui.mode, Mode::Preview);
525        assert!(app.show_preview());
526    }
527}