1use crossterm::{
2 event::{self, Event},
3 execute,
4 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
5};
6use ratatui::{
7 Terminal,
8 backend::CrosstermBackend,
9 layout::{Constraint, Direction, Layout, Rect},
10 style::{Color, Modifier, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, Paragraph, Wrap},
13};
14use std::fmt;
15use std::io;
16use tui_textarea::{CursorMove, Input, Key, TextArea};
17use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
22enum Mode {
23 Normal,
24 Insert,
25 Visual,
26 Operator(char),
27 Command(String), Search(String), }
30
31impl fmt::Display for Mode {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::Normal => write!(f, "NORMAL"),
35 Self::Insert => write!(f, "INSERT"),
36 Self::Visual => write!(f, "VISUAL"),
37 Self::Operator(c) => write!(f, "OPERATOR({})", c),
38 Self::Command(_) => write!(f, "COMMAND"),
39 Self::Search(_) => write!(f, "SEARCH"),
40 }
41 }
42}
43
44impl Mode {
45 fn cursor_style(&self) -> Style {
46 let color = match self {
47 Self::Normal => Color::Reset,
48 Self::Insert => Color::LightBlue,
49 Self::Visual => Color::LightYellow,
50 Self::Operator(_) => Color::LightGreen,
51 Self::Command(_) => Color::Reset,
52 Self::Search(_) => Color::Reset,
53 };
54 Style::default().fg(color).add_modifier(Modifier::REVERSED)
55 }
56
57 fn border_color(&self) -> Color {
58 match self {
59 Self::Normal => Color::DarkGray,
60 Self::Insert => Color::Cyan,
61 Self::Visual => Color::LightYellow,
62 Self::Operator(_) => Color::LightGreen,
63 Self::Command(_) => Color::DarkGray,
64 Self::Search(_) => Color::Magenta,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
73struct SearchMatch {
74 line: usize,
75 start: usize,
76 #[allow(dead_code)]
77 end: usize, }
79
80#[derive(Debug, Clone, Default)]
82struct SearchState {
83 pattern: String,
84 matches: Vec<SearchMatch>,
85 current_index: usize,
86}
87
88impl SearchState {
89 fn new() -> Self {
90 Self::default()
91 }
92
93 fn search(&mut self, pattern: &str, lines: &[String]) -> usize {
95 self.pattern = pattern.to_string();
96 self.matches.clear();
97 self.current_index = 0;
98
99 if pattern.is_empty() {
100 return 0;
101 }
102
103 for (line_idx, line) in lines.iter().enumerate() {
104 let mut start = 0;
105 while let Some(pos) = line[start..].find(pattern) {
106 let abs_start = start + pos;
107 self.matches.push(SearchMatch {
108 line: line_idx,
109 start: abs_start,
110 end: abs_start + pattern.len(),
111 });
112 start = abs_start + pattern.len();
113 if start >= line.len() {
114 break;
115 }
116 }
117 }
118
119 self.matches.len()
120 }
121
122 fn next_match(&mut self) -> Option<(usize, usize)> {
124 if self.matches.is_empty() {
125 return None;
126 }
127 let m = &self.matches[self.current_index];
128 let result = Some((m.line, m.start));
129 self.current_index = (self.current_index + 1) % self.matches.len();
130 result
131 }
132
133 fn prev_match(&mut self) -> Option<(usize, usize)> {
135 if self.matches.is_empty() {
136 return None;
137 }
138 if self.current_index == 0 {
139 self.current_index = self.matches.len() - 1;
140 } else {
141 self.current_index -= 1;
142 }
143 let m = &self.matches[self.current_index];
144 Some((m.line, m.start))
145 }
146
147 fn current_info(&self) -> (usize, usize) {
149 if self.matches.is_empty() {
150 (0, 0)
151 } else {
152 let display_idx = if self.current_index == 0 {
153 self.matches.len()
154 } else {
155 self.current_index
156 };
157 (display_idx, self.matches.len())
158 }
159 }
160}
161
162fn apply_search_highlight(buf: &mut ratatui::buffer::Buffer, area: Rect, search: &SearchState) {
166 if search.pattern.is_empty() || search.matches.is_empty() {
167 return;
168 }
169
170 let pattern = &search.pattern;
171
172 for row in area.top()..area.bottom() {
174 let content_start = area.left() + 3; let mut chars_with_pos: Vec<(char, u16)> = Vec::new();
176
177 for col in content_start..area.right() {
178 if let Some(cell) = buf.cell((col, row)) {
179 let symbol = cell.symbol();
180 for c in symbol.chars() {
181 chars_with_pos.push((c, col));
182 }
183 }
184 }
185
186 let pattern_chars: Vec<char> = pattern.chars().collect();
187 if pattern_chars.is_empty() {
188 continue;
189 }
190
191 let mut i = 0;
192 while i + pattern_chars.len() <= chars_with_pos.len() {
193 let is_match = pattern_chars.iter().enumerate().all(|(j, pc)| {
194 chars_with_pos
195 .get(i + j)
196 .map(|(c, _)| c == pc)
197 .unwrap_or(false)
198 });
199
200 if is_match {
201 let style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
203
204 for j in 0..pattern_chars.len() {
205 if let Some((_, col)) = chars_with_pos.get(i + j) {
206 buf[(*col, row)].set_style(style);
207 }
208 }
209
210 i += pattern_chars.len();
211 } else {
212 i += 1;
213 }
214 }
215 }
216}
217
218fn jump_to_match(textarea: &mut TextArea, line: usize, col: usize) {
220 textarea.move_cursor(CursorMove::Jump(
221 line.try_into().unwrap_or(0),
222 col.try_into().unwrap_or(0),
223 ));
224}
225
226enum Transition {
229 Nop,
230 Mode(Mode),
231 Pending(Input),
232 Submit, Quit, TryQuit, Search(String), NextMatch, PrevMatch, }
239
240struct Vim {
242 mode: Mode,
243 pending: Input,
244 search: SearchState,
245}
246
247impl Vim {
248 fn new(mode: Mode) -> Self {
249 Self {
250 mode,
251 pending: Input::default(),
252 search: SearchState::new(),
253 }
254 }
255
256 fn with_pending(self, pending: Input) -> Self {
257 Self {
258 mode: self.mode,
259 pending,
260 search: self.search,
261 }
262 }
263
264 fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
266 if input.key == Key::Null {
267 return Transition::Nop;
268 }
269
270 if input.ctrl && input.key == Key::Char('s') {
272 return Transition::Submit;
273 }
274
275 if input.ctrl && input.key == Key::Char('q') {
277 return Transition::Quit;
278 }
279
280 match &self.mode {
281 Mode::Command(cmd) => self.handle_command_mode(input, cmd),
282 Mode::Search(pattern) => self.handle_search_mode(input, pattern),
283 Mode::Insert => self.handle_insert_mode(input, textarea),
284 Mode::Normal | Mode::Visual | Mode::Operator(_) => {
285 self.handle_normal_visual_operator(input, textarea)
286 }
287 }
288 }
289
290 fn handle_insert_mode(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
292 match input {
293 Input { key: Key::Esc, .. }
294 | Input {
295 key: Key::Char('c'),
296 ctrl: true,
297 ..
298 } => Transition::Mode(Mode::Normal),
299 input => {
300 textarea.input(input);
301 Transition::Mode(Mode::Insert)
302 }
303 }
304 }
305
306 fn handle_command_mode(&self, input: Input, cmd: &str) -> Transition {
308 match input.key {
309 Key::Esc => Transition::Mode(Mode::Normal),
310 Key::Enter => {
311 let cmd = cmd.trim();
313 match cmd {
314 "wq" | "x" => Transition::Submit,
315 "w" => Transition::Submit,
316 "q" => Transition::TryQuit, "q!" => Transition::Quit, _ => Transition::Mode(Mode::Normal), }
320 }
321 Key::Backspace => {
322 if cmd.is_empty() {
323 Transition::Mode(Mode::Normal)
324 } else {
325 let mut new_cmd = cmd.to_string();
326 new_cmd.pop();
327 Transition::Mode(Mode::Command(new_cmd))
328 }
329 }
330 Key::Char(c) => {
331 let mut new_cmd = cmd.to_string();
332 new_cmd.push(c);
333 Transition::Mode(Mode::Command(new_cmd))
334 }
335 _ => Transition::Nop,
336 }
337 }
338
339 fn handle_search_mode(&self, input: Input, pattern: &str) -> Transition {
341 match input.key {
342 Key::Esc => Transition::Mode(Mode::Normal),
343 Key::Enter => {
344 Transition::Search(pattern.to_string())
346 }
347 Key::Backspace => {
348 if pattern.is_empty() {
349 Transition::Mode(Mode::Normal)
350 } else {
351 let mut new_pattern = pattern.to_string();
352 new_pattern.pop();
353 Transition::Mode(Mode::Search(new_pattern))
354 }
355 }
356 Key::Char(c) => {
357 let mut new_pattern = pattern.to_string();
358 new_pattern.push(c);
359 Transition::Mode(Mode::Search(new_pattern))
360 }
361 _ => Transition::Nop,
362 }
363 }
364
365 fn handle_normal_visual_operator(
367 &self,
368 input: Input,
369 textarea: &mut TextArea<'_>,
370 ) -> Transition {
371 match input {
372 Input {
374 key: Key::Char(':'),
375 ctrl: false,
376 ..
377 } if self.mode == Mode::Normal => {
378 return Transition::Mode(Mode::Command(String::new()));
379 }
380 Input {
382 key: Key::Char('/'),
383 ctrl: false,
384 ..
385 } if self.mode == Mode::Normal => {
386 return Transition::Mode(Mode::Search(String::new()));
387 }
388 Input {
390 key: Key::Char('n'),
391 ctrl: false,
392 ..
393 } if self.mode == Mode::Normal => {
394 return Transition::NextMatch;
395 }
396 Input {
398 key: Key::Char('N'),
399 ctrl: false,
400 ..
401 } if self.mode == Mode::Normal => {
402 return Transition::PrevMatch;
403 }
404 Input {
406 key: Key::Char('h'),
407 ..
408 } => textarea.move_cursor(CursorMove::Back),
409 Input {
410 key: Key::Char('j'),
411 ..
412 } => textarea.move_cursor(CursorMove::Down),
413 Input {
414 key: Key::Char('k'),
415 ..
416 } => textarea.move_cursor(CursorMove::Up),
417 Input {
418 key: Key::Char('l'),
419 ..
420 } => textarea.move_cursor(CursorMove::Forward),
421 Input {
422 key: Key::Char('w'),
423 ..
424 } => textarea.move_cursor(CursorMove::WordForward),
425 Input {
426 key: Key::Char('e'),
427 ctrl: false,
428 ..
429 } => {
430 textarea.move_cursor(CursorMove::WordEnd);
431 if matches!(self.mode, Mode::Operator(_)) {
432 textarea.move_cursor(CursorMove::Forward);
433 }
434 }
435 Input {
436 key: Key::Char('b'),
437 ctrl: false,
438 ..
439 } => textarea.move_cursor(CursorMove::WordBack),
440 Input {
441 key: Key::Char('^' | '0'),
442 ..
443 } => textarea.move_cursor(CursorMove::Head),
444 Input {
445 key: Key::Char('$'),
446 ..
447 } => textarea.move_cursor(CursorMove::End),
448 Input {
450 key: Key::Char('D'),
451 ..
452 } => {
453 textarea.delete_line_by_end();
454 return Transition::Mode(Mode::Normal);
455 }
456 Input {
457 key: Key::Char('C'),
458 ..
459 } => {
460 textarea.delete_line_by_end();
461 textarea.cancel_selection();
462 return Transition::Mode(Mode::Insert);
463 }
464 Input {
465 key: Key::Char('p'),
466 ..
467 } => {
468 textarea.paste();
469 return Transition::Mode(Mode::Normal);
470 }
471 Input {
472 key: Key::Char('u'),
473 ctrl: false,
474 ..
475 } => {
476 textarea.undo();
477 return Transition::Mode(Mode::Normal);
478 }
479 Input {
480 key: Key::Char('r'),
481 ctrl: true,
482 ..
483 } => {
484 textarea.redo();
485 return Transition::Mode(Mode::Normal);
486 }
487 Input {
488 key: Key::Char('x'),
489 ..
490 } => {
491 textarea.delete_next_char();
492 return Transition::Mode(Mode::Normal);
493 }
494 Input {
496 key: Key::Char('i'),
497 ..
498 } => {
499 textarea.cancel_selection();
500 return Transition::Mode(Mode::Insert);
501 }
502 Input {
503 key: Key::Char('a'),
504 ctrl: false,
505 ..
506 } => {
507 textarea.cancel_selection();
508 textarea.move_cursor(CursorMove::Forward);
509 return Transition::Mode(Mode::Insert);
510 }
511 Input {
512 key: Key::Char('A'),
513 ..
514 } => {
515 textarea.cancel_selection();
516 textarea.move_cursor(CursorMove::End);
517 return Transition::Mode(Mode::Insert);
518 }
519 Input {
520 key: Key::Char('o'),
521 ..
522 } => {
523 textarea.move_cursor(CursorMove::End);
524 textarea.insert_newline();
525 return Transition::Mode(Mode::Insert);
526 }
527 Input {
528 key: Key::Char('O'),
529 ..
530 } => {
531 textarea.move_cursor(CursorMove::Head);
532 textarea.insert_newline();
533 textarea.move_cursor(CursorMove::Up);
534 return Transition::Mode(Mode::Insert);
535 }
536 Input {
537 key: Key::Char('I'),
538 ..
539 } => {
540 textarea.cancel_selection();
541 textarea.move_cursor(CursorMove::Head);
542 return Transition::Mode(Mode::Insert);
543 }
544 Input {
546 key: Key::Char('e'),
547 ctrl: true,
548 ..
549 } => textarea.scroll((1, 0)),
550 Input {
551 key: Key::Char('y'),
552 ctrl: true,
553 ..
554 } => textarea.scroll((-1, 0)),
555 Input {
556 key: Key::Char('d'),
557 ctrl: true,
558 ..
559 } => textarea.scroll(tui_textarea::Scrolling::HalfPageDown),
560 Input {
561 key: Key::Char('u'),
562 ctrl: true,
563 ..
564 } => textarea.scroll(tui_textarea::Scrolling::HalfPageUp),
565 Input {
566 key: Key::Char('f'),
567 ctrl: true,
568 ..
569 } => textarea.scroll(tui_textarea::Scrolling::PageDown),
570 Input {
571 key: Key::Char('b'),
572 ctrl: true,
573 ..
574 } => textarea.scroll(tui_textarea::Scrolling::PageUp),
575 Input {
577 key: Key::Char('v'),
578 ctrl: false,
579 ..
580 } if self.mode == Mode::Normal => {
581 textarea.start_selection();
582 return Transition::Mode(Mode::Visual);
583 }
584 Input {
585 key: Key::Char('V'),
586 ctrl: false,
587 ..
588 } if self.mode == Mode::Normal => {
589 textarea.move_cursor(CursorMove::Head);
590 textarea.start_selection();
591 textarea.move_cursor(CursorMove::End);
592 return Transition::Mode(Mode::Visual);
593 }
594 Input { key: Key::Esc, .. }
595 | Input {
596 key: Key::Char('v'),
597 ctrl: false,
598 ..
599 } if self.mode == Mode::Visual => {
600 textarea.cancel_selection();
601 return Transition::Mode(Mode::Normal);
602 }
603 Input {
605 key: Key::Char('g'),
606 ctrl: false,
607 ..
608 } if matches!(
609 self.pending,
610 Input {
611 key: Key::Char('g'),
612 ctrl: false,
613 ..
614 }
615 ) =>
616 {
617 textarea.move_cursor(CursorMove::Top);
618 }
619 Input {
621 key: Key::Char('G'),
622 ctrl: false,
623 ..
624 } => textarea.move_cursor(CursorMove::Bottom),
625 Input {
627 key: Key::Char(c),
628 ctrl: false,
629 ..
630 } if self.mode == Mode::Operator(c) => {
631 textarea.move_cursor(CursorMove::Head);
632 textarea.start_selection();
633 let cursor = textarea.cursor();
634 textarea.move_cursor(CursorMove::Down);
635 if cursor == textarea.cursor() {
636 textarea.move_cursor(CursorMove::End);
637 }
638 }
639 Input {
641 key: Key::Char(op @ ('y' | 'd' | 'c')),
642 ctrl: false,
643 ..
644 } if self.mode == Mode::Normal => {
645 textarea.start_selection();
646 return Transition::Mode(Mode::Operator(op));
647 }
648 Input {
650 key: Key::Char('y'),
651 ctrl: false,
652 ..
653 } if self.mode == Mode::Visual => {
654 textarea.move_cursor(CursorMove::Forward);
655 textarea.copy();
656 return Transition::Mode(Mode::Normal);
657 }
658 Input {
659 key: Key::Char('d'),
660 ctrl: false,
661 ..
662 } if self.mode == Mode::Visual => {
663 textarea.move_cursor(CursorMove::Forward);
664 textarea.cut();
665 return Transition::Mode(Mode::Normal);
666 }
667 Input {
668 key: Key::Char('c'),
669 ctrl: false,
670 ..
671 } if self.mode == Mode::Visual => {
672 textarea.move_cursor(CursorMove::Forward);
673 textarea.cut();
674 return Transition::Mode(Mode::Insert);
675 }
676 Input { key: Key::Esc, .. } if self.mode == Mode::Normal => {
678 return Transition::Nop;
679 }
680 Input { key: Key::Esc, .. } => {
682 textarea.cancel_selection();
683 return Transition::Mode(Mode::Normal);
684 }
685 input => return Transition::Pending(input),
687 }
688
689 match self.mode {
691 Mode::Operator('y') => {
692 textarea.copy();
693 Transition::Mode(Mode::Normal)
694 }
695 Mode::Operator('d') => {
696 textarea.cut();
697 Transition::Mode(Mode::Normal)
698 }
699 Mode::Operator('c') => {
700 textarea.cut();
701 Transition::Mode(Mode::Insert)
702 }
703 _ => Transition::Nop,
704 }
705 }
706}
707
708#[allow(dead_code)]
723pub fn open_multiline_editor(title: &str) -> io::Result<Option<String>> {
724 open_editor_internal(title, &[], Mode::Insert)
725}
726
727pub fn open_multiline_editor_with_content(
733 title: &str,
734 initial_lines: &[String],
735) -> io::Result<Option<String>> {
736 open_editor_internal(title, initial_lines, Mode::Normal)
737}
738
739fn open_editor_internal(
741 title: &str,
742 initial_lines: &[String],
743 initial_mode: Mode,
744) -> io::Result<Option<String>> {
745 terminal::enable_raw_mode()?;
747 let mut stdout = io::stdout();
748 execute!(stdout, EnterAlternateScreen)?;
749
750 let backend = CrosstermBackend::new(stdout);
751 let mut terminal = Terminal::new(backend)?;
752
753 let mut textarea = if initial_lines.is_empty() {
755 TextArea::default()
756 } else {
757 TextArea::new(initial_lines.to_vec())
758 };
759 textarea.set_block(make_block(title, &initial_mode));
760 textarea.set_cursor_style(initial_mode.cursor_style());
761 textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED));
762 textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
763
764 if !initial_lines.is_empty() {
766 textarea.move_cursor(CursorMove::Bottom);
767 textarea.move_cursor(CursorMove::End);
768 }
769
770 let initial_snapshot: Vec<String> = textarea.lines().iter().map(|l| l.to_string()).collect();
772
773 let mut vim = Vim::new(initial_mode);
774 let result = run_editor_loop(
775 &mut terminal,
776 &mut textarea,
777 &mut vim,
778 title,
779 &initial_snapshot,
780 );
781
782 terminal::disable_raw_mode()?;
784 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
785
786 result
787}
788
789fn make_block<'a>(title: &str, mode: &Mode) -> Block<'a> {
791 Block::default()
792 .borders(Borders::ALL)
793 .title(format!(" {} ", title))
794 .border_style(Style::default().fg(mode.border_color()))
795}
796
797fn display_width_of(s: &str) -> usize {
799 UnicodeWidthStr::width(s)
800}
801
802fn count_wrapped_lines_unicode(s: &str, col_width: usize) -> usize {
804 if col_width == 0 || s.is_empty() {
805 return 1;
806 }
807 let mut lines = 1usize;
808 let mut current_width = 0usize;
809 for c in s.chars() {
810 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
811 if char_width == 0 {
812 continue;
813 }
814 if current_width + char_width > col_width {
815 lines += 1;
816 current_width = char_width;
817 } else {
818 current_width += char_width;
819 }
820 }
821 lines
822}
823
824fn cursor_wrapped_line_unicode(s: &str, cursor_pos: usize, col_width: usize) -> u16 {
826 if col_width == 0 {
827 return 0;
828 }
829 let mut line: u16 = 0;
830 let mut current_width: usize = 0;
831 for (i, c) in s.chars().enumerate() {
832 if i == cursor_pos {
833 return line;
834 }
835 let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
836 if char_width == 0 {
837 continue;
838 }
839 if current_width + char_width > col_width {
840 line += 1;
841 current_width = char_width;
842 } else {
843 current_width += char_width;
844 }
845 }
846 line
848}
849
850fn split_line_at_cursor(line: &str, cursor_col: usize) -> (String, String, String) {
852 let chars: Vec<char> = line.chars().collect();
853 let before: String = chars[..cursor_col.min(chars.len())].iter().collect();
854 let cursor_ch = if cursor_col < chars.len() {
855 chars[cursor_col].to_string()
856 } else {
857 " ".to_string()
858 };
859 let after: String = if cursor_col < chars.len() {
860 chars[cursor_col + 1..].iter().collect()
861 } else {
862 String::new()
863 };
864 (before, cursor_ch, after)
865}
866
867fn run_editor_loop(
869 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
870 textarea: &mut TextArea,
871 vim: &mut Vim,
872 title: &str,
873 initial_snapshot: &[String],
874) -> io::Result<Option<String>> {
875 let mut unsaved_warning = false;
877 let mut preview_scroll: u16 = 0;
879 let mut last_preview_row: usize = usize::MAX;
881
882 loop {
883 let mode = &vim.mode.clone();
884
885 let (cursor_row, cursor_col) = textarea.cursor();
887 let current_line_text: String = textarea
888 .lines()
889 .get(cursor_row)
890 .map(|l| l.to_string())
891 .unwrap_or_default();
892
893 if cursor_row != last_preview_row {
895 preview_scroll = 0;
896 last_preview_row = cursor_row;
897 }
898
899 let display_width: usize = display_width_of(¤t_line_text);
902
903 terminal.draw(|frame| {
905 let area_width = frame.area().width as usize;
906 let _area_height = frame.area().height;
907 let lnum_width = format!("{}", textarea.lines().len()).len() + 2 + 2;
909 let effective_width = area_width.saturating_sub(lnum_width);
910 let needs_preview = display_width > effective_width;
911
912 let constraints = if needs_preview {
913 vec![
914 Constraint::Percentage(55),
916 Constraint::Min(5),
917 Constraint::Length(2),
918 ]
919 } else {
920 vec![
921 Constraint::Min(3), Constraint::Length(2), ]
924 };
925
926 let chunks = Layout::default()
927 .direction(Direction::Vertical)
928 .constraints(constraints)
929 .split(frame.area());
930
931 frame.render_widget(&*textarea, chunks[0]);
933
934 if !vim.search.pattern.is_empty() {
936 apply_search_highlight(frame.buffer_mut(), chunks[0], &vim.search);
937 }
938
939 if needs_preview {
940 let preview_inner_h = chunks[1].height.saturating_sub(2) as u16;
942 let preview_inner_w = (chunks[1].width.saturating_sub(2)) as usize;
944
945 let total_wrapped =
947 count_wrapped_lines_unicode(¤t_line_text, preview_inner_w) as u16;
948 let max_scroll = total_wrapped.saturating_sub(preview_inner_h);
949
950 let cursor_wrap_line =
952 cursor_wrapped_line_unicode(¤t_line_text, cursor_col, preview_inner_w);
953 let auto_scroll = if cursor_wrap_line < preview_scroll {
954 cursor_wrap_line
955 } else if cursor_wrap_line >= preview_scroll + preview_inner_h {
956 cursor_wrap_line.saturating_sub(preview_inner_h - 1)
957 } else {
958 preview_scroll
959 };
960 let clamped_scroll = auto_scroll.min(max_scroll);
961
962 let scroll_hint = if total_wrapped > preview_inner_h {
963 format!(
964 " 📖 第 {} 行预览 [{}/{}行] Alt+↓/↑滚动 ",
965 cursor_row + 1,
966 clamped_scroll + preview_inner_h,
967 total_wrapped
968 )
969 } else {
970 format!(" 📖 第 {} 行预览 ", cursor_row + 1)
971 };
972
973 let preview_block = Block::default()
974 .borders(Borders::ALL)
975 .title(scroll_hint)
976 .title_style(
977 Style::default()
978 .fg(Color::Cyan)
979 .add_modifier(Modifier::BOLD),
980 )
981 .border_style(Style::default().fg(Color::Cyan));
982
983 let (before, cursor_ch, after) =
985 split_line_at_cursor(¤t_line_text, cursor_col);
986 let cursor_style = Style::default().fg(Color::Black).bg(Color::White);
987 let preview_text = vec![Line::from(vec![
988 Span::styled(before, Style::default().fg(Color::White)),
989 Span::styled(cursor_ch, cursor_style),
990 Span::styled(after, Style::default().fg(Color::White)),
991 ])];
992
993 let preview = Paragraph::new(preview_text)
994 .block(preview_block)
995 .wrap(Wrap { trim: false })
996 .scroll((clamped_scroll, 0));
997 frame.render_widget(preview, chunks[1]);
998
999 let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
1001 frame.render_widget(status_bar, chunks[2]);
1002 } else {
1003 let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
1005 frame.render_widget(status_bar, chunks[1]);
1006 }
1007 })?;
1008
1009 if let Event::Key(key_event) = event::read()? {
1011 if unsaved_warning {
1013 unsaved_warning = false;
1014 textarea.set_block(make_block(title, &vim.mode));
1015 }
1016
1017 let input = Input::from(key_event);
1018
1019 use crossterm::event::{KeyCode, KeyModifiers};
1021 if key_event.modifiers == KeyModifiers::ALT {
1022 match key_event.code {
1023 KeyCode::Down => {
1024 preview_scroll = preview_scroll.saturating_add(1);
1025 continue;
1026 }
1027 KeyCode::Up => {
1028 preview_scroll = preview_scroll.saturating_sub(1);
1029 continue;
1030 }
1031 _ => {}
1032 }
1033 }
1034
1035 match vim.transition(input, textarea) {
1036 Transition::Mode(new_mode) if vim.mode != new_mode => {
1037 textarea.set_block(make_block(title, &new_mode));
1038 textarea.set_cursor_style(new_mode.cursor_style());
1039 *vim = Vim::new(new_mode);
1040 }
1041 Transition::Nop | Transition::Mode(_) => {}
1042 Transition::Pending(input) => {
1043 let old = std::mem::replace(vim, Vim::new(Mode::Normal));
1044 *vim = old.with_pending(input);
1045 }
1046 Transition::Submit => {
1047 let lines = textarea.lines();
1048 let text = lines.join("\n");
1050 if text.is_empty() {
1051 return Ok(None);
1052 }
1053 return Ok(Some(text));
1054 }
1055 Transition::TryQuit => {
1056 let current_lines: Vec<String> =
1058 textarea.lines().iter().map(|l| l.to_string()).collect();
1059 if current_lines == initial_snapshot {
1060 return Ok(None);
1062 } else {
1063 unsaved_warning = true;
1065 textarea.set_block(
1066 Block::default()
1067 .borders(Borders::ALL)
1068 .title(" ⚠️ 有未保存的改动!使用 :q! 强制退出,或 :wq 保存退出 ")
1069 .border_style(Style::default().fg(Color::LightRed)),
1070 );
1071 *vim = Vim::new(Mode::Normal);
1072 }
1073 }
1074 Transition::Quit => {
1075 return Ok(None);
1076 }
1077 Transition::Search(pattern) => {
1078 let lines: Vec<String> =
1080 textarea.lines().iter().map(|l| l.to_string()).collect();
1081 let count = vim.search.search(&pattern, &lines);
1082
1083 if count > 0 {
1085 if let Some((line, col)) = vim.search.next_match() {
1086 jump_to_match(textarea, line, col);
1088 }
1089 }
1090
1091 *vim = Vim::new(Mode::Normal);
1092 vim.search = SearchState::new();
1093 vim.search.search(&pattern, &lines);
1094 }
1095 Transition::NextMatch => {
1096 if let Some((line, col)) = vim.search.next_match() {
1097 jump_to_match(textarea, line, col);
1098 }
1099 }
1100 Transition::PrevMatch => {
1101 if let Some((line, col)) = vim.search.prev_match() {
1102 jump_to_match(textarea, line, col);
1103 }
1104 }
1105 }
1106 }
1107 }
1108}
1109
1110fn build_status_bar(mode: &Mode, line_count: usize, search: &SearchState) -> Paragraph<'static> {
1112 let mut spans = vec![];
1113
1114 let (mode_text, mode_bg) = match mode {
1116 Mode::Insert => (" INSERT ", Color::LightBlue),
1117 Mode::Normal => (" NORMAL ", Color::DarkGray),
1118 Mode::Visual => (" VISUAL ", Color::LightYellow),
1119 Mode::Operator(c) => {
1120 let s: &'static str = match c {
1122 'y' => " YANK ",
1123 'd' => " DELETE ",
1124 'c' => " CHANGE ",
1125 _ => " OP ",
1126 };
1127 (s, Color::LightGreen)
1128 }
1129 Mode::Command(cmd) => {
1130 let cmd_display = format!(":{}", cmd);
1132 return Paragraph::new(Line::from(vec![
1133 Span::styled(
1134 " COMMAND ",
1135 Style::default().fg(Color::Black).bg(Color::LightMagenta),
1136 ),
1137 Span::raw(" "),
1138 Span::styled(
1139 cmd_display,
1140 Style::default()
1141 .fg(Color::White)
1142 .add_modifier(Modifier::BOLD),
1143 ),
1144 Span::styled("█", Style::default().fg(Color::White)),
1145 ]));
1146 }
1147 Mode::Search(pattern) => {
1148 let search_display = format!("/{}", pattern);
1150 return Paragraph::new(Line::from(vec![
1151 Span::styled(
1152 " SEARCH ",
1153 Style::default().fg(Color::Black).bg(Color::Magenta),
1154 ),
1155 Span::raw(" "),
1156 Span::styled(
1157 search_display,
1158 Style::default()
1159 .fg(Color::White)
1160 .add_modifier(Modifier::BOLD),
1161 ),
1162 Span::styled("█", Style::default().fg(Color::White)),
1163 ]));
1164 }
1165 };
1166
1167 spans.push(Span::styled(
1168 mode_text,
1169 Style::default().fg(Color::Black).bg(mode_bg),
1170 ));
1171 spans.push(Span::raw(" "));
1172
1173 match mode {
1175 Mode::Insert => {
1176 spans.push(Span::styled(
1177 " Ctrl+S ",
1178 Style::default().fg(Color::Black).bg(Color::Green),
1179 ));
1180 spans.push(Span::raw(" 提交 "));
1181 spans.push(Span::styled(
1182 " Ctrl+Q ",
1183 Style::default().fg(Color::Black).bg(Color::Red),
1184 ));
1185 spans.push(Span::raw(" 取消 "));
1186 spans.push(Span::styled(
1187 " Esc ",
1188 Style::default().fg(Color::Black).bg(Color::Yellow),
1189 ));
1190 spans.push(Span::raw(" Normal "));
1191 }
1192 Mode::Normal => {
1193 spans.push(Span::styled(
1194 " :wq ",
1195 Style::default().fg(Color::Black).bg(Color::Green),
1196 ));
1197 spans.push(Span::raw(" 提交 "));
1198 spans.push(Span::styled(
1199 " / ",
1200 Style::default().fg(Color::Black).bg(Color::Magenta),
1201 ));
1202 spans.push(Span::raw(" 搜索 "));
1203 spans.push(Span::styled(
1204 " n/N ",
1205 Style::default().fg(Color::Black).bg(Color::Cyan),
1206 ));
1207 spans.push(Span::raw(" 下/上 "));
1208 spans.push(Span::styled(
1209 " i ",
1210 Style::default().fg(Color::Black).bg(Color::Cyan),
1211 ));
1212 spans.push(Span::raw(" 编辑 "));
1213 }
1214 Mode::Visual => {
1215 spans.push(Span::styled(
1216 " y ",
1217 Style::default().fg(Color::Black).bg(Color::Green),
1218 ));
1219 spans.push(Span::raw(" 复制 "));
1220 spans.push(Span::styled(
1221 " d ",
1222 Style::default().fg(Color::Black).bg(Color::Red),
1223 ));
1224 spans.push(Span::raw(" 删除 "));
1225 spans.push(Span::styled(
1226 " Esc ",
1227 Style::default().fg(Color::Black).bg(Color::Yellow),
1228 ));
1229 spans.push(Span::raw(" 取消 "));
1230 }
1231 _ => {}
1232 }
1233
1234 spans.push(Span::styled(
1236 format!(" {} 行 ", line_count),
1237 Style::default().fg(Color::DarkGray),
1238 ));
1239
1240 if !search.pattern.is_empty() {
1242 let (current, total) = search.current_info();
1243 spans.push(Span::raw(" "));
1244 spans.push(Span::styled(
1245 format!(" [{}: {}/{}] ", search.pattern, current, total),
1246 Style::default().fg(Color::Magenta),
1247 ));
1248 }
1249
1250 Paragraph::new(Line::from(spans))
1251}