perf(ui): retain shaped report text
This commit is contained in:
parent
c0bec56dfe
commit
f2a015be8e
11 changed files with 525 additions and 38 deletions
|
|
@ -267,7 +267,7 @@ hooks, collision, landing, and movement-wire cadence remain unchanged.
|
||||||
|
|
||||||
## #236 — UiText re-runs full word-wrap shaping every visible frame
|
## #236 — UiText re-runs full word-wrap shaping every visible frame
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** DONE — 2026-07-25, Modern Runtime Slice H-a1
|
||||||
**Severity:** MEDIUM
|
**Severity:** MEDIUM
|
||||||
**Filed:** 2026-07-24
|
**Filed:** 2026-07-24
|
||||||
**Component:** ui (retained)
|
**Component:** ui (retained)
|
||||||
|
|
@ -291,6 +291,14 @@ to plan Slice H-a.
|
||||||
`src/AcDream.App/UI/Layout/ItemAppraisalReport.cs:137-183`;
|
`src/AcDream.App/UI/Layout/ItemAppraisalReport.cs:137-183`;
|
||||||
`src/AcDream.App/UI/Layout/CharacterStatController.cs:297,306`.
|
`src/AcDream.App/UI/Layout/CharacterStatController.cs:297,306`.
|
||||||
|
|
||||||
|
**Resolution:** `UiTextLayoutCache<T>` now retains shaped lines across stable
|
||||||
|
draws and invalidates on semantic content, width, padding, color, palette, or
|
||||||
|
font changes. Appraisal and effect controllers publish content through that
|
||||||
|
cache; character information is invalidated by the current character's
|
||||||
|
object/local-state events and panel-show lifecycle. Stable provider polling is
|
||||||
|
covered by a zero-managed-allocation test. Evidence:
|
||||||
|
`docs/research/2026-07-25-slice-h-a1-ui-text-cache.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #237 — PhysicsEngine allocates a fresh Transition graph per resolve call
|
## #237 — PhysicsEngine allocates a fresh Transition graph per resolve call
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Do not reset history and do not revert G3. The portal-warmup corrections
|
||||||
|
|
||||||
## 2. H-a — retained UI, live attachments, diagnostics, and frame scratch
|
## 2. H-a — retained UI, live attachments, diagnostics, and frame scratch
|
||||||
|
|
||||||
### H-a1 — cached shaped text
|
### H-a1 — cached shaped text — COMPLETE
|
||||||
|
|
||||||
1. Add a small retained text-layout cache keyed by source revision/value plus
|
1. Add a small retained text-layout cache keyed by source revision/value plus
|
||||||
width, padding, color, and font identity.
|
width, padding, color, and font identity.
|
||||||
|
|
@ -42,6 +42,9 @@ Do not reset history and do not revert G3. The portal-warmup corrections
|
||||||
Gate: the existing appraisal/character/effect behavior tests remain unchanged,
|
Gate: the existing appraisal/character/effect behavior tests remain unchanged,
|
||||||
and repeated stable provider polls allocate no report/wrap objects.
|
and repeated stable provider polls allocate no report/wrap objects.
|
||||||
|
|
||||||
|
Landed evidence:
|
||||||
|
[`../research/2026-07-25-slice-h-a1-ui-text-cache.md`](../research/2026-07-25-slice-h-a1-ui-text-cache.md).
|
||||||
|
|
||||||
### H-a2 — visible cooldown participants
|
### H-a2 — visible cooldown participants
|
||||||
|
|
||||||
1. Keep the shared retail heartbeat and one cooldown result per represented
|
1. Keep the shared retail heartbeat and one cooldown result per represented
|
||||||
|
|
|
||||||
39
docs/research/2026-07-25-slice-h-a1-ui-text-cache.md
Normal file
39
docs/research/2026-07-25-slice-h-a1-ui-text-cache.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Slice H-a1 — retained UI text cache gate
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`UiText` deliberately asks its `LinesProvider` for current content when it
|
||||||
|
draws. Appraisal, character information, and effect details had bound that
|
||||||
|
cheap question directly to expensive report construction and word wrapping.
|
||||||
|
At uncapped presentation rates, an unchanged visible panel rebuilt strings,
|
||||||
|
line lists, and wrapping measurements every frame.
|
||||||
|
|
||||||
|
## Implemented ownership
|
||||||
|
|
||||||
|
- `UiTextLayoutCache<T>` owns one semantic value and its shaped line
|
||||||
|
projection.
|
||||||
|
- A projection is reused while content, width, padding, default color, DAT
|
||||||
|
font, bitmap font, and appraisal font-color palette are unchanged.
|
||||||
|
- Appraisal publishes item reports and string fields through caches.
|
||||||
|
- Effect selection/enchantment events publish the detail string once.
|
||||||
|
- Character information subscribes to the current character's object/property
|
||||||
|
and local-character state. It rebuilds the report on the next draw after an
|
||||||
|
event or after the panel is shown.
|
||||||
|
- `UiText.LinesProvider` remains the renderer seam. Dynamic one-line fields are
|
||||||
|
not globally cached and no draw/input behavior changed.
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
- Focused appraisal, character, effects, and cache tests: 43 passed.
|
||||||
|
- App Release: 3,779 passed / 3 skipped.
|
||||||
|
- Complete Release solution: 8,263 passed / 5 skipped.
|
||||||
|
- A 1,000-poll stable-provider test observes zero managed bytes allocated on
|
||||||
|
the calling thread.
|
||||||
|
- Tests prove value-equal strings reuse the same line collection, source
|
||||||
|
changes rebuild it, and resize changes reshape without rebuilding the
|
||||||
|
character report.
|
||||||
|
- `IndicatorDetailText.Shape` and `ItemAppraisalTextLayout.Shape` no longer sit
|
||||||
|
directly behind an uncached per-frame provider.
|
||||||
|
|
||||||
|
The complete build remained warning-free for production projects; the solution
|
||||||
|
test build reported the same 16 pre-existing test-project warnings.
|
||||||
|
|
@ -87,6 +87,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private readonly UiElement _spellFormulaHost;
|
private readonly UiElement _spellFormulaHost;
|
||||||
private readonly UiTextureElement _spellIcon;
|
private readonly UiTextureElement _spellIcon;
|
||||||
private readonly List<UiElement> _spellFormulaCells = [];
|
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 _spellFormulaOriginX;
|
||||||
private readonly float _spellFormulaCellWidth;
|
private readonly float _spellFormulaCellWidth;
|
||||||
private AppraisalView _activeView;
|
private AppraisalView _activeView;
|
||||||
|
|
@ -173,8 +176,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
|
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
|
||||||
_close = layout.FindElement(CloseId) as UiButton;
|
_close = layout.FindElement(CloseId) as UiButton;
|
||||||
|
|
||||||
_title.LinesProvider = () =>
|
BindTextSource(
|
||||||
[new UiText.Line(_titleValue, _title.DefaultColor)];
|
_title,
|
||||||
|
() => _titleValue,
|
||||||
|
static (target, value) =>
|
||||||
|
[new UiText.Line(value, target.DefaultColor)]);
|
||||||
// ItemExamineUI::AddItemInfo @ 0x004AC050 appends the freshly
|
// ItemExamineUI::AddItemInfo @ 0x004AC050 appends the freshly
|
||||||
// rebuilt report from the beginning of the authored text surface.
|
// rebuilt report from the beginning of the authored text surface.
|
||||||
// LayoutDesc 0x2100006B carries Bottom here, which is appropriate for
|
// LayoutDesc 0x2100006B carries Bottom here, which is appropriate for
|
||||||
|
|
@ -200,8 +206,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
scrollbar.Model = _inscriptionField.Scroll;
|
scrollbar.Model = _inscriptionField.Scroll;
|
||||||
}
|
}
|
||||||
if (_signature is not null)
|
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)
|
if (_close is not null)
|
||||||
_close.OnClick = close;
|
_close.OnClick = close;
|
||||||
if (_inscriptionBackground is not null)
|
if (_inscriptionBackground is not null)
|
||||||
|
|
@ -717,11 +728,49 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
text.PreserveEndOnLayout = false;
|
text.PreserveEndOnLayout = false;
|
||||||
text.WheelScrollEnabled = true;
|
text.WheelScrollEnabled = true;
|
||||||
text.ClickThrough = false;
|
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)
|
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
|
||||||
scrollbar.Model = text.Scroll;
|
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(
|
private void ConfigureScrollableItemText(
|
||||||
UiText text,
|
UiText text,
|
||||||
uint scrollbarId)
|
uint scrollbarId)
|
||||||
|
|
@ -729,8 +778,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
text.PreserveEndOnLayout = false;
|
text.PreserveEndOnLayout = false;
|
||||||
text.WheelScrollEnabled = true;
|
text.WheelScrollEnabled = true;
|
||||||
text.ClickThrough = false;
|
text.ClickThrough = false;
|
||||||
text.LinesProvider = () =>
|
_itemTextLayout = new UiTextLayoutCache<ItemAppraisalReport>(
|
||||||
ItemAppraisalTextLayout.Shape(text, _itemReport);
|
text,
|
||||||
|
static (target, report) =>
|
||||||
|
ItemAppraisalTextLayout.Shape(target, report),
|
||||||
|
() => _itemReport,
|
||||||
|
ReferenceEqualityComparer.Instance);
|
||||||
|
text.LinesProvider = _itemTextLayout.Provider;
|
||||||
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
|
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
|
||||||
scrollbar.Model = text.Scroll;
|
scrollbar.Model = text.Scroll;
|
||||||
}
|
}
|
||||||
|
|
@ -746,8 +800,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetSpellText(UiText text, string value)
|
private void SetSpellText(UiText text, string value)
|
||||||
=> text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
|
=> GetTextLayout(text).SetValue(value);
|
||||||
|
|
||||||
private static string BuildSpellDisplay(
|
private static string BuildSpellDisplay(
|
||||||
SpellMetadata metadata,
|
SpellMetadata metadata,
|
||||||
|
|
@ -811,7 +865,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
text.PreserveEndOnLayout = false;
|
text.PreserveEndOnLayout = false;
|
||||||
text.WheelScrollEnabled = scrollable;
|
text.WheelScrollEnabled = scrollable;
|
||||||
text.ClickThrough = !scrollable;
|
text.ClickThrough = !scrollable;
|
||||||
text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
|
GetTextLayout(text).SetValue(value);
|
||||||
if (scrollable)
|
if (scrollable)
|
||||||
{
|
{
|
||||||
UiScrollbar? scrollbar = Descendants(text.Parent)
|
UiScrollbar? scrollbar = Descendants(text.Parent)
|
||||||
|
|
@ -830,7 +884,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
|
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
|
||||||
})
|
})
|
||||||
if (_layout.FindElement(id) is UiText text)
|
if (_layout.FindElement(id) is UiText text)
|
||||||
text.LinesProvider = static () => Array.Empty<UiText.Line>();
|
GetTextLayout(text).SetValue(string.Empty);
|
||||||
_creatureStats?.Flush();
|
_creatureStats?.Flush();
|
||||||
_creatureExtra?.Flush();
|
_creatureExtra?.Flush();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,12 +67,13 @@ public static class CharacterController
|
||||||
internal static IReadOnlyList<string> AugmentationStringKeys
|
internal static IReadOnlyList<string> AugmentationStringKeys
|
||||||
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
|
=> Augmentations.Select(descriptor => descriptor.StringKey).ToArray();
|
||||||
|
|
||||||
public static void Bind(
|
public static CharacterInformationUiController Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Func<CharacterSheet> data,
|
Func<CharacterSheet> data,
|
||||||
UiDatFont? datFont = null,
|
UiDatFont? datFont = null,
|
||||||
Action? close = null,
|
Action? close = null,
|
||||||
CharacterInfoStrings? strings = null)
|
CharacterInfoStrings? strings = null,
|
||||||
|
Func<Action, IDisposable>? subscribeChanged = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(layout);
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
ArgumentNullException.ThrowIfNull(data);
|
ArgumentNullException.ThrowIfNull(data);
|
||||||
|
|
@ -96,11 +97,19 @@ public static class CharacterController
|
||||||
|
|
||||||
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
|
CharacterInfoStrings copy = strings ?? CharacterInfoStrings.English;
|
||||||
text.PreserveEndOnLayout = false;
|
text.PreserveEndOnLayout = false;
|
||||||
text.LinesProvider = () => IndicatorDetailText.Shape(text, BuildReport(data(), copy));
|
UiScrollbar? scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar;
|
||||||
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
|
if (scrollbar is not null)
|
||||||
scrollbar.Model = text.Scroll;
|
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;
|
closeButton.OnClick = close;
|
||||||
|
return new CharacterInformationUiController(
|
||||||
|
text,
|
||||||
|
data,
|
||||||
|
copy,
|
||||||
|
subscribeChanged,
|
||||||
|
scrollbar,
|
||||||
|
closeButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
|
internal static string BuildReport(CharacterSheet sheet, CharacterInfoStrings strings)
|
||||||
|
|
@ -330,6 +339,69 @@ public static class CharacterController
|
||||||
bool IncludeValue);
|
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(
|
public sealed record CharacterInfoStrings(
|
||||||
string[] Birth,
|
string[] Birth,
|
||||||
string[] Played,
|
string[] Played,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ public sealed class CharacterSheetProvider
|
||||||
_sendTrainSkill = sendTrainSkill;
|
_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 ─────────────────────────────────────────────────────
|
// ── Sheet assembly ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
|
/// <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;
|
: _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>
|
/// <summary>
|
||||||
/// Load the portal ExperienceTable (0x0E000018), falling back to a
|
/// Load the portal ExperienceTable (0x0E000018), falling back to a
|
||||||
/// type scan for older or odd dat collections. Failures are logged —
|
/// type scan for older or odd dat collections. Failures are logged —
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ public sealed class EffectsUiController : IRetainedPanelController
|
||||||
private readonly string _selectPrompt;
|
private readonly string _selectPrompt;
|
||||||
private readonly UiItemList _list;
|
private readonly UiItemList _list;
|
||||||
private readonly UiText? _info;
|
private readonly UiText? _info;
|
||||||
|
private readonly UiTextLayoutCache<string>? _infoLayout;
|
||||||
private readonly UiButton? _close;
|
private readonly UiButton? _close;
|
||||||
private readonly UiScrollbar? _listScrollbar;
|
private readonly UiScrollbar? _listScrollbar;
|
||||||
private readonly UiScrollbar? _infoScrollbar;
|
private readonly UiScrollbar? _infoScrollbar;
|
||||||
|
|
@ -70,7 +71,7 @@ public sealed class EffectsUiController : IRetainedPanelController
|
||||||
_list.CellHeight = templates.Height;
|
_list.CellHeight = templates.Height;
|
||||||
if (_listScrollbar is not null)
|
if (_listScrollbar is not null)
|
||||||
_listScrollbar.Model = _list.Scroll;
|
_listScrollbar.Model = _list.Scroll;
|
||||||
ConfigureInfo();
|
_infoLayout = ConfigureInfo();
|
||||||
_spellbook.EnchantmentsChanged += Rebuild;
|
_spellbook.EnchantmentsChanged += Rebuild;
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +156,7 @@ public sealed class EffectsUiController : IRetainedPanelController
|
||||||
&& !_rows.Values.Any(row => row.Slot.EntryId == selected))
|
&& !_rows.Values.Any(row => row.Slot.EntryId == selected))
|
||||||
_selectedSpellId = null;
|
_selectedSpellId = null;
|
||||||
SyncSelection();
|
SyncSelection();
|
||||||
|
UpdateInfoText();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
|
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
|
||||||
|
|
@ -189,6 +191,7 @@ public sealed class EffectsUiController : IRetainedPanelController
|
||||||
// token's spell stat, not its enchantment layer identity.
|
// token's spell stat, not its enchantment layer identity.
|
||||||
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
|
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
|
||||||
SyncSelection();
|
SyncSelection();
|
||||||
|
UpdateInfoText();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SyncSelection()
|
private void SyncSelection()
|
||||||
|
|
@ -197,30 +200,39 @@ public sealed class EffectsUiController : IRetainedPanelController
|
||||||
row.Slot.SetSelected(row.Slot.EntryId == _selectedSpellId);
|
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.PreserveEndOnLayout = false;
|
||||||
_info.WheelScrollEnabled = true;
|
_info.WheelScrollEnabled = true;
|
||||||
_info.ClickThrough = false;
|
_info.ClickThrough = false;
|
||||||
if (_infoScrollbar is not null)
|
if (_infoScrollbar is not null)
|
||||||
_infoScrollbar.Model = _info.Scroll;
|
_infoScrollbar.Model = _info.Scroll;
|
||||||
_info.LinesProvider = () =>
|
var cache = new UiTextLayoutCache<string>(
|
||||||
{
|
|
||||||
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,
|
_info,
|
||||||
metadata.Name + "\n\n" + metadata.Description);
|
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))
|
||||||
|
{
|
||||||
|
// gmEffectsUI::UpdateSelection @ 0x004B7F90 supplies one literal
|
||||||
|
// name + blank line + description to UIElement_Text::SetText.
|
||||||
|
value = metadata.Name + "\n\n" + metadata.Description;
|
||||||
|
}
|
||||||
|
_infoLayout.SetValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnShown() => Rebuild();
|
public void OnShown() => Rebuild();
|
||||||
|
|
|
||||||
115
src/AcDream.App/UI/Layout/UiTextLayoutCache.cs
Normal file
115
src/AcDream.App/UI/Layout/UiTextLayoutCache.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1193,18 +1193,19 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
if (rootInfo is null || layout is null) return;
|
if (rootInfo is null || layout is null) return;
|
||||||
|
|
||||||
CharacterController.Bind(
|
CharacterInformationUiController controller = CharacterController.Bind(
|
||||||
layout,
|
layout,
|
||||||
_bindings.Character.Provider.BuildSheet,
|
_bindings.Character.Provider.BuildSheet,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
() => CloseWindow(WindowNames.CharacterInformation),
|
() => CloseWindow(WindowNames.CharacterInformation),
|
||||||
strings);
|
strings,
|
||||||
|
_bindings.Character.Provider.SubscribeChanged);
|
||||||
RegisterIndicatorDetailPanel(
|
RegisterIndicatorDetailPanel(
|
||||||
RetailPanelCatalog.CharacterInformation,
|
RetailPanelCatalog.CharacterInformation,
|
||||||
WindowNames.CharacterInformation,
|
WindowNames.CharacterInformation,
|
||||||
rootInfo,
|
rootInfo,
|
||||||
layout.Root,
|
layout.Root,
|
||||||
controller: null);
|
controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MountLinkStatusPanel()
|
private void MountLinkStatusPanel()
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,46 @@ public sealed class CharacterControllerTests
|
||||||
text => text.EventId == CharacterController.MainTextId);
|
text => text.EventId == CharacterController.MainTextId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stable_draws_reuse_report_until_source_or_layout_changes()
|
||||||
|
{
|
||||||
|
ImportedLayout layout = FixtureLoader.LoadCharacterInformation();
|
||||||
|
int builds = 0;
|
||||||
|
int deaths = 1;
|
||||||
|
Action? changed = null;
|
||||||
|
using CharacterInformationUiController controller =
|
||||||
|
CharacterController.Bind(
|
||||||
|
layout,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
builds++;
|
||||||
|
return new CharacterSheet { Deaths = deaths };
|
||||||
|
},
|
||||||
|
subscribeChanged: handler =>
|
||||||
|
{
|
||||||
|
changed = handler;
|
||||||
|
return new TestSubscription();
|
||||||
|
});
|
||||||
|
UiText text = Assert.IsType<UiText>(
|
||||||
|
layout.FindElement(CharacterController.MainTextId));
|
||||||
|
|
||||||
|
IReadOnlyList<UiText.Line> first = text.LinesProvider();
|
||||||
|
IReadOnlyList<UiText.Line> second = text.LinesProvider();
|
||||||
|
Assert.Same(first, second);
|
||||||
|
Assert.Equal(1, builds);
|
||||||
|
|
||||||
|
deaths = 2;
|
||||||
|
changed!();
|
||||||
|
IReadOnlyList<UiText.Line> changedLines = text.LinesProvider();
|
||||||
|
Assert.NotSame(first, changedLines);
|
||||||
|
Assert.Equal(2, builds);
|
||||||
|
|
||||||
|
text.Width -= 24f;
|
||||||
|
IReadOnlyList<UiText.Line> resized = text.LinesProvider();
|
||||||
|
Assert.NotSame(changedLines, resized);
|
||||||
|
Assert.Equal(2, builds);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Report(CharacterSheet sheet)
|
private static string Report(CharacterSheet sheet)
|
||||||
=> CharacterController.BuildReport(sheet, CharacterInfoStrings.English);
|
=> CharacterController.BuildReport(sheet, CharacterInfoStrings.English);
|
||||||
|
|
||||||
|
|
@ -101,4 +141,11 @@ public sealed class CharacterControllerTests
|
||||||
private static ImportedLayout FakeLayout()
|
private static ImportedLayout FakeLayout()
|
||||||
=> new(new UiPanel { Width = 300f, Height = 362f },
|
=> new(new UiPanel { Width = 300f, Height = 362f },
|
||||||
new Dictionary<uint, UiElement>());
|
new Dictionary<uint, UiElement>());
|
||||||
|
|
||||||
|
private sealed class TestSubscription : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs
Normal file
79
tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
public sealed class UiTextLayoutCacheTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Stable_content_and_layout_borrow_same_shaped_lines()
|
||||||
|
{
|
||||||
|
var text = new UiText { Width = 120f };
|
||||||
|
int shapes = 0;
|
||||||
|
var cache = new UiTextLayoutCache<string>(
|
||||||
|
text,
|
||||||
|
(target, value) =>
|
||||||
|
{
|
||||||
|
shapes++;
|
||||||
|
return IndicatorDetailText.Shape(target, value);
|
||||||
|
},
|
||||||
|
"alpha beta gamma",
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
IReadOnlyList<UiText.Line> first = cache.GetLines();
|
||||||
|
IReadOnlyList<UiText.Line> second = cache.GetLines();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
Assert.Equal(1, shapes);
|
||||||
|
|
||||||
|
cache.SetValue(new string("alpha beta gamma".ToCharArray()));
|
||||||
|
Assert.Same(first, cache.GetLines());
|
||||||
|
Assert.Equal(1, shapes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Content_width_and_font_inputs_invalidate_shaping()
|
||||||
|
{
|
||||||
|
var text = new UiText { Width = 120f };
|
||||||
|
int shapes = 0;
|
||||||
|
var cache = new UiTextLayoutCache<string>(
|
||||||
|
text,
|
||||||
|
(target, value) =>
|
||||||
|
{
|
||||||
|
shapes++;
|
||||||
|
return IndicatorDetailText.Shape(target, value);
|
||||||
|
},
|
||||||
|
"alpha beta gamma",
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
IReadOnlyList<UiText.Line> first = cache.GetLines();
|
||||||
|
cache.SetValue("delta epsilon");
|
||||||
|
IReadOnlyList<UiText.Line> changed = cache.GetLines();
|
||||||
|
text.Width = 48f;
|
||||||
|
IReadOnlyList<UiText.Line> resized = cache.GetLines();
|
||||||
|
|
||||||
|
Assert.NotSame(first, changed);
|
||||||
|
Assert.NotSame(changed, resized);
|
||||||
|
Assert.Equal(3, shapes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stable_provider_poll_allocates_no_managed_memory()
|
||||||
|
{
|
||||||
|
var text = new UiText { Width = 120f };
|
||||||
|
var cache = new UiTextLayoutCache<string>(
|
||||||
|
text,
|
||||||
|
static (target, value) => IndicatorDetailText.Shape(target, value),
|
||||||
|
"alpha beta gamma",
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
Func<IReadOnlyList<UiText.Line>> provider = cache.Provider;
|
||||||
|
_ = provider();
|
||||||
|
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
for (int i = 0; i < 1_000; i++)
|
||||||
|
_ = provider();
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
|
||||||
|
Assert.Equal(0L, allocated);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue