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/// 编辑器主循环
825fn run_editor_loop(
826    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
827    textarea: &mut TextArea,
828    vim: &mut Vim,
829    title: &str,
830    initial_snapshot: &[String],
831) -> io::Result<Option<String>> {
832    // 是否显示 "有未保存改动" 的提示(下次按键后清除)
833    let mut unsaved_warning = false;
834    // 预览区滚动偏移(向下滚动的行数)
835    let mut preview_scroll: u16 = 0;
836    // 上一次预览的行索引,切换行时重置滚动
837    let mut last_preview_row: usize = usize::MAX;
838
839    loop {
840        let mode = &vim.mode.clone();
841
842        // 获取当前光标所在行的内容(用于预览区)
843        let cursor_row = textarea.cursor().0;
844        let current_line_text: String = textarea
845            .lines()
846            .get(cursor_row)
847            .map(|l| l.to_string())
848            .unwrap_or_default();
849
850        // 切换到新行时重置预览滚动
851        if cursor_row != last_preview_row {
852            preview_scroll = 0;
853            last_preview_row = cursor_row;
854        }
855
856        // 判断当前行是否超过终端宽度,需要显示预览区
857        // 用终端宽度粗略判断(不减行号宽度,保守估计)
858        let display_width: usize = display_width_of(&current_line_text);
859
860        // 绘制界面
861        terminal.draw(|frame| {
862            let area_width = frame.area().width as usize;
863            let _area_height = frame.area().height;
864            // 预留行号宽度(行号位数 + 2 个边距)+ 边框宽度 2
865            let lnum_width = format!("{}", textarea.lines().len()).len() + 2 + 2;
866            let effective_width = area_width.saturating_sub(lnum_width);
867            let needs_preview = display_width > effective_width;
868
869            let constraints = if needs_preview {
870                vec![
871                    // 编辑区占 55%,预览区占 40%,状态栏固定 2 行
872                    Constraint::Percentage(55),
873                    Constraint::Min(5),
874                    Constraint::Length(2),
875                ]
876            } else {
877                vec![
878                    Constraint::Min(3),    // 编辑区
879                    Constraint::Length(2), // 状态栏
880                ]
881            };
882
883            let chunks = Layout::default()
884                .direction(Direction::Vertical)
885                .constraints(constraints)
886                .split(frame.area());
887
888            // 渲染编辑区
889            frame.render_widget(&*textarea, chunks[0]);
890
891            // 在 TextArea 渲染后,应用搜索高亮
892            if !vim.search.pattern.is_empty() {
893                apply_search_highlight(frame.buffer_mut(), chunks[0], &vim.search);
894            }
895
896            if needs_preview {
897                // 预览区内部可用高度(去掉上下边框各 1 行)
898                let preview_inner_h = chunks[1].height.saturating_sub(2) as u16;
899                // 预览区内部可用宽度(去掉左右边框各 1 列)
900                let preview_inner_w = (chunks[1].width.saturating_sub(2)) as usize;
901
902                // 计算总 wrap 行数(用于显示滚动进度)
903                let total_wrapped =
904                    count_wrapped_lines_unicode(&current_line_text, preview_inner_w) as u16;
905                let max_scroll = total_wrapped.saturating_sub(preview_inner_h);
906                // 钳制滚动偏移(防止越界)
907                let clamped_scroll = preview_scroll.min(max_scroll);
908
909                let scroll_hint = if total_wrapped > preview_inner_h {
910                    format!(
911                        " 📖 第 {} 行预览  [{}/{}行]  Alt+↓/↑滚动 ",
912                        cursor_row + 1,
913                        clamped_scroll + preview_inner_h,
914                        total_wrapped
915                    )
916                } else {
917                    format!(" 📖 第 {} 行预览 ", cursor_row + 1)
918                };
919
920                let preview_block = Block::default()
921                    .borders(Borders::ALL)
922                    .title(scroll_hint)
923                    .title_style(
924                        Style::default()
925                            .fg(Color::Cyan)
926                            .add_modifier(Modifier::BOLD),
927                    )
928                    .border_style(Style::default().fg(Color::Cyan));
929                let preview = Paragraph::new(current_line_text.clone())
930                    .block(preview_block)
931                    .style(Style::default().fg(Color::White))
932                    .wrap(Wrap { trim: false })
933                    .scroll((clamped_scroll, 0));
934                frame.render_widget(preview, chunks[1]);
935
936                // 渲染状态栏
937                let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
938                frame.render_widget(status_bar, chunks[2]);
939            } else {
940                // 渲染状态栏
941                let status_bar = build_status_bar(mode, textarea.lines().len(), &vim.search);
942                frame.render_widget(status_bar, chunks[1]);
943            }
944        })?;
945
946        // 处理输入事件
947        if let Event::Key(key_event) = event::read()? {
948            // 清除上次的警告提示
949            if unsaved_warning {
950                unsaved_warning = false;
951                textarea.set_block(make_block(title, &vim.mode));
952            }
953
954            let input = Input::from(key_event);
955
956            // Alt+↓ / Alt+↑:预览区滚动(不影响编辑区)
957            use crossterm::event::{KeyCode, KeyModifiers};
958            if key_event.modifiers == KeyModifiers::ALT {
959                match key_event.code {
960                    KeyCode::Down => {
961                        preview_scroll = preview_scroll.saturating_add(1);
962                        continue;
963                    }
964                    KeyCode::Up => {
965                        preview_scroll = preview_scroll.saturating_sub(1);
966                        continue;
967                    }
968                    _ => {}
969                }
970            }
971
972            match vim.transition(input, textarea) {
973                Transition::Mode(new_mode) if vim.mode != new_mode => {
974                    textarea.set_block(make_block(title, &new_mode));
975                    textarea.set_cursor_style(new_mode.cursor_style());
976                    *vim = Vim::new(new_mode);
977                }
978                Transition::Nop | Transition::Mode(_) => {}
979                Transition::Pending(input) => {
980                    let old = std::mem::replace(vim, Vim::new(Mode::Normal));
981                    *vim = old.with_pending(input);
982                }
983                Transition::Submit => {
984                    let lines = textarea.lines();
985                    // 不使用 trim(),保留每行的原始缩进
986                    let text = lines.join("\n");
987                    if text.is_empty() {
988                        return Ok(None);
989                    }
990                    return Ok(Some(text));
991                }
992                Transition::TryQuit => {
993                    // :q — 检查是否有实际改动
994                    let current_lines: Vec<String> =
995                        textarea.lines().iter().map(|l| l.to_string()).collect();
996                    if current_lines == initial_snapshot {
997                        // 无改动,直接退出
998                        return Ok(None);
999                    } else {
1000                        // 有改动,拒绝退出并提示
1001                        unsaved_warning = true;
1002                        textarea.set_block(
1003                            Block::default()
1004                                .borders(Borders::ALL)
1005                                .title(" ⚠️ 有未保存的改动!使用 :q! 强制退出,或 :wq 保存退出 ")
1006                                .border_style(Style::default().fg(Color::LightRed)),
1007                        );
1008                        *vim = Vim::new(Mode::Normal);
1009                    }
1010                }
1011                Transition::Quit => {
1012                    return Ok(None);
1013                }
1014                Transition::Search(pattern) => {
1015                    // 执行搜索
1016                    let lines: Vec<String> =
1017                        textarea.lines().iter().map(|l| l.to_string()).collect();
1018                    let count = vim.search.search(&pattern, &lines);
1019
1020                    // 跳转到第一个匹配
1021                    if count > 0 {
1022                        if let Some((line, col)) = vim.search.next_match() {
1023                            // 移动光标到匹配位置
1024                            jump_to_match(textarea, line, col);
1025                        }
1026                    }
1027
1028                    *vim = Vim::new(Mode::Normal);
1029                    vim.search = SearchState::new();
1030                    vim.search.search(&pattern, &lines);
1031                }
1032                Transition::NextMatch => {
1033                    if let Some((line, col)) = vim.search.next_match() {
1034                        jump_to_match(textarea, line, col);
1035                    }
1036                }
1037                Transition::PrevMatch => {
1038                    if let Some((line, col)) = vim.search.prev_match() {
1039                        jump_to_match(textarea, line, col);
1040                    }
1041                }
1042            }
1043        }
1044    }
1045}
1046
1047/// 构建底部状态栏
1048fn build_status_bar(mode: &Mode, line_count: usize, search: &SearchState) -> Paragraph<'static> {
1049    let mut spans = vec![];
1050
1051    // 模式标签
1052    let (mode_text, mode_bg) = match mode {
1053        Mode::Insert => (" INSERT ", Color::LightBlue),
1054        Mode::Normal => (" NORMAL ", Color::DarkGray),
1055        Mode::Visual => (" VISUAL ", Color::LightYellow),
1056        Mode::Operator(c) => {
1057            // 这里需要 'static,用 leaked string
1058            let s: &'static str = match c {
1059                'y' => " YANK ",
1060                'd' => " DELETE ",
1061                'c' => " CHANGE ",
1062                _ => " OP ",
1063            };
1064            (s, Color::LightGreen)
1065        }
1066        Mode::Command(cmd) => {
1067            // 命令模式特殊处理:直接显示命令行
1068            let cmd_display = format!(":{}", cmd);
1069            return Paragraph::new(Line::from(vec![
1070                Span::styled(
1071                    " COMMAND ",
1072                    Style::default().fg(Color::Black).bg(Color::LightMagenta),
1073                ),
1074                Span::raw(" "),
1075                Span::styled(
1076                    cmd_display,
1077                    Style::default()
1078                        .fg(Color::White)
1079                        .add_modifier(Modifier::BOLD),
1080                ),
1081                Span::styled("█", Style::default().fg(Color::White)),
1082            ]));
1083        }
1084        Mode::Search(pattern) => {
1085            // 搜索模式特殊处理:直接显示搜索词
1086            let search_display = format!("/{}", pattern);
1087            return Paragraph::new(Line::from(vec![
1088                Span::styled(
1089                    " SEARCH ",
1090                    Style::default().fg(Color::Black).bg(Color::Magenta),
1091                ),
1092                Span::raw(" "),
1093                Span::styled(
1094                    search_display,
1095                    Style::default()
1096                        .fg(Color::White)
1097                        .add_modifier(Modifier::BOLD),
1098                ),
1099                Span::styled("█", Style::default().fg(Color::White)),
1100            ]));
1101        }
1102    };
1103
1104    spans.push(Span::styled(
1105        mode_text,
1106        Style::default().fg(Color::Black).bg(mode_bg),
1107    ));
1108    spans.push(Span::raw("  "));
1109
1110    // 快捷键提示
1111    match mode {
1112        Mode::Insert => {
1113            spans.push(Span::styled(
1114                " Ctrl+S ",
1115                Style::default().fg(Color::Black).bg(Color::Green),
1116            ));
1117            spans.push(Span::raw(" 提交  "));
1118            spans.push(Span::styled(
1119                " Ctrl+Q ",
1120                Style::default().fg(Color::Black).bg(Color::Red),
1121            ));
1122            spans.push(Span::raw(" 取消  "));
1123            spans.push(Span::styled(
1124                " Esc ",
1125                Style::default().fg(Color::Black).bg(Color::Yellow),
1126            ));
1127            spans.push(Span::raw(" Normal  "));
1128        }
1129        Mode::Normal => {
1130            spans.push(Span::styled(
1131                " :wq ",
1132                Style::default().fg(Color::Black).bg(Color::Green),
1133            ));
1134            spans.push(Span::raw(" 提交  "));
1135            spans.push(Span::styled(
1136                " / ",
1137                Style::default().fg(Color::Black).bg(Color::Magenta),
1138            ));
1139            spans.push(Span::raw(" 搜索  "));
1140            spans.push(Span::styled(
1141                " n/N ",
1142                Style::default().fg(Color::Black).bg(Color::Cyan),
1143            ));
1144            spans.push(Span::raw(" 下/上  "));
1145            spans.push(Span::styled(
1146                " i ",
1147                Style::default().fg(Color::Black).bg(Color::Cyan),
1148            ));
1149            spans.push(Span::raw(" 编辑  "));
1150        }
1151        Mode::Visual => {
1152            spans.push(Span::styled(
1153                " y ",
1154                Style::default().fg(Color::Black).bg(Color::Green),
1155            ));
1156            spans.push(Span::raw(" 复制  "));
1157            spans.push(Span::styled(
1158                " d ",
1159                Style::default().fg(Color::Black).bg(Color::Red),
1160            ));
1161            spans.push(Span::raw(" 删除  "));
1162            spans.push(Span::styled(
1163                " Esc ",
1164                Style::default().fg(Color::Black).bg(Color::Yellow),
1165            ));
1166            spans.push(Span::raw(" 取消  "));
1167        }
1168        _ => {}
1169    }
1170
1171    // 行数
1172    spans.push(Span::styled(
1173        format!(" {} 行 ", line_count),
1174        Style::default().fg(Color::DarkGray),
1175    ));
1176
1177    // 搜索匹配信息
1178    if !search.pattern.is_empty() {
1179        let (current, total) = search.current_info();
1180        spans.push(Span::raw("  "));
1181        spans.push(Span::styled(
1182            format!(" [{}: {}/{}] ", search.pattern, current, total),
1183            Style::default().fg(Color::Magenta),
1184        ));
1185    }
1186
1187    Paragraph::new(Line::from(spans))
1188}