@@ -4,7 +4,7 @@ use crate::grouper::{GroupingStatus, SemanticGroup};
44use crate :: highlight:: HighlightCache ;
55use crate :: ui:: file_tree:: TreeNodeId ;
66use crossterm:: event:: { KeyCode , KeyEvent , KeyModifiers } ;
7- use std:: cell:: RefCell ;
7+ use std:: cell:: { Cell , RefCell } ;
88use std:: collections:: { HashMap , HashSet } ;
99use tokio:: sync:: mpsc;
1010use 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,
0 commit comments