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, Clone)]
119pub enum AppMode {
120 Normal,
122 Adding,
124 Editing,
126 ConfirmDelete,
128 ConfirmReport,
130 ConfirmCancelInput,
132 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 pub fn is_dirty(&self) -> bool {
162 self.list != self.snapshot
163 }
164
165 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 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 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 } else {
200 i + 1
201 }
202 }
203 None => 0,
204 };
205 self.state.select(Some(i));
206 }
207
208 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 } else {
219 i - 1
220 }
221 }
222 None => 0,
223 };
224 self.state.select(Some(i));
225 }
226
227 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 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 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 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 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 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 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 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 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 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 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
364pub 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
448pub 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 let has_changes = if app.mode == AppMode::Adding {
463 !app.input.trim().is_empty()
464 } else if app.mode == AppMode::Editing {
465 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 app.mode = AppMode::ConfirmCancelInput;
482 app.message = Some(
483 "⚠️ 有未保存的内容,是否保存?(Enter/y 保存 / n 放弃 / 其他键继续编辑)"
484 .to_string(),
485 );
486 } else {
487 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
560pub 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
574pub fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
576 app.mode = AppMode::Normal;
577 app.message = None;
578}
579
580pub 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 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 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 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 app.mode = prev_mode;
610 app.message = None;
611 }
612 }
613}
614
615pub 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 if app.is_dirty() {
623 if save_todo_list(&app.list) {
624 app.snapshot = app.list.clone();
625 }
626 }
627 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 app.report_pending_content = None;
639 app.message = Some("✅ 已标记为完成".to_string());
640 app.mode = AppMode::Normal;
641 }
642 }
643}
644
645pub 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
664pub fn display_width(s: &str) -> usize {
666 s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
667}
668
669pub 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
688pub 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 line
709}
710
711pub 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
737pub 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}