perf(ui): retain shaped report text

This commit is contained in:
Erik 2026-07-25 05:11:12 +02:00
parent c0bec56dfe
commit f2a015be8e
11 changed files with 525 additions and 38 deletions

View file

@ -87,6 +87,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly UiElement _spellFormulaHost;
private readonly UiTextureElement _spellIcon;
private readonly List<UiElement> _spellFormulaCells = [];
private readonly Dictionary<UiText, UiTextLayoutCache<string>>
_textLayouts = new();
private UiTextLayoutCache<ItemAppraisalReport> _itemTextLayout = null!;
private readonly float _spellFormulaOriginX;
private readonly float _spellFormulaCellWidth;
private AppraisalView _activeView;
@ -173,8 +176,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
_close = layout.FindElement(CloseId) as UiButton;
_title.LinesProvider = () =>
[new UiText.Line(_titleValue, _title.DefaultColor)];
BindTextSource(
_title,
() => _titleValue,
static (target, value) =>
[new UiText.Line(value, target.DefaultColor)]);
// ItemExamineUI::AddItemInfo @ 0x004AC050 appends the freshly
// rebuilt report from the beginning of the authored text surface.
// LayoutDesc 0x2100006B carries Bottom here, which is appropriate for
@ -200,8 +206,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
scrollbar.Model = _inscriptionField.Scroll;
}
if (_signature is not null)
_signature.LinesProvider = () =>
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
{
BindTextSource(
_signature,
() => _signatureValue,
static (target, value) =>
[new UiText.Line(value, target.DefaultColor)]);
}
if (_close is not null)
_close.OnClick = close;
if (_inscriptionBackground is not null)
@ -717,11 +728,49 @@ public sealed class AppraisalUiController : IRetainedPanelController
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = true;
text.ClickThrough = false;
text.LinesProvider = () => IndicatorDetailText.Shape(text, value());
BindTextSource(
text,
value,
static (target, content) =>
IndicatorDetailText.Shape(target, content));
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
}
private void BindTextSource(
UiText text,
Func<string> source,
Func<UiText, string, IReadOnlyList<UiText.Line>> shape)
{
var cache = new UiTextLayoutCache<string>(
text,
shape,
source,
StringComparer.Ordinal);
_textLayouts.Add(text, cache);
text.LinesProvider = cache.Provider;
}
private UiTextLayoutCache<string> GetTextLayout(UiText text)
{
if (_textLayouts.TryGetValue(
text,
out UiTextLayoutCache<string>? cache))
{
return cache;
}
cache = new UiTextLayoutCache<string>(
text,
static (target, value) =>
IndicatorDetailText.Shape(target, value),
string.Empty,
StringComparer.Ordinal);
_textLayouts.Add(text, cache);
text.LinesProvider = cache.Provider;
return cache;
}
private void ConfigureScrollableItemText(
UiText text,
uint scrollbarId)
@ -729,8 +778,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = true;
text.ClickThrough = false;
text.LinesProvider = () =>
ItemAppraisalTextLayout.Shape(text, _itemReport);
_itemTextLayout = new UiTextLayoutCache<ItemAppraisalReport>(
text,
static (target, report) =>
ItemAppraisalTextLayout.Shape(target, report),
() => _itemReport,
ReferenceEqualityComparer.Instance);
text.LinesProvider = _itemTextLayout.Provider;
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
}
@ -746,8 +800,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
}
}
private static void SetSpellText(UiText text, string value)
=> text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
private void SetSpellText(UiText text, string value)
=> GetTextLayout(text).SetValue(value);
private static string BuildSpellDisplay(
SpellMetadata metadata,
@ -811,7 +865,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = scrollable;
text.ClickThrough = !scrollable;
text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
GetTextLayout(text).SetValue(value);
if (scrollable)
{
UiScrollbar? scrollbar = Descendants(text.Parent)
@ -830,7 +884,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
})
if (_layout.FindElement(id) is UiText text)
text.LinesProvider = static () => Array.Empty<UiText.Line>();
GetTextLayout(text).SetValue(string.Empty);
_creatureStats?.Flush();
_creatureExtra?.Flush();
}

