From 82eda43c0eac2f6c47d447ef8cc2e9dcc51cf459 Mon Sep 17 00:00:00 2001 From: zignd Date: Sat, 10 Oct 2026 19:32:01 -0300 Subject: [PATCH 1/2] feat(layout): UIContext.FindLayoutProblems reports clipped and overflowing controls Text clipped or wrapped past its box (Label.TextFit and buttons), controls past their parent, a clipping ancestor or the viewport, with name, classes, origin and the overflow in pixels. Refs #9. --- src/Forma/LayoutProblems.cs | 163 ++++++++++++++++++++++++ src/Forma/UIContext.cs | 2 +- tests/Forma.Tests/LayoutProblemsTest.cs | 134 +++++++++++++++++++ 3 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/Forma/LayoutProblems.cs create mode 100644 tests/Forma.Tests/LayoutProblemsTest.cs diff --git a/src/Forma/LayoutProblems.cs b/src/Forma/LayoutProblems.cs new file mode 100644 index 00000000..4a328807 --- /dev/null +++ b/src/Forma/LayoutProblems.cs @@ -0,0 +1,163 @@ +// 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 + { + 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, + All = TextClipped | TextWrappedPastBox | ChildOutsideParent | OutsideViewport | ClippedByAncestor, + } + + /// Selects what reports. + public sealed class 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 + { + public LayoutProblem(LayoutProblemKind kind, Control control, Vector2 overflow, string message) + { + Kind = kind; + Control = control ?? throw new ArgumentNullException(nameof(control)); + Overflow = overflow; + Message = message; + } + + public LayoutProblemKind Kind { get; } + 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; } + public string Message { get; } + 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/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