Skip to content

Commit ebacda7

Browse files
committed
feat(Settings): implement directional input handling for mod settings controls
- Added IModSettingsDirectionalInputClaimant interface to manage directional input claims for various controls, ensuring proper focus handling during active modes. - Updated ModSettingsDropdownChoiceControl, ModSettingsKeyBindingControl, and ModSettingsActionsButton to implement the new interface, enhancing user experience with consistent input behavior. - Enhanced RitsuModSettingsSubmenu to support live up/down focus navigation, improving UI responsiveness and focus management during dynamic content changes.
1 parent d9ae812 commit ebacda7

2 files changed

Lines changed: 152 additions & 9 deletions

File tree

Settings/ModSettingsUi/Controls/ModSettingsUiControls.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ internal interface IModSettingsTransientPopupOwner
1515
void ForceCloseTransientUi();
1616
}
1717

18+
/// <summary>
19+
/// Implemented by entry controls that consume directional (up/down) input while in an active mode — an
20+
/// open dropdown/actions menu, or a key-binding control recording input. The submenu's live focus
21+
/// navigator skips controls whose ancestor claims directional input so the control's own handling wins.
22+
/// 由那些在激活模式下消费方向(上/下)输入的条目控件实现——展开的下拉/操作菜单,或正在录制输入的按键绑定控件。子菜单的
23+
/// 实时焦点导航器会跳过其祖先声明占用方向输入的控件,让该控件自身的处理生效。
24+
/// </summary>
25+
internal interface IModSettingsDirectionalInputClaimant
26+
{
27+
bool ClaimsDirectionalInput { get; }
28+
}
29+
1830
/// <summary>
1931
/// Standard On/Off toggle control used by mod settings entries.
2032
/// mod 设置条目使用的标准 On/Off 切换控件。
@@ -1036,7 +1048,7 @@ private void RefreshCurrentLabel()
10361048
/// 存储的选项值类型。
10371049
/// </typeparam>
10381050
public sealed partial class ModSettingsDropdownChoiceControl<TValue> : HBoxContainer,
1039-
IModSettingsTransientPopupOwner
1051+
IModSettingsTransientPopupOwner, IModSettingsDirectionalInputClaimant
10401052
{
10411053
private const float DropListMinWidth = 200f;
10421054
private const float RowHeight = 38f;
@@ -1142,6 +1154,8 @@ public ModSettingsDropdownChoiceControl()
11421154
{
11431155
}
11441156

1157+
bool IModSettingsDirectionalInputClaimant.ClaimsDirectionalInput => _dropOpen;
1158+
11451159
void IModSettingsTransientPopupOwner.ForceCloseTransientUi()
11461160
{
11471161
CloseDropdown();
@@ -2487,7 +2501,7 @@ private static string FormatColorValue(Color color)
24872501
/// Keybinding capture editor used by settings pages and custom editors.
24882502
/// 设置页面和自定义编辑器使用的按键绑定捕获编辑器。
24892503
/// </summary>
2490-
public sealed partial class ModSettingsKeyBindingControl : VBoxContainer
2504+
public sealed partial class ModSettingsKeyBindingControl : VBoxContainer, IModSettingsDirectionalInputClaimant
24912505
{
24922506
private readonly bool _allowModifierCombos;
24932507
private readonly bool _allowModifierOnly;
@@ -2606,6 +2620,8 @@ public ModSettingsKeyBindingControl()
26062620
{
26072621
}
26082622

2623+
bool IModSettingsDirectionalInputClaimant.ClaimsDirectionalInput => _capturing;
2624+
26092625
/// <inheritdoc />
26102626
public override void _Ready()
26112627
{
@@ -2801,7 +2817,7 @@ internal static bool IsModifierKey(Key key)
28012817
}
28022818

28032819
internal sealed partial class ModSettingsActionsButton : ModSettingsGamepadCompatibleButton,
2804-
IModSettingsTransientPopupOwner
2820+
IModSettingsTransientPopupOwner, IModSettingsDirectionalInputClaimant
28052821
{
28062822
private const float DropMinWidth = 260f;
28072823
private const float RowHeight = 38f;
@@ -2848,6 +2864,8 @@ public ModSettingsActionsButton()
28482864
Pressed += OnEllipsisPressed;
28492865
}
28502866

2867+
bool IModSettingsDirectionalInputClaimant.ClaimsDirectionalInput => _dropOpen;
2868+
28512869
void IModSettingsTransientPopupOwner.ForceCloseTransientUi()
28522870
{
28532871
CloseDropdown();
@@ -3292,7 +3310,7 @@ private void LayoutDropdownInViewport()
32923310
/// Multi-keybinding capture editor used by native settings pages.
32933311
/// 原生设置页面使用的多按键绑定捕获编辑器。
32943312
/// </summary>
3295-
public sealed partial class ModSettingsMultiKeyBindingControl : VBoxContainer
3313+
public sealed partial class ModSettingsMultiKeyBindingControl : VBoxContainer, IModSettingsDirectionalInputClaimant
32963314
{
32973315
private readonly bool _allowModifierCombos;
32983316
private readonly bool _allowModifierOnly;
@@ -3378,6 +3396,8 @@ public ModSettingsMultiKeyBindingControl()
33783396
{
33793397
}
33803398

3399+
bool IModSettingsDirectionalInputClaimant.ClaimsDirectionalInput => _capturing;
3400+
33813401
/// <inheritdoc />
33823402
public override void _Ready()
33833403
{

Settings/ModSettingsUi/Core/RitsuModSettingsSubmenu.cs

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)