View file

@ -67,12 +67,13 @@ public static class CharacterController
internal static IReadOnlyList<string> AugmentationStringKeys
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
public static void Bind(
public static CharacterInformationUiController Bind(
ImportedLayout layout,
Func<CharacterSheet> data,
UiDatFont? datFont = null,
Action? close = null,
CharacterInfoStrings? strings = null)
CharacterInfoStrings? strings = null,
Func<Action, IDisposable>? subscribeChanged = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(data);
@ -96,11 +97,19 @@ public static class CharacterController
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
text.PreserveEndOnLayout = false;
text.LinesProvider = () => IndicatorDetailText.Shape(text, BuildReport(data(), copy));
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
UiScrollbar? scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar;
if (scrollbar is not null)
scrollbar.Model = text.Scroll;
if (layout.FindElement(CloseId) is UiButton closeButton)
UiButton? closeButton = layout.FindElement(CloseId) as UiButton;
if (closeButton is not null)
closeButton.OnClick = close;
return new CharacterInformationUiController(
text,
data,
copy,
subscribeChanged,
scrollbar,
closeButton);
}
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
@ -330,6 +339,69 @@ public static class CharacterController
bool IncludeValue);
}
/// <summary>
/// Event-invalidated report owner for retail's character-information text.
/// Stable frames borrow the same shaped line collection.
/// </summary>
public sealed class CharacterInformationUiController : IRetainedPanelController
{
private readonly Func<CharacterSheet> _data;
private readonly CharacterInfoStrings _strings;
private readonly UiTextLayoutCache<string> _layout;
private readonly IDisposable? _changeBinding;
private readonly UiScrollbar? _scrollbar;
private readonly UiButton? _close;
private bool _contentDirty = true;
private bool _disposed;
internal CharacterInformationUiController(
UiText text,
Func<CharacterSheet> data,
CharacterInfoStrings strings,
Func<Action, IDisposable>? subscribeChanged,
UiScrollbar? scrollbar,
UiButton? close)
{
_data = data;
_strings = strings;
_scrollbar = scrollbar;
_close = close;
_layout = new UiTextLayoutCache<string>(
text,
static (target, value) => IndicatorDetailText.Shape(target, value),
string.Empty,
StringComparer.Ordinal);
text.LinesProvider = GetLines;
_changeBinding = subscribeChanged?.Invoke(InvalidateContent);
}
private IReadOnlyList<UiText.Line> GetLines()
{
if (_contentDirty)
{
_layout.SetValue(CharacterController.BuildReport(_data(), _strings));
_contentDirty = false;
}
return _layout.GetLines();
}
private void InvalidateContent() => _contentDirty = true;
public void OnShown() => InvalidateContent();
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_changeBinding?.Dispose();
if (_scrollbar is not null)
_scrollbar.Model = null;
if (_close is not null)
_close.OnClick = null;
}
}
public sealed record CharacterInfoStrings(
string[] Birth,
string[] Played,

View file

@ -78,6 +78,17 @@ public sealed class CharacterSheetProvider
_sendTrainSkill = sendTrainSkill;
}
/// <summary>
/// Subscribe to semantic inputs used by <see cref="BuildSheet"/>. The
/// returned binding owns every event registration and filters object-table
/// notices to the current player GUID.
/// </summary>
public IDisposable SubscribeChanged(Action changed)
{
ArgumentNullException.ThrowIfNull(changed);
return new ChangeBinding(this, changed);
}
// ── Sheet assembly ─────────────────────────────────────────────────────
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
@ -181,6 +192,52 @@ public sealed class CharacterSheetProvider
: _localPlayer.Properties;
}
private sealed class ChangeBinding : IDisposable
{
private CharacterSheetProvider? _owner;
private readonly Action _changed;
public ChangeBinding(CharacterSheetProvider owner, Action changed)
{
_owner = owner;
_changed = changed;
owner._objects.ObjectAdded += OnObjectChanged;
owner._objects.ObjectUpdated += OnObjectChanged;
owner._objects.ObjectRemoved += OnObjectChanged;
owner._objects.Cleared += OnCleared;
owner._localPlayer.AttributeChanged += OnAttributeChanged;
owner._localPlayer.CharacterChanged += OnCharacterChanged;
}
private void OnObjectChanged(ClientObject value)
{
CharacterSheetProvider? owner = _owner;
if (owner is not null && value.ObjectId == owner._playerGuid())
_changed();
}
private void OnCleared() => _changed();
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
_changed();
private void OnCharacterChanged() => _changed();
public void Dispose()
{
CharacterSheetProvider? owner = Interlocked.Exchange(ref _owner, null);
if (owner is null)
return;
owner._objects.ObjectAdded -= OnObjectChanged;
owner._objects.ObjectUpdated -= OnObjectChanged;
owner._objects.ObjectRemoved -= OnObjectChanged;
owner._objects.Cleared -= OnCleared;
owner._localPlayer.AttributeChanged -= OnAttributeChanged;
owner._localPlayer.CharacterChanged -= OnCharacterChanged;
}
}
/// <summary>
/// Load the portal ExperienceTable (0x0E000018), falling back to a
/// type scan for older or odd dat collections. Failures are logged —

