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}
159
160#[derive(PartialEq)]
161enum AppMode {
162 Normal,
164 Adding,
166 Editing,
168 ConfirmDelete,
170 Help,
172}
173
174impl TodoApp {
175 fn new() -> Self {
176 let list = load_todo_list();
177 let snapshot = list.clone();
178 let mut state = ListState::default();
179 if !list.items.is_empty() {
180 state.select(Some(0));
181 }
182 Self {
183 list,
184 snapshot,
185 state,
186 mode: AppMode::Normal,
187 input: String::new(),
188 edit_index: None,
189 message: None,
190 filter: 0,
191 quit_input: String::new(),
192 cursor_pos: 0,
193 }
194 }
195
196 fn is_dirty(&self) -> bool {
198 self.list != self.snapshot
199 }
200
201 fn filtered_indices(&self) -> Vec<usize> {
203 self.list
204 .items
205 .iter()
206 .enumerate()
207 .filter(|(_, item)| match self.filter {
208 1 => !item.done,
209 2 => item.done,
210 _ => true,
211 })
212 .map(|(i, _)| i)
213 .collect()
214 }
215
216 fn selected_real_index(&self) -> Option<usize> {
218 let indices = self.filtered_indices();
219 self.state
220 .selected()
221 .and_then(|sel| indices.get(sel).copied())
222 }
223
224 fn move_down(&mut self) {
226 let count = self.filtered_indices().len();
227 if count == 0 {
228 return;
229 }
230 let i = match self.state.selected() {
231 Some(i) => {
232 if i >= count - 1 {
233 0
234 } else {
235 i + 1
236 }
237 }
238 None => 0,
239 };
240 self.state.select(Some(i));
241 }
242
243 fn move_up(&mut self) {
245 let count = self.filtered_indices().len();
246 if count == 0 {
247 return;
248 }
249 let i = match self.state.selected() {
250 Some(i) => {
251 if i == 0 {
252 count - 1
253 } else {
254 i - 1
255 }
256 }
257 None => 0,
258 };
259 self.state.select(Some(i));
260 }
261
262 fn toggle_done(&mut self) {
264 if let Some(real_idx) = self.selected_real_index() {
265 let item = &mut self.list.items[real_idx];
266 item.done = !item.done;
267 if item.done {
268 item.done_at = Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
269 self.message = Some("✅ 已标记为完成".to_string());
270 } else {
271 item.done_at = None;
272 self.message = Some("⬜ 已标记为未完成".to_string());
273 }
274 }
275 }
276
277 fn add_item(&mut self) {
279 let text = self.input.trim().to_string();
280 if text.is_empty() {
281 self.message = Some("⚠️ 内容为空,已取消".to_string());
282 self.mode = AppMode::Normal;
283 self.input.clear();
284 return;
285 }
286 self.list.items.push(TodoItem {
287 content: text,
288 done: false,
289 created_at: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
290 done_at: None,
291 });
292 self.input.clear();
293 self.mode = AppMode::Normal;
294 let count = self.filtered_indices().len();
296 if count > 0 {
297 self.state.select(Some(count - 1));
298 }
299 self.message = Some("✅ 已添加新待办".to_string());
300 }
301
302 fn confirm_edit(&mut self) {
304 let text = self.input.trim().to_string();
305 if text.is_empty() {
306 self.message = Some("⚠️ 内容为空,已取消编辑".to_string());
307 self.mode = AppMode::Normal;
308 self.input.clear();
309 self.edit_index = None;
310 return;
311 }
312 if let Some(idx) = self.edit_index {
313 if idx < self.list.items.len() {
314 self.list.items[idx].content = text;
315 self.message = Some("✅ 已更新待办内容".to_string());
316 }
317 }
318 self.input.clear();
319 self.edit_index = None;
320 self.mode = AppMode::Normal;
321 }
322
323 fn delete_selected(&mut self) {
325 if let Some(real_idx) = self.selected_real_index() {
326 let removed = self.list.items.remove(real_idx);
327 self.message = Some(format!("🗑️ 已删除: {}", removed.content));
328 let count = self.filtered_indices().len();
330 if count == 0 {
331 self.state.select(None);
332 } else if let Some(sel) = self.state.selected() {
333 if sel >= count {
334 self.state.select(Some(count - 1));
335 }
336 }
337 }
338 self.mode = AppMode::Normal;
339 }
340
341 fn move_item_up(&mut self) {
343 if let Some(real_idx) = self.selected_real_index() {
344 if real_idx > 0 {
345 self.list.items.swap(real_idx, real_idx - 1);
346 self.move_up();
347 }
348 }
349 }
350
351 fn move_item_down(&mut self) {
353 if let Some(real_idx) = self.selected_real_index() {
354 if real_idx < self.list.items.len() - 1 {
355 self.list.items.swap(real_idx, real_idx + 1);
356 self.move_down();
357 }
358 }
359 }
360
361 fn toggle_filter(&mut self) {
363 self.filter = (self.filter + 1) % 3;
364 let count = self.filtered_indices().len();
365 if count > 0 {
366 self.state.select(Some(0));
367 } else {
368 self.state.select(None);
369 }
370 let label = match self.filter {
371 1 => "未完成",
372 2 => "已完成",
373 _ => "全部",
374 };
375 self.message = Some(format!("🔍 过滤: {}", label));
376 }
377
378 fn save(&mut self) {
380 if self.is_dirty() {
381 if save_todo_list(&self.list) {
382 self.snapshot = self.list.clone();
384 self.message = Some("💾 已保存".to_string());
385 }
386 } else {
387 self.message = Some("📋 无需保存,没有修改".to_string());
388 }
389 }
390}
391
392fn run_todo_tui() {
394 match run_todo_tui_internal() {
395 Ok(_) => {}
396 Err(e) => {
397 error!("❌ TUI 启动失败: {}", e);
398 }
399 }
400}
401
402fn run_todo_tui_internal() -> io::Result<()> {
403 terminal::enable_raw_mode()?;
405 let mut stdout = io::stdout();
406 execute!(stdout, EnterAlternateScreen)?;
407
408 let backend = CrosstermBackend::new(stdout);
409 let mut terminal = Terminal::new(backend)?;
410
411 let mut app = TodoApp::new();
412
413 loop {
414 terminal.draw(|f| draw_ui(f, &mut app))?;
416
417 if event::poll(std::time::Duration::from_millis(100))? {
419 if let Event::Key(key) = event::read()? {
420 match app.mode {
421 AppMode::Normal => {
422 if handle_normal_mode(&mut app, key) {
423 break;
424 }
425 }
426 AppMode::Adding => handle_input_mode(&mut app, key),
427 AppMode::Editing => handle_input_mode(&mut app, key),
428 AppMode::ConfirmDelete => handle_confirm_delete(&mut app, key),
429 AppMode::Help => handle_help_mode(&mut app, key),
430 }
431 }
432 }
433 }
434
435 terminal::disable_raw_mode()?;
439 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
440
441 Ok(())
442}
443
444fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
446 let size = f.area();
447
448 let chunks = Layout::default()
450 .direction(Direction::Vertical)
451 .constraints([
452 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), Constraint::Length(2), ])
457 .split(size);
458
459 let filter_label = match app.filter {
461 1 => " [未完成]",
462 2 => " [已完成]",
463 _ => "",
464 };
465 let total = app.list.items.len();
466 let done = app.list.items.iter().filter(|i| i.done).count();
467 let undone = total - done;
468 let title = format!(
469 " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
470 filter_label, total, done, undone
471 );
472 let title_block = Paragraph::new(Line::from(vec![Span::styled(
473 title,
474 Style::default()
475 .fg(Color::Cyan)
476 .add_modifier(Modifier::BOLD),
477 )]))
478 .block(
479 Block::default()
480 .borders(Borders::ALL)
481 .border_style(Style::default().fg(Color::Cyan)),
482 );
483 f.render_widget(title_block, chunks[0]);
484
485 if app.mode == AppMode::Help {
487 let help_lines = vec![
489 Line::from(Span::styled(
490 " 📖 快捷键帮助",
491 Style::default()
492 .fg(Color::Cyan)
493 .add_modifier(Modifier::BOLD),
494 )),
495 Line::from(""),
496 Line::from(vec![
497 Span::styled(" n / ↓ / j ", Style::default().fg(Color::Yellow)),
498 Span::raw("向下移动"),
499 ]),
500 Line::from(vec![
501 Span::styled(" N / ↑ / k ", Style::default().fg(Color::Yellow)),
502 Span::raw("向上移动"),
503 ]),
504 Line::from(vec![
505 Span::styled(" 空格 / 回车 ", Style::default().fg(Color::Yellow)),
506 Span::raw("切换完成状态 [x] / [ ]"),
507 ]),
508 Line::from(vec![
509 Span::styled(" a ", Style::default().fg(Color::Yellow)),
510 Span::raw("添加新待办"),
511 ]),
512 Line::from(vec![
513 Span::styled(" e ", Style::default().fg(Color::Yellow)),
514 Span::raw("编辑选中待办"),
515 ]),
516 Line::from(vec![
517 Span::styled(" d ", Style::default().fg(Color::Yellow)),
518 Span::raw("删除待办(需确认)"),
519 ]),
520 Line::from(vec![
521 Span::styled(" f ", Style::default().fg(Color::Yellow)),
522 Span::raw("过滤切换(全部 / 未完成 / 已完成)"),
523 ]),
524 Line::from(vec![
525 Span::styled(" J / K ", Style::default().fg(Color::Yellow)),
526 Span::raw("调整待办顺序(下移 / 上移)"),
527 ]),
528 Line::from(vec![
529 Span::styled(" s ", Style::default().fg(Color::Yellow)),
530 Span::raw("手动保存"),
531 ]),
532 Line::from(vec![
533 Span::styled(" y ", Style::default().fg(Color::Yellow)),
534 Span::raw("复制选中待办到剪切板"),
535 ]),
536 Line::from(vec![
537 Span::styled(" q ", Style::default().fg(Color::Yellow)),
538 Span::raw("退出(有未保存修改时需先保存或用 q! 强制退出)"),
539 ]),
540 Line::from(vec![
541 Span::styled(" q! ", Style::default().fg(Color::Yellow)),
542 Span::raw("强制退出(丢弃未保存的修改)"),
543 ]),
544 Line::from(vec![
545 Span::styled(" Esc ", Style::default().fg(Color::Yellow)),
546 Span::raw("退出(同 q)"),
547 ]),
548 Line::from(vec![
549 Span::styled(" Ctrl+C ", Style::default().fg(Color::Yellow)),
550 Span::raw("强制退出(不保存)"),
551 ]),
552 Line::from(vec![
553 Span::styled(" ? ", Style::default().fg(Color::Yellow)),
554 Span::raw("显示此帮助"),
555 ]),
556 ];
557 let help_block = Block::default()
558 .borders(Borders::ALL)
559 .border_style(Style::default().fg(Color::Cyan))
560 .title(" 帮助 ");
561 let help_widget = Paragraph::new(help_lines).block(help_block);
562 f.render_widget(help_widget, chunks[1]);
563 } else {
564 let indices = app.filtered_indices();
565 let list_inner_width = chunks[1].width.saturating_sub(2 + 3) as usize;
567 let items: Vec<ListItem> = indices
568 .iter()
569 .map(|&idx| {
570 let item = &app.list.items[idx];
571 let checkbox = if item.done { "[x]" } else { "[ ]" };
572 let checkbox_style = if item.done {
573 Style::default().fg(Color::Green)
574 } else {
575 Style::default().fg(Color::Yellow)
576 };
577 let content_style = if item.done {
578 Style::default()
579 .fg(Color::Gray)
580 .add_modifier(Modifier::CROSSED_OUT)
581 } else {
582 Style::default().fg(Color::White)
583 };
584
585 let checkbox_str = format!(" {} ", checkbox);
587 let checkbox_display_width = display_width(&checkbox_str);
588
589 let date_str = item
591 .created_at
592 .get(..10)
593 .map(|d| format!(" ({})", d))
594 .unwrap_or_default();
595 let date_display_width = display_width(&date_str);
596
597 let content_max_width = list_inner_width
599 .saturating_sub(checkbox_display_width)
600 .saturating_sub(date_display_width);
601
602 let content_display = truncate_to_width(&item.content, content_max_width);
604 let content_actual_width = display_width(&content_display);
605
606 let padding_width = content_max_width.saturating_sub(content_actual_width);
608 let padding = " ".repeat(padding_width);
609
610 ListItem::new(Line::from(vec![
611 Span::styled(checkbox_str, checkbox_style),
612 Span::styled(content_display, content_style),
613 Span::raw(padding),
614 Span::styled(date_str, Style::default().fg(Color::DarkGray)),
615 ]))
616 })
617 .collect();
618
619 let list_block = Block::default()
620 .borders(Borders::ALL)
621 .border_style(Style::default().fg(Color::White))
622 .title(" 待办列表 ");
623
624 if items.is_empty() {
625 let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
627 " (空) 按 a 添加新待办...",
628 Style::default().fg(Color::DarkGray),
629 )))])
630 .block(list_block);
631 f.render_widget(empty_hint, chunks[1]);
632 } else {
633 let list_widget = List::new(items)
634 .block(list_block)
635 .highlight_style(
636 Style::default()
637 .bg(Color::Indexed(24))
638 .add_modifier(Modifier::BOLD),
639 )
640 .highlight_symbol(" ▶ ");
641 f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
642 };
643 }
644
645 match &app.mode {
647 AppMode::Adding => {
648 let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
649 let input_widget = Paragraph::new(Line::from(vec![
650 Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
651 Span::raw(before),
652 Span::styled(
653 cursor_ch,
654 Style::default().fg(Color::Black).bg(Color::White),
655 ),
656 Span::raw(after),
657 ]))
658 .block(
659 Block::default()
660 .borders(Borders::ALL)
661 .border_style(Style::default().fg(Color::Green))
662 .title(" 添加模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
663 );
664 f.render_widget(input_widget, chunks[2]);
665 }
666 AppMode::Editing => {
667 let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
668 let input_widget = Paragraph::new(Line::from(vec![
669 Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
670 Span::raw(before),
671 Span::styled(
672 cursor_ch,
673 Style::default().fg(Color::Black).bg(Color::White),
674 ),
675 Span::raw(after),
676 ]))
677 .block(
678 Block::default()
679 .borders(Borders::ALL)
680 .border_style(Style::default().fg(Color::Yellow))
681 .title(" 编辑模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
682 );
683 f.render_widget(input_widget, chunks[2]);
684 }
685 AppMode::ConfirmDelete => {
686 let msg = if let Some(real_idx) = app.selected_real_index() {
687 format!(
688 " 确认删除「{}」?(y 确认 / n 取消)",
689 app.list.items[real_idx].content
690 )
691 } else {
692 " 没有选中的项目".to_string()
693 };
694 let confirm_widget = Paragraph::new(Line::from(Span::styled(
695 msg,
696 Style::default().fg(Color::Red),
697 )))
698 .block(
699 Block::default()
700 .borders(Borders::ALL)
701 .border_style(Style::default().fg(Color::Red))
702 .title(" ⚠️ 确认删除 "),
703 );
704 f.render_widget(confirm_widget, chunks[2]);
705 }
706 AppMode::Normal | AppMode::Help => {
707 let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
708 let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
709 let status_widget = Paragraph::new(Line::from(vec![
710 Span::styled(msg, Style::default().fg(Color::Gray)),
711 Span::styled(
712 dirty_indicator,
713 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
714 ),
715 ]))
716 .block(
717 Block::default()
718 .borders(Borders::ALL)
719 .border_style(Style::default().fg(Color::DarkGray)),
720 );
721 f.render_widget(status_widget, chunks[2]);
722 }
723 }
724
725 let help_text = match app.mode {
727 AppMode::Normal => {
728 " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
729 }
730 AppMode::Adding | AppMode::Editing => {
731 " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾"
732 }
733 AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
734 AppMode::Help => " 按任意键返回",
735 };
736 let help_widget = Paragraph::new(Line::from(Span::styled(
737 help_text,
738 Style::default().fg(Color::DarkGray),
739 )));
740 f.render_widget(help_widget, chunks[3]);
741}
742
743fn handle_normal_mode(app: &mut TodoApp, key: KeyEvent) -> bool {
745 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
747 return true;
748 }
749
750 match key.code {
751 KeyCode::Char('q') => {
753 if app.is_dirty() {
754 app.message = Some(
755 "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
756 );
757 app.quit_input = "q".to_string();
758 return false;
759 }
760 return true;
761 }
762 KeyCode::Esc => {
763 if app.is_dirty() {
764 app.message = Some(
765 "⚠️ 有未保存的修改!请先 s 保存,或输入 q! 强制退出(丢弃修改)".to_string(),
766 );
767 return false;
768 }
769 return true;
770 }
771
772 KeyCode::Char('!') => {
774 if app.quit_input == "q" {
775 return true; }
777 app.quit_input.clear();
778 }
779
780 KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
782
783 KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
785
786 KeyCode::Char(' ') | KeyCode::Enter => app.toggle_done(),
788
789 KeyCode::Char('a') => {
791 app.mode = AppMode::Adding;
792 app.input.clear();
793 app.cursor_pos = 0;
794 app.message = None;
795 }
796
797 KeyCode::Char('e') => {
799 if let Some(real_idx) = app.selected_real_index() {
800 app.input = app.list.items[real_idx].content.clone();
801 app.cursor_pos = app.input.chars().count();
802 app.edit_index = Some(real_idx);
803 app.mode = AppMode::Editing;
804 app.message = None;
805 }
806 }
807
808 KeyCode::Char('y') => {
810 if let Some(real_idx) = app.selected_real_index() {
811 let content = app.list.items[real_idx].content.clone();
812 if copy_to_clipboard(&content) {
813 app.message = Some(format!("📋 已复制到剪切板: {}", content));
814 } else {
815 app.message = Some("❌ 复制到剪切板失败".to_string());
816 }
817 }
818 }
819
820 KeyCode::Char('d') => {
822 if app.selected_real_index().is_some() {
823 app.mode = AppMode::ConfirmDelete;
824 }
825 }
826
827 KeyCode::Char('f') => app.toggle_filter(),
829
830 KeyCode::Char('s') => app.save(),
832
833 KeyCode::Char('K') => app.move_item_up(),
835 KeyCode::Char('J') => app.move_item_down(),
836
837 KeyCode::Char('?') => {
839 app.mode = AppMode::Help;
840 }
841
842 _ => {}
843 }
844
845 if key.code != KeyCode::Char('q') && key.code != KeyCode::Char('!') {
847 app.quit_input.clear();
848 }
849
850 false
851}
852
853fn handle_input_mode(app: &mut TodoApp, key: KeyEvent) {
855 let char_count = app.input.chars().count();
856
857 match key.code {
858 KeyCode::Enter => {
859 if app.mode == AppMode::Adding {
860 app.add_item();
861 } else {
862 app.confirm_edit();
863 }
864 }
865 KeyCode::Esc => {
866 app.mode = AppMode::Normal;
867 app.input.clear();
868 app.cursor_pos = 0;
869 app.edit_index = None;
870 app.message = Some("已取消".to_string());
871 }
872 KeyCode::Left => {
873 if app.cursor_pos > 0 {
874 app.cursor_pos -= 1;
875 }
876 }
877 KeyCode::Right => {
878 if app.cursor_pos < char_count {
879 app.cursor_pos += 1;
880 }
881 }
882 KeyCode::Home => {
883 app.cursor_pos = 0;
884 }
885 KeyCode::End => {
886 app.cursor_pos = char_count;
887 }
888 KeyCode::Backspace => {
889 if app.cursor_pos > 0 {
890 let start = app
892 .input
893 .char_indices()
894 .nth(app.cursor_pos - 1)
895 .map(|(i, _)| i)
896 .unwrap_or(0);
897 let end = app
898 .input
899 .char_indices()
900 .nth(app.cursor_pos)
901 .map(|(i, _)| i)
902 .unwrap_or(app.input.len());
903 app.input.drain(start..end);
904 app.cursor_pos -= 1;
905 }
906 }
907 KeyCode::Delete => {
908 if app.cursor_pos < char_count {
909 let start = app
910 .input
911 .char_indices()
912 .nth(app.cursor_pos)
913 .map(|(i, _)| i)
914 .unwrap_or(app.input.len());
915 let end = app
916 .input
917 .char_indices()
918 .nth(app.cursor_pos + 1)
919 .map(|(i, _)| i)
920 .unwrap_or(app.input.len());
921 app.input.drain(start..end);
922 }
923 }
924 KeyCode::Char(c) => {
925 let byte_idx = app
927 .input
928 .char_indices()
929 .nth(app.cursor_pos)
930 .map(|(i, _)| i)
931 .unwrap_or(app.input.len());
932 app.input.insert_str(byte_idx, &c.to_string());
933 app.cursor_pos += 1;
934 }
935 _ => {}
936 }
937}
938
939fn handle_confirm_delete(app: &mut TodoApp, key: KeyEvent) {
941 match key.code {
942 KeyCode::Char('y') | KeyCode::Char('Y') => {
943 app.delete_selected();
944 }
945 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
946 app.mode = AppMode::Normal;
947 app.message = Some("已取消删除".to_string());
948 }
949 _ => {}
950 }
951}
952
953fn handle_help_mode(app: &mut TodoApp, _key: KeyEvent) {
955 app.mode = AppMode::Normal;
956 app.message = None;
957}
958
959fn split_input_at_cursor(input: &str, cursor_pos: usize) -> (String, String, String) {
961 let chars: Vec<char> = input.chars().collect();
962 let before: String = chars[..cursor_pos].iter().collect();
963 let cursor_ch = if cursor_pos < chars.len() {
964 chars[cursor_pos].to_string()
965 } else {
966 " ".to_string() };
968 let after: String = if cursor_pos < chars.len() {
969 chars[cursor_pos + 1..].iter().collect()
970 } else {
971 String::new()
972 };
973 (before, cursor_ch, after)
974}
975
976fn display_width(s: &str) -> usize {
978 s.chars()
979 .map(|c| {
980 if c.is_ascii() {
981 1
982 } else {
983 2
985 }
986 })
987 .sum()
988}
989
990fn truncate_to_width(s: &str, max_width: usize) -> String {
992 if max_width == 0 {
993 return String::new();
994 }
995
996 let total_width = display_width(s);
997 if total_width <= max_width {
998 return s.to_string();
999 }
1000
1001 let ellipsis = "..";
1003 let ellipsis_width = 2;
1004 let content_budget = max_width.saturating_sub(ellipsis_width);
1005
1006 let mut width = 0;
1007 let mut result = String::new();
1008 for ch in s.chars() {
1009 let ch_width = if ch.is_ascii() { 1 } else { 2 };
1010 if width + ch_width > content_budget {
1011 break;
1012 }
1013 width += ch_width;
1014 result.push(ch);
1015 }
1016 result.push_str(ellipsis);
1017 result
1018}
1019
1020fn copy_to_clipboard(content: &str) -> bool {
1022 use std::io::Write;
1023 use std::process::{Command, Stdio};
1024
1025 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
1027 ("pbcopy", vec![])
1028 } else if cfg!(target_os = "linux") {
1029 if Command::new("which")
1031 .arg("xclip")
1032 .output()
1033 .map(|o| o.status.success())
1034 .unwrap_or(false)
1035 {
1036 ("xclip", vec!["-selection", "clipboard"])
1037 } else {
1038 ("xsel", vec!["--clipboard", "--input"])
1039 }
1040 } else {
1041 return false; };
1043
1044 let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
1045
1046 match child {
1047 Ok(mut child) => {
1048 if let Some(ref mut stdin) = child.stdin {
1049 let _ = stdin.write_all(content.as_bytes());
1050 }
1051 child.wait().map(|s| s.success()).unwrap_or(false)
1052 }
1053 Err(_) => false,
1054 }
1055}