feat(ui): importer carries dat justification (0x14/0x15) onto text widgets; drop character footer-title height hack

LayoutImporter.ReadState now reads Properties[0x14] (HorizontalJustification,
EnumBaseProperty: 0=Left, 1=Center, 3/5=Right) and Properties[0x15]
(VerticalJustification: 2=Top, 4=Bottom; else Center) into two new ElementInfo
fields HJustify/VJustify. Merge propagates them with the same non-default-wins
rule used for FontDid.

DatWidgetFactory.BuildText applies the resolved justify at build time:
HJustify=Center sets Centered=true, HJustify=Right sets RightAligned=true,
VJustify=Top/Bottom sets VerticalJustify. Controllers that FindElement and set
those properties afterward continue to override - backward-compat preserved.

UiText gains VerticalJustify (Top/Center/Bottom, default Center). The Centered
and RightAligned single-line paths call UiText.VOffset() for the Y coordinate,
so VJustify.Top renders text at y=Padding rather than the fixed (H-lh)/2 center.

CharacterStatController: footer title (0x1000024E, H=55 dat box) previously
used Height=18 + Anchors=None to prevent center-vertical overlap with line-1/2.
Diagnostic confirmed dat says HJustify=Center, VJustify=Center. The hack is
replaced with one minimal explicit override: VerticalJustify=Top on the
already-Centered element. Text now renders at top of the 55px box natively.
Centered=false and RightAligned=false hand-sets removed where dat supplies them.

Dat justify values (studio diagnostic, 2026-06-26):
  0x1000024E footer title: HJustify=Center, VJustify=Center
  0x10000235/0x10000243/0x10000245 value fields: HJustify=Right
  header name/heritage/pk/level: HJustify=Center

+13 tests: ElementReader Merge propagation; DatWidgetFactory BuildText
justify application + controller-override backward-compat; UiText VOffset.
696 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 10:05:36 +02:00
parent 8f222f7506
commit 41430420b3
8 changed files with 328 additions and 18 deletions

View file

