use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::app::App;
use crate::theme::Theme;
use crate::ui::styles;
use travelagent_core::model::LineRange;
pub(crate) const CONTENT_WIDTH: usize = 37;
pub(crate) fn wrap_line_to_width(text: &str, width: usize) -> Vec<String> {
if width == 0 || text.width() <= width {
return vec![text.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_w: usize = 0;
let mut last_ws_byte: Option<usize> = None;
for ch in text.chars() {
let ch_w = ch.width().unwrap_or(0);
if current_w + ch_w > width && !current.is_empty() {
if let Some(ws_idx) = last_ws_byte {
if let Some(ws_char) = current[ws_idx..].chars().next() {
let carry: String = current[ws_idx + ws_char.len_utf8()..].to_string();
let head: String = current[..ws_idx].to_string();
lines.push(head);
current = carry;
current_w = current.width();
last_ws_byte = None;
} else {
lines.push(std::mem::take(&mut current));
current_w = 0;
last_ws_byte = None;
}
} else {
lines.push(std::mem::take(&mut current));
current_w = 0;
}
}
if ch.is_whitespace() {
last_ws_byte = Some(current.len());
}
current.push(ch);
current_w += ch_w;
}
lines.push(current);
if lines.is_empty() {
lines.push(String::new());
}
lines
}
#[derive(Debug, Clone)]
pub struct CommentCursorInfo {
pub line_offset: usize,
pub column: u16,
}
#[derive(Debug, Clone)]
pub struct CommentTypePresentation {
pub label: String,
pub color: Color,
}
pub fn format_comment_input_lines(
theme: &Theme,
comment_type: CommentTypePresentation,
buffer: &str,
cursor_pos: usize,
line_range: Option<LineRange>,
is_editing: bool,
supports_keyboard_enhancement: bool,
) -> (Vec<Line<'static>>, CommentCursorInfo) {
let type_style = styles::comment_type_style(theme, comment_type.color);
let border_style = styles::comment_border_style(theme, comment_type.color);
let cursor_style = Style::default()
.fg(theme.cursor_color)
.add_modifier(Modifier::UNDERLINED);
let action = if is_editing { "Edit" } else { "Add" };
let line_info = match line_range {
Some(range) if range.is_single() => format!("L{} ", range.start),
Some(range) => format!("L{}-L{} ", range.start, range.end),
None => String::new(),
};
let newline_hint = if supports_keyboard_enhancement {
"Shift-Enter"
} else {
"Ctrl-J"
};
let mut result = Vec::new();
let border_prefix = " │ ";
let border_width = border_prefix.width() as u16;
let mut cursor_line_offset: usize = 1; let mut cursor_column: u16 = border_width;
result.push(Line::from(vec![
Span::styled(" ╭─ ", border_style),
Span::styled(format!("{action} "), styles::dim_style(theme)),
Span::styled(format!("[{}] ", comment_type.label), type_style),
Span::styled(line_info, styles::dim_style(theme)),
]));
if buffer.is_empty() {
result.push(Line::from(vec![
Span::styled(border_prefix, border_style),
Span::styled(" ", cursor_style),
Span::styled("Type your comment...", styles::dim_style(theme)),
]));
} else {
let buffer_lines: Vec<&str> = buffer.split('\n').collect();
let mut char_offset = 0;
let mut emitted_content_lines: usize = 0;
for (line_idx, text) in buffer_lines.iter().enumerate() {
let line_start = char_offset;
let line_end = char_offset + text.len();
let cursor_on_this_line = cursor_pos >= line_start
&& (cursor_pos <= line_end
|| (line_idx == buffer_lines.len() - 1 && cursor_pos == buffer.len()));
let wrapped = wrap_line_to_width(text, CONTENT_WIDTH);
if cursor_on_this_line {
let cursor_byte_in_line = (cursor_pos - line_start).min(text.len());
let mut consumed_bytes: usize = 0;
for (sub_idx, sub) in wrapped.iter().enumerate() {
let sub_byte_len = sub.len();
let sub_end_in_text = consumed_bytes + sub_byte_len;
let is_last_sub = sub_idx == wrapped.len() - 1;
let cursor_in_sub = cursor_byte_in_line >= consumed_bytes
&& (cursor_byte_in_line <= sub_end_in_text || is_last_sub);
let mut line_spans = vec![Span::styled(border_prefix, border_style)];
if cursor_in_sub {
let cursor_in_sub_byte = cursor_byte_in_line
.saturating_sub(consumed_bytes)
.min(sub.len());
let (before_cursor, after_cursor) = sub.split_at(cursor_in_sub_byte);
cursor_line_offset = 1 + emitted_content_lines + sub_idx;
cursor_column = border_width + before_cursor.width() as u16;
if after_cursor.is_empty() {
line_spans.push(Span::raw(before_cursor.to_string()));
line_spans.push(Span::styled(" ", cursor_style));
} else {
let mut chars = after_cursor.chars();
let cursor_char = chars.next().unwrap();
let remaining = chars.as_str();
line_spans.push(Span::raw(before_cursor.to_string()));
line_spans.push(Span::styled(cursor_char.to_string(), cursor_style));
line_spans.push(Span::raw(remaining.to_string()));
}
} else {
line_spans.push(Span::raw(sub.clone()));
}
result.push(Line::from(line_spans));
consumed_bytes = sub_end_in_text;
if !is_last_sub
&& consumed_bytes < text.len()
&& let Some(next_char) = text[consumed_bytes..].chars().next()
&& next_char.is_whitespace()
{
consumed_bytes += next_char.len_utf8();
}
}
} else {
for sub in &wrapped {
result.push(Line::from(vec![
Span::styled(border_prefix, border_style),
Span::raw(sub.clone()),
]));
}
}
emitted_content_lines += wrapped.len();
char_offset = line_end + 1;
}
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(38),
border_style,
)]));
result.push(Line::from(vec![Span::styled(
format!(" (Tab/S-Tab:type Enter:save {newline_hint}:newline Esc:cancel)"),
styles::dim_style(theme),
)]));
let cursor_info = CommentCursorInfo {
line_offset: cursor_line_offset,
column: cursor_column,
};
(result, cursor_info)
}
pub fn format_comment_lines(
theme: &Theme,
comment_type: CommentTypePresentation,
content: &str,
line_range: Option<LineRange>,
) -> Vec<Line<'static>> {
let type_style = styles::comment_type_style(theme, comment_type.color);
let border_style = styles::comment_border_style(theme, comment_type.color);
let line_info = match line_range {
Some(range) if range.is_single() => format!("L{} ", range.start),
Some(range) => format!("L{}-L{} ", range.start, range.end),
None => String::new(),
};
let content_lines: Vec<&str> = content.split('\n').collect();
let mut result = Vec::new();
result.push(Line::from(vec![
Span::styled(" ╭─ ", border_style),
Span::styled(format!("[{}] ", comment_type.label), type_style),
Span::styled(line_info, styles::dim_style(theme)),
Span::styled("─".repeat(30), border_style),
]));
for line in &content_lines {
for wrapped in wrap_line_to_width(line, CONTENT_WIDTH) {
result.push(Line::from(vec![
Span::styled(" │ ", border_style),
Span::raw(wrapped),
]));
}
}
result.push(Line::from(vec![Span::styled(
" ╰".to_string() + &"─".repeat(38),
border_style,
)]));
result
}
pub fn render_confirm_dialog(frame: &mut Frame, app: &App, message: &str) {
let theme = &app.theme;
let area = centered_rect(50, 20, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.style(styles::popup_style(theme))
.border_style(styles::border_style(theme, true));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
Line::from(""),
Line::from(Span::raw(message)),
Line::from(""),
Line::from(vec![
Span::styled(" [Y]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("es "),
Span::styled("[N]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("o"),
]),
];
let paragraph = Paragraph::new(lines)
.style(styles::popup_style(theme))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(paragraph, inner);
}
pub fn render_review_submit_dialog(frame: &mut Frame, app: &App) {
let theme = &app.theme;
let area = centered_rect(50, 50, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Submit Review ")
.borders(Borders::ALL)
.style(styles::popup_style(theme))
.border_style(styles::border_style(theme, true));
let inner = block.inner(area);
frame.render_widget(block, area);
let verdicts: Vec<&str> = if app.supports_request_changes() {
vec!["Comment", "Approve", "Request Changes"]
} else {
vec!["Comment", "Approve"]
};
let mut lines = vec![Line::from("")];
let (verdict_cursor, review_body, review_body_editing) = app
.remote()
.map(|r| {
(
r.review_verdict_cursor,
r.review_body.clone(),
r.review_body_editing,
)
})
.unwrap_or((0, String::new(), false));
for (i, verdict) in verdicts.iter().enumerate() {
let marker = if i == verdict_cursor { "> " } else { " " };
let style = if i == verdict_cursor {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
format!("{marker}{verdict}"),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Body (optional):",
styles::dim_style(theme),
)));
let body_display = if review_body.is_empty() && !review_body_editing {
" (press Enter to edit)".to_string()
} else if review_body.is_empty() {
" _".to_string()
} else {
format!(" {review_body}")
};
let body_style = if review_body_editing {
Style::default()
} else {
styles::dim_style(theme)
};
lines.push(Line::from(Span::styled(body_display, body_style)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Ctrl+S", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": Submit "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": Cancel"),
]));
let paragraph = Paragraph::new(lines).style(styles::popup_style(theme));
frame.render_widget(paragraph, inner);
}
pub fn render_forge_confirmation_modal(frame: &mut Frame, app: &App) {
use crate::app::{AgentActionKind, PendingAgentAction};
use travelagent_core::forge::ReviewVerdict;
let Some(PendingAgentAction {
kind, proposed_at, ..
}) = app.agent_action.pending()
else {
return;
};
let theme = &app.theme;
let area = centered_rect(60, 50, frame.area());
frame.render_widget(Clear, area);
let title = match kind {
AgentActionKind::SubmitReview { .. } => " Agent Forge Proposal ",
AgentActionKind::SetMentalModel { .. } => " Agent Mental Model Proposal ",
AgentActionKind::AcceptGeneratedTest { .. } => " Agent Generated Test Proposal ",
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(styles::popup_style(theme))
.border_style(styles::border_style(theme, true));
let inner = block.inner(area);
frame.render_widget(block, area);
if let AgentActionKind::SetMentalModel { mental_model } = kind {
render_mental_model_proposal_body(frame, inner, app, mental_model, *proposed_at);
return;
}
if let AgentActionKind::AcceptGeneratedTest {
test_path,
test_body,
spec_id,
} = kind
{
render_accept_generated_test_body(
frame,
inner,
app,
test_path,
test_body,
spec_id,
*proposed_at,
);
return;
}
let AgentActionKind::SubmitReview { verdict, body } = kind else {
unreachable!(
"SetMentalModel and AcceptGeneratedTest handled above; SubmitReview is the only \
remaining variant",
);
};
let verdict_label = match verdict {
ReviewVerdict::Approve => "APPROVE",
ReviewVerdict::RequestChanges => "REQUEST CHANGES",
ReviewVerdict::Comment => "COMMENT",
};
let verdict_style = Style::default()
.fg(match verdict {
ReviewVerdict::Approve => Color::Green,
ReviewVerdict::RequestChanges => Color::Red,
ReviewVerdict::Comment => Color::Yellow,
})
.add_modifier(Modifier::BOLD);
let host = app.forge_host_label();
let pr_id_desc = app
.remote()
.map(|r| format!("{}/{} #{}", r.pr_id.owner, r.pr_id.repo, r.pr_id.number))
.unwrap_or_else(|| "remote".to_string());
let comment_count: usize = app.engine.session().review_comments.len()
+ app
.engine
.session()
.files
.values()
.map(travelagent_core::model::review::FileReview::comment_count)
.sum::<usize>();
let elapsed = chrono::Utc::now().signed_duration_since(*proposed_at);
let timeout_secs = crate::app::CONFIRMATION_TIMEOUT.as_secs() as i64;
let remaining_secs = (timeout_secs - elapsed.num_seconds()).max(0);
let countdown = format!(
"expires in {:01}:{:02}",
remaining_secs / 60,
remaining_secs % 60,
);
let trimmed = body.trim_end();
let body_preview_lines: Vec<&str> = if trimmed.is_empty() {
Vec::new()
} else {
trimmed.lines().take(3).collect()
};
let has_more_lines = trimmed.lines().count() > body_preview_lines.len();
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Agent proposed: ", styles::dim_style(theme)),
Span::styled(
format!("submit review to {host} {pr_id_desc}"),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Verdict: ", styles::dim_style(theme)),
Span::styled(verdict_label.to_string(), verdict_style),
]));
lines.push(Line::from(vec![
Span::styled(" Pending comments: ", styles::dim_style(theme)),
Span::styled(
format!("{comment_count}"),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
if body_preview_lines.is_empty() {
lines.push(Line::from(Span::styled(
" (no body)".to_string(),
styles::dim_style(theme),
)));
} else {
lines.push(Line::from(Span::styled(
" Body preview:".to_string(),
styles::dim_style(theme),
)));
for raw_line in body_preview_lines {
let display: String = raw_line.chars().take(70).collect();
lines.push(Line::from(format!(" {display}")));
}
if has_more_lines {
lines.push(Line::from(Span::styled(
" ...".to_string(),
styles::dim_style(theme),
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {countdown}"),
styles::dim_style(theme),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" [y]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("es "),
Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("o "),
Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
]));
let paragraph = Paragraph::new(lines).style(styles::popup_style(theme));
frame.render_widget(paragraph, inner);
}
fn render_mental_model_proposal_body(
frame: &mut Frame,
inner: Rect,
app: &App,
mental_model: &travelagent_core::model::MentalModel,
proposed_at: chrono::DateTime<chrono::Utc>,
) {
let theme = &app.theme;
let has_existing = app.engine.session().mental_model.is_some();
let overwrite_label = if has_existing {
"overwrite existing"
} else {
"new capture"
};
let elapsed = chrono::Utc::now().signed_duration_since(proposed_at);
let timeout_secs = crate::app::CONFIRMATION_TIMEOUT.as_secs() as i64;
let remaining_secs = (timeout_secs - elapsed.num_seconds()).max(0);
let countdown = format!(
"expires in {:01}:{:02}",
remaining_secs / 60,
remaining_secs % 60,
);
fn preview(body: &str) -> String {
let first_line = body.lines().next().unwrap_or("");
let truncated: String = first_line.chars().take(68).collect();
if truncated.is_empty() {
"(empty)".to_string()
} else if body.lines().count() > 1 || first_line.chars().count() > 68 {
format!("{truncated} …")
} else {
truncated
}
}
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Agent proposed: ", styles::dim_style(theme)),
Span::styled(
format!("set mental model ({overwrite_label})"),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
let labels = [
("Should do:", &mental_model.should_do),
("Shouldn't do:", &mental_model.shouldnt_do),
("Could go wrong:", &mental_model.could_go_wrong),
("Assumptions:", &mental_model.assumptions),
];
for (label, body) in labels {
lines.push(Line::from(vec![
Span::styled(
format!(" {label:<16}"),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(preview(body)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {countdown}"),
styles::dim_style(theme),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" [y]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("es "),
Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("o "),
Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
]));
let paragraph = Paragraph::new(lines).style(styles::popup_style(theme));
frame.render_widget(paragraph, inner);
}
fn render_accept_generated_test_body(
frame: &mut Frame,
inner: Rect,
app: &App,
test_path: &str,
test_body: &str,
spec_id: &str,
proposed_at: chrono::DateTime<chrono::Utc>,
) {
let theme = &app.theme;
let elapsed = chrono::Utc::now().signed_duration_since(proposed_at);
let timeout_secs = crate::app::CONFIRMATION_TIMEOUT.as_secs() as i64;
let remaining_secs = (timeout_secs - elapsed.num_seconds()).max(0);
let countdown = format!(
"expires in {:01}:{:02}",
remaining_secs / 60,
remaining_secs % 60,
);
let body_bytes = test_body.len();
let body_line_count = test_body.lines().count();
let preview_lines: Vec<&str> = test_body.lines().take(6).collect();
let has_more = body_line_count > preview_lines.len();
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Agent proposed: ", styles::dim_style(theme)),
Span::styled(
"land generated test".to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Path: ", styles::dim_style(theme)),
Span::styled(
test_path.to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(" Spec id: ", styles::dim_style(theme)),
Span::raw(spec_id.to_string()),
]));
lines.push(Line::from(vec![
Span::styled(" Size: ", styles::dim_style(theme)),
Span::raw(format!("{body_bytes} bytes / {body_line_count} lines")),
]));
lines.push(Line::from(""));
if preview_lines.is_empty() {
lines.push(Line::from(Span::styled(
" (empty body)".to_string(),
styles::dim_style(theme),
)));
} else {
lines.push(Line::from(Span::styled(
" Preview:".to_string(),
styles::dim_style(theme),
)));
for raw in preview_lines {
let display: String = raw.chars().take(70).collect();
lines.push(Line::from(format!(" {display}")));
}
if has_more {
lines.push(Line::from(Span::styled(
" ...".to_string(),
styles::dim_style(theme),
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {countdown}"),
styles::dim_style(theme),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" [y]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("es "),
Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("o "),
Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
]));
let paragraph = Paragraph::new(lines).style(styles::popup_style(theme));
frame.render_widget(paragraph, inner);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
use ratatui::style::Color;
fn test_theme() -> Theme {
Theme::default()
}
#[test]
fn should_return_cursor_at_start_for_empty_buffer() {
let theme = test_theme();
let (lines, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
"",
0,
None,
false,
false,
);
assert_eq!(lines.len(), 4); assert_eq!(cursor_info.line_offset, 1); assert_eq!(cursor_info.column, 7); }
#[test]
fn should_return_cursor_position_for_ascii_text() {
let theme = test_theme();
let buffer = "hello";
let cursor_pos = 3;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
false,
);
assert_eq!(cursor_info.line_offset, 1); assert_eq!(cursor_info.column, 7 + 3); }
#[test]
fn should_return_cursor_position_for_multibyte_text() {
let theme = test_theme();
let buffer = "안녕"; let cursor_pos = 3;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
false,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 2);
}
#[test]
fn should_return_cursor_position_at_end_of_text() {
let theme = test_theme();
let buffer = "test";
let cursor_pos = 4;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
false,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 4); }
#[test]
fn should_return_cursor_position_on_second_line() {
let theme = test_theme();
let buffer = "line1\nline2";
let cursor_pos = 8;
let (lines, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
false,
);
assert_eq!(lines.len(), 5); assert_eq!(cursor_info.line_offset, 2); assert_eq!(cursor_info.column, 7 + 2); }
#[test]
fn should_wrap_long_single_line_to_width() {
let text = "a".repeat(60);
let wrapped = wrap_line_to_width(&text, 30);
assert!(
wrapped.len() >= 2,
"expected multiple wrapped lines, got {}",
wrapped.len()
);
for sub in &wrapped {
assert!(
sub.width() <= 30,
"wrapped line exceeded width 30: {:?} (width {})",
sub,
sub.width()
);
}
let total: usize = wrapped.iter().map(|s| s.width()).sum();
assert_eq!(total, 60);
}
#[test]
fn should_wrap_at_word_boundaries_when_possible() {
let text = "hello world this is a fairly long sentence for wrapping";
let wrapped = wrap_line_to_width(text, 20);
assert!(wrapped.len() >= 2);
for sub in &wrapped {
assert!(sub.width() <= 20, "line {:?} width {}", sub, sub.width());
assert!(
!sub.starts_with(' '),
"leading space on wrapped line: {sub:?}"
);
}
}
#[test]
fn should_leave_short_lines_unchanged() {
let text = "short line";
let wrapped = wrap_line_to_width(text, 30);
assert_eq!(wrapped, vec!["short line".to_string()]);
}
#[test]
fn should_preserve_explicit_newlines_and_wrap_long_segment() {
let theme = test_theme();
let long = "a".repeat(80);
let content = format!("short\n{long}");
let lines = format_comment_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
&content,
None,
);
assert!(
lines.len() >= 4,
"expected header + content + footer, got {}",
lines.len()
);
let content_line_count = lines.len().saturating_sub(2);
assert!(
content_line_count >= 3,
"expected at least 3 content lines (explicit break + wrapped segment), got {content_line_count}"
);
let first_content = &lines[1];
let first_text: String = first_content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
first_text.contains("short"),
"first content line should contain 'short', got {first_text:?}"
);
for line in &lines[1..lines.len() - 1] {
let body: String = line
.spans
.iter()
.skip(1)
.map(|s| s.content.as_ref())
.collect();
assert!(
body.width() <= CONTENT_WIDTH,
"content body width {} exceeded CONTENT_WIDTH {}: {:?}",
body.width(),
CONTENT_WIDTH,
body
);
}
}
#[test]
fn should_return_cursor_position_for_mixed_content() {
let theme = test_theme();
let buffer = "a좋b"; let cursor_pos = 4;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
buffer,
cursor_pos,
None,
false,
false,
);
assert_eq!(cursor_info.line_offset, 1);
assert_eq!(cursor_info.column, 7 + 3);
}
#[test]
fn wrap_line_to_width_handles_empty_input() {
let wrapped = wrap_line_to_width("", 10);
assert_eq!(wrapped, vec![String::new()]);
}
#[test]
fn wrap_line_to_width_handles_trailing_whitespace() {
let text = "abcdefghij "; let wrapped = wrap_line_to_width(text, 10);
assert!(!wrapped.is_empty());
for sub in &wrapped {
assert!(sub.width() <= 10, "{sub:?} exceeds width 10");
}
}
#[test]
fn wrap_line_to_width_handles_cjk_only_no_whitespace() {
let text = "한".repeat(10);
let wrapped = wrap_line_to_width(&text, 7);
assert!(wrapped.len() >= 2);
for sub in &wrapped {
assert!(sub.width() <= 7, "{sub:?} exceeds width 7");
assert!(!sub.is_empty());
}
}
#[test]
fn format_comment_input_lines_handles_cjk_wrap_without_panic() {
let theme = test_theme();
let buffer = "한".repeat(50);
let cursor_pos = 3;
let (_, cursor_info) = format_comment_input_lines(
&theme,
CommentTypePresentation {
label: "NOTE".to_string(),
color: Color::Blue,
},
&buffer,
cursor_pos,
None,
false,
false,
);
assert!(cursor_info.line_offset >= 1);
}
}