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
|
||||
|
||||
**Status:** OPEN
|
||||
**Status:** DONE — 2026-07-25, Modern Runtime Slice H-a1
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-07-24
|
||||
**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/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
### 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
|
||||
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,
|
||||
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
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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;
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -92,6 +92,46 @@ public sealed class CharacterControllerTests
|
|||
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)
|
||||
=> CharacterController.BuildReport(sheet, CharacterInfoStrings.English);
|
||||
|
||||
|
|
@ -101,4 +141,11 @@ public sealed class CharacterControllerTests
|
|||
private static ImportedLayout FakeLayout()
|
||||
=> new(new UiPanel { Width = 300f, Height = 362f },
|
||||
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