Skip to main content

scooter_core/
fields.rs

1// TODO: make most of the stuff in here pub(crate)
2
3#[cfg(feature = "steel")]
4use steel_derive::Steel;
5use unicode_width::UnicodeWidthStr;
6
7use crate::{
8    errors::AppError,
9    keyboard::{KeyCode, KeyModifiers},
10};
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum Field {
14    Text(TextField),
15    Checkbox(CheckboxField),
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct FieldError {
20    pub short: String,
21    pub long: String,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25#[cfg_attr(feature = "steel", derive(Steel))]
26pub struct TextField {
27    text: String,
28    cursor_idx: usize,
29    error: Option<FieldError>,
30}
31
32// TODO: treat punctuation as a delimiter to a word, e.g. in "hello,world" deleting the word backwards from
33// the end should only delete "world", currently it deletes the whole thing
34impl TextField {
35    pub fn new(initial: &str) -> Self {
36        Self {
37            text: initial.to_string(),
38            cursor_idx: initial.chars().count(),
39            error: None,
40        }
41    }
42
43    pub fn text(&self) -> &str {
44        &self.text
45    }
46
47    pub fn visual_cursor_pos(&self) -> usize {
48        let prefix: String = self.text.chars().take(self.cursor_idx).collect();
49        UnicodeWidthStr::width(prefix.as_str())
50    }
51
52    pub fn move_cursor_left(&mut self) {
53        self.move_cursor_left_by(1);
54    }
55
56    pub fn move_cursor_start(&mut self) {
57        self.cursor_idx = 0;
58    }
59
60    fn move_cursor_left_by(&mut self, n: usize) {
61        let cursor_moved_left = self.cursor_idx.saturating_sub(n);
62        self.cursor_idx = self.clamp_cursor(cursor_moved_left);
63    }
64
65    pub fn move_cursor_right(&mut self) {
66        self.move_cursor_right_by(1);
67    }
68
69    fn move_cursor_right_by(&mut self, n: usize) {
70        let cursor_moved_right = self.cursor_idx.saturating_add(n);
71        self.cursor_idx = self.clamp_cursor(cursor_moved_right);
72    }
73
74    pub fn move_cursor_end(&mut self) {
75        self.cursor_idx = self.text.chars().count();
76    }
77
78    pub fn enter_char(&mut self, new_char: char) {
79        let index = self.byte_index();
80        self.text.insert(index, new_char);
81        self.move_cursor_right();
82    }
83
84    fn byte_index(&self) -> usize {
85        self.text
86            .char_indices()
87            .map(|(i, _)| i)
88            .nth(self.cursor_idx)
89            .unwrap_or(self.text.len())
90    }
91
92    pub fn delete_char(&mut self) {
93        if self.cursor_idx == 0 {
94            return;
95        }
96
97        let before_char = self.text.chars().take(self.cursor_idx - 1);
98        let after_char = self.text.chars().skip(self.cursor_idx);
99
100        self.text = before_char.chain(after_char).collect();
101        self.move_cursor_left();
102    }
103
104    pub fn delete_char_forward(&mut self) {
105        let before_char = self.text.chars().take(self.cursor_idx);
106        let after_char = self.text.chars().skip(self.cursor_idx + 1);
107
108        self.text = before_char.chain(after_char).collect();
109    }
110
111    fn previous_word_start(&self) -> usize {
112        if self.cursor_idx == 0 {
113            return 0;
114        }
115
116        let before_char = self.text.chars().take(self.cursor_idx).collect::<Vec<_>>();
117        let mut idx = self.cursor_idx - 1;
118        while idx > 0 && before_char[idx] == ' ' {
119            idx -= 1;
120        }
121        while idx > 0 && before_char[idx - 1] != ' ' {
122            idx -= 1;
123        }
124        idx
125    }
126
127    pub fn move_cursor_back_word(&mut self) {
128        self.cursor_idx = self.previous_word_start();
129    }
130
131    pub fn delete_word_backward(&mut self) {
132        let new_cursor_pos = self.previous_word_start();
133        let before_char = self.text.chars().take(new_cursor_pos);
134        let after_char = self.text.chars().skip(self.cursor_idx);
135
136        self.text = before_char.chain(after_char).collect();
137        self.cursor_idx = new_cursor_pos;
138    }
139
140    fn next_word_start(&self) -> usize {
141        let after_char = self.text.chars().skip(self.cursor_idx).collect::<Vec<_>>();
142        let mut idx = 0;
143        let num_chars = after_char.len();
144        while idx < num_chars && after_char[idx] != ' ' {
145            idx += 1;
146        }
147        while idx < num_chars && after_char[idx] == ' ' {
148            idx += 1;
149        }
150        self.cursor_idx + idx
151    }
152
153    pub fn move_cursor_forward_word(&mut self) {
154        self.cursor_idx = self.next_word_start();
155    }
156
157    pub fn delete_word_forward(&mut self) {
158        let before_char = self.text.chars().take(self.cursor_idx);
159        let after_char = self.text.chars().skip(self.next_word_start());
160
161        self.text = before_char.chain(after_char).collect();
162    }
163
164    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
165        new_cursor_pos.clamp(0, self.text.chars().count())
166    }
167
168    pub fn clear(&mut self) {
169        self.text.clear();
170        self.cursor_idx = 0;
171    }
172
173    pub fn set_text(&mut self, text: &str) {
174        self.text = text.to_string();
175        self.cursor_idx = self.clamp_cursor(text.chars().count());
176    }
177
178    pub fn insert_text(&mut self, text: &str) {
179        let index = self.byte_index();
180        self.text.insert_str(index, text);
181        self.cursor_idx += text.chars().count();
182    }
183
184    pub fn cursor_idx(&self) -> usize {
185        self.cursor_idx
186    }
187
188    pub fn set_cursor_idx(&mut self, idx: usize) {
189        self.cursor_idx = self.clamp_cursor(idx);
190    }
191
192    pub fn delete_to_start(&mut self) {
193        self.text = self.text.chars().skip(self.cursor_idx).collect();
194        self.cursor_idx = 0;
195    }
196
197    pub fn set_error(&mut self, short: String, long: String) {
198        self.error = Some(FieldError { short, long });
199    }
200
201    pub fn clear_error(&mut self) {
202        self.error = None;
203    }
204
205    fn handle_keys(&mut self, code: KeyCode, modifiers: KeyModifiers) {
206        match (code, modifiers) {
207            (KeyCode::Char('w'), KeyModifiers::CONTROL)
208            | (KeyCode::Backspace, KeyModifiers::ALT) => {
209                self.delete_word_backward();
210            }
211            (KeyCode::Char('u'), KeyModifiers::CONTROL)
212            | (KeyCode::Backspace, KeyModifiers::META) => {
213                self.delete_to_start();
214            }
215            (KeyCode::Backspace, _) => {
216                self.delete_char();
217            }
218            (KeyCode::Left | KeyCode::Char('b' | 'B'), _)
219                if modifiers.contains(KeyModifiers::ALT) =>
220            {
221                self.move_cursor_back_word();
222            }
223            (KeyCode::Home, _) => {
224                self.move_cursor_start();
225            }
226            (KeyCode::Left, _) => {
227                self.move_cursor_left();
228            }
229            (KeyCode::Right | KeyCode::Char('f' | 'F'), _)
230                if modifiers.contains(KeyModifiers::ALT) =>
231            {
232                self.move_cursor_forward_word();
233            }
234            (KeyCode::Right, KeyModifiers::META) | (KeyCode::End, _) => {
235                self.move_cursor_end();
236            }
237            (KeyCode::Right, _) => {
238                self.move_cursor_right();
239            }
240            (KeyCode::Char('d') | KeyCode::Delete, KeyModifiers::ALT) => {
241                self.delete_word_forward();
242            }
243            (KeyCode::Delete, _) => {
244                self.delete_char_forward();
245            }
246            (KeyCode::Char(value), _) => {
247                self.enter_char(value);
248            }
249            _ => {}
250        }
251    }
252}
253
254#[derive(Clone, Debug, Eq, PartialEq)]
255pub struct CheckboxField {
256    pub checked: bool,
257    pub error: Option<FieldError>, // Not used currently so not rendered
258}
259
260impl CheckboxField {
261    pub fn new(initial: bool) -> Self {
262        Self {
263            checked: initial,
264            error: None,
265        }
266    }
267
268    pub fn handle_keys(&mut self, code: KeyCode, _modifiers: KeyModifiers) {
269        if code == KeyCode::Char(' ') {
270            self.checked = !self.checked;
271        }
272    }
273}
274
275#[derive(Clone, Debug, Eq, PartialEq)]
276pub enum FieldName {
277    Search,
278    Replace,
279    FixedStrings,
280    WholeWord,
281    MatchCase,
282    IncludeFiles,
283    ExcludeFiles,
284}
285
286impl FieldName {
287    pub fn title(&self) -> &str {
288        match self {
289            FieldName::Search => "Search text",
290            FieldName::Replace => "Replace text",
291            FieldName::FixedStrings => "Fixed strings",
292            FieldName::WholeWord => "Match whole word",
293            FieldName::MatchCase => "Match case",
294            FieldName::IncludeFiles => "Files to include",
295            FieldName::ExcludeFiles => "Files to exclude",
296        }
297    }
298}
299
300#[derive(Clone, Debug, Eq, PartialEq)]
301pub struct SearchField {
302    pub name: FieldName,
303    pub field: Field,
304    pub set_by_cli: bool,
305}
306
307impl SearchField {
308    pub fn new_text(name: FieldName, initial: &str, set_by_cli: bool) -> Self {
309        let field = Field::Text(TextField::new(initial));
310        Self {
311            name,
312            field,
313            set_by_cli,
314        }
315    }
316
317    pub fn new_checkbox(name: FieldName, initial: bool, set_by_cli: bool) -> Self {
318        let field = Field::Checkbox(CheckboxField::new(initial));
319        Self {
320            name,
321            field,
322            set_by_cli,
323        }
324    }
325
326    pub fn handle_keys(
327        &mut self,
328        code: KeyCode,
329        modifiers: KeyModifiers,
330        disable_prepopulated_fields: bool,
331    ) {
332        if self.set_by_cli && disable_prepopulated_fields {
333            return;
334        }
335        self.clear_error();
336        match &mut self.field {
337            Field::Text(f) => f.handle_keys(code, modifiers),
338            Field::Checkbox(f) => f.handle_keys(code, modifiers),
339        }
340    }
341
342    pub fn cursor_pos(&self) -> Option<usize> {
343        match &self.field {
344            Field::Text(f) => Some(f.visual_cursor_pos()),
345            Field::Checkbox(_) => None,
346        }
347    }
348
349    pub fn clear_error(&mut self) {
350        match &mut self.field {
351            Field::Text(f) => f.clear_error(),
352            Field::Checkbox(_) => {} // TODO
353        }
354    }
355
356    pub fn error(&self) -> Option<FieldError> {
357        match &self.field {
358            Field::Text(f) => f.error.clone(),
359            Field::Checkbox(f) => f.error.clone(),
360        }
361    }
362}
363
364pub const NUM_SEARCH_FIELDS: u16 = 7;
365
366#[derive(Clone, Debug, Eq, PartialEq)]
367pub struct SearchFields {
368    pub fields: [SearchField; NUM_SEARCH_FIELDS as usize],
369    pub highlighted: usize,
370}
371
372macro_rules! define_field_accessor {
373    ($method_name:ident, $field_name:expr, $field_variant:ident, $return_type:ty) => {
374        pub fn $method_name(&self) -> $return_type {
375            let field = self
376                .fields
377                .iter()
378                .find(|SearchField { name, .. }| *name == $field_name)
379                .expect("Couldn't find field");
380
381            if let Field::$field_variant(ref inner) = field.field {
382                inner
383            } else {
384                panic!("Incorrect field type")
385            }
386        }
387    };
388}
389
390macro_rules! define_field_accessor_mut {
391    ($method_name:ident, $field_name:expr, $field_variant:ident, $return_type:ty) => {
392        pub fn $method_name(&mut self) -> $return_type {
393            let field = self
394                .fields
395                .iter_mut()
396                .find(|SearchField { name, .. }| *name == $field_name)
397                .expect("Couldn't find field");
398
399            if let Field::$field_variant(inner) = &mut field.field {
400                inner
401            } else {
402                panic!("Incorrect field type")
403            }
404        }
405    };
406}
407
408impl SearchFields {
409    // TODO: generate these automatically?
410    define_field_accessor!(search, FieldName::Search, Text, &TextField);
411    define_field_accessor!(replace, FieldName::Replace, Text, &TextField);
412    define_field_accessor!(
413        fixed_strings,
414        FieldName::FixedStrings,
415        Checkbox,
416        &CheckboxField
417    );
418    define_field_accessor!(whole_word, FieldName::WholeWord, Checkbox, &CheckboxField);
419    define_field_accessor!(match_case, FieldName::MatchCase, Checkbox, &CheckboxField);
420    define_field_accessor!(include_files, FieldName::IncludeFiles, Text, &TextField);
421    define_field_accessor!(exclude_files, FieldName::ExcludeFiles, Text, &TextField);
422
423    define_field_accessor_mut!(search_mut, FieldName::Search, Text, &mut TextField);
424    define_field_accessor_mut!(
425        include_files_mut,
426        FieldName::IncludeFiles,
427        Text,
428        &mut TextField
429    );
430    define_field_accessor_mut!(
431        exclude_files_mut,
432        FieldName::ExcludeFiles,
433        Text,
434        &mut TextField
435    );
436
437    #[allow(clippy::needless_pass_by_value)]
438    pub fn with_values(
439        search_field_values: &SearchFieldValues<'_>,
440        disable_prepopulated_fields: bool,
441    ) -> Self {
442        let fields = [
443            SearchField::new_text(
444                FieldName::Search,
445                search_field_values.search.value,
446                search_field_values.search.set_by_cli,
447            ),
448            SearchField::new_text(
449                FieldName::Replace,
450                search_field_values.replace.value,
451                search_field_values.replace.set_by_cli,
452            ),
453            SearchField::new_checkbox(
454                FieldName::FixedStrings,
455                search_field_values.fixed_strings.value,
456                search_field_values.fixed_strings.set_by_cli,
457            ),
458            SearchField::new_checkbox(
459                FieldName::WholeWord,
460                search_field_values.match_whole_word.value,
461                search_field_values.match_whole_word.set_by_cli,
462            ),
463            SearchField::new_checkbox(
464                FieldName::MatchCase,
465                search_field_values.match_case.value,
466                search_field_values.match_case.set_by_cli,
467            ),
468            SearchField::new_text(
469                FieldName::IncludeFiles,
470                search_field_values.include_files.value,
471                search_field_values.include_files.set_by_cli,
472            ),
473            SearchField::new_text(
474                FieldName::ExcludeFiles,
475                search_field_values.exclude_files.value,
476                search_field_values.exclude_files.set_by_cli,
477            ),
478        ];
479
480        Self {
481            highlighted: Self::initial_highlight_position(&fields, disable_prepopulated_fields),
482            fields,
483        }
484    }
485
486    fn initial_highlight_position(
487        fields: &[SearchField],
488        disable_prepopulated_fields: bool,
489    ) -> usize {
490        if disable_prepopulated_fields {
491            fields
492                .iter()
493                .enumerate()
494                .find_map(|(idx, field)| if !field.set_by_cli { Some(idx) } else { None })
495                .unwrap_or(0)
496        } else {
497            0
498        }
499    }
500
501    pub fn highlighted_field(&self) -> &SearchField {
502        &self.fields[self.highlighted]
503    }
504
505    pub fn highlighted_field_mut(&mut self) -> &mut SearchField {
506        &mut self.fields[self.highlighted]
507    }
508
509    fn focus_impl(&mut self, backward: bool, disable_prepopulated_fields: bool) {
510        let step = if backward {
511            self.fields.len().saturating_sub(1)
512        } else {
513            1
514        };
515
516        let initial = self.highlighted;
517        let mut next = (initial + step).rem_euclid(self.fields.len());
518        if disable_prepopulated_fields {
519            while self.fields[next].set_by_cli && next != initial {
520                next = (next + step).rem_euclid(self.fields.len());
521            }
522        }
523        self.highlighted = next;
524    }
525
526    pub fn focus_next(&mut self, disable_prepopulated_fields: bool) {
527        self.focus_impl(false, disable_prepopulated_fields);
528    }
529
530    pub fn focus_prev(&mut self, disable_prepopulated_fields: bool) {
531        self.focus_impl(true, disable_prepopulated_fields);
532    }
533
534    pub fn errors(&self) -> Vec<AppError> {
535        self.fields
536            .iter()
537            .filter_map(|field| {
538                field.error().map(|err| AppError {
539                    name: field.name.title().to_string(),
540                    long: err.long,
541                })
542            })
543            .collect::<Vec<_>>()
544    }
545}
546
547#[derive(Clone, Debug, Eq, PartialEq)]
548pub struct FieldValue<T> {
549    pub value: T,
550    pub set_by_cli: bool,
551}
552
553impl<T> FieldValue<T> {
554    pub fn new(value: T, set_by_cli: bool) -> Self {
555        Self { value, set_by_cli }
556    }
557}
558
559impl<T: Default> Default for FieldValue<T> {
560    fn default() -> Self {
561        Self {
562            value: T::default(),
563            set_by_cli: false,
564        }
565    }
566}
567
568#[derive(Clone, Debug, Eq, PartialEq)]
569pub struct SearchFieldValues<'a> {
570    pub search: FieldValue<&'a str>,
571    pub replace: FieldValue<&'a str>,
572    pub fixed_strings: FieldValue<bool>,
573    pub match_whole_word: FieldValue<bool>,
574    pub match_case: FieldValue<bool>,
575    pub include_files: FieldValue<&'a str>,
576    pub exclude_files: FieldValue<&'a str>,
577}
578
579impl<'a> Default for SearchFieldValues<'a> {
580    fn default() -> SearchFieldValues<'a> {
581        Self {
582            search: FieldValue::new(Self::DEFAULT_SEARCH, false),
583            replace: FieldValue::new(Self::DEFAULT_REPLACE, false),
584            fixed_strings: FieldValue::new(Self::DEFAULT_FIXED_STRINGS, false),
585            match_whole_word: FieldValue::new(Self::DEFAULT_WHOLE_WORD, false),
586            match_case: FieldValue::new(Self::DEFAULT_MATCH_CASE, false),
587            include_files: FieldValue::new(Self::DEFAULT_INCLUDE_FILES, false),
588            exclude_files: FieldValue::new(Self::DEFAULT_EXCLUDE_FILES, false),
589        }
590    }
591}
592
593impl SearchFieldValues<'_> {
594    const DEFAULT_SEARCH: &'static str = "";
595    const DEFAULT_REPLACE: &'static str = "";
596    const DEFAULT_FIXED_STRINGS: bool = false;
597    const DEFAULT_WHOLE_WORD: bool = false;
598    const DEFAULT_MATCH_CASE: bool = true;
599    const DEFAULT_INCLUDE_FILES: &'static str = "";
600    const DEFAULT_EXCLUDE_FILES: &'static str = "";
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn test_text_field_operations() {
609        let mut field = TextField::new("");
610
611        // Test input
612        for c in "Hello".chars() {
613            field.enter_char(c);
614        }
615        assert_eq!(field.text(), "Hello");
616        assert_eq!(field.cursor_idx, 5);
617
618        field.move_cursor_left();
619        assert_eq!(field.cursor_idx, 4);
620        field.move_cursor_right();
621        assert_eq!(field.cursor_idx, 5);
622        field.move_cursor_start();
623        assert_eq!(field.cursor_idx, 0);
624        field.move_cursor_end();
625        assert_eq!(field.cursor_idx, 5);
626
627        field.clear();
628        for c in "Hello world".chars() {
629            field.enter_char(c);
630        }
631        field.move_cursor_start();
632        field.move_cursor_forward_word();
633        assert_eq!(field.cursor_idx, 6);
634        field.move_cursor_forward_word();
635        assert_eq!(field.cursor_idx, 11);
636        field.move_cursor_forward_word();
637        assert_eq!(field.cursor_idx, 11);
638        field.move_cursor_back_word();
639        assert_eq!(field.cursor_idx, 6);
640
641        // Test deletion
642        field.move_cursor_start();
643        field.delete_char_forward();
644        assert_eq!(field.text(), "ello world");
645        field.move_cursor_end();
646        field.delete_char();
647        assert_eq!(field.text(), "ello worl");
648        field.move_cursor_start();
649        field.delete_word_forward();
650        assert_eq!(field.text(), "worl");
651        field.move_cursor_end();
652        field.delete_word_backward();
653        assert_eq!(field.text(), "");
654    }
655
656    #[test]
657    fn test_unicode_text_handling() {
658        let mut field = TextField::new("");
659
660        // Test emoji input
661        field.enter_char('👍');
662        assert_eq!(field.text(), "👍");
663        assert_eq!(field.cursor_idx, 1);
664
665        field.enter_char('🎉');
666        assert_eq!(field.text(), "👍🎉");
667        assert_eq!(field.cursor_idx, 2);
668
669        // Test multi-byte characters
670        field.clear();
671        for c in "こんにちは".chars() {
672            field.enter_char(c);
673        }
674        assert_eq!(field.text(), "こんにちは");
675        assert_eq!(field.cursor_idx, 5);
676
677        // Test moving through multi-byte characters
678        field.move_cursor_left();
679        assert_eq!(field.cursor_idx, 4);
680        field.delete_char();
681        assert_eq!(field.text(), "こんには");
682
683        // Test mixed ASCII and Unicode
684        field.clear();
685        for c in "Hello👋World🌍".chars() {
686            field.enter_char(c);
687        }
688        assert_eq!(field.text(), "Hello👋World🌍");
689        assert_eq!(field.cursor_idx, 12);
690
691        // Move cursor to before emoji
692        field.move_cursor_start();
693        for _ in 0..5 {
694            field.move_cursor_right();
695        }
696        assert_eq!(field.cursor_idx, 5);
697        field.delete_char_forward();
698        assert_eq!(field.text(), "HelloWorld🌍");
699    }
700
701    #[test]
702    fn test_empty_text_edge_cases() {
703        let mut field = TextField::new("");
704
705        // Test operations on empty field
706        assert_eq!(field.text(), "");
707        assert_eq!(field.cursor_idx, 0);
708
709        field.delete_char();
710        assert_eq!(field.text(), "");
711        assert_eq!(field.cursor_idx, 0);
712
713        field.delete_char_forward();
714        assert_eq!(field.text(), "");
715        assert_eq!(field.cursor_idx, 0);
716
717        field.move_cursor_left();
718        assert_eq!(field.cursor_idx, 0);
719
720        field.move_cursor_right();
721        assert_eq!(field.cursor_idx, 0);
722
723        field.delete_word_backward();
724        assert_eq!(field.text(), "");
725
726        field.delete_word_forward();
727        assert_eq!(field.text(), "");
728
729        field.move_cursor_forward_word();
730        assert_eq!(field.cursor_idx, 0);
731
732        field.move_cursor_back_word();
733        assert_eq!(field.cursor_idx, 0);
734    }
735
736    #[test]
737    fn test_cursor_boundary_cases() {
738        let mut field = TextField::new("Test");
739
740        // Test cursor at start boundary
741        field.move_cursor_start();
742        field.move_cursor_left();
743        assert_eq!(field.cursor_idx, 0);
744
745        field.delete_char();
746        assert_eq!(field.text(), "Test");
747        assert_eq!(field.cursor_idx, 0);
748
749        // Test cursor at end boundary
750        field.move_cursor_end();
751        field.move_cursor_right();
752        assert_eq!(field.cursor_idx, 4);
753
754        field.delete_char_forward();
755        assert_eq!(field.text(), "Test");
756        assert_eq!(field.cursor_idx, 4);
757
758        // Test moving by multiple positions
759        field.move_cursor_left_by(10);
760        assert_eq!(field.cursor_idx, 0);
761
762        field.move_cursor_right_by(10);
763        assert_eq!(field.cursor_idx, 4);
764    }
765
766    #[test]
767    fn test_word_navigation_with_punctuation() {
768        let mut field = TextField::new("");
769
770        // Test with punctuation
771        for c in "hello, world! foo-bar_baz.qux".chars() {
772            field.enter_char(c);
773        }
774
775        field.move_cursor_start();
776        field.move_cursor_forward_word();
777        assert_eq!(field.cursor_idx, 7); // After "hello, " (punctuation is part of word)
778
779        field.move_cursor_forward_word();
780        assert_eq!(field.cursor_idx, 14); // After "world! "
781
782        field.move_cursor_forward_word();
783        assert_eq!(field.cursor_idx, 29); // End of string
784
785        field.move_cursor_back_word();
786        assert_eq!(field.cursor_idx, 14); // Start of "foo-bar_baz.qux"
787
788        field.move_cursor_back_word();
789        assert_eq!(field.cursor_idx, 7); // Start of "world!"
790
791        // Test word deletion with punctuation
792        field.delete_word_forward();
793        assert_eq!(field.text(), "hello, foo-bar_baz.qux");
794
795        field.move_cursor_end();
796        field.delete_word_backward();
797        assert_eq!(field.text(), "hello, ");
798    }
799
800    #[test]
801    fn test_word_navigation_special_cases() {
802        let mut field = TextField::new("");
803
804        // Multiple spaces
805        for c in "hello     world".chars() {
806            field.enter_char(c);
807        }
808        field.move_cursor_start();
809        field.move_cursor_forward_word();
810        assert_eq!(field.cursor_idx, 10); // Should skip all spaces
811
812        // Leading/trailing spaces
813        field.clear();
814        for c in "   hello   ".chars() {
815            field.enter_char(c);
816        }
817        field.move_cursor_start();
818        field.move_cursor_forward_word();
819        assert_eq!(field.cursor_idx, 3); // After "   " (skips leading spaces to "hello")
820
821        field.move_cursor_end();
822        field.move_cursor_back_word();
823        assert_eq!(field.cursor_idx, 3); // Start of "hello"
824
825        // Only spaces
826        field.clear();
827        for c in "     ".chars() {
828            field.enter_char(c);
829        }
830        field.move_cursor_start();
831        field.move_cursor_forward_word();
832        assert_eq!(field.cursor_idx, 5);
833
834        field.move_cursor_back_word();
835        assert_eq!(field.cursor_idx, 0);
836    }
837
838    #[test]
839    fn test_visual_cursor_position() {
840        let mut field = TextField::new("");
841
842        // ASCII text
843        for c in "Hello".chars() {
844            field.enter_char(c);
845        }
846        assert_eq!(field.visual_cursor_pos(), 5);
847
848        // Wide characters (e.g., East Asian characters)
849        field.clear();
850        for c in "你好".chars() {
851            field.enter_char(c);
852        }
853        assert_eq!(field.visual_cursor_pos(), 4); // Each character is 2 columns wide
854
855        // Mixed width characters
856        field.clear();
857        for c in "Hi你好".chars() {
858            field.enter_char(c);
859        }
860        assert_eq!(field.visual_cursor_pos(), 6); // "Hi" = 2, "你好" = 4
861
862        // Emoji (usually 2 columns wide)
863        field.clear();
864        field.enter_char('😀');
865        assert_eq!(field.visual_cursor_pos(), 2);
866
867        // Zero-width characters
868        field.clear();
869        for c in "e\u{0301}".chars() {
870            // e with combining acute accent
871            field.enter_char(c);
872        }
873        assert_eq!(field.visual_cursor_pos(), 1); // Combining character has 0 width
874    }
875
876    #[test]
877    fn test_cursor_position_with_wide_chars() {
878        let mut field = TextField::new("");
879
880        // Insert mixed-width text
881        for c in "A你B好C".chars() {
882            field.enter_char(c);
883        }
884
885        // Test cursor movement
886        field.move_cursor_start();
887        assert_eq!(field.cursor_idx, 0);
888        assert_eq!(field.visual_cursor_pos(), 0);
889
890        field.move_cursor_right();
891        assert_eq!(field.cursor_idx, 1); // After 'A'
892        assert_eq!(field.visual_cursor_pos(), 1);
893
894        field.move_cursor_right();
895        assert_eq!(field.cursor_idx, 2); // After '你'
896        assert_eq!(field.visual_cursor_pos(), 3); // 1 + 2
897
898        field.move_cursor_right();
899        assert_eq!(field.cursor_idx, 3); // After 'B'
900        assert_eq!(field.visual_cursor_pos(), 4); // 1 + 2 + 1
901
902        // Test deletion with wide characters
903        field.delete_char();
904        assert_eq!(field.text(), "A你好C");
905        assert_eq!(field.cursor_idx, 2);
906
907        field.delete_char();
908        assert_eq!(field.text(), "A好C");
909        assert_eq!(field.cursor_idx, 1);
910    }
911
912    #[test]
913    fn test_set_text_and_insert_text() {
914        let mut field = TextField::new("initial");
915
916        // Test set_text
917        field.set_text("new text");
918        assert_eq!(field.text(), "new text");
919        assert_eq!(field.cursor_idx, 8);
920
921        // Test set_text with cursor clamping
922        field.set_cursor_idx(100);
923        assert_eq!(field.cursor_idx, 8);
924
925        field.set_text("short");
926        assert_eq!(field.text(), "short");
927        assert_eq!(field.cursor_idx, 5); // Cursor should be clamped
928
929        // Test insert_text
930        field.move_cursor_start();
931        field.insert_text("pre-");
932        assert_eq!(field.text(), "pre-short");
933        assert_eq!(field.cursor_idx, 4);
934
935        field.move_cursor_end();
936        field.insert_text("-post");
937        assert_eq!(field.text(), "pre-short-post");
938        assert_eq!(field.cursor_idx, 14);
939
940        // Insert in middle
941        field.set_cursor_idx(9); // After "pre-short"
942        field.insert_text("[mid]");
943        assert_eq!(field.text(), "pre-short[mid]-post");
944        assert_eq!(field.cursor_idx, 14); // 9 + 5
945    }
946
947    #[test]
948    fn test_delete_to_start() {
949        let mut field = TextField::new("Hello World");
950
951        field.set_cursor_idx(6); // After "Hello "
952        field.delete_to_start();
953        assert_eq!(field.text(), "World");
954        assert_eq!(field.cursor_idx, 0);
955
956        // Delete from end
957        field.set_text("Test");
958        field.move_cursor_end();
959        field.delete_to_start();
960        assert_eq!(field.text(), "");
961        assert_eq!(field.cursor_idx, 0);
962
963        // Delete from start (no-op)
964        field.set_text("Test");
965        field.move_cursor_start();
966        field.delete_to_start();
967        assert_eq!(field.text(), "Test");
968        assert_eq!(field.cursor_idx, 0);
969    }
970
971    #[test]
972    fn test_complex_unicode_scenarios() {
973        let mut field = TextField::new("");
974
975        // Test with various Unicode categories
976        let test_string = "café☕ 世界🌏 Здравствуй👋";
977        for c in test_string.chars() {
978            field.enter_char(c);
979        }
980        assert_eq!(field.text(), test_string);
981
982        // Navigate through the text
983        field.move_cursor_start();
984        field.move_cursor_forward_word();
985        assert_eq!(field.cursor_idx, 6); // After "café☕ "
986
987        field.move_cursor_forward_word();
988        assert_eq!(field.cursor_idx, 10); // After "世界🌏 "
989
990        // Delete operations
991        field.delete_word_backward();
992        assert_eq!(field.text(), "café☕ Здравствуй👋");
993
994        // Test cursor index vs visual position
995        field.move_cursor_start();
996        for _ in 0..4 {
997            field.move_cursor_right();
998        }
999        assert_eq!(field.cursor_idx, 4); // After "café"
1000        assert_eq!(field.visual_cursor_pos(), 4); // café has no wide chars
1001
1002        field.move_cursor_right(); // Move past ☕
1003        assert_eq!(field.cursor_idx, 5);
1004        assert_eq!(field.visual_cursor_pos(), 6); // 4 + 2 for emoji
1005    }
1006
1007    #[test]
1008    fn test_byte_index_calculation() {
1009        let mut field = TextField::new("a你b");
1010
1011        // Verify byte indices for mixed content
1012        field.set_cursor_idx(0);
1013        assert_eq!(field.byte_index(), 0);
1014
1015        field.set_cursor_idx(1); // After 'a'
1016        assert_eq!(field.byte_index(), 1);
1017
1018        field.set_cursor_idx(2); // After '你'
1019        assert_eq!(field.byte_index(), 4); // 'a' = 1 byte, '你' = 3 bytes
1020
1021        field.set_cursor_idx(3); // After 'b'
1022        assert_eq!(field.byte_index(), 5);
1023
1024        // Test at end
1025        field.set_cursor_idx(10); // Beyond end
1026        assert_eq!(field.cursor_idx, 3); // Clamped
1027        assert_eq!(field.byte_index(), 5);
1028    }
1029
1030    #[test]
1031    fn test_checkbox_field() {
1032        let mut field = CheckboxField::new(false);
1033        assert!(!field.checked);
1034
1035        field.handle_keys(KeyCode::Char(' '), KeyModifiers::NONE);
1036        assert!(field.checked);
1037
1038        field.handle_keys(KeyCode::Char(' '), KeyModifiers::NONE);
1039        assert!(!field.checked);
1040
1041        field.handle_keys(KeyCode::Enter, KeyModifiers::NONE);
1042        assert!(!field.checked);
1043    }
1044
1045    #[test]
1046    fn test_search_fields() {
1047        let mut search_fields = SearchFields::with_values(&SearchFieldValues::default(), true);
1048
1049        // Test focus navigation
1050        assert_eq!(search_fields.highlighted, 0);
1051        search_fields.focus_next(true);
1052        assert_eq!(search_fields.highlighted, 1);
1053        search_fields.focus_next(true);
1054        assert_eq!(search_fields.highlighted, 2);
1055        search_fields.focus_next(true);
1056        assert_eq!(search_fields.highlighted, 3);
1057        search_fields.focus_next(true);
1058        assert_eq!(search_fields.highlighted, 4);
1059        search_fields.focus_next(true);
1060        assert_eq!(search_fields.highlighted, 5);
1061        search_fields.focus_next(true);
1062        assert_eq!(search_fields.highlighted, 6);
1063        search_fields.focus_next(true);
1064        assert_eq!(search_fields.highlighted, 0);
1065        search_fields.focus_prev(true);
1066        assert_eq!(search_fields.highlighted, 6);
1067        search_fields.focus_next(true);
1068        assert_eq!(search_fields.highlighted, 0);
1069
1070        for c in "test search".chars() {
1071            search_fields.highlighted_field_mut().handle_keys(
1072                KeyCode::Char(c),
1073                KeyModifiers::NONE,
1074                true,
1075            );
1076        }
1077        assert_eq!(search_fields.search().text(), "test search");
1078
1079        search_fields.focus_next(true);
1080        assert_eq!(search_fields.highlighted, 1);
1081        for c in "test replace".chars() {
1082            search_fields.highlighted_field_mut().handle_keys(
1083                KeyCode::Char(c),
1084                KeyModifiers::NONE,
1085                true,
1086            );
1087        }
1088        assert_eq!(search_fields.replace().text(), "test replace");
1089
1090        search_fields.focus_next(true);
1091        assert_eq!(search_fields.highlighted, 2);
1092        search_fields.highlighted_field_mut().handle_keys(
1093            KeyCode::Char(' '),
1094            KeyModifiers::NONE,
1095            true,
1096        );
1097        assert!(search_fields.fixed_strings().checked);
1098
1099        assert_eq!(search_fields.search().text(), "test search");
1100        assert_eq!(search_fields.fixed_strings().checked, true);
1101
1102        search_fields.highlighted_field_mut().handle_keys(
1103            KeyCode::Char(' '),
1104            KeyModifiers::NONE,
1105            true,
1106        );
1107
1108        assert_eq!(search_fields.search().text(), "test search");
1109        assert_eq!(search_fields.fixed_strings().checked, false);
1110    }
1111
1112    #[test]
1113    fn test_focus_with_locked_disabled_fields() {
1114        let mut search_fields = SearchFields::with_values(
1115            &SearchFieldValues {
1116                search: FieldValue::new("prepopulated", true),
1117                replace: FieldValue::new("", false),
1118                fixed_strings: FieldValue::new(true, true),
1119                match_whole_word: FieldValue::new(false, false),
1120                match_case: FieldValue::new(true, false),
1121                include_files: FieldValue::new("*.rs", true),
1122                exclude_files: FieldValue::new("", false),
1123            },
1124            true,
1125        );
1126
1127        assert_eq!(search_fields.highlighted, 1);
1128        assert_eq!(search_fields.highlighted_field().name, FieldName::Replace);
1129
1130        search_fields.focus_next(true);
1131        assert_eq!(search_fields.highlighted, 3);
1132        assert_eq!(search_fields.highlighted_field().name, FieldName::WholeWord);
1133
1134        search_fields.focus_next(true);
1135        assert_eq!(search_fields.highlighted, 4);
1136        assert_eq!(search_fields.highlighted_field().name, FieldName::MatchCase);
1137
1138        search_fields.focus_next(true);
1139        assert_eq!(search_fields.highlighted, 6);
1140        assert_eq!(
1141            search_fields.highlighted_field().name,
1142            FieldName::ExcludeFiles
1143        );
1144
1145        search_fields.focus_next(true);
1146        assert_eq!(search_fields.highlighted, 1);
1147        assert_eq!(search_fields.highlighted_field().name, FieldName::Replace);
1148
1149        search_fields.focus_prev(true);
1150        assert_eq!(search_fields.highlighted, 6);
1151        assert_eq!(
1152            search_fields.highlighted_field().name,
1153            FieldName::ExcludeFiles
1154        );
1155
1156        search_fields.focus_prev(true);
1157        assert_eq!(search_fields.highlighted, 4);
1158        assert_eq!(search_fields.highlighted_field().name, FieldName::MatchCase);
1159    }
1160
1161    #[test]
1162    fn test_focus_with_unlocked_disabled_fields() {
1163        let mut search_fields = SearchFields::with_values(
1164            &SearchFieldValues {
1165                search: FieldValue::new("prepopulated", true),
1166                replace: FieldValue::new("", false),
1167                fixed_strings: FieldValue::new(true, true),
1168                match_whole_word: FieldValue::new(false, false),
1169                match_case: FieldValue::new(true, false),
1170                include_files: FieldValue::new("*.rs", true),
1171                exclude_files: FieldValue::new("", false),
1172            },
1173            false,
1174        );
1175
1176        assert_eq!(search_fields.highlighted, 0);
1177
1178        search_fields.focus_next(false);
1179        assert_eq!(search_fields.highlighted, 1);
1180        search_fields.focus_next(false);
1181        assert_eq!(search_fields.highlighted, 2);
1182        search_fields.focus_next(false);
1183        assert_eq!(search_fields.highlighted, 3);
1184    }
1185
1186    #[test]
1187    fn test_focus_all_fields_disabled_and_locked() {
1188        let mut search_fields = SearchFields::with_values(
1189            &SearchFieldValues {
1190                search: FieldValue::new("search", true),
1191                replace: FieldValue::new("replace", true),
1192                fixed_strings: FieldValue::new(true, true),
1193                match_whole_word: FieldValue::new(false, true),
1194                match_case: FieldValue::new(true, true),
1195                include_files: FieldValue::new("*.rs", true),
1196                exclude_files: FieldValue::new("*.txt", true),
1197            },
1198            true,
1199        );
1200
1201        assert_eq!(search_fields.highlighted, 0);
1202
1203        search_fields.focus_next(true);
1204        assert_eq!(search_fields.highlighted, 0);
1205
1206        search_fields.focus_prev(true);
1207        assert_eq!(search_fields.highlighted, 0);
1208    }
1209
1210    #[test]
1211    fn test_initial_highlight_position_with_prepopulated_fields_disable_true() {
1212        let search_fields = SearchFields::with_values(
1213            &SearchFieldValues {
1214                search: FieldValue::new("cli_search", true),
1215                replace: FieldValue::new("cli_replace", true),
1216                fixed_strings: FieldValue::new(false, false),
1217                match_whole_word: FieldValue::new(false, false),
1218                match_case: FieldValue::new(true, false),
1219                include_files: FieldValue::new("*.rs", true),
1220                exclude_files: FieldValue::new("", false),
1221            },
1222            true,
1223        );
1224
1225        assert_eq!(search_fields.highlighted, 2);
1226        assert_eq!(
1227            search_fields.highlighted_field().name,
1228            FieldName::FixedStrings
1229        );
1230    }
1231
1232    #[test]
1233    fn test_initial_highlight_position_with_prepopulated_fields_disable_false() {
1234        let search_fields = SearchFields::with_values(
1235            &SearchFieldValues {
1236                search: FieldValue::new("cli_search", true),
1237                replace: FieldValue::new("cli_replace", true),
1238                fixed_strings: FieldValue::new(false, false),
1239                match_whole_word: FieldValue::new(false, false),
1240                match_case: FieldValue::new(true, false),
1241                include_files: FieldValue::new("*.rs", true),
1242                exclude_files: FieldValue::new("", false),
1243            },
1244            false,
1245        );
1246
1247        assert_eq!(search_fields.highlighted, 0);
1248        assert_eq!(search_fields.highlighted_field().name, FieldName::Search);
1249    }
1250}