use super::app::{AppMode, TodoApp, display_width, truncate_to_width};
use crate::constants::todo_filter;
use crate::tui::components::{
CommandItem, CommandPopupConfig, ConfirmDialogConfig, StatusInputParams, cursor_wrapped_lines,
draw_command_popup as render_command_popup, draw_confirm_dialog, draw_status_input,
};
use crate::util::text::wrap_text;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
};
pub fn draw_ui(f: &mut ratatui::Frame, app: &mut TodoApp) {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), Constraint::Length(1), ])
.split(size);
let filter_label = match app.filter {
todo_filter::UNDONE => " [未完成]",
todo_filter::DONE => " [已完成]",
_ => "",
};
let total = app.list.items.len();
let done = app.list.items.iter().filter(|i| i.done).count();
let undone = total - done;
let title = format!(
" 📝 待办{} — 共 {} 条 | ☑️ {} | ⬜ {} ",
filter_label, total, done, undone
);
let title_block = Paragraph::new(Line::from(vec![Span::styled(
title,
Style::default()
.fg(app.theme.help_title)
.add_modifier(Modifier::BOLD),
)]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.help_title)),
);
f.render_widget(title_block, chunks[0]);
if app.mode == AppMode::Help {
render_help(f, app, chunks[1]);
} else {
render_list(f, app, chunks[1]);
}
if app.mode == AppMode::CommandPopup {
draw_command_popup(f, app, chunks[1]);
}
render_status_bar(f, app, chunks[2]);
let help_text = match app.mode {
AppMode::Normal => {
" n/↓ 下移 | N/↑ 上移 | 空格/回车 切换完成 | a 添加 | e 编辑 | d 删除 | y 复制 | f 过滤 | s 保存 | / 命令 | ? 帮助 | q 退出"
}
AppMode::Adding | AppMode::Editing => {
" Enter 确认 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾"
}
AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
AppMode::ConfirmReport => " Enter/y 写入日报 | 其他键跳过",
AppMode::ConfirmCancelInput => " Enter/y 保存 | n/Esc 放弃 | 其他键继续编辑",
AppMode::Help => " 按任意键返回",
AppMode::CommandPopup => " ↑↓/jk 选择 | Enter 确认 | Esc 取消",
};
let help_widget = Paragraph::new(Line::from(Span::styled(
help_text,
Style::default().fg(app.theme.text_dim),
)));
f.render_widget(help_widget, chunks[3]);
}
fn render_help(f: &mut ratatui::Frame, app: &TodoApp, area: ratatui::layout::Rect) {
use crate::tui::components::{HelpPageConfig, HelpShortcut, draw_help_page};
let shortcuts = [
HelpShortcut::new(" n / ↓ / j ", "向下移动"),
HelpShortcut::new(" N / ↑ / k ", "向上移动"),
HelpShortcut::new(" 空格 / 回车 ", "切换完成状态 [x] / [ ]"),
HelpShortcut::new(" a ", "添加新待办"),
HelpShortcut::new(" e ", "编辑选中待办"),
HelpShortcut::new(" d ", "删除待办(需确认)"),
HelpShortcut::new(" f ", "过滤切换(全部 / 未完成 / 已完成)"),
HelpShortcut::new(" J / K ", "调整待办顺序(下移 / 上移)"),
HelpShortcut::new(" s ", "手动保存"),
HelpShortcut::new(" y ", "复制选中待办到剪切板"),
HelpShortcut::new(
" q ",
"退出(有未保存修改时需先保存或用 q! 强制退出)",
),
HelpShortcut::new(" q! ", "强制退出(丢弃未保存的修改)"),
HelpShortcut::new(" Esc ", "退出(同 q)"),
HelpShortcut::new(" Ctrl+C ", "强制退出(不保存)"),
HelpShortcut::new(" / ", "命令面板"),
HelpShortcut::new(" ? ", "显示此帮助"),
];
draw_help_page(
f,
area,
&HelpPageConfig {
title: "快捷键帮助",
title_icon: Some("📖"),
block_title: Some(" 帮助 "),
shortcuts: &shortcuts,
footer_lines: None,
theme: &app.theme,
},
);
}
fn render_list(f: &mut ratatui::Frame, app: &mut TodoApp, area: ratatui::layout::Rect) {
let indices = app.filtered_indices();
let list_inner_width = area.width.saturating_sub(2 + 3) as usize;
let checkbox_w = display_width(" [x] ");
let selected = app.state.selected();
let max_date_col_w = app
.list
.items
.iter()
.map(|item| {
let date_str = item
.created_at
.get(..10)
.map(|d| format!(" ({})", d))
.unwrap_or_default();
display_width(&date_str)
})
.max()
.unwrap_or(0);
let mut items: Vec<ListItem> = indices
.iter()
.enumerate()
.map(|(i, &idx)| {
let is_selected = selected == Some(i);
if app.mode == AppMode::Editing && app.edit_index == Some(idx) {
let edit_content_width = list_inner_width
.saturating_sub(checkbox_w)
.saturating_sub(max_date_col_w);
return build_editing_item(
&app.input,
app.cursor_pos,
edit_content_width,
checkbox_w,
is_selected,
&app.theme,
);
}
build_normal_item(
&app.list.items[idx],
list_inner_width,
checkbox_w,
is_selected,
&app.theme,
)
})
.collect();
if app.mode == AppMode::Adding {
let is_selected = selected == Some(indices.len());
let add_content_width = list_inner_width
.saturating_sub(checkbox_w)
.saturating_sub(max_date_col_w);
items.push(build_editing_item(
&app.input,
app.cursor_pos,
add_content_width,
checkbox_w,
is_selected,
&app.theme,
));
}
let list_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.separator))
.title(" 待办列表 ");
if items.is_empty() {
let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
" (空) 按 a 添加新待办...",
Style::default().fg(app.theme.text_dim),
)))])
.block(list_block);
f.render_widget(empty_hint, area);
} else {
let list_widget = List::new(items)
.block(list_block)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
f.render_stateful_widget(list_widget, area, &mut app.state);
}
}
fn pointer_span(selected: bool, theme: &crate::theme::Theme) -> Span<'static> {
if selected {
Span::styled(
" ❯ ",
Style::default()
.fg(theme.config_pointer)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
}
}
fn build_normal_item(
item: &super::app::TodoItem,
list_inner_width: usize,
checkbox_w: usize,
selected: bool,
theme: &crate::theme::Theme,
) -> ListItem<'static> {
let checkbox = if item.done { "[x]" } else { "[ ]" };
let checkbox_style = if item.done {
Style::default().fg(theme.config_toggle_on)
} else {
Style::default().fg(theme.config_pointer)
};
let content_style = if item.done {
Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default().fg(theme.text_normal)
};
let checkbox_str = format!(" {} ", checkbox);
let date_str = item
.created_at
.get(..10)
.map(|d| format!(" ({})", d))
.unwrap_or_default();
let date_display_width = display_width(&date_str);
let date_col_w = if date_display_width > 0 {
date_display_width
} else {
0
};
let content_width = list_inner_width
.saturating_sub(checkbox_w)
.saturating_sub(date_col_w);
let wrapped = wrap_text(&item.content, content_width);
let indent = " ".repeat(checkbox_w);
let mut item_lines: Vec<Line> = Vec::new();
for (i, line_text) in wrapped.iter().enumerate() {
let mut spans = if i == 0 {
vec![
pointer_span(selected, theme),
Span::styled(checkbox_str.clone(), checkbox_style),
]
} else {
vec![Span::raw(" "), Span::raw(indent.clone())]
};
spans.push(Span::styled(line_text.clone(), content_style));
if i == 0 && date_display_width > 0 {
let line_content_w = checkbox_w + display_width(line_text);
let padding_w = list_inner_width
.saturating_sub(line_content_w)
.saturating_sub(date_display_width);
spans.push(Span::raw(" ".repeat(padding_w)));
spans.push(Span::styled(
date_str.clone(),
Style::default().fg(theme.text_dim),
));
}
item_lines.push(Line::from(spans));
}
ListItem::new(item_lines)
}
fn build_editing_item(
input: &str,
cursor_pos: usize,
content_width: usize,
checkbox_w: usize,
selected: bool,
theme: &crate::theme::Theme,
) -> ListItem<'static> {
let indent = " ".repeat(checkbox_w);
let cursor_lines =
cursor_wrapped_lines(input, cursor_pos, content_width, Some("输入内容…"), theme);
let mut item_lines: Vec<Line> = Vec::new();
for (i, line) in cursor_lines.lines.into_iter().enumerate() {
let mut spans = if i == 0 {
vec![pointer_span(selected, theme)]
} else {
vec![Span::raw(" ")]
};
spans.push(Span::raw(indent.clone()));
spans.extend(line.spans);
item_lines.push(Line::from(spans));
}
ListItem::new(item_lines)
}
pub fn draw_command_popup(f: &mut ratatui::Frame, app: &mut TodoApp, main_area: Rect) {
let items = app.filtered_cmd_items();
let cmd_items: Vec<CommandItem<'_>> = items
.iter()
.map(|(_, key, label)| CommandItem::new(key, label))
.collect();
let title = if app.cmd_popup_filter.is_empty() {
" 命令面板 ".to_string()
} else {
format!(" 命令面板 [{}] ", app.cmd_popup_filter)
};
render_command_popup(
f,
main_area,
&CommandPopupConfig {
title,
items: cmd_items,
selected: app.cmd_popup_selected,
highlight_fg: None,
theme: &app.theme,
},
);
}
fn render_status_bar(f: &mut ratatui::Frame, app: &TodoApp, area: ratatui::layout::Rect) {
match &app.mode {
AppMode::Adding => {
let status = Paragraph::new(Line::from(vec![
Span::styled(
" ✏️ 添加模式",
Style::default()
.fg(app.theme.config_toggle_on)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" — 在列表中输入内容",
Style::default().fg(app.theme.text_dim),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.config_toggle_on)),
);
f.render_widget(status, area);
}
AppMode::Editing => {
let status = Paragraph::new(Line::from(vec![
Span::styled(
" ✏️ 编辑模式",
Style::default()
.fg(app.theme.config_pointer)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" — 在列表中修改内容",
Style::default().fg(app.theme.text_dim),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.config_pointer)),
);
f.render_widget(status, area);
}
AppMode::ConfirmDelete => {
let msg = if let Some(real_idx) = app.selected_real_index() {
format!(
" 确认删除「{}」?(y 确认 / n 取消)",
app.list.items[real_idx].content
)
} else {
" 没有选中的项目".to_string()
};
draw_confirm_dialog(
f,
area,
&ConfirmDialogConfig {
title: " ⚠️ 确认删除 ",
message: msg,
color: app.theme.toast_error_text,
},
);
}
AppMode::ConfirmReport => {
let inner_width = area.width.saturating_sub(2) as usize;
let msg = if let Some(ref content) = app.report_pending_content {
let prefix = " 写入日报: \"";
let suffix = "\" ? (Enter/y 写入, 其他跳过)";
let prefix_w = display_width(prefix);
let suffix_w = display_width(suffix);
let budget = inner_width.saturating_sub(prefix_w + suffix_w);
let truncated = truncate_to_width(content, budget);
format!("{}{}{}", prefix, truncated, suffix)
} else {
" 没有待写入的内容".to_string()
};
draw_confirm_dialog(
f,
area,
&ConfirmDialogConfig {
title: " 📝 写入日报 ",
message: msg,
color: app.theme.md_h1,
},
);
}
AppMode::ConfirmCancelInput => {
let inner_width = area.width.saturating_sub(2) as usize;
let prefix = " ⚠️ 是否保存?当前输入: \"";
let suffix = "\" (Enter/y 保存 / n/Esc 放弃 / 其他键继续编辑)";
let prefix_w = display_width(prefix);
let suffix_w = display_width(suffix);
let budget = inner_width.saturating_sub(prefix_w + suffix_w);
let truncated = truncate_to_width(&app.input, budget);
let msg = format!("{}{}{}", prefix, truncated, suffix);
draw_confirm_dialog(
f,
area,
&ConfirmDialogConfig {
title: " ⚠️ 未保存的内容 ",
message: msg,
color: app.theme.config_pointer,
},
);
}
AppMode::CommandPopup => {
draw_status_input(
f,
area,
&StatusInputParams {
label: "命令面板",
label_color: app.theme.md_h1,
input: &app.cmd_popup_filter,
cursor_pos: app.cmd_popup_filter.chars().count(),
placeholder: "输入筛选…",
hint: "↑↓ 选择 | Enter 确认 | Esc 取消",
},
&app.theme,
);
}
_ => {
let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
let dirty_indicator = if app.is_dirty() { " [未保存]" } else { "" };
let status_widget = Paragraph::new(Line::from(vec![
Span::styled(msg, Style::default().fg(app.theme.text_dim)),
Span::styled(
dirty_indicator,
Style::default()
.fg(app.theme.toast_error_text)
.add_modifier(Modifier::BOLD),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(app.theme.separator)),
);
f.render_widget(status_widget, area);
}
}
}