Skip to main content

j_cli/command/todo/
app.rs

1use crate::command::report;
2use crate::config::YamlConfig;
3use crate::error;
4use chrono::Local;
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use ratatui::widgets::ListState;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11// ========== 数据结构 ==========
12
13/// 单条待办事项
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct TodoItem {
16    /// 待办内容
17    pub content: String,
18    /// 是否已完成
19    pub done: bool,
20    /// 创建时间
21    pub created_at: String,
22    /// 完成时间(可选)
23    pub done_at: Option<String>,
24}
25
26/// 待办列表(序列化到 JSON)
27#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
28pub struct TodoList {
29    pub items: Vec<TodoItem>,
30}
31
32// ========== 文件路径 ==========
33
34/// 获取 todo 数据目录: ~/.jdata/todo/
35pub fn todo_dir() -> PathBuf {
36    let dir = YamlConfig::data_dir().join("todo");
37    let _ = fs::create_dir_all(&dir);
38    dir
39}
40
41/// 获取 todo 数据文件路径: ~/.jdata/todo/todo.json
42pub fn todo_file_path() -> PathBuf {
43    todo_dir().join("todo.json")
44}
45
46// ========== 数据读写 ==========
47
48/// 从文件加载待办列表
49pub fn load_todo_list() -> TodoList {
50    let path = todo_file_path();
51    if !path.exists() {
52        return TodoList::default();
53    }
54    match fs::read_to_string(&path) {
55        Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
56            error!("❌ 解析 todo.json 失败: {}", e);
57            TodoList::default()
58        }),
59        Err(e) => {
60            error!("❌ 读取 todo.json 失败: {}", e);
61            TodoList::default()
62        }
63    }
64}
65
66/// 保存待办列表到文件
67pub fn save_todo_list(list: &TodoList) -> bool {
68    let path = todo_file_path();
69    if let Some(parent) = path.parent() {
70        let _ = fs::create_dir_all(parent);
71    }
72    match serde_json::to_string_pretty(list) {
73        Ok(json) => match fs::write(&path, json) {
74            Ok(_) => true,
75            Err(e) => {
76                error!("❌ 保存 todo.json 失败: {}", e);
77                false
78            }
79        },
80        Err(e) => {
81            error!("❌ 序列化 todo 列表失败: {}", e);
82            false
83        }
84    }
85}
86
87// ========== TUI 应用状态 ==========
88
89/// TUI 应用状态
90pub struct TodoApp {
91    /// 待办列表数据
92    pub list: TodoList,
93    /// 加载时的快照(用于对比是否真正有修改)
94    pub snapshot: TodoList,
95    /// 列表选中状态
96    pub state: ListState,
97    /// 当前模式
98    pub mode: AppMode,
99    /// 输入缓冲区(添加/编辑模式使用)
100    pub input: String,
101    /// 编辑时记录的原始索引
102    pub edit_index: Option<usize>,
103    /// 状态栏消息
104    pub message: Option<String>,
105    /// 过滤模式: 0=全部, 1=未完成, 2=已完成
106    pub filter: usize,
107    /// 强制退出输入缓冲(用于 q! 退出)
108    pub quit_input: String,
109    /// 输入模式下的光标位置(字符索引)
110    pub cursor_pos: usize,
111    /// 预览区滚动偏移
112    pub preview_scroll: u16,
113    /// 待写入日报的内容(toggle done 后暂存)
114    pub report_pending_content: Option<String>,
115}
116
117#[derive(PartialEq)]
118pub enum AppMode {
119    /// 正常浏览模式
120    Normal,
121    /// 输入添加模式
122    Adding,
123    /// 编辑模式
124    Editing,
125    /// 确认删除
126    ConfirmDelete,
127    /// 确认写入日报
128    ConfirmReport,
129    /// 显示帮助
130    Help,
131}
132
133impl TodoApp {
134    pub fn new() -> Self {
135        let list = load_todo_list();
136        let snapshot = list.clone();
137        let mut state = ListState::default();
138        if !list.items.is_empty() {
139            state.select(Some(0));
140        }
141        Self {
142            list,
143            snapshot,
144            state,
145            mode: AppMode::Normal,
146            input: String::new(),
147            edit_index: None,
148            message: None,
149            filter: 0,
150            quit_input: String::new(),
151            cursor_pos: 0,
152            preview_scroll: 0,
153            report_pending_content: None,
154        }
155    }
156
157    /// 通过对比快照判断是否有未保存的修改
158    pub fn is_dirty(&self) -> bool {
159        self.list != self.snapshot
160    }
161
162    /// 获取当前过滤后的索引列表(映射到 list.items 的真实索引)
163    pub fn filtered_indices(&self) -> Vec<usize> {
164        self.list
165            .items
166            .iter()
167            .enumerate()
168            .filter(|(_, item)| match self.filter {
169                1 => !item.done,
170                2 => item.done,
171                _ => true,
172            })
173            .map(|(i, _)| i)
174            .collect()
175    }
176
177    /// 获取当前选中项在原始列表中的真实索引
178    pub fn selected_real_index(&self) -> Option<usize> {
179        let indices = self.filtered_indices();
180        self.state
181            .selected()
182            .and_then(|sel| indices.get(sel).copied())
183    }
184
185    /// 向下移动
186    pub fn move_down(&mut self) {
187        let count = self.filtered_indices().len();
188        if count == 0 {
189            return;
190        }
191        let i = match self.state.selected() {
192            Some(i) => {
193                if i >= count - 1 {
194                    0
195                } else {
196                    i + 1
197                }
198            }
199            None => 0,
200        };
201        self.state.select(Some(i));
202    }
203
204    /// 向上移动
205    pub fn move_up(&mut self) {
206        let count = self.filtered_indices().len();
207        if count == 0 {
208            return;
209        }
210        let i = match self.state.selected() {
211            Some(i) => {
212                if i == 0 {
213                    count - 1
214                } else {
215                    i - 1
216                }
217            }
218            None => 0,
219        };
220        self.state.select(Some(i));
221    }
222
223    /// 切换当前选中项的完成状态
224    pub fn toggle_done(&mut self) {
225        if let Some(real_idx) = self.selected_real_index() {
226            let item = &mut self.list.items[real_idx];
227            item.done = !item.done;
228            if item.done {
229                item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
230                // 暂存内容,进入确认写入日报模式
231                self.report_pending_content = Some(item.content.clone());
232                self.mode = AppMode::ConfirmReport;
233            } else {
234                item.done_at = None;
235                self.message = Some("⬜ 已标记为未完成".to_string());
236            }
237        }
238    }
239
240    /// 添加新待办
241    pub fn add_item(&mut self) {
242        let text = self.input.trim().to_string();
243        if text.is_empty() {
244            self.message = Some("⚠️ 内容为空,已取消".to_string());
245            self.mode = AppMode::Normal;
246            self.input.clear();
247            return;
248        }
249        self.list.items.push(TodoItem {
250            content: text,
251            done: false,
252            created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
253            done_at: None,
254        });
255        self.input.clear();
256        self.mode = AppMode::Normal;
257        let count = self.filtered_indices().len();
258        if count > 0 {
259            self.state.select(Some(count - 1));
260        }
261        self.message = Some("✅ 已添加新待办".to_string());
262    }
263
264    /// 确认编辑
265    pub fn confirm_edit(&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            self.edit_index = None;
272            return;
273        }
274        if let Some(idx) = self.edit_index {
275            if idx < self.list.items.len() {
276                self.list.items[idx].content = text;
277                self.message = Some("✅ 已更新待办内容".to_string());
278            }
279        }
280        self.input.clear();
281        self.edit_index = None;
282        self.mode = AppMode::Normal;
283    }
284
285    /// 删除当前选中项
286    pub fn delete_selected(&mut self) {
287        if let Some(real_idx) = self.selected_real_index() {
288            let removed = self.list.items.remove(real_idx);
289            self.message = Some(format!("🗑️ 已删除: {}", removed.content));
290            let count = self.filtered_indices().len();
291            if count == 0 {
292                self.state.select(None);
293            } else if let Some(sel) = self.state.selected() {
294                if sel >= count {
295                    self.state.select(Some(count - 1));
296                }
297            }
298        }
299        self.mode = AppMode::Normal;
300    }
301
302    /// 移动选中项向上(调整顺序)
303    pub fn move_item_up(&mut self) {
304        if let Some(real_idx) = self.selected_real_index() {
305            if real_idx > 0 {
306                self.list.items.swap(real_idx, real_idx - 1);
307                self.move_up();
308            }
309        }
310    }
311
312    /// 移动选中项向下(调整顺序)
313    pub fn move_item_down(&mut self) {
314        if let Some(real_idx) = self.selected_real_index() {
315            if real_idx < self.list.items.len() - 1 {
316                self.list.items.swap(real_idx, real_idx + 1);
317                self.move_down();
318            }
319        }
320    }
321
322    /// 切换过滤模式
323    pub fn toggle_filter(&mut self) {
324        self.filter = (self.filter + 1) % 3;
325        let count = self.filtered_indices().len();
326        if count > 0 {
327            self.state.select(Some(0));
328        } else {
329            self.state.select(None);
330        }
331        let label = match self.filter {
332            1 => "未完成",
333            2 => "已完成",
334            _ => "全部",
335        };
336        self.message = Some(format!("🔍 过滤: {}", label));
337    }
338
339    /// 保存数据
340    pub fn save(&mut self) {
341        if self.is_dirty() {
342            if save_todo_list(&self.list) {
343                self.snapshot = self.list.clone();
344                self.message = Some("💾 已保存".to_string());
345            }
346        } else {
347            self.message = Some("📋 无需保存,没有修改".to_string());
348        }
349    }
350}
351
352// ========== 按键处理 ==========
353
354/// 正常模式按键处理,返回 true 表示退出
355pub fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
356    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
357        return true;
358    }
359
360    match key.code {
361        KeyCode::Char('q') => {
362            if app.is_dirty() {
363                app.message = Some(
364                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
365                );
366                app.quit_input = "q".to_string();
367                return false;
368            }
369            return true;
370        }
371        KeyCode::Esc => {
372            if app.is_dirty() {
373                app.message = Some(
374                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
375                );
376                return false;
377            }
378            return true;
379        }
380        KeyCode::Char('!') => {
381            if app.quit_input == "q" {
382                return true;
383            }
384            app.quit_input.clear();
385        }
386        KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
387        KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
388        KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
389        KeyCode::Char('a') => {
390            app.mode = AppMode::Adding;
391            app.input.clear();
392            app.cursor_pos = 0;
393            app.message = None;
394        }
395        KeyCode::Char('e') => {
396            if let Some(real_idx) = app.selected_real_index() {
397                app.input = app.list.items[real_idx].content.clone();
398                app.cursor_pos = app.input.chars().count();
399                app.edit_index = Some(real_idx);
400                app.mode = AppMode::Editing;
401                app.message = None;
402            }
403        }
404        KeyCode::Char('y') => {
405            if let Some(real_idx) = app.selected_real_index() {
406                let content = app.list.items[real_idx].content.clone();
407                if copy_to_clipboard(&content) {
408                    app.message = Some(format!("📋 已复制到剪切板: {}", content));
409                } else {
410                    app.message = Some("❌ 复制到剪切板失败".to_string());
411                }
412            }
413        }
414        KeyCode::Char('d') => {
415            if app.selected_real_index().is_some() {
416                app.mode = AppMode::ConfirmDelete;
417            }
418        }
419        KeyCode::Char('f') => app.toggle_filter(),
420        KeyCode::Char('s') => app.save(),
421        KeyCode::Char('K') => app.move_item_up(),
422        KeyCode::Char('J') => app.move_item_down(),
423        KeyCode::Char('?') => {
424            app.mode = AppMode::Help;
425        }
426        _ => {}
427    }
428
429    if key.code != KeyCode::Char('q') && key.code != KeyCode::Char('!') {
430        app.quit_input.clear();
431    }
432
433    false
434}
435
436/// 输入模式按键处理(添加/编辑通用,支持光标移动和行内编辑)
437pub fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
438    let char_count = app.input.chars().count();
439
440    match key.code {
441        KeyCode::Enter => {
442            if app.mode == AppMode::Adding {
443                app.add_item();
444            } else {
445                app.confirm_edit();
446            }
447        }
448        KeyCode::Esc => {
449            app.mode = AppMode::Normal;
450            app.input.clear();
451            app.cursor_pos = 0;
452            app.edit_index = None;
453            app.message = Some("已取消".to_string());
454        }
455        KeyCode::Left => {
456            if app.cursor_pos > 0 {
457                app.cursor_pos -= 1;
458            }
459        }
460        KeyCode::Right => {
461            if app.cursor_pos < char_count {
462                app.cursor_pos += 1;
463            }
464        }
465        KeyCode::Home => {
466            app.cursor_pos = 0;
467        }
468        KeyCode::End => {
469            app.cursor_pos = char_count;
470        }
471        KeyCode::Backspace => {
472            if app.cursor_pos > 0 {
473                let start = app
474                    .input
475                    .char_indices()
476                    .nth(app.cursor_pos - 1)
477                    .map(|(i, _)| i)
478                    .unwrap_or(0);
479                let end = app
480                    .input
481                    .char_indices()
482                    .nth(app.cursor_pos)
483                    .map(|(i, _)| i)
484                    .unwrap_or(app.input.len());
485                app.input.drain(start..end);
486                app.cursor_pos -= 1;
487            }
488        }
489        KeyCode::Delete => {
490            if app.cursor_pos < char_count {
491                let start = app
492                    .input
493                    .char_indices()
494                    .nth(app.cursor_pos)
495                    .map(|(i, _)| i)
496                    .unwrap_or(app.input.len());
497                let end = app
498                    .input
499                    .char_indices()
500                    .nth(app.cursor_pos + 1)
501                    .map(|(i, _)| i)
502                    .unwrap_or(app.input.len());
503                app.input.drain(start..end);
504            }
505        }
506        KeyCode::Char(c) => {
507            let byte_idx = app
508                .input
509                .char_indices()
510                .nth(app.cursor_pos)
511                .map(|(i, _)| i)
512                .unwrap_or(app.input.len());
513            app.input.insert_str(byte_idx, &c.to_string());
514            app.cursor_pos += 1;
515        }
516        _ => {}
517    }
518}
519
520/// 确认删除按键处理
521pub fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
522    match key.code {
523        KeyCode::Char('y') | KeyCode::Char('Y') => {
524            app.delete_selected();
525        }
526        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
527            app.mode = AppMode::Normal;
528            app.message = Some("已取消删除".to_string());
529        }
530        _ => {}
531    }
532}
533
534/// 帮助模式按键处理(按任意键返回)
535pub fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
536    app.mode = AppMode::Normal;
537    app.message = None;
538}
539
540/// 确认写入日报按键处理
541pub fn handle_confirm_report(app: &mut TodoApp, key: KeyEvent, config: &mut YamlConfig) {
542    match key.code {
543        KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
544            if let Some(content) = app.report_pending_content.take() {
545                let write_ok = report::write_to_report(&content, config);
546                // 先保存 todo(save 会覆盖 message)
547                if app.is_dirty() {
548                    if save_todo_list(&app.list) {
549                        app.snapshot = app.list.clone();
550                    }
551                }
552                // 保存后再设置最终 message
553                if write_ok {
554                    app.message = Some("✅ 已标记为完成,已写入日报并保存".to_string());
555                } else {
556                    app.message = Some("✅ 已标记为完成,但写入日报失败".to_string());
557                }
558            }
559            app.mode = AppMode::Normal;
560        }
561        _ => {
562            // 其他任意键跳过,不写入日报
563            app.report_pending_content = None;
564            app.message = Some("✅ 已标记为完成".to_string());
565            app.mode = AppMode::Normal;
566        }
567    }
568}
569
570// ========== 工具函数 ==========
571
572/// 将输入字符串按光标位置分割为三部分:光标前、光标处字符、光标后
573pub fn split_input_at_cursor(input: &str, cursor_pos: usize) -> (String, String, String) {
574    let chars: Vec<char> = input.chars().collect();
575    let before: String = chars[..cursor_pos].iter().collect();
576    let cursor_ch = if cursor_pos < chars.len() {
577        chars[cursor_pos].to_string()
578    } else {
579        " ".to_string()
580    };
581    let after: String = if cursor_pos < chars.len() {
582        chars[cursor_pos + 1..].iter().collect()
583    } else {
584        String::new()
585    };
586    (before, cursor_ch, after)
587}
588
589/// 计算字符串的显示宽度(中文/全角字符占 2 列,ASCII 占 1 列)
590pub fn display_width(s: &str) -> usize {
591    s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
592}
593
594/// 计算字符串在指定列宽下换行后的行数
595pub fn count_wrapped_lines(s: &str, col_width: usize) -> usize {
596    if col_width == 0 || s.is_empty() {
597        return 1;
598    }
599    let mut lines = 1usize;
600    let mut current_width = 0usize;
601    for c in s.chars() {
602        let char_width = if c.is_ascii() { 1 } else { 2 };
603        if current_width + char_width > col_width {
604            lines += 1;
605            current_width = char_width;
606        } else {
607            current_width += char_width;
608        }
609    }
610    lines
611}
612
613/// 计算光标在指定列宽下 wrap 后所在的行号(0-based)
614pub fn cursor_wrapped_line(s: &str, cursor_pos: usize, col_width: usize) -> u16 {
615    if col_width == 0 {
616        return 0;
617    }
618    let mut line: u16 = 0;
619    let mut current_width: usize = 0;
620    for (i, c) in s.chars().enumerate() {
621        if i == cursor_pos {
622            return line;
623        }
624        let char_width = if c.is_ascii() { 1 } else { 2 };
625        if current_width + char_width > col_width {
626            line += 1;
627            current_width = char_width;
628        } else {
629            current_width += char_width;
630        }
631    }
632    // cursor_pos == chars.len() (cursor at end)
633    line
634}
635
636/// 将字符串截断到指定的显示宽度,超出部分用 ".." 替代
637pub fn truncate_to_width(s: &str, max_width: usize) -> String {
638    if max_width == 0 {
639        return String::new();
640    }
641    let total_width = display_width(s);
642    if total_width <= max_width {
643        return s.to_string();
644    }
645    let ellipsis = "..";
646    let ellipsis_width = 2;
647    let content_budget = max_width.saturating_sub(ellipsis_width);
648    let mut width = 0;
649    let mut result = String::new();
650    for ch in s.chars() {
651        let ch_width = if ch.is_ascii() { 1 } else { 2 };
652        if width + ch_width > content_budget {
653            break;
654        }
655        width += ch_width;
656        result.push(ch);
657    }
658    result.push_str(ellipsis);
659    result
660}
661
662/// 复制内容到系统剪切板(macOS 使用 pbcopy,Linux 使用 xclip)
663pub fn copy_to_clipboard(content: &str) -> bool {
664    use std::io::Write;
665    use std::process::{Command, Stdio};
666
667    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
668        ("pbcopy", vec![])
669    } else if cfg!(target_os = "linux") {
670        if Command::new("which")
671            .arg("xclip")
672            .output()
673            .map(|o| o.status.success())
674            .unwrap_or(false)
675        {
676            ("xclip", vec!["-selection", "clipboard"])
677        } else {
678            ("xsel", vec!["--clipboard", "--input"])
679        }
680    } else {
681        return false;
682    };
683
684    let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
685
686    match child {
687        Ok(mut child) => {
688            if let Some(ref mut stdin) = child.stdin {
689                let _ = stdin.write_all(content.as_bytes());
690            }
691            child.wait().map(|s| s.success()).unwrap_or(false)
692        }
693        Err(_) => false,
694    }
695}