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