Skip to main content

j_cli/command/
todo.rs

1use crate::config::YamlConfig;
2use crate::{error, info};
3use chrono::Local;
4use crossterm::{
5    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
6    execute,
7    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
8};
9use ratatui::{
10    Terminal,
11    backend::CrosstermBackend,
12    layout::{Constraint, Direction, Layout},
13    style::{Color, Modifier, Style},
14    text::{Line, Span},
15    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16};
17use serde::{Deserialize, Serialize};
18use std::fs;
19use std::io;
20use std::path::PathBuf;
21
22// ========== 数据结构 ==========
23
24/// 单条待办事项
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct TodoItem {
27    /// 待办内容
28    pub content: String,
29    /// 是否已完成
30    pub done: bool,
31    /// 创建时间
32    pub created_at: String,
33    /// 完成时间(可选)
34    pub done_at: Option<String>,
35}
36
37/// 待办列表(序列化到 JSON)
38#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
39pub struct TodoList {
40    pub items: Vec<TodoItem>,
41}
42
43// ========== 文件路径 ==========
44
45/// 获取 todo 数据目录: ~/.jdata/todo/
46fn todo_dir() -> PathBuf {
47    let dir = YamlConfig::data_dir().join("todo");
48    let _ = fs::create_dir_all(&dir);
49    dir
50}
51
52/// 获取 todo 数据文件路径: ~/.jdata/todo/todo.json
53fn todo_file_path() -> PathBuf {
54    todo_dir().join("todo.json")
55}
56
57// ========== 数据读写 ==========
58
59/// 从文件加载待办列表
60fn load_todo_list() -> TodoList {
61    let path = todo_file_path();
62    if !path.exists() {
63        return TodoList::default();
64    }
65    match fs::read_to_string(&path) {
66        Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
67            error!("❌ 解析 todo.json 失败: {}", e);
68            TodoList::default()
69        }),
70        Err(e) => {
71            error!("❌ 读取 todo.json 失败: {}", e);
72            TodoList::default()
73        }
74    }
75}
76
77/// 保存待办列表到文件
78fn save_todo_list(list: &TodoList) -> bool {
79    let path = todo_file_path();
80    // 确保目录存在
81    if let Some(parent) = path.parent() {
82        let _ = fs::create_dir_all(parent);
83    }
84    match serde_json::to_string_pretty(list) {
85        Ok(json) => match fs::write(&path, json) {
86            Ok(_) => true,
87            Err(e) => {
88                error!("❌ 保存 todo.json 失败: {}", e);
89                false
90            }
91        },
92        Err(e) => {
93            error!("❌ 序列化 todo 列表失败: {}", e);
94            false
95        }
96    }
97}
98
99// ========== 命令入口 ==========
100
101/// 处理 todo 命令: j todo [content...]
102pub fn handle_todo(content: &[String], _config: &YamlConfig) {
103    if content.is_empty() {
104        // 无参数:进入 TUI 待办管理界面
105        run_todo_tui();
106        return;
107    }
108
109    // 有参数:快速添加待办
110    let text = content.join(" ");
111    let text = text.trim().trim_matches('"').to_string();
112
113    if text.is_empty() {
114        error!("⚠️ 内容为空,无法添加待办");
115        return;
116    }
117
118    let mut list = load_todo_list();
119    list.items.push(TodoItem {
120        content: text.clone(),
121        done: false,
122        created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
123        done_at: None,
124    });
125
126    if save_todo_list(&list) {
127        info!("✅ 已添加待办: {}", text);
128        // 显示当前待办总数
129        let undone = list.items.iter().filter(|i| !i.done).count();
130        info!("📋 当前未完成待办: {} 条", undone);
131    }
132}
133
134// ========== TUI 界面 ==========
135
136/// TUI 应用状态
137struct TodoApp {
138    /// 待办列表数据
139    list: TodoList,
140    /// 加载时的快照(用于对比是否真正有修改)
141    snapshot: TodoList,
142    /// 列表选中状态
143    state: ListState,
144    /// 当前模式
145    mode: AppMode,
146    /// 输入缓冲区(添加/编辑模式使用)
147    input: String,
148    /// 编辑时记录的原始索引
149    edit_index: Option<usize>,
150    /// 状态栏消息
151    message: Option<String>,
152    /// 过滤模式: 0=全部, 1=未完成, 2=已完成
153    filter: usize,
154    /// 强制退出输入缓冲(用于 q! 退出)
155    quit_input: String,
156    /// 输入模式下的光标位置(字符索引)
157    cursor_pos: usize,
158    /// 预览区滚动偏移
159    preview_scroll: u16,
160}
161
162#[derive(PartialEq)]
163enum AppMode {
164    /// 正常浏览模式
165    Normal,
166    /// 输入添加模式
167    Adding,
168    /// 编辑模式
169    Editing,
170    /// 确认删除
171    ConfirmDelete,
172    /// 显示帮助
173    Help,
174}
175
176impl TodoApp {
177    fn new() -> Self {
178        let list = load_todo_list();
179        let snapshot = list.clone();
180        let mut state = ListState::default();
181        if !list.items.is_empty() {
182            state.select(Some(0));
183        }
184        Self {
185            list,
186            snapshot,
187            state,
188            mode: AppMode::Normal,
189            input: String::new(),
190            edit_index: None,
191            message: None,
192            filter: 0,
193            quit_input: String::new(),
194            cursor_pos: 0,
195            preview_scroll: 0,
196        }
197    }
198
199    /// 通过对比快照判断是否有未保存的修改
200    fn is_dirty(&self) -> bool {
201        self.list != self.snapshot
202    }
203
204    /// 获取当前过滤后的索引列表(映射到 list.items 的真实索引)
205    fn filtered_indices(&self) -> Vec<usize> {
206        self.list
207            .items
208            .iter()
209            .enumerate()
210            .filter(|(_, item)| match self.filter {
211                1 => !item.done,
212                2 => item.done,
213                _ => true,
214            })
215            .map(|(i, _)| i)
216            .collect()
217    }
218
219    /// 获取当前选中项在原始列表中的真实索引
220    fn selected_real_index(&self) -> Option<usize> {
221        let indices = self.filtered_indices();
222        self.state
223            .selected()
224            .and_then(|sel| indices.get(sel).copied())
225    }
226
227    /// 向下移动
228    fn move_down(&mut self) {
229        let count = self.filtered_indices().len();
230        if count == 0 {
231            return;
232        }
233        let i = match self.state.selected() {
234            Some(i) => {
235                if i >= count - 1 {
236                    0
237                } else {
238                    i + 1
239                }
240            }
241            None => 0,
242        };
243        self.state.select(Some(i));
244    }
245
246    /// 向上移动
247    fn move_up(&mut self) {
248        let count = self.filtered_indices().len();
249        if count == 0 {
250            return;
251        }
252        let i = match self.state.selected() {
253            Some(i) => {
254                if i == 0 {
255                    count - 1
256                } else {
257                    i - 1
258                }
259            }
260            None => 0,
261        };
262        self.state.select(Some(i));
263    }
264
265    /// 切换当前选中项的完成状态
266    fn toggle_done(&mut self) {
267        if let Some(real_idx) = self.selected_real_index() {
268            let item = &mut self.list.items[real_idx];
269            item.done = !item.done;
270            if item.done {
271                item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
272                self.message = Some("✅ 已标记为完成".to_string());
273            } else {
274                item.done_at = None;
275                self.message = Some("⬜ 已标记为未完成".to_string());
276            }
277        }
278    }
279
280    /// 添加新待办
281    fn add_item(&mut self) {
282        let text = self.input.trim().to_string();
283        if text.is_empty() {
284            self.message = Some("⚠️ 内容为空,已取消".to_string());
285            self.mode = AppMode::Normal;
286            self.input.clear();
287            return;
288        }
289        self.list.items.push(TodoItem {
290            content: text,
291            done: false,
292            created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
293            done_at: None,
294        });
295        self.input.clear();
296        self.mode = AppMode::Normal;
297        // 选中新添加的项
298        let count = self.filtered_indices().len();
299        if count > 0 {
300            self.state.select(Some(count - 1));
301        }
302        self.message = Some("✅ 已添加新待办".to_string());
303    }
304
305    /// 确认编辑
306    fn confirm_edit(&mut self) {
307        let text = self.input.trim().to_string();
308        if text.is_empty() {
309            self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
310            self.mode = AppMode::Normal;
311            self.input.clear();
312            self.edit_index = None;
313            return;
314        }
315        if let Some(idx) = self.edit_index {
316            if idx < self.list.items.len() {
317                self.list.items[idx].content = text;
318                self.message = Some("✅ 已更新待办内容".to_string());
319            }
320        }
321        self.input.clear();
322        self.edit_index = None;
323        self.mode = AppMode::Normal;
324    }
325
326    /// 删除当前选中项
327    fn delete_selected(&mut self) {
328        if let Some(real_idx) = self.selected_real_index() {
329            let removed = self.list.items.remove(real_idx);
330            self.message = Some(format!("🗑️ 已删除: {}", removed.content));
331            // 调整选中位置
332            let count = self.filtered_indices().len();
333            if count == 0 {
334                self.state.select(None);
335            } else if let Some(sel) = self.state.selected() {
336                if sel >= count {
337                    self.state.select(Some(count - 1));
338                }
339            }
340        }
341        self.mode = AppMode::Normal;
342    }
343
344    /// 移动选中项向上(调整顺序)
345    fn move_item_up(&mut self) {
346        if let Some(real_idx) = self.selected_real_index() {
347            if real_idx > 0 {
348                self.list.items.swap(real_idx, real_idx - 1);
349                self.move_up();
350            }
351        }
352    }
353
354    /// 移动选中项向下(调整顺序)
355    fn move_item_down(&mut self) {
356        if let Some(real_idx) = self.selected_real_index() {
357            if real_idx < self.list.items.len() - 1 {
358                self.list.items.swap(real_idx, real_idx + 1);
359                self.move_down();
360            }
361        }
362    }
363
364    /// 切换过滤模式
365    fn toggle_filter(&mut self) {
366        self.filter = (self.filter + 1) % 3;
367        let count = self.filtered_indices().len();
368        if count > 0 {
369            self.state.select(Some(0));
370        } else {
371            self.state.select(None);
372        }
373        let label = match self.filter {
374            1 => "未完成",
375            2 => "已完成",
376            _ => "全部",
377        };
378        self.message = Some(format!("🔍 过滤: {}", label));
379    }
380
381    /// 保存数据
382    fn save(&mut self) {
383        if self.is_dirty() {
384            if save_todo_list(&self.list) {
385                // 更新快照为当前状态
386                self.snapshot = self.list.clone();
387                self.message = Some("💾 已保存".to_string());
388            }
389        } else {
390            self.message = Some("📋 无需保存,没有修改".to_string());
391        }
392    }
393}
394
395/// 启动 TUI 待办管理界面
396fn run_todo_tui() {
397    match run_todo_tui_internal() {
398        Ok(_) => {}
399        Err(e) => {
400            error!("❌ TUI 启动失败: {}", e);
401        }
402    }
403}
404
405fn run_todo_tui_internal() -> io::Result<()> {
406    // 进入终端原始模式
407    terminal::enable_raw_mode()?;
408    let mut stdout = io::stdout();
409    execute!(stdout, EnterAlternateScreen)?;
410
411    let backend = CrosstermBackend::new(stdout);
412    let mut terminal = Terminal::new(backend)?;
413
414    let mut app = TodoApp::new();
415    // 记录上一次的输入内容长度,用于检测输入变化并重置预览滚动
416    let mut last_input_len: usize = 0;
417
418    loop {
419        // 渲染界面
420        terminal.draw(|f| draw_ui(f, &mut app))?;
421
422        // 检测输入内容变化,重置预览区滚动
423        // 当输入内容变化时重置滚动,让用户看到最新输入的内容
424        let current_input_len = app.input.chars().count();
425        if current_input_len != last_input_len {
426            app.preview_scroll = 0;
427            last_input_len = current_input_len;
428        }
429
430        // 处理输入事件
431        if event::poll(std::time::Duration::from_millis(100))? {
432            if let Event::Key(key) = event::read()? {
433                // Alt+↑/↓ 预览区滚动(在 Adding/Editing 模式下)
434                if (app.mode == AppMode::Adding || app.mode == AppMode::Editing)
435                    && key.modifiers.contains(KeyModifiers::ALT)
436                {
437                    match key.code {
438                        KeyCode::Down => {
439                            app.preview_scroll = app.preview_scroll.saturating_add(1);
440                            continue;
441                        }
442                        KeyCode::Up => {
443                            app.preview_scroll = app.preview_scroll.saturating_sub(1);
444                            continue;
445                        }
446                        _ => {}
447                    }
448                }
449
450                match app.mode {
451                    AppMode::Normal => {
452                        if handle_normal_mode(&mut app, key) {
453                            break;
454                        }
455                    }
456                    AppMode::Adding => handle_input_mode(&mut app, key),
457                    AppMode::Editing => handle_input_mode(&mut app, key),
458                    AppMode::ConfirmDelete => handle_confirm_delete(&mut app, key),
459                    AppMode::Help => handle_help_mode(&mut app, key),
460                }
461            }
462        }
463    }
464
465    // 退出前不自动保存(用户需要手动保存或用 q! 放弃修改)
466
467    // 恢复终端
468    terminal::disable_raw_mode()?;
469    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
470
471    Ok(())
472}
473
474/// 绘制 TUI 界面
475fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
476    let size = f.area();
477
478    // 判断是否需要显示预览区(只在 Adding/Editing 模式下显示,Normal 模式不显示)
479    // 预览区用于显示正在输入的内容,当内容超出输入框显示宽度时自动显示
480    let needs_preview = if app.mode == AppMode::Adding || app.mode == AppMode::Editing {
481        // 输入内容不为空时显示预览区
482        !app.input.is_empty()
483    } else {
484        false
485    };
486
487    // 整体布局: 标题栏 + 列表区 + [预览区] + 状态栏 + 帮助栏
488    let constraints = if needs_preview {
489        vec![
490            Constraint::Length(3),      // 标题栏
491            Constraint::Percentage(55), // 列表区
492            Constraint::Min(5),         // 预览区
493            Constraint::Length(3),      // 状态/输入栏
494            Constraint::Length(2),      // 帮助栏
495        ]
496    } else {
497        vec![
498            Constraint::Length(3), // 标题栏
499            Constraint::Min(5),    // 列表区
500            Constraint::Length(3), // 状态/输入栏
501            Constraint::Length(2), // 帮助栏
502        ]
503    };
504    let chunks = Layout::default()
505        .direction(Direction::Vertical)
506        .constraints(constraints)
507        .split(size);
508
509    // ========== 标题栏 ==========
510    let filter_label = match app.filter {
511        1 => " [未完成]",
512        2 => " [已完成]",
513        _ => "",
514    };
515    let total = app.list.items.len();
516    let done = app.list.items.iter().filter(|i| i.done).count();
517    let undone = total - done;
518    let title = format!(
519        " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
520        filter_label, total, done, undone
521    );
522    let title_block = Paragraph::new(Line::from(vec![Span::styled(
523        title,
524        Style::default()
525            .fg(Color::Cyan)
526            .add_modifier(Modifier::BOLD),
527    )]))
528    .block(
529        Block::default()
530            .borders(Borders::ALL)
531            .border_style(Style::default().fg(Color::Cyan)),
532    );
533    f.render_widget(title_block, chunks[0]);
534
535    // ========== 列表区 ==========
536    if app.mode == AppMode::Help {
537        // 帮助模式:显示完整帮助信息
538        let help_lines = vec![
539            Line::from(Span::styled(
540                "  📖 快捷键帮助",
541                Style::default()
542                    .fg(Color::Cyan)
543                    .add_modifier(Modifier::BOLD),
544            )),
545            Line::from(""),
546            Line::from(vec![
547                Span::styled("  n / ↓ / j    ", Style::default().fg(Color::Yellow)),
548                Span::raw("向下移动"),
549            ]),
550            Line::from(vec![
551                Span::styled("  N / ↑ / k    ", Style::default().fg(Color::Yellow)),
552                Span::raw("向上移动"),
553            ]),
554            Line::from(vec![
555                Span::styled("  空格 / 回车   ", Style::default().fg(Color::Yellow)),
556                Span::raw("切换完成状态 [x] / [ ]"),
557            ]),
558            Line::from(vec![
559                Span::styled("  a            ", Style::default().fg(Color::Yellow)),
560                Span::raw("添加新待办"),
561            ]),
562            Line::from(vec![
563                Span::styled("  e            ", Style::default().fg(Color::Yellow)),
564                Span::raw("编辑选中待办"),
565            ]),
566            Line::from(vec![
567                Span::styled("  d            ", Style::default().fg(Color::Yellow)),
568                Span::raw("删除待办(需确认)"),
569            ]),
570            Line::from(vec![
571                Span::styled("  f            ", Style::default().fg(Color::Yellow)),
572                Span::raw("过滤切换(全部 / 未完成 / 已完成)"),
573            ]),
574            Line::from(vec![
575                Span::styled("  J / K        ", Style::default().fg(Color::Yellow)),
576                Span::raw("调整待办顺序(下移 / 上移)"),
577            ]),
578            Line::from(vec![
579                Span::styled("  s            ", Style::default().fg(Color::Yellow)),
580                Span::raw("手动保存"),
581            ]),
582            Line::from(vec![
583                Span::styled("  y            ", Style::default().fg(Color::Yellow)),
584                Span::raw("复制选中待办到剪切板"),
585            ]),
586            Line::from(vec![
587                Span::styled("  q            ", Style::default().fg(Color::Yellow)),
588                Span::raw("退出(有未保存修改时需先保存或用 q! 强制退出)"),
589            ]),
590            Line::from(vec![
591                Span::styled("  q!           ", Style::default().fg(Color::Yellow)),
592                Span::raw("强制退出(丢弃未保存的修改)"),
593            ]),
594            Line::from(vec![
595                Span::styled("  Esc          ", Style::default().fg(Color::Yellow)),
596                Span::raw("退出(同 q)"),
597            ]),
598            Line::from(vec![
599                Span::styled("  Ctrl+C       ", Style::default().fg(Color::Yellow)),
600                Span::raw("强制退出(不保存)"),
601            ]),
602            Line::from(vec![
603                Span::styled("  ?            ", Style::default().fg(Color::Yellow)),
604                Span::raw("显示此帮助"),
605            ]),
606            Line::from(""),
607            Line::from(Span::styled(
608                "  添加/编辑模式下:",
609                Style::default().fg(Color::Gray),
610            )),
611            Line::from(vec![
612                Span::styled("  Alt+↓/↑      ", Style::default().fg(Color::Yellow)),
613                Span::raw("预览区滚动(长文本输入时)"),
614            ]),
615        ];
616        let help_block = Block::default()
617            .borders(Borders::ALL)
618            .border_style(Style::default().fg(Color::Cyan))
619            .title(" 帮助 ");
620        let help_widget = Paragraph::new(help_lines).block(help_block);
621        f.render_widget(help_widget, chunks[1]);
622    } else {
623        let indices = app.filtered_indices();
624        // 计算列表区域可用宽度(减去边框 2 + highlight_symbol " ▶ " 占 3 个字符)
625        let list_inner_width = chunks[1].width.saturating_sub(2 + 3) as usize;
626        let items: Vec<ListItem> = indices
627            .iter()
628            .map(|&idx| {
629                let item = &app.list.items[idx];
630                let checkbox = if item.done { "[x]" } else { "[ ]" };
631                let checkbox_style = if item.done {
632                    Style::default().fg(Color::Green)
633                } else {
634                    Style::default().fg(Color::Yellow)
635                };
636                let content_style = if item.done {
637                    Style::default()
638                        .fg(Color::Gray)
639                        .add_modifier(Modifier::CROSSED_OUT)
640                } else {
641                    Style::default().fg(Color::White)
642                };
643
644                // checkbox 部分: " [x] " 占 5 个显示宽度
645                let checkbox_str = format!(" {} ", checkbox);
646                let checkbox_display_width = display_width(&checkbox_str);
647
648                // 时间部分: "  (YYYY-MM-DD)" 占 14 个显示宽度
649                let date_str = item
650                    .created_at
651                    .get(..10)
652                    .map(|d| format!("  ({})", d))
653                    .unwrap_or_default();
654                let date_display_width = display_width(&date_str);
655
656                // 内容可用的最大显示宽度
657                let content_max_width = list_inner_width
658                    .saturating_sub(checkbox_display_width)
659                    .saturating_sub(date_display_width);
660
661                // 如果内容超出可用宽度则截断并加 "…"
662                let content_display = truncate_to_width(&item.content, content_max_width);
663                let content_actual_width = display_width(&content_display);
664
665                // 用空格填充内容和时间之间的间距,让时间右对齐
666                let padding_width = content_max_width.saturating_sub(content_actual_width);
667                let padding = " ".repeat(padding_width);
668
669                ListItem::new(Line::from(vec![
670                    Span::styled(checkbox_str, checkbox_style),
671                    Span::styled(content_display, content_style),
672                    Span::raw(padding),
673                    Span::styled(date_str, Style::default().fg(Color::DarkGray)),
674                ]))
675            })
676            .collect();
677
678        let list_block = Block::default()
679            .borders(Borders::ALL)
680            .border_style(Style::default().fg(Color::White))
681            .title(" 待办列表 ");
682
683        if items.is_empty() {
684            // 空列表提示
685            let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
686                "   (空) 按 a 添加新待办...",
687                Style::default().fg(Color::DarkGray),
688            )))])
689            .block(list_block);
690            f.render_widget(empty_hint, chunks[1]);
691        } else {
692            let list_widget = List::new(items)
693                .block(list_block)
694                .highlight_style(
695                    Style::default()
696                        .bg(Color::Indexed(24))
697                        .add_modifier(Modifier::BOLD),
698                )
699                .highlight_symbol(" ▶ ");
700            f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
701        };
702    }
703
704    // ========== 预览区(只在 Adding/Editing 模式下显示,预览用户输入内容) ==========
705    let (_preview_chunk_idx, status_chunk_idx, help_chunk_idx) = if needs_preview {
706        // 有预览区时的索引
707        // 预览区显示用户正在输入的内容
708        let input_content = &app.input;
709        // 预览区内部可用宽度(去掉左右边框各 1 列)
710        let preview_inner_w = (chunks[2].width.saturating_sub(2)) as usize;
711        // 预览区内部可用高度(去掉上下边框各 1 行)
712        let preview_inner_h = chunks[2].height.saturating_sub(2) as u16;
713
714        // 计算总 wrap 行数
715        let total_wrapped = count_wrapped_lines(input_content, preview_inner_w) as u16;
716        let max_scroll = total_wrapped.saturating_sub(preview_inner_h);
717        let clamped_scroll = app.preview_scroll.min(max_scroll);
718
719        // 构建标题
720        let mode_label = match app.mode {
721            AppMode::Adding => "新待办",
722            AppMode::Editing => "编辑中",
723            _ => "预览",
724        };
725        let title = if total_wrapped > preview_inner_h {
726            format!(
727                " 📖 {} 预览 [{}/{}行] Alt+↓/↑滚动 ",
728                mode_label,
729                clamped_scroll + preview_inner_h,
730                total_wrapped
731            )
732        } else {
733            format!(" 📖 {} 预览 ", mode_label)
734        };
735
736        let preview_block = Block::default()
737            .borders(Borders::ALL)
738            .title(title)
739            .title_style(
740                Style::default()
741                    .fg(Color::Cyan)
742                    .add_modifier(Modifier::BOLD),
743            )
744            .border_style(Style::default().fg(Color::Cyan));
745
746        use ratatui::widgets::Wrap;
747        let preview = Paragraph::new(input_content.clone())
748            .block(preview_block)
749            .style(Style::default().fg(Color::White))
750            .wrap(Wrap { trim: false })
751            .scroll((clamped_scroll, 0));
752        f.render_widget(preview, chunks[2]);
753        (2, 3, 4)
754    } else {
755        // 无预览区时的索引
756        (1, 2, 3)
757    };
758
759    // ========== 状态/输入栏 ==========
760    match &app.mode {
761        AppMode::Adding => {
762            let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
763            let input_widget = Paragraph::new(Line::from(vec![
764                Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
765                Span::raw(before),
766                Span::styled(
767                    cursor_ch,
768                    Style::default().fg(Color::Black).bg(Color::White),
769                ),
770                Span::raw(after),
771            ]))
772            .block(
773                Block::default()
774                    .borders(Borders::ALL)
775                    .border_style(Style::default().fg(Color::Green))
776                    .title(" 添加模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
777            );
778            f.render_widget(input_widget, chunks[status_chunk_idx]);
779        }
780        AppMode::Editing => {
781            let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
782            let input_widget = Paragraph::new(Line::from(vec![
783                Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
784                Span::raw(before),
785                Span::styled(
786                    cursor_ch,
787                    Style::default().fg(Color::Black).bg(Color::White),
788                ),
789                Span::raw(after),
790            ]))
791            .block(
792                Block::default()
793                    .borders(Borders::ALL)
794                    .border_style(Style::default().fg(Color::Yellow))
795                    .title(" 编辑模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
796            );
797            f.render_widget(input_widget, chunks[status_chunk_idx]);
798        }
799        AppMode::ConfirmDelete => {
800            let msg = if let Some(real_idx) = app.selected_real_index() {
801                format!(
802                    " 确认删除「{}」?(y 确认 / n 取消)",
803                    app.list.items[real_idx].content
804                )
805            } else {
806                " 没有选中的项目".to_string()
807            };
808            let confirm_widget = Paragraph::new(Line::from(Span::styled(
809                msg,
810                Style::default().fg(Color::Red),
811            )))
812            .block(
813                Block::default()
814                    .borders(Borders::ALL)
815                    .border_style(Style::default().fg(Color::Red))
816                    .title(" ⚠️ 确认删除 "),
817            );
818            f.render_widget(confirm_widget, chunks[2]);
819        }
820        AppMode::Normal | AppMode::Help => {
821            let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
822            let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
823            let status_widget = Paragraph::new(Line::from(vec![
824                Span::styled(msg, Style::default().fg(Color::Gray)),
825                Span::styled(
826                    dirty_indicator,
827                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
828                ),
829            ]))
830            .block(
831                Block::default()
832                    .borders(Borders::ALL)
833                    .border_style(Style::default().fg(Color::DarkGray)),
834            );
835            f.render_widget(status_widget, chunks[2]);
836        }
837    }
838
839    // ========== 帮助栏 ==========
840    let help_text = match app.mode {
841        AppMode::Normal => {
842            " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
843        }
844        AppMode::Adding | AppMode::Editing => {
845            " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾 | Alt+↓/↑ 预览滚动"
846        }
847        AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
848        AppMode::Help => " 按任意键返回",
849    };
850    let help_widget = Paragraph::new(Line::from(Span::styled(
851        help_text,
852        Style::default().fg(Color::DarkGray),
853    )));
854    f.render_widget(help_widget, chunks[help_chunk_idx]);
855}
856
857/// 正常模式按键处理,返回 true 表示退出
858fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
859    // Ctrl+C 强制退出(不保存)
860    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
861        return true;
862    }
863
864    match key.code {
865        // 退出:有未保存修改时拒绝,提示用 q! 或先保存
866        KeyCode::Char('q') => {
867            if app.is_dirty() {
868                app.message = Some(
869                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
870                );
871                app.quit_input = "q".to_string();
872                return false;
873            }
874            return true;
875        }
876        KeyCode::Esc => {
877            if app.is_dirty() {
878                app.message = Some(
879                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
880                );
881                return false;
882            }
883            return true;
884        }
885
886        // q! 强制退出(丢弃修改):通过 ! 键判断前一个输入是否为 q
887        KeyCode::Char('!') => {
888            if app.quit_input == "q" {
889                return true; // q! 强制退出
890            }
891            app.quit_input.clear();
892        }
893
894        // 向下移动
895        KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
896
897        // 向上移动
898        KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
899
900        // 切换完成状态
901        KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
902
903        // 添加
904        KeyCode::Char('a') => {
905            app.mode = AppMode::Adding;
906            app.input.clear();
907            app.cursor_pos = 0;
908            app.message = None;
909        }
910
911        // 编辑
912        KeyCode::Char('e') => {
913            if let Some(real_idx) = app.selected_real_index() {
914                app.input = app.list.items[real_idx].content.clone();
915                app.cursor_pos = app.input.chars().count();
916                app.edit_index = Some(real_idx);
917                app.mode = AppMode::Editing;
918                app.message = None;
919            }
920        }
921
922        // 复制选中待办到剪切板
923        KeyCode::Char('y') => {
924            if let Some(real_idx) = app.selected_real_index() {
925                let content = app.list.items[real_idx].content.clone();
926                if copy_to_clipboard(&content) {
927                    app.message = Some(format!("📋 已复制到剪切板: {}", content));
928                } else {
929                    app.message = Some("❌ 复制到剪切板失败".to_string());
930                }
931            }
932        }
933
934        // 删除(需确认)
935        KeyCode::Char('d') => {
936            if app.selected_real_index().is_some() {
937                app.mode = AppMode::ConfirmDelete;
938            }
939        }
940
941        // 过滤切换
942        KeyCode::Char('f') => app.toggle_filter(),
943
944        // 保存
945        KeyCode::Char('s') => app.save(),
946
947        // 调整顺序: Shift+↑ 上移 / Shift+↓ 下移
948        KeyCode::Char('K') => app.move_item_up(),
949        KeyCode::Char('J') => app.move_item_down(),
950
951        // 查看帮助
952        KeyCode::Char('?') => {
953            app.mode = AppMode::Help;
954        }
955
956        _ => {}
957    }
958
959    // 非 q 键时清空 quit_input 缓冲
960    if key.code != KeyCode::Char('q') && key.code != KeyCode::Char('!') {
961        app.quit_input.clear();
962    }
963
964    false
965}
966
967/// 输入模式按键处理(添加/编辑通用,支持光标移动和行内编辑)
968fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
969    let char_count = app.input.chars().count();
970
971    match key.code {
972        KeyCode::Enter => {
973            if app.mode == AppMode::Adding {
974                app.add_item();
975            } else {
976                app.confirm_edit();
977            }
978        }
979        KeyCode::Esc => {
980            app.mode = AppMode::Normal;
981            app.input.clear();
982            app.cursor_pos = 0;
983            app.edit_index = None;
984            app.message = Some("已取消".to_string());
985        }
986        KeyCode::Left => {
987            if app.cursor_pos > 0 {
988                app.cursor_pos -= 1;
989            }
990        }
991        KeyCode::Right => {
992            if app.cursor_pos < char_count {
993                app.cursor_pos += 1;
994            }
995        }
996        KeyCode::Home => {
997            app.cursor_pos = 0;
998        }
999        KeyCode::End => {
1000            app.cursor_pos = char_count;
1001        }
1002        KeyCode::Backspace => {
1003            if app.cursor_pos > 0 {
1004                // 找到第 cursor_pos-1 和 cursor_pos 个字符的字节偏移,删除该范围
1005                let start = app
1006                    .input
1007                    .char_indices()
1008                    .nth(app.cursor_pos - 1)
1009                    .map(|(i, _)| i)
1010                    .unwrap_or(0);
1011                let end = app
1012                    .input
1013                    .char_indices()
1014                    .nth(app.cursor_pos)
1015                    .map(|(i, _)| i)
1016                    .unwrap_or(app.input.len());
1017                app.input.drain(start..end);
1018                app.cursor_pos -= 1;
1019            }
1020        }
1021        KeyCode::Delete => {
1022            if app.cursor_pos < char_count {
1023                let start = app
1024                    .input
1025                    .char_indices()
1026                    .nth(app.cursor_pos)
1027                    .map(|(i, _)| i)
1028                    .unwrap_or(app.input.len());
1029                let end = app
1030                    .input
1031                    .char_indices()
1032                    .nth(app.cursor_pos + 1)
1033                    .map(|(i, _)| i)
1034                    .unwrap_or(app.input.len());
1035                app.input.drain(start..end);
1036            }
1037        }
1038        KeyCode::Char(c) => {
1039            // 在光标位置插入字符(支持多字节字符)
1040            let byte_idx = app
1041                .input
1042                .char_indices()
1043                .nth(app.cursor_pos)
1044                .map(|(i, _)| i)
1045                .unwrap_or(app.input.len());
1046            app.input.insert_str(byte_idx, &c.to_string());
1047            app.cursor_pos += 1;
1048        }
1049        _ => {}
1050    }
1051}
1052
1053/// 确认删除按键处理
1054fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
1055    match key.code {
1056        KeyCode::Char('y') | KeyCode::Char('Y') => {
1057            app.delete_selected();
1058        }
1059        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1060            app.mode = AppMode::Normal;
1061            app.message = Some("已取消删除".to_string());
1062        }
1063        _ => {}
1064    }
1065}
1066
1067/// 帮助模式按键处理(按任意键返回)
1068fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
1069    app.mode = AppMode::Normal;
1070    app.message = None;
1071}
1072
1073/// 将输入字符串按光标位置分割为三部分:光标前、光标处字符、光标后
1074fn split_input_at_cursor(input: &str, cursor_pos: usize) -> (String, String, String) {
1075    let chars: Vec<char> = input.chars().collect();
1076    let before: String = chars[..cursor_pos].iter().collect();
1077    let cursor_ch = if cursor_pos < chars.len() {
1078        chars[cursor_pos].to_string()
1079    } else {
1080        " ".to_string() // 光标在末尾时显示空格块
1081    };
1082    let after: String = if cursor_pos < chars.len() {
1083        chars[cursor_pos + 1..].iter().collect()
1084    } else {
1085        String::new()
1086    };
1087    (before, cursor_ch, after)
1088}
1089
1090/// 计算字符串的显示宽度(中文/全角字符占 2 列,ASCII 占 1 列)
1091fn display_width(s: &str) -> usize {
1092    s.chars()
1093        .map(|c| {
1094            if c.is_ascii() {
1095                1
1096            } else {
1097                // CJK 字符和全角字符占 2 列
1098                2
1099            }
1100        })
1101        .sum()
1102}
1103
1104/// 计算字符串在指定列宽下换行后的行数(使用 unicode_width 精确计算)
1105fn count_wrapped_lines(s: &str, col_width: usize) -> usize {
1106    if col_width == 0 || s.is_empty() {
1107        return 1;
1108    }
1109    let mut lines = 1usize;
1110    let mut current_width = 0usize;
1111    for c in s.chars() {
1112        let char_width = if c.is_ascii() { 1 } else { 2 };
1113        if current_width + char_width > col_width {
1114            lines += 1;
1115            current_width = char_width;
1116        } else {
1117            current_width += char_width;
1118        }
1119    }
1120    lines
1121}
1122
1123/// 将字符串截断到指定的显示宽度,超出部分用 ".." 替代
1124fn truncate_to_width(s: &str, max_width: usize) -> String {
1125    if max_width == 0 {
1126        return String::new();
1127    }
1128
1129    let total_width = display_width(s);
1130    if total_width <= max_width {
1131        return s.to_string();
1132    }
1133
1134    // 需要截断,预留 2 列给 ".."
1135    let ellipsis = "..";
1136    let ellipsis_width = 2;
1137    let content_budget = max_width.saturating_sub(ellipsis_width);
1138
1139    let mut width = 0;
1140    let mut result = String::new();
1141    for ch in s.chars() {
1142        let ch_width = if ch.is_ascii() { 1 } else { 2 };
1143        if width + ch_width > content_budget {
1144            break;
1145        }
1146        width += ch_width;
1147        result.push(ch);
1148    }
1149    result.push_str(ellipsis);
1150    result
1151}
1152
1153/// 复制内容到系统剪切板(macOS 使用 pbcopy,Linux 使用 xclip)
1154fn copy_to_clipboard(content: &str) -> bool {
1155    use std::io::Write;
1156    use std::process::{Command, Stdio};
1157
1158    // 根据平台选择剪切板命令
1159    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
1160        ("pbcopy", vec![])
1161    } else if cfg!(target_os = "linux") {
1162        // 优先尝试 xclip,其次 xsel
1163        if Command::new("which")
1164            .arg("xclip")
1165            .output()
1166            .map(|o| o.status.success())
1167            .unwrap_or(false)
1168        {
1169            ("xclip", vec!["-selection", "clipboard"])
1170        } else {
1171            ("xsel", vec!["--clipboard", "--input"])
1172        }
1173    } else {
1174        return false; // 不支持的平台
1175    };
1176
1177    let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
1178
1179    match child {
1180        Ok(mut child) => {
1181            if let Some(ref mut stdin) = child.stdin {
1182                let _ = stdin.write_all(content.as_bytes());
1183            }
1184            child.wait().map(|s| s.success()).unwrap_or(false)
1185        }
1186        Err(_) => false,
1187    }
1188}