View file

@ -32,6 +32,7 @@ public sealed class EffectsUiController : IRetainedPanelController
private readonly string _selectPrompt;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiTextLayoutCache<string>? _infoLayout;
private readonly UiButton? _close;
private readonly UiScrollbar? _listScrollbar;
private readonly UiScrollbar? _infoScrollbar;
@ -70,7 +71,7 @@ public sealed class EffectsUiController : IRetainedPanelController
_list.CellHeight = templates.Height;
if (_listScrollbar is not null)
_listScrollbar.Model = _list.Scroll;
ConfigureInfo();
_infoLayout = ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
}
@ -155,6 +156,7 @@ public sealed class EffectsUiController : IRetainedPanelController
&& !_rows.Values.Any(row => row.Slot.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
UpdateInfoText();
}
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
@ -189,6 +191,7 @@ public sealed class EffectsUiController : IRetainedPanelController
// token's spell stat, not its enchantment layer identity.
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
SyncSelection();
UpdateInfoText();
}
private void SyncSelection()
@ -197,30 +200,39 @@ public sealed class EffectsUiController : IRetainedPanelController
row.Slot.SetSelected(row.Slot.EntryId == _selectedSpellId);
}
private void ConfigureInfo()
private UiTextLayoutCache<string>? ConfigureInfo()
{
if (_info is null) return;
if (_info is null) return null;
_info.PreserveEndOnLayout = false;
_info.WheelScrollEnabled = true;
_info.ClickThrough = false;
if (_infoScrollbar is not null)
_infoScrollbar.Model = _info.Scroll;
_info.LinesProvider = () =>
var cache = new UiTextLayoutCache<string>(
_info,
static (target, value) => IndicatorDetailText.Shape(target, value),
_selectPrompt,
StringComparer.Ordinal);
_info.LinesProvider = cache.Provider;
return cache;
}
private void UpdateInfoText()
{
if (_infoLayout is null)
return;
string value = _selectPrompt;
if (_selectedSpellId is uint spellId
&& _spellbook.ActiveEnchantmentSnapshot.Any(
record => record.SpellId == spellId)
&& _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
{
if (_selectedSpellId is not uint spellId)
return IndicatorDetailText.Shape(_info, _selectPrompt);
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return IndicatorDetailText.Shape(_info, _selectPrompt);
// gmEffectsUI::UpdateSelection @ 0x004B7F90 supplies one literal
// name + blank line + description to UIElement_Text::SetText. The
// text widget reflows it to the authored information width.
return IndicatorDetailText.Shape(
_info,
metadata.Name + "\n\n" + metadata.Description);
};
// name + blank line + description to UIElement_Text::SetText.
value = metadata.Name + "\n\n" + metadata.Description;
}
_infoLayout.SetValue(value);
}
public void OnShown() => Rebuild();

