Skip to main content

j_cli/command/todo/
ui.rs

1use super::app::{
2    AppMode, TodoApp, count_wrapped_lines, cursor_wrapped_line, display_width,
3    split_input_at_cursor, truncate_to_width,
4};
5use crate::constants::todo_filter;
6use ratatui::{
7    layout::{Constraint, Direction, Layout},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, List, ListItem, Paragraph},
11};
12
13/// 绘制 TUI 界面
14pub fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
15    let size = f.area();
16
17    let needs_preview = if app.mode == AppMode::Adding || app.mode == AppMode::Editing {
18        !app.input.is_empty()
19    } else {
20        false
21    };
22
23    let constraints = if needs_preview {
24        vec![
25            Constraint::Length(3),
26            Constraint::Percentage(55),
27            Constraint::Min(5),
28            Constraint::Length(3),
29            Constraint::Length(2),
30        ]
31    } else {
32        vec![
33            Constraint::Length(3),
34            Constraint::Min(5),
35            Constraint::Length(3),
36            Constraint::Length(2),
37        ]
38    };
39    let chunks = Layout::default()
40        .direction(Direction::Vertical)
41        .constraints(constraints)
42        .split(size);
43
44    // ========== 标题栏 ==========
45    let filter_label = match app.filter {
46        todo_filter::UNDONE => " [未完成]",
47        todo_filter::DONE => " [已完成]",
48        _ => "",
49    };
50    let total = app.list.items.len();
51    let done = app.list.items.iter().filter(|i| i.done).count();
52    let undone = total - done;
53    let title = format!(
54        " 📋 待办备忘录{} — 共 {} 条 | ✅ {} | ⬜ {} ",
55        filter_label, total, done, undone
56    );
57    let title_block = Paragraph::new(Line::from(vec![Span::styled(
58        title,
59        Style::default()
60            .fg(Color::Cyan)
61            .add_modifier(Modifier::BOLD),
62    )]))
63    .block(
64        Block::default()
65            .borders(Borders::ALL)
66            .border_style(Style::default().fg(Color::Cyan)),
67    );
68    f.render_widget(title_block, chunks[0]);
69
70    // ========== 列表区 ==========
71    if app.mode == AppMode::Help {
72        let help_lines = vec![
73            Line::from(Span::styled(
74                "  📖 快捷键帮助",
75                Style::default()
76                    .fg(Color::Cyan)
77                    .add_modifier(Modifier::BOLD),
78            )),
79            Line::from(""),
80            Line::from(vec![
81                Span::styled("  n / ↓ / j    ", Style::default().fg(Color::Yellow)),
82                Span::raw("向下移动"),
83            ]),
84            Line::from(vec![
85                Span::styled("  N / ↑ / k    ", Style::default().fg(Color::Yellow)),
86                Span::raw("向上移动"),
87            ]),
88            Line::from(vec![
89                Span::styled("  空格 / 回车   ", Style::default().fg(Color::Yellow)),
90                Span::raw("切换完成状态 [x] / [ ]"),
91            ]),
92            Line::from(vec![
93                Span::styled("  a            ", Style::default().fg(Color::Yellow)),
94                Span::raw("添加新待办"),
95            ]),
96            Line::from(vec![
97                Span::styled("  e            ", Style::default().fg(Color::Yellow)),
98                Span::raw("编辑选中待办"),
99            ]),
100            Line::from(vec![
101                Span::styled("  d            ", Style::default().fg(Color::Yellow)),
102                Span::raw("删除待办(需确认)"),
103            ]),
104            Line::from(vec![
105                Span::styled("  f            ", Style::default().fg(Color::Yellow)),
106                Span::raw("过滤切换(全部 / 未完成 / 已完成)"),
107            ]),
108            Line::from(vec![
109                Span::styled("  J / K        ", Style::default().fg(Color::Yellow)),
110                Span::raw("调整待办顺序(下移 / 上移)"),
111            ]),
112            Line::from(vec![
113                Span::styled("  s            ", Style::default().fg(Color::Yellow)),
114                Span::raw("手动保存"),
115            ]),
116            Line::from(vec![
117                Span::styled("  y            ", Style::default().fg(Color::Yellow)),
118                Span::raw("复制选中待办到剪切板"),
119            ]),
120            Line::from(vec![
121                Span::styled("  q            ", Style::default().fg(Color::Yellow)),
122                Span::raw("退出(有未保存修改时需先保存或用 q! 强制退出)"),
123            ]),
124            Line::from(vec![
125                Span::styled("  q!           ", Style::default().fg(Color::Yellow)),
126                Span::raw("强制退出(丢弃未保存的修改)"),
127            ]),
128            Line::from(vec![
129                Span::styled("  Esc          ", Style::default().fg(Color::Yellow)),
130                Span::raw("退出(同 q)"),
131            ]),
132            Line::from(vec![
133                Span::styled("  Ctrl+C       ", Style::default().fg(Color::Yellow)),
134                Span::raw("强制退出(不保存)"),
135            ]),
136            Line::from(vec![
137                Span::styled("  ?            ", Style::default().fg(Color::Yellow)),
138                Span::raw("显示此帮助"),
139            ]),
140            Line::from(""),
141            Line::from(Span::styled(
142                "  添加/编辑模式下:",
143                Style::default().fg(Color::Gray),
144            )),
145            Line::from(vec![
146                Span::styled("  Alt+↓/↑      ", Style::default().fg(Color::Yellow)),
147                Span::raw("预览区滚动(长文本输入时)"),
148            ]),
149        ];
150        let help_block = Block::default()
151            .borders(Borders::ALL)
152            .border_style(Style::default().fg(Color::Cyan))
153            .title(" 帮助 ");
154        let help_widget = Paragraph::new(help_lines).block(help_block);
155        f.render_widget(help_widget, chunks[1]);
156    } else {
157        let indices = app.filtered_indices();
158        let list_inner_width = chunks[1].width.saturating_sub(2 + 3) as usize;
159        let items: Vec<ListItem> = indices
160            .iter()
161            .map(|&idx| {
162                let item = &app.list.items[idx];
163                let checkbox = if item.done { "[x]" } else { "[ ]" };
164                let checkbox_style = if item.done {
165                    Style::default().fg(Color::Green)
166                } else {
167                    Style::default().fg(Color::Yellow)
168                };
169                let content_style = if item.done {
170                    Style::default()
171                        .fg(Color::Gray)
172                        .add_modifier(Modifier::CROSSED_OUT)
173                } else {
174                    Style::default().fg(Color::White)
175                };
176
177                let checkbox_str = format!(" {} ", checkbox);
178                let checkbox_display_width = display_width(&checkbox_str);
179
180                let date_str = item
181                    .created_at
182                    .get(..10)
183                    .map(|d| format!("  ({})", d))
184                    .unwrap_or_default();
185                let date_display_width = display_width(&date_str);
186
187                let content_max_width = list_inner_width
188                    .saturating_sub(checkbox_display_width)
189                    .saturating_sub(date_display_width);
190
191                let content_display = truncate_to_width(&item.content, content_max_width);
192                let content_actual_width = display_width(&content_display);
193
194                let padding_width = content_max_width.saturating_sub(content_actual_width);
195                let padding = " ".repeat(padding_width);
196
197                ListItem::new(Line::from(vec![
198                    Span::styled(checkbox_str, checkbox_style),
199                    Span::styled(content_display, content_style),
200                    Span::raw(padding),
201                    Span::styled(date_str, Style::default().fg(Color::DarkGray)),
202                ]))
203            })
204            .collect();
205
206        let list_block = Block::default()
207            .borders(Borders::ALL)
208            .border_style(Style::default().fg(Color::White))
209            .title(" 待办列表 ");
210
211        if items.is_empty() {
212            let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
213                "   (空) 按 a 添加新待办...",
214                Style::default().fg(Color::DarkGray),
215            )))])
216            .block(list_block);
217            f.render_widget(empty_hint, chunks[1]);
218        } else {
219            let list_widget = List::new(items)
220                .block(list_block)
221                .highlight_style(
222                    Style::default()
223                        .bg(Color::Indexed(24))
224                        .add_modifier(Modifier::BOLD),
225                )
226                .highlight_symbol(" ▶ ");
227            f.render_stateful_widget(list_widget, chunks[1], &mut app.state);
228        };
229    }
230
231    // ========== 预览区 ==========
232    let (_preview_chunk_idx, status_chunk_idx, help_chunk_idx) = if needs_preview {
233        let input_content = &app.input;
234        let preview_inner_w = (chunks[2].width.saturating_sub(2)) as usize;
235        let preview_inner_h = chunks[2].height.saturating_sub(2) as u16;
236
237        let total_wrapped = count_wrapped_lines(input_content, preview_inner_w) as u16;
238        let max_scroll = total_wrapped.saturating_sub(preview_inner_h);
239
240        // 自动滚动到光标所在行可见
241        let cursor_line = cursor_wrapped_line(input_content, app.cursor_pos, preview_inner_w);
242        let auto_scroll = if cursor_line < app.preview_scroll {
243            cursor_line
244        } else if cursor_line >= app.preview_scroll + preview_inner_h {
245            cursor_line.saturating_sub(preview_inner_h - 1)
246        } else {
247            app.preview_scroll
248        };
249        let clamped_scroll = auto_scroll.min(max_scroll);
250        app.preview_scroll = clamped_scroll;
251
252        let mode_label = match app.mode {
253            AppMode::Adding => "新待办",
254            AppMode::Editing => "编辑中",
255            _ => "预览",
256        };
257        let title = if total_wrapped > preview_inner_h {
258            format!(
259                " 📖 {} 预览 [{}/{}行] Alt+↓/↑滚动 ",
260                mode_label,
261                clamped_scroll + preview_inner_h,
262                total_wrapped
263            )
264        } else {
265            format!(" 📖 {} 预览 ", mode_label)
266        };
267
268        let preview_block = Block::default()
269            .borders(Borders::ALL)
270            .title(title)
271            .title_style(
272                Style::default()
273                    .fg(Color::Cyan)
274                    .add_modifier(Modifier::BOLD),
275            )
276            .border_style(Style::default().fg(Color::Cyan));
277
278        // 构建带光标高亮的预览文本
279        let (before, cursor_ch, after) = split_input_at_cursor(input_content, app.cursor_pos);
280        let cursor_style = Style::default().fg(Color::Black).bg(Color::White);
281        let preview_text = vec![Line::from(vec![
282            Span::styled(before, Style::default().fg(Color::White)),
283            Span::styled(cursor_ch, cursor_style),
284            Span::styled(after, Style::default().fg(Color::White)),
285        ])];
286
287        use ratatui::widgets::Wrap;
288        let preview = Paragraph::new(preview_text)
289            .block(preview_block)
290            .wrap(Wrap { trim: false })
291            .scroll((clamped_scroll, 0));
292        f.render_widget(preview, chunks[2]);
293        (2, 3, 4)
294    } else {
295        (1, 2, 3)
296    };
297
298    // ========== 状态/输入栏 ==========
299    match &app.mode {
300        AppMode::Adding => {
301            let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
302            let input_widget = Paragraph::new(Line::from(vec![
303                Span::styled(" 新待办: ", Style::default().fg(Color::Green)),
304                Span::raw(before),
305                Span::styled(
306                    cursor_ch,
307                    Style::default().fg(Color::Black).bg(Color::White),
308                ),
309                Span::raw(after),
310            ]))
311            .block(
312                Block::default()
313                    .borders(Borders::ALL)
314                    .border_style(Style::default().fg(Color::Green))
315                    .title(" 添加模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
316            );
317            f.render_widget(input_widget, chunks[status_chunk_idx]);
318        }
319        AppMode::Editing => {
320            let (before, cursor_ch, after) = split_input_at_cursor(&app.input, app.cursor_pos);
321            let input_widget = Paragraph::new(Line::from(vec![
322                Span::styled(" 编辑: ", Style::default().fg(Color::Yellow)),
323                Span::raw(before),
324                Span::styled(
325                    cursor_ch,
326                    Style::default().fg(Color::Black).bg(Color::White),
327                ),
328                Span::raw(after),
329            ]))
330            .block(
331                Block::default()
332                    .borders(Borders::ALL)
333                    .border_style(Style::default().fg(Color::Yellow))
334                    .title(" 编辑模式 (Enter 确认 / Esc 取消 / ←→ 移动光标) "),
335            );
336            f.render_widget(input_widget, chunks[status_chunk_idx]);
337        }
338        AppMode::ConfirmDelete => {
339            let msg = if let Some(real_idx) = app.selected_real_index() {
340                format!(
341                    " 确认删除「{}」?(y 确认 / n 取消)",
342                    app.list.items[real_idx].content
343                )
344            } else {
345                " 没有选中的项目".to_string()
346            };
347            let confirm_widget = Paragraph::new(Line::from(Span::styled(
348                msg,
349                Style::default().fg(Color::Red),
350            )))
351            .block(
352                Block::default()
353                    .borders(Borders::ALL)
354                    .border_style(Style::default().fg(Color::Red))
355                    .title(" ⚠️ 确认删除 "),
356            );
357            f.render_widget(confirm_widget, chunks[2]);
358        }
359        AppMode::ConfirmReport => {
360            let inner_width = chunks[status_chunk_idx].width.saturating_sub(2) as usize;
361            let msg = if let Some(ref content) = app.report_pending_content {
362                // 预留前缀和后缀的显示宽度
363                let prefix = " 写入日报: \"";
364                let suffix = "\" ? (Enter/y 写入, 其他跳过)";
365                let prefix_w = display_width(prefix);
366                let suffix_w = display_width(suffix);
367                let budget = inner_width.saturating_sub(prefix_w + suffix_w);
368                let truncated = truncate_to_width(content, budget);
369                format!("{}{}{}", prefix, truncated, suffix)
370            } else {
371                " 没有待写入的内容".to_string()
372            };
373            let confirm_widget = Paragraph::new(Line::from(Span::styled(
374                msg,
375                Style::default().fg(Color::Cyan),
376            )))
377            .block(
378                Block::default()
379                    .borders(Borders::ALL)
380                    .border_style(Style::default().fg(Color::Cyan))
381                    .title(" 📝 写入日报 "),
382            );
383            f.render_widget(confirm_widget, chunks[status_chunk_idx]);
384        }
385        AppMode::Normal | AppMode::Help => {
386            let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
387            let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
388            let status_widget = Paragraph::new(Line::from(vec![
389                Span::styled(msg, Style::default().fg(Color::Gray)),
390                Span::styled(
391                    dirty_indicator,
392                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
393                ),
394            ]))
395            .block(
396                Block::default()
397                    .borders(Borders::ALL)
398                    .border_style(Style::default().fg(Color::DarkGray)),
399            );
400            f.render_widget(status_widget, chunks[2]);
401        }
402    }
403
404    // ========== 帮助栏 ==========
405    let help_text = match app.mode {
406        AppMode::Normal => {
407            " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
408        }
409        AppMode::Adding | AppMode::Editing => {
410            " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾 | Alt+↓/↑ 预览滚动"
411        }
412        AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
413        AppMode::ConfirmReport => " Enter/y 写入日报并保存 | 其他键 跳过",
414        AppMode::Help => " 按任意键返回",
415    };
416    let help_widget = Paragraph::new(Line::from(Span::styled(
417        help_text,
418        Style::default().fg(Color::DarkGray),
419    )));
420    f.render_widget(help_widget, chunks[help_chunk_idx]);
421}