Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Forma/Buttons.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/Forma/RuntimeAdapters/RuntimeTextCompositionSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions src/Forma/UIContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,61 @@ private void DispatchKey(Keys key, KeyboardState keyboard)
FocusedControl?.KeyPressed(key);
}

/// <summary>
/// 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 <see cref="Control.KeyPressed"/>
/// when it has no nearer behaviour, so plain action menus are keyboard-navigable without every
/// author wiring <see cref="Control.FocusNeighborLeft"/> and friends. Explicit neighbors are
/// resolved first in <see cref="DispatchKey(Keys, KeyboardState)"/> and still win.
/// </summary>
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<Control>();
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);
Expand Down
26 changes: 26 additions & 0 deletions tests/Forma.Tests/NativeTextCompositionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
29 changes: 29 additions & 0 deletions tests/Forma.Tests/UITest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
25 changes: 25 additions & 0 deletions tests/Forma.Tests/VirtualizingPanelsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecyclingRow>()),
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()
{
Expand Down
Loading