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
12pub 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 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 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 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 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 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 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::Normal | AppMode::Help => {
359 let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
360 let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
361 let status_widget = Paragraph::new(Line::from(vec![
362 Span::styled(msg, Style::default().fg(Color::Gray)),
363 Span::styled(
364 dirty_indicator,
365 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
366 ),
367 ]))
368 .block(
369 Block::default()
370 .borders(Borders::ALL)
371 .border_style(Style::default().fg(Color::DarkGray)),
372 );
373 f.render_widget(status_widget, chunks[2]);
374 }
375 }
376
377 let help_text = match app.mode {
379 AppMode::Normal => {
380 " n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | ? 帮助 | q 退出"
381 }
382 AppMode::Adding | AppMode::Editing => {
383 " Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾 | Alt+↓/↑ 预览滚动"
384 }
385 AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
386 AppMode::Help => " 按任意键返回",
387 };
388 let help_widget = Paragraph::new(Line::from(Span::styled(
389 help_text,
390 Style::default().fg(Color::DarkGray),
391 )));
392 f.render_widget(help_widget, chunks[help_chunk_idx]);
393}