Skip to main content

j_cli/command/todo/
ui.rs

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