Skip to main content

j_cli/command/todo/
app.rs

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