fix(chargen): Campaign CC gate round 1 Batch E — text origin, caption escapes, value rects, scrollbars, name prefill
R2-1/R2-6 (description-box text clipped left of the frame, regressed from
Batch C's frame un-consume): root cause was never the un-consume change
itself — the Heritage/Profession/Town/Summary description boxes
(0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four
independent text-inset margins (dat properties 0x23-0x26,
UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/
margD=15), which this codebase never read at all, before or after Batch C.
Un-consuming the gold-frame children just made the pre-existing missing-
margin bug visible for the first time (the frame's own left border now
draws around the same x=0 origin text always used). Fixed end to end:
ElementInfo.MarginLeft/Right/Top/Bottom (read in
ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/
Right/Top/Bottom (additive with the pre-existing Padding), a new pure
UiText.ContentOffsetX static consumed by the multi-line draw path's
per-line placement, and matching wrap-width shrinkage in
DatRichText.Compose and BuildText's own authored-multiline path. Scoped to
the multi-line (non-OneLine) path only.
R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live
credit value overlaps mid-caption): two stacked gaps. (1) UiButton
captions never escape-normalized the DAT's literal "\n" — centralized the
normalize into DatWidgetFactory's ResolveAuthoredString (the one choke
point every P0x17 resolution already shares) plus a NormalizeEscapes
helper for the per-state caption loop, so every caller normalizes
identically. (2) UiButton.Label only ever drew one line — retail's
UIElement_Button IS a UIElement_Text with OneLine=false on these buttons,
so a caption should word-wrap/stack like any other Type-12 box. Added
UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The
value-overlap itself: ValueBox was never wrong (live-DAT-measured correct
child rects) — the caption was drawing unconfined across the button's
full width ("Available Skill Credits" measures 193px in a 231px button
whose value box starts at x=116). Fixed by confining the caption's own
drawable width to stop before ValueBox.X whenever a ValueLabel coexists.
R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap —
the listbox authors a linked scrollbar via dat property 0x72
(ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's
constructor never resolved, unlike every other UiTemplateListBox owner in
the codebase. Fixed with the same resolve-and-wire pattern.
R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a
downstream symptom of R2-1, not an independent bug — UiScrollbar only
paints its thumb when the linked model has overflow, and the pre-fix wrap
width (un-inset) produced fewer/shorter lines than fit the view. Pinned
directly against the real installed strings/font (Aluvian's how-to text)
that the margin-correct width overflows. No UiScrollbar code changed.
R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis
Batch A's GF-15 closure left open — an authored initial-text string on
the field's own P0x17. Confirmed absent on every state in the installed
DAT. No code change; Batch A's closure stands, now pinned as a live-DAT
regression test.
App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0
unchanged. Full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2ad805469d
commit
e24ec20882
11 changed files with 895 additions and 23 deletions
|
|
@ -157,6 +157,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable
|
|||
if (_list is not null)
|
||||
_list.TemplateResolver = templateResolver;
|
||||
|
||||
// R2-7a (Campaign CC gate round 1 Batch E): the listbox's own linked
|
||||
// scrollbar (dat property 0x72, ScrollId 0x10000401 — a SIBLING
|
||||
// element, not a descendant of the listbox itself) was never wired
|
||||
// to UiTemplateListBox.Scroll. Every other UiTemplateListBox owner in
|
||||
// this codebase (SocialFriendsPageController, ConfigOptionsPageController,
|
||||
// the Fellowship/Allegiance/Squelch pages) resolves
|
||||
// ScrollbarElementId against the page root the SAME way — this page
|
||||
// was the one holdout that never did.
|
||||
if (_list is not null)
|
||||
{
|
||||
uint scrollbarElementId = _list.ScrollbarElementId;
|
||||
if (scrollbarElementId != 0
|
||||
&& UiElement.FindDescendant(pageRoot, scrollbarElementId) is UiScrollbar overviewScroll)
|
||||
{
|
||||
overviewScroll.Model = _list.Scroll;
|
||||
}
|
||||
}
|
||||
|
||||
_nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField;
|
||||
if (_nameField is not null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,7 +58,15 @@ internal static class DatRichText
|
|||
ArgumentNullException.ThrowIfNull(segments);
|
||||
|
||||
var lines = new List<UiText.Line>();
|
||||
float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding);
|
||||
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink
|
||||
// by the SAME left+right inset the draw path now applies (Padding
|
||||
// plus the four retail margins, UiText.MarginLeft's own doc) — the
|
||||
// Batch-C regression's second half: text wasn't just drawing at the
|
||||
// wrong X, it was also wrapping to the FULL box width instead of the
|
||||
// authored interior width, overflowing the visible right edge too.
|
||||
float maximumWidth = MathF.Max(
|
||||
1f,
|
||||
target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight));
|
||||
Func<string, float> measure = target.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static value => value.Length * 8f;
|
||||
|
|
|
|||
|
|
@ -730,6 +730,14 @@ public static class DatWidgetFactory
|
|||
// ElementInfo.Outline's own default, so this is a no-op for the ~99% of text
|
||||
// elements that don't author it.
|
||||
Outline = info.Outline,
|
||||
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset
|
||||
// margins (dat properties 0x23-0x26 — MarginLeft's own doc
|
||||
// comment on UiText). Default 0 — a no-op for every element that
|
||||
// doesn't author them (only consumed by the multi-line path).
|
||||
MarginLeft = info.MarginLeft,
|
||||
MarginRight = info.MarginRight,
|
||||
MarginTop = info.MarginTop,
|
||||
MarginBottom = info.MarginBottom,
|
||||
};
|
||||
t.ConfigureDatState(info);
|
||||
|
||||
|
|
@ -781,7 +789,12 @@ public static class DatWidgetFactory
|
|||
cachedWidth = t.Width;
|
||||
cachedFont = t.DatFont;
|
||||
cachedColor = t.DefaultColor;
|
||||
float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding);
|
||||
// R2-1: shrink by BOTH Padding and the four retail
|
||||
// margins — see DatRichText.Compose's own comment on
|
||||
// the same formula.
|
||||
float maximumWidth = Math.Max(
|
||||
1f,
|
||||
t.Width - (t.Padding + t.MarginLeft) - (t.Padding + t.MarginRight));
|
||||
Func<string, float> measure = t.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static value => value.Length * 8f;
|
||||
|
|
@ -810,7 +823,8 @@ public static class DatWidgetFactory
|
|||
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|
||||
|| stateCaption.Kind != UiPropertyKind.StringInfo)
|
||||
continue;
|
||||
if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text)
|
||||
if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
|
||||
is { Length: > 0 } text)
|
||||
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
|
||||
}
|
||||
if (stateStrings is not null)
|
||||
|
|
@ -1028,6 +1042,32 @@ public static class DatWidgetFactory
|
|||
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|
||||
|| property.Kind != UiPropertyKind.StringInfo)
|
||||
return null;
|
||||
return stringResolve(property.StringInfoValue);
|
||||
string? resolved = stringResolve(property.StringInfoValue);
|
||||
// R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL
|
||||
// two-character escape "\n" (0x5C 0x6E), not a real line break — same
|
||||
// fact BuildText's own authored-string path already normalized for
|
||||
// (see that call site's own comment). Centralizing the normalize
|
||||
// HERE, at the single choke point every P0x17 caption resolution in
|
||||
// this file goes through (BuildText, BuildButton's own caption AND
|
||||
// its lifted-child caption, BuildButton's coexisting ValueLabel,
|
||||
// BuildCheckbox), closes the exact class of bug R2-2 found: a caption
|
||||
// like the Profession credits button's own "Attribute\n Credits"
|
||||
// rendered the literal backslash-n because BuildButton never
|
||||
// normalized while BuildText did. BuildText's own subsequent
|
||||
// Replace("\\n","\n") is now a harmless no-op (idempotent) — left in
|
||||
// place rather than removed, since it costs nothing and documents the
|
||||
// same fact locally.
|
||||
return NormalizeEscapes(resolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
|
||||
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
|
||||
/// per-STATE authored-caption loop below (which resolves a state's own
|
||||
/// <c>0x17</c> directly, bypassing the effective-property resolution
|
||||
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
|
||||
/// instead of a second, easily-forgotten copy.
|
||||
/// </summary>
|
||||
private static string? NormalizeEscapes(string? raw) =>
|
||||
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,6 +245,27 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public bool Invisible;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC gate round 1 Batch E (R2-1): the four independent
|
||||
/// <c>UIElement_Text</c> text-inset margins, dat properties
|
||||
/// <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/<c>0x26</c> (IntegerBaseProperty
|
||||
/// — <c>UIElement_Text::OnSetAttribute @0x0046a640</c> cases
|
||||
/// <c>0xf</c>/<c>0x10</c>/<c>0x11</c>/<c>0x12</c>, i.e.
|
||||
/// <c>BaseProperty::GetPropertyName(arg2) - 0x14</c>, writing
|
||||
/// <c>m_margL</c>/<c>m_margR</c>/<c>m_margU</c>/<c>m_margD</c>). Ctor
|
||||
/// default is 0 on all four (<c>UIElement_Text::UIElement_Text
|
||||
/// @0x004686d1-0046872d</c> clears them before any authored value
|
||||
/// applies). The chargen description boxes author <c>margL=9,
|
||||
/// margR=26, margU=15, margD=15</c> (live-DAT-probe-confirmed on
|
||||
/// <c>0x100003C4</c>/<c>0x100003E0</c>/<c>0x10000409</c>/
|
||||
/// <c>0x10000404</c>) — this codebase never read these four
|
||||
/// properties before this fix, so every DAT-imported <c>UiText</c>
|
||||
/// drew flush against its own outer rect (<c>Padding</c> alone,
|
||||
/// always 0 for DAT-built text) regardless of what the DAT actually
|
||||
/// authored.
|
||||
/// </summary>
|
||||
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a property for a state using retail's DirectState-as-base rule. A
|
||||
/// named state's key overrides DirectState by presence, including false/zero.
|
||||
|
|
@ -421,6 +442,15 @@ public static class ElementReader
|
|||
Outline = derived.Outline || base_.Outline,
|
||||
// OutlineColor: same "non-null derived wins" rule as FontColor.
|
||||
OutlineColor = derived.OutlineColor ?? base_.OutlineColor,
|
||||
// R2-1: margins follow the same "non-zero derived wins" convention as
|
||||
// FontDid/ZLevel above — a derived element that authors no margin
|
||||
// property (0 is ApplyCanonicalLegacyProjection's own unset default,
|
||||
// matching retail's ctor-cleared default too) inherits the base
|
||||
// prototype's margin instead of silently zeroing it out.
|
||||
MarginLeft = derived.MarginLeft != 0 ? derived.MarginLeft : base_.MarginLeft,
|
||||
MarginRight = derived.MarginRight != 0 ? derived.MarginRight : base_.MarginRight,
|
||||
MarginTop = derived.MarginTop != 0 ? derived.MarginTop : base_.MarginTop,
|
||||
MarginBottom = derived.MarginBottom != 0 ? derived.MarginBottom : base_.MarginBottom,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// This helper merges one element snapshot only. LayoutImporter separately
|
||||
|
|
@ -526,6 +556,20 @@ public static class ElementReader
|
|||
}
|
||||
}
|
||||
|
||||
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset margins
|
||||
// (0x23 Left / 0x24 Right / 0x25 Up / 0x26 Down, IntegerBaseProperty —
|
||||
// see MarginLeft's own doc comment for the decomp anchor). Absent
|
||||
// properties leave the ElementInfo default of 0, matching retail's
|
||||
// ctor-cleared default.
|
||||
if (info.TryGetEffectiveInteger(0x23u, out int marginLeft))
|
||||
info.MarginLeft = marginLeft;
|
||||
if (info.TryGetEffectiveInteger(0x24u, out int marginRight))
|
||||
info.MarginRight = marginRight;
|
||||
if (info.TryGetEffectiveInteger(0x25u, out int marginTop))
|
||||
info.MarginTop = marginTop;
|
||||
if (info.TryGetEffectiveInteger(0x26u, out int marginBottom))
|
||||
info.MarginBottom = marginBottom;
|
||||
|
||||
// Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the
|
||||
// Type-8 tab control's authored {button element, page element, isDefault} rows
|
||||
// (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh
|
||||
|
|
|
|||
|
|
@ -483,11 +483,23 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
float boxY = LabelBox?.Y ?? 0f;
|
||||
float boxWidth = LabelBox?.Width ?? Width;
|
||||
float boxHeight = LabelBox?.Height ?? Height;
|
||||
float tx = LabelAlign == LabelAlignment.Left
|
||||
? boxX + LabelOffsetX
|
||||
: boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
||||
float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
|
||||
|
||||
// R2-2/R2-3 (Campaign CC gate round 1 Batch E): when this button
|
||||
// ALSO carries a coexisting ValueLabel (GF-4a's own-caption +
|
||||
// separate value slot — the Profession attribute/health/stamina/
|
||||
// mana credits buttons, the Skills credits button), the caption's
|
||||
// own drawable region stops before the value's authored rect
|
||||
// starts. LabelBox and ValueBox are mutually exclusive by
|
||||
// construction (DatWidgetFactory.BuildButton only ever sets one
|
||||
// or the other), so this never fights GF-11c's own LabelBox
|
||||
// confinement above. Live-DAT-measured: "Available Skill Credits"
|
||||
// is 193px wide in the Skills credits button's 231px-wide box
|
||||
// whose value box starts at local x=116 — without this, the live
|
||||
// credits number draws on top of the caption's own tail.
|
||||
if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX)
|
||||
boxWidth = MathF.Min(boxWidth, valueBoxX - boxX);
|
||||
|
||||
DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX);
|
||||
}
|
||||
|
||||
if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf)
|
||||
|
|
@ -517,6 +529,112 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-2 (Campaign CC gate round 1 Batch E): retail's <c>UIElement_Button</c>
|
||||
/// IS a <c>UIElement_Text</c> (<c>struct UIElement_Button : UIElement_Text</c>,
|
||||
/// <c>acclient.h</c>) — these captions author <c>OneLine=false</c>
|
||||
/// (live-DAT-probe-confirmed on 0x100003e2-e5/0x100003f9), so a caption
|
||||
/// that carries an authored newline (already normalized to a real
|
||||
/// <c>'\n'</c> by <see cref="Layout.DatWidgetFactory"/>'s shared
|
||||
/// <c>ResolveAuthoredString</c>) OR simply doesn't fit
|
||||
/// <paramref name="boxWidth"/> lays out as multiple stacked lines, using
|
||||
/// the SAME word-wrap <see cref="UiText.WrapWords"/> any other Type-12
|
||||
/// text box uses. A single line that already fits draws with byte-
|
||||
/// identical geometry to the pre-fix unconditional one-line math (same
|
||||
/// centered-block Y, same tx formula) — this is a strict superset, not a
|
||||
/// behavior change, for every button whose caption was already short
|
||||
/// enough to fit on one line.
|
||||
/// </summary>
|
||||
private void DrawBlockLabel(
|
||||
UiRenderContext ctx,
|
||||
string text,
|
||||
UiDatFont font,
|
||||
Vector4 color,
|
||||
float boxX,
|
||||
float boxY,
|
||||
float boxWidth,
|
||||
float boxHeight,
|
||||
LabelAlignment align,
|
||||
float leftOffset)
|
||||
{
|
||||
IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines(
|
||||
text, font.MeasureWidth, font.LineHeight,
|
||||
boxX, boxY, boxWidth, boxHeight, align, leftOffset);
|
||||
|
||||
// A multi-line result clips to its own box — the button's normal
|
||||
// draw has no ambient clip, and an oversized wrapped caption (e.g.
|
||||
// the Skills credits button's own tight 28px height) should be cut
|
||||
// off at the box edge rather than spill into whatever sits below the
|
||||
// button, matching every other clipped Type-12 text box in this
|
||||
// codebase (UiText.DrawText's own PushClip). Single-line captions —
|
||||
// the overwhelming majority — never pay this cost.
|
||||
bool clip = lines.Count > 1;
|
||||
if (clip)
|
||||
ctx.PushClip(boxX, boxY, boxWidth, boxHeight);
|
||||
try
|
||||
{
|
||||
foreach ((string line, float tx, float ty) in lines)
|
||||
ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (clip)
|
||||
ctx.PopClip();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure geometry half of <see cref="DrawBlockLabel"/> — normalized
|
||||
/// newline split + word-wrap (<see cref="UiText.WrapWords"/>) to
|
||||
/// <paramref name="boxWidth"/>, then block-centered vertically within
|
||||
/// <paramref name="boxHeight"/>. Pulled out as a static/pure method
|
||||
/// (same shape as <see cref="UiText.ContentOffsetX"/>) so the wrap/
|
||||
/// confinement math is unit-testable without a font atlas or draw
|
||||
/// context — <paramref name="measureWidth"/> takes the place of
|
||||
/// <see cref="UiDatFont.MeasureWidth(string)"/>.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines(
|
||||
string text,
|
||||
Func<string, float> measureWidth,
|
||||
float lineHeight,
|
||||
float boxX,
|
||||
float boxY,
|
||||
float boxWidth,
|
||||
float boxHeight,
|
||||
LabelAlignment align,
|
||||
float leftOffset)
|
||||
{
|
||||
float availableWidth = MathF.Max(
|
||||
1f,
|
||||
boxWidth - (align == LabelAlignment.Left ? leftOffset : 0f));
|
||||
|
||||
var lines = new List<string>();
|
||||
foreach (string paragraph in text.Split('\n'))
|
||||
{
|
||||
if (measureWidth(paragraph) <= availableWidth)
|
||||
{
|
||||
lines.Add(paragraph);
|
||||
continue;
|
||||
}
|
||||
lines.AddRange(UiText.WrapWords(paragraph, measureWidth, availableWidth));
|
||||
}
|
||||
|
||||
float totalHeight = lines.Count * lineHeight;
|
||||
float startY = boxY + (boxHeight - totalHeight) * 0.5f;
|
||||
|
||||
var result = new List<(string, float, float)>(lines.Count);
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
string line = lines[i];
|
||||
float tx = align == LabelAlignment.Left
|
||||
? boxX + leftOffset
|
||||
: boxX + (boxWidth - measureWidth(line)) * 0.5f;
|
||||
float ty = startY + i * lineHeight;
|
||||
result.Add((line, tx, ty));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
|
||||
{
|
||||
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
|
||||
|
|
|
|||
|
|
@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
/// </summary>
|
||||
public float Padding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC gate round 1 Batch E (R2-1): the four independent retail
|
||||
/// text-inset margins (dat properties <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/
|
||||
/// <c>0x26</c> — <see cref="Layout.ElementInfo.MarginLeft"/>'s own doc
|
||||
/// comment has the full decomp citation). Additive with
|
||||
/// <see cref="Padding"/> (every existing controller that sets
|
||||
/// <see cref="Padding"/> explicitly keeps behaving identically, since
|
||||
/// these four default to 0 unless <see cref="Layout.DatWidgetFactory"/>
|
||||
/// seeds them from the DAT). Applied ONLY to the scrollable multi-line
|
||||
/// path (<see cref="OneLine"/> == false) — the chargen description boxes
|
||||
/// that regressed in Batch C are all multi-line, and every authored
|
||||
/// nonzero-margin box measured against the installed DAT so far is also
|
||||
/// multi-line. The static Centered/RightAligned/OneLine single-line
|
||||
/// paths are unchanged (still bare <see cref="Padding"/>) to keep this
|
||||
/// fix's blast radius to the mechanism that actually regressed.
|
||||
/// </summary>
|
||||
public float MarginLeft { get; set; }
|
||||
public float MarginRight { get; set; }
|
||||
public float MarginTop { get; set; }
|
||||
public float MarginBottom { get; set; }
|
||||
|
||||
/// <summary>Retail property 0x20. Independent of horizontal/vertical
|
||||
/// justification; false permits the normal multi-line layout path.</summary>
|
||||
public bool OneLine { get; set; }
|
||||
|
|
@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
if (lines.Count == 0) return;
|
||||
|
||||
float lh = _lastLineHeight;
|
||||
float top = Padding, bottom = Height - Padding;
|
||||
// R2-1: the multi-line viewport insets by BOTH Padding (the pre-
|
||||
// existing uniform inset controllers already set) AND the four
|
||||
// retail-authored margins (additive — see MarginTop's own doc).
|
||||
float top = Padding + MarginTop, bottom = Height - Padding - MarginBottom;
|
||||
float innerH = bottom - top;
|
||||
float contentH = lines.Count * lh;
|
||||
|
||||
|
|
@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
float width = datFont is not null
|
||||
? datFont.MeasureWidth(text)
|
||||
: bitmapFont?.MeasureWidth(text) ?? 0f;
|
||||
if (Centered)
|
||||
return Math.Max(Padding, (Width - width) * 0.5f);
|
||||
if (RightAligned)
|
||||
return Math.Max(Padding, Width - Padding - width);
|
||||
return Padding;
|
||||
return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-1 (Campaign CC gate round 1 Batch E): pure per-line horizontal
|
||||
/// placement for the MULTI-LINE (scrollable) path — the static
|
||||
/// single-line Centered/RightAligned/OneLine branches in
|
||||
/// <see cref="DrawClippedText"/> have their own inline math and are
|
||||
/// deliberately left on bare <see cref="Padding"/> (see
|
||||
/// <see cref="MarginLeft"/>'s own doc comment). Here, both
|
||||
/// <see cref="Padding"/> and the four retail margins inset the content
|
||||
/// box a line lays out within. Pure/static so it is unit-testable
|
||||
/// without a font or draw context — the same shape as
|
||||
/// <see cref="VOffset"/>/<see cref="ContentBaseY"/> above.
|
||||
/// </summary>
|
||||
public static float ContentOffsetX(
|
||||
float elementWidth,
|
||||
float padding,
|
||||
float marginLeft,
|
||||
float marginRight,
|
||||
float lineWidth,
|
||||
bool centered,
|
||||
bool rightAligned)
|
||||
{
|
||||
float contentLeft = padding + marginLeft;
|
||||
float contentRight = elementWidth - padding - marginRight;
|
||||
if (centered)
|
||||
return Math.Max(contentLeft, contentLeft + (contentRight - contentLeft - lineWidth) * 0.5f);
|
||||
if (rightAligned)
|
||||
return Math.Max(contentLeft, contentRight - lineWidth);
|
||||
return contentLeft;
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue