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
160#[derive(PartialEq)]
161enum AppMode {
162    /// 正常浏览模式
163    Normal,
164    /// 输入添加模式
165    Adding,
166    /// 编辑模式
167    Editing,
168    /// 确认删除
169    ConfirmDelete,
170    /// 显示帮助
171    Help,
172}
173
174impl TodoApp {
175    fn new() -> Self {
176        let list = load_todo_list();
177        let snapshot = list.clone();
178        let mut state = ListState::default();
179        if !list.items.is_empty() {
180            state.select(Some(0));
181        }
182        Self {
183            list,
184            snapshot,
185            state,
186            mode: AppMode::Normal,
187            input: String::new(),
188            edit_index: None,
189            message: None,
190            filter: 0,
191            quit_input: String::new(),
192            cursor_pos: 0,
193        }
194    }
195
196    /// 通过对比快照判断是否有未保存的修改
197    fn is_dirty(&self) -> bool {
198        self.list != self.snapshot
199    }
200
201    /// 获取当前过滤后的索引列表(映射到 list.items 的真实索引)
202    fn filtered_indices(&self) -> Vec<usize> {
203        self.list
204            .items
205            .iter()
206            .enumerate()
207            .filter(|(_, item)| match self.filter {
208                1 => !item.done,
209                2 => item.done,
210                _ => true,
211            })
212            .map(|(i, _)| i)
213            .collect()
214    }
215
216    /// 获取当前选中项在原始列表中的真实索引
217    fn selected_real_index(&self) -> Option<usize> {
218        let indices = self.filtered_indices();
219        self.state
220            .selected()
221            .and_then(|sel| indices.get(sel).copied())
222    }
223
224    /// 向下移动
225    fn move_down(&mut self) {
226        let count = self.filtered_indices().len();
227        if count == 0 {
228            return;
229        }
230        let i = match self.state.selected() {
231            Some(i) => {
232                if i >= count - 1 {
233                    0
234                } else {
235                    i + 1
236                }
237            }
238            None => 0,
239        };
240        self.state.select(Some(i));
241    }
242
243    /// 向上移动
244    fn move_up(&mut self) {
245        let count = self.filtered_indices().len();
246        if count == 0 {
247            return;
248        }
249        let i = match self.state.selected() {
250            Some(i) => {
251                if i == 0 {
252                    count - 1
253                } else {
254                    i - 1
255                }
256            }
257            None => 0,
258        };
259        self.state.select(Some(i));
260    }
261
262    /// 切换当前选中项的完成状态
263    fn toggle_done(&mut self) {
264        if let Some(real_idx) = self.selected_real_index() {
265            let item = &mut self.list.items[real_idx];
266            item.done = !item.done;
267            if item.done {
268                item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
269                self.message = Some("✅ 已标记为完成".to_string());
270            } else {
271                item.done_at = None;
272                self.message = Some("⬜ 已标记为未完成".to_string());
273            }
274        }
275    }
276
277    /// 添加新待办
278    fn add_item(&mut self) {
279        let text = self.input.trim().to_string();
280        if text.is_empty() {
281            self.message = Some("⚠️ 内容为空,已取消".to_string());
282            self.mode = AppMode::Normal;
283            self.input.clear();
284            return;
285        }
286        self.list.items.push(TodoItem {
287            content: text,
288            done: false,
289            created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
290            done_at: None,
291        });
292        self.input.clear();
293        self.mode = AppMode::Normal;
294        // 选中新添加的项
295        let count = self.filtered_indices().len();
296        if count > 0 {
297            self.state.select(Some(count - 1));
298        }
299        self.message = Some("✅ 已添加新待办".to_string());
300    }
301
302    /// 确认编辑
303    fn confirm_edit(&mut self) {
304        let text = self.input.trim().to_string();
305        if text.is_empty() {
306            self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
307            self.mode = AppMode::Normal;
308            self.input.clear();
309            self.edit_index = None;
310            return;
311        }
312        if let Some(idx) = self.edit_index {
313            if idx < self.list.items.len() {
314                self.list.items[idx].content = text;
315                self.message = Some("✅ 已更新待办内容".to_string());
316            }
317        }
318        self.input.clear();
319        self.edit_index = None;
320        self.mode = AppMode::Normal;
321    }
322
323    /// 删除当前选中项
324    fn delete_selected(&mut self) {
325        if let Some(real_idx) = self.selected_real_index() {
326            let removed = self.list.items.remove(real_idx);
327            self.message = Some(format!("🗑️ 已删除: {}", removed.content));
328            // 调整选中位置
329            let count = self.filtered_indices().len();
330            if count == 0 {
331                self.state.select(None);
332            } else if let Some(sel) = self.state.selected() {
333                if sel >= count {
334                    self.state.select(Some(count - 1));
335                }
336            }
337        }
338        self.mode = AppMode::Normal;
339    }
340
341    /// 移动选中项向上(调整顺序)
342    fn move_item_up(&mut self) {
343        if let Some(real_idx) = self.selected_real_index() {
344            if real_idx > 0 {
345                self.list.items.swap(real_idx, real_idx - 1);
346                self.move_up();
347            }
348        }
349    }
350
351    /// 移动选中项向下(调整顺序)
352    fn move_item_down(&mut self) {
353        if let Some(real_idx) = self.selected_real_index() {
354            if real_idx < self.list.items.len() - 1 {
355                self.list.items.swap(real_idx, real_idx + 1);
356                self.move_down();
357            }
358        }
359    }
360
361    /// 切换过滤模式
362    fn toggle_filter(&mut self) {
363        self.filter = (self.filter + 1) % 3;
364        let count = self.filtered_indices().len();
365        if count > 0 {
366            self.state.select(Some(0));
367        } else {
368            self.state.select(None);
369        }
370        let label = match self.filter {
371            1 => "未完成",
372            2 => "已完成",
373            _ => "全部",
374        };
375        self.message = Some(format!("🔍 过滤: {}", label));
376    }
377
378    /// 保存数据
379    fn save(&mut self) {
380        if self.is_dirty() {
381            if save_todo_list(&self.list) {
382                // 更新快照为当前状态
383                self.snapshot = self.list.clone();
384                self.message = Some("💾 已保存".to_string());
385            }
386        } else {
387            self.message = Some("📋 无需保存,没有修改".to_string());
388        }
389    }
390}
391
392/// 启动 TUI 待办管理界面
393fn run_todo_tui() {
394    match run_todo_tui_internal() {
395        Ok(_) => {}
396        Err(e) => {
397            error!("❌ TUI 启动失败: {}", e);
398        }
399    }
400}
401
402fn run_todo_tui_internal() -> io::Result<()> {
403    // 进入终端原始模式
404    terminal::enable_raw_mode()?;
405    let mut stdout = io::stdout();
406    execute!(stdout, EnterAlternateScreen)?;
407
408    let backend = CrosstermBackend::new(stdout);
409    let mut terminal = Terminal::new(backend)?;
410
411    let mut app = TodoApp::new();
412
413    loop {
414        // 渲染界面
415        terminal.draw(|f| draw_ui(f, &mut app))?;
416
417        // 处理输入事件
418        if event::poll(std::time::Duration::from_millis(100))? {
419            if let Event::Key(key) = event::read()? {
420                match app.mode {
421                    AppMode::Normal => {
422                        if handle_normal_mode(&mut app, key) {
423                            break;
424                        }
425                    }
426                    AppMode::Adding => handle_input_mode(&mut app, key),
427                    AppMode::Editing => handle_input_mode(&mut app, key),
428                    AppMode::ConfirmDelete => handle_confirm_delete(&mut app, key),
429                    AppMode::Help => handle_help_mode(&mut app, key),
430                }
431            }
432        }
433    }
434
435    // 退出前不自动保存(用户需要手动保存或用 q! 放弃修改)
436
437    // 恢复终端
438    terminal::disable_raw_mode()?;
439    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
440
441    Ok(())
442}
443
444/// 绘制 TUI 界面
445fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
446    let size = f.area();
447
448    // 整体布局: 标题栏 + 列表区 + 状态栏 + 帮助栏
449    let chunks = Layout::default()
450        .direction(Direction::Vertical)
451        .constraints([
452            Constraint::Length(3), // 标题栏
453            Constraint::Min(5),    // 列表区
454            Constraint::Length(3), // 状态/输入栏
455            Constraint::Length(2), // 帮助栏
456        ])
457        .split(size);
458
459    // ========== 标题栏 ==========
460    let filter_label = match app.filter {
461        1 => " [未完成]",
462        2 => " [已完成]",
463        _ => "",
464    };
465    let total = app.list.items.len();
466    let done = app.list.items.iter().filter(|i| i.done).count();
467    let undone = total - done;
468    let title = format!(
469        " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
470        filter_label, total, done, undone
471    );
472    let title_block = Paragraph::new(Line::from(vec![Span::styled(
473        title,
474        Style::default()
475            .fg(Color::Cyan)
476            .add_modifier(Modifier::BOLD),
477    )]))
478    .block(
479        Block::default()
480            .borders(Borders::ALL)
481            .border_style(Style::default().fg(Color::Cyan)),
482    );
483    f.render_widget(title_block, chunks[0]);
484
485    // ========== 列表区 ==========
486    if app.mode == AppMode::Help {
487        // 帮助模式:显示完整帮助信息
488        let help_lines = vec![
489            Line::from(Span::styled(
490                "  📖 快捷键帮助",
491                Style::default()
492                    .fg(Color::Cyan)
493                    .add_modifier(Modifier::BOLD),
494            )),
495            Line::from(""),
496            Line::from(vec![
497                Span::styled("  n / ↓ / j    ", Style::default().fg(Color::Yellow)),
498                Span::raw("向下移动"),
499            ]),
500            Line::from(vec![
501                Span::styled("  N / ↑ / k    ", Style::default().fg(Color::Yellow)),
502                Span::raw("向上移动"),
503            ]),
504            Line::from(vec![
505                Span::styled("  空格 / 回车   ", Style::default().fg(Color::Yellow)),
506                Span::raw("切换完成状态 [x] / [ ]"),
507            ]),
508            Line::from(vec![
509                Span::styled("  a            ", Style::default().fg(Color::Yellow)),
510                Span::raw("添加新待办"),
511            ]),
512            Line::from(vec![
513                Span::styled("  e            ", Style::default().fg(Color::Yellow)),
514                Span::raw("编辑选中待办"),
515            ]),
516            Line::from(vec![
517                Span::styled("  d            ", Style::default().fg(Color::Yellow)),
518                Span::raw("删除待办(需确认)"),
519            ]),
520            Line::from(vec![
521                Span::styled("  f            ", Style::default().fg(Color::Yellow)),
522                Span::raw("过滤切换(全部 / 未完成 / 已完成)"),
523            ]),
524            Line::from(vec![
525                Span::styled("  J / K        ", Style::default().fg(Color::Yellow)),
526                Span::raw("调整待办顺序(下移 / 上移)"),
527            ]),
528            Line::from(vec![
529                Span::styled("  s            ", Style::default().fg(Color::Yellow)),
530                Span::raw("手动保存"),
531            ]),
532            Line::from(vec![
533                Span::styled("  y            ", Style::default().fg(Color::Yellow)),
534                Span::raw("复制选中待办到剪切板"),
535            ]),
536            Line::from(vec![
537                Span::styled("  q            ", Style::default().fg(Color::Yellow)),
538                Span::raw("退出(有未保存修改时需先保存或用 q! 强制退出)"),
539            ]),
540            Line::from(vec![
541                Span::styled("  q!           ", Style::default().fg(Color::Yellow)),
542                Span::raw("强制退出(丢弃未保存的修改)"),
543            ]),
544            Line::from(vec![
545                Span::styled("  Esc          ", Style::default().fg(Color::Yellow)),
546                Span::raw("退出(同 q)"),
547            ]),
548            Line::from(vec![
549                Span::styled("  Ctrl+C       ", Style::default().fg(Color::Yellow)),
550                Span::raw("强制退出(不保存)"),
551            ]),
552            Line::from(vec![
553                Span::styled("  ?            ", Style::default().fg(Color::Yellow)),
554                Span::raw("显示此帮助"),
555            ]),
556        ];
557        let help_block = Block::default()
558            .borders(Borders::ALL)
559            .border_style(Style::default().fg(Color::Cyan))
560            .title(" 帮助 ");
561        let help_widget = Paragraph::new(help_lines).block(help_block);
562        f.render_widget(help_widget, chunks[1]);
563    } else {
564        let indices = app.filtered_indices();
565        // 计算列表区域可用宽度(减去边框 2 + highlight_symbol " ▶ " 占 3 个字符)
566        let list_inner_width = chunks[1].width.saturating_sub(2 + 3) as usize;
567        let items: Vec<ListItem> = indices
568            .iter()
569            .map(|&idx| {
570                let item = &app.list.items[idx];
571                let checkbox = if item.done { "[x]" } else { "[ ]" };
572                let checkbox_style = if item.done {
573                    Style::default().fg(Color::Green)
574                } else {
575                    Style::default().fg(Color::Yellow)
576                };
577                let content_style = if item.done {
578                    Style::default()
579                        .fg(Color::Gray)
580                        .add_modifier(Modifier::CROSSED_OUT)
581                } else {
582                    Style::default().fg(Color::White)
583                };
584
585                // checkbox 部分: " [x] " 占 5 个显示宽度
586                let checkbox_str = format!(" {} ", checkbox);
587                let checkbox_display_width = display_width(&checkbox_str);
588
589                // 时间部分: "  (YYYY-MM-DD)" 占 14 个显示宽度
590                let date_str = item
591                    .created_at
592                    .get(..10)
593                    .map(|d| format!("  ({})", d))
594                    .unwrap_or_default();
595                let date_display_width = display_width(&date_str);
596
597                // 内容可用的最大显示宽度
598                let content_max_width = list_inner_width
599                    .saturating_sub(checkbox_display_width)
600                    .saturating_sub(date_display_width);
601
602                // 如果内容超出可用宽度则截断并加 "…"
603                let content_display = truncate_to_width(&item.content, content_max_width);
604                let content_actual_width = display_width(&content_display);
605
606                // 用空格填充内容和时间之间的间距,让时间右对齐
607                let padding_width = content_max_width.saturating_sub(content_actual_width);
608                let padding = " ".repeat(padding_width);
609
610                ListItem::new(Line::from(vec![
611                    Span::styled(checkbox_str, checkbox_style),
612                    Span::styled(content_display, content_style),
613                    Span::raw(padding),
614                    Span::styled(date_str, Style::default().fg(Color::DarkGray)),
615                ]))
616            })
617            .collect();
618
619        let list_block = Block::default()
620            .borders(Borders::ALL)
621            .border_style(Style::default().fg(Color::White))
622            .title(" 待办列表 ");
623
624        if items.is_empty() {
625            // 空列表提示
626            let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
627                "   (空) 按 a 添加新待办...",
628                Style::default().fg(Color::DarkGray),
629            )))])
630            .block(list_block);
631            f.render_widget(empty_hint, chunks[1]);
632        } else {
633            let list_widget = List::new(items)
634                .block(list_block)
635                .highlight_style(
636                    Style::default()
637                        .bg(Color::Indexed(24))
638                        .add_modifier(Modifier::BOLD),
639                )
640                .highlight_symbol(" ▶ ");
641            f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
642        };
643    }
644
645    // ========== 状态/输入栏 ==========
646    match &app.mode {
647        AppMode::Adding => {
648            let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
649            let input_widget = Paragraph::new(Line::from(vec![
650                Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
651                Span::raw(before),
652                Span::styled(
653                    cursor_ch,
654                    Style::default().fg(Color::Black).bg(Color::White),
655                ),
656                Span::raw(after),
657            ]))
658            .block(
659                Block::default()
660                    .borders(Borders::ALL)
661                    .border_style(Style::default().fg(Color::Green))
662                    .title(" 添加模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
663            );
664            f.render_widget(input_widget, chunks[2]);
665        }
666        AppMode::Editing => {
667            let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
668            let input_widget = Paragraph::new(Line::from(vec![
669                Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
670                Span::raw(before),
671                Span::styled(
672                    cursor_ch,
673                    Style::default().fg(Color::Black).bg(Color::White),
674                ),
675                Span::raw(after),
676            ]))
677            .block(
678                Block::default()
679                    .borders(Borders::ALL)
680                    .border_style(Style::default().fg(Color::Yellow))
681                    .title(" 编辑模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
682            );
683            f.render_widget(input_widget, chunks[2]);
684        }
685        AppMode::ConfirmDelete => {
686            let msg = if let Some(real_idx) = app.selected_real_index() {
687                format!(
688                    " 确认删除「{}」?(y 确认 / n 取消)",
689                    app.list.items[real_idx].content
690                )
691            } else {
692                " 没有选中的项目".to_string()
693            };
694            let confirm_widget = Paragraph::new(Line::from(Span::styled(
695                msg,
696                Style::default().fg(Color::Red),
697            )))
698            .block(
699                Block::default()
700                    .borders(Borders::ALL)
701                    .border_style(Style::default().fg(Color::Red))
702                    .title(" ⚠️ 确认删除 "),
703            );
704            f.render_widget(confirm_widget, chunks[2]);
705        }
706        AppMode::Normal | AppMode::Help => {
707            let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
708            let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
709            let status_widget = Paragraph::new(Line::from(vec![
710                Span::styled(msg, Style::default().fg(Color::Gray)),
711                Span::styled(
712                    dirty_indicator,
713                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
714                ),
715            ]))
716            .block(
717                Block::default()
718                    .borders(Borders::ALL)
719                    .border_style(Style::default().fg(Color::DarkGray)),
720            );
721            f.render_widget(status_widget, chunks[2]);
722        }
723    }
724
725    // ========== 帮助栏 ==========
726    let help_text = match app.mode {
727        AppMode::Normal => {
728            " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
729        }
730        AppMode::Adding | AppMode::Editing => {
731            " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾"
732        }
733        AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
734        AppMode::Help => " 按任意键返回",
735    };
736    let help_widget = Paragraph::new(Line::from(Span::styled(
737        help_text,
738        Style::default().fg(Color::DarkGray),
739    )));
740    f.render_widget(help_widget, chunks[3]);
741}
742
743/// 正常模式按键处理,返回 true 表示退出
744fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
745    // Ctrl+C 强制退出(不保存)
746    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
747        return true;
748    }
749
750    match key.code {
751        // 退出:有未保存修改时拒绝,提示用 q! 或先保存
752        KeyCode::Char('q') => {
753            if app.is_dirty() {
754                app.message = Some(
755                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
756                );
757                app.quit_input = "q".to_string();
758                return false;
759            }
760            return true;
761        }
762        KeyCode::Esc => {
763            if app.is_dirty() {
764                app.message = Some(
765                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
766                );
767                return false;
768            }
769            return true;
770        }
771
772        // q! 强制退出(丢弃修改):通过 ! 键判断前一个输入是否为 q
773        KeyCode::Char('!') => {
774            if app.quit_input == "q" {
775                return true; // q! 强制退出
776            }
777            app.quit_input.clear();
778        }
779
780        // 向下移动
781        KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
782
783        // 向上移动
784        KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
785
786        // 切换完成状态
787        KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
788
789        // 添加
790        KeyCode::Char('a') => {
791            app.mode = AppMode::Adding;
792            app.input.clear();
793            app.cursor_pos = 0;
794            app.message = None;
795        }
796
797        // 编辑
798        KeyCode::Char('e') => {
799            if let Some(real_idx) = app.selected_real_index() {
800                app.input = app.list.items[real_idx].content.clone();
801                app.cursor_pos = app.input.chars().count();
802                app.edit_index = Some(real_idx);
803                app.mode = AppMode::Editing;
804                app.message = None;
805            }
806        }
807
808        // 复制选中待办到剪切板
809        KeyCode::Char('y') => {
810            if let Some(real_idx) = app.selected_real_index() {
811                let content = app.list.items[real_idx].content.clone();
812                if copy_to_clipboard(&content) {
813                    app.message = Some(format!("📋 已复制到剪切板: {}", content));
814                } else {
815                    app.message = Some("❌ 复制到剪切板失败".to_string());
816                }
817            }
818        }
819
820        // 删除(需确认)
821        KeyCode::Char('d') => {
822            if app.selected_real_index().is_some() {
823                app.mode = AppMode::ConfirmDelete;
824            }
825        }
826
827        // 过滤切换
828        KeyCode::Char('f') => app.toggle_filter(),
829
830        // 保存
831        KeyCode::Char('s') => app.save(),
832
833        // 调整顺序: Shift+↑ 上移 / Shift+↓ 下移
834        KeyCode::Char('K') => app.move_item_up(),
835        KeyCode::Char('J') => app.move_item_down(),
836
837        // 查看帮助
838        KeyCode::Char('?') => {
839            app.mode = AppMode::Help;
840        }
841
842        _ => {}
843    }
844
845    // 非 q 键时清空 quit_input 缓冲
846    if key.code != KeyCode::Char('q') && key.code != KeyCode::Char('!') {
847        app.quit_input.clear();
848    }
849
850    false
851}
852
853/// 输入模式按键处理(添加/编辑通用,支持光标移动和行内编辑)
854fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
855    let char_count = app.input.chars().count();
856
857    match key.code {
858        KeyCode::Enter => {
859            if app.mode == AppMode::Adding {
860                app.add_item();
861            } else {
862                app.confirm_edit();
863            }
864        }
865        KeyCode::Esc => {
866            app.mode = AppMode::Normal;
867            app.input.clear();
868            app.cursor_pos = 0;
869            app.edit_index = None;
870            app.message = Some("已取消".to_string());
871        }
872        KeyCode::Left => {
873            if app.cursor_pos > 0 {
874                app.cursor_pos -= 1;
875            }
876        }
877        KeyCode::Right => {
878            if app.cursor_pos < char_count {
879                app.cursor_pos += 1;
880            }
881        }
882        KeyCode::Home => {
883            app.cursor_pos = 0;
884        }
885        KeyCode::End => {
886            app.cursor_pos = char_count;
887        }
888        KeyCode::Backspace => {
889            if app.cursor_pos > 0 {
890                // 找到第 cursor_pos-1 和 cursor_pos 个字符的字节偏移,删除该范围
891                let start = app
892                    .input
893                    .char_indices()
894                    .nth(app.cursor_pos - 1)
895                    .map(|(i, _)| i)
896                    .unwrap_or(0);
897                let end = app
898                    .input
899                    .char_indices()
900                    .nth(app.cursor_pos)
901                    .map(|(i, _)| i)
902                    .unwrap_or(app.input.len());
903                app.input.drain(start..end);
904                app.cursor_pos -= 1;
905            }
906        }
907        KeyCode::Delete => {
908            if app.cursor_pos < char_count {
909                let start = app
910                    .input
911                    .char_indices()
912                    .nth(app.cursor_pos)
913                    .map(|(i, _)| i)
914                    .unwrap_or(app.input.len());
915                let end = app
916                    .input
917                    .char_indices()
918                    .nth(app.cursor_pos + 1)
919                    .map(|(i, _)| i)
920                    .unwrap_or(app.input.len());
921                app.input.drain(start..end);
922            }
923        }
924        KeyCode::Char(c) => {
925            // 在光标位置插入字符(支持多字节字符)
926            let byte_idx = app
927                .input
928                .char_indices()
929                .nth(app.cursor_pos)
930                .map(|(i, _)| i)
931                .unwrap_or(app.input.len());
932            app.input.insert_str(byte_idx, &c.to_string());
933            app.cursor_pos += 1;
934        }
935        _ => {}
936    }
937}
938
939/// 确认删除按键处理
940fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
941    match key.code {
942        KeyCode::Char('y') | KeyCode::Char('Y') => {
943            app.delete_selected();
944        }
945        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
946            app.mode = AppMode::Normal;
947            app.message = Some("已取消删除".to_string());
948        }
949        _ => {}
950    }
951}
952
953/// 帮助模式按键处理(按任意键返回)
954fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
955    app.mode = AppMode::Normal;
956    app.message = None;
957}
958
959/// 将输入字符串按光标位置分割为三部分:光标前、光标处字符、光标后
960fn split_input_at_cursor(input: &str, cursor_pos: usize) -> (String, String, String) {
961    let chars: Vec<char> = input.chars().collect();
962    let before: String = chars[..cursor_pos].iter().collect();
963    let cursor_ch = if cursor_pos < chars.len() {
964        chars[cursor_pos].to_string()
965    } else {
966        " ".to_string() // 光标在末尾时显示空格块
967    };
968    let after: String = if cursor_pos < chars.len() {
969        chars[cursor_pos + 1..].iter().collect()
970    } else {
971        String::new()
972    };
973    (before, cursor_ch, after)
974}
975
976/// 计算字符串的显示宽度(中文/全角字符占 2 列,ASCII 占 1 列)
977fn display_width(s: &str) -> usize {
978    s.chars()
979        .map(|c| {
980            if c.is_ascii() {
981                1
982            } else {
983                // CJK 字符和全角字符占 2 列
984                2
985            }
986        })
987        .sum()
988}
989
990/// 将字符串截断到指定的显示宽度,超出部分用 ".." 替代
991fn truncate_to_width(s: &str, max_width: usize) -> String {
992    if max_width == 0 {
993        return String::new();
994    }
995
996    let total_width = display_width(s);
997    if total_width <= max_width {
998        return s.to_string();
999    }
1000
1001    // 需要截断,预留 2 列给 ".."
1002    let ellipsis = "..";
1003    let ellipsis_width = 2;
1004    let content_budget = max_width.saturating_sub(ellipsis_width);
1005
1006    let mut width = 0;
1007    let mut result = String::new();
1008    for ch in s.chars() {
1009        let ch_width = if ch.is_ascii() { 1 } else { 2 };
1010        if width + ch_width > content_budget {
1011            break;
1012        }
1013        width += ch_width;
1014        result.push(ch);
1015    }
1016    result.push_str(ellipsis);
1017    result
1018}
1019
1020/// 复制内容到系统剪切板(macOS 使用 pbcopy,Linux 使用 xclip)
1021fn copy_to_clipboard(content: &str) -> bool {
1022    use std::io::Write;
1023    use std::process::{Command, Stdio};
1024
1025    // 根据平台选择剪切板命令
1026    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
1027        ("pbcopy", vec![])
1028    } else if cfg!(target_os = "linux") {
1029        // 优先尝试 xclip,其次 xsel
1030        if Command::new("which")
1031            .arg("xclip")
1032            .output()
1033            .map(|o| o.status.success())
1034            .unwrap_or(false)
1035        {
1036            ("xclip", vec!["-selection", "clipboard"])
1037        } else {
1038            ("xsel", vec!["--clipboard", "--input"])
1039        }
1040    } else {
1041        return false; // 不支持的平台
1042    };
1043
1044    let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
1045
1046    match child {
1047        Ok(mut child) => {
1048            if let Some(ref mut stdin) = child.stdin {
1049                let _ = stdin.write_all(content.as_bytes());
1050            }
1051            child.wait().map(|s| s.success()).unwrap_or(false)
1052        }
1053        Err(_) => false,
1054    }
1055}