1use crate::agent::commands::SLASH_COMMANDS;
11use crate::agent::ui::colors::ansi;
12use crossterm::{
13 cursor::{self, MoveUp},
14 event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyModifiers},
15 execute,
16 terminal::{self, Clear, ClearType},
17};
18use std::io::{self, Write};
19use std::path::PathBuf;
20
21pub enum InputResult {
23 Submit(String),
25 Cancel,
27 Exit,
29 TogglePlanMode,
31}
32
33#[derive(Clone)]
35struct Suggestion {
36 display: String,
37 value: String,
38 is_dir: bool,
39}
40
41struct InputState {
43 text: String,
45 cursor: usize,
47 suggestions: Vec<Suggestion>,
49 selected: i32,
51 showing_suggestions: bool,
53 completion_start: Option<usize>,
55 project_path: PathBuf,
57 rendered_lines: usize,
59 prev_wrapped_lines: usize,
61 prev_cursor_line: usize,
63 plan_mode: bool,
65}
66
67impl InputState {
68 fn new(project_path: PathBuf, plan_mode: bool) -> Self {
69 Self {
70 text: String::new(),
71 cursor: 0,
72 suggestions: Vec::new(),
73 selected: -1,
74 showing_suggestions: false,
75 completion_start: None,
76 project_path,
77 rendered_lines: 0,
78 prev_wrapped_lines: 1,
79 prev_cursor_line: 0,
80 plan_mode,
81 }
82 }
83
84 fn insert_char(&mut self, c: char) {
86 if c == '\r' {
88 return;
89 }
90
91 let byte_pos = self.char_to_byte_pos(self.cursor);
93 self.text.insert(byte_pos, c);
94 self.cursor += 1;
95
96 if c == '@' {
98 let valid_trigger = self.cursor == 1
99 || self
100 .text
101 .chars()
102 .nth(self.cursor - 2)
103 .map(|c| c.is_whitespace())
104 .unwrap_or(false);
105 if valid_trigger {
106 self.completion_start = Some(self.cursor - 1);
107 self.refresh_suggestions();
108 }
109 } else if c == '/' && self.cursor == 1 {
110 self.completion_start = Some(0);
112 self.refresh_suggestions();
113 } else if c.is_whitespace() {
114 self.close_suggestions();
116 } else if self.completion_start.is_some() {
117 self.refresh_suggestions();
119 }
120 }
121
122 fn backspace(&mut self) {
124 if self.cursor > 0 {
125 let byte_pos = self.char_to_byte_pos(self.cursor - 1);
126 let next_byte_pos = self.char_to_byte_pos(self.cursor);
127 self.text.replace_range(byte_pos..next_byte_pos, "");
128 self.cursor -= 1;
129
130 if let Some(start) = self.completion_start {
132 if self.cursor <= start {
133 self.close_suggestions();
134 } else {
135 self.refresh_suggestions();
136 }
137 }
138 }
139 }
140
141 fn delete_word_left(&mut self) {
143 if self.cursor == 0 {
144 return;
145 }
146
147 let chars: Vec<char> = self.text.chars().collect();
148 let mut new_cursor = self.cursor;
149
150 while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
152 new_cursor -= 1;
153 }
154
155 while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
157 new_cursor -= 1;
158 }
159
160 let start_byte = self.char_to_byte_pos(new_cursor);
162 let end_byte = self.char_to_byte_pos(self.cursor);
163 self.text.replace_range(start_byte..end_byte, "");
164 self.cursor = new_cursor;
165
166 if let Some(start) = self.completion_start {
168 if self.cursor <= start {
169 self.close_suggestions();
170 } else {
171 self.refresh_suggestions();
172 }
173 }
174 }
175
176 fn clear_all(&mut self) {
178 self.text.clear();
179 self.cursor = 0;
180 self.close_suggestions();
181 }
182
183 fn delete_to_line_start(&mut self) {
185 if self.cursor == 0 {
186 return;
187 }
188
189 let chars: Vec<char> = self.text.chars().collect();
190
191 let mut line_start = self.cursor;
193 while line_start > 0 && chars[line_start - 1] != '\n' {
194 line_start -= 1;
195 }
196
197 if line_start == self.cursor && self.cursor > 0 {
199 line_start -= 1;
200 }
201
202 let start_byte = self.char_to_byte_pos(line_start);
204 let end_byte = self.char_to_byte_pos(self.cursor);
205 self.text.replace_range(start_byte..end_byte, "");
206 self.cursor = line_start;
207
208 self.close_suggestions();
209 }
210
211 fn char_to_byte_pos(&self, char_pos: usize) -> usize {
213 self.text
214 .char_indices()
215 .nth(char_pos)
216 .map(|(i, _)| i)
217 .unwrap_or(self.text.len())
218 }
219
220 fn get_filter(&self) -> Option<String> {
222 self.completion_start.map(|start| {
223 let filter_start = start + 1; if filter_start <= self.cursor {
225 self.text
226 .chars()
227 .skip(filter_start)
228 .take(self.cursor - filter_start)
229 .collect()
230 } else {
231 String::new()
232 }
233 })
234 }
235
236 fn refresh_suggestions(&mut self) {
238 let filter = self.get_filter().unwrap_or_default();
239 let trigger = self
240 .completion_start
241 .and_then(|pos| self.text.chars().nth(pos));
242
243 self.suggestions = match trigger {
244 Some('@') => self.search_files(&filter),
245 Some('/') => self.search_commands(&filter),
246 _ => Vec::new(),
247 };
248
249 self.showing_suggestions = !self.suggestions.is_empty();
250 self.selected = if self.showing_suggestions { 0 } else { -1 };
251 }
252
253 fn search_files(&self, filter: &str) -> Vec<Suggestion> {
255 let mut results = Vec::new();
256 let filter_lower = filter.to_lowercase();
257
258 self.walk_dir(
259 &self.project_path.clone(),
260 &filter_lower,
261 &mut results,
262 0,
263 4,
264 );
265
266 results.sort_by(|a, b| match (a.is_dir, b.is_dir) {
268 (true, false) => std::cmp::Ordering::Less,
269 (false, true) => std::cmp::Ordering::Greater,
270 _ => a.value.len().cmp(&b.value.len()),
271 });
272
273 results.truncate(8);
274 results
275 }
276
277 fn walk_dir(
279 &self,
280 dir: &PathBuf,
281 filter: &str,
282 results: &mut Vec<Suggestion>,
283 depth: usize,
284 max_depth: usize,
285 ) {
286 if depth > max_depth || results.len() >= 20 {
287 return;
288 }
289
290 let skip_dirs = [
291 "node_modules",
292 ".git",
293 "target",
294 "__pycache__",
295 ".venv",
296 "venv",
297 "dist",
298 "build",
299 ".next",
300 ];
301
302 let entries = match std::fs::read_dir(dir) {
303 Ok(e) => e,
304 Err(_) => return,
305 };
306
307 for entry in entries.flatten() {
308 let path = entry.path();
309 let file_name = entry.file_name().to_string_lossy().to_string();
310
311 if file_name.starts_with('.')
313 && !file_name.starts_with(".env")
314 && file_name != ".gitignore"
315 {
316 continue;
317 }
318
319 let rel_path = path
320 .strip_prefix(&self.project_path)
321 .map(|p| p.to_string_lossy().to_string())
322 .unwrap_or_else(|_| file_name.clone());
323
324 let is_dir = path.is_dir();
325
326 if filter.is_empty()
327 || rel_path.to_lowercase().contains(filter)
328 || file_name.to_lowercase().contains(filter)
329 {
330 let display = if is_dir {
331 format!("{}/", rel_path)
332 } else {
333 rel_path.clone()
334 };
335 results.push(Suggestion {
336 display: display.clone(),
337 value: display,
338 is_dir,
339 });
340 }
341
342 if is_dir && !skip_dirs.contains(&file_name.as_str()) {
343 self.walk_dir(&path, filter, results, depth + 1, max_depth);
344 }
345 }
346 }
347
348 fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
350 let filter_lower = filter.to_lowercase();
351
352 SLASH_COMMANDS
353 .iter()
354 .filter(|cmd| {
355 cmd.name.to_lowercase().starts_with(&filter_lower)
356 || cmd
357 .alias
358 .map(|a| a.to_lowercase().starts_with(&filter_lower))
359 .unwrap_or(false)
360 })
361 .take(8)
362 .map(|cmd| Suggestion {
363 display: format!("/{:<12} {}", cmd.name, cmd.description),
364 value: format!("/{}", cmd.name),
365 is_dir: false,
366 })
367 .collect()
368 }
369
370 fn close_suggestions(&mut self) {
372 self.showing_suggestions = false;
373 self.suggestions.clear();
374 self.selected = -1;
375 self.completion_start = None;
376 }
377
378 fn select_up(&mut self) {
380 if self.showing_suggestions && !self.suggestions.is_empty() && self.selected > 0 {
381 self.selected -= 1;
382 }
383 }
384
385 fn select_down(&mut self) {
387 if self.showing_suggestions
388 && !self.suggestions.is_empty()
389 && self.selected < self.suggestions.len() as i32 - 1
390 {
391 self.selected += 1;
392 }
393 }
394
395 fn accept_selection(&mut self) -> bool {
397 if self.showing_suggestions
398 && self.selected >= 0
399 && let Some(suggestion) = self.suggestions.get(self.selected as usize)
400 {
401 if let Some(start) = self.completion_start {
402 let before = self.text.chars().take(start).collect::<String>();
404 let after = self.text.chars().skip(self.cursor).collect::<String>();
405
406 let replacement = if suggestion.value.starts_with('/') {
408 format!("{} ", suggestion.value)
409 } else {
410 format!("@{} ", suggestion.value)
411 };
412
413 self.text = format!("{}{}{}", before, replacement, after);
414 self.cursor = before.len() + replacement.len();
415 }
416 self.close_suggestions();
417 return true;
418 }
419 false
420 }
421
422 fn cursor_left(&mut self) {
424 if self.cursor > 0 {
425 self.cursor -= 1;
426 }
427 }
428
429 fn cursor_right(&mut self) {
431 if self.cursor < self.text.chars().count() {
432 self.cursor += 1;
433 }
434 }
435
436 fn cursor_word_left(&mut self) {
438 if self.cursor == 0 {
439 return;
440 }
441
442 let chars: Vec<char> = self.text.chars().collect();
443 let mut pos = self.cursor;
444
445 while pos > 0 && chars[pos - 1].is_whitespace() {
447 pos -= 1;
448 }
449
450 while pos > 0 && !chars[pos - 1].is_whitespace() {
452 pos -= 1;
453 }
454
455 self.cursor = pos;
456 }
457
458 fn cursor_word_right(&mut self) {
460 let chars: Vec<char> = self.text.chars().collect();
461 let text_len = chars.len();
462
463 if self.cursor >= text_len {
464 return;
465 }
466
467 let mut pos = self.cursor;
468
469 while pos < text_len && !chars[pos].is_whitespace() {
471 pos += 1;
472 }
473
474 while pos < text_len && chars[pos].is_whitespace() {
476 pos += 1;
477 }
478
479 self.cursor = pos;
480 }
481
482 fn cursor_home(&mut self) {
484 self.cursor = 0;
485 }
486
487 fn cursor_end(&mut self) {
489 self.cursor = self.text.chars().count();
490 }
491
492 fn cursor_up(&mut self) {
494 let chars: Vec<char> = self.text.chars().collect();
495 if self.cursor == 0 {
496 return;
497 }
498
499 let mut current_line_start = self.cursor;
501 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
502 current_line_start -= 1;
503 }
504
505 if current_line_start == 0 {
507 return;
508 }
509
510 let col = self.cursor - current_line_start;
512
513 let prev_line_end = current_line_start - 1; let mut prev_line_start = prev_line_end;
516 while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
517 prev_line_start -= 1;
518 }
519
520 let prev_line_len = prev_line_end - prev_line_start;
522
523 self.cursor = prev_line_start + col.min(prev_line_len);
525 }
526
527 fn cursor_down(&mut self) {
529 let chars: Vec<char> = self.text.chars().collect();
530 let text_len = chars.len();
531
532 let mut current_line_start = self.cursor;
534 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
535 current_line_start -= 1;
536 }
537
538 let col = self.cursor - current_line_start;
540
541 let mut current_line_end = self.cursor;
543 while current_line_end < text_len && chars[current_line_end] != '\n' {
544 current_line_end += 1;
545 }
546
547 if current_line_end >= text_len {
549 return;
550 }
551
552 let next_line_start = current_line_end + 1;
554
555 let mut next_line_end = next_line_start;
557 while next_line_end < text_len && chars[next_line_end] != '\n' {
558 next_line_end += 1;
559 }
560
561 let next_line_len = next_line_end - next_line_start;
563
564 self.cursor = next_line_start + col.min(next_line_len);
566 }
567}
568
569fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
571 let (term_width, _) = terminal::size().unwrap_or((80, 24));
573 let term_width = term_width as usize;
574
575 let mode_prefix_len = if state.plan_mode { 2 } else { 0 }; let prompt_len = prompt.len() + 1 + mode_prefix_len; if state.prev_cursor_line > 0 {
582 execute!(stdout, cursor::MoveUp(state.prev_cursor_line as u16))?;
583 }
584 execute!(stdout, cursor::MoveToColumn(0))?;
585
586 execute!(stdout, Clear(ClearType::FromCursorDown))?;
588
589 let display_text = state.text.replace('\n', "\r\n");
592 if state.plan_mode {
593 print!(
594 "{}★{} {}{}{} {}",
595 ansi::ORANGE,
596 ansi::RESET,
597 ansi::SUCCESS,
598 prompt,
599 ansi::RESET,
600 display_text
601 );
602 } else {
603 print!(
604 "{}{}{} {}",
605 ansi::SUCCESS,
606 prompt,
607 ansi::RESET,
608 display_text
609 );
610 }
611 stdout.flush()?;
612
613 let mut total_lines = 1;
615 let mut current_line_len = prompt_len;
616
617 for c in state.text.chars() {
618 if c == '\n' {
619 total_lines += 1;
620 current_line_len = 0;
621 } else {
622 current_line_len += 1;
623 if term_width > 0 && current_line_len > term_width {
624 total_lines += 1;
625 current_line_len = 1;
626 }
627 }
628 }
629 state.prev_wrapped_lines = total_lines;
630
631 let mut lines_rendered = 0;
633 if state.showing_suggestions && !state.suggestions.is_empty() {
634 print!("\r\n");
636 lines_rendered += 1;
637
638 for (i, suggestion) in state.suggestions.iter().enumerate() {
639 let is_selected = i as i32 == state.selected;
640 let prefix = if is_selected { "▸" } else { " " };
641
642 if is_selected {
643 if suggestion.is_dir {
644 print!(
646 " {}{}{} {}{}\r\n",
647 ansi::BOLD,
648 ansi::STD_CYAN,
649 prefix,
650 suggestion.display,
651 ansi::RESET
652 );
653 } else {
654 print!(
656 " {}{} {}{}\r\n",
657 ansi::BRIGHT,
658 prefix,
659 suggestion.display,
660 ansi::RESET
661 );
662 }
663 } else {
664 print!(
666 " {}{} {}{}\r\n",
667 ansi::SUBDUED,
668 prefix,
669 suggestion.display,
670 ansi::RESET
671 );
672 }
673 lines_rendered += 1;
674 }
675
676 print!(
678 " {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n",
679 ansi::SUBDUED,
680 ansi::RESET
681 );
682 lines_rendered += 1;
683 }
684
685 let mut cursor_line = 0;
688 let mut cursor_col = prompt_len;
689
690 for (i, c) in state.text.chars().enumerate() {
691 if i >= state.cursor {
692 break;
693 }
694 if c == '\n' {
695 cursor_line += 1;
696 cursor_col = 0;
697 } else {
698 cursor_col += 1;
699 if term_width > 0 && cursor_col >= term_width {
700 cursor_line += 1;
701 cursor_col = 0;
702 }
703 }
704 }
705
706 let lines_after_cursor = total_lines.saturating_sub(cursor_line + 1) + lines_rendered;
708 if lines_after_cursor > 0 {
709 execute!(stdout, cursor::MoveUp(lines_after_cursor as u16))?;
710 }
711 execute!(stdout, cursor::MoveToColumn(cursor_col as u16))?;
712
713 state.prev_cursor_line = cursor_line;
715
716 stdout.flush()?;
717 Ok(lines_rendered)
718}
719
720fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
722 if num_lines > 0 {
723 for _ in 0..num_lines {
725 execute!(stdout, cursor::MoveDown(1), Clear(ClearType::CurrentLine))?;
726 }
727 execute!(stdout, MoveUp(num_lines as u16))?;
728 }
729 Ok(())
730}
731
732pub fn read_input_with_file_picker(
735 prompt: &str,
736 project_path: &std::path::Path,
737 plan_mode: bool,
738) -> InputResult {
739 let mut stdout = io::stdout();
740
741 print!("{}", ansi::SHOW_CURSOR);
743 let _ = stdout.flush();
744
745 if terminal::enable_raw_mode().is_err() {
747 return read_simple_input(prompt);
748 }
749
750 let _ = execute!(stdout, EnableBracketedPaste);
752
753 if plan_mode {
755 print!(
756 "{}★{} {}{}{} ",
757 ansi::ORANGE,
758 ansi::RESET,
759 ansi::SUCCESS,
760 prompt,
761 ansi::RESET
762 );
763 } else {
764 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
765 }
766 let _ = stdout.flush();
767
768 let mut state = InputState::new(project_path.to_path_buf(), plan_mode);
770
771 let result = loop {
772 match event::read() {
773 Ok(Event::Paste(pasted_text)) => {
775 let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
777 for c in normalized.chars() {
778 state.insert_char(c);
779 }
780 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
782 }
783 Ok(Event::Key(key_event)) => {
784 match key_event.code {
785 KeyCode::Enter => {
786 if key_event.modifiers.contains(KeyModifiers::SHIFT)
788 || key_event.modifiers.contains(KeyModifiers::ALT)
789 {
790 state.insert_char('\n');
791 } else if state.showing_suggestions && state.selected >= 0 {
792 state.accept_selection();
794 } else if !state.text.trim().is_empty() {
795 print!("\r\n");
797 let _ = stdout.flush();
798 break InputResult::Submit(state.text.clone());
799 }
800 }
801 KeyCode::Tab => {
802 if state.showing_suggestions && state.selected >= 0 {
804 state.accept_selection();
805 }
806 }
807 KeyCode::BackTab => {
808 print!("\r\n");
810 let _ = stdout.flush();
811 break InputResult::TogglePlanMode;
812 }
813 KeyCode::Esc => {
814 if state.showing_suggestions {
815 state.close_suggestions();
816 } else {
817 print!("\r\n");
818 let _ = stdout.flush();
819 break InputResult::Cancel;
820 }
821 }
822 KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
823 print!("\r\n");
825 let _ = stdout.flush();
826 break InputResult::Cancel;
827 }
828 KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
829 print!("\r\n");
830 let _ = stdout.flush();
831 break InputResult::Exit;
832 }
833 KeyCode::Up => {
834 if state.showing_suggestions {
835 state.select_up();
836 } else {
837 state.cursor_up();
838 }
839 }
840 KeyCode::Down => {
841 if state.showing_suggestions {
842 state.select_down();
843 } else {
844 state.cursor_down();
845 }
846 }
847 KeyCode::Left => {
848 state.cursor_left();
849 if let Some(start) = state.completion_start
851 && state.cursor <= start
852 {
853 state.close_suggestions();
854 }
855 }
856 KeyCode::Right => {
857 state.cursor_right();
858 }
859 KeyCode::Char('b') if key_event.modifiers.contains(KeyModifiers::ALT) => {
861 state.cursor_word_left();
862 state.close_suggestions();
863 }
864 KeyCode::Char('f') if key_event.modifiers.contains(KeyModifiers::ALT) => {
866 state.cursor_word_right();
867 }
868 KeyCode::Home | KeyCode::Char('a')
869 if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
870 {
871 state.cursor_home();
872 state.close_suggestions();
873 }
874 KeyCode::End | KeyCode::Char('e')
875 if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
876 {
877 state.cursor_end();
878 }
879 KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
881 state.clear_all();
882 }
883 KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
885 state.delete_to_line_start();
886 }
887 KeyCode::Backspace
889 if key_event.modifiers.contains(KeyModifiers::CONTROL)
890 && key_event.modifiers.contains(KeyModifiers::SHIFT) =>
891 {
892 state.delete_to_line_start();
893 }
894 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
896 state.delete_to_line_start();
897 }
898 KeyCode::Char('w') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
900 state.delete_word_left();
901 }
902 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::ALT) => {
903 state.delete_word_left();
904 }
905 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
906 state.delete_word_left();
907 }
908 KeyCode::Char('j') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
910 state.insert_char('\n');
911 }
912 KeyCode::Backspace => {
913 state.backspace();
914 }
915 KeyCode::Char('\n') => {
916 state.insert_char('\n');
918 }
919 KeyCode::Char(c) => {
920 state.insert_char(c);
921 }
922 _ => {}
923 }
924
925 let should_render =
928 !event::poll(std::time::Duration::from_millis(0)).unwrap_or(false);
929 if should_render {
930 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
931 }
932 }
933 Ok(Event::Resize(_, _)) => {
934 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
936 }
937 Err(_) => {
938 break InputResult::Cancel;
939 }
940 _ => {}
941 }
942 };
943
944 let _ = execute!(stdout, DisableBracketedPaste);
946
947 let _ = terminal::disable_raw_mode();
949
950 if state.rendered_lines > 0 {
952 let _ = clear_suggestions(state.rendered_lines, &mut stdout);
953 }
954
955 result
956}
957
958fn read_simple_input(prompt: &str) -> InputResult {
960 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
961 let _ = io::stdout().flush();
962
963 let mut input = String::new();
964 match io::stdin().read_line(&mut input) {
965 Ok(_) => {
966 let trimmed = input.trim();
967 if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
968 InputResult::Exit
969 } else {
970 InputResult::Submit(trimmed.to_string())
971 }
972 }
973 Err(_) => InputResult::Cancel,
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 fn new_state() -> InputState {
982 InputState::new(PathBuf::from("/tmp"), false)
983 }
984
985 #[test]
986 fn test_insert_char_basic() {
987 let mut state = new_state();
988 state.insert_char('h');
989 state.insert_char('i');
990 assert_eq!(state.text, "hi");
991 assert_eq!(state.cursor, 2);
992 }
993
994 #[test]
995 fn test_insert_char_utf8() {
996 let mut state = new_state();
997 state.insert_char('日');
998 state.insert_char('本');
999 assert_eq!(state.text, "日本");
1000 assert_eq!(state.cursor, 2);
1001 }
1002
1003 #[test]
1004 fn test_insert_char_skips_cr() {
1005 let mut state = new_state();
1006 state.insert_char('a');
1007 state.insert_char('\r');
1008 state.insert_char('b');
1009 assert_eq!(state.text, "ab");
1010 }
1011
1012 #[test]
1013 fn test_backspace_basic() {
1014 let mut state = new_state();
1015 state.insert_char('h');
1016 state.insert_char('e');
1017 state.insert_char('l');
1018 state.backspace();
1019 assert_eq!(state.text, "he");
1020 assert_eq!(state.cursor, 2);
1021 }
1022
1023 #[test]
1024 fn test_backspace_utf8() {
1025 let mut state = new_state();
1026 state.insert_char('日');
1027 state.insert_char('本');
1028 state.backspace();
1029 assert_eq!(state.text, "日");
1030 assert_eq!(state.cursor, 1);
1031 }
1032
1033 #[test]
1034 fn test_backspace_at_start() {
1035 let mut state = new_state();
1036 state.backspace(); assert_eq!(state.text, "");
1038 assert_eq!(state.cursor, 0);
1039 }
1040
1041 #[test]
1042 fn test_cursor_movement() {
1043 let mut state = new_state();
1044 state.insert_char('h');
1045 state.insert_char('e');
1046 state.insert_char('l');
1047 state.insert_char('l');
1048 state.insert_char('o');
1049 assert_eq!(state.cursor, 5);
1050
1051 state.cursor_left();
1052 assert_eq!(state.cursor, 4);
1053
1054 state.cursor_home();
1055 assert_eq!(state.cursor, 0);
1056
1057 state.cursor_right();
1058 assert_eq!(state.cursor, 1);
1059
1060 state.cursor_end();
1061 assert_eq!(state.cursor, 5);
1062 }
1063
1064 #[test]
1065 fn test_cursor_bounds() {
1066 let mut state = new_state();
1067 state.insert_char('a');
1068
1069 state.cursor_left();
1070 state.cursor_left(); assert_eq!(state.cursor, 0);
1072
1073 state.cursor_right();
1074 state.cursor_right(); assert_eq!(state.cursor, 1);
1076 }
1077
1078 #[test]
1079 fn test_char_to_byte_pos_ascii() {
1080 let mut state = new_state();
1081 state.text = "hello".to_string();
1082 assert_eq!(state.char_to_byte_pos(0), 0);
1083 assert_eq!(state.char_to_byte_pos(2), 2);
1084 assert_eq!(state.char_to_byte_pos(5), 5);
1085 }
1086
1087 #[test]
1088 fn test_char_to_byte_pos_utf8() {
1089 let mut state = new_state();
1090 state.text = "日本語".to_string(); assert_eq!(state.char_to_byte_pos(0), 0);
1092 assert_eq!(state.char_to_byte_pos(1), 3);
1093 assert_eq!(state.char_to_byte_pos(2), 6);
1094 assert_eq!(state.char_to_byte_pos(3), 9);
1095 }
1096
1097 #[test]
1098 fn test_clear_all() {
1099 let mut state = new_state();
1100 state.insert_char('h');
1101 state.insert_char('e');
1102 state.insert_char('l');
1103 state.clear_all();
1104 assert_eq!(state.text, "");
1105 assert_eq!(state.cursor, 0);
1106 }
1107
1108 #[test]
1109 fn test_delete_word_left() {
1110 let mut state = new_state();
1111 for c in "hello world".chars() {
1112 state.insert_char(c);
1113 }
1114 state.delete_word_left();
1115 assert_eq!(state.text, "hello ");
1116 assert_eq!(state.cursor, 6);
1117 }
1118
1119 #[test]
1120 fn test_multiline_cursor_navigation() {
1121 let mut state = new_state();
1122 for c in "ab".chars() {
1124 state.insert_char(c);
1125 }
1126 state.insert_char('\n');
1127 for c in "cd".chars() {
1128 state.insert_char(c);
1129 }
1130 assert_eq!(state.cursor, 5); state.cursor_up();
1133 assert_eq!(state.cursor, 2); state.cursor_down();
1136 assert_eq!(state.cursor, 5); }
1138
1139 #[test]
1140 fn test_get_filter_at_symbol() {
1141 let mut state = new_state();
1142 state.text = "@src".to_string();
1143 state.cursor = 4;
1144 state.completion_start = Some(0);
1145 assert_eq!(state.get_filter(), Some("src".to_string()));
1146 }
1147
1148 #[test]
1149 fn test_get_filter_no_completion() {
1150 let mut state = new_state();
1151 state.text = "hello".to_string();
1152 state.cursor = 5;
1153 assert_eq!(state.get_filter(), None);
1154 }
1155
1156 #[test]
1157 fn test_cursor_word_left() {
1158 let mut state = new_state();
1159 state.text = "hello world test".to_string();
1160 state.cursor = 16; state.cursor_word_left();
1163 assert_eq!(state.cursor, 12); state.cursor_word_left();
1166 assert_eq!(state.cursor, 6); state.cursor_word_left();
1169 assert_eq!(state.cursor, 0); state.cursor_word_left();
1172 assert_eq!(state.cursor, 0); }
1174
1175 #[test]
1176 fn test_cursor_word_right() {
1177 let mut state = new_state();
1178 state.text = "hello world test".to_string();
1179 state.cursor = 0; state.cursor_word_right();
1182 assert_eq!(state.cursor, 6); state.cursor_word_right();
1185 assert_eq!(state.cursor, 12); state.cursor_word_right();
1188 assert_eq!(state.cursor, 16); state.cursor_word_right();
1191 assert_eq!(state.cursor, 16); }
1193
1194 #[test]
1195 fn test_cursor_word_movement_mid_word() {
1196 let mut state = new_state();
1197 state.text = "hello world".to_string();
1198 state.cursor = 8; state.cursor_word_left();
1201 assert_eq!(state.cursor, 6); state.cursor = 3; state.cursor_word_right();
1205 assert_eq!(state.cursor, 6); }
1207}