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 src/Forma/Label.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ public enum LabelTextFit
/// <summary>The result of <see cref="Label.TextFit"/>: the fit and the measured and available text sizes.</summary>
public readonly struct LabelTextFitInfo
{
/// <summary>Creates a fit result.</summary>
public LabelTextFitInfo(LabelTextFit fit, Vector2 measured, Vector2 available)
{
Fit = fit;
Measured = measured;
Available = available;
}

/// <summary>How the text relates to its box.</summary>
public LabelTextFit Fit { get; }
/// <summary>The size the text needs (wrapped to the available width when the label autowraps).</summary>
public Vector2 Measured { get; }
Expand Down
173 changes: 173 additions & 0 deletions src/Forma/LayoutProblems.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>What kind of layout problem <see cref="UIContext.FindLayoutProblems(Control, LayoutProblemOptions)"/> found.</summary>
[Flags]
public enum LayoutProblemKind
{
/// <summary>No kind selected.</summary>
None = 0,
/// <summary>A label or button's text needs more room than its box has and is cut off or drawn past it.</summary>
TextClipped = 1,
/// <summary>An autowrapping label's wrapped lines are taller than its box.</summary>
TextWrappedPastBox = 2,
/// <summary>A control extends past the bounds of its parent.</summary>
ChildOutsideParent = 4,
/// <summary>A control extends past the viewport.</summary>
OutsideViewport = 8,
/// <summary>A control extends past an ancestor that clips its contents.</summary>
ClippedByAncestor = 16,
/// <summary>Every kind.</summary>
All = TextClipped | TextWrappedPastBox | ChildOutsideParent | OutsideViewport | ClippedByAncestor,
}

/// <summary>Selects what <see cref="UIContext.FindLayoutProblems(Control, LayoutProblemOptions)"/> reports.</summary>
public sealed class LayoutProblemOptions
{
/// <summary>Creates options that report every kind with a one pixel tolerance.</summary>
public LayoutProblemOptions() { }

/// <summary>The kinds to report. Default: all.</summary>
public LayoutProblemKind Kinds { get; set; } = LayoutProblemKind.All;
/// <summary>How many pixels a control may extend past its box before it counts. Default 1.</summary>
public float Tolerance { get; set; } = 1f;
/// <summary>Whether to ignore controls that scroll or clip their content by design (scroll containers, lists, data grids, tabs, text fields). Default true.</summary>
public bool IgnoreScrollingContainers { get; set; } = true;
}

/// <summary>One clipped or overflowing control.</summary>
public sealed class LayoutProblem
{
/// <summary>Creates a finding.</summary>
public LayoutProblem(LayoutProblemKind kind, Control control, Vector2 overflow, string message)
{
Kind = kind;
Control = control ?? throw new ArgumentNullException(nameof(control));
Overflow = overflow;
Message = message;
}

/// <summary>What kind of problem this is.</summary>
public LayoutProblemKind Kind { get; }
/// <summary>The control that has the problem.</summary>
public Control Control { get; }
/// <summary>The control's name (<c>x:Name</c> or <c>id</c>), or an empty string.</summary>
public string Name => Control.Name ?? string.Empty;
/// <summary>The control's style classes, space separated.</summary>
public string Classes => string.Join(" ", Control.Classes);
/// <summary>Where the control was authored (<c>file:line</c>) when the view carries it in the <c>origin</c> data attribute; otherwise empty.</summary>
public string Origin => Control.GetData("origin") ?? string.Empty;
/// <summary>How far the control extends past its box, per axis, in layout pixels.</summary>
public Vector2 Overflow { get; }
/// <summary>A sentence describing the problem, naming the control.</summary>
public string Message { get; }
/// <summary>The message.</summary>
public override string ToString() => Message;
}

public partial class UIContext
{
/// <summary>Finds text that does not fit its box and controls that extend past their parent, an ancestor's clip or the viewport.</summary>
/// <param name="root">The control to inspect, with its descendants.</param>
/// <param name="options">What to report; null reports everything with a one pixel tolerance.</param>
public IReadOnlyList<LayoutProblem> FindLayoutProblems(Control root, LayoutProblemOptions options = null)
{
if (root == null) throw new ArgumentNullException(nameof(root));
options ??= new LayoutProblemOptions();
Layout();
var problems = new List<LayoutProblem>();
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<LayoutProblem> 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<LayoutProblem> 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<Control, bool> 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) + "...";
}
}
2 changes: 1 addition & 1 deletion src/Forma/UIContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <see cref="UIComponent"/> for the usual game component integration.
/// </summary>
public sealed class UIContext : IDisposable
public sealed partial class UIContext : IDisposable
{
internal int OwnerThreadId { get; } = Environment.CurrentManagedThreadId;
internal IDisposable ModalSessionOwner { get; set; }
Expand Down
3 changes: 3 additions & 0 deletions src/Forma/Xaml/PseudoLocalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ namespace Forma.Xaml
/// <summary>Options for <see cref="PseudoLocalizer"/>.</summary>
public sealed class PseudoLocaleOptions
{
/// <summary>Creates the default options: 40% longer, accented, in markers, left to right.</summary>
public PseudoLocaleOptions() { }

/// <summary>How much longer each string becomes, as a fraction of its length (0.4 is 40% longer). Default 0.4.</summary>
public double ExpansionFactor { get; set; } = 0.4;
/// <summary>Replaces plain letters with accented ones, so text that assumes ASCII widths or a font without accents shows up.</summary>
Expand Down
134 changes: 134 additions & 0 deletions tests/Forma.Tests/LayoutProblemsTest.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.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<Label> configure = null)
{
var label = new Label { Text = text, UIFont = new DynamicUIFont(face, 16), Padding = new Thickness(0), Position = position, Size = size };
configure?.Invoke(label);
return label;
}

[Test]
public void AViewThatFits_HasNoProblems()
{
using var face = Face();
var (context, root) = Host(new Vector2(400, 300), TextLabel(face, "Save", new Vector2(10, 10), new Vector2(200, 40)));
using var _ = context;

Assert.That(context.FindLayoutProblems(root), Is.Empty);
}

[Test]
public void ClippedText_IsReported_WithItsNameClassesAndOverflow()
{
using var face = Face();
var label = TextLabel(face, "Auto-place Spawn Points and everything else", new Vector2(10, 10), new Vector2(80, 40));
label.Name = "Menu";
label.Classes.Add("trace-text");
var (context, root) = Host(new Vector2(400, 300), label);
using var _ = context;

var problem = context.FindLayoutProblems(root).Single(p => p.Kind == LayoutProblemKind.TextClipped);

Assert.Multiple(() =>
{
Assert.That(problem.Control, Is.SameAs(label));
Assert.That(problem.Name, Is.EqualTo("Menu"));
Assert.That(problem.Classes, Is.EqualTo("trace-text"));
Assert.That(problem.Overflow.X, Is.GreaterThan(0));
Assert.That(problem.Message, Does.Contain("Menu").And.Contain("Auto-place"));
});
}

[Test]
public void WrappedLinesTallerThanTheBox_AreTextWrappedPastBox()
{
using var face = Face();
var label = TextLabel(face, "Select an object in the map or the list to edit it.", new Vector2(10, 10), new Vector2(120, 20), l => l.Autowrap = true);
var (context, root) = Host(new Vector2(400, 300), label);
using var _ = context;

var problems = context.FindLayoutProblems(root);

Assert.That(problems.Select(p => p.Kind), Does.Contain(LayoutProblemKind.TextWrappedPastBox));
}

[Test]
public void AControlPastItsParent_IsChildOutsideParent_AndPastAClippingAncestor_IsClippedByAncestor()
{
var inner = new ColorRect { Position = new Vector2(80, 80), Size = new Vector2(50, 50) };
var plain = new Control { Position = new Vector2(10, 10), Size = new Vector2(100, 100) };
plain.AddChild(inner);
var clipped = new ColorRect { Position = new Vector2(80, 0), Size = new Vector2(50, 20) };
var clipper = new Control { Position = new Vector2(200, 10), Size = new Vector2(100, 100), ClipContents = true };
clipper.AddChild(clipped);
var (context, root) = Host(new Vector2(400, 300), plain, clipper);
using var _ = context;

var problems = context.FindLayoutProblems(root);

Assert.Multiple(() =>
{
Assert.That(problems.Any(p => p.Kind == LayoutProblemKind.ChildOutsideParent && ReferenceEquals(p.Control, inner)), Is.True);
Assert.That(problems.Any(p => p.Kind == LayoutProblemKind.ClippedByAncestor && ReferenceEquals(p.Control, clipped)), Is.True);
});
}

[Test]
public void AControlPastTheViewport_IsOutsideViewport()
{
var wide = new ColorRect { Position = new Vector2(300, 10), Size = new Vector2(200, 20) };
var (context, root) = Host(new Vector2(400, 300), wide);
using var _ = context;

var problem = context.FindLayoutProblems(root).Single(p => p.Kind == LayoutProblemKind.OutsideViewport);

Assert.That(problem.Overflow.X, Is.EqualTo(100).Within(1));
}

[Test]
public void ContentOfAScrollContainer_IsNotReported_ByDefault()
{
var scroll = new ScrollContainer { Position = new Vector2(10, 10), Size = new Vector2(100, 100) };
scroll.AddChild(new ColorRect { Size = new Vector2(400, 400) });
var (context, root) = Host(new Vector2(400, 300), scroll);
using var _ = context;

Assert.That(context.FindLayoutProblems(root), Is.Empty);
}

[Test]
public void TheKindsOption_FiltersWhatIsReported_AndResultsAreStable()
{
var wide = new ColorRect { Position = new Vector2(300, 10), Size = new Vector2(200, 20) };
var (context, root) = Host(new Vector2(400, 300), wide);
using var _ = context;
var only = new LayoutProblemOptions { Kinds = LayoutProblemKind.TextClipped };

Assert.Multiple(() =>
{
Assert.That(context.FindLayoutProblems(root, only), Is.Empty);
Assert.That(context.FindLayoutProblems(root).Select(p => p.ToString()), Is.EqualTo(context.FindLayoutProblems(root).Select(p => p.ToString())));

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / build

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / build

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / parity

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / parity

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / MonoGame / windows-latest

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / FNA / windows-latest

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / MonoGame / ubuntu-latest

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / FNA / ubuntu-latest

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / MonoGame / macos-latest

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)

Check warning on line 130 in tests/Forma.Tests/LayoutProblemsTest.cs

View workflow job for this annotation

GitHub Actions / FNA / macos-latest

The actual and the expected argument is the same 'context.FindLayoutProblems(root).Select(p => p.ToString())' (https://github.com/nunit/nunit.analyzers/tree/master/documentation/NUnit2009.md)
});
}
}
}
Loading