refactor(plan_prompt): use display-width in wrap_text, skip wasted render work

- wrap_text: replace chars().count() with UnicodeWidthStr::width() so
  CJK text is wrapped by display columns, consistent with
  wrapped_line_count and ratatui's Paragraph::wrap.  Also fix the
  hard-split loop to use exclusive byte ranges (..end) instead of
  inclusive (..=i) so multi-byte UTF-8 prefixes are always valid.
- render: hoist the confirming_exit branch to an early return so the
  plan-content construction (lines, scroll bounds, footer) is skipped
  entirely when the confirmation dialog is visible.
This commit is contained in:
Implementist
2026-06-03 23:12:58 +08:00
committed by Hunter B
parent e3a52555eb
commit 966b5cf1fb
+70 -54
View File
@@ -319,6 +319,47 @@ impl ModalView for PlanPromptView {
}
fn render(&self, area: Rect, buf: &mut Buffer) {
// When the user pressed Esc after scrolling, show a confirmation prompt
// instead of the normal plan + options. Render it early so we skip the
// plan-content construction entirely.
if self.confirming_exit {
let confirm_lines = vec![
Line::from(Span::styled(
"Exit without implementing?",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)),
Line::from(""),
Line::from(Span::styled(
"You've scrolled through the plan content. Are you sure you want to exit?",
Style::default().fg(palette::TEXT_PRIMARY),
)),
Line::from(""),
Line::from(Span::styled(
" y — Yes, exit Plan mode",
Style::default().fg(palette::DEEPSEEK_SKY),
)),
Line::from(Span::styled(
" n / Esc — Cancel, go back to plan",
Style::default().fg(palette::TEXT_MUTED),
)),
];
let confirm_footer = Line::from(vec![
Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]);
let popup_area = centered_rect(66, 34, area);
render_modal_chrome(area, popup_area, buf);
let confirm = Paragraph::new(confirm_lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(modal_block().title_bottom(confirm_footer));
confirm.render(popup_area, buf);
return;
}
let popup_area = centered_rect(72, 52, area);
let content_width = usize::from(popup_area.width.saturating_sub(4).max(1));
let mut lines: Vec<Line> = Vec::new();
@@ -417,53 +458,14 @@ impl ModalView for PlanPromptView {
);
footer_spans.push(desc_span);
// When the user pressed Esc after scrolling, show a confirmation prompt
// instead of the normal plan + options.
if self.confirming_exit {
let confirm_lines = vec![
Line::from(Span::styled(
"Exit without implementing?",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)),
Line::from(""),
Line::from(Span::styled(
"You've scrolled through the plan content. Are you sure you want to exit?",
Style::default().fg(palette::TEXT_PRIMARY),
)),
Line::from(""),
Line::from(Span::styled(
" y — Yes, exit Plan mode",
Style::default().fg(palette::DEEPSEEK_SKY),
)),
Line::from(Span::styled(
" n / Esc — Cancel, go back to plan",
Style::default().fg(palette::TEXT_MUTED),
)),
];
let confirm_footer = Line::from(vec![
Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]);
let popup_area = centered_rect(66, 34, area);
render_modal_chrome(area, popup_area, buf);
let confirm = Paragraph::new(confirm_lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(modal_block().title_bottom(confirm_footer));
confirm.render(popup_area, buf);
} else {
render_modal_chrome(area, popup_area, buf);
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(modal_block().title_bottom(Line::from(footer_spans)))
.scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
render_modal_chrome(area, popup_area, buf);
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(modal_block().title_bottom(Line::from(footer_spans)))
.scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
paragraph.render(popup_area, buf);
}
paragraph.render(popup_area, buf);
}
}
@@ -481,21 +483,35 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
let words: Vec<&str> = paragraph.split_whitespace().collect();
let mut current = String::new();
for word in words {
let word_width = word.chars().count();
let word_width = UnicodeWidthStr::width(word);
if word_width > width {
if !current.is_empty() {
lines.push(current.trim_end().to_string());
current.clear();
}
let mut chars = word.chars();
loop {
let segment: String = chars.by_ref().take(width).collect();
if segment.is_empty() {
break;
// Split an over-width word by display width, not code points,
// so CJK characters are measured consistently with
// wrapped_line_count and ratatui's Paragraph::wrap.
let mut remaining = word;
while !remaining.is_empty() {
let mut split_at = 0usize;
for (i, ch) in remaining.char_indices() {
// Use the exclusive byte range [..end) so the prefix is
// always valid UTF-8, even for multi-byte characters.
let end = i + ch.len_utf8();
if UnicodeWidthStr::width(&remaining[..end]) > width {
break;
}
split_at = end;
}
lines.push(segment);
if split_at == 0 {
// Even one character is wider than width; take it anyway.
split_at = remaining.chars().next().unwrap().len_utf8();
}
lines.push(remaining[..split_at].to_string());
remaining = &remaining[split_at..];
}
} else if current.chars().count() + 1 + word_width > width {
} else if UnicodeWidthStr::width(current.as_str()) + 1 + word_width > width {
lines.push(current.trim_end().to_string());
current.clear();
current.push_str(word);