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

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

View file

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

View 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.

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

View file

@ -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()
{
}
}
}

View 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);
}
}