use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
use super::ScreenRowInfo;
use crate::diff::DiffLine;
pub type ImageDimensions = (Option<(u32, u32)>, Option<(u32, u32)>);
pub fn wrapped_line_height(
line: &DiffLine,
content_width: usize,
image_dims: Option<ImageDimensions>,
panel_width: u16,
font_size: (u16, u16),
) -> usize {
if content_width == 0 {
return 1;
}
if line.is_image_marker() {
if let Some((before, after)) = image_dims
&& (before.is_some() || after.is_some())
{
return crate::ui::image_view::calculate_image_height_for_images(
before,
after,
panel_width,
font_size,
) as usize;
}
return 1;
}
if has_pure_deletion_inline(line) {
let del_width = line
.old_content
.as_ref()
.map(|s| content_display_width(s))
.unwrap_or(0);
let ins_width = line.display_width();
let del_height = if del_width == 0 {
0
} else {
del_width.div_ceil(content_width).max(1)
};
let ins_height = ins_width.div_ceil(content_width).max(1);
return del_height + ins_height;
}
if has_mixed_inline_changes(line) && line.display_width() > content_width {
let del_width = line
.old_content
.as_ref()
.map(|s| content_display_width(s))
.unwrap_or(0);
let ins_width = line.display_width();
let del_height = if del_width == 0 {
0
} else {
del_width.div_ceil(content_width)
};
let ins_height = ins_width.div_ceil(content_width);
return del_height + ins_height;
}
let width = line.display_width();
if width <= content_width {
1
} else {
width.div_ceil(content_width)
}
}
fn has_mixed_inline_changes(line: &DiffLine) -> bool {
if line.inline_spans.is_empty() {
return false;
}
let has_deletions = line.inline_spans.iter().any(|s| s.is_deletion);
let has_insertions = line
.inline_spans
.iter()
.any(|s| !s.is_deletion && s.source.is_some());
has_deletions && has_insertions
}
fn has_pure_deletion_inline(line: &DiffLine) -> bool {
if line.inline_spans.is_empty() {
return false;
}
let has_deletions = line.inline_spans.iter().any(|s| s.is_deletion);
let has_insertions = line
.inline_spans
.iter()
.any(|s| !s.is_deletion && s.source.is_some());
has_deletions && !has_insertions
}
pub use crate::diff::content_display_width;
fn sanitize_for_display(s: &str) -> Option<String> {
use unicode_width::UnicodeWidthChar;
if !s
.chars()
.any(|ch| ch == '\t' || UnicodeWidthChar::width(ch).is_none())
{
return None;
}
let mut result = String::with_capacity(s.len());
for ch in s.chars() {
if ch == '\t' {
result.push_str(" ");
} else if UnicodeWidthChar::width(ch).is_none() {
result.push(' ');
} else {
result.push(ch);
}
}
Some(result)
}
fn display_width_split(s: &str, max_width: usize) -> usize {
let mut width = 0;
for (i, ch) in s.char_indices() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw > max_width {
return i;
}
width += cw;
}
s.len()
}
pub fn wrap_content(
content_spans: Vec<Span<'static>>,
content: &str,
prefix_str: String,
prefix_char: String,
style: Style,
content_width: usize,
prefix_width: usize,
) -> (Vec<Line<'static>>, Vec<ScreenRowInfo>) {
let content_spans: Vec<Span<'static>> = content_spans
.into_iter()
.map(|span| match sanitize_for_display(&span.content) {
Some(sanitized) => Span::styled(sanitized, span.style),
None => span,
})
.collect();
let content = sanitize_for_display(content)
.unwrap_or_else(|| content.to_string());
let total_width: usize = content_spans.iter().map(|s| content_display_width(&s.content)).sum();
if total_width <= content_width {
let mut spans = Vec::new();
spans.push(Span::styled(prefix_str, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(prefix_char, style));
spans.extend(content_spans);
let row_info = ScreenRowInfo {
content,
is_file_header: false,
file_path: None,
is_continuation: false,
};
return (vec![Line::from(spans)], vec![row_info]);
}
let mut result_lines = Vec::new();
let mut row_infos = Vec::new();
let mut current_line_spans: Vec<Span<'static>> = Vec::new();
let mut current_width = 0;
let mut is_first_line = true;
let mut current_content = String::new();
let continuation_indent = " ".repeat(prefix_width);
let emit_line = |current_line_spans: &mut Vec<Span<'static>>,
current_content: &mut String,
is_first_line: &mut bool,
row_infos: &mut Vec<ScreenRowInfo>,
result_lines: &mut Vec<Line<'static>>| {
let mut line_spans = Vec::new();
if *is_first_line {
line_spans.push(Span::styled(prefix_str.clone(), Style::default().fg(Color::DarkGray)));
line_spans.push(Span::styled(prefix_char.clone(), style));
*is_first_line = false;
} else {
line_spans.push(Span::styled(continuation_indent.clone(), Style::default().fg(Color::DarkGray)));
}
line_spans.append(current_line_spans);
result_lines.push(Line::from(line_spans));
row_infos.push(ScreenRowInfo {
content: std::mem::take(current_content),
is_file_header: false,
file_path: None,
is_continuation: !row_infos.is_empty(),
});
};
for span in content_spans {
let span_text = span.content.to_string();
let span_style = span.style;
let mut remaining = span_text.as_str();
while !remaining.is_empty() {
let space_available = content_width.saturating_sub(current_width);
if space_available == 0 {
emit_line(
&mut current_line_spans,
&mut current_content,
&mut is_first_line,
&mut row_infos,
&mut result_lines,
);
current_width = 0;
continue;
}
let remaining_display_width = content_display_width(remaining);
if remaining_display_width <= space_available {
current_line_spans.push(Span::styled(remaining.to_string(), span_style));
current_content.push_str(remaining);
current_width += remaining_display_width;
remaining = "";
} else {
let split_at = display_width_split(remaining, space_available)
.max(remaining.ceil_char_boundary(1));
let (chunk, rest) = remaining.split_at(split_at);
current_line_spans.push(Span::styled(chunk.to_string(), span_style));
current_content.push_str(chunk);
remaining = rest;
emit_line(
&mut current_line_spans,
&mut current_content,
&mut is_first_line,
&mut row_infos,
&mut result_lines,
);
current_width = 0;
}
}
}
if !current_line_spans.is_empty() || is_first_line {
emit_line(
&mut current_line_spans,
&mut current_content,
&mut is_first_line,
&mut row_infos,
&mut result_lines,
);
}
(result_lines, row_infos)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrapped_line_widths_do_not_exceed_available_width() {
let content_width = 40;
let prefix_width = 8;
let content = "a".repeat(content_width + 20);
let content_spans = vec![Span::styled(content.clone(), Style::default())];
let prefix_str = " ".to_string();
let prefix_char = "± C ".to_string();
let (lines, _) = wrap_content(
content_spans,
&content,
prefix_str,
prefix_char,
Style::default(),
content_width,
prefix_width,
);
assert!(lines.len() > 1, "Content should wrap");
for (i, line) in lines.iter().enumerate() {
let display_width: usize = line
.spans
.iter()
.map(|s| content_display_width(&s.content))
.sum();
assert!(
display_width <= prefix_width + content_width,
"Line {} has display width {} but max is {} (prefix {} + content {})",
i,
display_width,
prefix_width + content_width,
prefix_width,
content_width
);
}
}
#[test]
fn test_wrapped_unicode_content_correct_width() {
let content_width = 20;
let prefix_width = 6;
let content = "→ hello → world → foo → bar → baz";
let content_spans = vec![Span::styled(content.to_string(), Style::default())];
let (lines, _) = wrap_content(
content_spans,
content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
for (i, line) in lines.iter().enumerate() {
let display_width: usize = line
.spans
.iter()
.map(|s| content_display_width(&s.content))
.sum();
assert!(
display_width <= prefix_width + content_width,
"Line {} display width {} exceeds max {}",
i,
display_width,
prefix_width + content_width
);
}
}
#[test]
fn test_no_wrap_when_content_fits() {
let content_width = 40;
let prefix_width = 6;
let content = "short line";
let content_spans = vec![Span::styled(content.to_string(), Style::default())];
let (lines, row_infos) = wrap_content(
content_spans,
content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
assert_eq!(lines.len(), 1);
assert_eq!(row_infos.len(), 1);
assert!(!row_infos[0].is_continuation);
}
#[test]
fn test_continuation_lines_marked_correctly() {
let content_width = 10;
let prefix_width = 6;
let content = "a".repeat(25);
let content_spans = vec![Span::styled(content.clone(), Style::default())];
let (lines, row_infos) = wrap_content(
content_spans,
&content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
assert!(lines.len() >= 3, "Should produce 3+ wrapped lines");
assert!(!row_infos[0].is_continuation);
for info in &row_infos[1..] {
assert!(info.is_continuation);
}
}
#[test]
fn test_wide_cjk_characters_wrap_correctly() {
let content_width = 10;
let prefix_width = 6;
let content = "ä½ å¥½ä¸–ç•Œä½ å¥½";
let content_spans = vec![Span::styled(content.to_string(), Style::default())];
let (lines, _) = wrap_content(
content_spans,
content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
for (i, line) in lines.iter().enumerate() {
let display_width: usize = line
.spans
.iter()
.map(|s| content_display_width(&s.content))
.sum();
assert!(
display_width <= prefix_width + content_width,
"Line {} display width {} exceeds max {}",
i,
display_width,
prefix_width + content_width
);
}
}
#[test]
fn test_tab_characters_expanded_before_wrapping() {
let content_width = 30;
let prefix_width = 6;
let content = "\t\tsome_long_identifier = value;";
let content_spans = vec![Span::styled(content.to_string(), Style::default())];
let (lines, row_infos) = wrap_content(
content_spans,
content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
assert!(lines.len() > 1, "Tab-containing content should wrap when expanded width exceeds limit");
for line in &lines {
for span in &line.spans {
assert!(
!span.content.contains('\t'),
"Rendered spans should not contain tab characters"
);
}
}
for info in &row_infos {
assert!(
!info.content.contains('\t'),
"ScreenRowInfo content should not contain tabs"
);
}
for (i, line) in lines.iter().enumerate() {
let display_width: usize = line
.spans
.iter()
.map(|s| content_display_width(&s.content))
.sum();
assert!(
display_width <= prefix_width + content_width,
"Line {} display width {} exceeds max {}",
i,
display_width,
prefix_width + content_width
);
}
}
#[test]
fn test_tabs_without_wrapping_still_expanded() {
let content_width = 40;
let prefix_width = 6;
let content = "\thi";
let content_spans = vec![Span::styled(content.to_string(), Style::default())];
let (lines, row_infos) = wrap_content(
content_spans,
content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
assert_eq!(lines.len(), 1);
for span in &lines[0].spans {
assert!(!span.content.contains('\t'), "Tab should be expanded to spaces");
}
assert_eq!(row_infos[0].content, " hi");
}
#[test]
fn test_control_characters_sanitized() {
let content_width = 40;
let prefix_width = 6;
let content = "hello\x01world\x7f!";
let content_spans = vec![Span::styled(content.to_string(), Style::default())];
let (lines, row_infos) = wrap_content(
content_spans,
content,
" ".to_string(),
"+ C ".to_string(),
Style::default(),
content_width,
prefix_width,
);
assert_eq!(lines.len(), 1);
let rendered: String = lines[0].spans.iter().map(|s| s.content.to_string()).collect();
assert!(rendered.contains("hello world !"), "Control chars should become spaces, got: {:?}", rendered);
assert_eq!(row_infos[0].content, "hello world !");
}
#[test]
fn test_content_display_width_invariant_under_sanitization() {
let test_cases = [
"hello world",
"\t\tindented",
"has\x01control\x7fchars",
"mixed\t\x01both",
"ä½ å¥½ä¸–ç•Œ",
];
for input in &test_cases {
let raw = content_display_width(input);
let sanitized = sanitize_for_display(input)
.unwrap_or_else(|| input.to_string());
let after = content_display_width(&sanitized);
assert_eq!(
raw, after,
"width changed across sanitization for {:?}: {} -> {}",
input, raw, after
);
}
}
#[test]
fn test_wrapped_line_height_short_line() {
use crate::diff::LineSource;
let line = DiffLine::new(LineSource::Base, "short line".to_string(), ' ', None);
let height = wrapped_line_height(&line, 80, None, 100, (8, 16));
assert_eq!(height, 1);
}
#[test]
fn test_wrapped_line_height_long_line() {
use crate::diff::LineSource;
let line = DiffLine::new(LineSource::Base, "x".repeat(150), ' ', None);
let height = wrapped_line_height(&line, 80, None, 100, (8, 16));
assert_eq!(height, 2); }
#[test]
fn test_wrapped_line_height_image_with_dimensions() {
let line = DiffLine::image_marker("test.png");
let dims: ImageDimensions = (Some((192, 192)), None);
let height = wrapped_line_height(&line, 80, Some(dims), 100, (8, 16));
let expected = crate::ui::image_view::calculate_image_height_for_images(
Some((192, 192)),
None,
100,
(8, 16),
) as usize;
assert_eq!(height, expected);
assert!(height > 1, "Image with dimensions should be taller than 1 row");
}
#[test]
fn test_wrapped_line_height_image_without_dimensions() {
let line = DiffLine::image_marker("test.png");
let height = wrapped_line_height(&line, 80, None, 100, (8, 16));
assert_eq!(height, 1);
}
#[test]
fn test_wrapped_line_height_image_with_empty_dimensions_tuple() {
let line = DiffLine::image_marker("test.png");
let dims: ImageDimensions = (None, None);
let height = wrapped_line_height(&line, 80, Some(dims), 100, (8, 16));
assert_eq!(height, 1, "Image with no actual dimensions should fallback to 1");
}
#[test]
fn test_wrapped_line_height_image_with_both_dimensions() {
let line = DiffLine::image_marker("test.png");
let dims: ImageDimensions = (Some((100, 100)), Some((200, 200)));
let height = wrapped_line_height(&line, 80, Some(dims), 100, (8, 16));
let expected = crate::ui::image_view::calculate_image_height_for_images(
Some((100, 100)),
Some((200, 200)),
100,
(8, 16),
) as usize;
assert_eq!(height, expected);
}
#[test]
fn test_wrapped_line_height_zero_content_width() {
use crate::diff::LineSource;
let line = DiffLine::new(LineSource::Base, "x".repeat(100), ' ', None);
let height = wrapped_line_height(&line, 0, None, 100, (8, 16));
assert_eq!(height, 1, "Zero content width should return 1");
}
#[test]
fn test_wrapped_line_height_mixed_inline_changes() {
use crate::diff::{InlineSpan, LineSource};
let mut line = DiffLine::new(LineSource::Unstaged, "x".repeat(100), '+', None);
line.old_content = Some("y".repeat(80));
line.inline_spans = vec![
InlineSpan {
text: "deleted".to_string(),
source: Some(LineSource::Unstaged),
is_deletion: true,
},
InlineSpan {
text: "inserted".to_string(),
source: Some(LineSource::Unstaged),
is_deletion: false,
},
];
let height = wrapped_line_height(&line, 50, None, 100, (8, 16));
assert_eq!(height, 4);
}
#[test]
fn test_wrapped_line_height_pure_deletion_splits_into_two_lines() {
use crate::diff::{InlineSpan, LineSource};
let mut line = DiffLine::new(LineSource::Base, "hello wrld".to_string(), ' ', None);
line.old_content = Some("hello world".to_string());
line.inline_spans = vec![
InlineSpan {
text: "hello w".to_string(),
source: None,
is_deletion: false,
},
InlineSpan {
text: "o".to_string(),
source: Some(LineSource::DeletedBase),
is_deletion: true,
},
InlineSpan {
text: "rld".to_string(),
source: None,
is_deletion: false,
},
];
let height = wrapped_line_height(&line, 80, None, 100, (8, 16));
assert_eq!(height, 2, "pure deletion should render as two lines (del + ins)");
}
#[test]
fn test_wrapped_line_height_pure_deletion_long_lines() {
use crate::diff::{InlineSpan, LineSource};
let prefix = "a".repeat(90);
let old = format!("{}Xend", prefix);
let new = format!("{}end", prefix);
let mut line = DiffLine::new(LineSource::Base, new, ' ', None);
line.old_content = Some(old);
line.inline_spans = vec![
InlineSpan {
text: prefix,
source: None,
is_deletion: false,
},
InlineSpan {
text: "X".to_string(),
source: Some(LineSource::DeletedBase),
is_deletion: true,
},
InlineSpan {
text: "end".to_string(),
source: None,
is_deletion: false,
},
];
let height = wrapped_line_height(&line, 50, None, 100, (8, 16));
assert_eq!(height, 4);
}
#[test]
fn test_has_pure_deletion_inline_true() {
use crate::diff::{InlineSpan, LineSource};
let mut line = DiffLine::new(LineSource::Base, "test".to_string(), ' ', None);
line.inline_spans = vec![
InlineSpan {
text: "tes".to_string(),
source: None,
is_deletion: false,
},
InlineSpan {
text: "x".to_string(),
source: Some(LineSource::DeletedBase),
is_deletion: true,
},
InlineSpan {
text: "t".to_string(),
source: None,
is_deletion: false,
},
];
assert!(has_pure_deletion_inline(&line));
}
#[test]
fn test_has_pure_deletion_inline_false_for_mixed() {
use crate::diff::{InlineSpan, LineSource};
let mut line = DiffLine::new(LineSource::Base, "test".to_string(), ' ', None);
line.inline_spans = vec![
InlineSpan {
text: "old".to_string(),
source: Some(LineSource::DeletedBase),
is_deletion: true,
},
InlineSpan {
text: "new".to_string(),
source: Some(LineSource::Committed),
is_deletion: false,
},
];
assert!(!has_pure_deletion_inline(&line));
}
#[test]
fn test_has_pure_deletion_inline_false_for_empty() {
use crate::diff::LineSource;
let line = DiffLine::new(LineSource::Base, "test".to_string(), ' ', None);
assert!(!has_pure_deletion_inline(&line));
}
}