@@ -2355,18 +2355,141 @@ private static void GrabControlDeferred(Control? target)
23552355
23562356 private static void WireVerticalOnlyChain ( IReadOnlyList < Control > chain )
23572357 {
2358- for ( var index = 0 ; index < chain . Count ; index ++ )
2358+ // Pin every neighbor to self so Godot's native focus traversal never moves on its own. Up/down are
2359+ // driven live by TryHandleDirectionalFocusInput (which recomputes the focusable order each press),
2360+ // and left/right stay blocked so vertical navigation cannot escape sideways into the other pane.
2361+ // The previous design wired absolute NodePaths to the prev/next chain entry; those dangle the moment
2362+ // the content tree mutates (refresh frees/recreates controls, list add/remove, host swaps,
2363+ // expand/collapse), which made focus jump to a freed node and disappear on complex/dynamic pages.
2364+ // 把每个 neighbor 都钉到自身,使 Godot 原生焦点遍历不会自行移动。上/下由 TryHandleDirectionalFocusInput 实时驱动
2365+ // (每次按键重新计算可聚焦顺序),左/右保持封锁,使纵向导航不会横向逃逸到另一个窗格。旧设计把绝对 NodePath 接到
2366+ // 链中前/后一项;一旦内容树变动(刷新释放/重建控件、列表增删、host 替换、展开/折叠),这些路径就悬空,导致焦点跳到已
2367+ // 释放的节点并在复杂/动态页面上消失。
2368+ foreach ( var current in chain )
23592369 {
2360- var current = chain [ index ] ;
23612370 var selfPath = current . GetPath ( ) ;
23622371 current . FocusNeighborLeft = selfPath ;
23632372 current . FocusNeighborRight = selfPath ;
2364- current . FocusNeighborTop = index > 0 ? chain [ index - 1 ] . GetPath ( ) : null ;
2365- current . FocusNeighborBottom =
2366- index < chain . Count - 1 ? chain [ index + 1 ] . GetPath ( ) : null ;
2373+ current . FocusNeighborTop = selfPath ;
2374+ current . FocusNeighborBottom = selfPath ;
23672375 }
23682376 }
23692377
2378+ /// <inheritdoc />
2379+ public override void _Input ( InputEvent @event )
2380+ {
2381+ if ( TryHandleDirectionalFocusInput ( @event ) )
2382+ return ;
2383+ base . _Input ( @event ) ;
2384+ }
2385+
2386+ /// <summary>
2387+ /// Live up/down focus navigation for the active pane. Instead of relying on pre-wired
2388+ /// <see cref="Control.FocusNeighborTop" /> / <see cref="Control.FocusNeighborBottom" /> paths (which go
2389+ /// stale and drop focus the moment the content tree mutates), the focusable order is recomputed from
2390+ /// the live tree on every press, so navigation stays correct through refreshes, list edits, host swaps
2391+ /// and expand/collapse. Order matches the previous preorder chain, so secondary controls (e.g. the
2392+ /// per-entry actions button) remain reachable. Text editors, popups and the open dropdown keep their
2393+ /// own up/down handling.
2394+ /// 当前窗格的实时上/下焦点导航。不再依赖预先连好的 <see cref="Control.FocusNeighborTop" /> /
2395+ /// <see cref="Control.FocusNeighborBottom" /> 路径(它们在内容树变动时会悬空并丢失焦点),而是每次按键都从实时树重新
2396+ /// 计算可聚焦顺序,因此在刷新、列表编辑、host 替换、展开/折叠期间导航始终正确。顺序与旧的前序链一致,故次级控件(如每
2397+ /// 条目的 actions 按钮)仍可到达。文本编辑器、弹窗与展开的下拉框保留各自的上/下处理。
2398+ /// </summary>
2399+ private bool TryHandleDirectionalFocusInput ( InputEvent @event )
2400+ {
2401+ if ( ! Visible || ! IsInstanceValid ( this ) )
2402+ return false ;
2403+ if ( ! ActiveScreenContext . Instance . IsCurrent ( this ) )
2404+ return false ;
2405+
2406+ // Echo is allowed so holding the key keeps moving the focus, matching the previous behavior.
2407+ int delta ;
2408+ if ( @event . IsActionPressed ( "ui_up" ) )
2409+ delta = - 1 ;
2410+ else if ( @event . IsActionPressed ( "ui_down" ) )
2411+ delta = 1 ;
2412+ else
2413+ return false ;
2414+
2415+ var owner = GetViewport ( ) ? . GuiGetFocusOwner ( ) ;
2416+ if ( owner == null || ! IsInstanceValid ( owner ) )
2417+ return false ;
2418+
2419+ // Multiline editors use up/down to move the caret; native popups handle their own vertical
2420+ // navigation. Leave those to their own input handling.
2421+ if ( owner is TextEdit || IsFocusUnderPopupOrTransientWindow ( owner ) )
2422+ return false ;
2423+
2424+ // An open dropdown / actions menu, or a key-binding control recording input, owns directional input
2425+ // while active (its overlay lives inside the content pane, so the pane check above does not exclude
2426+ // it). Defer to the control's own handling in that case.
2427+ for ( Node ? n = owner ; n != null && ! ReferenceEquals ( n , this ) ; n = n . GetParent ( ) )
2428+ if ( n is IModSettingsDirectionalInputClaimant { ClaimsDirectionalInput : true } )
2429+ return false ;
2430+
2431+ Control paneRoot ;
2432+ ScrollContainer paneScroll ;
2433+ if ( _contentPanelRoot . IsAncestorOf ( owner ) )
2434+ {
2435+ paneRoot = _contentPanelRoot ;
2436+ paneScroll = _scrollContainer ;
2437+ }
2438+ else if ( _sidebarPanelRoot . IsAncestorOf ( owner ) )
2439+ {
2440+ paneRoot = _sidebarPanelRoot ;
2441+ paneScroll = _sidebarScrollContainer ;
2442+ }
2443+ else
2444+ {
2445+ return false ;
2446+ }
2447+
2448+ var focusables = new List < Control > ( ) ;
2449+ CollectSettingsFocusChainPreorder ( paneRoot , focusables ) ;
2450+ if ( focusables . Count == 0 )
2451+ return false ;
2452+
2453+ var currentIndex = focusables . IndexOf ( owner ) ;
2454+ if ( currentIndex < 0 )
2455+ currentIndex = ResolveNearestFocusIndex ( focusables , owner ) ;
2456+ if ( currentIndex < 0 )
2457+ return false ;
2458+
2459+ var nextIndex = currentIndex + delta ;
2460+ // At either end, consume the event so focus stays put rather than escaping the pane or being lost.
2461+ if ( nextIndex >= 0 && nextIndex < focusables . Count )
2462+ {
2463+ var target = focusables [ nextIndex ] ;
2464+ target . GrabFocus ( ) ;
2465+ if ( ! IsInstanceValid ( paneScroll ) )
2466+ {
2467+ GetViewport ( ) ? . SetInputAsHandled ( ) ;
2468+ return true ;
2469+ }
2470+
2471+ if ( ReferenceEquals ( paneScroll , _scrollContainer ) )
2472+ Callable . From ( ( ) => paneScroll . EnsureControlVisible ( target ) ) . CallDeferred ( ) ;
2473+ else
2474+ paneScroll . EnsureControlVisible ( target ) ;
2475+ }
2476+
2477+ GetViewport ( ) ? . SetInputAsHandled ( ) ;
2478+ return true ;
2479+ }
2480+
2481+ private static int ResolveNearestFocusIndex ( IReadOnlyList < Control > focusables , Control owner )
2482+ {
2483+ for ( var i = 0 ; i < focusables . Count ; i ++ )
2484+ {
2485+ var candidate = focusables [ i ] ;
2486+ if ( candidate == owner || candidate . IsAncestorOf ( owner ) || owner . IsAncestorOf ( candidate ) )
2487+ return i ;
2488+ }
2489+
2490+ return - 1 ;
2491+ }
2492+
23702493 private static void CollectSettingsFocusChainPreorder ( Control parent , List < Control > controls )
23712494 {
23722495 foreach ( var child in parent . GetChildren ( ) )
0 commit comments