fix(ui): match spellbook frame and tab alignment
This commit is contained in:
parent
16eb4844c1
commit
1249ad03de
5 changed files with 116 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue