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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct TodoItem {
17 pub content: String,
19 pub done: bool,
21 pub created_at: String,
23 pub done_at: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
29pub struct TodoList {
30 pub items: Vec<TodoItem>,
31}
32
33pub fn todo_dir() -> PathBuf {
37 let dir = YamlConfig::data_dir().join("todo");
38 let _ = fs::create_dir_all(&dir);
39 dir
40}
41
42pub fn todo_file_path() -> PathBuf {
44 todo_dir().join("todo.json")
45}
46
47pub 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
67pub 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
88pub struct TodoApp {
92 pub list: TodoList,
94 pub snapshot: TodoList,
96 pub state: ListState,
98 pub mode: AppMode,
100 pub input: String,
102 pub edit_index: Option<usize>,
104 pub message: Option<String>,
106 pub filter: usize,
108 pub quit_input: String,
110 pub cursor_pos: usize,
112 pub preview_scroll: u16,
114 pub report_pending_content: Option<String>,
116}
117
118#[derive(PartialEq)]
119pub enum AppMode {
120 Normal,
122 Adding,
124 Editing,
126 ConfirmDelete,
128 ConfirmReport,
130 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 pub fn is_dirty(&self) -> bool {
160 self.list != self.snapshot
161 }
162
163 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 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 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 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 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 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 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 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 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 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 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 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 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
350pub 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
434pub 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
518pub 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
532pub fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
534 app.mode = AppMode::Normal;
535 app.message = None;
536}
537
538pub 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 if app.is_dirty() {
546 if save_todo_list(&app.list) {
547 app.snapshot = app.list.clone();
548 }
549 }
550 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 app.report_pending_content = None;
562 app.message = Some("✅ 已标记为完成".to_string());
563 app.mode = AppMode::Normal;
564 }
565 }
566}
567
568pub 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
587pub fn display_width(s: &str) -> usize {
589 s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
590}
591
592pub 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
611pub 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 line
632}
633
634pub 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
660pub 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}