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
2 changes: 2 additions & 0 deletions config/cspell-project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,5 @@ AARRGGBB
llms
unitless
Lnnnn
VIDEODRIVER
AUDIODRIVER
37 changes: 37 additions & 0 deletions docs/headless-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Headless UI testing

`UIContext` needs no window or `GraphicsDevice` for layout, hit testing and input, so control behavior can be tested in
plain NUnit/xUnit. `Forma.Testing` adds `HeadlessInput`, a small driver over the context's `Inject*` side channel.

```csharp
var input = HeadlessInput.Create(800, 600); // UIContext with SuppressPolledInput on
input.Context.Add(myList);
input.Context.Layout();

input.Click(myList.GetRealizedContainer(1)); // move + press + release at the control's centre
input.DoubleClick(row);
input.Wheel(-120, position);
input.Key(Keys.A, Keys.LeftControl); // chord
input.Type("hello"); // committed text
input.Gamepad(HeadlessGamepadButton.DPadDown); // maps to the navigation key
input.Advance(TimeSpan.FromMilliseconds(100)); // run a frame (animations, timers)
```

Notes:

- Positions are physical pixels, equal to logical pixels at the default `DisplayScale` of 1.
- Gamepad input is expressed as the keys UI navigation uses (D-pad = arrows, Accept = Enter, Cancel = Escape);
`UIContext` has no gamepad-specific entry point.
- Inject calls run synchronously on the calling thread; call them between frames.
- For game-level tests, drive the same context the game's screens read, and make the game's own input (for example a
mouse snapshot) come from one source, or a test can pass while real input fails.
- Time-based behavior (double-click window) follows `UIContext.CurrentTime`; use `Advance` rather than sleeping.
- Rendering (`Draw`, render targets) still needs a graphics device. A game built on a MonoGame fork with a headless
backend can run `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` set in the *process* environment (setting them
inside the test is too late on Unix).

## Render tests on macOS

`Forma.RenderTests` excludes macOS because it uses the stock DesktopGL package, which needs a real GL window. The
headless backend lives in a MonoGame fork; Forma can use it only through `MonoGameProjectPath`, and then only for
tests that do not read back pixels. These tests were not re-enabled.
134 changes: 134 additions & 0 deletions src/Forma.Testing/HeadlessInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Igor Hipólito Vieira
// SPDX-License-Identifier: MIT

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;

