diff --git a/src/Forma/Label.cs b/src/Forma/Label.cs
index ff4977e6..1f213cbb 100644
--- a/src/Forma/Label.cs
+++ b/src/Forma/Label.cs
@@ -28,6 +28,7 @@ public enum LabelTextFit
/// The result of : the fit and the measured and available text sizes.
public readonly struct LabelTextFitInfo
{
+ /// Creates a fit result.
public LabelTextFitInfo(LabelTextFit fit, Vector2 measured, Vector2 available)
{
Fit = fit;
@@ -35,6 +36,7 @@ public LabelTextFitInfo(LabelTextFit fit, Vector2 measured, Vector2 available)
Available = available;
}
+ /// How the text relates to its box.
public LabelTextFit Fit { get; }
/// The size the text needs (wrapped to the available width when the label autowraps).
public Vector2 Measured { get; }
diff --git a/src/Forma/LayoutProblems.cs b/src/Forma/LayoutProblems.cs
new file mode 100644
index 00000000..e6401c06
--- /dev/null
+++ b/src/Forma/LayoutProblems.cs
@@ -0,0 +1,173 @@
+// Copyright (c) 2026 Igor Hipólito Vieira
+// SPDX-License-Identifier: MIT
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Xna.Framework;
+
+namespace Forma
+{
+ /// What kind of layout problem found.
+ [Flags]
+ public enum LayoutProblemKind
+ {
+ /// No kind selected.
+ None = 0,
+ /// A label or button's text needs more room than its box has and is cut off or drawn past it.
+ TextClipped = 1,
+ /// An autowrapping label's wrapped lines are taller than its box.
+ TextWrappedPastBox = 2,
+ /// A control extends past the bounds of its parent.
+ ChildOutsideParent = 4,
+ /// A control extends past the viewport.
+ OutsideViewport = 8,
+ /// A control extends past an ancestor that clips its contents.
+ ClippedByAncestor = 16,
+ /// Every kind.
+ All = TextClipped | TextWrappedPastBox | ChildOutsideParent | OutsideViewport | ClippedByAncestor,
+ }
+
+ /// Selects what reports.
+ public sealed class LayoutProblemOptions
+ {
+ /// Creates options that report every kind with a one pixel tolerance.
+ public LayoutProblemOptions() { }
+
+ /// The kinds to report. Default: all.
+ public LayoutProblemKind Kinds { get; set; } = LayoutProblemKind.All;
+ /// How many pixels a control may extend past its box before it counts. Default 1.
+ public float Tolerance { get; set; } = 1f;
+ /// Whether to ignore controls that scroll or clip their content by design (scroll containers, lists, data grids, tabs, text fields). Default true.
+ public bool IgnoreScrollingContainers { get; set; } = true;
+ }
+
+ /// One clipped or overflowing control.
+ public sealed class LayoutProblem
+ {
+ /// Creates a finding.
+ public LayoutProblem(LayoutProblemKind kind, Control control, Vector2 overflow, string message)
+ {
+ Kind = kind;
+ Control = control ?? throw new ArgumentNullException(nameof(control));
+ Overflow = overflow;
+ Message = message;
+ }
+
+ /// What kind of problem this is.
+ public LayoutProblemKind Kind { get; }
+ /// The control that has the problem.
+ public Control Control { get; }
+ /// The control's name (x:Name or id), or an empty string.
+ public string Name => Control.Name ?? string.Empty;
+ /// The control's style classes, space separated.
+ public string Classes => string.Join(" ", Control.Classes);
+ /// Where the control was authored (file:line) when the view carries it in the origin data attribute; otherwise empty.
+ public string Origin => Control.GetData("origin") ?? string.Empty;
+ /// How far the control extends past its box, per axis, in layout pixels.
+ public Vector2 Overflow { get; }
+ /// A sentence describing the problem, naming the control.
+ public string Message { get; }
+ /// The message.
+ public override string ToString() => Message;
+ }
+
+ public partial class UIContext
+ {
+ /// Finds text that does not fit its box and controls that extend past their parent, an ancestor's clip or the viewport.
+ /// The control to inspect, with its descendants.
+ /// What to report; null reports everything with a one pixel tolerance.
+ public IReadOnlyList FindLayoutProblems(Control root, LayoutProblemOptions options = null)
+ {
+ if (root == null) throw new ArgumentNullException(nameof(root));
+ options ??= new LayoutProblemOptions();
+ Layout();
+ var problems = new List();
+ var viewport = new Rectangle(0, 0, (int)MathF.Round(ViewportSize.X), (int)MathF.Round(ViewportSize.Y));
+ Walk(root, root, viewport, options, problems);
+ return problems;
+ }
+
+ private static void Walk(Control control, Control root, Rectangle viewport, LayoutProblemOptions options, List problems)
+ {
+ if (!control.Visible) return;
+ var box = control.Bounds;
+ if (box.Width > 0 && box.Height > 0)
+ {
+ CheckText(control, options, problems);
+ var visual = control.VisualBounds;
+ if (!ReferenceEquals(control, root))
+ {
+ var scrolled = options.IgnoreScrollingContainers && AnyAncestor(control, IsScrolling);
+ if (!scrolled)
+ {
+ if ((options.Kinds & LayoutProblemKind.OutsideViewport) != 0 && Past(visual, viewport, options.Tolerance) is { } outside)
+ problems.Add(new LayoutProblem(LayoutProblemKind.OutsideViewport, control, outside, Describe(control, $"extends {Format(outside)} past the {viewport.Width}x{viewport.Height} viewport")));
+ if ((options.Kinds & LayoutProblemKind.ClippedByAncestor) != 0 && FirstClippingAncestor(control, root) is { } clip && Past(visual, clip.VisualBounds, options.Tolerance) is { } clipped)
+ problems.Add(new LayoutProblem(LayoutProblemKind.ClippedByAncestor, control, clipped, Describe(control, $"extends {Format(clipped)} past {clip.GetType().Name} '{clip.Name}' that clips it")));
+ else if ((options.Kinds & LayoutProblemKind.ChildOutsideParent) != 0 && control.VisualParent is { } parent && !parent.ClipContents && Past(visual, parent.VisualBounds, options.Tolerance) is { } beyond)
+ problems.Add(new LayoutProblem(LayoutProblemKind.ChildOutsideParent, control, beyond, Describe(control, $"extends {Format(beyond)} past its parent {parent.GetType().Name} '{parent.Name}'")));
+ }
+ }
+ }
+
+ foreach (var child in control.Children) Walk(child, root, viewport, options, problems);
+ }
+
+ private static void CheckText(Control control, LayoutProblemOptions options, List problems)
+ {
+ if (control is Label label)
+ {
+ var info = label.TextFit;
+ if (info.Fit != LabelTextFit.Clipped) return;
+ var wrapped = label.AutowrapMode != LabelAutowrapMode.Off;
+ var kind = wrapped ? LayoutProblemKind.TextWrappedPastBox : LayoutProblemKind.TextClipped;
+ if ((options.Kinds & kind) == 0 || (info.Overflow.X <= options.Tolerance && info.Overflow.Y <= options.Tolerance)) return;
+ problems.Add(new LayoutProblem(kind, control, info.Overflow,
+ Describe(control, $"text \"{Shorten(label.Text)}\" needs {info.Measured.X:0}x{info.Measured.Y:0} but has {info.Available.X:0}x{info.Available.Y:0}")));
+ }
+ else if (control is BaseButton button && (options.Kinds & LayoutProblemKind.TextClipped) != 0)
+ {
+ var minimum = button.GetMinimumSize();
+ var overflow = new Vector2(Math.Max(0, minimum.X - control.Size.X), Math.Max(0, minimum.Y - control.Size.Y));
+ if (overflow.X > options.Tolerance || overflow.Y > options.Tolerance)
+ problems.Add(new LayoutProblem(LayoutProblemKind.TextClipped, control, overflow,
+ Describe(control, $"button \"{Shorten(button.Text)}\" needs {minimum.X:0}x{minimum.Y:0} but has {control.Size.X:0}x{control.Size.Y:0}")));
+ }
+ }
+
+ private static Vector2? Past(Rectangle box, Rectangle limit, float tolerance)
+ {
+ var x = Math.Max(Math.Max(0, limit.Left - box.Left), Math.Max(0, box.Right - limit.Right));
+ var y = Math.Max(Math.Max(0, limit.Top - box.Top), Math.Max(0, box.Bottom - limit.Bottom));
+ return x > tolerance || y > tolerance ? new Vector2(x, y) : (Vector2?)null;
+ }
+
+ private static Control FirstClippingAncestor(Control control, Control root)
+ {
+ for (var ancestor = control.VisualParent; ancestor != null; ancestor = ancestor.VisualParent)
+ {
+ if (ancestor.ClipContents && !IsScrolling(ancestor)) return ancestor;
+ if (ReferenceEquals(ancestor, root)) break;
+ }
+
+ return null;
+ }
+
+ private static bool AnyAncestor(Control control, Func predicate)
+ {
+ for (var parent = control.VisualParent; parent != null; parent = parent.VisualParent)
+ if (predicate(parent)) return true;
+ return false;
+ }
+
+ private static bool IsScrolling(Control control) =>
+ control is ScrollContainer or ListBox or DataGrid or ItemsControl or TabContainer or LineEdit or TextEdit;
+
+ private static string Describe(Control control, string what) =>
+ $"{control.GetType().Name}{(string.IsNullOrEmpty(control.Name) ? string.Empty : " '" + control.Name + "'")}{(control.Classes.Count > 0 ? " (" + string.Join(" ", control.Classes) + ")" : string.Empty)} {what}.";
+
+ private static string Format(Vector2 overflow) => $"{overflow.X:0}x{overflow.Y:0}px";
+
+ private static string Shorten(string text) => text == null ? string.Empty : text.Length <= 40 ? text : text.Substring(0, 37) + "...";
+ }
+}
diff --git a/src/Forma/UIContext.cs b/src/Forma/UIContext.cs
index 7a7b42ef..8a75941a 100644
--- a/src/Forma/UIContext.cs
+++ b/src/Forma/UIContext.cs
@@ -20,7 +20,7 @@ public enum InputModality { Pointer, Touch, Keyboard, Gamepad }
/// Owns a UI tree's input state, focus and rendering. Add it to a Game's update/draw loop, or use
/// for the usual game component integration.
///
- public sealed class UIContext : IDisposable
+ public sealed partial class UIContext : IDisposable
{
internal int OwnerThreadId { get; } = Environment.CurrentManagedThreadId;
internal IDisposable ModalSessionOwner { get; set; }
diff --git a/src/Forma/Xaml/PseudoLocalizer.cs b/src/Forma/Xaml/PseudoLocalizer.cs
index 8c4d2d0d..ed8ac124 100644
--- a/src/Forma/Xaml/PseudoLocalizer.cs
+++ b/src/Forma/Xaml/PseudoLocalizer.cs
@@ -11,6 +11,9 @@ namespace Forma.Xaml
/// Options for .
public sealed class PseudoLocaleOptions
{
+ /// Creates the default options: 40% longer, accented, in markers, left to right.
+ public PseudoLocaleOptions() { }
+
/// How much longer each string becomes, as a fraction of its length (0.4 is 40% longer). Default 0.4.
public double ExpansionFactor { get; set; } = 0.4;
/// Replaces plain letters with accented ones, so text that assumes ASCII widths or a font without accents shows up.
diff --git a/tests/Forma.Tests/LayoutProblemsTest.cs b/tests/Forma.Tests/LayoutProblemsTest.cs
new file mode 100644
index 00000000..56e1aa04
--- /dev/null
+++ b/tests/Forma.Tests/LayoutProblemsTest.cs
@@ -0,0 +1,134 @@
+// Copyright (c) 2026 Igor Hipólito Vieira
+// SPDX-License-Identifier: MIT
+
+using System.Linq;
+using Microsoft.Xna.Framework;
+
+namespace Forma.Tests
+{
+ [TestFixture]
+ public sealed class LayoutProblemsTest
+ {
+ private static UIFontFace Face() => UIFontFace.FromProjectFile(TestContext.CurrentContext.TestDirectory, "Fonts/Inter_Regular.ttf");
+
+ private static (UIContext Context, Control Root) Host(Vector2 viewport, params Control[] children)
+ {
+ var context = new UIContext { ViewportSize = viewport };
+ var root = new Control { Size = viewport };
+ foreach (var child in children) root.AddChild(child);
+ context.Add(root);
+ return (context, root);
+ }
+
+ private static Label TextLabel(UIFontFace face, string text, Vector2 position, Vector2 size, Action