Skip to main content

twrite_core/
search_hook.rs

1use std::ops::Range;
2
3use crate::{
4    EditorError, EditorHook, HookContext, KeyCode, PromptAction, PromptPlacement, PromptSpec,
5    SearchQuery, SearchState, Selection, replace_all_query, replace_one_query,
6};
7
8/// Prompt ids owned by [`SearchHook`].
9pub const SEARCH_PROMPT_ID: &str = "search";
10/// Prompt ids owned by [`SearchHook`].
11pub const REPLACE_PROMPT_ID: &str = "replace";
12
13/// Synthetic search-panel actions from pointer chrome (buttons, boxes).
14///
15/// These are *not* keys: they originate from mouse clicks on the prompt bar
16/// and travel on their own channel
17/// ([`EditorHook::on_search_action`]) so [`KeyCode`] stays purely about
18/// physical keys.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SearchAction {
21    /// Focus the find input.
22    FocusSearch,
23    /// Focus the replace input.
24    FocusReplace,
25    /// Toggle the replace section open/closed.
26    ToggleReplace,
27}
28
29fn search_spec() -> PromptSpec {
30    PromptSpec::new(
31        SEARCH_PROMPT_ID,
32        "/",
33        "Find in page",
34        PromptPlacement::BottomBar,
35        true,
36    )
37}
38
39fn replace_spec() -> PromptSpec {
40    PromptSpec::new(
41        REPLACE_PROMPT_ID,
42        "Replace",
43        "Replace with",
44        PromptPlacement::BottomBar,
45        false,
46    )
47}
48
49/// A stock hook connecting the headless search engine to the shared prompt.
50///
51/// Keymap (all headless, no frontend code):
52/// - `Ctrl+F`: open search (selected text becomes the initial query).
53/// - Typing: live match refresh.
54/// - `Enter` / `F3` / `Down`: next match, `Shift+F3` / `Up`: previous match.
55/// - `Alt+Down` / `Alt+Up`: input history (Up/Down navigate matches here).
56/// - `Alt+C` / `Alt+W` / `Alt+H`: flip Match Case / Whole Word / Highlight All
57///   (also while active with the prompt closed).
58/// - `Ctrl+H`: open the replace field; `Enter` on it stores the replacement
59///   and returns to the search prompt (`REPLACE` mode).
60/// - `Ctrl+Enter`: replace the current match and advance.
61/// - `Alt+A`: replace all matches (single undo step).
62/// - `Escape`: close.
63///
64/// `Enter` always navigates, even in `REPLACE` mode. Register this hook
65/// before mode hooks (e.g. vim) so its keys win while the prompt is open;
66/// unconsumed keys pass through untouched when the prompt is closed.
67#[derive(Debug, Clone)]
68pub struct SearchHook {
69    state: SearchState,
70    query_text: String,
71    replacement: String,
72    replace_mode: bool,
73    prompt_is_replace: bool,
74    active: bool,
75    status_cache: String,
76    case_sensitive: bool,
77    whole_word: bool,
78    highlight_all: bool,
79}
80
81impl Default for SearchHook {
82    fn default() -> Self {
83        Self {
84            state: SearchState::new(),
85            query_text: String::new(),
86            replacement: String::new(),
87            replace_mode: false,
88            prompt_is_replace: false,
89            active: false,
90            status_cache: "SEARCH".to_string(),
91            case_sensitive: true,
92            whole_word: false,
93            highlight_all: true,
94        }
95    }
96}
97
98impl SearchHook {
99    /// Creates an idle search hook (case-sensitive, whole-word off,
100    /// highlight-all on).
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Whether the hook currently owns the open prompt.
106    pub fn is_active(&self) -> bool {
107        self.active
108    }
109
110    /// Number of matches for the current query.
111    pub fn match_count(&self) -> usize {
112        self.state.match_count()
113    }
114
115    /// The current match range, if navigation has occurred.
116    pub fn current_match(&self) -> Option<Range<usize>> {
117        self.state.current_match()
118    }
119
120    /// The last submitted query text.
121    pub fn query_text(&self) -> &str {
122        &self.query_text
123    }
124
125    /// The stored replacement text.
126    pub fn replacement(&self) -> &str {
127        &self.replacement
128    }
129
130    /// Whether matching is case-sensitive.
131    pub fn case_sensitive(&self) -> bool {
132        self.case_sensitive
133    }
134
135    /// Whether matches must span whole words.
136    pub fn whole_word(&self) -> bool {
137        self.whole_word
138    }
139
140    /// Whether all matches wash the viewport (vs current match only).
141    pub fn highlight_all(&self) -> bool {
142        self.highlight_all
143    }
144
145    /// Whether replace mode is active.
146    pub fn is_replace_mode(&self) -> bool {
147        self.replace_mode
148    }
149
150    /// Whether the active input prompt is currently the replace field.
151    pub fn is_replace_prompt(&self) -> bool {
152        self.prompt_is_replace
153    }
154
155    /// Builds the active query from the text plus toggle flags.
156    /// Returns `None` when the query text is empty.
157    pub fn build_query(&self) -> Option<SearchQuery> {
158        if self.query_text.is_empty() {
159            return None;
160        }
161        Some(SearchQuery::new(
162            &self.query_text,
163            self.case_sensitive,
164            self.whole_word,
165            false,
166        ))
167    }
168
169    /// Flips Match Case and re-scans.
170    pub fn toggle_case(&mut self, ctx: &mut HookContext) {
171        self.case_sensitive = !self.case_sensitive;
172        self.rescan(ctx);
173    }
174
175    /// Flips Whole Word and re-scans.
176    pub fn toggle_word(&mut self, ctx: &mut HookContext) {
177        self.whole_word = !self.whole_word;
178        self.rescan(ctx);
179    }
180
181    /// Flips the highlight-all wash (no re-scan needed).
182    pub fn toggle_highlight(&mut self) {
183        self.highlight_all = !self.highlight_all;
184        self.update_status();
185    }
186
187    /// Opens the search prompt with `initial` as the query.
188    pub fn open_search(&mut self, ctx: &mut HookContext, initial: &str) {
189        ctx.prompt.open(search_spec(), initial);
190        self.query_text = initial.to_string();
191        self.active = true;
192        self.prompt_is_replace = false;
193        self.refresh_from_input(ctx);
194    }
195
196    /// Opens the replace prompt, stashing the current query.
197    pub fn open_replace(&mut self, ctx: &mut HookContext) {
198        if ctx.prompt.spec().is_some_and(|s| s.id == SEARCH_PROMPT_ID) {
199            self.query_text = ctx.prompt.input().to_string();
200        }
201        let replacement = self.replacement.clone();
202        ctx.prompt.open(replace_spec(), &replacement);
203        self.active = true;
204        self.replace_mode = true;
205        self.prompt_is_replace = true;
206    }
207
208    /// Closes a hook-owned prompt, leaving replace mode.
209    pub fn close(&mut self, ctx: &mut HookContext) {
210        ctx.prompt.close();
211        self.active = false;
212        self.replace_mode = false;
213        self.prompt_is_replace = false;
214        self.update_status();
215    }
216
217    /// Toggles replace mode on and off.
218    pub fn toggle_replace(&mut self, ctx: &mut HookContext) {
219        if self.replace_mode {
220            self.replace_mode = false;
221            if self.prompt_is_replace {
222                self.focus_search(ctx);
223            }
224        } else {
225            self.open_replace(ctx);
226        }
227    }
228
229    /// Switches prompt focus to the search query field.
230    pub fn focus_search(&mut self, ctx: &mut HookContext) {
231        if self.prompt_is_replace {
232            self.replacement = ctx.prompt.input().to_string();
233            let query = self.query_text.clone();
234            ctx.prompt.open(search_spec(), &query);
235            self.prompt_is_replace = false;
236            self.refresh_from_input(ctx);
237        }
238    }
239
240    /// Switches prompt focus to the replacement text field.
241    pub fn focus_replace(&mut self, ctx: &mut HookContext) {
242        if !self.prompt_is_replace {
243            self.open_replace(ctx);
244        }
245    }
246
247    /// Re-scans from the prompt input with the current toggle flags.
248    fn refresh_from_input(&mut self, ctx: &mut HookContext) {
249        self.query_text = ctx.prompt.input().to_string();
250        self.rescan(ctx);
251    }
252
253    /// Re-scans the stored query text with the current toggle flags.
254    fn rescan(&mut self, ctx: &mut HookContext) {
255        if self.query_text.is_empty() {
256            self.state = SearchState::new();
257        } else {
258            self.state.set_query(SearchQuery::new(
259                &self.query_text,
260                self.case_sensitive,
261                self.whole_word,
262                false,
263            ));
264            // Flag combinations always compile; ignore staleness errors.
265            let _ = self.state.refresh(ctx.buffer);
266        }
267        self.update_status();
268    }
269
270    fn update_status(&mut self) {
271        let scope = if self.replace_mode {
272            "REPLACE"
273        } else {
274            "SEARCH"
275        };
276        let flags = format!(
277            "[{}] [{}] [{}]",
278            if self.case_sensitive { "Aa" } else { "aa" },
279            if self.whole_word { "W" } else { "w" },
280            if self.highlight_all { "H" } else { "h" },
281        );
282        let count = self.state.match_count();
283        self.status_cache = if count == 0 {
284            format!("{scope} — no matches {flags}")
285        } else {
286            match self.state.current_index() {
287                Some(i) => format!("{scope} {}/{} {flags}", i + 1, count),
288                None => format!("{scope} — {count} matches {flags}"),
289            }
290        };
291    }
292
293    /// Selects the next match at or after the cursor.
294    ///
295    /// Sitting exactly on the current match start steps past it, so repeated
296    /// `Enter` / `F3` walks forward instead of re-selecting.
297    pub fn navigate_next(&mut self, ctx: &mut HookContext, wrap: bool) -> bool {
298        self.navigate_next_from(ctx, ctx.buffer.cursor_offset(), wrap)
299    }
300
301    /// Selects the next match at or after `from` (see [`Self::navigate_next`]).
302    pub fn navigate_next_from(&mut self, ctx: &mut HookContext, from: usize, wrap: bool) -> bool {
303        let from = match self.state.current_match() {
304            Some(m) if m.start == from => from + 1,
305            _ => from,
306        };
307        let found = self
308            .state
309            .next(ctx.buffer, from, wrap)
310            .ok()
311            .flatten()
312            .map(|m| {
313                ctx.buffer.set_cursor_offset(m.start);
314                *ctx.selection = Some(Selection::range(m.start, m.end));
315            })
316            .is_some();
317        self.update_status();
318        found
319    }
320
321    /// Selects the previous match at or before the cursor.
322    pub fn navigate_prev(&mut self, ctx: &mut HookContext, wrap: bool) -> bool {
323        self.navigate_prev_from(ctx, ctx.buffer.cursor_offset(), wrap)
324    }
325
326    /// Selects the previous match at or before `from`.
327    pub fn navigate_prev_from(&mut self, ctx: &mut HookContext, from: usize, wrap: bool) -> bool {
328        let found = self
329            .state
330            .prev(ctx.buffer, from, wrap)
331            .ok()
332            .flatten()
333            .map(|m| {
334                ctx.buffer.set_cursor_offset(m.start);
335                *ctx.selection = Some(Selection::range(m.start, m.end));
336            })
337            .is_some();
338        self.update_status();
339        found
340    }
341
342    /// Replaces the match containing the cursor (else the next match) and
343    /// advances to the following match. Returns `Ok(false)` when there is
344    /// nothing to replace.
345    pub fn replace_current(&mut self, ctx: &mut HookContext) -> Result<bool, EditorError> {
346        if ctx.prompt.spec().is_some_and(|s| s.id == REPLACE_PROMPT_ID) {
347            self.replacement = ctx.prompt.input().to_string();
348        }
349        if self.query_text.is_empty() {
350            return Ok(false);
351        }
352        let _ = self.state.refresh(ctx.buffer);
353        let from = ctx.buffer.cursor_offset();
354        let target = self
355            .state
356            .matches()
357            .iter()
358            .find(|m| m.start <= from && from <= m.end)
359            .cloned()
360            .or_else(|| {
361                self.state
362                    .matches()
363                    .iter()
364                    .find(|m| m.start >= from)
365                    .cloned()
366            })
367            .or_else(|| self.state.matches().first().cloned());
368        let Some(target) = target else {
369            return Ok(false);
370        };
371        let Some(query) = self.build_query() else {
372            return Ok(false);
373        };
374        let replacement = self.replacement.clone();
375        if !replace_one_query(ctx.buffer, &query, target, &replacement)? {
376            return Ok(false);
377        }
378        let _ = self.state.refresh(ctx.buffer);
379        self.navigate_next(ctx, true);
380        Ok(true)
381    }
382
383    /// Replaces all matches as a single undoable transaction, reporting the
384    /// count in the prompt message. Returns the number of replacements.
385    pub fn replace_all(&mut self, ctx: &mut HookContext) -> Result<usize, EditorError> {
386        if ctx.prompt.spec().is_some_and(|s| s.id == REPLACE_PROMPT_ID) {
387            self.replacement = ctx.prompt.input().to_string();
388        }
389        let Some(query) = self.build_query() else {
390            return Ok(0);
391        };
392        let replacement = self.replacement.clone();
393        let n = replace_all_query(ctx.buffer, &query, &replacement)?;
394        let _ = self.state.refresh(ctx.buffer);
395        *ctx.selection = None;
396        ctx.prompt.set_message(&format!(
397            "Replaced {n} match{}",
398            if n == 1 { "" } else { "es" }
399        ));
400        self.update_status();
401        Ok(n)
402    }
403
404    /// Initial query for `Ctrl+F`: selected text, else the last query.
405    fn ctrl_f_initial(&self, ctx: &HookContext) -> String {
406        match ctx.selection.map(|s| s.byte_range()) {
407            Some(range) if !range.is_empty() => {
408                let end = range.end.min(ctx.buffer.len_bytes());
409                let start = range.start.min(end);
410                ctx.buffer.text().byte_slice(start..end).to_string()
411            }
412            _ => self.query_text.clone(),
413        }
414    }
415}
416
417impl SearchHook {
418    /// Whether this hook currently owns the open prompt (find or replace).
419    fn owns_prompt(&self, ctx: &HookContext) -> bool {
420        ctx.prompt.is_open()
421            && ctx
422                .prompt
423                .spec()
424                .is_some_and(|s| s.id == SEARCH_PROMPT_ID || s.id == REPLACE_PROMPT_ID)
425    }
426}
427
428impl EditorHook for SearchHook {
429    fn on_search_action(
430        &mut self,
431        ctx: &mut HookContext,
432        action: SearchAction,
433    ) -> crate::HookOutcome {
434        if !self.owns_prompt(ctx) {
435            return crate::HookOutcome::PassThrough;
436        }
437        match action {
438            SearchAction::FocusSearch => self.focus_search(ctx),
439            SearchAction::FocusReplace => self.focus_replace(ctx),
440            SearchAction::ToggleReplace => self.toggle_replace(ctx),
441        }
442        crate::HookOutcome::Consumed
443    }
444
445    fn on_key(&mut self, ctx: &mut HookContext, event: &crate::KeyEvent) -> crate::HookOutcome {
446        use crate::HookOutcome;
447
448        let lowered: Option<char> = match event.code {
449            KeyCode::Char(c) => Some(c.to_ascii_lowercase()),
450            _ => None,
451        };
452        let mods = &event.modifiers;
453        let owns_prompt = self.owns_prompt(ctx);
454
455        if owns_prompt {
456            if lowered == Some('f') && mods.ctrl && !mods.alt && !mods.meta {
457                self.focus_search(ctx);
458                return HookOutcome::Consumed;
459            }
460            if event.code == KeyCode::Tab
461                && !mods.ctrl
462                && !mods.alt
463                && !mods.meta
464                && self.replace_mode
465            {
466                if self.prompt_is_replace {
467                    self.focus_search(ctx);
468                } else {
469                    self.focus_replace(ctx);
470                }
471                return HookOutcome::Consumed;
472            }
473            if event.code == KeyCode::Enter && mods.ctrl && !mods.alt && !mods.meta {
474                let _ = self.replace_current(ctx);
475                return HookOutcome::Consumed;
476            }
477            if lowered == Some('a') && mods.alt && !mods.ctrl && !mods.meta {
478                let _ = self.replace_all(ctx);
479                return HookOutcome::Consumed;
480            }
481            if lowered == Some('c') && mods.alt && !mods.ctrl && !mods.meta {
482                self.toggle_case(ctx);
483                return HookOutcome::Consumed;
484            }
485            if lowered == Some('w') && mods.alt && !mods.ctrl && !mods.meta {
486                self.toggle_word(ctx);
487                return HookOutcome::Consumed;
488            }
489            if lowered == Some('h') && mods.alt && !mods.ctrl && !mods.meta {
490                self.toggle_highlight();
491                return HookOutcome::Consumed;
492            }
493            if event.code == KeyCode::Up && !mods.ctrl && !mods.meta {
494                if mods.alt {
495                    ctx.prompt.history_prev();
496                } else {
497                    self.navigate_prev(ctx, true);
498                }
499                return HookOutcome::Consumed;
500            }
501            if event.code == KeyCode::Down && !mods.ctrl && !mods.meta {
502                if mods.alt {
503                    ctx.prompt.history_next();
504                } else {
505                    self.navigate_next(ctx, true);
506                }
507                return HookOutcome::Consumed;
508            }
509            if lowered == Some('h') && mods.ctrl && !mods.alt && !mods.meta {
510                if !self.replace_mode {
511                    self.open_replace(ctx);
512                } else if !self.prompt_is_replace {
513                    self.focus_replace(ctx);
514                } else {
515                    self.toggle_replace(ctx);
516                }
517                return HookOutcome::Consumed;
518            }
519            if event.code == KeyCode::F(3) && !mods.ctrl && !mods.alt && !mods.meta {
520                if mods.shift {
521                    self.navigate_prev(ctx, true);
522                } else {
523                    self.navigate_next(ctx, true);
524                }
525                return HookOutcome::Consumed;
526            }
527            let is_replace = ctx.prompt.spec().is_some_and(|s| s.id == REPLACE_PROMPT_ID);
528            match ctx.prompt.handle_key(event) {
529                PromptAction::Editing => {
530                    if is_replace {
531                        self.replacement = ctx.prompt.input().to_string();
532                    } else {
533                        self.refresh_from_input(ctx);
534                    }
535                    HookOutcome::Consumed
536                }
537                PromptAction::Submitted(input) => {
538                    if is_replace {
539                        self.replacement = input;
540                        let _ = self.replace_current(ctx);
541                    } else {
542                        self.query_text = input;
543                        self.navigate_next(ctx, true);
544                    }
545                    HookOutcome::Consumed
546                }
547                PromptAction::Cancelled => {
548                    self.active = false;
549                    self.replace_mode = false;
550                    self.prompt_is_replace = false;
551                    self.update_status();
552                    HookOutcome::Consumed
553                }
554                PromptAction::Ignored => HookOutcome::Consumed,
555            }
556        } else if !ctx.prompt.is_open() {
557            let lowered: Option<char> = match event.code {
558                KeyCode::Char(c) => Some(c.to_ascii_lowercase()),
559                _ => None,
560            };
561            if lowered == Some('f') && mods.ctrl && !mods.alt && !mods.meta {
562                let initial = self.ctrl_f_initial(ctx);
563                self.replace_mode = false;
564                self.open_search(ctx, &initial);
565                return HookOutcome::Consumed;
566            }
567            if lowered == Some('h') && mods.ctrl && !mods.alt && !mods.meta {
568                if !self.active {
569                    let initial = self.ctrl_f_initial(ctx);
570                    self.open_search(ctx, &initial);
571                }
572                self.open_replace(ctx);
573                return HookOutcome::Consumed;
574            }
575            if lowered == Some('c') && mods.alt && !mods.ctrl && !mods.meta && self.active {
576                self.toggle_case(ctx);
577                return HookOutcome::Consumed;
578            }
579            if lowered == Some('w') && mods.alt && !mods.ctrl && !mods.meta && self.active {
580                self.toggle_word(ctx);
581                return HookOutcome::Consumed;
582            }
583            if lowered == Some('h') && mods.alt && !mods.ctrl && !mods.meta && self.active {
584                self.toggle_highlight();
585                return HookOutcome::Consumed;
586            }
587            if event.code == KeyCode::F(3)
588                && !mods.ctrl
589                && !mods.alt
590                && !mods.meta
591                && self.active
592                && !self.query_text.is_empty()
593            {
594                if mods.shift {
595                    self.navigate_prev(ctx, true);
596                } else {
597                    self.navigate_next(ctx, true);
598                }
599                return HookOutcome::Consumed;
600            }
601            HookOutcome::PassThrough
602        } else {
603            HookOutcome::PassThrough
604        }
605    }
606
607    fn status_text(&self) -> Option<&str> {
608        if self.active {
609            Some(&self.status_cache)
610        } else {
611            None
612        }
613    }
614
615    fn search_snapshot(&self) -> Option<crate::SearchSnapshot> {
616        if !self.active {
617            return None;
618        }
619        Some(crate::SearchSnapshot {
620            active: true,
621            case_sensitive: self.case_sensitive,
622            whole_word: self.whole_word,
623            highlight_all: self.highlight_all,
624            matches: self.state.matches().to_vec(),
625            current: self.state.current_index(),
626            replace_mode: self.replace_mode,
627            is_replace_prompt: self.prompt_is_replace,
628            query: self.query_text.clone(),
629            replacement: self.replacement.clone(),
630        })
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::{
638        CursorStyle, EditorBuffer, HookContext, HookOutcome, KeyCode, KeyEvent, PromptState,
639    };
640
641    struct Harness {
642        buffer: EditorBuffer,
643        selection: Option<Selection>,
644        cursor_style: CursorStyle,
645        prompt: PromptState,
646        effects: Vec<crate::HookEffect>,
647        hook: SearchHook,
648    }
649
650    impl Harness {
651        fn new(text: &str) -> Self {
652            Self {
653                buffer: EditorBuffer::new(text),
654                selection: None,
655                cursor_style: CursorStyle::Bar,
656                prompt: PromptState::new(),
657                effects: Vec::new(),
658                hook: SearchHook::new(),
659            }
660        }
661
662        fn key(&mut self, code: KeyCode) -> HookOutcome {
663            let event = KeyEvent::plain(code);
664            let Harness {
665                buffer,
666                selection,
667                cursor_style,
668                prompt,
669                effects,
670                hook,
671            } = self;
672            let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
673            hook.on_key(&mut ctx, &event)
674        }
675
676        fn key_mod(&mut self, code: KeyCode, ctrl: bool, alt: bool, shift: bool) -> HookOutcome {
677            let event = KeyEvent {
678                code,
679                modifiers: crate::Modifiers {
680                    ctrl,
681                    alt,
682                    shift,
683                    meta: false,
684                },
685            };
686            let Harness {
687                buffer,
688                selection,
689                cursor_style,
690                prompt,
691                effects,
692                hook,
693            } = self;
694            let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
695            hook.on_key(&mut ctx, &event)
696        }
697
698        fn ctx_text(&self) -> String {
699            self.buffer.text().to_string()
700        }
701
702        fn type_into_prompt(&mut self, text: &str) {
703            for ch in text.chars() {
704                self.key(KeyCode::Char(ch));
705            }
706        }
707
708        fn with_ctx<R>(&mut self, f: impl FnOnce(&mut HookContext<'_>, &mut SearchHook) -> R) -> R {
709            let Harness {
710                buffer,
711                selection,
712                cursor_style,
713                prompt,
714                effects,
715                hook,
716            } = self;
717            let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
718            f(&mut ctx, hook)
719        }
720    }
721
722    #[test]
723    fn idle_hook_passes_everything_through() {
724        let mut h = Harness::new("hello");
725        assert_eq!(h.key(KeyCode::Char('a')), HookOutcome::PassThrough);
726        assert_eq!(h.key(KeyCode::Enter), HookOutcome::PassThrough);
727        assert!(h.hook.status_text().is_none());
728    }
729
730    #[test]
731    fn ctrl_f_opens_search_with_selection_as_initial() {
732        let mut h = Harness::new("hello world");
733        h.selection = Some(Selection::range(0, 5));
734        assert_eq!(
735            h.key_mod(KeyCode::Char('f'), true, false, false),
736            HookOutcome::Consumed
737        );
738        assert!(h.prompt.is_open());
739        assert_eq!(h.prompt.input(), "hello");
740        assert_eq!(h.hook.match_count(), 1);
741        assert!(h.hook.status_text().is_some());
742    }
743
744    #[test]
745    fn typing_live_refreshes_and_enter_navigates() {
746        let mut h = Harness::new("foo bar foo");
747        assert_eq!(
748            h.key_mod(KeyCode::Char('f'), true, false, false),
749            HookOutcome::Consumed
750        );
751
752        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
753            assert_eq!(h.key(k), HookOutcome::Consumed);
754        }
755        assert_eq!(h.hook.match_count(), 2);
756
757        assert_eq!(h.key(KeyCode::Enter), HookOutcome::Consumed);
758        assert_eq!(h.selection.unwrap().byte_range(), 0..3);
759        assert_eq!(h.hook.status_text(), Some("SEARCH 1/2 [Aa] [w] [H]"));
760
761        assert_eq!(h.key(KeyCode::Enter), HookOutcome::Consumed);
762        assert_eq!(h.selection.unwrap().byte_range(), 8..11);
763        assert_eq!(h.hook.status_text(), Some("SEARCH 2/2 [Aa] [w] [H]"));
764    }
765
766    #[test]
767    fn toggle_case_rescans_case_insensitively() {
768        let mut h = Harness::new("Foo foo FOO");
769        h.key_mod(KeyCode::Char('f'), true, false, false);
770        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
771            h.key(k);
772        }
773        assert_eq!(h.hook.match_count(), 1);
774        assert!(h.hook.case_sensitive());
775
776        h.with_ctx(|ctx, hook| hook.toggle_case(ctx));
777        assert!(!h.hook.case_sensitive());
778        assert_eq!(h.hook.match_count(), 3);
779        assert_eq!(
780            h.hook.status_text(),
781            Some("SEARCH — 3 matches [aa] [w] [H]")
782        );
783
784        h.with_ctx(|ctx, hook| hook.toggle_case(ctx));
785        assert_eq!(h.hook.match_count(), 1);
786    }
787
788    #[test]
789    fn toggle_word_filters_substring_matches() {
790        let mut h = Harness::new("foo foobar foo");
791        h.key_mod(KeyCode::Char('f'), true, false, false);
792        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
793            h.key(k);
794        }
795        assert_eq!(h.hook.match_count(), 3);
796
797        h.with_ctx(|ctx, hook| hook.toggle_word(ctx));
798        assert!(h.hook.whole_word());
799        assert_eq!(h.hook.match_count(), 2);
800        assert_eq!(
801            h.hook.status_text(),
802            Some("SEARCH — 2 matches [Aa] [W] [H]")
803        );
804    }
805
806    #[test]
807    fn toggle_highlight_flips_without_rescanning() {
808        let mut h = Harness::new("foo foo");
809        h.key_mod(KeyCode::Char('f'), true, false, false);
810        h.key(KeyCode::Char('f'));
811        assert!(h.hook.highlight_all());
812
813        h.with_ctx(|ctx, hook| {
814            hook.toggle_highlight();
815            let _ = ctx;
816        });
817        assert!(!h.hook.highlight_all());
818        assert_eq!(h.hook.match_count(), 2);
819        assert!(h.hook.status_text().unwrap().ends_with("[Aa] [w] [h]"));
820    }
821
822    #[test]
823    fn replace_honors_toggle_flags() {
824        let mut h = Harness::new("Foo foo");
825        h.key_mod(KeyCode::Char('f'), true, false, false);
826        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
827            h.key(k);
828        }
829        h.with_ctx(|ctx, hook| hook.toggle_case(ctx));
830        h.key_mod(KeyCode::Char('h'), true, false, false);
831        for k in [KeyCode::Char('b'), KeyCode::Char('a'), KeyCode::Char('r')] {
832            h.key(k);
833        }
834        h.with_ctx(|ctx, hook| {
835            assert_eq!(hook.replace_all(ctx).unwrap(), 2);
836        });
837        assert_eq!(h.ctx_text(), "bar bar");
838    }
839
840    #[test]
841    fn build_query_is_none_when_empty() {
842        let hook = SearchHook::new();
843        assert!(hook.build_query().is_none());
844    }
845
846    #[test]
847    fn alt_h_toggles_highlight_via_key() {
848        let mut h = Harness::new("foo foo");
849        h.key_mod(KeyCode::Char('f'), true, false, false);
850        h.key(KeyCode::Char('f'));
851        assert!(h.hook.highlight_all());
852        assert_eq!(
853            h.key_mod(KeyCode::Char('h'), false, true, false),
854            HookOutcome::Consumed
855        );
856        assert!(!h.hook.highlight_all());
857        // Prompt stays open; matching is untouched.
858        assert!(h.prompt.is_open());
859        assert_eq!(h.hook.match_count(), 2);
860    }
861
862    #[test]
863    fn snapshot_reports_state_while_active() {
864        let mut h = Harness::new("foo bar foo");
865        assert!(h.hook.search_snapshot().is_none());
866        h.key_mod(KeyCode::Char('f'), true, false, false);
867        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
868            h.key(k);
869        }
870        h.key(KeyCode::Enter);
871        let snap = h.hook.search_snapshot().expect("snapshot while active");
872        assert!(snap.active);
873        assert!(snap.case_sensitive);
874        assert!(!snap.whole_word);
875        assert!(snap.highlight_all);
876        assert_eq!(snap.matches, vec![0..3, 8..11]);
877        assert_eq!(snap.current, Some(0));
878        h.key(KeyCode::Escape);
879        assert!(h.hook.search_snapshot().is_none());
880    }
881
882    #[test]
883    fn alt_c_and_alt_w_toggle_via_keys() {
884        let mut h = Harness::new("Foo foo foobar");
885        h.key_mod(KeyCode::Char('f'), true, false, false);
886        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
887            h.key(k);
888        }
889        assert_eq!(h.hook.match_count(), 2);
890
891        assert_eq!(
892            h.key_mod(KeyCode::Char('c'), false, true, false),
893            HookOutcome::Consumed
894        );
895        assert_eq!(h.hook.match_count(), 3);
896
897        assert_eq!(
898            h.key_mod(KeyCode::Char('w'), false, true, false),
899            HookOutcome::Consumed
900        );
901        assert_eq!(h.hook.match_count(), 2);
902        assert_eq!(
903            h.hook.status_text(),
904            Some("SEARCH — 2 matches [aa] [W] [H]")
905        );
906    }
907
908    #[test]
909    fn up_down_navigate_matches_and_alt_reaches_history() {
910        let mut h = Harness::new("foo bar foo");
911        h.key_mod(KeyCode::Char('f'), true, false, false);
912        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
913            h.key(k);
914        }
915        h.key(KeyCode::Enter);
916        assert_eq!(h.selection.unwrap().byte_range(), 0..3);
917
918        // Down advances instead of walking history.
919        assert_eq!(h.key(KeyCode::Down), HookOutcome::Consumed);
920        assert_eq!(h.selection.unwrap().byte_range(), 8..11);
921        assert_eq!(h.key(KeyCode::Up), HookOutcome::Consumed);
922        assert_eq!(h.selection.unwrap().byte_range(), 0..3);
923
924        // History moved to Alt+Up.
925        h.key(KeyCode::Enter);
926        h.type_into_prompt("zzz");
927        assert_eq!(
928            h.key_mod(KeyCode::Up, false, true, false),
929            HookOutcome::Consumed
930        );
931        assert_eq!(h.prompt.input(), "foo");
932    }
933
934    #[test]
935    fn f3_keys_navigate_without_typing() {
936        let mut h = Harness::new("aa aa");
937        h.key_mod(KeyCode::Char('f'), true, false, false);
938        h.key(KeyCode::Char('a'));
939        h.key(KeyCode::Char('a'));
940        h.key(KeyCode::Enter);
941        assert_eq!(h.selection.unwrap().byte_range(), 0..2);
942
943        assert_eq!(h.key(KeyCode::F(3)), HookOutcome::Consumed);
944        assert_eq!(h.selection.unwrap().byte_range(), 3..5);
945
946        assert_eq!(
947            h.key_mod(KeyCode::F(3), false, false, true),
948            HookOutcome::Consumed
949        );
950        assert_eq!(h.selection.unwrap().byte_range(), 0..2);
951    }
952
953    #[test]
954    fn escape_closes_and_resets_mode() {
955        let mut h = Harness::new("foo foo");
956        h.key_mod(KeyCode::Char('f'), true, false, false);
957        assert!(h.prompt.is_open());
958        assert_eq!(h.key(KeyCode::Escape), HookOutcome::Consumed);
959        assert!(!h.prompt.is_open());
960        assert!(h.hook.status_text().is_none());
961        assert_eq!(h.key(KeyCode::Char('a')), HookOutcome::PassThrough);
962    }
963
964    #[test]
965    fn ctrl_h_replace_flow_replaces_current_and_all() {
966        let mut h = Harness::new("foo bar foo");
967        h.key_mod(KeyCode::Char('f'), true, false, false);
968        for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
969            h.key(k);
970        }
971
972        assert_eq!(
973            h.key_mod(KeyCode::Char('h'), true, false, false),
974            HookOutcome::Consumed
975        );
976        assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
977        for k in [KeyCode::Char('b'), KeyCode::Char('a'), KeyCode::Char('z')] {
978            h.key(k);
979        }
980        assert_eq!(h.key(KeyCode::Enter), HookOutcome::Consumed);
981        assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
982        assert_eq!(h.hook.replacement(), "baz");
983        assert_eq!(h.ctx_text(), "baz bar foo");
984        assert_eq!(h.selection.unwrap().byte_range(), 8..11);
985
986        assert_eq!(
987            h.key_mod(KeyCode::Char('a'), false, true, false),
988            HookOutcome::Consumed
989        );
990        assert_eq!(h.ctx_text(), "baz bar baz");
991        assert_eq!(h.prompt.message(), Some("Replaced 1 match"));
992        h.buffer.undo();
993        assert_eq!(h.ctx_text(), "baz bar foo");
994    }
995
996    #[test]
997    fn public_api_drives_vim_style_flows() {
998        let mut h = Harness::new("foo bar foo");
999        let Harness {
1000            buffer,
1001            selection,
1002            cursor_style,
1003            prompt,
1004            effects,
1005            hook,
1006        } = &mut h;
1007        let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
1008
1009        hook.open_search(&mut ctx, "foo");
1010        assert_eq!(hook.match_count(), 2);
1011        assert!(hook.navigate_next(&mut ctx, true));
1012        assert_eq!(ctx.selection.unwrap().byte_range(), 0..3);
1013        assert!(hook.navigate_prev(&mut ctx, true));
1014        assert_eq!(ctx.selection.unwrap().byte_range(), 8..11);
1015
1016        hook.close(&mut ctx);
1017        assert!(!ctx.prompt.is_open());
1018        assert!(!hook.is_active());
1019    }
1020
1021    #[test]
1022    fn replace_while_typing_in_replace_prompt_with_ctrl_enter_and_alt_a() {
1023        let mut h = Harness::new("alpha beta alpha");
1024        h.key_mod(KeyCode::Char('f'), true, false, false);
1025        for k in [
1026            KeyCode::Char('a'),
1027            KeyCode::Char('l'),
1028            KeyCode::Char('p'),
1029            KeyCode::Char('h'),
1030            KeyCode::Char('a'),
1031        ] {
1032            h.key(k);
1033        }
1034        assert_eq!(h.hook.match_count(), 2);
1035
1036        assert_eq!(
1037            h.key_mod(KeyCode::Char('h'), true, false, false),
1038            HookOutcome::Consumed
1039        );
1040        assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
1041
1042        for k in [
1043            KeyCode::Char('o'),
1044            KeyCode::Char('m'),
1045            KeyCode::Char('e'),
1046            KeyCode::Char('g'),
1047            KeyCode::Char('a'),
1048        ] {
1049            h.key(k);
1050        }
1051        assert_eq!(h.hook.replacement(), "omega");
1052
1053        assert_eq!(
1054            h.key_mod(KeyCode::Enter, true, false, false),
1055            HookOutcome::Consumed
1056        );
1057        assert_eq!(h.ctx_text(), "omega beta alpha");
1058
1059        assert_eq!(
1060            h.key_mod(KeyCode::Char('a'), false, true, false),
1061            HookOutcome::Consumed
1062        );
1063        assert_eq!(h.ctx_text(), "omega beta omega");
1064    }
1065
1066    #[test]
1067    fn tab_toggles_between_search_and_replace_in_replace_mode() {
1068        let mut h = Harness::new("one two one");
1069        h.key_mod(KeyCode::Char('f'), true, false, false);
1070        for k in [KeyCode::Char('o'), KeyCode::Char('n'), KeyCode::Char('e')] {
1071            h.key(k);
1072        }
1073        h.key_mod(KeyCode::Char('h'), true, false, false);
1074        assert!(h.hook.is_replace_mode());
1075        assert!(h.hook.is_replace_prompt());
1076
1077        h.key(KeyCode::Tab);
1078        assert!(!h.hook.is_replace_prompt());
1079        assert_eq!(h.prompt.spec().unwrap().id, SEARCH_PROMPT_ID);
1080        assert_eq!(h.prompt.input(), "one");
1081
1082        h.key(KeyCode::Tab);
1083        assert!(h.hook.is_replace_prompt());
1084        assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
1085    }
1086
1087    #[test]
1088    fn search_snapshot_reflects_replace_state() {
1089        let mut h = Harness::new("test test");
1090        h.key_mod(KeyCode::Char('f'), true, false, false);
1091        h.type_into_prompt("test");
1092        let snap = h.hook.search_snapshot().unwrap();
1093        assert_eq!(snap.query, "test");
1094        assert!(!snap.replace_mode);
1095        assert!(!snap.is_replace_prompt);
1096
1097        h.key_mod(KeyCode::Char('h'), true, false, false);
1098        h.type_into_prompt("passed");
1099        let snap = h.hook.search_snapshot().unwrap();
1100        assert_eq!(snap.query, "test");
1101        assert_eq!(snap.replacement, "passed");
1102        assert!(snap.replace_mode);
1103        assert!(snap.is_replace_prompt);
1104    }
1105}