Skip to main content

j_cli/tui/
editor.rs

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// ========== Vim 模式定义 ==========
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22enum Mode {
23    Normal,
24    Insert,
25    Visual,
26    Operator(char),
27    Command(String), // 命令行模式,存储输入的命令字符串
28    Search(String),  // 搜索模式,存储输入的搜索词
29}
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// ========== 搜索状态 ==========
70
71/// 搜索匹配结果
72#[derive(Debug, Clone)]
73struct SearchMatch {
74    line: usize,
75    start: usize,
76    #[allow(dead_code)]
77    end: usize, // 保留用于将来可能的高亮增强
78}
79
80/// 搜索状态管理
81#[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    /// 执行搜索,返回匹配数量
94    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    /// 跳转到下一个匹配,返回 (line, column)
123    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    /// 跳转到上一个匹配,返回 (line, column)
134    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    /// 获取当前匹配信息(用于状态栏显示)
148    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
162// ========== 搜索高亮函数 ==========
163
164/// 应用搜索高亮到 buffer(直接修改 buffer 样式)
165fn 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    // 遍历 buffer 查找 pattern
173    for row in area.top()..area.bottom() {
174        let content_start = area.left() + 3; // 跳过行号
175        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                // 匹配文字用红色显示
202                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
218/// 跳转到指定行和列
219fn 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
226// ========== 编辑器状态转换 ==========
227
228enum Transition {
229    Nop,
230    Mode(Mode),
231    Pending(Input),
232    Submit,         // 提交内容
233    Quit,           // 强制取消退出(:q! / Ctrl+Q)
234    TryQuit,        // 尝试退出,若有改动则拒绝(:q)
235    Search(String), // 执行搜索
236    NextMatch,      // 跳转到下一个匹配
237    PrevMatch,      // 跳转到上一个匹配
238}
239
240/// Vim 状态机
241struct 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    /// 处理 Normal/Visual/Operator 模式的按键
265    fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition {
266        if input.key == Key::Null {
267            return Transition::Nop;
268        }
269
270        // 任何模式下 Ctrl+S → 提交
271        if input.ctrl && input.key == Key::Char('s') {
272            return Transition::Submit;
273        }
274
275        // 任何模式下 Ctrl+Q → 强制取消退出
276        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    /// Insert 模式:Esc 回到 Normal,其他交给 textarea 默认处理
291    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    /// Command 模式:处理 :wq, :w, :q, :q! 等命令
307    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                // 执行命令
312                let cmd = cmd.trim();
313                match cmd {
314                    "wq" | "x" => Transition::Submit,
315                    "w" => Transition::Submit,
316                    "q" => Transition::TryQuit, // 有改动时拒绝退出
317                    "q!" => Transition::Quit,   // 强制退出
318                    _ => Transition::Mode(Mode::Normal), // 未知命令,回到 Normal
319                }
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    /// Search 模式:处理 /pattern 输入
340    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                // 执行搜索
345                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    /// Normal / Visual / Operator 模式的 vim 按键处理
366    fn handle_normal_visual_operator(
367        &self,
368        input: Input,
369        textarea: &mut TextArea<'_>,
370    ) -> Transition {
371        match input {
372            // : 进入命令模式
373            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            // / 进入搜索模式
381            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            // n 跳转到下一个匹配
389            Input {
390                key: Key::Char('n'),
391                ctrl: false,
392                ..
393            } if self.mode == Mode::Normal => {
394                return Transition::NextMatch;
395            }
396            // N 跳转到上一个匹配
397            Input {
398                key: Key::Char('N'),
399                ctrl: false,
400                ..
401            } if self.mode == Mode::Normal => {
402                return Transition::PrevMatch;
403            }
404            // 移动
405            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            // 删除 / 修改
449            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            // 进入 Insert 模式
495            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            // 滚动
545            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            // Visual 模式
576            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            // gg → 跳到开头
604            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            // G → 跳到结尾
620            Input {
621                key: Key::Char('G'),
622                ctrl: false,
623                ..
624            } => textarea.move_cursor(CursorMove::Bottom),
625            // Operator 重复(yy, dd, cc)
626            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            // 进入 Operator 模式(y/d/c)
640            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            // Visual 模式下的 y/d/c
649            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            // Esc 在 Normal 模式下不退出(vim 标准行为)
677            Input { key: Key::Esc, .. } if self.mode == Mode::Normal => {
678                return Transition::Nop;
679            }
680            // Esc 在 Operator 模式取消
681            Input { key: Key::Esc, .. } => {
682                textarea.cancel_selection();
683                return Transition::Mode(Mode::Normal);
684            }
685            // 其他未匹配的按键 → pending(用于 gg 等序列)
686            input => return Transition::Pending(input),
687        }
688
689        // 处理 pending operator
690        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// ========== 公共 API ==========
709
710/// 打开全屏多行编辑器(vim 模式),返回用户输入的文本内容
711///
712/// 操作方式:
713/// - 默认进入 INSERT 模式,可直接输入
714/// - Esc: 回到 NORMAL 模式
715/// - `:wq` / `:w` / `:x`: 提交内容
716/// - `:q` / `:q!`: 取消退出
717/// - `/pattern`: 搜索 pattern
718/// - `n` / `N`: 跳转到下一个/上一个匹配
719/// - Ctrl+S: 任何模式下快速提交
720///
721/// 返回 Some(text) 表示提交,None 表示取消
722#[allow(dead_code)]
723pub fn open_multiline_editor(title: &str) -> io::Result<Option<String>> {
724    open_editor_internal(title, &[], Mode::Insert)
725}
726
727/// 打开全屏多行编辑器,带有预填充内容,默认 NORMAL 模式
728///
729/// - `initial_lines`: 预填充到编辑区的行(如历史日报 + 日期前缀)
730///
731/// 返回 Some(text) 表示提交,None 表示取消
732pub 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
739/// 内部统一入口:初始化终端 + 编辑区 + 主循环
740fn open_editor_internal(
741    title: &str,
742    initial_lines: &[String],
743    initial_mode: Mode,
744) -> io::Result<Option<String>> {
745    // 进入终端原始模式
746    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    // 初始化文本编辑区
754    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    // 如果有预填内容,光标跳到最后一行末尾
765    if !initial_lines.is_empty() {
766        textarea.move_cursor(CursorMove::Bottom);
767        textarea.move_cursor(CursorMove::End);
768    }
769
770    // 记录初始内容的快照,用于判断是否有实际改动
771    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    // 恢复终端状态
783    terminal::disable_raw_mode()?;
784    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
785
786    result
787}
788
789/// 构造编辑区的 Block(根据模式变色)
790fn 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
797/// 计算字符串的显示宽度(使用 unicode_width,与 ratatui 内部一致)
798fn display_width_of(s: &str) -> usize {
799    UnicodeWidthStr::width(s)
800}
801
802/// 精确计算字符串在给定列宽下 wrap 后的行数(用于预览区滚动进度显示)
803fn 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
824/// 计算光标在指定列宽下 wrap 后所在的行号(0-based,基于 unicode_width)
825fn 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    // cursor_pos == chars.len() (cursor at end)
847    line
848}
849
850/// 将字符串按光标位置(字符索引)拆分为三部分:光标前、光标字符、光标后
851fn 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
867/// 编辑器主循环
868fn 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    // 是否显示 "有未保存改动" 的提示(下次按键后清除)
876    let mut unsaved_warning = false;
877    // 预览区滚动偏移(向下滚动的行数)
878    let mut preview_scroll: u16 = 0;
879    // 上一次预览的行索引,切换行时重置滚动
880    let mut last_preview_row: usize = usize::MAX;
881
882    loop {
883        let mode = &vim.mode.clone();
884
885        // 获取当前光标所在行的内容和列位置(用于预览区)
886        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        // 切换到新行时重置预览滚动
894        if cursor_row != last_preview_row {
895            preview_scroll = 0;
896            last_preview_row = cursor_row;
897        }
898
899        // 判断当前行是否超过终端宽度,需要显示预览区
900        // 用终端宽度粗略判断(不减行号宽度,保守估计)
901        let display_width: usize = display_width_of(&current_line_text);
902
903        // 绘制界面
904        terminal.draw(|frame| {
905            let area_width = frame.area().width as usize;
906            let _area_height = frame.area().height;
907            // 预留行号宽度(行号位数 + 2 个边距)+ 边框宽度 2
908            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                    // 编辑区占 55%,预览区占 40%,状态栏固定 2 行
915                    Constraint::Percentage(55),
916                    Constraint::Min(5),
917                    Constraint::Length(2),
918                ]
919            } else {
920                vec![
921                    Constraint::Min(3),    // 编辑区
922                    Constraint::Length(2), // 状态栏
923                ]
924            };
925
926            let chunks = Layout::default()
927                .direction(Direction::Vertical)
928                .constraints(constraints)
929                .split(frame.area());
930
931            // 渲染编辑区
932            frame.render_widget(&*textarea, chunks[0]);
933
934            // 在 TextArea 渲染后,应用搜索高亮
935            if !vim.search.pattern.is_empty() {
936                apply_search_highlight(frame.buffer_mut(), chunks[0], &vim.search);
937            }
938
939            if needs_preview {
940                // 预览区内部可用高度(去掉上下边框各 1 行)
941                let preview_inner_h = chunks[1].height.saturating_sub(2) as u16;
942                // 预览区内部可用宽度(去掉左右边框各 1 列)
943                let preview_inner_w = (chunks[1].width.saturating_sub(2)) as usize;
944
945                // 计算总 wrap 行数(用于显示滚动进度)
946                let total_wrapped =
947                    count_wrapped_lines_unicode(&current_line_text, preview_inner_w) as u16;
948                let max_scroll = total_wrapped.saturating_sub(preview_inner_h);
949
950                // 自动滚动到光标所在的 wrap 行可见
951                let cursor_wrap_line =
952                    cursor_wrapped_line_unicode(&current_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                // 构建带光标高亮的预览文本
984                let (before, cursor_ch, after) =
985                    split_line_at_cursor(&current_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                // 渲染状态栏
1000                let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
1001                frame.render_widget(status_bar, chunks[2]);
1002            } else {
1003                // 渲染状态栏
1004                let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
1005                frame.render_widget(status_bar, chunks[1]);
1006            }
1007        })?;
1008
1009        // 处理输入事件
1010        if let Event::Key(key_event) = event::read()? {
1011            // 清除上次的警告提示
1012            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            // Alt+↓ / Alt+↑:预览区滚动(不影响编辑区)
1020            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                    // 不使用 trim(),保留每行的原始缩进
1049                    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                    // :q — 检查是否有实际改动
1057                    let current_lines: Vec<String> =
1058                        textarea.lines().iter().map(|l| l.to_string()).collect();
1059                    if current_lines == initial_snapshot {
1060                        // 无改动,直接退出
1061                        return Ok(None);
1062                    } else {
1063                        // 有改动,拒绝退出并提示
1064                        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                    // 执行搜索
1079                    let lines: Vec<String> =
1080                        textarea.lines().iter().map(|l| l.to_string()).collect();
1081                    let count = vim.search.search(&pattern, &lines);
1082
1083                    // 跳转到第一个匹配
1084                    if count > 0 {
1085                        if let Some((line, col)) = vim.search.next_match() {
1086                            // 移动光标到匹配位置
1087                            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
1110/// 构建底部状态栏
1111fn build_status_bar(mode: &Mode, line_count: usize, search: &SearchState) -> Paragraph<'static> {
1112    let mut spans = vec![];
1113
1114    // 模式标签
1115    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            // 这里需要 'static,用 leaked string
1121            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            // 命令模式特殊处理:直接显示命令行
1131            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            // 搜索模式特殊处理:直接显示搜索词
1149            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    // 快捷键提示
1174    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    // 行数
1235    spans.push(Span::styled(
1236        format!(" {} 行 ", line_count),
1237        Style::default().fg(Color::DarkGray),
1238    ));
1239
1240    // 搜索匹配信息
1241    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}