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