fix(ui): match spellbook frame and tab alignment

This commit is contained in:
Erik 2026-07-15 17:08:48 +02:00
parent 16eb4844c1
commit 1249ad03de
5 changed files with 116 additions and 7 deletions

View file

@ -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

View file

@ -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,

View file

@ -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)
};
/// <summary>
/// Resolve the first line's Y coordinate for the normal multi-line path.
/// Retail <c>UIElement_Text::CalcJustification @ 0x00467260</c> 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
/// <paramref name="honorJustification"/> as <see langword="false"/>.
/// </summary>
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,
};
}
/// <summary>Order two caret positions so the first is <= the second (by line,
/// then column).</summary>
public static (Pos start, Pos end) Order(Pos a, Pos b)

View file

@ -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);

View file

@ -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);
}
}