use std::cell::OnceCell;
use crate::diff::{DiffLine, LineSource};
use crate::ui::wrapping::{wrapped_line_height, ImageDimensions};
use super::App;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayableItem {
Line(usize),
Elided(usize),
Message(&'static str),
}
impl DisplayableItem {
pub fn as_line_index(&self) -> Option<usize> {
match self {
DisplayableItem::Line(idx) => Some(*idx),
_ => None,
}
}
pub fn is_elided(&self) -> bool {
matches!(self, DisplayableItem::Elided(_))
}
pub fn elided_count(&self) -> Option<usize> {
match self {
DisplayableItem::Elided(count) => Some(*count),
_ => None,
}
}
}
pub struct FrameContext {
items: Vec<DisplayableItem>,
viewport_height: usize,
scroll_offset: usize,
content_width: usize,
max_scroll: OnceCell<usize>,
wrap_heights: OnceCell<Vec<usize>>,
visible_range: OnceCell<(usize, usize)>,
}
impl FrameContext {
pub fn new(app: &App) -> Self {
let items = app.compute_displayable_items();
Self::with_items(items, app)
}
pub fn with_items(items: Vec<DisplayableItem>, app: &App) -> Self {
Self {
items,
viewport_height: app.view.viewport_height,
scroll_offset: app.view.scroll_offset,
content_width: app.view.content_width,
max_scroll: OnceCell::new(),
wrap_heights: OnceCell::new(),
visible_range: OnceCell::new(),
}
}
pub fn item(&self, display_idx: usize) -> &DisplayableItem {
&self.items[display_idx]
}
pub fn items(&self) -> &[DisplayableItem] {
&self.items
}
pub fn line<'a>(&self, app: &'a App, display_idx: usize) -> &'a DiffLine {
match self.items[display_idx] {
DisplayableItem::Line(idx) => &app.lines[idx],
_ => panic!("Called line() on non-Line item at index {}", display_idx),
}
}
pub fn try_line<'a>(&self, app: &'a App, display_idx: usize) -> Option<&'a DiffLine> {
match self.items[display_idx] {
DisplayableItem::Line(idx) => Some(&app.lines[idx]),
_ => None,
}
}
pub fn original_index(&self, display_idx: usize) -> Option<usize> {
self.items[display_idx].as_line_index()
}
pub fn item_count(&self) -> usize {
self.items.len()
}
pub fn line_count(&self) -> usize {
self.items.iter().filter(|i| matches!(i, DisplayableItem::Line(_))).count()
}
pub fn max_scroll(&self, app: &App) -> usize {
*self.max_scroll.get_or_init(|| self.compute_max_scroll(app))
}
pub fn visible_range(&self, app: &App) -> (usize, usize) {
*self.visible_range.get_or_init(|| self.compute_visible_range(app))
}
pub fn iter_items(&self) -> impl Iterator<Item = &DisplayableItem> {
self.items.iter()
}
pub fn iter_visible_items<'a>(&'a self, app: &App) -> impl Iterator<Item = &'a DisplayableItem> {
let (start, end) = self.visible_range(app);
self.items[start..end].iter()
}
pub fn find_next_file_header(&self, app: &App, start: usize) -> Option<usize> {
for (i, item) in self.items.iter().enumerate().skip(start + 1) {
if let DisplayableItem::Line(idx) = item
&& app.lines[*idx].source == LineSource::FileHeader
{
return Some(i);
}
}
None
}
pub fn find_prev_file_header(&self, app: &App, current: usize) -> Option<usize> {
if current == 0 {
return None;
}
let current_is_header = matches!(
self.items.get(current),
Some(DisplayableItem::Line(idx)) if app.lines[*idx].source == LineSource::FileHeader
);
let search_start = if current_is_header {
current.saturating_sub(1)
} else {
current
};
for i in (0..=search_start).rev() {
if let DisplayableItem::Line(idx) = self.items[i]
&& app.lines[idx].source == LineSource::FileHeader
{
return Some(i);
}
}
None
}
fn compute_max_scroll(&self, app: &App) -> usize {
if self.items.is_empty() {
return 0;
}
let wrap_heights = self.get_wrap_heights(app);
let total_rows: usize = wrap_heights.iter().sum();
if total_rows <= self.viewport_height {
return 0;
}
let mut rows_from_end = 0;
let mut items_from_end = 0;
for height in wrap_heights.iter().rev() {
if rows_from_end + height > self.viewport_height {
break;
}
rows_from_end += height;
items_from_end += 1;
}
self.items.len().saturating_sub(items_from_end)
}
fn compute_visible_range(&self, app: &App) -> (usize, usize) {
if self.items.is_empty() {
return (0, 0);
}
let start = self.scroll_offset.min(self.items.len());
let wrap_heights = self.get_wrap_heights(app);
let mut rows_used = 0;
let mut end = start;
for height in wrap_heights.iter().skip(start) {
if rows_used >= self.viewport_height && end > start {
break;
}
rows_used += height;
end += 1;
}
(start, end)
}
fn get_wrap_heights(&self, app: &App) -> &[usize] {
self.wrap_heights.get_or_init(|| self.compute_wrap_heights(app))
}
fn compute_wrap_heights(&self, app: &App) -> Vec<usize> {
self.items.iter().map(|item| {
match item {
DisplayableItem::Line(idx) => {
let line = &app.lines[*idx];
self.wrapped_line_height(line, app)
}
DisplayableItem::Elided(_) | DisplayableItem::Message(_) => 1,
}
}).collect()
}
fn wrapped_line_height(&self, line: &DiffLine, app: &App) -> usize {
let image_dims: Option<ImageDimensions> = if line.is_image_marker() {
line.file_path.as_ref().and_then(|path| {
app.image_cache.peek(path).map(|state| {
let before = state
.before
.as_ref()
.map(|img| (img.original_width, img.original_height));
let after = state
.after
.as_ref()
.map(|img| (img.original_width, img.original_height));
(before, after)
})
})
} else {
None
};
wrapped_line_height(
line,
self.content_width,
image_dims,
app.view.panel_width,
app.font_size,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{base_line, change_line, TestAppBuilder};
#[test]
fn test_displayable_item_as_line_index() {
assert_eq!(DisplayableItem::Line(5).as_line_index(), Some(5));
assert_eq!(DisplayableItem::Elided(10).as_line_index(), None);
}
#[test]
fn test_displayable_item_is_elided() {
assert!(!DisplayableItem::Line(5).is_elided());
assert!(DisplayableItem::Elided(10).is_elided());
}
#[test]
fn test_frame_context_full_mode_all_lines() {
let lines = vec![
DiffLine::file_header("test.rs"),
base_line("line1"),
change_line("line2"),
base_line("line3"),
];
let app = TestAppBuilder::new().with_lines(lines).build();
let ctx = FrameContext::new(&app);
assert_eq!(ctx.item_count(), 4);
assert_eq!(ctx.line_count(), 4);
for i in 0..4 {
assert!(ctx.try_line(&app, i).is_some());
}
}
#[test]
fn test_frame_context_max_scroll_empty() {
let app = TestAppBuilder::new().build();
let ctx = FrameContext::new(&app);
assert_eq!(ctx.max_scroll(&app), 0);
}
#[test]
fn test_frame_context_max_scroll_fits_viewport() {
let lines: Vec<_> = (0..5).map(|i| base_line(&format!("line{}", i))).collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 10;
let ctx = FrameContext::new(&app);
assert_eq!(ctx.max_scroll(&app), 0);
}
#[test]
fn test_frame_context_max_scroll_scrollable() {
let lines: Vec<_> = (0..20).map(|i| base_line(&format!("line{}", i))).collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 10;
let ctx = FrameContext::new(&app);
assert_eq!(ctx.max_scroll(&app), 10);
}
#[test]
fn test_frame_context_visible_range() {
let lines: Vec<_> = (0..20).map(|i| base_line(&format!("line{}", i))).collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 5;
app.view.scroll_offset = 3;
let ctx = FrameContext::new(&app);
let (start, end) = ctx.visible_range(&app);
assert_eq!(start, 3);
assert_eq!(end, 8);
}
#[test]
fn test_frame_context_find_next_file_header() {
let lines = vec![
DiffLine::file_header("file1.rs"),
base_line("line1"),
base_line("line2"),
DiffLine::file_header("file2.rs"),
base_line("line3"),
];
let app = TestAppBuilder::new().with_lines(lines).build();
let ctx = FrameContext::new(&app);
assert_eq!(ctx.find_next_file_header(&app, 0), Some(3));
assert_eq!(ctx.find_next_file_header(&app, 3), None);
}
#[test]
fn test_frame_context_find_prev_file_header() {
let lines = vec![
DiffLine::file_header("file1.rs"),
base_line("line1"),
base_line("line2"),
DiffLine::file_header("file2.rs"),
base_line("line3"),
];
let app = TestAppBuilder::new().with_lines(lines).build();
let ctx = FrameContext::new(&app);
assert_eq!(ctx.find_prev_file_header(&app, 4), Some(3));
assert_eq!(ctx.find_prev_file_header(&app, 3), Some(0));
assert_eq!(ctx.find_prev_file_header(&app, 0), None);
}
#[test]
fn test_frame_context_iter_visible_items() {
let lines: Vec<_> = (0..10).map(|i| base_line(&format!("line{}", i))).collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 3;
app.view.scroll_offset = 2;
let ctx = FrameContext::new(&app);
let visible: Vec<_> = ctx.iter_visible_items(&app).collect();
assert_eq!(visible.len(), 3);
}
#[test]
fn test_frame_context_uses_viewport_height_at_creation_time() {
let lines: Vec<_> = (0..50).map(|i| base_line(&format!("line{}", i))).collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
let ctx_with_default = FrameContext::new(&app);
let (start, end) = ctx_with_default.visible_range(&app);
assert_eq!(end - start, 10, "With default viewport_height=10, visible range should be 10");
app.view.viewport_height = 40;
let ctx_with_correct = FrameContext::new(&app);
let (start, end) = ctx_with_correct.visible_range(&app);
assert_eq!(end - start, 40, "With viewport_height=40, visible range should be 40");
}
#[test]
fn test_visible_range_accounts_for_wrapped_lines() {
let mut lines: Vec<_> = (0..10).map(|i| base_line(&format!("short{}", i))).collect();
lines.push(base_line(&"x".repeat(200)));
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 5;
app.view.content_width = 50;
app.view.scroll_offset = 8;
let ctx = FrameContext::new(&app);
let (start, end) = ctx.visible_range(&app);
assert_eq!(start, 8);
assert!(end <= 11, "visible range should not exceed total items");
let wrap_heights = ctx.get_wrap_heights(&app);
let rows_before_last: usize = wrap_heights[start..end.saturating_sub(1)].iter().sum();
assert!(rows_before_last < app.view.viewport_height,
"rows before last item ({}) should start within viewport ({})", rows_before_last, app.view.viewport_height);
}
#[test]
fn test_visible_range_includes_at_least_one_item_when_taller_than_viewport() {
let lines = vec![base_line(&"x".repeat(500))];
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 3;
app.view.content_width = 50;
app.view.scroll_offset = 0;
let ctx = FrameContext::new(&app);
let (start, end) = ctx.visible_range(&app);
assert_eq!(start, 0);
assert_eq!(end, 1, "should include at least one item even if taller than viewport");
}
#[test]
fn test_visible_range_includes_partial_items_at_viewport_bottom() {
let mut lines: Vec<_> = (0..5).map(|i| base_line(&format!("short{}", i))).collect();
lines.push(base_line(&"x".repeat(200)));
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 6; app.view.content_width = 50;
app.view.scroll_offset = 0;
let ctx = FrameContext::new(&app);
let (start, end) = ctx.visible_range(&app);
assert_eq!(start, 0);
assert_eq!(end, 6, "should include partial item at viewport bottom");
let wrap_heights = ctx.get_wrap_heights(&app);
let total_rows: usize = wrap_heights[start..end].iter().sum();
assert!(total_rows > app.view.viewport_height,
"total rows ({}) should exceed viewport ({}) due to partial item",
total_rows, app.view.viewport_height);
}
#[test]
fn test_visible_range_includes_partial_image_at_viewport_bottom() {
use crate::image_diff::{CachedImage, ImageDiffState};
use image::DynamicImage;
let lines = vec![
DiffLine::file_header("test.png"),
base_line("short line 1"),
DiffLine::image_marker("test.png"),
];
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 5; app.view.content_width = 80;
app.view.panel_width = 100;
let cached_image = CachedImage {
display_image: DynamicImage::new_rgb8(192, 192),
original_width: 192,
original_height: 192,
file_size: 1024,
format_name: "PNG".to_string(),
protocol: None,
};
app.image_cache.insert(
"test.png".to_string(),
ImageDiffState {
before: Some(cached_image),
after: None,
},
);
let ctx = FrameContext::new(&app);
let wrap_heights = ctx.get_wrap_heights(&app);
let image_height = wrap_heights[2];
assert!(
image_height > app.view.viewport_height,
"image height ({}) should exceed viewport ({}) for this test to be meaningful",
image_height, app.view.viewport_height
);
let (start, end) = ctx.visible_range(&app);
assert_eq!(start, 0);
assert_eq!(end, 3, "should include partial image marker at viewport bottom");
let total_rows: usize = wrap_heights[start..end].iter().sum();
assert!(
total_rows > app.view.viewport_height,
"total rows ({}) should exceed viewport ({}) due to partial image",
total_rows, app.view.viewport_height
);
}
#[test]
fn test_wrapped_line_height_accounts_for_mixed_inline_changes() {
use crate::diff::InlineSpan;
let mut line = base_line(&"x".repeat(100));
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 mut app = TestAppBuilder::new().with_lines(vec![line]).build();
app.view.content_width = 50;
let ctx = FrameContext::new(&app);
let wrap_heights = ctx.get_wrap_heights(&app);
assert_eq!(wrap_heights[0], 4, "mixed change: 80/50=2 del rows + 100/50=2 ins rows = 4 total");
}
#[test]
fn test_initial_visible_range_fills_viewport_with_realistic_content_width() {
let lines: Vec<_> = (0..30)
.map(|i| base_line(&format!(
"line {:02} with lots of extra content padding to make this line about one hundred characters long xx",
i
)))
.collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 20;
app.view.scroll_offset = 0;
app.view.content_width = 50;
let ctx_narrow = FrameContext::new(&app);
let (start_narrow, end_narrow) = ctx_narrow.visible_range(&app);
let visible_narrow = end_narrow - start_narrow;
app.view.content_width = 150;
let ctx_wide = FrameContext::new(&app);
let (start_wide, end_wide) = ctx_wide.visible_range(&app);
let visible_wide = end_wide - start_wide;
assert_eq!(
visible_wide, 20,
"With wide content_width, should see viewport_height (20) lines"
);
assert!(
visible_narrow < visible_wide,
"Narrow content_width ({} visible) should show fewer lines than wide ({} visible) due to wrapping",
visible_narrow, visible_wide
);
assert!(
visible_narrow <= 10,
"With 50-char width and ~100-char lines, should see ~10 lines (2 rows each), got {}",
visible_narrow
);
}
#[test]
fn test_initial_render_content_width_must_be_set_before_visible_range() {
let lines: Vec<_> = (0..30)
.map(|i| base_line(&format!(
"line {:02} - this line is intentionally long enough to wrap at 80 chars but fit on one line at 150 chars padding",
i
)))
.collect();
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 20;
app.view.scroll_offset = 0;
let default_width = app.view.content_width; assert_eq!(default_width, 80, "Test assumes default content_width is 80");
let ctx_with_default = FrameContext::new(&app);
let (_, end_default) = ctx_with_default.visible_range(&app);
let visible_with_default = end_default;
app.view.content_width = 150;
let ctx_with_actual = FrameContext::new(&app);
let (_, end_actual) = ctx_with_actual.visible_range(&app);
let visible_with_actual = end_actual;
assert!(
visible_with_default < visible_with_actual,
"Bug: default content_width ({}) should show fewer items ({}) than actual width ({}) which shows {}",
default_width, visible_with_default, 150, visible_with_actual
);
assert_eq!(
visible_with_actual, 20,
"With actual content_width=150, viewport should be filled with 20 lines"
);
assert!(
visible_with_default <= 12,
"With default content_width=80, should only see ~10-12 lines due to wrap, got {}",
visible_with_default
);
}
#[test]
fn test_frame_context_uses_image_cache_for_height() {
use crate::image_diff::{CachedImage, ImageDiffState};
use image::DynamicImage;
let lines = vec![
DiffLine::file_header("image1.png"),
DiffLine::image_marker("image1.png"),
DiffLine::file_header("image2.png"),
DiffLine::image_marker("image2.png"),
DiffLine::file_header("image3.png"),
DiffLine::image_marker("image3.png"),
];
let mut app = TestAppBuilder::new().with_lines(lines).build();
app.view.viewport_height = 60; app.view.content_width = 80;
app.view.panel_width = 100;
for i in 1..=3 {
let cached_image = CachedImage {
display_image: DynamicImage::new_rgb8(192, 192),
original_width: 192,
original_height: 192,
file_size: 1024,
format_name: "PNG".to_string(),
protocol: None,
};
app.image_cache.insert(
format!("image{}.png", i),
ImageDiffState {
before: Some(cached_image),
after: None,
},
);
}
let ctx = FrameContext::new(&app);
let wrap_heights = ctx.get_wrap_heights(&app);
assert_eq!(wrap_heights[0], 1, "File header should be 1 row");
assert_eq!(wrap_heights[2], 1, "File header should be 1 row");
assert_eq!(wrap_heights[4], 1, "File header should be 1 row");
let expected_image_height = crate::ui::image_view::calculate_image_height_for_images(
Some((192, 192)),
None,
100,
(8, 16),
) as usize;
assert_eq!(
wrap_heights[1], expected_image_height,
"Image 1 should use cache dimensions, not fallback"
);
assert_eq!(
wrap_heights[3], expected_image_height,
"Image 2 should use cache dimensions, not fallback"
);
assert_eq!(
wrap_heights[5], expected_image_height,
"Image 3 should use cache dimensions, not fallback"
);
let (start, end) = ctx.visible_range(&app);
assert_eq!(start, 0);
assert_eq!(
end, 6,
"All 6 items (3 headers + 3 images) should be visible with 60-row viewport"
);
}
}