1use crate::config::YamlConfig;
2use crate::{error, info};
3use chrono::Local;
4use crossterm::{
5 event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
6 execute,
7 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
8};
9use ratatui::{
10 Terminal,
11 backend::CrosstermBackend,
12 layout::{Constraint, Direction, Layout},
13 style::{Color, Modifier, Style},
14 text::{Line, Span},
15 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16};
17use serde::{Deserialize, Serialize};
18use std::fs;
19use std::io;
20use std::path::PathBuf;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TodoItem {
27 pub content: String,
29 pub done: bool,
31 pub created_at: String,
33 pub done_at: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct TodoList {
40 pub items: Vec<TodoItem>,
41}
42
43fn todo_dir() -> PathBuf {
47 let dir = YamlConfig::data_dir().join("todo");
48 let _ = fs::create_dir_all(&dir);
49 dir
50}
51
52fn todo_file_path() -> PathBuf {
54 todo_dir().join("todo.json")
55}
56
57fn load_todo_list() -> TodoList {
61 let path = todo_file_path();
62 if !path.exists() {
63 return TodoList::default();
64 }
65 match fs::read_to_string(&path) {
66 Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
67 error!("❌ 解析 todo.json 失败: {}", e);
68 TodoList::default()
69 }),
70 Err(e) => {
71 error!("❌ 读取 todo.json 失败: {}", e);
72 TodoList::default()
73 }
74 }
75}
76
77fn save_todo_list(list: &TodoList) -> bool {
79 let path = todo_file_path();
80 if let Some(parent) = path.parent() {
82 let _ = fs::create_dir_all(parent);
83 }
84 match serde_json::to_string_pretty(list) {
85 Ok(json) => match fs::write(&path, json) {
86 Ok(_) => true,
87 Err(e) => {
88 error!("❌ 保存 todo.json 失败: {}", e);
89 false
90 }
91 },
92 Err(e) => {
93 error!("❌ 序列化 todo 列表失败: {}", e);
94 false
95 }
96 }
97}
98
99pub fn handle_todo(content: &[String], _config: &YamlConfig) {
103 if content.is_empty() {
104 run_todo_tui();
106 return;
107 }
108
109 let text = content.join(" ");
111 let text = text.trim().trim_matches('"').to_string();
112
113 if text.is_empty() {
114 error!("⚠️ 内容为空,无法添加待办");
115 return;
116 }
117
118 let mut list = load_todo_list();
119 list.items.push(TodoItem {
120 content: text.clone(),
121 done: false,
122 created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
123 done_at: None,
124 });
125
126 if save_todo_list(&list) {
127 info!("✅ 已添加待办: {}", text);
128 let undone = list.items.iter().filter(|i| !i.done).count();
130 info!("📋 当前未完成待办: {} 条", undone);
131 }
132}
133
134struct TodoApp {
138 list: TodoList,
140 state: ListState,
142 mode: AppMode,
144 input: String,
146 edit_index: Option<usize>,
148 dirty: bool,
150 message: Option<String>,
152 filter: usize,
154}
155
156#[derive(PartialEq)]
157enum AppMode {
158 Normal,
160 Adding,
162 Editing,
164 ConfirmDelete,
166}
167
168impl TodoApp {
169 fn new() -> Self {
170 let list = load_todo_list();
171 let mut state = ListState::default();
172 if !list.items.is_empty() {
173 state.select(Some(0));
174 }
175 Self {
176 list,
177 state,
178 mode: AppMode::Normal,
179 input: String::new(),
180 edit_index: None,
181 dirty: false,
182 message: None,
183 filter: 0,
184 }
185 }
186
187 fn filtered_indices(&self) -> Vec<usize> {
189 self.list
190 .items
191 .iter()
192 .enumerate()
193 .filter(|(_, item)| match self.filter {
194 1 => !item.done,
195 2 => item.done,
196 _ => true,
197 })
198 .map(|(i, _)| i)
199 .collect()
200 }
201
202 fn selected_real_index(&self) -> Option<usize> {
204 let indices = self.filtered_indices();
205 self.state
206 .selected()
207 .and_then(|sel| indices.get(sel).copied())
208 }
209
210 fn move_down(&mut self) {
212 let count = self.filtered_indices().len();
213 if count == 0 {
214 return;
215 }
216 let i = match self.state.selected() {
217 Some(i) => {
218 if i >= count - 1 {
219 0
220 } else {
221 i + 1
222 }
223 }
224 None => 0,
225 };
226 self.state.select(Some(i));
227 }
228
229 fn move_up(&mut self) {
231 let count = self.filtered_indices().len();
232 if count == 0 {
233 return;
234 }
235 let i = match self.state.selected() {
236 Some(i) => {
237 if i == 0 {
238 count - 1
239 } else {
240 i - 1
241 }
242 }
243 None => 0,
244 };
245 self.state.select(Some(i));
246 }
247
248 fn toggle_done(&mut self) {
250 if let Some(real_idx) = self.selected_real_index() {
251 let item = &mut self.list.items[real_idx];
252 item.done = !item.done;
253 if item.done {
254 item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
255 self.message = Some("✅ 已标记为完成".to_string());
256 } else {
257 item.done_at = None;
258 self.message = Some("⬜ 已标记为未完成".to_string());
259 }
260 self.dirty = true;
261 }
262 }
263
264 fn add_item(&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 return;
272 }
273 self.list.items.push(TodoItem {
274 content: text,
275 done: false,
276 created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
277 done_at: None,
278 });
279 self.dirty = true;
280 self.input.clear();
281 self.mode = AppMode::Normal;
282 let count = self.filtered_indices().len();
284 if count > 0 {
285 self.state.select(Some(count - 1));
286 }
287 self.message = Some("✅ 已添加新待办".to_string());
288 }
289
290 fn confirm_edit(&mut self) {
292 let text = self.input.trim().to_string();
293 if text.is_empty() {
294 self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
295 self.mode = AppMode::Normal;
296 self.input.clear();
297 self.edit_index = None;
298 return;
299 }
300 if let Some(idx) = self.edit_index {
301 if idx < self.list.items.len() {
302 self.list.items[idx].content = text;
303 self.dirty = true;
304 self.message = Some("✅ 已更新待办内容".to_string());
305 }
306 }
307 self.input.clear();
308 self.edit_index = None;
309 self.mode = AppMode::Normal;
310 }
311
312 fn delete_selected(&mut self) {
314 if let Some(real_idx) = self.selected_real_index() {
315 let removed = self.list.items.remove(real_idx);
316 self.dirty = true;
317 self.message = Some(format!("🗑️ 已删除: {}", removed.content));
318 let count = self.filtered_indices().len();
320 if count == 0 {
321 self.state.select(None);
322 } else if let Some(sel) = self.state.selected() {
323 if sel >= count {
324 self.state.select(Some(count - 1));
325 }
326 }
327 }
328 self.mode = AppMode::Normal;
329 }
330
331 fn move_item_up(&mut self) {
333 if let Some(real_idx) = self.selected_real_index() {
334 if real_idx > 0 {
335 self.list.items.swap(real_idx, real_idx - 1);
336 self.dirty = true;
337 self.move_up();
338 }
339 }
340 }
341
342 fn move_item_down(&mut self) {
344 if let Some(real_idx) = self.selected_real_index() {
345 if real_idx < self.list.items.len() - 1 {
346 self.list.items.swap(real_idx, real_idx + 1);
347 self.dirty = true;
348 self.move_down();
349 }
350 }
351 }
352
353 fn toggle_filter(&mut self) {
355 self.filter = (self.filter + 1) % 3;
356 let count = self.filtered_indices().len();
357 if count > 0 {
358 self.state.select(Some(0));
359 } else {
360 self.state.select(None);
361 }
362 let label = match self.filter {
363 1 => "未完成",
364 2 => "已完成",
365 _ => "全部",
366 };
367 self.message = Some(format!("🔍 过滤: {}", label));
368 }
369
370 fn save(&mut self) {
372 if self.dirty {
373 if save_todo_list(&self.list) {
374 self.dirty = false;
375 self.message = Some("💾 已保存".to_string());
376 }
377 }
378 }
379}
380
381fn run_todo_tui() {
383 match run_todo_tui_internal() {
384 Ok(_) => {}
385 Err(e) => {
386 error!("❌ TUI 启动失败: {}", e);
387 }
388 }
389}
390
391fn run_todo_tui_internal() -> io::Result<()> {
392 terminal::enable_raw_mode()?;
394 let mut stdout = io::stdout();
395 execute!(stdout, EnterAlternateScreen)?;
396
397 let backend = CrosstermBackend::new(stdout);
398 let mut terminal = Terminal::new(backend)?;
399
400 let mut app = TodoApp::new();
401
402 loop {
403 terminal.draw(|f| draw_ui(f, &mut app))?;
405
406 if event::poll(std::time::Duration::from_millis(100))? {
408 if let Event::Key(key) = event::read()? {
409 match app.mode {
410 AppMode::Normal => {
411 if handle_normal_mode(&mut app, key) {
412 break;
413 }
414 }
415 AppMode::Adding => handle_input_mode(&mut app, key),
416 AppMode::Editing => handle_input_mode(&mut app, key),
417 AppMode::ConfirmDelete => handle_confirm_delete(&mut app, key),
418 }
419 }
420 }
421 }
422
423 if app.dirty {
425 save_todo_list(&app.list);
426 }
427
428 terminal::disable_raw_mode()?;
430 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
431
432 Ok(())
433}
434
435fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
437 let size = f.area();
438
439 let chunks = Layout::default()
441 .direction(Direction::Vertical)
442 .constraints([
443 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), Constraint::Length(2), ])
448 .split(size);
449
450 let filter_label = match app.filter {
452 1 => " [未完成]",
453 2 => " [已完成]",
454 _ => "",
455 };
456 let total = app.list.items.len();
457 let done = app.list.items.iter().filter(|i| i.done).count();
458 let undone = total - done;
459 let title = format!(
460 " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
461 filter_label, total, done, undone
462 );
463 let title_block = Paragraph::new(Line::from(vec![Span::styled(
464 title,
465 Style::default()
466 .fg(Color::Cyan)
467 .add_modifier(Modifier::BOLD),
468 )]))
469 .block(
470 Block::default()
471 .borders(Borders::ALL)
472 .border_style(Style::default().fg(Color::Cyan)),
473 );
474 f.render_widget(title_block, chunks[0]);
475
476 let indices = app.filtered_indices();
478 let items: Vec<ListItem> = indices
479 .iter()
480 .map(|&idx| {
481 let item = &app.list.items[idx];
482 let checkbox = if item.done { "[x]" } else { "[ ]" };
483 let style = if item.done {
484 Style::default()
485 .fg(Color::DarkGray)
486 .add_modifier(Modifier::CROSSED_OUT)
487 } else {
488 Style::default().fg(Color::White)
489 };
490
491 let mut spans = vec![
492 Span::styled(
493 format!(" {} ", checkbox),
494 if item.done {
495 Style::default().fg(Color::Green)
496 } else {
497 Style::default().fg(Color::Yellow)
498 },
499 ),
500 Span::styled(&item.content, style),
501 ];
502
503 if let Some(short_date) = item.created_at.get(..10) {
505 spans.push(Span::styled(
506 format!(" ({})", short_date),
507 Style::default().fg(Color::DarkGray),
508 ));
509 }
510
511 ListItem::new(Line::from(spans))
512 })
513 .collect();
514
515 let list_block = Block::default()
516 .borders(Borders::ALL)
517 .border_style(Style::default().fg(Color::White))
518 .title(" 待办列表 ");
519
520 if items.is_empty() {
521 let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
523 " (空) 按 a 添加新待办...",
524 Style::default().fg(Color::DarkGray),
525 )))])
526 .block(list_block);
527 f.render_widget(empty_hint, chunks[1]);
528 } else {
529 let list_widget = List::new(items)
530 .block(list_block)
531 .highlight_style(
532 Style::default()
533 .bg(Color::DarkGray)
534 .add_modifier(Modifier::BOLD),
535 )
536 .highlight_symbol("▶ ");
537 f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
538 };
539
540 match &app.mode {
542 AppMode::Adding => {
543 let input_widget = Paragraph::new(Line::from(vec![
544 Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
545 Span::raw(&app.input),
546 Span::styled("█", Style::default().fg(Color::White)),
547 ]))
548 .block(
549 Block::default()
550 .borders(Borders::ALL)
551 .border_style(Style::default().fg(Color::Green))
552 .title(" 添加模式 (Enter 确认 / Esc 取消) "),
553 );
554 f.render_widget(input_widget, chunks[2]);
555 }
556 AppMode::Editing => {
557 let input_widget = Paragraph::new(Line::from(vec![
558 Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
559 Span::raw(&app.input),
560 Span::styled("█", Style::default().fg(Color::White)),
561 ]))
562 .block(
563 Block::default()
564 .borders(Borders::ALL)
565 .border_style(Style::default().fg(Color::Yellow))
566 .title(" 编辑模式 (Enter 确认 / Esc 取消) "),
567 );
568 f.render_widget(input_widget, chunks[2]);
569 }
570 AppMode::ConfirmDelete => {
571 let msg = if let Some(real_idx) = app.selected_real_index() {
572 format!(
573 " 确认删除「{}」?(y 确认 / n 取消)",
574 app.list.items[real_idx].content
575 )
576 } else {
577 " 没有选中的项目".to_string()
578 };
579 let confirm_widget = Paragraph::new(Line::from(Span::styled(
580 msg,
581 Style::default().fg(Color::Red),
582 )))
583 .block(
584 Block::default()
585 .borders(Borders::ALL)
586 .border_style(Style::default().fg(Color::Red))
587 .title(" ⚠️ 确认删除 "),
588 );
589 f.render_widget(confirm_widget, chunks[2]);
590 }
591 AppMode::Normal => {
592 let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
593 let dirty_indicator = if app.dirty { " [未保存]" } else { "" };
594 let status_widget = Paragraph::new(Line::from(vec![
595 Span::styled(msg, Style::default().fg(Color::Gray)),
596 Span::styled(
597 dirty_indicator,
598 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
599 ),
600 ]))
601 .block(
602 Block::default()
603 .borders(Borders::ALL)
604 .border_style(Style::default().fg(Color::DarkGray)),
605 );
606 f.render_widget(status_widget, chunks[2]);
607 }
608 }
609
610 let help_text = match app.mode {
612 AppMode::Normal => {
613 " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | f 过滤 | s 保存 | q/Esc 退出"
614 }
615 AppMode::Adding | AppMode::Editing => " Enter 确认 | Esc 取消",
616 AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
617 };
618 let help_widget = Paragraph::new(Line::from(Span::styled(
619 help_text,
620 Style::default().fg(Color::DarkGray),
621 )));
622 f.render_widget(help_widget, chunks[3]);
623}
624
625fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
627 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
629 return true;
630 }
631
632 match key.code {
633 KeyCode::Char('q') | KeyCode::Esc => return true,
635
636 KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
638
639 KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
641
642 KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
644
645 KeyCode::Char('a') => {
647 app.mode = AppMode::Adding;
648 app.input.clear();
649 app.message = None;
650 }
651
652 KeyCode::Char('e') => {
654 if let Some(real_idx) = app.selected_real_index() {
655 app.input = app.list.items[real_idx].content.clone();
656 app.edit_index = Some(real_idx);
657 app.mode = AppMode::Editing;
658 app.message = None;
659 }
660 }
661
662 KeyCode::Char('d') => {
664 if app.selected_real_index().is_some() {
665 app.mode = AppMode::ConfirmDelete;
666 }
667 }
668
669 KeyCode::Char('f') => app.toggle_filter(),
671
672 KeyCode::Char('s') => app.save(),
674
675 KeyCode::Char('K') => app.move_item_up(),
677 KeyCode::Char('J') => app.move_item_down(),
678
679 _ => {}
680 }
681
682 false
683}
684
685fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
687 match key.code {
688 KeyCode::Enter => {
689 if app.mode == AppMode::Adding {
690 app.add_item();
691 } else {
692 app.confirm_edit();
693 }
694 }
695 KeyCode::Esc => {
696 app.mode = AppMode::Normal;
697 app.input.clear();
698 app.edit_index = None;
699 app.message = Some("已取消".to_string());
700 }
701 KeyCode::Backspace => {
702 app.input.pop();
703 }
704 KeyCode::Char(c) => {
705 app.input.push(c);
706 }
707 _ => {}
708 }
709}
710
711fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
713 match key.code {
714 KeyCode::Char('y') | KeyCode::Char('Y') => {
715 app.delete_selected();
716 }
717 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
718 app.mode = AppMode::Normal;
719 app.message = Some("已取消删除".to_string());
720 }
721 _ => {}
722 }
723}