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, PartialEq)]
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, PartialEq)]
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 snapshot: TodoList,
142 state: ListState,
144 mode: AppMode,
146 input: String,
148 edit_index: Option<usize>,
150 message: Option<String>,
152 filter: usize,
154 quit_input: String,
156 cursor_pos: usize,
158 preview_scroll: u16,
160}
161
162#[derive(PartialEq)]
163enum AppMode {
164 Normal,
166 Adding,
168 Editing,
170 ConfirmDelete,
172 Help,
174}
175
176impl TodoApp {
177 fn new() -> Self {
178 let list = load_todo_list();
179 let snapshot = list.clone();
180 let mut state = ListState::default();
181 if !list.items.is_empty() {
182 state.select(Some(0));
183 }
184 Self {
185 list,
186 snapshot,
187 state,
188 mode: AppMode::Normal,
189 input: String::new(),
190 edit_index: None,
191 message: None,
192 filter: 0,
193 quit_input: String::new(),
194 cursor_pos: 0,
195 preview_scroll: 0,
196 }
197 }
198
199 fn is_dirty(&self) -> bool {
201 self.list != self.snapshot
202 }
203
204 fn filtered_indices(&self) -> Vec<usize> {
206 self.list
207 .items
208 .iter()
209 .enumerate()
210 .filter(|(_, item)| match self.filter {
211 1 => !item.done,
212 2 => item.done,
213 _ => true,
214 })
215 .map(|(i, _)| i)
216 .collect()
217 }
218
219 fn selected_real_index(&self) -> Option<usize> {
221 let indices = self.filtered_indices();
222 self.state
223 .selected()
224 .and_then(|sel| indices.get(sel).copied())
225 }
226
227 fn move_down(&mut self) {
229 let count = self.filtered_indices().len();
230 if count == 0 {
231 return;
232 }
233 let i = match self.state.selected() {
234 Some(i) => {
235 if i >= count - 1 {
236 0
237 } else {
238 i + 1
239 }
240 }
241 None => 0,
242 };
243 self.state.select(Some(i));
244 }
245
246 fn move_up(&mut self) {
248 let count = self.filtered_indices().len();
249 if count == 0 {
250 return;
251 }
252 let i = match self.state.selected() {
253 Some(i) => {
254 if i == 0 {
255 count - 1
256 } else {
257 i - 1
258 }
259 }
260 None => 0,
261 };
262 self.state.select(Some(i));
263 }
264
265 fn toggle_done(&mut self) {
267 if let Some(real_idx) = self.selected_real_index() {
268 let item = &mut self.list.items[real_idx];
269 item.done = !item.done;
270 if item.done {
271 item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
272 self.message = Some("✅ 已标记为完成".to_string());
273 } else {
274 item.done_at = None;
275 self.message = Some("⬜ 已标记为未完成".to_string());
276 }
277 }
278 }
279
280 fn add_item(&mut self) {
282 let text = self.input.trim().to_string();
283 if text.is_empty() {
284 self.message = Some("⚠️ 内容为空,已取消".to_string());
285 self.mode = AppMode::Normal;
286 self.input.clear();
287 return;
288 }
289 self.list.items.push(TodoItem {
290 content: text,
291 done: false,
292 created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
293 done_at: None,
294 });
295 self.input.clear();
296 self.mode = AppMode::Normal;
297 let count = self.filtered_indices().len();
299 if count > 0 {
300 self.state.select(Some(count - 1));
301 }
302 self.message = Some("✅ 已添加新待办".to_string());
303 }
304
305 fn confirm_edit(&mut self) {
307 let text = self.input.trim().to_string();
308 if text.is_empty() {
309 self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
310 self.mode = AppMode::Normal;
311 self.input.clear();
312 self.edit_index = None;
313 return;
314 }
315 if let Some(idx) = self.edit_index {
316 if idx < self.list.items.len() {
317 self.list.items[idx].content = text;
318 self.message = Some("✅ 已更新待办内容".to_string());
319 }
320 }
321 self.input.clear();
322 self.edit_index = None;
323 self.mode = AppMode::Normal;
324 }
325
326 fn delete_selected(&mut self) {
328 if let Some(real_idx) = self.selected_real_index() {
329 let removed = self.list.items.remove(real_idx);
330 self.message = Some(format!("🗑️ 已删除: {}", removed.content));
331 let count = self.filtered_indices().len();
333 if count == 0 {
334 self.state.select(None);
335 } else if let Some(sel) = self.state.selected() {
336 if sel >= count {
337 self.state.select(Some(count - 1));
338 }
339 }
340 }
341 self.mode = AppMode::Normal;
342 }
343
344 fn move_item_up(&mut self) {
346 if let Some(real_idx) = self.selected_real_index() {
347 if real_idx > 0 {
348 self.list.items.swap(real_idx, real_idx - 1);
349 self.move_up();
350 }
351 }
352 }
353
354 fn move_item_down(&mut self) {
356 if let Some(real_idx) = self.selected_real_index() {
357 if real_idx < self.list.items.len() - 1 {
358 self.list.items.swap(real_idx, real_idx + 1);
359 self.move_down();
360 }
361 }
362 }
363
364 fn toggle_filter(&mut self) {
366 self.filter = (self.filter + 1) % 3;
367 let count = self.filtered_indices().len();
368 if count > 0 {
369 self.state.select(Some(0));
370 } else {
371 self.state.select(None);
372 }
373 let label = match self.filter {
374 1 => "未完成",
375 2 => "已完成",
376 _ => "全部",
377 };
378 self.message = Some(format!("🔍 过滤: {}", label));
379 }
380
381 fn save(&mut self) {
383 if self.is_dirty() {
384 if save_todo_list(&self.list) {
385 self.snapshot = self.list.clone();
387 self.message = Some("💾 已保存".to_string());
388 }
389 } else {
390 self.message = Some("📋 无需保存,没有修改".to_string());
391 }
392 }
393}
394
395fn run_todo_tui() {
397 match run_todo_tui_internal() {
398 Ok(_) => {}
399 Err(e) => {
400 error!("❌ TUI 启动失败: {}", e);
401 }
402 }
403}
404
405fn run_todo_tui_internal() -> io::Result<()> {
406 terminal::enable_raw_mode()?;
408 let mut stdout = io::stdout();
409 execute!(stdout, EnterAlternateScreen)?;
410
411 let backend = CrosstermBackend::new(stdout);
412 let mut terminal = Terminal::new(backend)?;
413
414 let mut app = TodoApp::new();
415 let mut last_input_len: usize = 0;
417
418 loop {
419 terminal.draw(|f| draw_ui(f, &mut app))?;
421
422 let current_input_len = app.input.chars().count();
425 if current_input_len != last_input_len {
426 app.preview_scroll = 0;
427 last_input_len = current_input_len;
428 }
429
430 if event::poll(std::time::Duration::from_millis(100))? {
432 if let Event::Key(key) = event::read()? {
433 if (app.mode == AppMode::Adding || app.mode == AppMode::Editing)
435 && key.modifiers.contains(KeyModifiers::ALT)
436 {
437 match key.code {
438 KeyCode::Down => {
439 app.preview_scroll = app.preview_scroll.saturating_add(1);
440 continue;
441 }
442 KeyCode::Up => {
443 app.preview_scroll = app.preview_scroll.saturating_sub(1);
444 continue;
445 }
446 _ => {}
447 }
448 }
449
450 match app.mode {
451 AppMode::Normal => {
452 if handle_normal_mode(&mut app, key) {
453 break;
454 }
455 }
456 AppMode::Adding => handle_input_mode(&mut app, key),
457 AppMode::Editing => handle_input_mode(&mut app, key),
458 AppMode::ConfirmDelete => handle_confirm_delete(&mut app, key),
459 AppMode::Help => handle_help_mode(&mut app, key),
460 }
461 }
462 }
463 }
464
465 terminal::disable_raw_mode()?;
469 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
470
471 Ok(())
472}
473
474fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
476 let size = f.area();
477
478 let needs_preview = if app.mode == AppMode::Adding || app.mode == AppMode::Editing {
481 !app.input.is_empty()
483 } else {
484 false
485 };
486
487 let constraints = if needs_preview {
489 vec![
490 Constraint::Length(3), Constraint::Percentage(55), Constraint::Min(5), Constraint::Length(3), Constraint::Length(2), ]
496 } else {
497 vec![
498 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), Constraint::Length(2), ]
503 };
504 let chunks = Layout::default()
505 .direction(Direction::Vertical)
506 .constraints(constraints)
507 .split(size);
508
509 let filter_label = match app.filter {
511 1 => " [未完成]",
512 2 => " [已完成]",
513 _ => "",
514 };
515 let total = app.list.items.len();
516 let done = app.list.items.iter().filter(|i| i.done).count();
517 let undone = total - done;
518 let title = format!(
519 " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
520 filter_label, total, done, undone
521 );
522 let title_block = Paragraph::new(Line::from(vec![Span::styled(
523 title,
524 Style::default()
525 .fg(Color::Cyan)
526 .add_modifier(Modifier::BOLD),
527 )]))
528 .block(
529 Block::default()
530 .borders(Borders::ALL)
531 .border_style(Style::default().fg(Color::Cyan)),
532 );
533 f.render_widget(title_block, chunks[0]);
534
535 if app.mode == AppMode::Help {
537 let help_lines = vec![
539 Line::from(Span::styled(
540 " 📖 快捷键帮助",
541 Style::default()
542 .fg(Color::Cyan)
543 .add_modifier(Modifier::BOLD),
544 )),
545 Line::from(""),
546 Line::from(vec![
547 Span::styled(" n / ↓ / j ", Style::default().fg(Color::Yellow)),
548 Span::raw("向下移动"),
549 ]),
550 Line::from(vec![
551 Span::styled(" N / ↑ / k ", Style::default().fg(Color::Yellow)),
552 Span::raw("向上移动"),
553 ]),
554 Line::from(vec![
555 Span::styled(" 空格 / 回车 ", Style::default().fg(Color::Yellow)),
556 Span::raw("切换完成状态 [x] / [ ]"),
557 ]),
558 Line::from(vec![
559 Span::styled(" a ", Style::default().fg(Color::Yellow)),
560 Span::raw("添加新待办"),
561 ]),
562 Line::from(vec![
563 Span::styled(" e ", Style::default().fg(Color::Yellow)),
564 Span::raw("编辑选中待办"),
565 ]),
566 Line::from(vec![
567 Span::styled(" d ", Style::default().fg(Color::Yellow)),
568 Span::raw("删除待办(需确认)"),
569 ]),
570 Line::from(vec![
571 Span::styled(" f ", Style::default().fg(Color::Yellow)),
572 Span::raw("过滤切换(全部 / 未完成 / 已完成)"),
573 ]),
574 Line::from(vec![
575 Span::styled(" J / K ", Style::default().fg(Color::Yellow)),
576 Span::raw("调整待办顺序(下移 / 上移)"),
577 ]),
578 Line::from(vec![
579 Span::styled(" s ", Style::default().fg(Color::Yellow)),
580 Span::raw("手动保存"),
581 ]),
582 Line::from(vec![
583 Span::styled(" y ", Style::default().fg(Color::Yellow)),
584 Span::raw("复制选中待办到剪切板"),
585 ]),
586 Line::from(vec![
587 Span::styled(" q ", Style::default().fg(Color::Yellow)),
588 Span::raw("退出(有未保存修改时需先保存或用 q! 强制退出)"),
589 ]),
590 Line::from(vec![
591 Span::styled(" q! ", Style::default().fg(Color::Yellow)),
592 Span::raw("强制退出(丢弃未保存的修改)"),
593 ]),
594 Line::from(vec![
595 Span::styled(" Esc ", Style::default().fg(Color::Yellow)),
596 Span::raw("退出(同 q)"),
597 ]),
598 Line::from(vec![
599 Span::styled(" Ctrl+C ", Style::default().fg(Color::Yellow)),
600 Span::raw("强制退出(不保存)"),
601 ]),
602 Line::from(vec![
603 Span::styled(" ? ", Style::default().fg(Color::Yellow)),
604 Span::raw("显示此帮助"),
605 ]),
606 Line::from(""),
607 Line::from(Span::styled(
608 " 添加/编辑模式下:",
609 Style::default().fg(Color::Gray),
610 )),
611 Line::from(vec![
612 Span::styled(" Alt+↓/↑ ", Style::default().fg(Color::Yellow)),
613 Span::raw("预览区滚动(长文本输入时)"),
614 ]),
615 ];
616 let help_block = Block::default()
617 .borders(Borders::ALL)
618 .border_style(Style::default().fg(Color::Cyan))
619 .title(" 帮助 ");
620 let help_widget = Paragraph::new(help_lines).block(help_block);
621 f.render_widget(help_widget, chunks[1]);
622 } else {
623 let indices = app.filtered_indices();
624 let list_inner_width = chunks[1].width.saturating_sub(2 + 3) as usize;
626 let items: Vec<ListItem> = indices
627 .iter()
628 .map(|&idx| {
629 let item = &app.list.items[idx];
630 let checkbox = if item.done { "[x]" } else { "[ ]" };
631 let checkbox_style = if item.done {
632 Style::default().fg(Color::Green)
633 } else {
634 Style::default().fg(Color::Yellow)
635 };
636 let content_style = if item.done {
637 Style::default()
638 .fg(Color::Gray)
639 .add_modifier(Modifier::CROSSED_OUT)
640 } else {
641 Style::default().fg(Color::White)
642 };
643
644 let checkbox_str = format!(" {} ", checkbox);
646 let checkbox_display_width = display_width(&checkbox_str);
647
648 let date_str = item
650 .created_at
651 .get(..10)
652 .map(|d| format!(" ({})", d))
653 .unwrap_or_default();
654 let date_display_width = display_width(&date_str);
655
656 let content_max_width = list_inner_width
658 .saturating_sub(checkbox_display_width)
659 .saturating_sub(date_display_width);
660
661 let content_display = truncate_to_width(&item.content, content_max_width);
663 let content_actual_width = display_width(&content_display);
664
665 let padding_width = content_max_width.saturating_sub(content_actual_width);
667 let padding = " ".repeat(padding_width);
668
669 ListItem::new(Line::from(vec![
670 Span::styled(checkbox_str, checkbox_style),
671 Span::styled(content_display, content_style),
672 Span::raw(padding),
673 Span::styled(date_str, Style::default().fg(Color::DarkGray)),
674 ]))
675 })
676 .collect();
677
678 let list_block = Block::default()
679 .borders(Borders::ALL)
680 .border_style(Style::default().fg(Color::White))
681 .title(" 待办列表 ");
682
683 if items.is_empty() {
684 let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
686 " (空) 按 a 添加新待办...",
687 Style::default().fg(Color::DarkGray),
688 )))])
689 .block(list_block);
690 f.render_widget(empty_hint, chunks[1]);
691 } else {
692 let list_widget = List::new(items)
693 .block(list_block)
694 .highlight_style(
695 Style::default()
696 .bg(Color::Indexed(24))
697 .add_modifier(Modifier::BOLD),
698 )
699 .highlight_symbol(" ▶ ");
700 f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
701 };
702 }
703
704 let (_preview_chunk_idx, status_chunk_idx, help_chunk_idx) = if needs_preview {
706 let input_content = &app.input;
709 let preview_inner_w = (chunks[2].width.saturating_sub(2)) as usize;
711 let preview_inner_h = chunks[2].height.saturating_sub(2) as u16;
713
714 let total_wrapped = count_wrapped_lines(input_content, preview_inner_w) as u16;
716 let max_scroll = total_wrapped.saturating_sub(preview_inner_h);
717 let clamped_scroll = app.preview_scroll.min(max_scroll);
718
719 let mode_label = match app.mode {
721 AppMode::Adding => "新待办",
722 AppMode::Editing => "编辑中",
723 _ => "预览",
724 };
725 let title = if total_wrapped > preview_inner_h {
726 format!(
727 " 📖 {} 预览 [{}/{}行] Alt+↓/↑滚动 ",
728 mode_label,
729 clamped_scroll + preview_inner_h,
730 total_wrapped
731 )
732 } else {
733 format!(" 📖 {} 预览 ", mode_label)
734 };
735
736 let preview_block = Block::default()
737 .borders(Borders::ALL)
738 .title(title)
739 .title_style(
740 Style::default()
741 .fg(Color::Cyan)
742 .add_modifier(Modifier::BOLD),
743 )
744 .border_style(Style::default().fg(Color::Cyan));
745
746 use ratatui::widgets::Wrap;
747 let preview = Paragraph::new(input_content.clone())
748 .block(preview_block)
749 .style(Style::default().fg(Color::White))
750 .wrap(Wrap { trim: false })
751 .scroll((clamped_scroll, 0));
752 f.render_widget(preview, chunks[2]);
753 (2, 3, 4)
754 } else {
755 (1, 2, 3)
757 };
758
759 match &app.mode {
761 AppMode::Adding => {
762 let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
763 let input_widget = Paragraph::new(Line::from(vec![
764 Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
765 Span::raw(before),
766 Span::styled(
767 cursor_ch,
768 Style::default().fg(Color::Black).bg(Color::White),
769 ),
770 Span::raw(after),
771 ]))
772 .block(
773 Block::default()
774 .borders(Borders::ALL)
775 .border_style(Style::default().fg(Color::Green))
776 .title(" 添加模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
777 );
778 f.render_widget(input_widget, chunks[status_chunk_idx]);
779 }
780 AppMode::Editing => {
781 let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
782 let input_widget = Paragraph::new(Line::from(vec![
783 Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
784 Span::raw(before),
785 Span::styled(
786 cursor_ch,
787 Style::default().fg(Color::Black).bg(Color::White),
788 ),
789 Span::raw(after),
790 ]))
791 .block(
792 Block::default()
793 .borders(Borders::ALL)
794 .border_style(Style::default().fg(Color::Yellow))
795 .title(" 编辑模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
796 );
797 f.render_widget(input_widget, chunks[status_chunk_idx]);
798 }
799 AppMode::ConfirmDelete => {
800 let msg = if let Some(real_idx) = app.selected_real_index() {
801 format!(
802 " 确认删除「{}」?(y 确认 / n 取消)",
803 app.list.items[real_idx].content
804 )
805 } else {
806 " 没有选中的项目".to_string()
807 };
808 let confirm_widget = Paragraph::new(Line::from(Span::styled(
809 msg,
810 Style::default().fg(Color::Red),
811 )))
812 .block(
813 Block::default()
814 .borders(Borders::ALL)
815 .border_style(Style::default().fg(Color::Red))
816 .title(" ⚠️ 确认删除 "),
817 );
818 f.render_widget(confirm_widget, chunks[2]);
819 }
820 AppMode::Normal | AppMode::Help => {
821 let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
822 let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
823 let status_widget = Paragraph::new(Line::from(vec![
824 Span::styled(msg, Style::default().fg(Color::Gray)),
825 Span::styled(
826 dirty_indicator,
827 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
828 ),
829 ]))
830 .block(
831 Block::default()
832 .borders(Borders::ALL)
833 .border_style(Style::default().fg(Color::DarkGray)),
834 );
835 f.render_widget(status_widget, chunks[2]);
836 }
837 }
838
839 let help_text = match app.mode {
841 AppMode::Normal => {
842 " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
843 }
844 AppMode::Adding | AppMode::Editing => {
845 " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾 | Alt+↓/↑ 预览滚动"
846 }
847 AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
848 AppMode::Help => " 按任意键返回",
849 };
850 let help_widget = Paragraph::new(Line::from(Span::styled(
851 help_text,
852 Style::default().fg(Color::DarkGray),
853 )));
854 f.render_widget(help_widget, chunks[help_chunk_idx]);
855}
856
857fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
859 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
861 return true;
862 }
863
864 match key.code {
865 KeyCode::Char('q') => {
867 if app.is_dirty() {
868 app.message = Some(
869 "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
870 );
871 app.quit_input = "q".to_string();
872 return false;
873 }
874 return true;
875 }
876 KeyCode::Esc => {
877 if app.is_dirty() {
878 app.message = Some(
879 "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
880 );
881 return false;
882 }
883 return true;
884 }
885
886 KeyCode::Char('!') => {
888 if app.quit_input == "q" {
889 return true; }
891 app.quit_input.clear();
892 }
893
894 KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
896
897 KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
899
900 KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
902
903 KeyCode::Char('a') => {
905 app.mode = AppMode::Adding;
906 app.input.clear();
907 app.cursor_pos = 0;
908 app.message = None;
909 }
910
911 KeyCode::Char('e') => {
913 if let Some(real_idx) = app.selected_real_index() {
914 app.input = app.list.items[real_idx].content.clone();
915 app.cursor_pos = app.input.chars().count();
916 app.edit_index = Some(real_idx);
917 app.mode = AppMode::Editing;
918 app.message = None;
919 }
920 }
921
922 KeyCode::Char('y') => {
924 if let Some(real_idx) = app.selected_real_index() {
925 let content = app.list.items[real_idx].content.clone();
926 if copy_to_clipboard(&content) {
927 app.message = Some(format!("📋 已复制到剪切板: {}", content));
928 } else {
929 app.message = Some("❌ 复制到剪切板失败".to_string());
930 }
931 }
932 }
933
934 KeyCode::Char('d') => {
936 if app.selected_real_index().is_some() {
937 app.mode = AppMode::ConfirmDelete;
938 }
939 }
940
941 KeyCode::Char('f') => app.toggle_filter(),
943
944 KeyCode::Char('s') => app.save(),
946
947 KeyCode::Char('K') => app.move_item_up(),
949 KeyCode::Char('J') => app.move_item_down(),
950
951 KeyCode::Char('?') => {
953 app.mode = AppMode::Help;
954 }
955
956 _ => {}
957 }
958
959 if key.code != KeyCode::Char('q') && key.code != KeyCode::Char('!') {
961 app.quit_input.clear();
962 }
963
964 false
965}
966
967fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
969 let char_count = app.input.chars().count();
970
971 match key.code {
972 KeyCode::Enter => {
973 if app.mode == AppMode::Adding {
974 app.add_item();
975 } else {
976 app.confirm_edit();
977 }
978 }
979 KeyCode::Esc => {
980 app.mode = AppMode::Normal;
981 app.input.clear();
982 app.cursor_pos = 0;
983 app.edit_index = None;
984 app.message = Some("已取消".to_string());
985 }
986 KeyCode::Left => {
987 if app.cursor_pos > 0 {
988 app.cursor_pos -= 1;
989 }
990 }
991 KeyCode::Right => {
992 if app.cursor_pos < char_count {
993 app.cursor_pos += 1;
994 }
995 }
996 KeyCode::Home => {
997 app.cursor_pos = 0;
998 }
999 KeyCode::End => {
1000 app.cursor_pos = char_count;
1001 }
1002 KeyCode::Backspace => {
1003 if app.cursor_pos > 0 {
1004 let start = app
1006 .input
1007 .char_indices()
1008 .nth(app.cursor_pos - 1)
1009 .map(|(i, _)| i)
1010 .unwrap_or(0);
1011 let end = app
1012 .input
1013 .char_indices()
1014 .nth(app.cursor_pos)
1015 .map(|(i, _)| i)
1016 .unwrap_or(app.input.len());
1017 app.input.drain(start..end);
1018 app.cursor_pos -= 1;
1019 }
1020 }
1021 KeyCode::Delete => {
1022 if app.cursor_pos < char_count {
1023 let start = app
1024 .input
1025 .char_indices()
1026 .nth(app.cursor_pos)
1027 .map(|(i, _)| i)
1028 .unwrap_or(app.input.len());
1029 let end = app
1030 .input
1031 .char_indices()
1032 .nth(app.cursor_pos + 1)
1033 .map(|(i, _)| i)
1034 .unwrap_or(app.input.len());
1035 app.input.drain(start..end);
1036 }
1037 }
1038 KeyCode::Char(c) => {
1039 let byte_idx = app
1041 .input
1042 .char_indices()
1043 .nth(app.cursor_pos)
1044 .map(|(i, _)| i)
1045 .unwrap_or(app.input.len());
1046 app.input.insert_str(byte_idx, &c.to_string());
1047 app.cursor_pos += 1;
1048 }
1049 _ => {}
1050 }
1051}
1052
1053fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
1055 match key.code {
1056 KeyCode::Char('y') | KeyCode::Char('Y') => {
1057 app.delete_selected();
1058 }
1059 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1060 app.mode = AppMode::Normal;
1061 app.message = Some("已取消删除".to_string());
1062 }
1063 _ => {}
1064 }
1065}
1066
1067fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
1069 app.mode = AppMode::Normal;
1070 app.message = None;
1071}
1072
1073fn split_input_at_cursor(input: &str, cursor_pos: usize) -> (String, String, String) {
1075 let chars: Vec<char> = input.chars().collect();
1076 let before: String = chars[..cursor_pos].iter().collect();
1077 let cursor_ch = if cursor_pos < chars.len() {
1078 chars[cursor_pos].to_string()
1079 } else {
1080 " ".to_string() };
1082 let after: String = if cursor_pos < chars.len() {
1083 chars[cursor_pos + 1..].iter().collect()
1084 } else {
1085 String::new()
1086 };
1087 (before, cursor_ch, after)
1088}
1089
1090fn display_width(s: &str) -> usize {
1092 s.chars()
1093 .map(|c| {
1094 if c.is_ascii() {
1095 1
1096 } else {
1097 2
1099 }
1100 })
1101 .sum()
1102}
1103
1104fn count_wrapped_lines(s: &str, col_width: usize) -> usize {
1106 if col_width == 0 || s.is_empty() {
1107 return 1;
1108 }
1109 let mut lines = 1usize;
1110 let mut current_width = 0usize;
1111 for c in s.chars() {
1112 let char_width = if c.is_ascii() { 1 } else { 2 };
1113 if current_width + char_width > col_width {
1114 lines += 1;
1115 current_width = char_width;
1116 } else {
1117 current_width += char_width;
1118 }
1119 }
1120 lines
1121}
1122
1123fn truncate_to_width(s: &str, max_width: usize) -> String {
1125 if max_width == 0 {
1126 return String::new();
1127 }
1128
1129 let total_width = display_width(s);
1130 if total_width <= max_width {
1131 return s.to_string();
1132 }
1133
1134 let ellipsis = "..";
1136 let ellipsis_width = 2;
1137 let content_budget = max_width.saturating_sub(ellipsis_width);
1138
1139 let mut width = 0;
1140 let mut result = String::new();
1141 for ch in s.chars() {
1142 let ch_width = if ch.is_ascii() { 1 } else { 2 };
1143 if width + ch_width > content_budget {
1144 break;
1145 }
1146 width += ch_width;
1147 result.push(ch);
1148 }
1149 result.push_str(ellipsis);
1150 result
1151}
1152
1153fn copy_to_clipboard(content: &str) -> bool {
1155 use std::io::Write;
1156 use std::process::{Command, Stdio};
1157
1158 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
1160 ("pbcopy", vec![])
1161 } else if cfg!(target_os = "linux") {
1162 if Command::new("which")
1164 .arg("xclip")
1165 .output()
1166 .map(|o| o.status.success())
1167 .unwrap_or(false)
1168 {
1169 ("xclip", vec!["-selection", "clipboard"])
1170 } else {
1171 ("xsel", vec!["--clipboard", "--input"])
1172 }
1173 } else {
1174 return false; };
1176
1177 let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
1178
1179 match child {
1180 Ok(mut child) => {
1181 if let Some(ref mut stdin) = child.stdin {
1182 let _ = stdin.write_all(content.as_bytes());
1183 }
1184 child.wait().map(|s| s.success()).unwrap_or(false)
1185 }
1186 Err(_) => false,
1187 }
1188}