use super::app::{AppMode, FlatEntryKind, NotebookApp};
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 ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
pub fn draw_ui(f: &mut ratatui::Frame, app: &mut NotebookApp) {
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 total = app.notes.len();
let dir_count = app
.flat_entries
.iter()
.filter(|e| matches!(e.kind, FlatEntryKind::Dir { .. }))
.count();
let filter_suffix = match &app.search_filter {
Some(kw) => format!(" [搜索: {}]", kw),
None => String::new(),
};
let title = if dir_count > 0 {
format!(
" 笔记本{} — {} 篇笔记, {} 个文件夹 ",
filter_suffix, total, dir_count
)
} else {
format!(" 笔记本{} — 共 {} 篇 ", filter_suffix, total)
};
let title_block = Paragraph::new(Line::from(vec![Span::styled(
title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(title_block, chunks[0]);
if app.mode == AppMode::Help {
render_help(f, chunks[1], &app.theme);
} else if app.mode == AppMode::Preview {
render_preview_full(f, app, chunks[1]);
} else {
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(app.panel_ratio), Constraint::Percentage(100 - app.panel_ratio), ])
.split(chunks[1]);
render_list(f, app, main_chunks[0]);
render_preview(f, app, main_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/↑ 上移 | Enter/e 编辑 | a 新建 | d 删除 | r 重命名 | Tab 展开/折叠 | p 预览 | / 命令面板 | [ ] 调整比例 | y 复制 | o 打开目录 | s 刷新 | ? 帮助 | q 退出"
}
AppMode::Preview => " ↑↓/jk 滚动 | n/N 切换笔记 | p/Esc 退出预览",
AppMode::Adding => " Enter 确认新建 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾",
AppMode::Renaming => " Enter 确认重命名 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾",
AppMode::Search => " Enter 搜索 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾",
AppMode::ConfirmDelete => " y 确认删除 | n/Esc 取消",
AppMode::Help => " 按任意键返回",
AppMode::CommandPopup => " ↑↓/jk 选择 | Enter 确认 | 输入筛选 | Esc 取消",
AppMode::RatioInput => " Enter 确认 | Esc 取消 | 格式: x:y (如 20:80)",
AppMode::Mkdir => " Enter 确认创建目录 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾",
AppMode::Mv => " Enter 确认移动 | Esc 取消 | ←→ 移动光标 | Home/End 行首尾",
};
let help_widget = Paragraph::new(Line::from(Span::styled(
help_text,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(help_widget, chunks[3]);
}
fn render_list(f: &mut ratatui::Frame, app: &mut NotebookApp, area: Rect) {
let inner_width = area.width.saturating_sub(2) as usize; let selected = app.state.selected();
let mut items: Vec<ListItem> = app
.flat_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let is_selected = selected == Some(i);
if let FlatEntryKind::File { note_index } = &entry.kind
&& app.mode == AppMode::Renaming
&& app.rename_index == Some(*note_index)
{
return build_rename_item(
&app.input,
app.cursor_pos,
inner_width,
is_selected,
&app.theme,
);
}
let indent_style = Style::default().fg(Color::DarkGray);
match &entry.kind {
FlatEntryKind::Dir {
name, file_count, ..
} => {
let dir_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let count_str = format!(" ({})", file_count);
ListItem::new(Line::from(vec![
Span::styled(entry.guide.clone(), indent_style),
Span::styled(name.clone(), dir_style),
Span::styled(count_str, Style::default().fg(Color::DarkGray)),
]))
}
FlatEntryKind::File { note_index } => {
let note = &app.notes[*note_index];
let name_style = Style::default().fg(Color::Reset);
let guide_width = unicode_width::UnicodeWidthStr::width(entry.guide.as_str());
let name_display_width = inner_width.saturating_sub(guide_width);
let display_name = note.display_name();
let name_text =
if display_name.chars().collect::<Vec<_>>().len() > name_display_width {
let mut s: String = display_name
.chars()
.take(name_display_width.saturating_sub(2))
.collect();
s.push_str("..");
s
} else {
display_name.to_string()
};
ListItem::new(Line::from(vec![
Span::styled(entry.guide.clone(), indent_style),
Span::styled(name_text, name_style),
]))
}
}
})
.collect();
if app.mode == AppMode::Adding {
let is_selected = selected == Some(app.flat_entries.len());
items.push(build_adding_item(
&app.input,
app.cursor_pos,
inner_width,
is_selected,
&app.theme,
));
}
let list_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(" 笔记列表 ");
if items.is_empty() {
let empty_hint = List::new(vec![ListItem::new(Line::from(Span::styled(
" (空) 按 a 新建笔记...",
Style::default().fg(Color::DarkGray),
)))])
.block(list_block);
f.render_widget(empty_hint, area);
} else {
let list_widget = List::new(items).block(list_block).highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list_widget, area, &mut app.state);
}
}
fn build_adding_item(
input: &str,
cursor_pos: usize,
width: usize,
selected: bool,
theme: &crate::theme::Theme,
) -> ListItem<'static> {
let pointer = if selected {
Span::styled(
" ❯ ",
Style::default()
.fg(theme.md_h1)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let content_width = width.saturating_sub(3); let cursor_lines =
cursor_wrapped_lines(input, cursor_pos, content_width, Some("输入标题…"), theme);
let mut item_lines: Vec<Line<'static>> = Vec::new();
for (i, line) in cursor_lines.lines.into_iter().enumerate() {
let mut spans = if i == 0 {
vec![pointer.clone()]
} else {
vec![Span::raw(" ")]
};
spans.extend(line.spans);
item_lines.push(Line::from(spans));
}
ListItem::new(item_lines)
}
fn build_rename_item(
input: &str,
cursor_pos: usize,
width: usize,
selected: bool,
theme: &crate::theme::Theme,
) -> ListItem<'static> {
build_adding_item(input, cursor_pos, width, selected, theme)
}
fn render_preview(f: &mut ratatui::Frame, app: &mut NotebookApp, area: Rect) {
let inner_width = area.width.saturating_sub(2); app.render_preview_with_width(inner_width);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let content = if app.preview_lines.is_empty() {
match &app.preview_content {
Some(_) => Paragraph::new(Line::from(Span::styled(
" (空笔记)",
Style::default().fg(Color::DarkGray),
)))
.block(block),
None => Paragraph::new(Line::from(Span::styled(
" 选择笔记以预览内容",
Style::default().fg(Color::DarkGray),
)))
.block(block),
}
} else {
Paragraph::new(app.preview_lines.clone())
.block(block)
.wrap(Wrap { trim: false })
.scroll((app.preview_scroll, 0))
};
f.render_widget(content, area);
}
fn render_preview_full(f: &mut ratatui::Frame, app: &mut NotebookApp, area: Rect) {
let inner_width = area.width.saturating_sub(2);
app.render_preview_with_width(inner_width);
let title = match app.selected_name() {
Some(name) => format!(" 预览: {} ", name),
None => " 预览 ".to_string(),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(title);
if app.preview_lines.is_empty() {
let content = Paragraph::new(Line::from(Span::styled(
" (空)",
Style::default().fg(Color::DarkGray),
)))
.block(block);
f.render_widget(content, area);
} else {
let scroll = app.preview_scroll as usize;
let visible_lines: Vec<Line> = app.preview_lines.iter().skip(scroll).cloned().collect();
if visible_lines.is_empty() {
let content = Paragraph::new(Line::from(Span::styled(
" (已到末尾)",
Style::default().fg(Color::DarkGray),
)))
.block(block);
f.render_widget(content, area);
} else {
let content = Paragraph::new(visible_lines).block(block);
f.render_widget(content, area);
}
}
}
fn render_help(f: &mut ratatui::Frame, area: Rect, theme: &crate::theme::Theme) {
use crate::tui::components::{HelpPageConfig, HelpShortcut, draw_help_page};
let shortcuts = [
HelpShortcut::new(" n / ↓ / j ", "向下移动"),
HelpShortcut::new(" N / ↑ / k ", "向上移动"),
HelpShortcut::new(" Enter / e ", "编辑笔记(Markdown 编辑器)"),
HelpShortcut::new(" a ", "新建笔记"),
HelpShortcut::new(" d ", "删除笔记(需确认)"),
HelpShortcut::new(" r ", "重命名笔记"),
HelpShortcut::new(" Tab ", "展开/折叠目录"),
HelpShortcut::new(" / mkdir ", "新建目录"),
HelpShortcut::new(" / mv ", "移动笔记到目录"),
HelpShortcut::new(" p ", "全屏预览当前笔记"),
HelpShortcut::new(" / ", "打开命令面板"),
HelpShortcut::new(" [ ", "缩小左侧面板 (-5%)"),
HelpShortcut::new(" ] ", "扩大左侧面板 (+5%)"),
HelpShortcut::new(" y ", "复制笔记名到剪切板"),
HelpShortcut::new(" o ", "在 Finder 中打开 notebook 目录"),
HelpShortcut::new(" s ", "刷新笔记列表"),
HelpShortcut::new(" Esc ", "清除搜索 / 退出"),
HelpShortcut::new(" q ", "退出"),
HelpShortcut::new(" Ctrl+C ", "强制退出"),
HelpShortcut::new(" ? ", "显示此帮助"),
];
draw_help_page(
f,
area,
&HelpPageConfig {
title: "快捷键帮助",
title_icon: None,
block_title: Some(" 帮助 "),
shortcuts: &shortcuts,
footer_lines: None,
theme,
},
);
}
fn render_status_bar(f: &mut ratatui::Frame, app: &NotebookApp, area: Rect) {
match &app.mode {
AppMode::Adding => {
let status = Paragraph::new(Line::from(vec![
Span::styled(
" 新建笔记",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" — 输入标题后按 Enter 创建",
Style::default().fg(Color::DarkGray),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
);
f.render_widget(status, area);
}
AppMode::Renaming => {
let status = Paragraph::new(Line::from(vec![
Span::styled(
" 重命名",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" — 输入新名称后按 Enter 确认",
Style::default().fg(Color::DarkGray),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
);
f.render_widget(status, area);
}
AppMode::Mkdir => {
draw_status_input(
f,
area,
&StatusInputParams {
label: "新建目录",
label_color: Color::Cyan,
input: &app.input,
cursor_pos: app.cursor_pos,
placeholder: "输入目录名…",
hint: "Enter 确认 | Esc 取消",
},
&app.theme,
);
}
AppMode::Mv => {
draw_status_input(
f,
area,
&StatusInputParams {
label: "移动笔记",
label_color: Color::Magenta,
input: &app.input,
cursor_pos: app.cursor_pos,
placeholder: "输入目标路径…",
hint: "Enter 确认 | Esc 取消",
},
&app.theme,
);
}
AppMode::Search => {
draw_status_input(
f,
area,
&StatusInputParams {
label: "搜索",
label_color: Color::Cyan,
input: &app.input,
cursor_pos: app.cursor_pos,
placeholder: "输入关键词…",
hint: "Enter 搜索 | Esc 取消",
},
&app.theme,
);
}
AppMode::ConfirmDelete => {
let msg = if let Some(name) = app.selected_name() {
format!(" 确认删除\"{}\"? (y/n)", name)
} else {
" 没有选中的笔记".to_string()
};
draw_confirm_dialog(
f,
area,
&ConfirmDialogConfig {
title: " 确认删除 ",
message: msg,
color: Color::Red,
},
);
}
AppMode::Preview => {
let status = Paragraph::new(Line::from(vec![
Span::styled(
" 预览模式",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" — ↑↓/jk 滚动 | p/Esc 退出预览",
Style::default().fg(Color::DarkGray),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(status, area);
}
AppMode::CommandPopup => {
draw_status_input(
f,
area,
&StatusInputParams {
label: "命令面板",
label_color: Color::Magenta,
input: &app.cmd_popup_filter,
cursor_pos: app.cmd_popup_filter.chars().count(),
placeholder: "输入筛选…",
hint: "↑↓ 选择 | Enter 确认 | Esc 取消",
},
&app.theme,
);
}
AppMode::RatioInput => {
draw_status_input(
f,
area,
&StatusInputParams {
label: "比例",
label_color: Color::Yellow,
input: &app.input,
cursor_pos: app.cursor_pos,
placeholder: "20:80",
hint: "如 20:80",
},
&app.theme,
);
}
_ => {
let msg = app.message.as_deref().unwrap_or("按 ? 查看完整帮助");
let status_widget = Paragraph::new(Line::from(Span::styled(
format!(" {}", msg),
Style::default().fg(Color::Gray),
)))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(status_widget, area);
}
}
}
fn draw_command_popup(f: &mut ratatui::Frame, app: &mut NotebookApp, 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: Some(Color::Black),
theme: &app.theme,
},
);
}