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, Clone)]
119pub enum AppMode {
120    /// 正常浏览模式
121    Normal,
122    /// 输入添加模式
123    Adding,
124    /// 编辑模式
125    Editing,
126    /// 确认删除
127    ConfirmDelete,
128    /// 确认写入日报
129    ConfirmReport,
130    /// 确认取消输入(有内容变化时)
131    ConfirmCancelInput,
132    /// 显示帮助
133    Help,
134}
135
136impl TodoApp {
137    pub fn new() -> Self {
138        let list = load_todo_list();
139        let snapshot = list.clone();
140        let mut state = ListState::default();
141        if !list.items.is_empty() {
142            state.select(Some(0));
143        }
144        Self {
145            list,
146            snapshot,
147            state,
148            mode: AppMode::Normal,
149            input: String::new(),
150            edit_index: None,
151            message: None,
152            filter: todo_filter::DEFAULT,
153            quit_input: String::new(),
154            cursor_pos: 0,
155            preview_scroll: 0,
156            report_pending_content: None,
157        }
158    }
159
160    /// 通过对比快照判断是否有未保存的修改
161    pub fn is_dirty(&self) -> bool {
162        self.list != self.snapshot
163    }
164
165    /// 获取当前过滤后的索引列表(映射到 list.items 的真实索引)
166    pub fn filtered_indices(&self) -> Vec<usize> {
167        self.list
168            .items
169            .iter()
170            .enumerate()
171            .filter(|(_, item)| match self.filter {
172                todo_filter::UNDONE => !item.done,
173                todo_filter::DONE => item.done,
174                todo_filter::ALL => true,
175                _ => true,
176            })
177            .map(|(i, _)| i)
178            .collect()
179    }
180
181    /// 获取当前选中项在原始列表中的真实索引
182    pub fn selected_real_index(&self) -> Option<usize> {
183        let indices = self.filtered_indices();
184        self.state
185            .selected()
186            .and_then(|sel| indices.get(sel).copied())
187    }
188
189    /// 向下移动
190    pub fn move_down(&mut self) {
191        let count = self.filtered_indices().len();
192        if count == 0 {
193            return;
194        }
195        let i = match self.state.selected() {
196            Some(i) => {
197                if i >= count - 1 {
198                    count - 1 // 到达底部时停止,不再循环
199                } else {
200                    i + 1
201                }
202            }
203            None => 0,
204        };
205        self.state.select(Some(i));
206    }
207
208    /// 向上移动
209    pub fn move_up(&mut self) {
210        let count = self.filtered_indices().len();
211        if count == 0 {
212            return;
213        }
214        let i = match self.state.selected() {
215            Some(i) => {
216                if i == 0 {
217                    0 // 到达顶部时停止,不再循环
218                } else {
219                    i - 1
220                }
221            }
222            None => 0,
223        };
224        self.state.select(Some(i));
225    }
226
227    /// 切换当前选中项的完成状态
228    pub fn toggle_done(&mut self) {
229        if let Some(real_idx) = self.selected_real_index() {
230            let item = &mut self.list.items[real_idx];
231            item.done = !item.done;
232            if item.done {
233                item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
234                // 暂存内容,进入确认写入日报模式
235                self.report_pending_content = Some(item.content.clone());
236                self.mode = AppMode::ConfirmReport;
237            } else {
238                item.done_at = None;
239                self.message = Some("⬜ 已标记为未完成".to_string());
240            }
241        }
242    }
243
244    /// 添加新待办
245    pub fn add_item(&mut self) {
246        let text = self.input.trim().to_string();
247        if text.is_empty() {
248            self.message = Some("⚠️ 内容为空,已取消".to_string());
249            self.mode = AppMode::Normal;
250            self.input.clear();
251            return;
252        }
253        self.list.items.push(TodoItem {
254            content: text,
255            done: false,
256            created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
257            done_at: None,
258        });
259        self.input.clear();
260        self.mode = AppMode::Normal;
261        let count = self.filtered_indices().len();
262        if count > 0 {
263            self.state.select(Some(count - 1));
264        }
265        // 自动保存到文件
266        if save_todo_list(&self.list) {
267            self.snapshot = self.list.clone();
268            self.message = Some("✅ 已添加并保存".to_string());
269        } else {
270            self.message = Some("✅ 已添加(保存失败)".to_string());
271        }
272    }
273
274    /// 确认编辑
275    pub fn confirm_edit(&mut self) {
276        let text = self.input.trim().to_string();
277        if text.is_empty() {
278            self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
279            self.mode = AppMode::Normal;
280            self.input.clear();
281            self.edit_index = None;
282            return;
283        }
284        if let Some(idx) = self.edit_index {
285            if idx < self.list.items.len() {
286                self.list.items[idx].content = text;
287                // 自动保存到文件
288                if save_todo_list(&self.list) {
289                    self.snapshot = self.list.clone();
290                    self.message = Some("✅ 已更新并保存".to_string());
291                } else {
292                    self.message = Some("✅ 已更新(保存失败)".to_string());
293                }
294            }
295        }
296        self.input.clear();
297        self.edit_index = None;
298        self.mode = AppMode::Normal;
299    }
300
301    /// 删除当前选中项
302    pub fn delete_selected(&mut self) {
303        if let Some(real_idx) = self.selected_real_index() {
304            let removed = self.list.items.remove(real_idx);
305            self.message = Some(format!("🗑️ 已删除: {}", removed.content));
306            let count = self.filtered_indices().len();
307            if count == 0 {
308                self.state.select(None);
309            } else if let Some(sel) = self.state.selected() {
310                if sel >= count {
311                    self.state.select(Some(count - 1));
312                }
313            }
314        }
315        self.mode = AppMode::Normal;
316    }
317
318    /// 移动选中项向上(调整顺序)
319    pub fn move_item_up(&mut self) {
320        if let Some(real_idx) = self.selected_real_index() {
321            if real_idx > 0 {
322                self.list.items.swap(real_idx, real_idx - 1);
323                self.move_up();
324            }
325        }
326    }
327
328    /// 移动选中项向下(调整顺序)
329    pub fn move_item_down(&mut self) {
330        if let Some(real_idx) = self.selected_real_index() {
331            if real_idx < self.list.items.len() - 1 {
332                self.list.items.swap(real_idx, real_idx + 1);
333                self.move_down();
334            }
335        }
336    }
337
338    /// 切换过滤模式
339    pub fn toggle_filter(&mut self) {
340        self.filter = (self.filter + 1) % todo_filter::COUNT;
341        let count = self.filtered_indices().len();
342        if count > 0 {
343            self.state.select(Some(0));
344        } else {
345            self.state.select(None);
346        }
347        let label = todo_filter::label(self.filter);
348        self.message = Some(format!("🔍 过滤: {}", label));
349    }
350
351    /// 保存数据
352    pub fn save(&mut self) {
353        if self.is_dirty() {
354            if save_todo_list(&self.list) {
355                self.snapshot = self.list.clone();
356                self.message = Some("💾 已保存".to_string());
357            }
358        } else {
359            self.message = Some("📋 无需保存,没有修改".to_string());
360        }
361    }
362}
363
364// ========== 按键处理 ==========
365
366/// 正常模式按键处理,返回 true 表示退出
367pub fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
368    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
369        return true;
370    }
371
372    match key.code {
373        KeyCode::Char('q') => {
374            if app.is_dirty() {
375                app.message = Some(
376                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
377                );
378                app.quit_input = "q".to_string();
379                return false;
380            }
381            return true;
382        }
383        KeyCode::Esc => {
384            if app.is_dirty() {
385                app.message = Some(
386                    "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
387                );
388                return false;
389            }
390            return true;
391        }
392        KeyCode::Char('!') => {
393            if app.quit_input == "q" {
394                return true;
395            }
396            app.quit_input.clear();
397        }
398        KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
399        KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
400        KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
401        KeyCode::Char('a') => {
402            app.mode = AppMode::Adding;
403            app.input.clear();
404            app.cursor_pos = 0;
405            app.message = None;
406        }
407        KeyCode::Char('e') => {
408            if let Some(real_idx) = app.selected_real_index() {
409                app.input = app.list.items[real_idx].content.clone();
410                app.cursor_pos = app.input.chars().count();
411                app.edit_index = Some(real_idx);
412                app.mode = AppMode::Editing;
413                app.message = None;
414            }
415        }
416        KeyCode::Char('y') => {
417            if let Some(real_idx) = app.selected_real_index() {
418                let content = app.list.items[real_idx].content.clone();
419                if copy_to_clipboard(&content) {
420                    app.message = Some(format!("📋 已复制到剪切板: {}", content));
421                } else {
422                    app.message = Some("❌ 复制到剪切板失败".to_string());
423                }
424            }
425        }
426        KeyCode::Char('d') => {
427            if app.selected_real_index().is_some() {
428                app.mode = AppMode::ConfirmDelete;
429            }
430        }
431        KeyCode::Char('f') => app.toggle_filter(),
432        KeyCode::Char('s') => app.save(),
433        KeyCode::Char('K') => app.move_item_up(),
434        KeyCode::Char('J') => app.move_item_down(),
435        KeyCode::Char('?') => {
436            app.mode = AppMode::Help;
437        }
438        _ => {}
439    }
440
441    if key.code != KeyCode::Char('q') && key.code != KeyCode::Char('!') {
442        app.quit_input.clear();
443    }
444
445    false
446}
447
448/// 输入模式按键处理(添加/编辑通用,支持光标移动和行内编辑)
449pub fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
450    let char_count = app.input.chars().count();
451
452    match key.code {
453        KeyCode::Enter => {
454            if app.mode == AppMode::Adding {
455                app.add_item();
456            } else {
457                app.confirm_edit();
458            }
459        }
460        KeyCode::Esc => {
461            // 检查是否有内容变化,有变化则提示是否保存
462            let has_changes = if app.mode == AppMode::Adding {
463                !app.input.trim().is_empty()
464            } else if app.mode == AppMode::Editing {
465                // 编辑模式:比较当前输入和原始内容
466                if let Some(idx) = app.edit_index {
467                    if idx < app.list.items.len() {
468                        app.input.trim() != app.list.items[idx].content.trim()
469                    } else {
470                        !app.input.trim().is_empty()
471                    }
472                } else {
473                    !app.input.trim().is_empty()
474                }
475            } else {
476                false
477            };
478
479            if has_changes {
480                // 有修改,进入确认模式,询问是否保存
481                app.mode = AppMode::ConfirmCancelInput;
482                app.message = Some(
483                    "⚠️ 有未保存的内容,是否保存?(Enter/y 保存 / n 放弃 / 其他键继续编辑)"
484                        .to_string(),
485                );
486            } else {
487                // 无修改,直接取消
488                app.mode = AppMode::Normal;
489                app.input.clear();
490                app.cursor_pos = 0;
491                app.edit_index = None;
492                app.message = Some("已取消".to_string());
493            }
494        }
495        KeyCode::Left => {
496            if app.cursor_pos > 0 {
497                app.cursor_pos -= 1;
498            }
499        }
500        KeyCode::Right => {
501            if app.cursor_pos < char_count {
502                app.cursor_pos += 1;
503            }
504        }
505        KeyCode::Home => {
506            app.cursor_pos = 0;
507        }
508        KeyCode::End => {
509            app.cursor_pos = char_count;
510        }
511        KeyCode::Backspace => {
512            if app.cursor_pos > 0 {
513                let start = app
514                    .input
515                    .char_indices()
516                    .nth(app.cursor_pos - 1)
517                    .map(|(i, _)| i)
518                    .unwrap_or(0);
519                let end = app
520                    .input
521                    .char_indices()
522                    .nth(app.cursor_pos)
523                    .map(|(i, _)| i)
524                    .unwrap_or(app.input.len());
525                app.input.drain(start..end);
526                app.cursor_pos -= 1;
527            }
528        }
529        KeyCode::Delete => {
530            if app.cursor_pos < char_count {
531                let start = app
532                    .input
533                    .char_indices()
534                    .nth(app.cursor_pos)
535                    .map(|(i, _)| i)
536                    .unwrap_or(app.input.len());
537                let end = app
538                    .input
539                    .char_indices()
540                    .nth(app.cursor_pos + 1)
541                    .map(|(i, _)| i)
542                    .unwrap_or(app.input.len());
543                app.input.drain(start..end);
544            }
545        }
546        KeyCode::Char(c) => {
547            let byte_idx = app
548                .input
549                .char_indices()
550                .nth(app.cursor_pos)
551                .map(|(i, _)| i)
552                .unwrap_or(app.input.len());
553            app.input.insert_str(byte_idx, &c.to_string());
554            app.cursor_pos += 1;
555        }
556        _ => {}
557    }
558}
559
560/// 确认删除按键处理
561pub fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
562    match key.code {
563        KeyCode::Char('y') | KeyCode::Char('Y') => {
564            app.delete_selected();
565        }
566        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
567            app.mode = AppMode::Normal;
568            app.message = Some("已取消删除".to_string());
569        }
570        _ => {}
571    }
572}
573
574/// 帮助模式按键处理(按任意键返回)
575pub fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
576    app.mode = AppMode::Normal;
577    app.message = None;
578}
579
580/// 确认取消输入按键处理
581pub fn handle_confirm_cancel_input(app: &mut TodoApp, key: KeyEvent, prev_mode: AppMode) {
582    match key.code {
583        KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
584            // Enter/y 确认保存
585            if prev_mode == AppMode::Adding {
586                app.add_item();
587            } else {
588                app.confirm_edit();
589            }
590        }
591        KeyCode::Char('n') | KeyCode::Char('N') => {
592            // n 放弃修改
593            app.mode = AppMode::Normal;
594            app.input.clear();
595            app.cursor_pos = 0;
596            app.edit_index = None;
597            app.message = Some("已放弃".to_string());
598        }
599        KeyCode::Esc => {
600            // Esc 放弃修改
601            app.mode = AppMode::Normal;
602            app.input.clear();
603            app.cursor_pos = 0;
604            app.edit_index = None;
605            app.message = Some("已放弃".to_string());
606        }
607        _ => {
608            // 其他键继续编辑
609            app.mode = prev_mode;
610            app.message = None;
611        }
612    }
613}
614
615/// 确认写入日报按键处理
616pub fn handle_confirm_report(app: &mut TodoApp, key: KeyEvent, config: &mut YamlConfig) {
617    match key.code {
618        KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
619            if let Some(content) = app.report_pending_content.take() {
620                let write_ok = report::write_to_report(&content, config);
621                // 先保存 todo(save 会覆盖 message)
622                if app.is_dirty() {
623                    if save_todo_list(&app.list) {
624                        app.snapshot = app.list.clone();
625                    }
626                }
627                // 保存后再设置最终 message
628                if write_ok {
629                    app.message = Some("✅ 已标记为完成,已写入日报并保存".to_string());
630                } else {
631                    app.message = Some("✅ 已标记为完成,但写入日报失败".to_string());
632                }
633            }
634            app.mode = AppMode::Normal;
635        }
636        _ => {
637            // 其他任意键跳过,不写入日报
638            app.report_pending_content = None;
639            app.message = Some("✅ 已标记为完成".to_string());
640            app.mode = AppMode::Normal;
641        }
642    }
643}
644
645// ========== 工具函数 ==========
646
647/// 将输入字符串按光标位置分割为三部分:光标前、光标处字符、光标后
648pub fn split_input_at_cursor(input: &str, cursor_pos: usize) -> (String, String, String) {
649    let chars: Vec<char> = input.chars().collect();
650    let before: String = chars[..cursor_pos].iter().collect();
651    let cursor_ch = if cursor_pos < chars.len() {
652        chars[cursor_pos].to_string()
653    } else {
654        " ".to_string()
655    };
656    let after: String = if cursor_pos < chars.len() {
657        chars[cursor_pos + 1..].iter().collect()
658    } else {
659        String::new()
660    };
661    (before, cursor_ch, after)
662}
663
664/// 计算字符串的显示宽度(中文/全角字符占 2 列,ASCII 占 1 列)
665pub fn display_width(s: &str) -> usize {
666    s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
667}
668
669/// 计算字符串在指定列宽下换行后的行数
670pub fn count_wrapped_lines(s: &str, col_width: usize) -> usize {
671    if col_width == 0 || s.is_empty() {
672        return 1;
673    }
674    let mut lines = 1usize;
675    let mut current_width = 0usize;
676    for c in s.chars() {
677        let char_width = if c.is_ascii() { 1 } else { 2 };
678        if current_width + char_width > col_width {
679            lines += 1;
680            current_width = char_width;
681        } else {
682            current_width += char_width;
683        }
684    }
685    lines
686}
687
688/// 计算光标在指定列宽下 wrap 后所在的行号(0-based)
689pub fn cursor_wrapped_line(s: &str, cursor_pos: usize, col_width: usize) -> u16 {
690    if col_width == 0 {
691        return 0;
692    }
693    let mut line: u16 = 0;
694    let mut current_width: usize = 0;
695    for (i, c) in s.chars().enumerate() {
696        if i == cursor_pos {
697            return line;
698        }
699        let char_width = if c.is_ascii() { 1 } else { 2 };
700        if current_width + char_width > col_width {
701            line += 1;
702            current_width = char_width;
703        } else {
704            current_width += char_width;
705        }
706    }
707    // cursor_pos == chars.len() (cursor at end)
708    line
709}
710
711/// 将字符串截断到指定的显示宽度,超出部分用 ".." 替代
712pub fn truncate_to_width(s: &str, max_width: usize) -> String {
713    if max_width == 0 {
714        return String::new();
715    }
716    let total_width = display_width(s);
717    if total_width <= max_width {
718        return s.to_string();
719    }
720    let ellipsis = "..";
721    let ellipsis_width = 2;
722    let content_budget = max_width.saturating_sub(ellipsis_width);
723    let mut width = 0;
724    let mut result = String::new();
725    for ch in s.chars() {
726        let ch_width = if ch.is_ascii() { 1 } else { 2 };
727        if width + ch_width > content_budget {
728            break;
729        }
730        width += ch_width;
731        result.push(ch);
732    }
733    result.push_str(ellipsis);
734    result
735}
736
737/// 复制内容到系统剪切板(macOS 使用 pbcopy,Linux 使用 xclip)
738pub fn copy_to_clipboard(content: &str) -> bool {
739    use std::io::Write;
740    use std::process::{Command, Stdio};
741
742    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
743        ("pbcopy", vec![])
744    } else if cfg!(target_os = "linux") {
745        if Command::new("which")
746            .arg("xclip")
747            .output()
748            .map(|o| o.status.success())
749            .unwrap_or(false)
750        {
751            ("xclip", vec!["-selection", "clipboard"])
752        } else {
753            ("xsel", vec!["--clipboard", "--input"])
754        }
755    } else {
756        return false;
757    };
758
759    let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
760
761    match child {
762        Ok(mut child) => {
763            if let Some(ref mut stdin) = child.stdin {
764                let _ = stdin.write_all(content.as_bytes());
765            }
766            child.wait().map(|s| s.success()).unwrap_or(false)
767        }
768        Err(_) => false,
769    }
770}