use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use unicode_width::UnicodeWidthStr;
fn char_width(ch: char) -> usize {
unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0)
}
fn slice_by_width(s: &str, start: usize, max_width: usize) -> (usize, usize) {
let mut end = start;
let mut width = 0;
for (i, ch) in s[start..].char_indices() {
let w = char_width(ch);
if width + w > max_width {
break;
}
width += w;
end = start + i + ch.len_utf8();
}
(end, width)
}
use super::theme::{ACCENT, BG, INFO, WARN};
use crate::state::{AppState, HelpLineSpec};
fn clear_popup(frame: &mut ratatui::Frame<'_>, popup: Rect) {
let total_w = frame.area().width;
let x = popup.x.saturating_sub(1);
let w = (popup.width + 2).min(total_w.saturating_sub(x));
let expanded = Rect::new(x, popup.y, w, popup.height);
frame.render_widget(Clear, expanded);
frame.render_widget(Block::default().style(Style::default().bg(BG)), expanded);
}
pub fn render_help_popup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
let spec = state.help_popup_spec();
if !spec.show {
return;
}
let popup = centered_rect_abs(50, 20, area);
clear_popup(frame, popup);
let block = Block::default()
.title("帮助")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(BG))
.border_style(Style::default().fg(INFO));
frame.render_widget(block, popup);
let key_style = Style::default().fg(ACCENT).add_modifier(Modifier::BOLD);
let sep = Style::default().fg(ratatui::style::Color::DarkGray);
let mut lines = Vec::new();
for line in spec.lines {
match line {
HelpLineSpec::Title(text) => lines.push(Line::from(Span::styled(
text,
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
))),
HelpLineSpec::Empty => lines.push(Line::raw("")),
HelpLineSpec::Shortcut { key, description } => lines.push(Line::from(vec![
Span::styled(key, key_style),
Span::styled(" — ", sep),
Span::raw(description),
])),
HelpLineSpec::Raw(text) => lines.push(Line::raw(text)),
}
}
let help = Paragraph::new(Text::from(lines));
frame.render_widget(help, popup.inner(Margin::new(2, 1)));
}
pub fn render_confirm_stop_popup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
let spec = state.confirm_stop_popup_spec();
if !spec.show {
return;
}
let popup = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(7),
Constraint::Fill(1),
])
.split(area);
let popup = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(36),
Constraint::Fill(1),
])
.split(popup[1])[1];
clear_popup(frame, popup);
let block = Block::default()
.title(" 中止隧道 ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(BG))
.border_style(Style::default().fg(WARN));
frame.render_widget(block, popup);
let inner = popup.inner(Margin::new(2, 1));
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(spec.prompt, Style::default().fg(Color::White))),
rows[0],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"[Y]",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
),
Span::styled(" 停止 ", Style::default().fg(Color::Gray)),
Span::styled(
"[N]",
Style::default().fg(INFO).add_modifier(Modifier::BOLD),
),
Span::styled(" 取消", Style::default().fg(Color::Gray)),
])),
rows[2],
);
}
pub fn centered_rect_abs(width: u16, height: u16, area: Rect) -> Rect {
let clamped_w = width.min(area.width);
let w = clamped_w & !1;
let h = height.min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect::new(x, y, w, h)
}
pub fn render_edit_popup(frame: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
let spec = state.edit_popup_spec();
if !spec.show {
return;
}
let popup_h = (spec.fields.len() * 3 + 4) as u16;
let vert = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(popup_h),
Constraint::Fill(1),
])
.split(area);
let horiz = Layout::horizontal([
Constraint::Fill(1),
Constraint::Percentage(60),
Constraint::Fill(1),
])
.split(vert[1]);
let popup = horiz[1];
clear_popup(frame, popup);
let block = Block::default()
.title(spec.title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().bg(BG))
.border_style(Style::default().fg(ACCENT));
frame.render_widget(block, popup);
let inner = popup.inner(Margin::new(2, 1));
let mut constraints = vec![Constraint::Length(1)];
for _ in &spec.fields {
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1));
let rows = Layout::vertical(constraints).split(inner);
for (i, field) in spec.fields.iter().enumerate() {
let base = 1 + i * 3;
let label_row = rows[base];
let value_row = rows[base + 1];
let label_style = if field.active {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(
Paragraph::new(Span::styled(field.label, label_style)),
label_row,
);
let max_w = value_row.width as usize;
let (display, cursor_offset) = if field.active {
let before_cursor = &field.value[..field.cursor];
let cursor_width = UnicodeWidthStr::width(before_cursor);
let full_width = UnicodeWidthStr::width(field.value.as_str());
if full_width == 0 {
(" ".to_string(), 0usize)
} else if cursor_width >= max_w {
let mut start_byte = 0;
let mut acc_width = 0;
let target = cursor_width.saturating_sub(max_w);
for (i, ch) in field.value.char_indices() {
let w = char_width(ch);
if acc_width + w > target + w {
break;
}
acc_width += w;
start_byte = i + ch.len_utf8();
}
let (end_byte, _) = slice_by_width(&field.value, start_byte, max_w);
let s = field.value[start_byte..end_byte].to_string();
let prefix_w = UnicodeWidthStr::width(&field.value[start_byte..field.cursor]);
(s, prefix_w)
} else {
let (end_byte, _) = slice_by_width(&field.value, 0, max_w);
let s = field.value[..end_byte].to_string();
(s, cursor_width)
}
} else if field.value.is_empty() {
("(空)".to_string(), 0)
} else {
let full_width = UnicodeWidthStr::width(field.value.as_str());
if full_width <= max_w {
(field.value.clone(), 0)
} else {
let (end_byte, _) = slice_by_width(&field.value, 0, max_w.saturating_sub(1));
let mut s = field.value[..end_byte].to_string();
s.push('…');
(s, 0)
}
};
let value_style = if field.active {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::UNDERLINED)
} else {
Style::default().fg(Color::Gray)
};
frame.render_widget(
Paragraph::new(Span::styled(display, value_style)),
value_row,
);
if field.active {
let offset = u16::try_from(cursor_offset).unwrap_or(u16::MAX);
frame.set_cursor_position((value_row.x.saturating_add(offset), value_row.y));
}
}
let hint_row = rows[1 + spec.fields.len() * 3];
frame.render_widget(
Paragraph::new(Span::styled(
spec.hint,
Style::default().fg(Color::DarkGray),
)),
hint_row,
);
}