From 41430420b3b64f907940f0ec76254c2b57cbde09 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 26 Jun 2026 10:05:36 +0200 Subject: [PATCH] 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 --- .../UI/Layout/CharacterStatController.cs | 18 ++--- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 34 ++++++++- src/AcDream.App/UI/Layout/ElementReader.cs | 33 +++++++++ src/AcDream.App/UI/Layout/LayoutImporter.cs | 34 +++++++++ src/AcDream.App/UI/UiText.cs | 49 +++++++++++-- .../UI/Layout/DatWidgetFactoryTests.cs | 71 +++++++++++++++++++ .../UI/Layout/ElementReaderTests.cs | 53 ++++++++++++++ tests/AcDream.App.Tests/UI/UiTextTests.cs | 54 ++++++++++++++ 8 files changed, 328 insertions(+), 18 deletions(-) diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 78966be8..7d28c711 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -747,22 +747,22 @@ public static class CharacterStatController var titleEl = ByPos(0f, 0f, FooterTitleId); 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; - // The dat title element is H=55 (the full footer box). Centering the title text in it - // 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; + titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above) } // Title color: State A = body (parchment); State B = WHITE per retail (confirmed 2026-06-26 ref). if (titleEl is not null) { titleEl.DatFont = datFont; - titleEl.Centered = false; - titleEl.RightAligned = false; + // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here. + // RightAligned stays false (BuildText default for a Center element). titleEl.ClickThrough = true; - titleEl.Padding = 0f; titleEl.LinesProvider = () => { if (sel[0] < 0) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 125f174a..ebc19b0b 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -239,13 +239,43 @@ public static class DatWidgetFactory /// 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 /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing - /// because defaults to transparent. + /// because defaults to transparent. + /// + /// + /// Justification from the dat ( / + /// ) is applied here at build time so that controllers + /// that subsequently call / + /// on dat-origin elements can be simplified. Controllers that explicitly set those + /// properties after still override the build-time + /// defaults — the build-time value is just the starting point, not a lock. + /// + /// private static UiText BuildText(ElementInfo info, Func resolve) { uint bg = info.StateMedia.TryGetValue( !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) ? 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, + }; } } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index bbafcd05..27883dfd 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -2,6 +2,18 @@ using System.Collections.Generic; namespace AcDream.App.UI.Layout; +/// +/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification). +/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right. +/// +public enum HJustify : byte { Left = 0, Center = 1, Right = 2 } + +/// +/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification). +/// Values: 2=Top, 4=Bottom; absent/other = Center. +/// +public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 } + /// /// GL-free, dat-free snapshot of a resolved layout element. /// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc @@ -51,6 +63,21 @@ public sealed class ElementInfo /// public uint FontDid; + /// + /// Horizontal text justification from dat Properties[0x14] + /// (EnumBaseProperty: 0=Left, 1=Center, 3/5=Right). + /// Default is to preserve existing behavior where + /// controllers set Centered=true and no property was read. + /// + public HJustify HJustify = HJustify.Center; + + /// + /// Vertical text justification from dat Properties[0x15] + /// (EnumBaseProperty: 2=Top, 4=Bottom; absent/other = Center). + /// Default is to preserve existing behavior. + /// + public VJustify VJustify = VJustify.Center; + /// /// Sprite per state: state name → (RenderSurface file id, DrawMode int). /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). @@ -160,6 +187,12 @@ public static class ElementReader ReadOrder = derived.ReadOrder, ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, 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 = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, // Children come from the derived element's own tree, not the base prototype's. diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 8859f25f..799470f2 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -362,6 +362,40 @@ public static class LayoutImporter { 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 ─────────────────────────────────────────── diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 6e7222ab..e6bc45f0 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Numerics; using System.Text; using AcDream.App.Rendering; +using AcDream.App.UI.Layout; namespace AcDream.App.UI; @@ -78,6 +79,23 @@ public sealed class UiText : UiElement /// precedence. Pair with ClickThrough = true for non-interactive labels. public bool RightAligned { get; set; } + /// + /// Vertical position of the text within the element rect in single-line mode + /// ( or ). + /// + /// Center (default) — vertically centered, matching the original + /// behavior of the centered/right-aligned paths. + /// Top — text is placed at y = Padding (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. + /// Bottom — text is placed at y = Height - lineHeight - Padding. + /// + /// Only meaningful when or is true. + /// Has no effect on the scrollable multi-line path. + /// + public VJustify VerticalJustify { get; set; } = VJustify.Center; + /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); @@ -138,8 +156,8 @@ public sealed class UiText : UiElement ctx.DrawFill(0, 0, Width, Height, BackgroundColor); // 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 - // skip the scroll/selection machinery entirely. + // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula + // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely. if (Centered) { var cLines = LinesProvider(); @@ -148,20 +166,20 @@ public sealed class UiText : UiElement if (DatFont is { } cdf) { 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); } else if ((Font ?? ctx.DefaultFont) is { } cbf) { 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); } return; } // 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) { var rLines = LinesProvider(); @@ -170,13 +188,13 @@ public sealed class UiText : UiElement if (DatFont is { } rdf) { 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); } else if ((Font ?? ctx.DefaultFont) is { } rbf) { 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); } return; @@ -366,6 +384,23 @@ public sealed class UiText : UiElement // ── Pure, testable logic (no GL / no font texture) ─────────────────── + /// + /// 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. + /// + /// Element height in pixels. + /// Font line height in pixels. + /// Content padding. + /// Vertical justification. + 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) + }; + /// Order two caret positions so the first is <= the second (by line, /// then column). public static (Pos start, Pos end) Order(Pos a, Pos b) diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 67a3a10a..0adb9eee 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -228,4 +228,75 @@ public class DatWidgetFactoryTests Assert.NotEqual(OverlayFile, m.FrontRight); Assert.NotEqual(OverlayFile, m.FrontTile); } + + // ── Justification build-time application (new for importer Fix A) ──────── + + /// + /// 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. + /// + [Fact] + public void BuildText_HJustifyCenter_SetsCentered() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.True(t.Centered); + Assert.False(t.RightAligned); + Assert.Equal(VJustify.Center, t.VerticalJustify); + } + + /// + /// A Type-12 text element with HJustify=Right must produce a UiText with + /// Centered=false and RightAligned=true at build time. + /// + [Fact] + public void BuildText_HJustifyRight_SetsRightAligned() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.False(t.Centered); + Assert.True(t.RightAligned); + } + + /// + /// A Type-12 text element with HJustify=Left must produce a UiText with + /// Centered=false and RightAligned=false at build time. + /// + [Fact] + public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.False(t.Centered); + Assert.False(t.RightAligned); + } + + /// + /// A Type-12 text element with VJustify=Top must produce a UiText with + /// VerticalJustify=Top at build time. + /// + [Fact] + public void BuildText_VJustifyTop_SetsVerticalJustifyTop() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.Equal(VJustify.Top, t.VerticalJustify); + } + + /// + /// 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. + /// + [Fact] + public void BuildText_ControllerOverrideWinsAfterBuild() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.True(t.Centered); // build-time value + t.Centered = false; // controller override + Assert.False(t.Centered); // override wins + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index 9d79f58d..2745590d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -161,4 +161,57 @@ public class ElementReaderTests Assert.Single(merged.Children); Assert.Equal(0x2u, merged.Children[0].Id); } + + // ── HJustify / VJustify — Merge propagation ───────────────────────────── + + /// + /// When the derived element has a non-Center HJustify, the derived value wins + /// (same "non-default wins" rule as FontDid). + /// + [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); + } + + /// + /// When the derived element has the default HJustify (Center), the base value + /// is inherited — Center from the derived does NOT override a Left base. + /// + [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); + } + + /// + /// VJustify=Top from the base propagates when the derived element has no explicit + /// (Center) vertical justification. + /// + [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); + } + + /// + /// VJustify=Bottom from the derived element wins over a Center base. + /// + [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); + } } diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 691dc213..946f7fad 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.UI; +using AcDream.App.UI.Layout; 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, 8), e2); } + + // ── VOffset: vertical positioning for single-line mode ─────────────────── + + /// + /// 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). + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// VJustify.Bottom: vertical offset = height - lineHeight - padding. + /// + [Fact] + public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding() + { + float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom); + Assert.Equal(55f - 12f - 2f, y); + } + + /// + /// 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. + /// + [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); + } }