diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md index 2c85585d..8cbec9f4 100644 --- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md +++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md @@ -221,6 +221,16 @@ UIElement_Text::OnSetAttribute @ 0x0046A640 SetOneLine(value) // absent 0x20 therefore remains multi-line; do not force it true +UIElement_Text::CalcJustification @ 0x00467260 + available = max(scrollableExtent, surfaceExtent) - leadingMargin - trailingMargin + if justification is Center: + offset = available / 2 - contentExtent / 2 + else if justification is Right or Bottom: + offset = available - contentExtent + else: + offset = 0 + return leadingMargin + offset + UIElement::SetState @ 0x00464E70 next = desc.AccessStateDesc(stateId) if next != current: @@ -245,6 +255,12 @@ before its retained children lets the center chrome overpaint the caption. This rule is derived structurally from `PassToChildren`, not from the two spellbook element ids. +Layout `0x21000034` supplies the 300 by 600 content surface, including its tab +bar and close control, but not the enclosing movable-window border. Production +mounts it through the same shared retained nine-slice frame used by the +Attributes/Skills window (`0x2100002E`). Treating the root as already framed +removes the gold outer border. + The Magic-combat favorite tab captions are authored text (`I` through `VIII`) and intentionally omit property `0x20`. Their small rectangles still render because retail begins with zero text margins. A generic four-pixel inset on diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 96d4f42b..807c08f4 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -764,7 +764,10 @@ public sealed class RetailUiRuntime : IDisposable new RetailWindowFrame.Options { WindowName = WindowNames.Spellbook, - Chrome = RetailWindowChrome.Imported, + // LayoutDesc 0x21000034 is the 300x600 content surface. Like the + // Attributes/Skills window, retail supplies the outer gold frame + // through the shared window chrome rather than this root element. + Chrome = RetailWindowChrome.NineSlice, Left = 18f, Top = 18f, Visible = false, diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 966ca3be..f17d2b43 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -166,6 +166,7 @@ public sealed class UiText : UiElement, IUiDatStateful private uint _activeRetailStateId = UiStateInfo.DirectStateId; private string _activeDatStateName = ""; private bool _drawTextAfterChildren; + private bool _honorDatVerticalJustification; // ── Selection state ────────────────────────────────────────────────── private Pos? _selAnchor; // where the drag started @@ -206,6 +207,7 @@ public sealed class UiText : UiElement, IUiDatStateful internal void ConfigureDatState(ElementInfo info) { _datInfo = info; + _honorDatVerticalJustification = true; // Retail spellbook tabs are UIElement_Text parents whose Open/Closed // PassToChildren states drive three authored chrome pieces. In retail's // software surface those pieces form the tab background while the text @@ -429,12 +431,18 @@ public sealed class UiText : UiElement, IUiDatStateful (int)MathF.Floor(innerH), preserveEnd: true); - // UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest. - // Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max). - // Invert: baseY = bottom - contentH + (MaxScroll - ScrollY). - // With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓ - // Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓ - float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY); + // Overflow keeps the UiScrollable convention: ScrollY=0 is TOP/oldest and + // ScrollY=MaxScroll is BOTTOM/newest. Fitting DAT-authored content instead + // uses retail CalcJustification (tabs center their one glyph line); synthesized + // transcript widgets retain the established bottom pin. + float baseY = ContentBaseY( + top, + bottom, + contentH, + Scroll.MaxScroll, + Scroll.ScrollY, + VerticalJustify, + _honorDatVerticalJustification); _lastBaseY = baseY; // Normalised selection span (start <= end), if any. @@ -624,6 +632,35 @@ public sealed class UiText : UiElement, IUiDatStateful _ => (height - lineHeight) * 0.5f, // Center (default) }; + /// + /// Resolve the first line's Y coordinate for the normal multi-line path. + /// Retail UIElement_Text::CalcJustification @ 0x00467260 applies the + /// authored vertical justification when the text content fits the surface; + /// overflow continues to use the scroll offset. Synthesized transcript widgets + /// retain the historical bottom-pinned behavior by passing + /// as . + /// + public static float ContentBaseY( + float top, + float bottom, + float contentHeight, + float maxScroll, + float scrollY, + VJustify justification, + bool honorJustification) + { + float viewHeight = Math.Max(0f, bottom - top); + if (!honorJustification || contentHeight > viewHeight) + return bottom - contentHeight + (maxScroll - scrollY); + + return justification switch + { + VJustify.Top => top, + VJustify.Bottom => bottom - contentHeight, + _ => top + (viewHeight - contentHeight) * 0.5f, + }; + } + /// 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/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs index 4918455b..9088aa31 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs @@ -48,6 +48,9 @@ public sealed class SpellbookWindowControllerTests Assert.Equal("Spellbook", Assert.Single(spellTab.LinesProvider()).Text); Assert.Equal("Components", Assert.Single(componentTab.LinesProvider()).Text); + Assert.False(spellTab.OneLine); + Assert.True(spellTab.Centered); + Assert.Equal(VJustify.Center, spellTab.VerticalJustify); Assert.True(spellTab.DrawTextAfterChildren); Assert.True(componentTab.DrawTextAfterChildren); diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 391cc824..ba9b365f 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -227,4 +227,54 @@ public class UiTextTests Assert.Equal(yNoPad, yWithPad); // Center is padding-independent Assert.Equal((40f - 12f) * 0.5f, yNoPad); } + + [Theory] + [InlineData(VJustify.Top, 0f)] + [InlineData(VJustify.Center, 4.5f)] + [InlineData(VJustify.Bottom, 9f)] + public void ContentBaseY_FittingAuthoredText_UsesRetailVerticalJustification( + VJustify justification, + float expected) + { + float y = UiText.ContentBaseY( + top: 0f, + bottom: 25f, + contentHeight: 16f, + maxScroll: 0f, + scrollY: 0f, + justification, + honorJustification: true); + + Assert.Equal(expected, y); + } + + [Fact] + public void ContentBaseY_OverflowingAuthoredText_PreservesScrollPosition() + { + float y = UiText.ContentBaseY( + top: 0f, + bottom: 25f, + contentHeight: 64f, + maxScroll: 39f, + scrollY: 12f, + VJustify.Center, + honorJustification: true); + + Assert.Equal(-12f, y); + } + + [Fact] + public void ContentBaseY_SynthesizedText_RetainsBottomPinWhenContentFits() + { + float y = UiText.ContentBaseY( + top: 0f, + bottom: 25f, + contentHeight: 16f, + maxScroll: 0f, + scrollY: 0f, + VJustify.Center, + honorJustification: false); + + Assert.Equal(9f, y); + } }