Skip to content

Commit e33e8f2

Browse files
Alan Shumclaude
andcommitted
fix: account for line wrapping in scroll offset calculation
Tracks diff view panel width and uses it to compute visual row counts for wrapped lines, ensuring the selected item stays visible in viewport. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a195cbe commit e33e8f2

File tree

2 files changed

+115
-13
lines changed

2 files changed

+115
-13
lines changed

src/app.rs

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::grouper::{GroupingStatus, SemanticGroup};
44
use crate::highlight::HighlightCache;
55
use crate::ui::file_tree::TreeNodeId;
66
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7-
use std::cell::RefCell;
7+
use std::cell::{Cell, RefCell};
88
use std::collections::{HashMap, HashSet};
99
use tokio::sync::mpsc;
1010
use tui_tree_widget::TreeState;
@@ -67,6 +67,8 @@ pub struct UiState {
6767
pub collapsed: HashSet<NodeId>,
6868
/// Terminal viewport height, updated each frame.
6969
pub viewport_height: u16,
70+
/// Width of the diff view panel (Cell for interior mutability during render).
71+
pub diff_view_width: Cell<u16>,
7072
}
7173

7274
/// An item in the flattened visible list.
@@ -124,6 +126,7 @@ impl App {
124126
scroll_offset: 0,
125127
collapsed: HashSet::new(),
126128
viewport_height: 24, // will be updated on first draw
129+
diff_view_width: Cell::new(80),
127130
},
128131
highlight_cache,
129132
should_quit: false,
@@ -707,16 +710,105 @@ impl App {
707710
}
708711
}
709712

710-
/// Adjust scroll offset to keep the selected item visible.
713+
/// Estimate the character width of a visible item's rendered line.
714+
fn item_char_width(&self, item: &VisibleItem) -> usize {
715+
match item {
716+
VisibleItem::FileHeader { file_idx } => {
717+
let file = &self.diff_data.files[*file_idx];
718+
let name = if file.is_rename {
719+
format!(
720+
"renamed: {} -> {}",
721+
file.source_file.trim_start_matches("a/"),
722+
file.target_file.trim_start_matches("b/")
723+
)
724+
} else {
725+
file.target_file.trim_start_matches("b/").to_string()
726+
};
727+
// " v " + name + " " + "+N" + " -N"
728+
3 + name.len()
729+
+ 1
730+
+ format!("+{}", file.added_count).len()
731+
+ format!(" -{}", file.removed_count).len()
732+
}
733+
VisibleItem::HunkHeader { file_idx, hunk_idx } => {
734+
let hunk = &self.diff_data.files[*file_idx].hunks[*hunk_idx];
735+
// " v " + header
736+
5 + hunk.header.len()
737+
}
738+
VisibleItem::DiffLine {
739+
file_idx,
740+
hunk_idx,
741+
line_idx,
742+
} => {
743+
let line =
744+
&self.diff_data.files[*file_idx].hunks[*hunk_idx].lines[*line_idx];
745+
// gutter (10) + prefix (2) + content
746+
12 + line.content.len()
747+
}
748+
}
749+
}
750+
751+
/// Calculate the visual row count for an item given the available width.
752+
pub fn item_visual_rows(&self, item: &VisibleItem, width: u16) -> usize {
753+
if width == 0 {
754+
return 1;
755+
}
756+
let char_width = self.item_char_width(item);
757+
char_width.div_ceil(width as usize).max(1)
758+
}
759+
760+
/// Adjust scroll offset to keep the selected item visible,
761+
/// accounting for line wrapping.
711762
fn adjust_scroll(&mut self) {
712-
let selected = self.ui_state.selected_index as u16;
713-
let viewport = self.ui_state.viewport_height;
763+
let width = self.ui_state.diff_view_width.get();
764+
let viewport = self.ui_state.viewport_height as usize;
765+
let items = self.visible_items();
766+
let selected = self.ui_state.selected_index;
767+
768+
if items.is_empty() || viewport == 0 {
769+
self.ui_state.scroll_offset = 0;
770+
return;
771+
}
772+
773+
let scroll = self.ui_state.scroll_offset as usize;
714774

715-
if selected < self.ui_state.scroll_offset {
716-
self.ui_state.scroll_offset = selected;
717-
} else if selected >= self.ui_state.scroll_offset + viewport {
718-
self.ui_state.scroll_offset = selected - viewport + 1;
775+
// Selected is above viewport
776+
if selected < scroll {
777+
self.ui_state.scroll_offset = selected as u16;
778+
return;
779+
}
780+
781+
// Check if selected fits within viewport from current scroll
782+
let mut rows = 0usize;
783+
for (i, item) in items.iter().enumerate().take(selected + 1).skip(scroll) {
784+
rows += self.item_visual_rows(item, width);
785+
if rows > viewport && i < selected {
786+
break;
787+
}
788+
}
789+
790+
if rows <= viewport {
791+
return;
792+
}
793+
794+
// Selected is below viewport — find scroll that shows it at bottom
795+
let selected_height = self.item_visual_rows(&items[selected], width);
796+
if selected_height >= viewport {
797+
self.ui_state.scroll_offset = selected as u16;
798+
return;
799+
}
800+
801+
let mut remaining = viewport - selected_height;
802+
let mut new_scroll = selected;
803+
for i in (0..selected).rev() {
804+
let h = self.item_visual_rows(&items[i], width);
805+
if h > remaining {
806+
break;
807+
}
808+
remaining -= h;
809+
new_scroll = i;
719810
}
811+
self.ui_state.scroll_offset = new_scroll as u16;
720812
}
721813

722814
/// Compute the list of visible items respecting collapsed state, active filter,

src/ui/diff_view.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,25 @@ pub fn render_diff(app: &App, frame: &mut Frame, area: Rect) {
1111
let scroll = app.ui_state.scroll_offset as usize;
1212
let viewport_height = area.height as usize;
1313

14-
let mut lines: Vec<Line> = Vec::new();
14+
// Store width so adjust_scroll can account for wrapping
15+
app.ui_state.diff_view_width.set(area.width);
1516

16-
// Only render items visible in the viewport
17-
let start = scroll;
18-
let end = (scroll + viewport_height).min(items.len());
17+
let mut lines: Vec<Line> = Vec::new();
18+
let mut visual_rows_used = 0usize;
1919

20-
for (idx, item) in items.iter().enumerate().take(end).skip(start) {
20+
for (idx, item) in items.iter().enumerate().skip(scroll) {
21+
if visual_rows_used >= viewport_height {
22+
break;
23+
}
2124
let is_selected = idx == app.ui_state.selected_index;
2225
let line = render_item(app, item, is_selected);
26+
let char_width: usize = line.spans.iter().map(|s| s.content.len()).sum();
27+
let wrapped_rows = if area.width > 0 && char_width > 0 {
28+
char_width.div_ceil(area.width as usize)
29+
} else {
30+
1
31+
};
32+
visual_rows_used += wrapped_rows;
2333
lines.push(line);
2434
}
2535

0 commit comments

Comments
 (0)