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

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