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() {