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
13pub 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 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 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 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 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 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 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 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::ConfirmCancelInput => {
386 let inner_width = chunks[status_chunk_idx].width.saturating_sub(2) as usize;
387 let prefix = " ⚠️ 是否保存?当前输入: \"";
388 let suffix = "\" (Enter/y 保存 / n/Esc 放弃 / 其他键继续编辑)";
389 let prefix_w = display_width(prefix);
390 let suffix_w = display_width(suffix);
391 let budget = inner_width.saturating_sub(prefix_w + suffix_w);
392 let truncated = truncate_to_width(&app.input, budget);
393 let msg = format!("{}{}{}", prefix, truncated, suffix);
394 let confirm_widget = Paragraph::new(Line::from(Span::styled(
395 msg,
396 Style::default().fg(Color::Yellow),
397 )))
398 .block(
399 Block::default()
400 .borders(Borders::ALL)
401 .border_style(Style::default().fg(Color::Yellow))
402 .title(" ⚠️ 未保存的内容 "),
403 );
404 f.render_widget(confirm_widget, chunks[status_chunk_idx]);
405 }
406 AppMode::Normal | AppMode::Help => {
407 let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
408 let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
409 let status_widget = Paragraph::new(Line::from(vec![
410 Span::styled(msg, Style::default().fg(Color::Gray)),
411 Span::styled(
412 dirty_indicator,
413 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
414 ),
415 ]))
416 .block(
417 Block::default()
418 .borders(Borders::ALL)
419 .border_style(Style::default().fg(Color::DarkGray)),
420 );
421 f.render_widget(status_widget, chunks[2]);
422 }
423 }
424
425 let help_text = match app.mode {
427 AppMode::Normal => {
428 " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
429 }
430 AppMode::Adding | AppMode::Editing => {
431 " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾 | Alt+↓/↑ 预览滚动"
432 }
433 AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
434 AppMode::ConfirmReport => " Enter/y 写入日报并保存 | 其他键 跳过",
435 AppMode::ConfirmCancelInput => " Enter/y 保存 | n/Esc 放弃 | 其他键 继续编辑",
436 AppMode::Help => " 按任意键返回",
437 };
438 let help_widget = Paragraph::new(Line::from(Span::styled(
439 help_text,
440 Style::default().fg(Color::DarkGray),
441 )));
442 f.render_widget(help_widget, chunks[help_chunk_idx]);
443}