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 pub view_offset: usize, pub num_displayed: Option<usize>, 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 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>, 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 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 " (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
517macro_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(), &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 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 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; 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, }
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, ) {
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 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 #[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 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 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 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 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 search_fields_state.preview_update_state =
1082 Some(PreviewUpdateStatus::new(handle, cancelled));
1083 }
1084 } else {
1085 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 #[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 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 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 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 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 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)); 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 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), ]);
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}