scooter_core/
app.rs

1use std::{
2    cmp::{max, min},
3    io::Cursor,
4    iter::{self, Iterator},
5    mem,
6    path::PathBuf,
7    sync::{
8        Arc,
9        atomic::{AtomicBool, AtomicUsize, Ordering},
10    },
11    time::{Duration, Instant},
12};
13
14use frep_core::{
15    line_reader::{BufReadExt, LineEnding},
16    replace::{add_replacement, replacement_if_match},
17    search::{FileSearcher, ParsedSearchConfig, SearchResult, SearchResultWithReplacement},
18    validation::{
19        DirConfig, SearchConfig, ValidationErrorHandler, ValidationResult,
20        validate_search_configuration,
21    },
22};
23use ignore::WalkState;
24use log::{debug, warn};
25use tokio::{
26    sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
27    task::{self, JoinHandle},
28};
29
30use crate::{
31    commands::{
32        Command, CommandGeneral, CommandSearchFields, CommandSearchFocusFields,
33        CommandSearchFocusResults, KeyMap, display_conflict_errors,
34    },
35    config::Config,
36    errors::AppError,
37    fields::{FieldName, SearchFieldValues, SearchFields},
38    keyboard::{KeyCode, KeyEvent, KeyModifiers},
39    replace::{self, PerformingReplacementState, ReplaceState},
40    search::Searcher,
41    utils::{Either, Either::Left, Either::Right, ceil_div},
42};
43
44#[derive(Debug, Clone)]
45pub enum InputSource {
46    Directory(PathBuf),
47    Stdin(Arc<String>),
48}
49
50#[derive(Debug)]
51pub enum ExitState {
52    Stats(ReplaceState),
53    StdinState(ExitAndReplaceState),
54}
55
56#[derive(Debug)]
57pub enum EventHandlingResult {
58    Rerender,
59    Exit(Option<Box<ExitState>>),
60    None,
61}
62
63impl EventHandlingResult {
64    pub(crate) fn new_exit_stats(stats: ReplaceState) -> EventHandlingResult {
65        Self::new_exit(ExitState::Stats(stats))
66    }
67
68    fn new_exit(exit_state: ExitState) -> EventHandlingResult {
69        EventHandlingResult::Exit(Some(Box::new(exit_state)))
70    }
71}
72
73#[derive(Debug)]
74pub enum BackgroundProcessingEvent {
75    AddSearchResult(SearchResult),
76    AddSearchResults(Vec<SearchResult>),
77    SearchCompleted,
78    ReplacementCompleted(ReplaceState),
79    UpdateReplacements {
80        start: usize,
81        end: usize,
82        cancelled: Arc<AtomicBool>,
83    },
84    UpdateAllReplacements {
85        cancelled: Arc<AtomicBool>,
86    },
87}
88
89#[derive(Debug)]
90pub enum AppEvent {
91    PerformSearch,
92}
93
94#[derive(Debug)]
95pub enum InternalEvent {
96    App(AppEvent),
97    Background(BackgroundProcessingEvent),
98}
99
100#[derive(Debug)]
101pub struct ExitAndReplaceState {
102    pub stdin: Arc<String>,
103    pub search_config: ParsedSearchConfig,
104    pub replace_results: Vec<SearchResultWithReplacement>,
105}
106
107#[derive(Debug)]
108pub enum Event {
109    LaunchEditor((PathBuf, usize)),
110    ExitAndReplace(ExitAndReplaceState),
111    Rerender,
112    Internal(InternalEvent),
113}
114
115#[derive(Debug, PartialEq, Eq)]
116struct MultiSelected {
117    anchor: usize,
118    primary: usize,
119}
120impl MultiSelected {
121    fn ordered(&self) -> (usize, usize) {
122        if self.anchor < self.primary {
123            (self.anchor, self.primary)
124        } else {
125            (self.primary, self.anchor)
126        }
127    }
128
129    fn flip_direction(&mut self) {
130        (self.anchor, self.primary) = (self.primary, self.anchor);
131    }
132}
133
134#[derive(Debug, PartialEq, Eq)]
135enum Selected {
136    Single(usize),
137    Multi(MultiSelected),
138}
139
140#[derive(Debug)]
141pub struct SearchState {
142    pub results: Vec<SearchResultWithReplacement>,
143
144    selected: Selected,
145    // TODO: make the view logic with scrolling etc. into a generic component
146    pub view_offset: usize,           // Updated by UI, not app
147    pub num_displayed: Option<usize>, // Updated by UI, not app
148
149    processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
150    processing_sender: UnboundedSender<BackgroundProcessingEvent>,
151
152    pub last_render: Instant,
153    pub search_started: Instant,
154    pub search_completed: Option<Instant>,
155    pub cancelled: Arc<AtomicBool>,
156}
157
158impl SearchState {
159    pub fn new(
160        processing_sender: UnboundedSender<BackgroundProcessingEvent>,
161        processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
162        cancelled: Arc<AtomicBool>,
163    ) -> Self {
164        Self {
165            results: vec![],
166            selected: Selected::Single(0),
167            view_offset: 0,
168            num_displayed: None,
169            processing_sender,
170            processing_receiver,
171            last_render: Instant::now(),
172            search_started: Instant::now(),
173            search_completed: None,
174            cancelled,
175        }
176    }
177
178    fn move_selected_up_by(&mut self, n: usize) {
179        let primary_selected_pos = self.primary_selected_pos();
180        if primary_selected_pos == 0 {
181            self.selected = Selected::Single(self.results.len().saturating_sub(1));
182        } else {
183            self.move_primary_sel(primary_selected_pos.saturating_sub(n));
184        }
185    }
186
187    fn move_selected_down_by(&mut self, n: usize) {
188        let primary_selected_pos = self.primary_selected_pos();
189        let end = self.results.len().saturating_sub(1);
190        if primary_selected_pos >= end {
191            self.selected = Selected::Single(0);
192        } else {
193            self.move_primary_sel(min(primary_selected_pos + n, end));
194        }
195    }
196
197    fn move_selected_up(&mut self) {
198        self.move_selected_up_by(1);
199    }
200
201    fn move_selected_down(&mut self) {
202        self.move_selected_down_by(1);
203    }
204
205    fn move_selected_up_full_page(&mut self) {
206        self.move_selected_up_by(max(self.num_displayed.unwrap(), 1));
207    }
208
209    fn move_selected_down_full_page(&mut self) {
210        self.move_selected_down_by(max(self.num_displayed.unwrap(), 1));
211    }
212
213    fn move_selected_up_half_page(&mut self) {
214        self.move_selected_up_by(max(ceil_div(self.num_displayed.unwrap(), 2), 1));
215    }
216
217    fn move_selected_down_half_page(&mut self) {
218        self.move_selected_down_by(max(ceil_div(self.num_displayed.unwrap(), 2), 1));
219    }
220
221    fn move_selected_top(&mut self) {
222        self.move_primary_sel(0);
223    }
224
225    fn move_selected_bottom(&mut self) {
226        self.move_primary_sel(self.results.len().saturating_sub(1));
227    }
228
229    fn move_primary_sel(&mut self, idx: usize) {
230        self.selected = match &self.selected {
231            Selected::Single(_) => Selected::Single(idx),
232            Selected::Multi(MultiSelected { anchor, .. }) => Selected::Multi(MultiSelected {
233                anchor: *anchor,
234                primary: idx,
235            }),
236        };
237    }
238
239    fn toggle_selected_inclusion(&mut self) {
240        let all_included = self
241            .selected_fields()
242            .iter()
243            .all(|res| res.search_result.included);
244        self.selected_fields_mut().iter_mut().for_each(|selected| {
245            selected.search_result.included = !all_included;
246        });
247    }
248
249    fn toggle_all_selected(&mut self) {
250        let all_included = self.results.iter().all(|res| res.search_result.included);
251        self.results
252            .iter_mut()
253            .for_each(|res| res.search_result.included = !all_included);
254    }
255
256    // TODO: add tests
257    fn selected_range(&self) -> (usize, usize) {
258        match &self.selected {
259            Selected::Single(sel) => (*sel, *sel),
260            Selected::Multi(ms) => ms.ordered(),
261        }
262    }
263
264    fn selected_fields(&self) -> &[SearchResultWithReplacement] {
265        if self.results.is_empty() {
266            return &[];
267        }
268        let (low, high) = self.selected_range();
269        &self.results[low..=high]
270    }
271
272    fn selected_fields_mut(&mut self) -> &mut [SearchResultWithReplacement] {
273        if self.results.is_empty() {
274            return &mut [];
275        }
276        let (low, high) = self.selected_range();
277        &mut self.results[low..=high]
278    }
279
280    pub fn primary_selected_field_mut(&mut self) -> Option<&mut SearchResultWithReplacement> {
281        let sel = self.primary_selected_pos();
282        if !self.results.is_empty() {
283            Some(&mut self.results[sel])
284        } else {
285            None
286        }
287    }
288
289    pub fn primary_selected_pos(&self) -> usize {
290        match self.selected {
291            Selected::Single(sel) => sel,
292            Selected::Multi(MultiSelected { primary, .. }) => primary,
293        }
294    }
295
296    fn toggle_multiselect_mode(&mut self) {
297        self.selected = match &self.selected {
298            Selected::Single(sel) => Selected::Multi(MultiSelected {
299                anchor: *sel,
300                primary: *sel,
301            }),
302            Selected::Multi(MultiSelected { primary, .. }) => Selected::Single(*primary),
303        };
304    }
305
306    pub fn is_selected(&self, idx: usize) -> bool {
307        match &self.selected {
308            Selected::Single(sel) => idx == *sel,
309            Selected::Multi(ms) => {
310                let (low, high) = ms.ordered();
311                idx >= low && idx <= high
312            }
313        }
314    }
315
316    fn multiselect_enabled(&self) -> bool {
317        match &self.selected {
318            Selected::Single(_) => false,
319            Selected::Multi(_) => true,
320        }
321    }
322
323    pub fn is_primary_selected(&self, idx: usize) -> bool {
324        idx == self.primary_selected_pos()
325    }
326
327    fn flip_multiselect_direction(&mut self) {
328        match &mut self.selected {
329            Selected::Single(_) => {}
330            Selected::Multi(ms) => {
331                ms.flip_direction();
332            }
333        }
334    }
335
336    pub fn set_search_completed_now(&mut self) {
337        self.search_completed = Some(Instant::now());
338    }
339}
340
341#[derive(Clone, Debug, Eq, PartialEq)]
342pub enum FocussedSection {
343    SearchFields,
344    SearchResults,
345}
346
347#[derive(Debug)]
348pub struct PreviewUpdateStatus {
349    replace_debounce_timer: JoinHandle<()>,
350    update_replacement_cancelled: Arc<AtomicBool>,
351    replacements_updated: usize,
352    total_replacements_to_update: usize,
353}
354
355impl PreviewUpdateStatus {
356    fn new(
357        replace_debounce_timer: JoinHandle<()>,
358        update_replacement_cancelled: Arc<AtomicBool>,
359    ) -> Self {
360        Self {
361            replace_debounce_timer,
362            update_replacement_cancelled,
363            replacements_updated: 0,
364            total_replacements_to_update: 0,
365        }
366    }
367}
368
369#[derive(Debug)]
370pub struct SearchFieldsState {
371    pub focussed_section: FocussedSection,
372    pub search_state: Option<SearchState>, // Becomes Some when search begins
373    pub search_debounce_timer: Option<JoinHandle<()>>,
374    pub preview_update_state: Option<PreviewUpdateStatus>,
375}
376
377impl Default for SearchFieldsState {
378    fn default() -> Self {
379        Self {
380            focussed_section: FocussedSection::SearchFields,
381            search_state: None,
382            search_debounce_timer: None,
383            preview_update_state: None,
384        }
385    }
386}
387
388impl SearchFieldsState {
389    pub fn replacements_in_progress(&self) -> Option<(usize, usize)> {
390        self.preview_update_state.as_ref().and_then(|p| {
391            if p.replacements_updated != p.total_replacements_to_update {
392                Some((p.replacements_updated, p.total_replacements_to_update))
393            } else {
394                None
395            }
396        })
397    }
398
399    pub fn cancel_preview_updates(&mut self) {
400        if let Some(ref mut state) = self.preview_update_state {
401            state.replace_debounce_timer.abort();
402            state
403                .update_replacement_cancelled
404                .store(true, Ordering::Relaxed);
405        }
406        self.preview_update_state = None;
407    }
408}
409
410#[derive(Debug)]
411pub enum Screen {
412    SearchFields(SearchFieldsState),
413    PerformingReplacement(PerformingReplacementState),
414    Results(ReplaceState),
415}
416
417impl Screen {
418    fn name(&self) -> &str {
419        // TODO: is there a better way of doing this?
420        match &self {
421            Screen::SearchFields(_) => "SearchFields",
422            Screen::PerformingReplacement(_) => "PerformingReplacement",
423            Screen::Results(_) => "Results",
424        }
425    }
426
427    fn unwrap_search_fields_state_mut(&mut self) -> &mut SearchFieldsState {
428        let name = self.name().to_owned();
429        let Screen::SearchFields(search_fields_state) = self else {
430            panic!("Expected current_screen to be SearchFields, found {name}");
431        };
432        search_fields_state
433    }
434}
435
436#[derive(Debug)]
437pub enum Popup {
438    Error,
439    Help,
440    Text { title: String, body: String },
441}
442
443#[derive(Clone, Debug, PartialEq, Eq)]
444#[allow(clippy::struct_excessive_bools)]
445pub struct AppRunConfig {
446    pub include_hidden: bool,
447    pub advanced_regex: bool,
448    pub immediate_search: bool,
449    pub immediate_replace: bool,
450    pub print_results: bool,
451    pub print_on_exit: bool,
452}
453
454#[allow(clippy::derivable_impls)]
455impl Default for AppRunConfig {
456    fn default() -> Self {
457        Self {
458            include_hidden: false,
459            advanced_regex: false,
460            immediate_search: false,
461            immediate_replace: false,
462            print_results: false,
463            print_on_exit: false,
464        }
465    }
466}
467
468#[derive(Debug)]
469#[allow(clippy::struct_excessive_bools)]
470pub struct App {
471    pub config: Config,
472    key_map: KeyMap,
473    pub current_screen: Screen,
474    pub search_fields: SearchFields,
475    pub searcher: Option<Searcher>,
476    pub input_source: InputSource,
477    pub event_sender: UnboundedSender<Event>,
478    event_receiver: UnboundedReceiver<Event>,
479    errors: Vec<AppError>,
480    include_hidden: bool,
481    immediate_replace: bool,
482    pub print_results: bool,
483    pub print_on_exit: bool,
484    popup: Option<Popup>,
485    advanced_regex: bool,
486}
487
488#[derive(Debug)]
489enum SearchStrategy {
490    Files(FileSearcher),
491    Text {
492        haystack: Arc<String>,
493        config: ParsedSearchConfig,
494    },
495}
496
497fn generate_escape_deprecation_message(quit_keymap: Option<KeyEvent>) -> String {
498    let quit_keymap_str = quit_keymap.map_or("".to_string(), |keymap| {
499        let optional_help = if let KeyEvent {
500            code: KeyCode::Char('c'),
501            modifiers: KeyModifiers::CONTROL,
502        } = keymap
503        {
504            // Add some additional help text when using the default
505            " (i.e. `ctrl + c`)"
506        } else {
507            ""
508        };
509        format!(": use `{keymap}`{optional_help} instead")
510    });
511
512    format!(
513        "Pressing escape to quit is no longer enabled by default{quit_keymap_str}.\n\nYou can remap this in your scooter config.",
514    )
515}
516
517// Macro to get the background processing receiver from current_screen, needed because
518// methods can't express split borrows but macros can
519macro_rules! get_bg_receiver {
520    ($self:expr) => {
521        match &mut $self.current_screen {
522            Screen::SearchFields(SearchFieldsState { search_state, .. }) => {
523                search_state.as_mut().map(|s| &mut s.processing_receiver)
524            }
525            Screen::PerformingReplacement(PerformingReplacementState {
526                processing_receiver,
527                ..
528            }) => Some(processing_receiver),
529            Screen::Results(_) => None,
530        }
531    };
532}
533
534macro_rules! recv_optional {
535    ($opt_receiver:expr) => {
536        async {
537            match $opt_receiver {
538                Some(r) => r.recv().await,
539                None => None,
540            }
541        }
542    };
543}
544
545impl<'a> App {
546    pub fn new(
547        input_source: InputSource,
548        search_field_values: &SearchFieldValues<'a>,
549        app_run_config: &AppRunConfig,
550        config: Config,
551    ) -> anyhow::Result<Self> {
552        let (event_sender, event_receiver) = mpsc::unbounded_channel();
553
554        let search_fields = SearchFields::with_values(
555            search_field_values,
556            config.search.disable_prepopulated_fields,
557        );
558
559        let mut search_fields_state = SearchFieldsState::default();
560        if app_run_config.immediate_search {
561            search_fields_state.focussed_section = FocussedSection::SearchResults;
562        }
563
564        let key_map = KeyMap::from_config(&config.keys).map_err(display_conflict_errors)?;
565
566        let mut app = Self {
567            config,
568            key_map,
569            current_screen: Screen::SearchFields(search_fields_state),
570            search_fields,
571            searcher: None,
572            input_source,
573            include_hidden: app_run_config.include_hidden,
574            errors: vec![],
575            popup: None,
576            event_sender,
577            event_receiver,
578            immediate_replace: app_run_config.immediate_replace,
579            print_results: app_run_config.print_results,
580            print_on_exit: app_run_config.print_on_exit,
581            advanced_regex: app_run_config.advanced_regex,
582        };
583
584        if app_run_config.immediate_search || !search_field_values.search.value.is_empty() {
585            app.perform_search_if_valid();
586        }
587
588        Ok(app)
589    }
590
591    pub fn handle_internal_event(&mut self, event: InternalEvent) -> EventHandlingResult {
592        match event {
593            InternalEvent::App(app_event) => self.handle_app_event(app_event),
594            InternalEvent::Background(bg_event) => {
595                self.handle_background_processing_event(bg_event)
596            }
597        }
598    }
599
600    #[allow(clippy::needless_pass_by_value)]
601    fn handle_app_event(&mut self, app_event: AppEvent) -> EventHandlingResult {
602        match app_event {
603            AppEvent::PerformSearch => {
604                self.perform_search_unwrap();
605                EventHandlingResult::Rerender
606            }
607        }
608    }
609
610    fn cancel_search(&mut self) {
611        if let Screen::SearchFields(SearchFieldsState {
612            search_state: Some(SearchState { cancelled, .. }),
613            ..
614        }) = &mut self.current_screen
615        {
616            cancelled.store(true, Ordering::Relaxed);
617        }
618    }
619
620    fn cancel_replacement(&mut self) {
621        if let Screen::PerformingReplacement(PerformingReplacementState { cancelled, .. }) =
622            &mut self.current_screen
623        {
624            cancelled.store(true, Ordering::Relaxed);
625        }
626    }
627
628    pub fn cancel_in_progress_tasks(&mut self) {
629        self.cancel_search();
630        self.cancel_replacement();
631    }
632
633    pub fn reset(&mut self) {
634        self.cancel_in_progress_tasks();
635        *self = Self::new(
636            self.input_source.clone(), // TODO: avoid cloning
637            &SearchFieldValues::default(),
638            &AppRunConfig {
639                include_hidden: self.include_hidden,
640                advanced_regex: self.advanced_regex,
641                immediate_search: false,
642                immediate_replace: self.immediate_replace,
643                print_results: self.print_results,
644                print_on_exit: self.print_on_exit,
645            },
646            std::mem::take(&mut self.config),
647        )
648        .expect("App initialisation errors should have been detected on initial construction");
649    }
650
651    pub async fn event_recv(&mut self) -> Event {
652        tokio::select! {
653            Some(event) = self.event_receiver.recv() => event,
654            Some(bg_event) = recv_optional!(get_bg_receiver!(self)) => {
655                Event::Internal(InternalEvent::Background(bg_event))
656            }
657        }
658    }
659
660    pub fn background_processing_reciever(
661        &mut self,
662    ) -> Option<&mut UnboundedReceiver<BackgroundProcessingEvent>> {
663        get_bg_receiver!(self)
664    }
665
666    // Called when searching explicitly: shows error popup if validation fails
667    pub fn perform_search_if_valid(&mut self) {
668        let Some(search_config) = self.validate_fields().unwrap() else {
669            return;
670        };
671        self.searcher = Some(search_config);
672        self.perform_search_unwrap();
673    }
674
675    /// NOTE: validation should have been performed (with `validate_fields`) before calling
676    // TODO: how can we enforce validation by type system - e.g. pass in searcher?
677    fn perform_search_unwrap(&mut self) {
678        let Screen::SearchFields(ref mut search_fields_state) = self.current_screen else {
679            return;
680        };
681        if self.search_fields.search().text().is_empty() {
682            search_fields_state.search_state = None;
683        }
684
685        let (background_processing_sender, background_processing_receiver) =
686            mpsc::unbounded_channel();
687        let cancelled = Arc::new(AtomicBool::new(false));
688        let search_state = SearchState::new(
689            background_processing_sender.clone(),
690            background_processing_receiver,
691            cancelled.clone(),
692        );
693
694        let strategy = match &self.searcher {
695            Some(Searcher::FileSearcher(file_searcher)) => {
696                SearchStrategy::Files(file_searcher.clone())
697            }
698            Some(Searcher::TextSearcher { search_config }) => {
699                let InputSource::Stdin(ref stdin) = self.input_source else {
700                    panic!("Expected InputSource::Stdin, found {:?}", self.input_source);
701                };
702                SearchStrategy::Text {
703                    haystack: Arc::clone(stdin),
704                    config: search_config.clone(),
705                }
706            }
707            None => {
708                panic!("Fields should have been parsed")
709            }
710        };
711
712        Self::spawn_search_task(
713            strategy,
714            background_processing_sender.clone(),
715            self.event_sender.clone(),
716            cancelled,
717        );
718
719        search_fields_state.search_state = Some(search_state);
720    }
721
722    #[allow(clippy::needless_pass_by_value)]
723    fn update_all_replacements(&mut self, cancelled: Arc<AtomicBool>) -> EventHandlingResult {
724        if cancelled.load(Ordering::Relaxed) {
725            return EventHandlingResult::None;
726        }
727        let Screen::SearchFields(SearchFieldsState {
728            search_state: Some(search_state),
729            preview_update_state: Some(preview_update_state),
730            ..
731        }) = &mut self.current_screen
732        else {
733            return EventHandlingResult::None;
734        };
735
736        preview_update_state.total_replacements_to_update = search_state.results.len();
737
738        #[allow(clippy::items_after_statements)]
739        static STEP: usize = 7919; // Slightly random so that increments seem more natural in UI
740
741        let num_results = search_state.results.len();
742        for start in (0..num_results).step_by(STEP) {
743            let end = (start + STEP - 1).min(num_results.saturating_sub(1));
744            let _ = search_state.processing_sender.send(
745                BackgroundProcessingEvent::UpdateReplacements {
746                    start,
747                    end,
748                    cancelled: cancelled.clone(),
749                },
750            );
751        }
752
753        EventHandlingResult::Rerender
754    }
755
756    #[allow(clippy::needless_pass_by_value)]
757    fn update_replacements(
758        &mut self,
759        start: usize,
760        end: usize,
761        cancelled: Arc<AtomicBool>,
762    ) -> EventHandlingResult {
763        if cancelled.load(Ordering::Relaxed) {
764            return EventHandlingResult::None;
765        }
766        let Screen::SearchFields(SearchFieldsState {
767            search_state: Some(search_state),
768            preview_update_state: Some(preview_update_state),
769            ..
770        }) = &mut self.current_screen
771        else {
772            return EventHandlingResult::None;
773        };
774        let file_searcher = self
775            .searcher
776            .as_ref()
777            .expect("Fields should have been parsed");
778        for res in &mut search_state.results[start..=end] {
779            match replacement_if_match(
780                &res.search_result.line,
781                file_searcher.search(),
782                file_searcher.replace(),
783            ) {
784                Some(replacement) => res.replacement = replacement,
785                None => return EventHandlingResult::Rerender, // TODO: can we handle this better?
786            }
787        }
788        preview_update_state.replacements_updated += end - start + 1;
789
790        EventHandlingResult::Rerender
791    }
792
793    pub fn perform_replacement(&mut self) {
794        if !self.ready_to_replace() {
795            return;
796        }
797
798        let temp_placeholder = Screen::SearchFields(SearchFieldsState::default());
799        match mem::replace(
800            &mut self.current_screen,
801            temp_placeholder, // Will get reset if we are not on `SearchComplete` screen
802        ) {
803            Screen::SearchFields(SearchFieldsState {
804                search_state: Some(state),
805                ..
806            }) => {
807                let (background_processing_sender, background_processing_receiver) =
808                    mpsc::unbounded_channel();
809                let cancelled = Arc::new(AtomicBool::new(false));
810                let total_replacements = state
811                    .results
812                    .iter()
813                    .filter(|r| r.search_result.included)
814                    .count();
815                let replacements_completed = Arc::new(AtomicUsize::new(0));
816
817                let Some(searcher) = self.validate_fields().unwrap() else {
818                    panic!("Attempted to replace with invalid fields");
819                };
820                match searcher {
821                    Searcher::FileSearcher(file_searcher) => {
822                        replace::perform_replacement(
823                            state.results,
824                            background_processing_sender.clone(),
825                            cancelled.clone(),
826                            replacements_completed.clone(),
827                            self.event_sender.clone(),
828                            Some(file_searcher),
829                        );
830                    }
831                    Searcher::TextSearcher { search_config } => {
832                        let InputSource::Stdin(ref stdin) = self.input_source else {
833                            panic!("Expected stdin input source, found {:?}", self.input_source)
834                        };
835                        self.event_sender
836                            .send(Event::ExitAndReplace(ExitAndReplaceState {
837                                stdin: Arc::clone(stdin),
838                                replace_results: state.results,
839                                search_config,
840                            }))
841                            .expect("Failed to send ExitAndReplace event");
842                    }
843                }
844
845                self.current_screen =
846                    Screen::PerformingReplacement(PerformingReplacementState::new(
847                        background_processing_receiver,
848                        cancelled,
849                        replacements_completed,
850                        total_replacements,
851                    ));
852            }
853            screen => self.current_screen = screen,
854        }
855    }
856
857    fn ready_to_replace(&mut self) -> bool {
858        if !self.search_has_completed() {
859            self.add_error(AppError {
860                name: "Search still in progress".to_string(),
861                long: "Try again when search is complete".to_string(),
862            });
863            return false;
864        } else if !self.is_preview_updated() {
865            self.add_error(AppError {
866                name: "Updating replacement preview".to_string(),
867                long: "Try again when complete".to_string(),
868            });
869            return false;
870        } else if !self
871            .background_processing_reciever()
872            .is_some_and(|r| r.is_empty())
873        {
874            self.add_error(AppError {
875                name: "Background processing in progress".to_string(),
876                long: "Try again in a moment".to_string(),
877            });
878            return false;
879        }
880        true
881    }
882
883    pub fn handle_background_processing_event(
884        &mut self,
885        event: BackgroundProcessingEvent,
886    ) -> EventHandlingResult {
887        match event {
888            BackgroundProcessingEvent::AddSearchResult(result) => {
889                self.add_search_results(iter::once(result))
890            }
891            BackgroundProcessingEvent::AddSearchResults(results) => {
892                self.add_search_results(results)
893            }
894            BackgroundProcessingEvent::SearchCompleted => {
895                if let Screen::SearchFields(SearchFieldsState {
896                    search_state: Some(state),
897                    focussed_section,
898                    ..
899                }) = &mut self.current_screen
900                {
901                    state.set_search_completed_now();
902                    if self.immediate_replace && *focussed_section == FocussedSection::SearchResults
903                    {
904                        self.perform_replacement();
905                    }
906                }
907                EventHandlingResult::Rerender
908            }
909            BackgroundProcessingEvent::ReplacementCompleted(replace_state) => {
910                if self.print_results {
911                    EventHandlingResult::new_exit_stats(replace_state)
912                } else {
913                    self.current_screen = Screen::Results(replace_state);
914                    EventHandlingResult::Rerender
915                }
916            }
917            BackgroundProcessingEvent::UpdateAllReplacements { cancelled } => {
918                self.update_all_replacements(cancelled)
919            }
920            BackgroundProcessingEvent::UpdateReplacements {
921                start,
922                end,
923                cancelled,
924            } => self.update_replacements(start, end, cancelled),
925        }
926    }
927
928    fn add_search_results<I>(&mut self, results: I) -> EventHandlingResult
929    where
930        I: IntoIterator<Item = SearchResult>,
931    {
932        let mut rerender = false;
933        if let Screen::SearchFields(SearchFieldsState {
934            search_state: Some(search_in_progress_state),
935            ..
936        }) = &mut self.current_screen
937        {
938            let mut results_with_replacements = Vec::new();
939            let searcher = self
940                .searcher
941                .as_ref()
942                .expect("searcher should not be None when adding search results");
943            for res in results {
944                let updated = add_replacement(res, searcher.search(), searcher.replace());
945                if let Some(updated) = updated {
946                    results_with_replacements.push(updated);
947                }
948            }
949            search_in_progress_state
950                .results
951                .append(&mut results_with_replacements);
952
953            // Slightly random duration so that time taken isn't a round number
954            if search_in_progress_state.last_render.elapsed() >= Duration::from_millis(92) {
955                rerender = true;
956                search_in_progress_state.last_render = Instant::now();
957            }
958        }
959        if rerender {
960            EventHandlingResult::Rerender
961        } else {
962            EventHandlingResult::None
963        }
964    }
965
966    /// Should only be called on `Screen::SearchFields`, and when focussed section is `FocussedSection::SearchFields`
967    #[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
968    fn handle_command_search_fields(
969        &mut self,
970        event: CommandSearchFocusFields,
971    ) -> EventHandlingResult {
972        match event {
973            CommandSearchFocusFields::UnlockPrepopulatedFields => {
974                self.unlock_prepopulated_fields();
975                EventHandlingResult::Rerender
976            }
977            CommandSearchFocusFields::TriggerSearch => {
978                if !self.errors().is_empty() {
979                    self.set_popup(Popup::Error);
980                } else if self.search_fields.search().text().is_empty() {
981                    self.add_error(AppError {
982                        name: "Search field must not be empty".to_string(),
983                        long: "Please enter some search text".to_string(),
984                    });
985                } else {
986                    let Screen::SearchFields(ref mut search_fields_state) = self.current_screen
987                    else {
988                        panic!(
989                            "Expected SearchFields, found {:?}",
990                            self.current_screen.name()
991                        );
992                    };
993                    search_fields_state.focussed_section = FocussedSection::SearchResults;
994                    // Check if search has been performed
995                    if search_fields_state.search_state.is_some() {
996                        if self.immediate_replace && self.search_has_completed() {
997                            self.perform_replacement();
998                        }
999                    } else {
1000                        if let Some(timer) = search_fields_state.search_debounce_timer.take() {
1001                            timer.abort();
1002                        }
1003                        self.perform_search_if_valid();
1004                    }
1005                }
1006                EventHandlingResult::Rerender
1007            }
1008            CommandSearchFocusFields::FocusPreviousField => {
1009                self.search_fields
1010                    .focus_prev(self.config.search.disable_prepopulated_fields);
1011                EventHandlingResult::Rerender
1012            }
1013            CommandSearchFocusFields::FocusNextField => {
1014                self.search_fields
1015                    .focus_next(self.config.search.disable_prepopulated_fields);
1016                EventHandlingResult::Rerender
1017            }
1018            CommandSearchFocusFields::EnterChars(key_code, key_modifiers) => {
1019                self.enter_chars_into_field(key_code, key_modifiers)
1020            }
1021        }
1022    }
1023
1024    fn enter_chars_into_field(
1025        &mut self,
1026        key_code: KeyCode,
1027        key_modifiers: KeyModifiers,
1028    ) -> EventHandlingResult {
1029        let Screen::SearchFields(ref mut search_fields_state) = self.current_screen else {
1030            return EventHandlingResult::None;
1031        };
1032        if let FieldName::FixedStrings = self.search_fields.highlighted_field().name {
1033            // TODO: ideally this should only happen when the field is checked, but for now this will do
1034            self.search_fields.search_mut().clear_error();
1035        }
1036
1037        search_fields_state.cancel_preview_updates();
1038
1039        self.search_fields.highlighted_field_mut().handle_keys(
1040            key_code,
1041            key_modifiers,
1042            self.config.search.disable_prepopulated_fields,
1043        );
1044        if let Some(search_config) = self.validate_fields().unwrap() {
1045            self.searcher = Some(search_config);
1046        } else {
1047            return EventHandlingResult::Rerender;
1048        }
1049        let Screen::SearchFields(ref mut search_fields_state) = self.current_screen else {
1050            return EventHandlingResult::None;
1051        };
1052        let file_searcher = self
1053            .searcher
1054            .as_ref()
1055            .expect("Fields should have been parsed");
1056
1057        if let FieldName::Replace = self.search_fields.highlighted_field().name {
1058            if let Some(ref mut state) = search_fields_state.search_state {
1059                // Immediately update replacement on selected fields - the remainder will be updated async
1060                if let Some(highlighted) = state.primary_selected_field_mut()
1061                    && let Some(updated) = replacement_if_match(
1062                        &highlighted.search_result.line,
1063                        file_searcher.search(),
1064                        file_searcher.replace(),
1065                    )
1066                {
1067                    highlighted.replacement = updated;
1068                }
1069
1070                // Debounce replacement requests
1071                let sender = state.processing_sender.clone();
1072                let cancelled = Arc::new(AtomicBool::new(false));
1073                let cancelled_clone = cancelled.clone();
1074                let handle = tokio::spawn(async move {
1075                    tokio::time::sleep(Duration::from_millis(300)).await;
1076                    let _ = sender.send(BackgroundProcessingEvent::UpdateAllReplacements {
1077                        cancelled: cancelled_clone,
1078                    });
1079                });
1080                // Note that cancel_preview_updates is called above, which cancels any existing preview updates
1081                search_fields_state.preview_update_state =
1082                    Some(PreviewUpdateStatus::new(handle, cancelled));
1083            }
1084        } else {
1085            // Debounce search requests
1086            if let Some(timer) = search_fields_state.search_debounce_timer.take() {
1087                timer.abort();
1088            }
1089            let event_sender = self.event_sender.clone();
1090            search_fields_state.search_debounce_timer = Some(tokio::spawn(async move {
1091                tokio::time::sleep(Duration::from_millis(300)).await;
1092                let _ =
1093                    event_sender.send(Event::Internal(InternalEvent::App(AppEvent::PerformSearch)));
1094            }));
1095        }
1096        EventHandlingResult::Rerender
1097    }
1098
1099    fn get_search_state_unwrap(&mut self) -> &mut SearchState {
1100        self.current_screen
1101            .unwrap_search_fields_state_mut()
1102            .search_state
1103            .as_mut()
1104            .expect("Focussed on search results but search_state is None")
1105    }
1106
1107    /// Should only be called on `Screen::SearchFields`, and when focussed section is `FocussedSection::SearchResults`
1108    #[allow(clippy::needless_pass_by_value)]
1109    fn handle_command_search_results(
1110        &mut self,
1111        event: CommandSearchFocusResults,
1112    ) -> EventHandlingResult {
1113        assert!(
1114            matches!(self.current_screen, Screen::SearchFields(_)),
1115            "Expected current_screen to be SearchFields, found {}",
1116            self.current_screen.name()
1117        );
1118
1119        match event {
1120            CommandSearchFocusResults::TriggerReplacement => {
1121                self.perform_replacement();
1122                EventHandlingResult::Rerender
1123            }
1124            CommandSearchFocusResults::BackToFields => {
1125                self.cancel_search();
1126                let search_fields_state = self.current_screen.unwrap_search_fields_state_mut();
1127                search_fields_state.focussed_section = FocussedSection::SearchFields;
1128                EventHandlingResult::Rerender
1129            }
1130            CommandSearchFocusResults::OpenInEditor => {
1131                let search_fields_state = self.current_screen.unwrap_search_fields_state_mut();
1132                if let Some(ref mut search_in_progress_state) = search_fields_state.search_state {
1133                    let selected = search_in_progress_state
1134                        .primary_selected_field_mut()
1135                        .expect("Expected to find selected field");
1136                    if let Some(ref path) = selected.search_result.path {
1137                        self.event_sender
1138                            .send(Event::LaunchEditor((
1139                                path.clone(),
1140                                selected.search_result.line_number,
1141                            )))
1142                            .expect("Failed to send event");
1143                    }
1144                }
1145                EventHandlingResult::Rerender
1146            }
1147            CommandSearchFocusResults::MoveDown => {
1148                self.get_search_state_unwrap().move_selected_down();
1149                EventHandlingResult::Rerender
1150            }
1151            CommandSearchFocusResults::MoveUp => {
1152                self.get_search_state_unwrap().move_selected_up();
1153                EventHandlingResult::Rerender
1154            }
1155            CommandSearchFocusResults::MoveDownHalfPage => {
1156                self.get_search_state_unwrap()
1157                    .move_selected_down_half_page();
1158                EventHandlingResult::Rerender
1159            }
1160            CommandSearchFocusResults::MoveDownFullPage => {
1161                self.get_search_state_unwrap()
1162                    .move_selected_down_full_page();
1163                EventHandlingResult::Rerender
1164            }
1165            CommandSearchFocusResults::MoveUpHalfPage => {
1166                self.get_search_state_unwrap().move_selected_up_half_page();
1167                EventHandlingResult::Rerender
1168            }
1169            CommandSearchFocusResults::MoveUpFullPage => {
1170                self.get_search_state_unwrap().move_selected_up_full_page();
1171                EventHandlingResult::Rerender
1172            }
1173            CommandSearchFocusResults::MoveTop => {
1174                self.get_search_state_unwrap().move_selected_top();
1175                EventHandlingResult::Rerender
1176            }
1177            CommandSearchFocusResults::MoveBottom => {
1178                self.get_search_state_unwrap().move_selected_bottom();
1179                EventHandlingResult::Rerender
1180            }
1181            CommandSearchFocusResults::ToggleSelectedInclusion => {
1182                self.get_search_state_unwrap().toggle_selected_inclusion();
1183                EventHandlingResult::Rerender
1184            }
1185            CommandSearchFocusResults::ToggleAllSelected => {
1186                self.get_search_state_unwrap().toggle_all_selected();
1187                EventHandlingResult::Rerender
1188            }
1189            CommandSearchFocusResults::ToggleMultiselectMode => {
1190                self.get_search_state_unwrap().toggle_multiselect_mode();
1191                EventHandlingResult::Rerender
1192            }
1193            CommandSearchFocusResults::FlipMultiselectDirection => {
1194                self.get_search_state_unwrap().flip_multiselect_direction();
1195                EventHandlingResult::Rerender
1196            }
1197        }
1198    }
1199
1200    pub fn handle_key_event(&mut self, key_event: KeyEvent) -> EventHandlingResult {
1201        let command = match self.handle_special_cases(key_event) {
1202            Left(command) => command,
1203            Right(event_handling_result) => return event_handling_result,
1204        };
1205
1206        // Note that general commands are looked up after screen-specific commands in `.lookup`, so this if will only be hit
1207        // if there are no screen-specific commands
1208        if let Command::General(command) = command {
1209            match command {
1210                CommandGeneral::Quit => {
1211                    self.reset();
1212                    return EventHandlingResult::Exit(None);
1213                }
1214                CommandGeneral::Reset => {
1215                    self.reset();
1216                    return EventHandlingResult::Rerender;
1217                }
1218                CommandGeneral::ShowHelpMenu => {
1219                    self.set_popup(Popup::Help);
1220                    return EventHandlingResult::Rerender;
1221                }
1222            }
1223        }
1224
1225        match &mut self.current_screen {
1226            Screen::SearchFields(search_fields_state) => {
1227                let Command::SearchFields(command) = command else {
1228                    panic!("Expected SearchFields command, found {command:?}");
1229                };
1230
1231                match command {
1232                    CommandSearchFields::TogglePreviewWrapping => {
1233                        self.config.preview.wrap_text = !self.config.preview.wrap_text;
1234                        EventHandlingResult::Rerender
1235                    }
1236                    CommandSearchFields::SearchFocusFields(command) => {
1237                        if !matches!(
1238                            search_fields_state.focussed_section,
1239                            FocussedSection::SearchFields
1240                        ) {
1241                            panic!(
1242                                "Expected FocussedSection::SearchFields, found {:?}",
1243                                search_fields_state.focussed_section
1244                            );
1245                        }
1246                        self.handle_command_search_fields(command)
1247                    }
1248                    CommandSearchFields::SearchFocusResults(command) => {
1249                        if !matches!(
1250                            search_fields_state.focussed_section,
1251                            FocussedSection::SearchResults
1252                        ) {
1253                            panic!(
1254                                "Expected FocussedSection::SearchResults, found {:?}",
1255                                search_fields_state.focussed_section
1256                            );
1257                        }
1258                        self.handle_command_search_results(command)
1259                    }
1260                }
1261            }
1262            Screen::PerformingReplacement(_) => EventHandlingResult::None,
1263            Screen::Results(replace_state) => {
1264                let Command::Results(command) = command else {
1265                    panic!("Expected SearchFields event, found {command:?}");
1266                };
1267                replace_state.handle_command_results(command)
1268            }
1269        }
1270    }
1271
1272    fn handle_special_cases(
1273        &mut self,
1274        key_event: KeyEvent,
1275    ) -> Either<Command, EventHandlingResult> {
1276        let maybe_event = self.key_map.lookup(&self.current_screen, key_event);
1277
1278        // Quit should take precedent over closing popup etc.
1279        if !matches!(maybe_event, Some(Command::General(CommandGeneral::Quit))) {
1280            if self.popup.is_some() {
1281                self.clear_popup();
1282                return Right(EventHandlingResult::Rerender);
1283            }
1284            if key_event.code == KeyCode::Esc && self.multiselect_enabled() {
1285                self.toggle_multiselect_mode();
1286                return Right(EventHandlingResult::Rerender);
1287            }
1288        }
1289
1290        let event = if let Some(event) = maybe_event {
1291            event
1292        } else {
1293            if key_event.code == KeyCode::Esc {
1294                let quit_keymap = self.config.keys.general.quit.first().copied();
1295                self.set_popup(Popup::Text {
1296                    title: "Key mapping deprecated".to_string(),
1297                    body: generate_escape_deprecation_message(quit_keymap),
1298                });
1299                return Right(EventHandlingResult::Rerender);
1300            }
1301
1302            // If we're in SearchFields focus, treat unmatched keys as text input
1303            if let Screen::SearchFields(state) = &self.current_screen {
1304                if state.focussed_section == FocussedSection::SearchFields {
1305                    Command::SearchFields(CommandSearchFields::SearchFocusFields(
1306                        CommandSearchFocusFields::EnterChars(key_event.code, key_event.modifiers),
1307                    ))
1308                } else {
1309                    return Right(EventHandlingResult::None);
1310                }
1311            } else {
1312                return Right(EventHandlingResult::None);
1313            }
1314        };
1315        Left(event)
1316    }
1317
1318    pub fn validate_fields(&mut self) -> anyhow::Result<Option<Searcher>> {
1319        let search_config = SearchConfig {
1320            search_text: self.search_fields.search().text(),
1321            replacement_text: self.search_fields.replace().text(),
1322            fixed_strings: self.search_fields.fixed_strings().checked,
1323            advanced_regex: self.advanced_regex,
1324            match_whole_word: self.search_fields.whole_word().checked,
1325            match_case: self.search_fields.match_case().checked,
1326        };
1327        let dir_config = match &self.input_source {
1328            InputSource::Directory(directory) => Some(DirConfig {
1329                include_globs: Some(self.search_fields.include_files().text()),
1330                exclude_globs: Some(self.search_fields.exclude_files().text()),
1331                include_hidden: self.include_hidden,
1332                directory: directory.clone(),
1333            }),
1334            InputSource::Stdin(_) => None,
1335        };
1336
1337        let mut error_handler = AppErrorHandler::new();
1338        let result = validate_search_configuration(search_config, dir_config, &mut error_handler)?;
1339        error_handler.apply_to_app(self);
1340
1341        let maybe_searcher = match result {
1342            ValidationResult::Success((search_config, dir_config)) => match &self.input_source {
1343                InputSource::Directory(_) => {
1344                    let file_searcher = FileSearcher::new(
1345                        search_config,
1346                        dir_config.expect("Found None dir_config when searching through files"),
1347                    );
1348                    Some(Searcher::FileSearcher(file_searcher))
1349                }
1350                InputSource::Stdin(_) => Some(Searcher::TextSearcher { search_config }),
1351            },
1352            ValidationResult::ValidationErrors => None,
1353        };
1354        Ok(maybe_searcher)
1355    }
1356
1357    fn spawn_search_task(
1358        strategy: SearchStrategy,
1359        background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
1360        event_sender: UnboundedSender<Event>,
1361        cancelled: Arc<AtomicBool>,
1362    ) -> JoinHandle<()> {
1363        tokio::spawn(async move {
1364            let sender_for_search = background_processing_sender.clone();
1365            let mut search_handle = task::spawn_blocking(move || {
1366                match strategy {
1367                    SearchStrategy::Files(file_searcher) => {
1368                        file_searcher.walk_files(Some(&cancelled), || {
1369                            let sender = sender_for_search.clone();
1370                            Box::new(move |results| {
1371                                // Ignore error - likely state reset, thread about to be killed
1372                                let _ = sender
1373                                    .send(BackgroundProcessingEvent::AddSearchResults(results));
1374                                WalkState::Continue
1375                            })
1376                        });
1377                    }
1378                    SearchStrategy::Text { haystack, config } => {
1379                        let cursor = Cursor::new(haystack.as_bytes());
1380                        for (idx, line_result) in cursor.lines_with_endings().enumerate() {
1381                            if cancelled.load(Ordering::Relaxed) {
1382                                break;
1383                            }
1384
1385                            let (line_ending, line) = match read_line(line_result) {
1386                                Ok(res) => res,
1387                                Err(e) => {
1388                                    debug!("Error when reading line {idx}: {e}");
1389                                    continue;
1390                                }
1391                            };
1392                            if replacement_if_match(&line, &config.search, &config.replace)
1393                                .is_some()
1394                            {
1395                                let result = SearchResult {
1396                                    path: None,
1397                                    line_number: idx + 1,
1398                                    line,
1399                                    line_ending,
1400                                    included: true,
1401                                };
1402                                // Ignore error - likely state reset, thread about to be killed
1403                                let _ = sender_for_search
1404                                    .send(BackgroundProcessingEvent::AddSearchResult(result));
1405                            }
1406                        }
1407                    }
1408                }
1409            });
1410
1411            let mut rerender_interval = tokio::time::interval(Duration::from_millis(92)); // Slightly random duration so that time taken isn't a round number
1412            rerender_interval.tick().await;
1413
1414            loop {
1415                tokio::select! {
1416                    res = &mut search_handle => {
1417                        if let Err(e) = res {
1418                            warn!("Search thread panicked: {e}");
1419                        }
1420                        break;
1421                    },
1422                    _ = rerender_interval.tick() => {
1423                        let _ = event_sender.send(Event::Rerender);
1424                    }
1425                }
1426            }
1427
1428            if let Err(err) =
1429                background_processing_sender.send(BackgroundProcessingEvent::SearchCompleted)
1430            {
1431                // Log and ignore error: likely have gone back to previous screen
1432                warn!("Found error when attempting to send SearchCompleted event: {err}");
1433            }
1434        })
1435    }
1436
1437    pub fn show_popup(&self) -> bool {
1438        self.popup.is_some()
1439    }
1440
1441    pub fn popup(&self) -> Option<&Popup> {
1442        self.popup.as_ref()
1443    }
1444
1445    pub fn errors(&self) -> Vec<AppError> {
1446        let app_errors = self.errors.clone().into_iter();
1447        let field_errors = self.search_fields.errors().into_iter();
1448        app_errors.chain(field_errors).collect()
1449    }
1450
1451    pub fn add_error(&mut self, error: AppError) {
1452        self.popup = Some(Popup::Error);
1453        self.errors.push(error);
1454    }
1455
1456    fn clear_popup(&mut self) {
1457        self.popup = None;
1458        self.errors.clear();
1459    }
1460
1461    fn set_popup(&mut self, popup: Popup) {
1462        self.popup = Some(popup);
1463    }
1464
1465    pub fn keymaps_all(&self) -> Vec<(String, String)> {
1466        self.keymaps_impl(false)
1467    }
1468
1469    pub fn keymaps_compact(&self) -> Vec<(String, String)> {
1470        self.keymaps_impl(true)
1471    }
1472
1473    #[allow(clippy::too_many_lines)]
1474    fn keymaps_impl(&self, compact: bool) -> Vec<(String, String)> {
1475        enum Show {
1476            Both,
1477            FullOnly,
1478            #[allow(dead_code)]
1479            CompactOnly,
1480        }
1481
1482        macro_rules! keymap {
1483            ($($path:tt).+, $name:expr, $show:expr $(,)?) => {
1484                (
1485                    format!("<{}>", self.config.keys.$($path).+.first()
1486                        .map_or_else(|| "n/a".to_string(), std::string::ToString::to_string)),
1487                    $name,
1488                    $show,
1489                )
1490            };
1491        }
1492
1493        let current_screen_keys = match &self.current_screen {
1494            Screen::SearchFields(search_fields_state) => {
1495                let mut keys = vec![];
1496                match search_fields_state.focussed_section {
1497                    FocussedSection::SearchFields => {
1498                        keys.extend([
1499                            keymap!(search.fields.trigger_search, "jump to results", Show::Both),
1500                            keymap!(search.fields.focus_next_field, "focus next", Show::Both),
1501                            keymap!(
1502                                search.fields.focus_previous_field,
1503                                "focus previous",
1504                                Show::FullOnly,
1505                            ),
1506                            ("<space>".to_string(), "toggle checkbox", Show::FullOnly), // TODO(key-remap): add to config?
1507                        ]);
1508                        if self.config.search.disable_prepopulated_fields {
1509                            keys.push(keymap!(
1510                                search.fields.unlock_prepopulated_fields,
1511                                "unlock pre-populated fields",
1512                                if self.search_fields.fields.iter().any(|f| f.set_by_cli) {
1513                                    Show::Both
1514                                } else {
1515                                    Show::FullOnly
1516                                },
1517                            ));
1518                        }
1519                    }
1520                    FocussedSection::SearchResults => {
1521                        keys.extend([
1522                            keymap!(
1523                                search.results.toggle_selected_inclusion,
1524                                "toggle",
1525                                Show::Both,
1526                            ),
1527                            keymap!(
1528                                search.results.toggle_all_selected,
1529                                "toggle all",
1530                                Show::FullOnly,
1531                            ),
1532                            keymap!(
1533                                search.results.toggle_multiselect_mode,
1534                                "toggle multi-select mode",
1535                                Show::FullOnly,
1536                            ),
1537                            keymap!(
1538                                search.results.flip_multiselect_direction,
1539                                "flip multi-select direction",
1540                                Show::FullOnly,
1541                            ),
1542                            keymap!(
1543                                search.results.open_in_editor,
1544                                "open in editor",
1545                                Show::FullOnly,
1546                            ),
1547                            keymap!(
1548                                search.results.back_to_fields,
1549                                "back to search fields",
1550                                Show::Both,
1551                            ),
1552                            keymap!(search.results.move_down, "down", Show::FullOnly),
1553                            keymap!(search.results.move_up, "up", Show::FullOnly),
1554                            keymap!(
1555                                search.results.move_up_half_page,
1556                                "up half a page",
1557                                Show::FullOnly
1558                            ),
1559                            keymap!(
1560                                search.results.move_down_half_page,
1561                                "down half a page",
1562                                Show::FullOnly
1563                            ),
1564                            keymap!(
1565                                search.results.move_up_full_page,
1566                                "up a full page",
1567                                Show::FullOnly
1568                            ),
1569                            keymap!(
1570                                search.results.move_down_full_page,
1571                                "down a full page",
1572                                Show::FullOnly
1573                            ),
1574                            keymap!(search.results.move_top, "jump to top", Show::FullOnly),
1575                            keymap!(search.results.move_bottom, "jump to bottom", Show::FullOnly),
1576                        ]);
1577                        if self.search_has_completed() {
1578                            keys.push(keymap!(
1579                                search.results.trigger_replacement,
1580                                "replace selected",
1581                                Show::Both,
1582                            ));
1583                        }
1584                    }
1585                }
1586                keys.push(keymap!(
1587                    search.toggle_preview_wrapping,
1588                    "toggle text wrapping in preview",
1589                    Show::FullOnly,
1590                ));
1591                keys
1592            }
1593            Screen::PerformingReplacement(_) => vec![],
1594            Screen::Results(replace_state) => {
1595                if !replace_state.errors.is_empty() {
1596                    vec![
1597                        keymap!(results.scroll_errors_down, "down", Show::Both),
1598                        keymap!(results.scroll_errors_up, "up", Show::Both),
1599                    ]
1600                } else {
1601                    vec![]
1602                }
1603            }
1604        };
1605
1606        let on_search_results = if let Screen::SearchFields(ref s) = self.current_screen {
1607            s.focussed_section == FocussedSection::SearchResults
1608        } else {
1609            false
1610        };
1611
1612        let esc_help = format!(
1613            "close popup{}",
1614            if on_search_results {
1615                " / exit multi-select"
1616            } else {
1617                ""
1618            }
1619        );
1620
1621        let additional_keys = vec![
1622            keymap!(
1623                general.reset,
1624                "reset",
1625                if on_search_results {
1626                    Show::FullOnly
1627                } else {
1628                    Show::Both
1629                },
1630            ),
1631            keymap!(general.show_help_menu, "help", Show::Both),
1632            ("<esc>".to_string(), esc_help.as_str(), Show::FullOnly),
1633            keymap!(general.quit, "quit", Show::Both),
1634        ];
1635
1636        let all_keys = current_screen_keys.into_iter().chain(additional_keys);
1637
1638        all_keys
1639            .filter_map(move |(from, to, show)| {
1640                let include = match show {
1641                    Show::Both => true,
1642                    Show::CompactOnly => compact,
1643                    Show::FullOnly => !compact,
1644                };
1645                if include {
1646                    Some((from, to.to_owned()))
1647                } else {
1648                    None
1649                }
1650            })
1651            .collect()
1652    }
1653
1654    fn multiselect_enabled(&self) -> bool {
1655        match &self.current_screen {
1656            Screen::SearchFields(SearchFieldsState {
1657                search_state: Some(state),
1658                ..
1659            }) => state.multiselect_enabled(),
1660            _ => false,
1661        }
1662    }
1663
1664    fn toggle_multiselect_mode(&mut self) {
1665        match &mut self.current_screen {
1666            Screen::SearchFields(SearchFieldsState {
1667                search_state: Some(state),
1668                ..
1669            }) => state.toggle_multiselect_mode(),
1670            _ => panic!(
1671                "Tried to disable multi-select on {:?}",
1672                self.current_screen.name()
1673            ),
1674        }
1675    }
1676
1677    fn unlock_prepopulated_fields(&mut self) {
1678        for field in &mut self.search_fields.fields {
1679            field.set_by_cli = false;
1680        }
1681    }
1682
1683    pub fn search_has_completed(&self) -> bool {
1684        if let Screen::SearchFields(SearchFieldsState {
1685            search_state: Some(state),
1686            search_debounce_timer,
1687            ..
1688        }) = &self.current_screen
1689        {
1690            state.search_completed.is_some()
1691                && search_debounce_timer
1692                    .as_ref()
1693                    .is_none_or(tokio::task::JoinHandle::is_finished)
1694        } else {
1695            false
1696        }
1697    }
1698
1699    pub fn is_preview_updated(&self) -> bool {
1700        if let Screen::SearchFields(SearchFieldsState {
1701            search_state:
1702                Some(SearchState {
1703                    processing_receiver,
1704                    ..
1705                }),
1706            preview_update_state,
1707            ..
1708        }) = &self.current_screen
1709        {
1710            processing_receiver.is_empty()
1711                && preview_update_state
1712                    .as_ref()
1713                    .is_none_or(|p| p.replace_debounce_timer.is_finished())
1714        } else {
1715            false
1716        }
1717    }
1718}
1719
1720fn read_line(
1721    line_result: Result<(Vec<u8>, LineEnding), std::io::Error>,
1722) -> anyhow::Result<(LineEnding, String)> {
1723    let (line_bytes, line_ending) = line_result?;
1724    let line = String::from_utf8(line_bytes)?;
1725    Ok((line_ending, line))
1726}
1727
1728#[allow(clippy::struct_field_names)]
1729#[derive(Clone, Debug, PartialEq, Eq)]
1730struct AppErrorHandler {
1731    search_errors: Option<(String, String)>,
1732    include_errors: Option<(String, String)>,
1733    exclude_errors: Option<(String, String)>,
1734}
1735
1736impl AppErrorHandler {
1737    fn new() -> Self {
1738        Self {
1739            search_errors: None,
1740            include_errors: None,
1741            exclude_errors: None,
1742        }
1743    }
1744
1745    fn apply_to_app(&self, app: &mut App) {
1746        if let Some((error, detail)) = &self.search_errors {
1747            app.search_fields
1748                .search_mut()
1749                .set_error(error.clone(), detail.clone());
1750        }
1751
1752        if let Some((error, detail)) = &self.include_errors {
1753            app.search_fields
1754                .include_files_mut()
1755                .set_error(error.clone(), detail.clone());
1756        }
1757
1758        if let Some((error, detail)) = &self.exclude_errors {
1759            app.search_fields
1760                .exclude_files_mut()
1761                .set_error(error.clone(), detail.clone());
1762        }
1763    }
1764}
1765
1766impl ValidationErrorHandler for AppErrorHandler {
1767    fn handle_search_text_error(&mut self, error: &str, detail: &str) {
1768        self.search_errors = Some((error.to_owned(), detail.to_string()));
1769    }
1770
1771    fn handle_include_files_error(&mut self, error: &str, detail: &str) {
1772        self.include_errors = Some((error.to_owned(), detail.to_string()));
1773    }
1774
1775    fn handle_exclude_files_error(&mut self, error: &str, detail: &str) {
1776        self.exclude_errors = Some((error.to_owned(), detail.to_string()));
1777    }
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782    use frep_core::{
1783        line_reader::LineEnding,
1784        replace::{ReplaceResult, ReplaceStats},
1785        search::{SearchResult, SearchResultWithReplacement},
1786    };
1787    use rand::Rng;
1788
1789    use super::*;
1790
1791    fn random_num() -> usize {
1792        let mut rng = rand::rng();
1793        rng.random_range(1..10000)
1794    }
1795
1796    fn search_result_with_replacement(included: bool) -> SearchResultWithReplacement {
1797        SearchResultWithReplacement {
1798            search_result: SearchResult {
1799                path: Some(PathBuf::from("random/file")),
1800                line_number: random_num(),
1801                line: "foo".to_owned(),
1802                line_ending: LineEnding::Lf,
1803                included,
1804            },
1805            replacement: "bar".to_owned(),
1806            replace_result: None,
1807        }
1808    }
1809
1810    fn build_test_results(num_results: usize) -> Vec<SearchResultWithReplacement> {
1811        (0..num_results)
1812            .map(|i| SearchResultWithReplacement {
1813                search_result: SearchResult {
1814                    path: Some(PathBuf::from(format!("test{i}.txt"))),
1815                    line_number: 1,
1816                    line: format!("test line {i}").to_string(),
1817                    line_ending: LineEnding::Lf,
1818                    included: true,
1819                },
1820                replacement: format!("replacement {i}").to_string(),
1821                replace_result: None,
1822            })
1823            .collect()
1824    }
1825
1826    fn build_test_search_state(num_results: usize) -> SearchState {
1827        let results = build_test_results(num_results);
1828        build_test_search_state_with_results(results)
1829    }
1830
1831    fn build_test_search_state_with_results(
1832        results: Vec<SearchResultWithReplacement>,
1833    ) -> SearchState {
1834        let (processing_sender, processing_receiver) = mpsc::unbounded_channel();
1835        SearchState {
1836            results,
1837            selected: Selected::Single(0),
1838            view_offset: 0,
1839            num_displayed: Some(5),
1840            processing_receiver,
1841            processing_sender,
1842            cancelled: Arc::new(AtomicBool::new(false)),
1843            last_render: Instant::now(),
1844            search_started: Instant::now(),
1845            search_completed: None,
1846        }
1847    }
1848
1849    #[test]
1850    fn test_toggle_all_selected_when_all_selected() {
1851        let mut search_state = build_test_search_state_with_results(vec![
1852            search_result_with_replacement(true),
1853            search_result_with_replacement(true),
1854            search_result_with_replacement(true),
1855        ]);
1856        search_state.toggle_all_selected();
1857        assert_eq!(
1858            search_state
1859                .results
1860                .iter()
1861                .map(|res| res.search_result.included)
1862                .collect::<Vec<_>>(),
1863            vec![false, false, false]
1864        );
1865    }
1866
1867    #[test]
1868    fn test_toggle_all_selected_when_none_selected() {
1869        let mut search_state = build_test_search_state_with_results(vec![
1870            search_result_with_replacement(false),
1871            search_result_with_replacement(false),
1872            search_result_with_replacement(false),
1873        ]);
1874        search_state.toggle_all_selected();
1875        assert_eq!(
1876            search_state
1877                .results
1878                .iter()
1879                .map(|res| res.search_result.included)
1880                .collect::<Vec<_>>(),
1881            vec![true, true, true]
1882        );
1883    }
1884
1885    #[test]
1886    fn test_toggle_all_selected_when_some_selected() {
1887        let mut search_state = build_test_search_state_with_results(vec![
1888            search_result_with_replacement(true),
1889            search_result_with_replacement(false),
1890            search_result_with_replacement(true),
1891        ]);
1892        search_state.toggle_all_selected();
1893        assert_eq!(
1894            search_state
1895                .results
1896                .iter()
1897                .map(|res| res.search_result.included)
1898                .collect::<Vec<_>>(),
1899            vec![true, true, true]
1900        );
1901    }
1902
1903    #[test]
1904    fn test_toggle_all_selected_when_no_results() {
1905        let mut search_state = build_test_search_state_with_results(vec![]);
1906        search_state.toggle_all_selected();
1907        assert_eq!(
1908            search_state
1909                .results
1910                .iter()
1911                .map(|res| res.search_result.included)
1912                .collect::<Vec<_>>(),
1913            vec![] as Vec<bool>
1914        );
1915    }
1916
1917    fn success_result() -> SearchResultWithReplacement {
1918        SearchResultWithReplacement {
1919            search_result: SearchResult {
1920                path: Some(PathBuf::from("random/file")),
1921                line_number: random_num(),
1922                line: "foo".to_owned(),
1923                line_ending: LineEnding::Lf,
1924                included: true,
1925            },
1926            replacement: "bar".to_owned(),
1927            replace_result: Some(ReplaceResult::Success),
1928        }
1929    }
1930
1931    fn ignored_result() -> SearchResultWithReplacement {
1932        SearchResultWithReplacement {
1933            search_result: SearchResult {
1934                path: Some(PathBuf::from("random/file")),
1935                line_number: random_num(),
1936                line: "foo".to_owned(),
1937                line_ending: LineEnding::Lf,
1938                included: false,
1939            },
1940            replacement: "bar".to_owned(),
1941            replace_result: None,
1942        }
1943    }
1944
1945    fn error_result() -> SearchResultWithReplacement {
1946        SearchResultWithReplacement {
1947            search_result: SearchResult {
1948                path: Some(PathBuf::from("random/file")),
1949                line_number: random_num(),
1950                line: "foo".to_owned(),
1951                line_ending: LineEnding::Lf,
1952                included: true,
1953            },
1954            replacement: "bar".to_owned(),
1955            replace_result: Some(ReplaceResult::Error("error".to_owned())),
1956        }
1957    }
1958
1959    #[tokio::test]
1960    async fn test_calculate_statistics_all_success() {
1961        let search_results_with_replacements =
1962            vec![success_result(), success_result(), success_result()];
1963
1964        let (results, _num_ignored) =
1965            crate::replace::split_results(search_results_with_replacements);
1966        let stats = frep_core::replace::calculate_statistics(results);
1967
1968        assert_eq!(
1969            stats,
1970            ReplaceStats {
1971                num_successes: 3,
1972                errors: vec![],
1973            }
1974        );
1975    }
1976
1977    #[tokio::test]
1978    async fn test_calculate_statistics_with_ignores_and_errors() {
1979        let error_result = error_result();
1980        let search_results_with_replacements = vec![
1981            success_result(),
1982            ignored_result(),
1983            success_result(),
1984            error_result.clone(),
1985            ignored_result(),
1986        ];
1987
1988        let (results, _num_ignored) =
1989            crate::replace::split_results(search_results_with_replacements);
1990        let stats = frep_core::replace::calculate_statistics(results);
1991
1992        assert_eq!(
1993            stats,
1994            ReplaceStats {
1995                num_successes: 2,
1996                errors: vec![error_result],
1997            }
1998        );
1999    }
2000
2001    #[tokio::test]
2002    async fn test_search_state_toggling() {
2003        fn included(state: &SearchState) -> Vec<bool> {
2004            state
2005                .results
2006                .iter()
2007                .map(|r| r.search_result.included)
2008                .collect::<Vec<_>>()
2009        }
2010
2011        let mut state = build_test_search_state(3);
2012
2013        assert_eq!(included(&state), [true, true, true]);
2014        state.toggle_selected_inclusion();
2015        assert_eq!(included(&state), [false, true, true]);
2016        state.toggle_selected_inclusion();
2017        assert_eq!(included(&state), [true, true, true]);
2018        state.toggle_selected_inclusion();
2019        assert_eq!(included(&state), [false, true, true]);
2020        state.move_selected_down();
2021        state.toggle_selected_inclusion();
2022        assert_eq!(included(&state), [false, false, true]);
2023        state.toggle_selected_inclusion();
2024        assert_eq!(included(&state), [false, true, true]);
2025    }
2026
2027    #[tokio::test]
2028    async fn test_search_state_movement_single() {
2029        let mut state = build_test_search_state(3);
2030
2031        assert_eq!(state.selected, Selected::Single(0));
2032        state.move_selected_down();
2033        assert_eq!(state.selected, Selected::Single(1));
2034        state.move_selected_down();
2035        assert_eq!(state.selected, Selected::Single(2));
2036        state.move_selected_down();
2037        assert_eq!(state.selected, Selected::Single(0));
2038        state.move_selected_down();
2039        assert_eq!(state.selected, Selected::Single(1));
2040        state.move_selected_up();
2041        assert_eq!(state.selected, Selected::Single(0));
2042        state.move_selected_up();
2043        assert_eq!(state.selected, Selected::Single(2));
2044        state.move_selected_up();
2045        assert_eq!(state.selected, Selected::Single(1));
2046    }
2047
2048    #[tokio::test]
2049    async fn test_search_state_movement_top_bottom() {
2050        let mut state = build_test_search_state(3);
2051
2052        state.move_selected_top();
2053        assert_eq!(state.selected, Selected::Single(0));
2054        state.move_selected_bottom();
2055        assert_eq!(state.selected, Selected::Single(2));
2056        state.move_selected_bottom();
2057        assert_eq!(state.selected, Selected::Single(2));
2058        state.move_selected_top();
2059        assert_eq!(state.selected, Selected::Single(0));
2060    }
2061
2062    #[tokio::test]
2063    async fn test_search_state_movement_half_page_increments() {
2064        let mut state = build_test_search_state(8);
2065
2066        assert_eq!(state.selected, Selected::Single(0));
2067        state.move_selected_down_half_page();
2068        assert_eq!(state.selected, Selected::Single(3));
2069        state.move_selected_down_half_page();
2070        assert_eq!(state.selected, Selected::Single(6));
2071        state.move_selected_down_half_page();
2072        assert_eq!(state.selected, Selected::Single(7));
2073        state.move_selected_up_half_page();
2074        assert_eq!(state.selected, Selected::Single(4));
2075        state.move_selected_up_half_page();
2076        assert_eq!(state.selected, Selected::Single(1));
2077        state.move_selected_up_half_page();
2078        assert_eq!(state.selected, Selected::Single(0));
2079        state.move_selected_up_half_page();
2080        assert_eq!(state.selected, Selected::Single(7));
2081        state.move_selected_up_half_page();
2082        assert_eq!(state.selected, Selected::Single(4));
2083        state.move_selected_down_half_page();
2084        assert_eq!(state.selected, Selected::Single(7));
2085        state.move_selected_down_half_page();
2086        assert_eq!(state.selected, Selected::Single(0));
2087    }
2088
2089    #[tokio::test]
2090    async fn test_search_state_movement_page_increments() {
2091        let mut state = build_test_search_state(12);
2092
2093        assert_eq!(state.selected, Selected::Single(0));
2094        state.move_selected_down_full_page();
2095        assert_eq!(state.selected, Selected::Single(5));
2096        state.move_selected_down_full_page();
2097        assert_eq!(state.selected, Selected::Single(10));
2098        state.move_selected_down_full_page();
2099        assert_eq!(state.selected, Selected::Single(11));
2100        state.move_selected_down_full_page();
2101        assert_eq!(state.selected, Selected::Single(0));
2102        state.move_selected_up_full_page();
2103        assert_eq!(state.selected, Selected::Single(11));
2104        state.move_selected_up_full_page();
2105        assert_eq!(state.selected, Selected::Single(6));
2106        state.move_selected_up_full_page();
2107        assert_eq!(state.selected, Selected::Single(1));
2108        state.move_selected_up_full_page();
2109        assert_eq!(state.selected, Selected::Single(0));
2110        state.move_selected_up_full_page();
2111        assert_eq!(state.selected, Selected::Single(11));
2112        state.move_selected_up_full_page();
2113        assert_eq!(state.selected, Selected::Single(6));
2114        state.move_selected_up();
2115        assert_eq!(state.selected, Selected::Single(5));
2116        state.move_selected_up();
2117        assert_eq!(state.selected, Selected::Single(4));
2118        state.move_selected_up_full_page();
2119        assert_eq!(state.selected, Selected::Single(0));
2120    }
2121
2122    #[test]
2123    fn test_selected_fields_movement() {
2124        let mut results = build_test_results(10);
2125        let mut state = build_test_search_state_with_results(results.clone());
2126
2127        assert_eq!(state.selected, Selected::Single(0));
2128        assert_eq!(state.selected_fields(), &mut results[0..=0]);
2129
2130        state.toggle_multiselect_mode();
2131        assert_eq!(
2132            state.selected,
2133            Selected::Multi(MultiSelected {
2134                anchor: 0,
2135                primary: 0,
2136            })
2137        );
2138        assert_eq!(state.selected_fields(), &mut results[0..=0]);
2139
2140        state.move_selected_down();
2141        state.move_selected_down();
2142        assert_eq!(
2143            state.selected,
2144            Selected::Multi(MultiSelected {
2145                anchor: 0,
2146                primary: 2,
2147            })
2148        );
2149        assert_eq!(state.selected_fields(), &mut results[0..=2]);
2150
2151        state.toggle_multiselect_mode();
2152        assert_eq!(state.selected, Selected::Single(2));
2153        assert_eq!(state.selected_fields(), &mut results[2..=2]);
2154
2155        state.toggle_multiselect_mode();
2156        assert_eq!(
2157            state.selected,
2158            Selected::Multi(MultiSelected {
2159                anchor: 2,
2160                primary: 2,
2161            })
2162        );
2163        assert_eq!(state.selected_fields(), &mut results[2..=2]);
2164    }
2165
2166    #[test]
2167    fn test_selected_fields_toggling() {
2168        let mut state = build_test_search_state(6);
2169
2170        assert_eq!(state.selected, Selected::Single(0));
2171        state.move_selected_down();
2172        state.move_selected_down();
2173        state.move_selected_down();
2174        state.move_selected_down();
2175        assert_eq!(state.selected, Selected::Single(4));
2176        state.toggle_multiselect_mode();
2177        assert_eq!(
2178            state.selected,
2179            Selected::Multi(MultiSelected {
2180                anchor: 4,
2181                primary: 4,
2182            })
2183        );
2184        assert_eq!(state.selected_fields(), &state.results[4..=4]);
2185        state.move_selected_up();
2186        state.move_selected_up();
2187        assert_eq!(
2188            state.selected,
2189            Selected::Multi(MultiSelected {
2190                anchor: 4,
2191                primary: 2,
2192            })
2193        );
2194        assert_eq!(state.selected_fields(), &state.results[2..=4]);
2195        assert_eq!(
2196            state
2197                .results
2198                .iter()
2199                .map(|res| res.search_result.included)
2200                .collect::<Vec<_>>(),
2201            vec![true, true, true, true, true, true]
2202        );
2203        state.toggle_selected_inclusion();
2204        assert_eq!(
2205            state
2206                .results
2207                .iter()
2208                .map(|res| res.search_result.included)
2209                .collect::<Vec<_>>(),
2210            vec![true, true, false, false, false, true]
2211        );
2212        assert_eq!(
2213            state.selected,
2214            Selected::Multi(MultiSelected {
2215                anchor: 4,
2216                primary: 2,
2217            })
2218        );
2219        assert_eq!(state.selected_fields(), &state.results[2..=4]);
2220        state.toggle_multiselect_mode();
2221        assert_eq!(state.selected, Selected::Single(2));
2222        assert_eq!(state.selected_fields(), &state.results[2..=2]);
2223        state.move_selected_up();
2224        state.move_selected_up();
2225        assert_eq!(state.selected, Selected::Single(0));
2226        assert_eq!(state.selected_fields(), &state.results[0..=0]);
2227        state.toggle_selected_inclusion();
2228        assert_eq!(
2229            state
2230                .results
2231                .iter()
2232                .map(|res| res.search_result.included)
2233                .collect::<Vec<_>>(),
2234            vec![false, true, false, false, false, true]
2235        );
2236    }
2237
2238    #[test]
2239    fn test_flip_multi_select_direction() {
2240        let mut state = build_test_search_state(10);
2241        assert_eq!(state.selected, Selected::Single(0));
2242        state.flip_multiselect_direction();
2243        assert_eq!(state.selected, Selected::Single(0));
2244        state.move_selected_down();
2245        assert_eq!(state.selected, Selected::Single(1));
2246        state.toggle_multiselect_mode();
2247        state.move_selected_down();
2248        state.move_selected_down();
2249        assert_eq!(
2250            state.selected,
2251            Selected::Multi(MultiSelected {
2252                anchor: 1,
2253                primary: 3,
2254            })
2255        );
2256        state.flip_multiselect_direction();
2257        assert_eq!(
2258            state.selected,
2259            Selected::Multi(MultiSelected {
2260                anchor: 3,
2261                primary: 1,
2262            })
2263        );
2264        state.move_selected_up();
2265        assert_eq!(
2266            state.selected,
2267            Selected::Multi(MultiSelected {
2268                anchor: 3,
2269                primary: 0,
2270            })
2271        );
2272        state.flip_multiselect_direction();
2273        assert_eq!(
2274            state.selected,
2275            Selected::Multi(MultiSelected {
2276                anchor: 0,
2277                primary: 3,
2278            })
2279        );
2280        state.move_selected_bottom();
2281        assert_eq!(
2282            state.selected,
2283            Selected::Multi(MultiSelected {
2284                anchor: 0,
2285                primary: 9,
2286            })
2287        );
2288        state.move_selected_down();
2289        assert_eq!(state.selected, Selected::Single(0));
2290    }
2291
2292    #[test]
2293    fn test_key_handling_quit_takes_precedent() {
2294        let mut app = App::new(
2295            InputSource::Directory(std::env::current_dir().unwrap()),
2296            &SearchFieldValues::default(),
2297            &AppRunConfig::default(),
2298            Config::default(),
2299        )
2300        .unwrap();
2301        app.set_popup(Popup::Text {
2302            title: "Error title".to_owned(),
2303            body: "some text in the body".to_owned(),
2304        });
2305        let res = app.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
2306        assert!(matches!(res, EventHandlingResult::Exit(None)));
2307    }
2308
2309    #[test]
2310    fn test_key_handling_unmapped_key_closes_popup() {
2311        let mut app = App::new(
2312            InputSource::Directory(std::env::current_dir().unwrap()),
2313            &SearchFieldValues::default(),
2314            &AppRunConfig::default(),
2315            Config::default(),
2316        )
2317        .unwrap();
2318        app.set_popup(Popup::Text {
2319            title: "Error title".to_owned(),
2320            body: "some text in the body".to_owned(),
2321        });
2322        let res = app.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
2323        assert!(matches!(res, EventHandlingResult::Rerender));
2324        assert!(app.popup().is_none());
2325    }
2326
2327    #[test]
2328    fn test_escape_deprecation_message_with_default() {
2329        let keymap = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2330        let message = generate_escape_deprecation_message(Some(keymap));
2331        assert_eq!(
2332            message,
2333            "Pressing escape to quit is no longer enabled by default: use `C-c` \
2334             (i.e. `ctrl + c`) instead.\n\nYou can remap this in your scooter config."
2335        );
2336    }
2337
2338    #[test]
2339    fn test_escape_deprecation_message_with_no_mapping() {
2340        let message = generate_escape_deprecation_message(None);
2341        assert_eq!(
2342            message,
2343            "Pressing escape to quit is no longer enabled by default.\n\n\
2344             You can remap this in your scooter config."
2345        );
2346    }
2347
2348    #[test]
2349    fn test_escape_deprecation_message_with_f_key() {
2350        let keymap = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
2351        let message = generate_escape_deprecation_message(Some(keymap));
2352        assert_eq!(
2353            message,
2354            "Pressing escape to quit is no longer enabled by default: use `F1` instead.\n\n\
2355             You can remap this in your scooter config."
2356        );
2357    }
2358
2359    #[test]
2360    fn test_escape_deprecation_message_with_ctrl_alt_q_keymap() {
2361        let keymap = KeyEvent::new(
2362            KeyCode::Char('q'),
2363            KeyModifiers::CONTROL | KeyModifiers::ALT,
2364        );
2365        let message = generate_escape_deprecation_message(Some(keymap));
2366        assert_eq!(
2367            message,
2368            "Pressing escape to quit is no longer enabled by default: use `C-A-q` instead.\n\n\
2369             You can remap this in your scooter config."
2370        );
2371    }
2372}