namespace Forma.Testing
{
/// <summary>Gamepad buttons <see cref="HeadlessInput"/> can press. UI navigation is keyboard-driven, so each maps to a key.</summary>
public enum HeadlessGamepadButton { DPadUp, DPadDown, DPadLeft, DPadRight, Accept, Cancel }

/// <summary>
/// Builds and drives a <see cref="UIContext"/> with no window or graphics device, through the context's
/// Inject* side channel. Positions are logical pixels when the context's DisplayScale is 1 (the default).
/// </summary>
public sealed class HeadlessInput
{
public HeadlessInput(UIContext context)
{
Context = context ?? throw new ArgumentNullException(nameof(context));
context.SuppressPolledInput = true;
}

public UIContext Context { get; }
public Point Position { get; private set; }

/// <summary>Creates a context with the given viewport. Dispose it when done.</summary>
public static UIContext CreateContext(int width = 800, int height = 600) =>
new UIContext { ViewportSize = new Vector2(width, height) };

/// <summary>Creates a context and the driver for it.</summary>
public static HeadlessInput Create(int width = 800, int height = 600) => new HeadlessInput(CreateContext(width, height));

/// <summary>Centre of a control's laid-out bounds, for aiming input at it.</summary>
public static Point CenterOf(Control control)
{
if (control == null) throw new ArgumentNullException(nameof(control));
control.Context?.Layout();
var b = control.Bounds;
return new Point((int)(b.X + b.Width / 2), (int)(b.Y + b.Height / 2));
}

public HeadlessInput Move(Point position)
{
Position = position;
Context.InjectPointerMove(position);
return this;
}

public HeadlessInput Move(Control control) => Move(CenterOf(control));

public HeadlessInput Press(PointerButton button = PointerButton.Left)
{
Context.InjectPointerPress(Position, button);
return this;
}

public HeadlessInput Release(PointerButton button = PointerButton.Left)
{
Context.InjectPointerRelease(Position, button);
return this;
}

/// <summary>Move, press and release at a position or the centre of a control.</summary>
public HeadlessInput Click(Point position, PointerButton button = PointerButton.Left)
{
Move(position);
Press(button);
return Release(button);
}

public HeadlessInput Click(Control control, PointerButton button = PointerButton.Left) => Click(CenterOf(control), button);

/// <summary>Two clicks in a row at the same spot with no time passing between them.</summary>
public HeadlessInput DoubleClick(Control control) => Click(control).Click(control);

public HeadlessInput Wheel(int delta, Point? position = null)
{
if (position.HasValue) Move(position.Value);
Context.InjectPointerWheel(Position, delta);
return this;
}

public HeadlessInput Key(Keys key, params Keys[] modifiers)
{
Context.InjectKeyPress(key, modifiers);
Context.InjectKeyRelease(key);
return this;
}

public HeadlessInput KeyDown(Keys key, params Keys[] modifiers)
{
Context.InjectKeyPress(key, modifiers);
return this;
}

public HeadlessInput KeyUp(Keys key)
{
Context.InjectKeyRelease(key);
return this;
}

public HeadlessInput Type(string text)
{
Context.InjectText(text);
return this;
}

/// <summary>Presses and releases a gamepad button as the key Forma's UI navigation uses for it.</summary>
public HeadlessInput Gamepad(HeadlessGamepadButton button)
{
var key = button switch
{
HeadlessGamepadButton.DPadUp => Keys.Up,
HeadlessGamepadButton.DPadDown => Keys.Down,
HeadlessGamepadButton.DPadLeft => Keys.Left,
HeadlessGamepadButton.DPadRight => Keys.Right,
HeadlessGamepadButton.Accept => Keys.Enter,
HeadlessGamepadButton.Cancel => Keys.Escape,
_ => throw new ArgumentOutOfRangeException(nameof(button)),
};
return Key(key);
}

/// <summary>Advances the frame by the given time so animations, timers and double-click windows move.</summary>
public HeadlessInput Advance(TimeSpan elapsed)
{
var total = Context.CurrentTime + elapsed;
Context.Update(new GameTime(total, elapsed), default, default);
return this;
}
}
}
32 changes: 30 additions & 2 deletions src/Forma/UIContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -866,8 +866,30 @@ private static void DispatchPointerPressed(Control target, Point point)
for (var control = target; control != null; control = control.VisualParent)
{
control.PointerPressed(point);
if (control.ConsumeEventAccepted() || control.MouseFilter != MouseFilter.Pass) break;
if (control.ConsumeEventAccepted()) break;
if (control.MouseFilter != MouseFilter.Pass)
{
GetRowOwnerForStoppedPointer(control)?.PointerPressed(point);
break;
}
}
}

// Row content (a Label, Border, container) defaults to MouseFilter.Stop, so a press on it never bubbles to the
// ListBox that owns the row. When a non-interactive control stops the event inside a ListBoxItem, the list
// still receives it; a focusable control (a Button) keeps the click for itself.
private static ListBox GetRowOwnerForStoppedPointer(Control stoppedAt)
{
if (stoppedAt is ListBox || (stoppedAt.FocusMode != FocusMode.None && stoppedAt is not ListBoxItem)) return null;
for (var control = stoppedAt; control != null; control = control.VisualParent)
{
if (control is ListBox) return null;
if (control is not ListBoxItem) continue;
for (var parent = control.VisualParent; parent != null; parent = parent.VisualParent)
if (parent is ListBox list) return list;
return null;
}
return null;
}

