@@ -495,6 +495,8 @@ pub struct App {
495495 pub sandbox_providers_list : Vec < String > ,
496496 pub policy_lines : Vec < ratatui:: text:: Line < ' static > > ,
497497 pub policy_scroll : usize ,
498+ /// Visible line count in the policy pane, set during draw for PageUp/PageDown.
499+ pub policy_viewport_height : usize ,
498500
499501 // Create sandbox modal
500502 pub create_form : Option < CreateSandboxForm > ,
@@ -656,6 +658,7 @@ impl App {
656658 sandbox_providers_list : Vec :: new ( ) ,
657659 policy_lines : Vec :: new ( ) ,
658660 policy_scroll : 0 ,
661+ policy_viewport_height : 0 ,
659662 create_form : None ,
660663 pending_create_sandbox : false ,
661664 pending_forward_ports : Vec :: new ( ) ,
@@ -1155,9 +1158,21 @@ impl App {
11551158 KeyCode :: Char ( 'k' ) | KeyCode :: Up => {
11561159 self . scroll_policy ( -1 ) ;
11571160 }
1161+ // Page-scroll by one viewport height.
1162+ KeyCode :: PageDown => {
1163+ let delta = self . policy_viewport_height . max ( 1 ) . cast_signed ( ) ;
1164+ self . scroll_policy ( delta) ;
1165+ }
1166+ KeyCode :: PageUp => {
1167+ let delta = self . policy_viewport_height . max ( 1 ) . cast_signed ( ) ;
1168+ self . scroll_policy ( -delta) ;
1169+ }
11581170 KeyCode :: Char ( 'G' ) => {
1159- // Scroll to bottom.
1160- self . policy_scroll = self . policy_lines . len ( ) . saturating_sub ( 1 ) ;
1171+ // Scroll to bottom, keeping a full viewport visible.
1172+ self . policy_scroll = self
1173+ . policy_lines
1174+ . len ( )
1175+ . saturating_sub ( self . policy_viewport_height . max ( 1 ) ) ;
11611176 }
11621177 KeyCode :: Char ( 'g' ) => {
11631178 self . policy_scroll = 0 ;
@@ -1426,6 +1441,21 @@ impl App {
14261441 self . draft_scroll -= 1 ;
14271442 }
14281443 }
1444+ // Page-scroll by one viewport height, clamping the cursor to
1445+ // stay within the visible range.
1446+ KeyCode :: PageDown if total > 0 => {
1447+ let page = vh. max ( 1 ) ;
1448+ let max_scroll = total. saturating_sub ( vh. min ( total) ) ;
1449+ self . draft_scroll = ( self . draft_scroll + page) . min ( max_scroll) ;
1450+ let visible = total. saturating_sub ( self . draft_scroll ) . min ( vh) ;
1451+ self . draft_selected = self . draft_selected . min ( visible. saturating_sub ( 1 ) ) ;
1452+ }
1453+ KeyCode :: PageUp if total > 0 => {
1454+ let page = vh. max ( 1 ) ;
1455+ self . draft_scroll = self . draft_scroll . saturating_sub ( page) ;
1456+ let visible = total. saturating_sub ( self . draft_scroll ) . min ( vh) ;
1457+ self . draft_selected = self . draft_selected . min ( visible. saturating_sub ( 1 ) ) ;
1458+ }
14291459 KeyCode :: Char ( 'g' ) => {
14301460 self . draft_scroll = 0 ;
14311461 self . draft_selected = 0 ;
@@ -1490,16 +1520,15 @@ impl App {
14901520 }
14911521
14921522 /// Scroll policy pane by a delta (positive = down, negative = up).
1523+ ///
1524+ /// Clamps so at least one viewport of content remains visible.
14931525 pub fn scroll_policy ( & mut self , delta : isize ) {
1494- let max = self . policy_lines . len ( ) . saturating_sub ( 1 ) ;
1495- if delta < 0 {
1496- self . policy_scroll = self . policy_scroll . saturating_sub ( delta. unsigned_abs ( ) ) ;
1497- } else {
1498- #[ allow( clippy:: cast_sign_loss) ]
1499- {
1500- self . policy_scroll = ( self . policy_scroll + delta as usize ) . min ( max) ;
1501- }
1502- }
1526+ self . policy_scroll = clamped_scroll (
1527+ self . policy_scroll ,
1528+ delta,
1529+ self . policy_lines . len ( ) ,
1530+ self . policy_viewport_height ,
1531+ ) ;
15031532 }
15041533
15051534 fn handle_logs_key ( & mut self , key : KeyEvent ) {
@@ -1608,6 +1637,17 @@ impl App {
16081637 }
16091638 self . log_autoscroll = false ;
16101639 }
1640+ // Page-scroll by one viewport height.
1641+ KeyCode :: PageDown => {
1642+ let delta = vh. max ( 1 ) . cast_signed ( ) ;
1643+ self . scroll_logs ( delta) ;
1644+ self . log_autoscroll = false ;
1645+ }
1646+ KeyCode :: PageUp => {
1647+ let delta = vh. max ( 1 ) . cast_signed ( ) ;
1648+ self . scroll_logs ( -delta) ;
1649+ self . log_autoscroll = false ;
1650+ }
16111651 KeyCode :: Char ( 'G' | 'f' ) => {
16121652 self . log_selection_anchor = None ;
16131653 self . sandbox_log_scroll = self . log_autoscroll_offset ( ) ;
@@ -2247,3 +2287,94 @@ fn unique_provider_name(base: &str, existing: &[String]) -> String {
22472287 }
22482288 base. to_string ( )
22492289}
2290+
2291+ /// Compute a new scroll position after applying `delta`, clamped so the last
2292+ /// viewport of content remains visible.
2293+ ///
2294+ /// * `current` - current scroll offset
2295+ /// * `delta` - lines to scroll (positive = down, negative = up)
2296+ /// * `total` - total number of lines/items
2297+ /// * `viewport` - visible line count (0 before first draw, treated as 1)
2298+ fn clamped_scroll ( current : usize , delta : isize , total : usize , viewport : usize ) -> usize {
2299+ let max = total. saturating_sub ( viewport. max ( 1 ) ) ;
2300+ if delta < 0 {
2301+ current. saturating_sub ( delta. unsigned_abs ( ) )
2302+ } else {
2303+ #[ allow( clippy:: cast_sign_loss) ]
2304+ let stepped = current + delta as usize ;
2305+ stepped. min ( max)
2306+ }
2307+ }
2308+
2309+ #[ cfg( test) ]
2310+ mod tests {
2311+ use super :: * ;
2312+
2313+ // -- clamped_scroll -------------------------------------------------
2314+
2315+ #[ test]
2316+ fn scroll_empty_content ( ) {
2317+ // No lines at all: scroll should stay at 0 regardless of delta.
2318+ assert_eq ! ( clamped_scroll( 0 , 1 , 0 , 10 ) , 0 ) ;
2319+ assert_eq ! ( clamped_scroll( 0 , -1 , 0 , 10 ) , 0 ) ;
2320+ assert_eq ! ( clamped_scroll( 0 , 20 , 0 , 10 ) , 0 ) ;
2321+ }
2322+
2323+ #[ test]
2324+ fn scroll_content_shorter_than_viewport ( ) {
2325+ // 5 lines in a 10-line viewport: max scroll is 0.
2326+ assert_eq ! ( clamped_scroll( 0 , 1 , 5 , 10 ) , 0 ) ;
2327+ assert_eq ! ( clamped_scroll( 0 , 5 , 5 , 10 ) , 0 ) ;
2328+ }
2329+
2330+ #[ test]
2331+ fn scroll_content_equals_viewport ( ) {
2332+ // Exactly 10 lines in a 10-line viewport: max scroll is 0.
2333+ assert_eq ! ( clamped_scroll( 0 , 1 , 10 , 10 ) , 0 ) ;
2334+ assert_eq ! ( clamped_scroll( 0 , -1 , 10 , 10 ) , 0 ) ;
2335+ }
2336+
2337+ #[ test]
2338+ fn scroll_down_one ( ) {
2339+ // 100 lines, viewport 20, start at 0: scroll to 1.
2340+ assert_eq ! ( clamped_scroll( 0 , 1 , 100 , 20 ) , 1 ) ;
2341+ }
2342+
2343+ #[ test]
2344+ fn scroll_page_down ( ) {
2345+ // 100 lines, viewport 20, start at 0: scroll to 20.
2346+ assert_eq ! ( clamped_scroll( 0 , 20 , 100 , 20 ) , 20 ) ;
2347+ }
2348+
2349+ #[ test]
2350+ fn scroll_page_down_clamps_at_bottom ( ) {
2351+ // 100 lines, viewport 20: max scroll = 80.
2352+ assert_eq ! ( clamped_scroll( 75 , 20 , 100 , 20 ) , 80 ) ;
2353+ assert_eq ! ( clamped_scroll( 80 , 20 , 100 , 20 ) , 80 ) ;
2354+ }
2355+
2356+ #[ test]
2357+ fn scroll_page_up_from_middle ( ) {
2358+ assert_eq ! ( clamped_scroll( 40 , -20 , 100 , 20 ) , 20 ) ;
2359+ }
2360+
2361+ #[ test]
2362+ fn scroll_page_up_clamps_at_top ( ) {
2363+ // Scrolling up past 0 saturates to 0.
2364+ assert_eq ! ( clamped_scroll( 5 , -20 , 100 , 20 ) , 0 ) ;
2365+ assert_eq ! ( clamped_scroll( 0 , -1 , 100 , 20 ) , 0 ) ;
2366+ }
2367+
2368+ #[ test]
2369+ fn scroll_viewport_zero_before_first_draw ( ) {
2370+ // viewport=0 is treated as 1 (the .max(1) fallback).
2371+ // 100 lines, viewport 0 -> max = 99.
2372+ assert_eq ! ( clamped_scroll( 0 , 1 , 100 , 0 ) , 1 ) ;
2373+ assert_eq ! ( clamped_scroll( 98 , 5 , 100 , 0 ) , 99 ) ;
2374+ }
2375+
2376+ #[ test]
2377+ fn scroll_up_one ( ) {
2378+ assert_eq ! ( clamped_scroll( 10 , -1 , 100 , 20 ) , 9 ) ;
2379+ }
2380+ }
0 commit comments