View file

@ -0,0 +1,115 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retains the shaped line projection of one <see cref="UiText"/> value.
/// Controllers publish semantic content; the cache reshapes only when that
/// content or the text widget's layout/font inputs change.
/// </summary>
internal sealed class UiTextLayoutCache<T>
{
private readonly UiText _target;
private readonly Func<UiText, T, IReadOnlyList<UiText.Line>> _shape;
private readonly IEqualityComparer<T> _comparer;
private readonly Func<T>? _source;
private T _value = default!;
private bool _hasValue;
private bool _shaped;
private IReadOnlyList<UiText.Line> _lines = Array.Empty<UiText.Line>();
private float _width;
private float _padding;
private Vector4 _defaultColor;
private UiDatFont? _datFont;
private BitmapFont? _bitmapFont;
private IReadOnlyList<Vector4>? _fontColorPalette;
public UiTextLayoutCache(
UiText target,
Func<UiText, T, IReadOnlyList<UiText.Line>> shape,
T initialValue,
IEqualityComparer<T>? comparer = null)
: this(target, shape, source: null, comparer, initialize: true)
{
SetValue(initialValue);
}
public UiTextLayoutCache(
UiText target,
Func<UiText, T, IReadOnlyList<UiText.Line>> shape,
Func<T> source,
IEqualityComparer<T>? comparer = null)
: this(
target,
shape,
source ?? throw new ArgumentNullException(nameof(source)),
comparer,
initialize: true)
{
}
private UiTextLayoutCache(
UiText target,
Func<UiText, T, IReadOnlyList<UiText.Line>> shape,
Func<T>? source,
IEqualityComparer<T>? comparer,
bool initialize)
{
_ = initialize;
_target = target ?? throw new ArgumentNullException(nameof(target));
_shape = shape ?? throw new ArgumentNullException(nameof(shape));
_source = source;
_comparer = comparer ?? EqualityComparer<T>.Default;
}
public Func<IReadOnlyList<UiText.Line>> Provider => GetLines;
public void SetValue(T value)
{
if (_hasValue && _comparer.Equals(_value, value))
return;
_value = value;
_hasValue = true;
_shaped = false;
}
public void Invalidate() => _shaped = false;
public IReadOnlyList<UiText.Line> GetLines()
{
if (_source is not null)
SetValue(_source());
if (!_hasValue)
return Array.Empty<UiText.Line>();
if (!_shaped || LayoutChanged())
{
CaptureLayout();
_lines = _shape(_target, _value);
_shaped = true;
}
return _lines;
}
private bool LayoutChanged()
=> _width != _target.Width
|| _padding != _target.Padding
|| _defaultColor != _target.DefaultColor
|| !ReferenceEquals(_datFont, _target.DatFont)
|| !ReferenceEquals(_bitmapFont, _target.Font)
|| !ReferenceEquals(_fontColorPalette, _target.FontColorPalette);
private void CaptureLayout()
{
_width = _target.Width;
_padding = _target.Padding;
_defaultColor = _target.DefaultColor;
_datFont = _target.DatFont;
_bitmapFont = _target.Font;
_fontColorPalette = _target.FontColorPalette;
}
}

View file

@ -1193,18 +1193,19 @@ public sealed class RetailUiRuntime : IDisposable
}
if (rootInfo is null || layout is null) return;
CharacterController.Bind(
CharacterInformationUiController controller = CharacterController.Bind(
layout,
_bindings.Character.Provider.BuildSheet,
_bindings.Assets.DefaultFont,
() => CloseWindow(WindowNames.CharacterInformation),
strings);
strings,
_bindings.Character.Provider.SubscribeChanged);
RegisterIndicatorDetailPanel(
RetailPanelCatalog.CharacterInformation,
WindowNames.CharacterInformation,
rootInfo,
layout.Root,
controller: null);
controller);
}
private void MountLinkStatusPanel()