Skip to main content

j_cli/command/todo/
app.rs

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