@@ -786,6 +786,13 @@ impl FormPane {
786786 self . group . focus_child ( scroll_id, ctx) ;
787787 if let Some ( sg) = self . scroll_mut ( ) {
788788 sg. focus_child ( id, ctx) ;
789+ // Actively snap the landed field into view. The per-event settle
790+ // (region-aware) would otherwise leave a first/last focusable field
791+ // off-screen on open when it sits below a tall read-only head (its
792+ // whole region is a scroll fixed point), so land it explicitly.
793+ if let Some ( logical) = sg. logical_of ( id) {
794+ sg. ensure_visible ( logical, ctx) ;
795+ }
789796 }
790797 // Always land with the caret homed to the start of the value (not the
791798 // select-all-to-end block Turbo Vision leaves on focus). This covers a
@@ -860,12 +867,22 @@ impl FormPane {
860867 None => ids. len ( ) - 1 ,
861868 } ;
862869 let next_id = ids[ next] ;
870+ let moved = cur != Some ( next_id) ;
863871 if let Some ( sg) = self . scroll_mut ( ) {
864872 sg. focus_child ( next_id, ctx) ;
865- // Scroll immediately so the newly focused field — and thus the
866- // hardware cursor — is on screen this frame, not one pump tick later.
867- if let Some ( logical) = sg. logical_of ( next_id) {
868- sg. ensure_visible ( logical, ctx) ;
873+ if moved {
874+ // Focus actually advanced: snap the newly focused field fully into
875+ // view this frame (so the hardware cursor is on screen now, not one
876+ // pump tick later).
877+ if let Some ( logical) = sg. logical_of ( next_id) {
878+ sg. ensure_visible ( logical, ctx) ;
879+ }
880+ } else {
881+ // Clamped at an end (Down on the last / Up on the first focusable
882+ // field): don't re-snap to the bare field rect — that would yank a
883+ // deliberately scrolled-in read-only head/tail back off screen. Use
884+ // the region-aware settle, which leaves an in-band scroll alone.
885+ sg. ensure_focused_visible ( ctx) ;
869886 }
870887 }
871888 // Enter the field with the caret at the start, not a select-all block.
@@ -1374,8 +1391,21 @@ impl View for FormPane {
13741391 self . group . handle_event ( ev, ctx) ;
13751392 }
13761393 } else if nav {
1394+ // Plain (non-list, non-launch) focused field. If it is the FIRST or
1395+ // LAST focusable field and the form has a non-focusable read-only
1396+ // head/tail, Up/Down first walks the viewport through that hidden
1397+ // region (read-only cells never take focus, so this is the only way to
1398+ // bring a trailing/leading read-only field into view); only once the
1399+ // region's near edge is visible does focus advance to the prev/next
1400+ // field.
13771401 let down = matches ! ( ev, Event :: KeyDown ( k) if k. key == Key :: Down ) ;
1378- self . focus_field ( if down { 1 } else { -1 } , ctx) ;
1402+ let scrolled = self
1403+ . scroll_mut ( )
1404+ . map ( |sg| sg. scroll_focus_region_edge ( down, 1 , ctx) )
1405+ . unwrap_or ( false ) ;
1406+ if !scrolled {
1407+ self . focus_field ( if down { 1 } else { -1 } , ctx) ;
1408+ }
13791409 ev. clear ( ) ;
13801410 } else if is_keydown && self . focused_is_launch_view ( ) {
13811411 // Action key on a read-only launch/list block: route it to the focused
@@ -2610,6 +2640,139 @@ mod tests {
26102640 ) ;
26112641 }
26122642
2643+ #[ test]
2644+ fn trailing_readonly_tail_scrolls_into_view ( ) {
2645+ // Reported bug: read-only fields can never take focus, and the form's
2646+ // scroll anchors on the focused field — so a run of read-only fields
2647+ // BELOW the last focusable field (past the last visible row of a long
2648+ // form) can never be scrolled into view. Pressing Down on the last
2649+ // focusable field must walk the viewport through that trailing read-only
2650+ // tail one line at a time, instead of clamping in place.
2651+ let mut fields = vec ! [ ef( "cn" , "z" , true ) ] ; // the only focusable field
2652+ for i in 0 ..10 {
2653+ fields. push ( ef ( & format ! ( "ro{i}" ) , & format ! ( "v{i}" ) , false ) ) ;
2654+ }
2655+ let ( _shared, mut pane) = build_pane_with_form ( fields) ;
2656+ // 6-row viewport (rows 2..8); content is 11 rows → max_top 5.
2657+ <FormPane as View >:: change_bounds ( & mut pane, Rect :: new ( 0 , 0 , 80 , 8 ) ) ;
2658+ let mut out = VecDeque :: new ( ) ;
2659+ let mut timers = tv:: timer:: TimerQueue :: new ( ) ;
2660+ let mut deferred = Vec :: new ( ) ;
2661+ let mut tick = Event :: Broadcast {
2662+ command : REFRESH ,
2663+ source : None ,
2664+ } ;
2665+ pane. handle_event (
2666+ & mut tick,
2667+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2668+ ) ;
2669+
2670+ assert_eq ! (
2671+ pane. focusable_value_ids( ) . len( ) ,
2672+ 1 ,
2673+ "cn is the only focusable field; the ro* tail is read-only"
2674+ ) ;
2675+ let max_top = pane. scroll_mut ( ) . unwrap ( ) . max_top ( ) ;
2676+ assert_eq ! ( max_top, 5 , "content (11) overflows the 6-row viewport" ) ;
2677+
2678+ // Down past the last focusable field scrolls the read-only tail into view
2679+ // one line at a time (before the fix this did nothing).
2680+ for expected_top in 1 ..=max_top {
2681+ let mut d = Event :: KeyDown ( tv:: KeyEvent :: from ( tv:: Key :: Down ) ) ;
2682+ pane. handle_event (
2683+ & mut d,
2684+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2685+ ) ;
2686+ assert_eq ! (
2687+ pane. scroll_mut( ) . unwrap( ) . top_for_test( ) ,
2688+ expected_top,
2689+ "Down scrolls the read-only tail into view one line at a time"
2690+ ) ;
2691+ }
2692+
2693+ // A passive event (a REFRESH tick) must NOT yank the scroll back to the
2694+ // focused field: the tail stays revealed.
2695+ let mut tick2 = Event :: Broadcast {
2696+ command : REFRESH ,
2697+ source : None ,
2698+ } ;
2699+ pane. handle_event (
2700+ & mut tick2,
2701+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2702+ ) ;
2703+ assert_eq ! (
2704+ pane. scroll_mut( ) . unwrap( ) . top_for_test( ) ,
2705+ max_top,
2706+ "the revealed tail is stable across passive re-anchor events"
2707+ ) ;
2708+
2709+ // At the bottom, further Down is a no-op (nothing left to reveal).
2710+ let mut d = Event :: KeyDown ( tv:: KeyEvent :: from ( tv:: Key :: Down ) ) ;
2711+ pane. handle_event (
2712+ & mut d,
2713+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2714+ ) ;
2715+ assert_eq ! ( pane. scroll_mut( ) . unwrap( ) . top_for_test( ) , max_top) ;
2716+ }
2717+
2718+ #[ test]
2719+ fn leading_readonly_head_scrolls_into_view ( ) {
2720+ // Symmetric to the trailing tail: a run of read-only fields ABOVE the
2721+ // first focusable field. On open the focused field must be visible (the
2722+ // scroll snaps to show it, head partly above), and Up must then walk the
2723+ // viewport up through the read-only head one line at a time.
2724+ let mut fields: Vec < EditField > = ( 0 ..10 )
2725+ . map ( |i| ef ( & format ! ( "ro{i}" ) , & format ! ( "v{i}" ) , false ) )
2726+ . collect ( ) ;
2727+ fields. push ( ef ( "cn" , "z" , true ) ) ; // the only focusable field, at the bottom
2728+ let ( _shared, mut pane) = build_pane_with_form ( fields) ;
2729+ // 6-row viewport (rows 2..8); content is 11 rows → max_top 5.
2730+ <FormPane as View >:: change_bounds ( & mut pane, Rect :: new ( 0 , 0 , 80 , 8 ) ) ;
2731+ let mut out = VecDeque :: new ( ) ;
2732+ let mut timers = tv:: timer:: TimerQueue :: new ( ) ;
2733+ let mut deferred = Vec :: new ( ) ;
2734+ let mut tick = Event :: Broadcast {
2735+ command : REFRESH ,
2736+ source : None ,
2737+ } ;
2738+ pane. handle_event (
2739+ & mut tick,
2740+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2741+ ) ;
2742+
2743+ assert_eq ! ( pane. focusable_value_ids( ) . len( ) , 1 , "only cn is focusable" ) ;
2744+ // cn is the last content row (row 10); to show it the 6-row viewport must
2745+ // scroll to top=5 (rows 5..11) on open — not sit at top=0 hiding it.
2746+ assert_eq ! (
2747+ pane. scroll_mut( ) . unwrap( ) . top_for_test( ) ,
2748+ 5 ,
2749+ "the focused field is snapped into view on open, not hidden by the head"
2750+ ) ;
2751+
2752+ // Up past the first focusable field walks the read-only head into view one
2753+ // line at a time (top counts back down to 0).
2754+ for expected_top in ( 0 ..=4 ) . rev ( ) {
2755+ let mut u = Event :: KeyDown ( tv:: KeyEvent :: from ( tv:: Key :: Up ) ) ;
2756+ pane. handle_event (
2757+ & mut u,
2758+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2759+ ) ;
2760+ assert_eq ! (
2761+ pane. scroll_mut( ) . unwrap( ) . top_for_test( ) ,
2762+ expected_top,
2763+ "Up scrolls the read-only head into view one line at a time"
2764+ ) ;
2765+ }
2766+
2767+ // At the top, further Up is a no-op.
2768+ let mut u = Event :: KeyDown ( tv:: KeyEvent :: from ( tv:: Key :: Up ) ) ;
2769+ pane. handle_event (
2770+ & mut u,
2771+ & mut headless_ctx ( & mut out, & mut timers, & mut deferred) ,
2772+ ) ;
2773+ assert_eq ! ( pane. scroll_mut( ) . unwrap( ) . top_for_test( ) , 0 ) ;
2774+ }
2775+
26132776 #[ test]
26142777 fn short_launch_block_advances_focus_immediately ( ) {
26152778 // A Launch block that already fits the viewport must behave exactly as
0 commit comments