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};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
21enum Mode {
22 Normal,
23 Insert,
24 Visual,
25 Operator(char),
26 Command(String), Search(String), }
29
30impl fmt::Display for Mode {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 Self::Normal => write!(f, "NORMAL"),
34 Self::Insert => write!(f, "INSERT"),
35 Self::Visual => write!(f, "VISUAL"),
36 Self::Operator(c) => write!(f, "OPERATOR({})", c),
37 Self::Command(_) => write!(f, "COMMAND"),
38 Self::Search(_) => write!(f, "SEARCH"),
39 }
40 }
41}
42
43impl Mode {
44 fn cursor_style(&self) -> Style {
45 let color = match self {
46 Self::Normal => Color::Reset,
47 Self::Insert => Color::LightBlue,
48 Self::Visual => Color::LightYellow,
49 Self::Operator(_) => Color::LightGreen,
50 Self::Command(_) => Color::Reset,
51 Self::Search(_) => Color::Reset,
52 };
53 Style::default().fg(color).add_modifier(Modifier::REVERSED)
54 }
55
56 fn border_color(&self) -> Color {
57 match self {
58 Self::Normal => Color::DarkGray,
59 Self::Insert => Color::Cyan,
60 Self::Visual => Color::LightYellow,
61 Self::Operator(_) => Color::LightGreen,
62 Self::Command(_) => Color::DarkGray,
63 Self::Search(_) => Color::Magenta,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
72struct SearchMatch {
73 line: usize,
74 start: usize,
75 #[allow(dead_code)]
76 end: usize, }
78
79#[derive(Debug, Clone, Default)]
81struct SearchState {
82 pattern: String,
83 matches: Vec<SearchMatch>,
84 current_index: usize,
85}
86
87impl SearchState {
88 fn new() -> Self {
89 Self::default()
90 }
91
92 fn search(&mut self, pattern: &str, lines: &[String]) -> usize {
94 self.pattern = pattern.to_string();
95 self.matches.clear();
96 self.current_index = 0;
97
98 if pattern.is_empty() {
99 return 0;
100 }
101
102 for (line_idx, line) in lines.iter().enumerate() {
103 let mut start = 0;
104 while let Some(pos) = line[start..].find(pattern) {
105 let abs_start = start + pos;
106 self.matches.push(SearchMatch {
107 line: line_idx,
108 start: abs_start,
109 end: abs_start + pattern.len(),
110 });
111 start = abs_start + pattern.len();
112 if start >= line.len() {
113 break;
114 }
115 }
116 }
117
118 self.matches.len()
119 }
120
121 fn next_match(&mut self) -> Option<(usize, usize)> {
123 if self.matches.is_empty() {
124 return None;
125 }
126 let m = &self.matches[self.current_index];
127 let result = Some((m.line, m.start));
128 self.current_index = (self.current_index + 1) % self.matches.len();
129 result
130 }
131
132 fn prev_match(&mut self) -> Option<(usize, usize)> {
134 if self.matches.is_empty() {
135 return None;
136 }
137 if self.current_index == 0 {
138 self.current_index = self.matches.len() - 1;
139 } else {
140 self.current_index -= 1;
141 }
142 let m = &self.matches[self.current_index];
143 Some((m.line, m.start))
144 }
145
146 fn current_info(&self) -> (usize, usize) {
148 if self.matches.is_empty() {
149 (0, 0)
150 } else {
151 let display_idx = if self.current_index == 0 {
152 self.matches.len()
153 } else {
154 self.current_index
155 };
156 (display_idx, self.matches.len())
157 }
158 }
159}
160
161fn apply_search_highlight(buf: &mut ratatui::buffer::Buffer, area: Rect, search: &SearchState) {
165 if search.pattern.is_empty() || search.matches.is_empty() {
166 return;
167 }
168
169 let pattern = &search.pattern;
170
171 for row in area.top()..area.bottom() {
173 let content_start = area.left() + 3; let mut chars_with_pos: Vec<(char, u16)> = Vec::new();
175
176 for col in content_start..area.right() {
177 if let Some(cell) = buf.cell((col, row)) {
178 let symbol = cell.symbol();
179 for c in symbol.chars() {
180 chars_with_pos.push((c, col));
181 }
182 }
183 }
184
185 let pattern_chars: Vec<char> = pattern.chars().collect();
186 if pattern_chars.is_empty() {
187 continue;
188 }
189
190 let mut i = 0;
191 while i + pattern_chars.len() <= chars_with_pos.len() {
192 let is_match = pattern_chars.iter().enumerate().all(|(j, pc)| {
193 chars_with_pos
194 .get(i + j)
195 .map(|(c, _)| c == pc)
196 .unwrap_or(false)
197 });
198
199 if is_match {
200 let style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
202
203 for j in 0..pattern_chars.len() {
204 if let Some((_, col)) = chars_with_pos.get(i + j) {
205 buf[(*col, row)].set_style(style);
206 }
207 }
208
209 i += pattern_chars.len();
210 } else {
211 i += 1;
212 }
213 }
214 }
215}
216
217fn jump_to_match(textarea: &mut TextArea, line: usize, col: usize) {
219 textarea.move_cursor(CursorMove::Jump(
220 line.try_into().unwrap_or(0),
221 col.try_into().unwrap_or(0),
222 ));
223}
224
225enum Transition {
228 Nop,
229 Mode(Mode),
230 Pending(Input),
231 Submit, Quit, TryQuit, Search(String), NextMatch, PrevMatch, }
238
239struct Vim {
241 mode: Mode,
242 pending: Input,
243 search: SearchState,
244}
245
246impl Vim {
247 fn new(mode: Mode) -> Self {
248 Self {
249 mode,
250 pending: Input::default(),
251 search: SearchState::new(),
252 }
253 }
254
255 fn with_pending(self, pending: Input) -> Self {
256 Self {
257 mode: self.mode,
258 pending,
259 search: self.search,
260 }
261 }
262
263 fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
265 if input.key == Key::Null {
266 return Transition::Nop;
267 }
268
269 if input.ctrl && input.key == Key::Char('s') {
271 return Transition::Submit;
272 }
273
274 if input.ctrl && input.key == Key::Char('q') {
276 return Transition::Quit;
277 }
278
279 match &self.mode {
280 Mode::Command(cmd) => self.handle_command_mode(input, cmd),
281 Mode::Search(pattern) => self.handle_search_mode(input, pattern),
282 Mode::Insert => self.handle_insert_mode(input, textarea),
283 Mode::Normal | Mode::Visual | Mode::Operator(_) => {
284 self.handle_normal_visual_operator(input, textarea)
285 }
286 }
287 }
288
289 fn handle_insert_mode(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
291 match input {
292 Input { key: Key::Esc, .. }
293 | Input {
294 key: Key::Char('c'),
295 ctrl: true,
296 ..
297 } => Transition::Mode(Mode::Normal),
298 input => {
299 textarea.input(input);
300 Transition::Mode(Mode::Insert)
301 }
302 }
303 }
304
305 fn handle_command_mode(&self, input: Input, cmd: &str) -> Transition {
307 match input.key {
308 Key::Esc => Transition::Mode(Mode::Normal),
309 Key::Enter => {
310 let cmd = cmd.trim();
312 match cmd {
313 "wq" | "x" => Transition::Submit,
314 "w" => Transition::Submit,
315 "q" => Transition::TryQuit, "q!" => Transition::Quit, _ => Transition::Mode(Mode::Normal), }
319 }
320 Key::Backspace => {
321 if cmd.is_empty() {
322 Transition::Mode(Mode::Normal)
323 } else {
324 let mut new_cmd = cmd.to_string();
325 new_cmd.pop();
326 Transition::Mode(Mode::Command(new_cmd))
327 }
328 }
329 Key::Char(c) => {
330 let mut new_cmd = cmd.to_string();
331 new_cmd.push(c);
332 Transition::Mode(Mode::Command(new_cmd))
333 }
334 _ => Transition::Nop,
335 }
336 }
337
338 fn handle_search_mode(&self, input: Input, pattern: &str) -> Transition {
340 match input.key {
341 Key::Esc => Transition::Mode(Mode::Normal),
342 Key::Enter => {
343 Transition::Search(pattern.to_string())
345 }
346 Key::Backspace => {
347 if pattern.is_empty() {
348 Transition::Mode(Mode::Normal)
349 } else {
350 let mut new_pattern = pattern.to_string();
351 new_pattern.pop();
352 Transition::Mode(Mode::Search(new_pattern))
353 }
354 }
355 Key::Char(c) => {
356 let mut new_pattern = pattern.to_string();
357 new_pattern.push(c);
358 Transition::Mode(Mode::Search(new_pattern))
359 }
360 _ => Transition::Nop,
361 }
362 }
363
364 fn handle_normal_visual_operator(
366 &self,
367 input: Input,
368 textarea: &mut TextArea<'_>,
369 ) -> Transition {
370 match input {
371 Input {
373 key: Key::Char(':'),
374 ctrl: false,
375 ..
376 } if self.mode == Mode::Normal => {
377 return Transition::Mode(Mode::Command(String::new()));
378 }
379 Input {
381 key: Key::Char('/'),
382 ctrl: false,
383 ..
384 } if self.mode == Mode::Normal => {
385 return Transition::Mode(Mode::Search(String::new()));
386 }
387 Input {
389 key: Key::Char('n'),
390 ctrl: false,
391 ..
392 } if self.mode == Mode::Normal => {
393 return Transition::NextMatch;
394 }
395 Input {
397 key: Key::Char('N'),
398 ctrl: false,
399 ..
400 } if self.mode == Mode::Normal => {
401 return Transition::PrevMatch;
402 }
403 Input {
405 key: Key::Char('h'),
406 ..
407 } => textarea.move_cursor(CursorMove::Back),
408 Input {
409 key: Key::Char('j'),
410 ..
411 } => textarea.move_cursor(CursorMove::Down),
412 Input {
413 key: Key::Char('k'),
414 ..
415 } => textarea.move_cursor(CursorMove::Up),
416 Input {
417 key: Key::Char('l'),
418 ..
419 } => textarea.move_cursor(CursorMove::Forward),
420 Input {
421 key: Key::Char('w'),
422 ..
423 } => textarea.move_cursor(CursorMove::WordForward),
424 Input {
425 key: Key::Char('e'),
426 ctrl: false,
427 ..
428 } => {
429 textarea.move_cursor(CursorMove::WordEnd);
430 if matches!(self.mode, Mode::Operator(_)) {
431 textarea.move_cursor(CursorMove::Forward);
432 }
433 }
434 Input {
435 key: Key::Char('b'),
436 ctrl: false,
437 ..
438 } => textarea.move_cursor(CursorMove::WordBack),
439 Input {
440 key: Key::Char('^' | '0'),
441 ..
442 } => textarea.move_cursor(CursorMove::Head),
443 Input {
444 key: Key::Char('$'),
445 ..
446 } => textarea.move_cursor(CursorMove::End),
447 Input {
449 key: Key::Char('D'),
450 ..
451 } => {
452 textarea.delete_line_by_end();
453 return Transition::Mode(Mode::Normal);
454 }
455 Input {
456 key: Key::Char('C'),
457 ..
458 } => {
459 textarea.delete_line_by_end();
460 textarea.cancel_selection();
461 return Transition::Mode(Mode::Insert);
462 }
463 Input {
464 key: Key::Char('p'),
465 ..
466 } => {
467 textarea.paste();
468 return Transition::Mode(Mode::Normal);
469 }
470 Input {
471 key: Key::Char('u'),
472 ctrl: false,
473 ..
474 } => {
475 textarea.undo();
476 return Transition::Mode(Mode::Normal);
477 }
478 Input {
479 key: Key::Char('r'),
480 ctrl: true,
481 ..
482 } => {
483 textarea.redo();
484 return Transition::Mode(Mode::Normal);
485 }
486 Input {
487 key: Key::Char('x'),
488 ..
489 } => {
490 textarea.delete_next_char();
491 return Transition::Mode(Mode::Normal);
492 }
493 Input {
495 key: Key::Char('i'),
496 ..
497 } => {
498 textarea.cancel_selection();
499 return Transition::Mode(Mode::Insert);
500 }
501 Input {
502 key: Key::Char('a'),
503 ctrl: false,
504 ..
505 } => {
506 textarea.cancel_selection();
507 textarea.move_cursor(CursorMove::Forward);
508 return Transition::Mode(Mode::Insert);
509 }
510 Input {
511 key: Key::Char('A'),
512 ..
513 } => {
514 textarea.cancel_selection();
515 textarea.move_cursor(CursorMove::End);
516 return Transition::Mode(Mode::Insert);
517 }
518 Input {
519 key: Key::Char('o'),
520 ..
521 } => {
522 textarea.move_cursor(CursorMove::End);
523 textarea.insert_newline();
524 return Transition::Mode(Mode::Insert);
525 }
526 Input {
527 key: Key::Char('O'),
528 ..
529 } => {
530 textarea.move_cursor(CursorMove::Head);
531 textarea.insert_newline();
532 textarea.move_cursor(CursorMove::Up);
533 return Transition::Mode(Mode::Insert);
534 }
535 Input {
536 key: Key::Char('I'),
537 ..
538 } => {
539 textarea.cancel_selection();
540 textarea.move_cursor(CursorMove::Head);
541 return Transition::Mode(Mode::Insert);
542 }
543 Input {
545 key: Key::Char('e'),
546 ctrl: true,
547 ..
548 } => textarea.scroll((1, 0)),
549 Input {
550 key: Key::Char('y'),
551 ctrl: true,
552 ..
553 } => textarea.scroll((-1, 0)),
554 Input {
555 key: Key::Char('d'),
556 ctrl: true,
557 ..
558 } => textarea.scroll(tui_textarea::Scrolling::HalfPageDown),
559 Input {
560 key: Key::Char('u'),
561 ctrl: true,
562 ..
563 } => textarea.scroll(tui_textarea::Scrolling::HalfPageUp),
564 Input {
565 key: Key::Char('f'),
566 ctrl: true,
567 ..
568 } => textarea.scroll(tui_textarea::Scrolling::PageDown),
569 Input {
570 key: Key::Char('b'),
571 ctrl: true,
572 ..
573 } => textarea.scroll(tui_textarea::Scrolling::PageUp),
574 Input {
576 key: Key::Char('v'),
577 ctrl: false,
578 ..
579 } if self.mode == Mode::Normal => {
580 textarea.start_selection();
581 return Transition::Mode(Mode::Visual);
582 }
583 Input {
584 key: Key::Char('V'),
585 ctrl: false,
586 ..
587 } if self.mode == Mode::Normal => {
588 textarea.move_cursor(CursorMove::Head);
589 textarea.start_selection();
590 textarea.move_cursor(CursorMove::End);
591 return Transition::Mode(Mode::Visual);
592 }
593 Input { key: Key::Esc, .. }
594 | Input {
595 key: Key::Char('v'),
596 ctrl: false,
597 ..
598 } if self.mode == Mode::Visual => {
599 textarea.cancel_selection();
600 return Transition::Mode(Mode::Normal);
601 }
602 Input {
604 key: Key::Char('g'),
605 ctrl: false,
606 ..
607 } if matches!(
608 self.pending,
609 Input {
610 key: Key::Char('g'),
611 ctrl: false,
612 ..
613 }
614 ) =>
615 {
616 textarea.move_cursor(CursorMove::Top);
617 }
618 Input {
620 key: Key::Char('G'),
621 ctrl: false,
622 ..
623 } => textarea.move_cursor(CursorMove::Bottom),
624 Input {
626 key: Key::Char(c),
627 ctrl: false,
628 ..
629 } if self.mode == Mode::Operator(c) => {
630 textarea.move_cursor(CursorMove::Head);
631 textarea.start_selection();
632 let cursor = textarea.cursor();
633 textarea.move_cursor(CursorMove::Down);
634 if cursor == textarea.cursor() {
635 textarea.move_cursor(CursorMove::End);
636 }
637 }
638 Input {
640 key: Key::Char(op @ ('y' | 'd' | 'c')),
641 ctrl: false,
642 ..
643 } if self.mode == Mode::Normal => {
644 textarea.start_selection();
645 return Transition::Mode(Mode::Operator(op));
646 }
647 Input {
649 key: Key::Char('y'),
650 ctrl: false,
651 ..
652 } if self.mode == Mode::Visual => {
653 textarea.move_cursor(CursorMove::Forward);
654 textarea.copy();
655 return Transition::Mode(Mode::Normal);
656 }
657 Input {
658 key: Key::Char('d'),
659 ctrl: false,
660 ..
661 } if self.mode == Mode::Visual => {
662 textarea.move_cursor(CursorMove::Forward);
663 textarea.cut();
664 return Transition::Mode(Mode::Normal);
665 }
666 Input {
667 key: Key::Char('c'),
668 ctrl: false,
669 ..
670 } if self.mode == Mode::Visual => {
671 textarea.move_cursor(CursorMove::Forward);
672 textarea.cut();
673 return Transition::Mode(Mode::Insert);
674 }
675 Input { key: Key::Esc, .. } if self.mode == Mode::Normal => {
677 return Transition::Nop;
678 }
679 Input { key: Key::Esc, .. } => {
681 textarea.cancel_selection();
682 return Transition::Mode(Mode::Normal);
683 }
684 input => return Transition::Pending(input),
686 }
687
688 match self.mode {
690 Mode::Operator('y') => {
691 textarea.copy();
692 Transition::Mode(Mode::Normal)
693 }
694 Mode::Operator('d') => {
695 textarea.cut();
696 Transition::Mode(Mode::Normal)
697 }
698 Mode::Operator('c') => {
699 textarea.cut();
700 Transition::Mode(Mode::Insert)
701 }
702 _ => Transition::Nop,
703 }
704 }
705}
706
707#[allow(dead_code)]
722pub fn open_multiline_editor(title: &str) -> io::Result<Option<String>> {
723 open_editor_internal(title, &[], Mode::Insert)
724}
725
726pub fn open_multiline_editor_with_content(
732 title: &str,
733 initial_lines: &[String],
734) -> io::Result<Option<String>> {
735 open_editor_internal(title, initial_lines, Mode::Normal)
736}
737
738fn open_editor_internal(
740 title: &str,
741 initial_lines: &[String],
742 initial_mode: Mode,
743) -> io::Result<Option<String>> {
744 terminal::enable_raw_mode()?;
746 let mut stdout = io::stdout();
747 execute!(stdout, EnterAlternateScreen)?;
748
749 let backend = CrosstermBackend::new(stdout);
750 let mut terminal = Terminal::new(backend)?;
751
752 let mut textarea = if initial_lines.is_empty() {
754 TextArea::default()
755 } else {
756 TextArea::new(initial_lines.to_vec())
757 };
758 textarea.set_block(make_block(title, &initial_mode));
759 textarea.set_cursor_style(initial_mode.cursor_style());
760 textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED));
761 textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
762
763 if !initial_lines.is_empty() {
765 textarea.move_cursor(CursorMove::Bottom);
766 textarea.move_cursor(CursorMove::End);
767 }
768
769 let initial_snapshot: Vec<String> = textarea.lines().iter().map(|l| l.to_string()).collect();
771
772 let mut vim = Vim::new(initial_mode);
773 let result = run_editor_loop(
774 &mut terminal,
775 &mut textarea,
776 &mut vim,
777 title,
778 &initial_snapshot,
779 );
780
781 terminal::disable_raw_mode()?;
783 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
784
785 result
786}
787
788fn make_block<'a>(title: &str, mode: &Mode) -> Block<'a> {
790 Block::default()
791 .borders(Borders::ALL)
792 .title(format!(" {} ", title))
793 .border_style(Style::default().fg(mode.border_color()))
794}
795
796fn display_width_of(s: &str) -> usize {
798 s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
799}
800
801fn run_editor_loop(
803 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
804 textarea: &mut TextArea,
805 vim: &mut Vim,
806 title: &str,
807 initial_snapshot: &[String],
808) -> io::Result<Option<String>> {
809 let mut unsaved_warning = false;
811 loop {
812 let mode = &vim.mode.clone();
813
814 let cursor_row = textarea.cursor().0;
816 let current_line_text: String = textarea
817 .lines()
818 .get(cursor_row)
819 .map(|l| l.to_string())
820 .unwrap_or_default();
821 let display_width: usize = display_width_of(¤t_line_text);
823
824 terminal.draw(|frame| {
826 let area_width = frame.area().width as usize;
827 let lnum_width = format!("{}", textarea.lines().len()).len() + 2 + 2;
829 let effective_width = area_width.saturating_sub(lnum_width);
830 let needs_preview = display_width > effective_width;
831
832 let preview_inner_width = area_width.saturating_sub(2).max(1);
835 let preview_height = if needs_preview {
836 let wrapped_lines =
837 (display_width as f64 / preview_inner_width as f64).ceil() as u16;
838 wrapped_lines.saturating_add(2).clamp(3, 8)
840 } else {
841 0
842 };
843
844 let constraints = if needs_preview {
845 vec![
846 Constraint::Min(3), Constraint::Length(preview_height), Constraint::Length(2), ]
850 } else {
851 vec![
852 Constraint::Min(3), Constraint::Length(2), ]
855 };
856
857 let chunks = Layout::default()
858 .direction(Direction::Vertical)
859 .constraints(constraints)
860 .split(frame.area());
861
862 frame.render_widget(&*textarea, chunks[0]);
864
865 if !vim.search.pattern.is_empty() {
867 apply_search_highlight(frame.buffer_mut(), chunks[0], &vim.search);
868 }
869
870 if needs_preview {
871 let preview_block = Block::default()
873 .borders(Borders::ALL)
874 .title(format!(" 📖 第 {} 行预览 ", cursor_row + 1))
875 .title_style(
876 Style::default()
877 .fg(Color::Cyan)
878 .add_modifier(Modifier::BOLD),
879 )
880 .border_style(Style::default().fg(Color::Cyan));
881 let preview = Paragraph::new(current_line_text.clone())
882 .block(preview_block)
883 .style(Style::default().fg(Color::White))
884 .wrap(Wrap { trim: false });
885 frame.render_widget(preview, chunks[1]);
886
887 let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
889 frame.render_widget(status_bar, chunks[2]);
890 } else {
891 let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
893 frame.render_widget(status_bar, chunks[1]);
894 }
895 })?;
896
897 if let Event::Key(key_event) = event::read()? {
899 if unsaved_warning {
901 unsaved_warning = false;
902 textarea.set_block(make_block(title, &vim.mode));
903 }
904
905 let input = Input::from(key_event);
906 match vim.transition(input, textarea) {
907 Transition::Mode(new_mode) if vim.mode != new_mode => {
908 textarea.set_block(make_block(title, &new_mode));
909 textarea.set_cursor_style(new_mode.cursor_style());
910 *vim = Vim::new(new_mode);
911 }
912 Transition::Nop | Transition::Mode(_) => {}
913 Transition::Pending(input) => {
914 let old = std::mem::replace(vim, Vim::new(Mode::Normal));
915 *vim = old.with_pending(input);
916 }
917 Transition::Submit => {
918 let lines = textarea.lines();
919 let text = lines.join("\n");
921 if text.is_empty() {
922 return Ok(None);
923 }
924 return Ok(Some(text));
925 }
926 Transition::TryQuit => {
927 let current_lines: Vec<String> =
929 textarea.lines().iter().map(|l| l.to_string()).collect();
930 if current_lines == initial_snapshot {
931 return Ok(None);
933 } else {
934 unsaved_warning = true;
936 textarea.set_block(
937 Block::default()
938 .borders(Borders::ALL)
939 .title(" ⚠️ 有未保存的改动!使用 :q! 强制退出,或 :wq 保存退出 ")
940 .border_style(Style::default().fg(Color::LightRed)),
941 );
942 *vim = Vim::new(Mode::Normal);
943 }
944 }
945 Transition::Quit => {
946 return Ok(None);
947 }
948 Transition::Search(pattern) => {
949 let lines: Vec<String> =
951 textarea.lines().iter().map(|l| l.to_string()).collect();
952 let count = vim.search.search(&pattern, &lines);
953
954 if count > 0 {
956 if let Some((line, col)) = vim.search.next_match() {
957 jump_to_match(textarea, line, col);
959 }
960 }
961
962 *vim = Vim::new(Mode::Normal);
963 vim.search = SearchState::new();
964 vim.search.search(&pattern, &lines);
965 }
966 Transition::NextMatch => {
967 if let Some((line, col)) = vim.search.next_match() {
968 jump_to_match(textarea, line, col);
969 }
970 }
971 Transition::PrevMatch => {
972 if let Some((line, col)) = vim.search.prev_match() {
973 jump_to_match(textarea, line, col);
974 }
975 }
976 }
977 }
978 }
979}
980
981fn build_status_bar(mode: &Mode, line_count: usize, search: &SearchState) -> Paragraph<'static> {
983 let mut spans = vec![];
984
985 let (mode_text, mode_bg) = match mode {
987 Mode::Insert => (" INSERT ", Color::LightBlue),
988 Mode::Normal => (" NORMAL ", Color::DarkGray),
989 Mode::Visual => (" VISUAL ", Color::LightYellow),
990 Mode::Operator(c) => {
991 let s: &'static str = match c {
993 'y' => " YANK ",
994 'd' => " DELETE ",
995 'c' => " CHANGE ",
996 _ => " OP ",
997 };
998 (s, Color::LightGreen)
999 }
1000 Mode::Command(cmd) => {
1001 let cmd_display = format!(":{}", cmd);
1003 return Paragraph::new(Line::from(vec![
1004 Span::styled(
1005 " COMMAND ",
1006 Style::default().fg(Color::Black).bg(Color::LightMagenta),
1007 ),
1008 Span::raw(" "),
1009 Span::styled(
1010 cmd_display,
1011 Style::default()
1012 .fg(Color::White)
1013 .add_modifier(Modifier::BOLD),
1014 ),
1015 Span::styled("█", Style::default().fg(Color::White)),
1016 ]));
1017 }
1018 Mode::Search(pattern) => {
1019 let search_display = format!("/{}", pattern);
1021 return Paragraph::new(Line::from(vec![
1022 Span::styled(
1023 " SEARCH ",
1024 Style::default().fg(Color::Black).bg(Color::Magenta),
1025 ),
1026 Span::raw(" "),
1027 Span::styled(
1028 search_display,
1029 Style::default()
1030 .fg(Color::White)
1031 .add_modifier(Modifier::BOLD),
1032 ),
1033 Span::styled("█", Style::default().fg(Color::White)),
1034 ]));
1035 }
1036 };
1037
1038 spans.push(Span::styled(
1039 mode_text,
1040 Style::default().fg(Color::Black).bg(mode_bg),
1041 ));
1042 spans.push(Span::raw(" "));
1043
1044 match mode {
1046 Mode::Insert => {
1047 spans.push(Span::styled(
1048 " Ctrl+S ",
1049 Style::default().fg(Color::Black).bg(Color::Green),
1050 ));
1051 spans.push(Span::raw(" 提交 "));
1052 spans.push(Span::styled(
1053 " Ctrl+Q ",
1054 Style::default().fg(Color::Black).bg(Color::Red),
1055 ));
1056 spans.push(Span::raw(" 取消 "));
1057 spans.push(Span::styled(
1058 " Esc ",
1059 Style::default().fg(Color::Black).bg(Color::Yellow),
1060 ));
1061 spans.push(Span::raw(" Normal "));
1062 }
1063 Mode::Normal => {
1064 spans.push(Span::styled(
1065 " :wq ",
1066 Style::default().fg(Color::Black).bg(Color::Green),
1067 ));
1068 spans.push(Span::raw(" 提交 "));
1069 spans.push(Span::styled(
1070 " / ",
1071 Style::default().fg(Color::Black).bg(Color::Magenta),
1072 ));
1073 spans.push(Span::raw(" 搜索 "));
1074 spans.push(Span::styled(
1075 " n/N ",
1076 Style::default().fg(Color::Black).bg(Color::Cyan),
1077 ));
1078 spans.push(Span::raw(" 下/上 "));
1079 spans.push(Span::styled(
1080 " i ",
1081 Style::default().fg(Color::Black).bg(Color::Cyan),
1082 ));
1083 spans.push(Span::raw(" 编辑 "));
1084 }
1085 Mode::Visual => {
1086 spans.push(Span::styled(
1087 " y ",
1088 Style::default().fg(Color::Black).bg(Color::Green),
1089 ));
1090 spans.push(Span::raw(" 复制 "));
1091 spans.push(Span::styled(
1092 " d ",
1093 Style::default().fg(Color::Black).bg(Color::Red),
1094 ));
1095 spans.push(Span::raw(" 删除 "));
1096 spans.push(Span::styled(
1097 " Esc ",
1098 Style::default().fg(Color::Black).bg(Color::Yellow),
1099 ));
1100 spans.push(Span::raw(" 取消 "));
1101 }
1102 _ => {}
1103 }
1104
1105 spans.push(Span::styled(
1107 format!(" {} 行 ", line_count),
1108 Style::default().fg(Color::DarkGray),
1109 ));
1110
1111 if !search.pattern.is_empty() {
1113 let (current, total) = search.current_info();
1114 spans.push(Span::raw(" "));
1115 spans.push(Span::styled(
1116 format!(" [{}: {}/{}] ", search.pattern, current, total),
1117 Style::default().fg(Color::Magenta),
1118 ));
1119 }
1120
1121 Paragraph::new(Line::from(spans))
1122}