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)]
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)]
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    state: ListState,
142    /// 当前模式
143    mode: AppMode,
144    /// 输入缓冲区(添加/编辑模式使用)
145    input: String,
146    /// 编辑时记录的原始索引
147    edit_index: Option<usize>,
148    /// 是否有未保存的修改
149    dirty: bool,
150    /// 状态栏消息
151    message: Option<String>,
152    /// 过滤模式: 0=全部, 1=未完成, 2=已完成
153    filter: usize,
154}
155
156#[derive(PartialEq)]
157enum AppMode {
158    /// 正常浏览模式
159    Normal,
160    /// 输入添加模式
161    Adding,
162    /// 编辑模式
163    Editing,
164    /// 确认删除
165    ConfirmDelete,
166}
167
168impl TodoApp {
169    fn new() -> Self {
170        let list = load_todo_list();
171        let mut state = ListState::default();
172        if !list.items.is_empty() {
173            state.select(Some(0));
174        }
175        Self {
176            list,
177            state,
178            mode: AppMode::Normal,
179            input: String::new(),
180            edit_index: None,
181            dirty: false,
182            message: None,
183            filter: 0,
184        }
185    }
186
187    /// 获取当前过滤后的索引列表(映射到 list.items 的真实索引)
188    fn filtered_indices(&self) -> Vec<usize> {
189        self.list
190            .items
191            .iter()
192            .enumerate()
193            .filter(|(_, item)| match self.filter {
194                1 => !item.done,
195                2 => item.done,
196                _ => true,
197            })
198            .map(|(i, _)| i)
199            .collect()
200    }
201
202    /// 获取当前选中项在原始列表中的真实索引
203    fn selected_real_index(&self) -> Option<usize> {
204        let indices = self.filtered_indices();
205        self.state
206            .selected()
207            .and_then(|sel| indices.get(sel).copied())
208    }
209
210    /// 向下移动
211    fn move_down(&mut self) {
212        let count = self.filtered_indices().len();
213        if count == 0 {
214            return;
215        }
216        let i = match self.state.selected() {
217            Some(i) => {
218                if i >= count - 1 {
219                    0
220                } else {
221                    i + 1
222                }
223            }
224            None => 0,
225        };
226        self.state.select(Some(i));
227    }
228
229    /// 向上移动
230    fn move_up(&mut self) {
231        let count = self.filtered_indices().len();
232        if count == 0 {
233            return;
234        }
235        let i = match self.state.selected() {
236            Some(i) => {
237                if i == 0 {
238                    count - 1
239                } else {
240                    i - 1
241                }
242            }
243            None => 0,
244        };
245        self.state.select(Some(i));
246    }
247
248    /// 切换当前选中项的完成状态
249    fn toggle_done(&mut self) {
250        if let Some(real_idx) = self.selected_real_index() {
251            let item = &mut self.list.items[real_idx];
252            item.done = !item.done;
253            if item.done {
254                item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
255                self.message = Some("✅ 已标记为完成".to_string());
256            } else {
257                item.done_at = None;
258                self.message = Some("⬜ 已标记为未完成".to_string());
259            }
260            self.dirty = true;
261        }
262    }
263
264    /// 添加新待办
265    fn add_item(&mut self) {
266        let text = self.input.trim().to_string();
267        if text.is_empty() {
268            self.message = Some("⚠️ 内容为空,已取消".to_string());
269            self.mode = AppMode::Normal;
270            self.input.clear();
271            return;
272        }
273        self.list.items.push(TodoItem {
274            content: text,
275            done: false,
276            created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
277            done_at: None,
278        });
279        self.dirty = true;
280        self.input.clear();
281        self.mode = AppMode::Normal;
282        // 选中新添加的项
283        let count = self.filtered_indices().len();
284        if count > 0 {
285            self.state.select(Some(count - 1));
286        }
287        self.message = Some("✅ 已添加新待办".to_string());
288    }
289
290    /// 确认编辑
291    fn confirm_edit(&mut self) {
292        let text = self.input.trim().to_string();
293        if text.is_empty() {
294            self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
295            self.mode = AppMode::Normal;
296            self.input.clear();
297            self.edit_index = None;
298            return;
299        }
300        if let Some(idx) = self.edit_index {
301            if idx < self.list.items.len() {
302                self.list.items[idx].content = text;
303                self.dirty = true;
304                self.message = Some("✅ 已更新待办内容".to_string());
305            }
306        }
307        self.input.clear();
308        self.edit_index = None;
309        self.mode = AppMode::Normal;
310    }
311
312    /// 删除当前选中项
313    fn delete_selected(&mut self) {
314        if let Some(real_idx) = self.selected_real_index() {
315            let removed = self.list.items.remove(real_idx);
316            self.dirty = true;
317            self.message = Some(format!("🗑️ 已删除: {}", removed.content));
318            // 调整选中位置
319            let count = self.filtered_indices().len();
320            if count == 0 {
321                self.state.select(None);
322            } else if let Some(sel) = self.state.selected() {
323                if sel >= count {
324                    self.state.select(Some(count - 1));
325                }
326            }
327        }
328        self.mode = AppMode::Normal;
329    }
330
331    /// 移动选中项向上(调整顺序)
332    fn move_item_up(&mut self) {
333        if let Some(real_idx) = self.selected_real_index() {
334            if real_idx > 0 {
335                self.list.items.swap(real_idx, real_idx - 1);
336                self.dirty = true;
337                self.move_up();
338            }
339        }
340    }
341
342    /// 移动选中项向下(调整顺序)
343    fn move_item_down(&mut self) {
344        if let Some(real_idx) = self.selected_real_index() {
345            if real_idx < self.list.items.len() - 1 {
346                self.list.items.swap(real_idx, real_idx + 1);
347                self.dirty = true;
348                self.move_down();
349            }
350        }
351    }
352
353    /// 切换过滤模式
354    fn toggle_filter(&mut self) {
355        self.filter = (self.filter + 1) % 3;
356        let count = self.filtered_indices().len();
357        if count > 0 {
358            self.state.select(Some(0));
359        } else {
360            self.state.select(None);
361        }
362        let label = match self.filter {
363            1 => "未完成",
364            2 => "已完成",
365            _ => "全部",
366        };
367        self.message = Some(format!("🔍 过滤: {}", label));
368    }
369
370    /// 保存数据
371    fn save(&mut self) {
372        if self.dirty {
373            if save_todo_list(&self.list) {
374                self.dirty = false;
375                self.message = Some("💾 已保存".to_string());
376            }
377        }
378    }
379}
380
381/// 启动 TUI 待办管理界面
382fn run_todo_tui() {
383    match run_todo_tui_internal() {
384        Ok(_) => {}
385        Err(e) => {
386            error!("❌ TUI 启动失败: {}", e);
387        }
388    }
389}
390
391fn run_todo_tui_internal() -> io::Result<()> {
392    // 进入终端原始模式
393    terminal::enable_raw_mode()?;
394    let mut stdout = io::stdout();
395    execute!(stdout, EnterAlternateScreen)?;
396
397    let backend = CrosstermBackend::new(stdout);
398    let mut terminal = Terminal::new(backend)?;
399
400    let mut app = TodoApp::new();
401
402    loop {
403        // 渲染界面
404        terminal.draw(|f| draw_ui(f, &mut app))?;
405
406        // 处理输入事件
407        if event::poll(std::time::Duration::from_millis(100))? {
408            if let Event::Key(key) = event::read()? {
409                match app.mode {
410                    AppMode::Normal => {
411                        if handle_normal_mode(&mut app, key) {
412                            break;
413                        }
414                    }
415                    AppMode::Adding => handle_input_mode(&mut app, key),
416                    AppMode::Editing => handle_input_mode(&mut app, key),
417                    AppMode::ConfirmDelete => handle_confirm_delete(&mut app, key),
418                }
419            }
420        }
421    }
422
423    // 退出前自动保存
424    if app.dirty {
425        save_todo_list(&app.list);
426    }
427
428    // 恢复终端
429    terminal::disable_raw_mode()?;
430    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
431
432    Ok(())
433}
434
435/// 绘制 TUI 界面
436fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
437    let size = f.area();
438
439    // 整体布局: 标题栏 + 列表区 + 状态栏 + 帮助栏
440    let chunks = Layout::default()
441        .direction(Direction::Vertical)
442        .constraints([
443            Constraint::Length(3), // 标题栏
444            Constraint::Min(5),    // 列表区
445            Constraint::Length(3), // 状态/输入栏
446            Constraint::Length(2), // 帮助栏
447        ])
448        .split(size);
449
450    // ========== 标题栏 ==========
451    let filter_label = match app.filter {
452        1 => " [未完成]",
453        2 => " [已完成]",
454        _ => "",
455    };
456    let total = app.list.items.len();
457    let done = app.list.items.iter().filter(|i| i.done).count();
458    let undone = total - done;
459    let title = format!(
460        " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
461        filter_label, total, done, undone
462    );
463    let title_block = Paragraph::new(Line::from(vec![Span::styled(
464        title,
465        Style::default()
466            .fg(Color::Cyan)
467            .add_modifier(Modifier::BOLD),
468    )]))
469    .block(
470        Block::default()
471            .borders(Borders::ALL)
472            .border_style(Style::default().fg(Color::Cyan)),
473    );
474    f.render_widget(title_block, chunks[0]);
475
476    // ========== 列表区 ==========
477    let indices = app.filtered_indices();
478    let items: Vec<ListItem> = indices
479        .iter()
480        .map(|&idx| {
481            let item = &app.list.items[idx];
482            let checkbox = if item.done { "[x]" } else { "[ ]" };
483            let style = if item.done {
484                Style::default()
485                    .fg(Color::DarkGray)
486                    .add_modifier(Modifier::CROSSED_OUT)
487            } else {
488                Style::default().fg(Color::White)
489            };
490
491            let mut spans = vec![
492                Span::styled(
493                    format!(" {} ", checkbox),
494                    if item.done {
495                        Style::default().fg(Color::Green)
496                    } else {
497                        Style::default().fg(Color::Yellow)
498                    },
499                ),
500                Span::styled(&item.content, style),
501            ];
502
503            // 显示创建时间(缩短格式)
504            if let Some(short_date) = item.created_at.get(..10) {
505                spans.push(Span::styled(
506                    format!("  ({})", short_date),
507                    Style::default().fg(Color::DarkGray),
508                ));
509            }
510
511            ListItem::new(Line::from(spans))
512        })
513        .collect();
514
515    let list_block = Block::default()
516        .borders(Borders::ALL)
517        .border_style(Style::default().fg(Color::White))
518        .title(" 待办列表 ");
519
520    if items.is_empty() {
521        // 空列表提示
522        let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
523            "   (空) 按 a 添加新待办...",
524            Style::default().fg(Color::DarkGray),
525        )))])
526        .block(list_block);
527        f.render_widget(empty_hint, chunks[1]);
528    } else {
529        let list_widget = List::new(items)
530            .block(list_block)
531            .highlight_style(
532                Style::default()
533                    .bg(Color::DarkGray)
534                    .add_modifier(Modifier::BOLD),
535            )
536            .highlight_symbol("▶ ");
537        f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
538    };
539
540    // ========== 状态/输入栏 ==========
541    match &app.mode {
542        AppMode::Adding => {
543            let input_widget = Paragraph::new(Line::from(vec![
544                Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
545                Span::raw(&app.input),
546                Span::styled("█", Style::default().fg(Color::White)),
547            ]))
548            .block(
549                Block::default()
550                    .borders(Borders::ALL)
551                    .border_style(Style::default().fg(Color::Green))
552                    .title(" 添加模式 (Enter 确认 / Esc 取消) "),
553            );
554            f.render_widget(input_widget, chunks[2]);
555        }
556        AppMode::Editing => {
557            let input_widget = Paragraph::new(Line::from(vec![
558                Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
559                Span::raw(&app.input),
560                Span::styled("█", Style::default().fg(Color::White)),
561            ]))
562            .block(
563                Block::default()
564                    .borders(Borders::ALL)
565                    .border_style(Style::default().fg(Color::Yellow))
566                    .title(" 编辑模式 (Enter 确认 / Esc 取消) "),
567            );
568            f.render_widget(input_widget, chunks[2]);
569        }
570        AppMode::ConfirmDelete => {
571            let msg = if let Some(real_idx) = app.selected_real_index() {
572                format!(
573                    " 确认删除「{}」?(y 确认 / n 取消)",
574                    app.list.items[real_idx].content
575                )
576            } else {
577                " 没有选中的项目".to_string()
578            };
579            let confirm_widget = Paragraph::new(Line::from(Span::styled(
580                msg,
581                Style::default().fg(Color::Red),
582            )))
583            .block(
584                Block::default()
585                    .borders(Borders::ALL)
586                    .border_style(Style::default().fg(Color::Red))
587                    .title(" ⚠️ 确认删除 "),
588            );
589            f.render_widget(confirm_widget, chunks[2]);
590        }
591        AppMode::Normal => {
592            let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
593            let dirty_indicator = if app.dirty { " [未保存]" } else { "" };
594            let status_widget = Paragraph::new(Line::from(vec![
595                Span::styled(msg, Style::default().fg(Color::Gray)),
596                Span::styled(
597                    dirty_indicator,
598                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
599                ),
600            ]))
601            .block(
602                Block::default()
603                    .borders(Borders::ALL)
604                    .border_style(Style::default().fg(Color::DarkGray)),
605            );
606            f.render_widget(status_widget, chunks[2]);
607        }
608    }
609
610    // ========== 帮助栏 ==========
611    let help_text = match app.mode {
612        AppMode::Normal => {
613            " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | f 过滤 | s 保存 | q/Esc 退出"
614        }
615        AppMode::Adding | AppMode::Editing => " Enter 确认 | Esc 取消",
616        AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
617    };
618    let help_widget = Paragraph::new(Line::from(Span::styled(
619        help_text,
620        Style::default().fg(Color::DarkGray),
621    )));
622    f.render_widget(help_widget, chunks[3]);
623}
624
625/// 正常模式按键处理,返回 true 表示退出
626fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
627    // Ctrl+C 强制退出
628    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
629        return true;
630    }
631
632    match key.code {
633        // 退出
634        KeyCode::Char('q') | KeyCode::Esc => return true,
635
636        // 向下移动
637        KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
638
639        // 向上移动
640        KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
641
642        // 切换完成状态
643        KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
644
645        // 添加
646        KeyCode::Char('a') => {
647            app.mode = AppMode::Adding;
648            app.input.clear();
649            app.message = None;
650        }
651
652        // 编辑
653        KeyCode::Char('e') => {
654            if let Some(real_idx) = app.selected_real_index() {
655                app.input = app.list.items[real_idx].content.clone();
656                app.edit_index = Some(real_idx);
657                app.mode = AppMode::Editing;
658                app.message = None;
659            }
660        }
661
662        // 删除(需确认)
663        KeyCode::Char('d') => {
664            if app.selected_real_index().is_some() {
665                app.mode = AppMode::ConfirmDelete;
666            }
667        }
668
669        // 过滤切换
670        KeyCode::Char('f') => app.toggle_filter(),
671
672        // 保存
673        KeyCode::Char('s') => app.save(),
674
675        // 调整顺序: Shift+↑ 上移 / Shift+↓ 下移
676        KeyCode::Char('K') => app.move_item_up(),
677        KeyCode::Char('J') => app.move_item_down(),
678
679        _ => {}
680    }
681
682    false
683}
684
685/// 输入模式按键处理(添加/编辑通用)
686fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
687    match key.code {
688        KeyCode::Enter => {
689            if app.mode == AppMode::Adding {
690                app.add_item();
691            } else {
692                app.confirm_edit();
693            }
694        }
695        KeyCode::Esc => {
696            app.mode = AppMode::Normal;
697            app.input.clear();
698            app.edit_index = None;
699            app.message = Some("已取消".to_string());
700        }
701        KeyCode::Backspace => {
702            app.input.pop();
703        }
704        KeyCode::Char(c) => {
705            app.input.push(c);
706        }
707        _ => {}
708    }
709}
710
711/// 确认删除按键处理
712fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
713    match key.code {
714        KeyCode::Char('y') | KeyCode::Char('Y') => {
715            app.delete_selected();
716        }
717        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
718            app.mode = AppMode::Normal;
719            app.message = Some("已取消删除".to_string());
720        }
721        _ => {}
722    }
723}