private static void DispatchPointerMoved(Control target, Point point)
Expand Down Expand Up @@ -904,7 +926,13 @@ private static void DispatchPointerReleased(Control target, Point point)
for (var control = target; control != null; control = control.VisualParent)
{
control.PointerReleased(point, control.ContainsPoint(point));
if (control.ConsumeEventAccepted() || control.MouseFilter != MouseFilter.Pass) break;
if (control.ConsumeEventAccepted()) break;
if (control.MouseFilter != MouseFilter.Pass)
{
var owner = GetRowOwnerForStoppedPointer(control);
owner?.PointerReleased(point, owner.ContainsPoint(point));
break;
}
}
}

Expand Down
90 changes: 90 additions & 0 deletions tests/Forma.Tests/ListBoxRowPointerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Igor Hipólito Vieira
// SPDX-License-Identifier: MIT

using System;
using Forma.Testing;
using Microsoft.Xna.Framework;

namespace Forma.Tests;

/// <summary>
/// A press on content inside a row must reach the owning ListBox, whatever the content's MouseFilter,
/// so rows select and activate on click without per-template tweaks.
/// </summary>
public sealed class ListBoxRowPointerTest
{
private static (HeadlessInput Input, ListBox List) Build(Func<Control> row, bool singleClick = false)
{
var input = HeadlessInput.Create();
var list = new ListBox
{
Size = new Vector2(300, 300),
ActivateOnSingleClick = singleClick,
ItemTemplate = DataTemplate.Create<string>((_, item) => row()),
ItemsSource = new[] { "a", "b", "c" },
};
input.Context.Add(list);
input.Context.Layout();
return (input, list);
}

private static Control Row(Control child)
{
var box = new HBoxContainer { CustomMinimumSize = new Vector2(280, 30) };
box.AddChild(child);
return box;
}

private static Control Stopper() => new Border { CustomMinimumSize = new Vector2(280, 30), MouseFilter = MouseFilter.Stop };
private static Control Plain() => new Label { Text = "row", CustomMinimumSize = new Vector2(280, 30) };

[TestCase(false), TestCase(true)]
public void ClickOnRowContentSelectsTheRow(bool stop)
{
var (input, list) = Build(() => Row(stop ? Stopper() : Plain()));
using var context = input.Context;
var container = list.GetRealizedContainer(1);
input.Click(HeadlessInput.CenterOf(container));
Assert.That(list.SelectedIndex, Is.EqualTo(1));
}

[Test]
public void SingleClickActivatesWhenEnabled()
{
var (input, list) = Build(() => Row(Stopper()), singleClick: true);
using var context = input.Context;
var activated = -1;
list.ItemActivated += (_, e) => activated = e.Index;
input.Click(HeadlessInput.CenterOf(list.GetRealizedContainer(2)));
Assert.That(activated, Is.EqualTo(2));
}

[Test]
public void DoubleClickActivatesAndSingleClickDoesNot()
{
var (input, list) = Build(() => Row(Stopper()));
using var context = input.Context;
var activated = 0;
list.ItemActivated += (_, _) => activated++;
var at = HeadlessInput.CenterOf(list.GetRealizedContainer(0));
input.Click(at);
Assert.That(activated, Is.EqualTo(0));
input.Click(at);
Assert.That(activated, Is.EqualTo(1));
}

[Test]
public void ButtonInsideARowStillGetsItsOwnClick()
{
var clicked = 0;
var (input, list) = Build(() =>
{
var button = new Button { Text = "x", CustomMinimumSize = new Vector2(280, 30) };
button.Pressed += (_, _) => clicked++;
return Row(button);
});
using var context = input.Context;
input.Click(HeadlessInput.CenterOf(list.GetRealizedContainer(0)));
Assert.That(clicked, Is.EqualTo(1));
}
}
Loading