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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct TodoItem {
15 pub content: String,
17 pub done: bool,
19 pub created_at: String,
21 pub done_at: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
27pub struct TodoList {
28 pub items: Vec<TodoItem>,
29}
30
31pub fn todo_dir() -> PathBuf {
35 let dir = YamlConfig::data_dir().join("todo");
36 let _ = fs::create_dir_all(&dir);
37 dir
38}
39
40pub fn todo_file_path() -> PathBuf {
42 todo_dir().join("todo.json")
43}
44
45pub 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
65pub 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
86pub struct TodoApp {
90 pub list: TodoList,
92 pub snapshot: TodoList,
94 pub state: ListState,
96 pub mode: AppMode,
98 pub input: String,
100 pub edit_index: Option<usize>,
102 pub message: Option<String>,
104 pub filter: usize,
106 pub quit_input: String,
108 pub cursor_pos: usize,
110 pub preview_scroll: u16,
112}
113
114#[derive(PartialEq)]
115pub enum AppMode {
116 Normal,
118 Adding,
120 Editing,
122 ConfirmDelete,
124 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 pub fn is_dirty(&self) -> bool {
153 self.list != self.snapshot
154 }
155
156 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 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 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 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 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 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 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 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 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 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 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 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
344pub 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
428pub 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
512pub 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
526pub fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
528 app.mode = AppMode::Normal;
529 app.message = None;
530}
531
532pub 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
551pub fn display_width(s: &str) -> usize {
553 s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
554}
555
556pub 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
575pub 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 line
596}
597
598pub 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
624pub 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}