diff --git a/src/Forma/Buttons.cs b/src/Forma/Buttons.cs
index b58be340..3b890f98 100644
--- a/src/Forma/Buttons.cs
+++ b/src/Forma/Buttons.cs
@@ -213,6 +213,14 @@ internal override void KeyPressed(Keys key)
// Godot's BaseButton::gui_input returns immediately for every input kind, including
// the ui_accept action, whenever the button is disabled.
if (!Enabled) return;
+ // A focused button moves focus in the pressed direction unless it declares an explicit
+ // FocusNeighbor (resolved earlier in UIContext.DispatchKey), so action menus are navigable
+ // by arrow keys without every author linking siblings by hand.
+ if (key is Keys.Left or Keys.Up or Keys.Right or Keys.Down)
+ {
+ Context?.MoveFocusDirectionally(key);
+ return;
+ }
if (key != Keys.Enter && key != Keys.Space) return;
if (_pressed) return;
_activeKey = key;
diff --git a/src/Forma/RuntimeAdapters/RuntimeTextCompositionSession.cs b/src/Forma/RuntimeAdapters/RuntimeTextCompositionSession.cs
index 11cfca43..ff31f2e7 100644
--- a/src/Forma/RuntimeAdapters/RuntimeTextCompositionSession.cs
+++ b/src/Forma/RuntimeAdapters/RuntimeTextCompositionSession.cs
@@ -44,8 +44,10 @@ private void Synchronize()
{
if (_changing || _disposed) return;
var focus = _context.FocusedControl;
+ // Only an editable text control composes text. Activating the host's text input for a focused button as well made the
+ // browser's composition proxy take every printable key, so a game could never read a letter key as a shortcut.
var target = _windowActive && _context.CanFocus(focus) &&
- focus is not LineEdit { Editable: false } ? focus : null;
+ focus is LineEdit { Editable: true } ? focus : null;
if (ReferenceEquals(target, _target)) return;
_changing = true;
try
diff --git a/src/Forma/UIContext.cs b/src/Forma/UIContext.cs
index 7a7b42ef..94bd7735 100644
--- a/src/Forma/UIContext.cs
+++ b/src/Forma/UIContext.cs
@@ -949,6 +949,61 @@ private void DispatchKey(Keys key, KeyboardState keyboard)
FocusedControl?.KeyPressed(key);
}
+ ///
+ /// Moves focus to the nearest focusable control in a direction. A control that owns the arrow
+ /// key itself (a list, grid, slider or text editor) calls this from
+ /// when it has no nearer behaviour, so plain action menus are keyboard-navigable without every
+ /// author wiring and friends. Explicit neighbors are
+ /// resolved first in and still win.
+ ///
+ internal bool MoveFocusDirectionally(Keys key)
+ {
+ var target = FindDirectionalNeighbor(key);
+ if (!CanFocus(target)) return false;
+ SetFocus(target);
+ return true;
+ }
+
+ private Control FindDirectionalNeighbor(Keys key)
+ {
+ var current = FocusedControl;
+ if (current == null) return null;
+ var focusable = new List();
+ var modalPopup = GetActiveModalPopup();
+ if (modalPopup != null) CollectFocusable(modalPopup, focusable);
+ else foreach (var root in _roots) CollectFocusable(root, focusable);
+ if (focusable.Count == 0) return null;
+
+ var from = current.Bounds;
+ var originX = from.X + from.Width / 2f;
+ var originY = from.Y + from.Height / 2f;
+ Control best = null;
+ var bestScore = float.MaxValue;
+ foreach (var candidate in focusable)
+ {
+ if (ReferenceEquals(candidate, current)) continue;
+ var to = candidate.Bounds;
+ var dx = to.X + to.Width / 2f - originX;
+ var dy = to.Y + to.Height / 2f - originY;
+ float primary;
+ float secondary;
+ switch (key)
+ {
+ case Keys.Up when dy < 0: primary = -dy; secondary = MathF.Abs(dx); break;
+ case Keys.Down when dy > 0: primary = dy; secondary = MathF.Abs(dx); break;
+ case Keys.Left when dx < 0: primary = -dx; secondary = MathF.Abs(dy); break;
+ case Keys.Right when dx > 0: primary = dx; secondary = MathF.Abs(dy); break;
+ default: continue;
+ }
+ // A control far off the axis must not pull focus from one directly ahead: only accept
+ // candidates whose primary lead is at least their sideways offset, then prefer the nearest.
+ if (secondary > primary) continue;
+ var score = primary + secondary;
+ if (score < bestScore) { bestScore = score; best = candidate; }
+ }
+ return best;
+ }
+
private bool DispatchShortcutInput(Keys key, KeyboardState keyboard, Control modalRoot)
{
if (modalRoot != null) return DispatchShortcutInput(modalRoot, key, keyboard);
diff --git a/tests/Forma.Tests/NativeTextCompositionTest.cs b/tests/Forma.Tests/NativeTextCompositionTest.cs
index 66964f11..520faa16 100644
--- a/tests/Forma.Tests/NativeTextCompositionTest.cs
+++ b/tests/Forma.Tests/NativeTextCompositionTest.cs
@@ -63,6 +63,32 @@ private static LineEdit AddEditor(UIContext context, string text = "before")
return editor;
}
+ [Test]
+ public void OnlyAnEditableTextControlActivatesTheHostTextInput()
+ {
+ using var context = CreateContext();
+ var button = new Button { Text = "Start", Size = new Vector2(120, 40) };
+ context.Add(button);
+ button.GrabFocus();
+ var window = new NativeWindowStub();
+ using var bridge = RuntimeTextCompositionBridge.TryCreate(window, typeof(NativeWindowStub));
+ using var session = new RuntimeTextCompositionSession(context, bridge);
+ session.Update(true);
+ // A focused button takes no text: activating the host's composition input for it would make the browser
+ // capture every printable key, so a letter could never be a shortcut.
+ Assert.That(window.Activations, Is.Empty.Or.All.False);
+
+ var editor = new LineEdit { Text = "x", Size = new Vector2(240, 40) };
+ context.Add(editor);
+ editor.GrabFocus();
+ session.Update(true);
+ Assert.That(window.Activations.LastOrDefault(), Is.True, "an editable text control does");
+
+ button.GrabFocus();
+ session.Update(true);
+ Assert.That(window.Activations.Last(), Is.False, "and focus leaving it turns the input off again");
+ }
+
[Test]
public void OptionalBridgeRejectsMissingAndUnsupportedRuntimeApis()
{
diff --git a/tests/Forma.Tests/UITest.cs b/tests/Forma.Tests/UITest.cs
index f4fbb702..1a5f8616 100644
--- a/tests/Forma.Tests/UITest.cs
+++ b/tests/Forma.Tests/UITest.cs
@@ -4093,6 +4093,35 @@ public void ExplicitFocusNeighbors_OverrideTreeTraversal()
Assert.That(context.FocusedControl, Is.SameAs(right));
}
+ [Test]
+ public void ArrowKeysMoveFocusThroughNearbyButtonsWithoutExplicitNeighbors()
+ {
+ var context = new UIContext();
+ var root = new VBoxContainer { Size = new Vector2(200, 240) };
+ var first = new Button { Text = "One" };
+ var second = new Button { Text = "Two" };
+ var third = new Button { Text = "Three" };
+ root.AddChild(first); root.AddChild(second); root.AddChild(third);
+ context.Add(root);
+ context.Layout();
+ first.GrabFocus();
+
+ // A key only dispatches on its rising edge, so release between presses.
+ context.Update(Time, Mouse(0, 0), new KeyboardState(Keys.Down));
+ Assert.That(context.FocusedControl, Is.SameAs(second));
+ context.Update(Time, Mouse(0, 0), new KeyboardState());
+ context.Update(Time, Mouse(0, 0), new KeyboardState(Keys.Down));
+ Assert.That(context.FocusedControl, Is.SameAs(third));
+ context.Update(Time, Mouse(0, 0), new KeyboardState());
+ context.Update(Time, Mouse(0, 0), new KeyboardState(Keys.Up));
+ Assert.That(context.FocusedControl, Is.SameAs(second));
+
+ // Left/Right have no candidate in a vertical stack, so focus stays put.
+ context.Update(Time, Mouse(0, 0), new KeyboardState());
+ context.Update(Time, Mouse(0, 0), new KeyboardState(Keys.Left));
+ Assert.That(context.FocusedControl, Is.SameAs(second));
+ }
+
[Test]
public void MouseFilterPass_PropagatesEventsAndIgnoreDoesNotHideChildren()
{
diff --git a/tests/Forma.Tests/VirtualizingPanelsTest.cs b/tests/Forma.Tests/VirtualizingPanelsTest.cs
index 51150a0c..d4ba4c80 100644
--- a/tests/Forma.Tests/VirtualizingPanelsTest.cs
+++ b/tests/Forma.Tests/VirtualizingPanelsTest.cs
@@ -439,6 +439,31 @@ public void ListBox_RecyclingClearsContainerSelectionAndSelectabilityState()
list.Dispose();
}
+ [Test]
+ public void ListBox_ScrollsWithThePointerWheelOverItsRows()
+ {
+ using var context = new UIContext { ViewportSize = new Vector2(100, 60) };
+ var list = new ListBox
+ {
+ ItemsPanel = new ItemsPanelTemplate(_ => new VirtualizingStackPanel { EstimatedItemExtent = 20 }),
+ ItemTemplate = CreateRecyclingTemplate(new List()),
+ ItemsSource = Enumerable.Range(0, 100).ToArray(),
+ Size = new Vector2(100, 60),
+ };
+ context.Add(list);
+ context.Layout();
+ context.Layout();
+
+ Assert.That(context.InjectPointerWheel(new Point(50, 30), -120), Is.True, "a wheel notch over the rows scrolls the list");
+ var down = list.ScrollOffset.Y;
+ Assert.That(down, Is.GreaterThan(0));
+ context.InjectPointerWheel(new Point(50, 30), 120);
+ Assert.That(list.ScrollOffset.Y, Is.LessThan(down), "the opposite notch scrolls back");
+ for (var notch = 0; notch < 40; notch++) context.InjectPointerWheel(new Point(50, 30), 120);
+ Assert.That(list.ScrollOffset.Y, Is.Zero, "scrolling back past the top stops at the top");
+ list.Dispose();
+ }
+
[Test]
public void ListBox_VirtualizationPreservesDuplicateSlotSelectionAcrossMutations()
{