fix(plan_prompt): pre-wrap CJK+Latin mixed text to avoid forced line-breaks at script boundaries

Wrap plan steps via wrap_text() before rendering, breaking only on display-width overflow, not on Latin/CJK Unicode word boundaries. Switch main render path from Wrap { trim: true } to Wrap { trim: false } since all content is pre-wrapped. Replace wrapped_line_count() with lines.len() for accurate scroll bounds. Keep confirm-exit dialog on Wrap { trim: true } (English-only, no risk).
This commit is contained in:
Implementist
2026-06-03 23:44:00 +08:00
committed by Hunter B
parent 966b5cf1fb
commit 88422f3ad3
+35 -86
View File
@@ -278,16 +278,8 @@ impl ModalView for PlanPromptView {
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.scroll = 0;
self.pending_g = false;
ViewAction::None
}
KeyCode::Char('g')
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.pending_g = true;
self.scroll = 0;
ViewAction::None
}
KeyCode::Char('G')
@@ -298,14 +290,6 @@ impl ModalView for PlanPromptView {
self.scroll = usize::MAX;
ViewAction::None
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll = self.scroll.saturating_add(6);
ViewAction::None
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll = self.scroll.saturating_sub(6);
ViewAction::None
}
KeyCode::Home => {
self.scroll = 0;
ViewAction::None
@@ -314,6 +298,18 @@ impl ModalView for PlanPromptView {
self.scroll = usize::MAX;
ViewAction::None
}
KeyCode::Char('g') => {
self.pending_g = true;
ViewAction::None
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll = self.scroll.saturating_add(6);
ViewAction::None
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll = self.scroll.saturating_sub(6);
ViewAction::None
}
_ => ViewAction::None,
}
}
@@ -395,10 +391,13 @@ impl ModalView for PlanPromptView {
crate::tools::plan::StepStatus::InProgress => "\u{25b6}",
crate::tools::plan::StepStatus::Completed => "\u{2713}",
};
lines.push(Line::from(Span::styled(
format!(" {status_mark} {}. {}", i + 1, &item.step),
Style::default().fg(palette::TEXT_PRIMARY),
)));
let step_text = format!(" {status_mark} {}. {}", i + 1, &item.step);
for line in wrap_text(&step_text, content_width) {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
}
lines.push(Line::from(""));
}
@@ -416,8 +415,9 @@ impl ModalView for PlanPromptView {
}
// Calculate scroll bounds so long plan content doesn't clip the options.
// Use wrapped_line_count to estimate post-wrap line count.
let total_lines = wrapped_line_count(&lines, content_width);
// Since plan steps are now pre-wrapped via wrap_text(), each Line is
// already width-bounded — use the raw line count directly.
let total_lines = lines.len();
let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1);
let max_scroll = total_lines.saturating_sub(visible_lines);
self.last_max_scroll.set(max_scroll);
@@ -428,7 +428,11 @@ impl ModalView for PlanPromptView {
let mut footer_spans: Vec<Span> = Vec::new();
if total_lines > visible_lines {
footer_spans.push(Span::styled(
format!(" [{}/{} PgUp/Dn · Ctrl+U/D] ", scroll + 1, max_scroll + 1),
format!(
" [{}/{} PgUp/Dn \u{b7} Ctrl+U/D] ",
scroll + 1,
max_scroll + 1
),
Style::default().fg(palette::DEEPSEEK_SKY),
));
}
@@ -453,15 +457,20 @@ impl ModalView for PlanPromptView {
// Selected option description, right-aligned by filling space.
let desc = PLAN_OPTIONS[self.selected].description;
let desc_span = Span::styled(
format!(" {desc}"),
format!(" \u{2192} {desc}"),
Style::default().fg(palette::TEXT_MUTED),
);
footer_spans.push(desc_span);
render_modal_chrome(area, popup_area, buf);
// Wrap { trim: false } — disable ratatui's word-boundary-based line
// wrapping. All content is already pre-wrapped via wrap_text() above,
// which breaks only on display-width overflow, not on script boundaries
// (Latin ↔ CJK). This avoids forced line-breaks between English and
// Chinese characters when there is still room on the current line.
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.wrap(Wrap { trim: false })
.block(modal_block().title_bottom(Line::from(footer_spans)))
.scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0));
@@ -529,66 +538,6 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
lines
}
/// Estimate the number of display lines after word-wrapping a set of logical
/// lines to `width` columns. Simulates ratatui's word-wrapping (breaks at word
/// boundaries) and accounts for CJK display widths via `UnicodeWidthStr`.
fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize {
if width == 0 {
return lines.len().max(1);
}
let mut total = 0usize;
for line in lines {
let text: String = line.iter().map(|s| s.content.as_ref()).collect();
if text.is_empty() {
total += 1;
continue;
}
let leading_bytes = text.len() - text.trim_start().len();
let leading_spaces =
UnicodeWidthStr::width(&text[..leading_bytes]).min(width.saturating_sub(1));
let mut line_count = 0;
let mut current_width = leading_spaces;
let mut first_word = true;
for word in text.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
if first_word {
let total_width = leading_spaces + word_width;
if total_width > width {
let lines_needed = total_width.div_ceil(width);
line_count = lines_needed;
current_width = total_width % width;
if current_width == 0 {
current_width = width;
}
} else {
current_width = total_width;
line_count = 1;
}
first_word = false;
} else if current_width + 1 + word_width > width {
line_count += 1;
if word_width > width {
let lines_needed = word_width.div_ceil(width);
line_count += lines_needed - 1;
current_width = word_width % width;
if current_width == 0 {
current_width = width;
}
} else {
current_width = word_width;
}
} else {
current_width += 1 + word_width;
}
}
if line_count == 0 {
line_count = 1;
}
total += line_count;
}
total
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)