@ -747,22 +747,22 @@ public static class CharacterStatController
var titleEl = ByPos(0f, 0f, FooterTitleId); var titleEl = ByPos(0f, 0f, FooterTitleId);
if (titleEl is not null) if (titleEl is not null)
{ {
// The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so
// without an override the text would center vertically in the 55px box, overlapping
// line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the
// top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping.
// The dat says HJustify=Center (Centered=true from BuildText) — the title is centered.
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
titleEl.BackgroundSprite = 0; titleEl.BackgroundSprite = 0;
// The dat title element is H=55 (the full footer box). Centering the title text in it titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
// lands dead-center (~y572), overlapping line-1/line-2 (y565/y582 — confirmed by a
// position dump). Constrain it to a thin line at the footer top, and drop its anchors
// so the per-frame stretch pass doesn't re-grow it back to 55.
titleEl.Height = 18f;
titleEl.Anchors = AnchorEdges.None;
} }
// Title color: State A = body (parchment); State B = WHITE per retail (confirmed 2026-06-26 ref). // Title color: State A = body (parchment); State B = WHITE per retail (confirmed 2026-06-26 ref).
if (titleEl is not null) if (titleEl is not null)
{ {
titleEl.DatFont = datFont; titleEl.DatFont = datFont;
titleEl.Centered = false; // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
titleEl.RightAligned = false; // RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true; titleEl.ClickThrough = true;
titleEl.Padding = 0f;
titleEl.LinesProvider = () => titleEl.LinesProvider = () =>
{ {
if (sel[0] < 0) if (sel[0] < 0)

View file

@ -239,13 +239,43 @@ public static class DatWidgetFactory
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary> /// because <see cref="UiText.BackgroundColor"/> defaults to transparent.
///
/// <para>
/// Justification from the dat (<see cref="ElementInfo.HJustify"/> /
/// <see cref="ElementInfo.VJustify"/>) is applied here at build time so that controllers
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
/// defaults — the build-time value is just the starting point, not a lock.
/// </para>
/// </summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve) private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
{ {
uint bg = info.StateMedia.TryGetValue( uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u; ? m.File : 0u;
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
// Apply horizontal + vertical justification from the dat at build time.
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
// afterward will override these — this is only the dat-driven default.
bool centered = info.HJustify == HJustify.Center;
bool rightAligned = info.HJustify == HJustify.Right;
var vJustify = info.VJustify switch
{
VJustify.Top => VJustify.Top,
VJustify.Bottom => VJustify.Bottom,
_ => VJustify.Center,
};
return new UiText
{
BackgroundSprite = bg,
SpriteResolve = resolve,
Centered = centered,
RightAligned = rightAligned,
VerticalJustify = vJustify,
};
} }
} }

View file

@ -2,6 +2,18 @@ using System.Collections.Generic;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
/// <summary>
/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right.
/// </summary>
public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
/// Values: 2=Top, 4=Bottom; absent/other = Center.
/// </summary>
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
/// <summary> /// <summary>
/// GL-free, dat-free snapshot of a resolved layout element. /// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c> /// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
@ -51,6 +63,21 @@ public sealed class ElementInfo
/// </summary> /// </summary>
public uint FontDid; public uint FontDid;
/// <summary>
/// Horizontal text justification from dat <c>Properties[0x14]</c>
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
/// controllers set <c>Centered=true</c> and no property was read.
/// </summary>
public HJustify HJustify = HJustify.Center;
/// <summary>
/// Vertical text justification from dat <c>Properties[0x15]</c>
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
/// </summary>
public VJustify VJustify = VJustify.Center;
/// <summary> /// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int). /// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>). /// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
@ -160,6 +187,12 @@ public static class ElementReader
ReadOrder = derived.ReadOrder, ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value.
// Center is the default (= "not set by this element") so Center-derived never overrides
// a non-Center base — matching the FontDid "non-zero wins" convention.
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// DefaultStateName: derived wins if set; otherwise inherit the base's default. // DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's. // Children come from the derived element's own tree, not the base prototype's.

View file

@ -362,6 +362,40 @@ public static class LayoutImporter
{ {
info.FontDid = did.Value; info.FontDid = did.Value;
} }
if (sd.Properties is not null)
{
// HorizontalJustification (0x14): EnumBaseProperty.
// Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec).
// Only update if still at the default (Center); derived-wins handled in Merge.
if (info.HJustify == HJustify.Center
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
&& hRaw is EnumBaseProperty hEnum)
{
info.HJustify = hEnum.Value switch
{
0u => HJustify.Left,
1u => HJustify.Center,
3u => HJustify.Right,
5u => HJustify.Right,
_ => HJustify.Center, // unknown → center (safe default)
};
}
// VerticalJustification (0x15): EnumBaseProperty.
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
if (info.VJustify == VJustify.Center
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
&& vRaw is EnumBaseProperty vEnum)
{
info.VJustify = vEnum.Value switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
}
} }
// ── Prototype detection helpers ─────────────────────────────────────────── // ── Prototype detection helpers ───────────────────────────────────────────

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Text; using System.Text;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -78,6 +79,23 @@ public sealed class UiText : UiElement
/// precedence. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary> /// precedence. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool RightAligned { get; set; } public bool RightAligned { get; set; }
/// <summary>
/// Vertical position of the text within the element rect in single-line mode
/// (<see cref="Centered"/> or <see cref="RightAligned"/>).
/// <list type="bullet">
/// <item><description><b>Center</b> (default) — vertically centered, matching the original
/// behavior of the centered/right-aligned paths.</description></item>
/// <item><description><b>Top</b> — text is placed at <c>y = Padding</c> (top of the content
/// area), so the text sits at the top of the element rather than centering in it.
/// Used for footer title elements whose dat box is the full footer height (55 px) but
/// the text should render near the top.</description></item>
/// <item><description><b>Bottom</b> — text is placed at <c>y = Height - lineHeight - Padding</c>.</description></item>
/// </list>
/// Only meaningful when <see cref="Centered"/> or <see cref="RightAligned"/> is true.
/// Has no effect on the scrollable multi-line path.
/// </summary>
public VJustify VerticalJustify { get; set; } = VJustify.Center;
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary> /// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new(); public UiScrollable Scroll { get; } = new();
@ -138,8 +156,8 @@ public sealed class UiText : UiElement
ctx.DrawFill(0, 0, Width, Height, BackgroundColor); ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first // Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V with the SAME formula UIElement_Meter used for its label, then // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// skip the scroll/selection machinery entirely. // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
if (Centered) if (Centered)
{ {
var cLines = LinesProvider(); var cLines = LinesProvider();
@ -148,20 +166,20 @@ public sealed class UiText : UiElement
if (DatFont is { } cdf) if (DatFont is { } cdf)
{ {
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f; float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cdf.LineHeight) * 0.5f; float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color); ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
} }
else if ((Font ?? ctx.DefaultFont) is { } cbf) else if ((Font ?? ctx.DefaultFont) is { } cbf)
{ {
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f; float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cbf.LineHeight) * 0.5f; float cy = VOffset(Height, cbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf); ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
} }
return; return;
} }
// Static right-aligned single-line mode: draw the first line flush with the right // Static right-aligned single-line mode: draw the first line flush with the right
// edge, vertically centered, then skip the scroll/selection machinery. // edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
if (RightAligned) if (RightAligned)
{ {
var rLines = LinesProvider(); var rLines = LinesProvider();
@ -170,13 +188,13 @@ public sealed class UiText : UiElement
if (DatFont is { } rdf) if (DatFont is { } rdf)
{ {
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding; float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
float ry = (Height - rdf.LineHeight) * 0.5f; float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color); ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
} }
else if ((Font ?? ctx.DefaultFont) is { } rbf) else if ((Font ?? ctx.DefaultFont) is { } rbf)
{ {
float rx = Width - rbf.MeasureWidth(line0.Text) - Padding; float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
float ry = (Height - rbf.LineHeight) * 0.5f; float ry = VOffset(Height, rbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf); ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
} }
return; return;
@ -366,6 +384,23 @@ public sealed class UiText : UiElement
// ── Pure, testable logic (no GL / no font texture) ─────────────────── // ── Pure, testable logic (no GL / no font texture) ───────────────────
/// <summary>
/// Compute the Y offset (local space) for a single line in the Centered/RightAligned
/// single-line path, given the element height, font line-height, padding, and
/// vertical justification.
/// </summary>
/// <param name="height">Element height in pixels.</param>
/// <param name="lineHeight">Font line height in pixels.</param>
/// <param name="padding">Content padding.</param>
/// <param name="vj">Vertical justification.</param>
public static float VOffset(float height, float lineHeight, float padding, VJustify vj)
=> vj switch
{
VJustify.Top => padding,
VJustify.Bottom => height - lineHeight - padding,
_ => (height - lineHeight) * 0.5f, // Center (default)
};
/// <summary>Order two caret positions so the first is <= the second (by line, /// <summary>Order two caret positions so the first is <= the second (by line,
/// then column).</summary> /// then column).</summary>
public static (Pos start, Pos end) Order(Pos a, Pos b) public static (Pos start, Pos end) Order(Pos a, Pos b)

View file

@ -228,4 +228,75 @@ public class DatWidgetFactoryTests
Assert.NotEqual(OverlayFile, m.FrontRight); Assert.NotEqual(OverlayFile, m.FrontRight);
Assert.NotEqual(OverlayFile, m.FrontTile); Assert.NotEqual(OverlayFile, m.FrontTile);
} }
// ── Justification build-time application (new for importer Fix A) ────────
/// <summary>
/// A Type-12 text element with HJustify=Center (the default) must produce a
/// UiText with Centered=true and RightAligned=false at build time.
/// This proves BuildText applies the dat's HJustify at construction without a
/// controller binding step.
/// </summary>
[Fact]
public void BuildText_HJustifyCenter_SetsCentered()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Center, t.VerticalJustify);
}
/// <summary>
/// A Type-12 text element with HJustify=Right must produce a UiText with
/// Centered=false and RightAligned=true at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyRight_SetsRightAligned()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.True(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with HJustify=Left must produce a UiText with
/// Centered=false and RightAligned=false at build time.
/// </summary>
[Fact]
public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.False(t.RightAligned);
}
/// <summary>
/// A Type-12 text element with VJustify=Top must produce a UiText with
/// VerticalJustify=Top at build time.
/// </summary>
[Fact]
public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
/// <summary>
/// A controller override: build-time sets Centered=true (HJustify=Center), then
/// a controller sets Centered=false afterward. The controller's override wins —
/// this is the backward-compatibility guarantee.
/// </summary>
[Fact]
public void BuildText_ControllerOverrideWinsAfterBuild()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered); // build-time value
t.Centered = false; // controller override
Assert.False(t.Centered); // override wins
}
} }

