diff --git a/docs/ISSUES.md b/docs/ISSUES.md index be8739fc..69911ba4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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` 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 diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md index b812ac12..5deffbb6 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-h.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md @@ -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 diff --git a/docs/research/2026-07-25-slice-h-a1-ui-text-cache.md b/docs/research/2026-07-25-slice-h-a1-ui-text-cache.md new file mode 100644 index 00000000..a50c80da --- /dev/null +++ b/docs/research/2026-07-25-slice-h-a1-ui-text-cache.md @@ -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` 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. diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 064643b7..a3052bd7 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -87,6 +87,9 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly UiElement _spellFormulaHost; private readonly UiTextureElement _spellIcon; private readonly List _spellFormulaCells = []; + private readonly Dictionary> + _textLayouts = new(); + private UiTextLayoutCache _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 source, + Func> shape) + { + var cache = new UiTextLayoutCache( + text, + shape, + source, + StringComparer.Ordinal); + _textLayouts.Add(text, cache); + text.LinesProvider = cache.Provider; + } + + private UiTextLayoutCache GetTextLayout(UiText text) + { + if (_textLayouts.TryGetValue( + text, + out UiTextLayoutCache? cache)) + { + return cache; + } + + cache = new UiTextLayoutCache( + 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( + 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(); + GetTextLayout(text).SetValue(string.Empty); _creatureStats?.Flush(); _creatureExtra?.Flush(); } diff --git a/src/AcDream.App/UI/Layout/CharacterController.cs b/src/AcDream.App/UI/Layout/CharacterController.cs index 10bf522a..6c60dc15 100644 --- a/src/AcDream.App/UI/Layout/CharacterController.cs +++ b/src/AcDream.App/UI/Layout/CharacterController.cs @@ -67,12 +67,13 @@ public static class CharacterController internal static IReadOnlyList AugmentationStringKeys => Augmentations.Select(descriptor => descriptor.StringKey).ToArray(); - public static void Bind( + public static CharacterInformationUiController Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, Action? close = null, - CharacterInfoStrings? strings = null) + CharacterInfoStrings? strings = null, + Func? 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); } +/// +/// Event-invalidated report owner for retail's character-information text. +/// Stable frames borrow the same shaped line collection. +/// +public sealed class CharacterInformationUiController : IRetainedPanelController +{ + private readonly Func _data; + private readonly CharacterInfoStrings _strings; + private readonly UiTextLayoutCache _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 data, + CharacterInfoStrings strings, + Func? subscribeChanged, + UiScrollbar? scrollbar, + UiButton? close) + { + _data = data; + _strings = strings; + _scrollbar = scrollbar; + _close = close; + _layout = new UiTextLayoutCache( + text, + static (target, value) => IndicatorDetailText.Shape(target, value), + string.Empty, + StringComparer.Ordinal); + text.LinesProvider = GetLines; + _changeBinding = subscribeChanged?.Invoke(InvalidateContent); + } + + private IReadOnlyList 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, diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 7b5c10d7..dc390f97 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -78,6 +78,17 @@ public sealed class CharacterSheetProvider _sendTrainSkill = sendTrainSkill; } + /// + /// Subscribe to semantic inputs used by . The + /// returned binding owns every event registration and filters object-table + /// notices to the current player GUID. + /// + public IDisposable SubscribeChanged(Action changed) + { + ArgumentNullException.ThrowIfNull(changed); + return new ChangeBinding(this, changed); + } + // ── Sheet assembly ───────────────────────────────────────────────────── /// Best display name: active toon key, else the live object's name, else "Player". @@ -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; + } + } + /// /// Load the portal ExperienceTable (0x0E000018), falling back to a /// type scan for older or odd dat collections. Failures are logged — diff --git a/src/AcDream.App/UI/Layout/EffectsUiController.cs b/src/AcDream.App/UI/Layout/EffectsUiController.cs index 3d45a5d5..3ab84179 100644 --- a/src/AcDream.App/UI/Layout/EffectsUiController.cs +++ b/src/AcDream.App/UI/Layout/EffectsUiController.cs @@ -32,6 +32,7 @@ public sealed class EffectsUiController : IRetainedPanelController private readonly string _selectPrompt; private readonly UiItemList _list; private readonly UiText? _info; + private readonly UiTextLayoutCache? _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 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? 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( + _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() - .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(); diff --git a/src/AcDream.App/UI/Layout/UiTextLayoutCache.cs b/src/AcDream.App/UI/Layout/UiTextLayoutCache.cs new file mode 100644 index 00000000..ba2dd571 --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiTextLayoutCache.cs @@ -0,0 +1,115 @@ +using System.Numerics; +using AcDream.App.Rendering; + +namespace AcDream.App.UI.Layout; + +/// +/// Retains the shaped line projection of one value. +/// Controllers publish semantic content; the cache reshapes only when that +/// content or the text widget's layout/font inputs change. +/// +internal sealed class UiTextLayoutCache +{ + private readonly UiText _target; + private readonly Func> _shape; + private readonly IEqualityComparer _comparer; + private readonly Func? _source; + private T _value = default!; + private bool _hasValue; + private bool _shaped; + private IReadOnlyList _lines = Array.Empty(); + private float _width; + private float _padding; + private Vector4 _defaultColor; + private UiDatFont? _datFont; + private BitmapFont? _bitmapFont; + private IReadOnlyList? _fontColorPalette; + + public UiTextLayoutCache( + UiText target, + Func> shape, + T initialValue, + IEqualityComparer? comparer = null) + : this(target, shape, source: null, comparer, initialize: true) + { + SetValue(initialValue); + } + + public UiTextLayoutCache( + UiText target, + Func> shape, + Func source, + IEqualityComparer? comparer = null) + : this( + target, + shape, + source ?? throw new ArgumentNullException(nameof(source)), + comparer, + initialize: true) + { + } + + private UiTextLayoutCache( + UiText target, + Func> shape, + Func? source, + IEqualityComparer? comparer, + bool initialize) + { + _ = initialize; + _target = target ?? throw new ArgumentNullException(nameof(target)); + _shape = shape ?? throw new ArgumentNullException(nameof(shape)); + _source = source; + _comparer = comparer ?? EqualityComparer.Default; + } + + public Func> 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 GetLines() + { + if (_source is not null) + SetValue(_source()); + + if (!_hasValue) + return Array.Empty(); + + 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; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index c1fb9aa5..a1b7cce2 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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() diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs index 534093c8..be4e4d60 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs @@ -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( + layout.FindElement(CharacterController.MainTextId)); + + IReadOnlyList first = text.LinesProvider(); + IReadOnlyList second = text.LinesProvider(); + Assert.Same(first, second); + Assert.Equal(1, builds); + + deaths = 2; + changed!(); + IReadOnlyList changedLines = text.LinesProvider(); + Assert.NotSame(first, changedLines); + Assert.Equal(2, builds); + + text.Width -= 24f; + IReadOnlyList 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()); + + private sealed class TestSubscription : IDisposable + { + public void Dispose() + { + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs new file mode 100644 index 00000000..4a097bf0 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiTextLayoutCacheTests.cs @@ -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( + text, + (target, value) => + { + shapes++; + return IndicatorDetailText.Shape(target, value); + }, + "alpha beta gamma", + StringComparer.Ordinal); + + IReadOnlyList first = cache.GetLines(); + IReadOnlyList 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( + text, + (target, value) => + { + shapes++; + return IndicatorDetailText.Shape(target, value); + }, + "alpha beta gamma", + StringComparer.Ordinal); + + IReadOnlyList first = cache.GetLines(); + cache.SetValue("delta epsilon"); + IReadOnlyList changed = cache.GetLines(); + text.Width = 48f; + IReadOnlyList 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( + text, + static (target, value) => IndicatorDetailText.Shape(target, value), + "alpha beta gamma", + StringComparer.Ordinal); + Func> 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); + } +}