diff --git a/config/cspell-project-words.txt b/config/cspell-project-words.txt
index e2aa19b4..c077e71c 100644
--- a/config/cspell-project-words.txt
+++ b/config/cspell-project-words.txt
@@ -106,3 +106,5 @@ AARRGGBB
llms
unitless
Lnnnn
+VIDEODRIVER
+AUDIODRIVER
diff --git a/docs/headless-testing.md b/docs/headless-testing.md
new file mode 100644
index 00000000..0d121611
--- /dev/null
+++ b/docs/headless-testing.md
@@ -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.
diff --git a/src/Forma.Testing/HeadlessInput.cs b/src/Forma.Testing/HeadlessInput.cs
new file mode 100644
index 00000000..274c944c
--- /dev/null
+++ b/src/Forma.Testing/HeadlessInput.cs
@@ -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
+{
+ /// Gamepad buttons can press. UI navigation is keyboard-driven, so each maps to a key.
+ public enum HeadlessGamepadButton { DPadUp, DPadDown, DPadLeft, DPadRight, Accept, Cancel }
+
+ ///
+ /// Builds and drives a 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).
+ ///
+ 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; }
+
+ /// Creates a context with the given viewport. Dispose it when done.
+ public static UIContext CreateContext(int width = 800, int height = 600) =>
+ new UIContext { ViewportSize = new Vector2(width, height) };
+
+ /// Creates a context and the driver for it.
+ public static HeadlessInput Create(int width = 800, int height = 600) => new HeadlessInput(CreateContext(width, height));
+
+ /// Centre of a control's laid-out bounds, for aiming input at it.
+ 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;
+ }
+
+ /// Move, press and release at a position or the centre of a control.
+ 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);
+
+ /// Two clicks in a row at the same spot with no time passing between them.
+ 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;
+ }
+
+ /// Presses and releases a gamepad button as the key Forma's UI navigation uses for it.
+ 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);
+ }
+
+ /// Advances the frame by the given time so animations, timers and double-click windows move.
+ public HeadlessInput Advance(TimeSpan elapsed)
+ {
+ var total = Context.CurrentTime + elapsed;
+ Context.Update(new GameTime(total, elapsed), default, default);
+ return this;
+ }
+ }
+}
diff --git a/src/Forma/UIContext.cs b/src/Forma/UIContext.cs
index 8a75941a..b2f5fdcf 100644
--- a/src/Forma/UIContext.cs
+++ b/src/Forma/UIContext.cs
@@ -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)
@@ -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;
+ }
}
}
diff --git a/tests/Forma.Tests/ListBoxRowPointerTest.cs b/tests/Forma.Tests/ListBoxRowPointerTest.cs
new file mode 100644
index 00000000..6e22f67a
--- /dev/null
+++ b/tests/Forma.Tests/ListBoxRowPointerTest.cs
@@ -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;
+
+///
+/// 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.
+///
+public sealed class ListBoxRowPointerTest
+{
+ private static (HeadlessInput Input, ListBox List) Build(Func row, bool singleClick = false)
+ {
+ var input = HeadlessInput.Create();
+ var list = new ListBox
+ {
+ Size = new Vector2(300, 300),
+ ActivateOnSingleClick = singleClick,
+ ItemTemplate = DataTemplate.Create((_, 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));
+ }
+}