use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
#[derive(Debug, Clone, PartialEq)]
pub enum ConfirmAction {
DeleteTask,
DeleteProject,
HideProject,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DialogType {
Input {
title: String,
prompt: String,
value: String,
cursor_pos: usize,
},
Select {
title: String,
items: Vec<String>,
selected: usize,
filter: String,
},
Confirm {
title: String,
message: String,
yes_selected: bool,
action: ConfirmAction, },
}
pub fn render_dialog(f: &mut Frame, dialog: &DialogType) {
render_backdrop(f, f.area());
let area = centered_rect(60, 50, f.area());
f.render_widget(Clear, area);
match dialog {
DialogType::Input {
title,
prompt,
value,
cursor_pos,
} => render_input_dialog(f, area, title, prompt, value, *cursor_pos),
DialogType::Select {
title,
items,
selected,
filter,
} => render_select_dialog(f, area, title, items, *selected, filter),
DialogType::Confirm {
title,
message,
yes_selected,
..
} => render_confirm_dialog(f, area, title, message, *yes_selected),
}
}
fn render_backdrop(f: &mut Frame, area: Rect) {
let block = Block::default().style(Style::default().bg(Color::Rgb(0, 0, 0))); f.render_widget(block, area);
}
fn render_input_dialog(
f: &mut Frame,
area: Rect,
title: &str,
prompt: &str,
value: &str,
cursor_pos: usize,
) {
let is_task_input = title.contains("任务");
let block = Block::default()
.title(format!(" {} ", title))
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(76, 86, 106))) .border_type(ratatui::widgets::BorderType::Rounded)
.style(Style::default().bg(Color::Rgb(46, 52, 64)));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = if is_task_input {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(10), Constraint::Length(3), ])
.split(inner)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(3), Constraint::Length(2), ])
.split(inner)
};
let prompt_text = if is_task_input {
Paragraph::new(format!("{}\n(支持多行输入,包含任务的所有内容)", prompt))
.style(Style::default().fg(Color::Rgb(129, 161, 193))) } else {
Paragraph::new(prompt).style(Style::default().fg(Color::Rgb(129, 161, 193)))
};
f.render_widget(prompt_text, chunks[0]);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(136, 192, 208))) .border_type(ratatui::widgets::BorderType::Rounded);
let input_inner = input_block.inner(chunks[1]);
f.render_widget(input_block, chunks[1]);
let chars: Vec<char> = value.chars().collect();
let input_with_cursor = if cursor_pos >= chars.len() {
format!("{}_", value)
} else {
let mut display_chars = chars.clone();
display_chars.insert(cursor_pos, '|');
display_chars.into_iter().collect()
};
let input_text = Paragraph::new(input_with_cursor)
.wrap(Wrap { trim: false }); f.render_widget(input_text, input_inner);
let help_text = if is_task_input {
"Ctrl+J: 换行 | Enter: 确认 | ESC: 取消"
} else {
"Enter: 确认 | ESC: 取消"
};
let help = Paragraph::new(help_text)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
fn render_select_dialog(
f: &mut Frame,
area: Rect,
title: &str,
items: &[String],
selected: usize,
filter: &str,
) {
let block = Block::default()
.title(format!(" {} ", title))
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(76, 86, 106))) .border_type(ratatui::widgets::BorderType::Rounded)
.style(Style::default().bg(Color::Rgb(46, 52, 64)));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
.split(inner);
let search_text = if filter.is_empty() {
"🔍 输入搜索...".to_string()
} else {
format!("🔍 {}", filter)
};
let search_style = if filter.is_empty() {
Style::default().fg(Color::Rgb(129, 161, 193)) } else {
Style::default().fg(Color::Rgb(136, 192, 208)) };
let search_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(136, 192, 208)))
.border_type(ratatui::widgets::BorderType::Rounded);
let search_inner = search_block.inner(chunks[0]);
f.render_widget(search_block, chunks[0]);
let search_paragraph = Paragraph::new(search_text)
.style(search_style);
f.render_widget(search_paragraph, search_inner);
let filtered_items: Vec<_> = if filter.is_empty() {
items.iter().enumerate().collect()
} else {
items
.iter()
.enumerate()
.filter(|(_, item)| item.to_lowercase().contains(&filter.to_lowercase()))
.collect()
};
let list_items: Vec<ListItem> = filtered_items
.iter()
.flat_map(|(idx, item)| {
let is_selected = *idx == selected;
let lines: Vec<&str> = item.lines().collect();
let main_line = lines.get(0).unwrap_or(&"");
let sub_line = lines.get(1);
let mut content_lines = vec![];
if *idx == 0 {
content_lines.push(Line::from(""));
}
if is_selected {
let sequence_num = format!("{}", *idx + 1);
content_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
sequence_num,
Style::default()
.fg(Color::White)
.bg(Color::Rgb(94, 129, 172)) .add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
*main_line,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("✓", Style::default().fg(Color::Rgb(163, 190, 140))), Span::raw(" "),
Span::styled(
"Enter",
Style::default()
.fg(Color::Black)
.bg(Color::Rgb(136, 192, 208)),
),
]));
} else {
content_lines.push(Line::from(format!(" {}", main_line)));
}
if let Some(sub) = sub_line {
let sub_style = if is_selected {
Style::default().fg(Color::Rgb(216, 222, 233))
} else {
Style::default().fg(Color::Rgb(129, 161, 193))
};
content_lines.push(Line::from(vec![
Span::styled(*sub, sub_style),
]));
}
if *idx < filtered_items.len() - 1 {
content_lines.push(Line::from(vec![
Span::styled(
" ────────────────────────────────────────────────────────",
Style::default().fg(Color::Rgb(76, 86, 106)), ),
]));
}
let style = if is_selected {
Style::default()
.bg(Color::Rgb(59, 66, 82)) } else {
Style::default()
};
vec![ListItem::new(content_lines).style(style)]
})
.collect();
let list = List::new(list_items);
let mut list_state = ratatui::widgets::ListState::default();
let filtered_selected = filtered_items.iter()
.position(|(idx, _)| *idx == selected)
.unwrap_or(0);
list_state.select(Some(filtered_selected));
f.render_stateful_widget(list, chunks[1], &mut list_state);
let help_text = format!("↑↓ 导航 Enter 确认 Esc 取消 [{}/{}]", filtered_items.len(), items.len());
let help_paragraph = Paragraph::new(help_text)
.style(Style::default().fg(Color::Rgb(129, 161, 193))) .alignment(Alignment::Center);
f.render_widget(help_paragraph, chunks[2]);
let count_text = format!("{}/{}", filtered_items.len(), items.len());
let count_area = Rect {
x: area.x + area.width.saturating_sub(count_text.len() as u16 + 3),
y: area.y,
width: count_text.len() as u16 + 2,
height: 1,
};
let count_paragraph = Paragraph::new(count_text)
.style(Style::default().fg(Color::Rgb(129, 161, 193)));
f.render_widget(count_paragraph, count_area);
}
fn render_confirm_dialog(
f: &mut Frame,
area: Rect,
title: &str,
message: &str,
yes_selected: bool,
) {
let block = Block::default()
.title(format!(" {} ", title))
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(235, 203, 139))) .border_type(ratatui::widgets::BorderType::Rounded)
.style(Style::default().bg(Color::Rgb(46, 52, 64)));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(3), ])
.split(inner);
let message_text = Paragraph::new(message)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Rgb(216, 222, 233))); f.render_widget(message_text, chunks[0]);
let button_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(chunks[1]);
let no_style = if !yes_selected {
Style::default()
.bg(Color::Rgb(191, 97, 106)) .fg(Color::Rgb(46, 52, 64)) .add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Rgb(191, 97, 106))
.add_modifier(Modifier::DIM)
};
let no_button = Paragraph::new("[ n ] 否")
.style(no_style)
.alignment(Alignment::Center);
f.render_widget(no_button, button_chunks[1]);
let yes_style = if yes_selected {
Style::default()
.bg(Color::Rgb(163, 190, 140)) .fg(Color::Rgb(46, 52, 64)) .add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Rgb(163, 190, 140))
.add_modifier(Modifier::DIM)
};
let yes_button = Paragraph::new("[ y ] 是")
.style(yes_style)
.alignment(Alignment::Center);
f.render_widget(yes_button, button_chunks[2]);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}