View file

@ -161,4 +161,57 @@ public class ElementReaderTests
Assert.Single(merged.Children); Assert.Single(merged.Children);
Assert.Equal(0x2u, merged.Children[0].Id); Assert.Equal(0x2u, merged.Children[0].Id);
} }
// ── HJustify / VJustify — Merge propagation ─────────────────────────────
/// <summary>
/// When the derived element has a non-Center HJustify, the derived value wins
/// (same "non-default wins" rule as FontDid).
/// </summary>
[Fact]
public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
{
var base_ = new ElementInfo { HJustify = HJustify.Center };
var derived = new ElementInfo { HJustify = HJustify.Right };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Right, merged.HJustify);
}
/// <summary>
/// When the derived element has the default HJustify (Center), the base value
/// is inherited — Center from the derived does NOT override a Left base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
{
var base_ = new ElementInfo { HJustify = HJustify.Left };
var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Left, merged.HJustify);
}
/// <summary>
/// VJustify=Top from the base propagates when the derived element has no explicit
/// (Center) vertical justification.
/// </summary>
[Fact]
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Top };
var derived = new ElementInfo { VJustify = VJustify.Center }; // default
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Top, merged.VJustify);
}
/// <summary>
/// VJustify=Bottom from the derived element wins over a Center base.
/// </summary>
[Fact]
public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Center };
var derived = new ElementInfo { VJustify = VJustify.Bottom };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Bottom, merged.VJustify);
}
} }

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI; namespace AcDream.App.Tests.UI;
@ -140,4 +141,57 @@ public class UiTextTests
Assert.Equal(new UiText.Pos(1, 2), s2); Assert.Equal(new UiText.Pos(1, 2), s2);
Assert.Equal(new UiText.Pos(1, 8), e2); Assert.Equal(new UiText.Pos(1, 8), e2);
} }
// ── VOffset: vertical positioning for single-line mode ───────────────────
/// <summary>
/// VJustify.Center: vertical offset = (height - lineHeight) / 2.
/// This is the original formula used by both the Centered and RightAligned paths
/// before VerticalJustify was introduced. Must remain unchanged (backward compat).
/// </summary>
[Fact]
public void VOffset_Center_ReturnsHalfHeightMinusLineHeight()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Center);
Assert.Equal((55f - 12f) * 0.5f, y);
}
/// <summary>
/// VJustify.Top: vertical offset = padding (top of the content area).
/// Used for footer title elements whose dat box is the full footer height (55 px)
/// but the text should render near the top.
/// </summary>
[Fact]
public void VOffset_Top_ReturnsPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Top);
Assert.Equal(0f, y);
float yWithPadding = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 4f, VJustify.Top);
Assert.Equal(4f, yWithPadding);
}
/// <summary>
/// VJustify.Bottom: vertical offset = height - lineHeight - padding.
/// </summary>
[Fact]
public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding()
{
float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom);
Assert.Equal(55f - 12f - 2f, y);
}
/// <summary>
/// Center with non-zero padding: padding does NOT affect the center formula —
/// VJustify.Center always uses (height - lineHeight) / 2 regardless of padding,
/// matching the original behavior.
/// </summary>
[Fact]
public void VOffset_Center_IgnoresPadding()
{
float yNoPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 0f, VJustify.Center);
float yWithPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 4f, VJustify.Center);
Assert.Equal(yNoPad, yWithPad); // Center is padding-independent
Assert.Equal((40f - 12f) * 0.5f, yNoPad);
}
} }