Owner request: plugin panels (Decal/VirindiViewService-class, per the
MosswartMassacre reference usage) need to embed real DAT icons the way
FlagTrackerView.SafeSetListImage does — spell/skill art, raw portal
indices, and a window icon. This is Slice B of
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md (Slice A, the
movable/collapsible shelf, landed in 01b98ca30/4fada238e/718005b21).
What shipped:
- AcDream.Plugin.Abstractions.PluginIcons.Normalize: the one Decal-style
bare-index -> 0x06xxxxxx RenderSurface DID grammar, applied at every
icon SINK (descriptor IconSurfaceId in PluginShelfButton, and markup
<icon did>/<button icon>/<list icons> did-kind ids) rather than on the
plugin-facing records, which already carry real DIDs read straight
from the client's tables.
- PluginSpellInfo.IconId / PluginSkillInfo.IconId /
PluginInventoryItem.IconId / PluginWorldObject.IconId: additive init
properties (default 0), filled in AppAutomationSurface from
SpellMetadata.IconId (already projected from SpellBase.Icon by
RetailSpellMetadataProjector — no gap there), a new BindSkillIcons
parallel to BindSkillNames (GameWindow reads
DatReaderWriter.Types.SkillBase.IconId — confirmed via reflection over
the installed Chorizite.DatReaderWriter package, since its XML docs
don't cover Pack/Unpack-generated public fields: Description, Name,
IconId (uint), TrainedCost, SpecializedCost, Category, ChargenUse,
MinLevel, Formula, UpperBound, LowerBound, LearnMod), and
ClientObject.IconId in CaptureOwnedItems/ProjectWorldObject.
- IMarkupIconResolver (AcDream.App.UI): ResolveDid/ResolveSpell/
ResolveItem. MarkupDocument.Build gains an optional parameter (null by
default -> every icon sink resolves to nothing rather than throwing,
so pre-Slice-B callers/tests are unaffected). RetailUiRuntime.
MountPlugins builds ONE RetailMarkupIconResolver per pass from
RetailUiAssets.ResolveSprite + RetailUiAssets.Icons (the shared
IconComposer) + Toolbar.Objects (the SAME ClientObjectTable
Magic/Toolbar bindings already borrow for their own icon resolution —
no second object lookup introduced).
- New UiMarkupIcon widget (<icon x y w h did|spell|item tooltip>):
exactly one source required (FormatException at Build otherwise,
matching every other malformed-attribute rule), aspect-preserved,
centered, click-through unless a tooltip makes it a real hit-test
target.
- UiSimpleButton.IconSource and UiMarkupList.IconIdsSource/IconResolve:
additive, default null/no-op, so every existing button/list caller
(including the plugin shelf's own toggle/minimize buttons) is
unaffected. Button icon draws flush left and shifts the caption's
centering region right; list icons reserve a leading RowHeight-2
column (Decal's IconColumn) and skip rows whose id is 0 or
unresolvable.
- MarkupDocument centralizes the did/spell/item dispatch (including
PluginIcons.Normalize for did) in two small helpers (BuildIconSource
for <icon>/<button>, BuildRowIconResolve for <list>) so all three
markup surfaces share one resolver call path.
- AcDream.Plugins.Smoke ships a RegisterPanelContent (in-memory KSML,
no plugin-side .xml file) proof panel exercising every new surface:
a bare-index <icon>, a literal-hex <icon>, a composited <icon
spell=...>, a <button icon=...>, and a <list icons=... iconkind=
spell> of the first five known self-buffs with their IconId printed
alongside. Descriptor IconSurfaceId reuses the same bare index to
prove the shelf button and the panel's own icon normalize identically.
- docs/plugin-ui-markup.md is the new SSOT for the full markup
vocabulary + icon grammar + the Slice A shelf; linked from
docs/README.md and docs/plans/2026-04-24-ui-framework.md.
Design decisions where the plan left room:
- Normalize runs inside the resolver dispatch (BuildIconSource/
BuildRowIconResolve), not scattered at each markup call site, so
every did-kind sink shares one choke point.
- did/spell/item all accept either a literal (decimal or 0x-hex) or a
{Binding}, via one BindUintLiteralOrBinding helper, for symmetry —
the plan only showed spell/item as bindings but didn't forbid a
literal.
- <icon> requires exactly one source INCLUDING zero (not just two);
an icon with no source is not a coherent element.
- The button/list icon draw math (icon column extent, padding) lives
in the widgets themselves (UiSimpleButton/UiMarkupList), not in
MarkupDocument, keeping the parser only responsible for wiring
Func<(tex,w,h)> sources.
Tests: PluginIconsTests (Normalize table), MarkupIconTests (icon/button/
list resolver dispatch via a fake IMarkupIconResolver, plus draw-level
pins via the RecordingGpuDevice/TextRenderer apparatus already used by
UiAncestorClipTests/UiRenderContextDrawStringDatOutlineTests — "draws
nothing when unresolvable" and "button/list icon shifts the text"),
and AppAutomationSurfaceIconInstalledDatTests (Lane=InstalledDat: a
known spell's IconId matches the real installed SpellTable's own Icon
field exactly). Verified every new test fails to COMPILE without this
change (git-stashed the src/ changes, rebuilt the test project: CS0246
on IMarkupIconResolver) before restoring. Full App suite: 7331 passed /
97 skipped / 36 failed (identical pre-existing failure set/count to the
7306/97/36 baseline; the +25 passes are exactly the new tests).
AcDream.Plugins.MossTank.Tests (the main consumer of the touched
Plugin.Abstractions records) passes 337/337 unchanged, confirming
API-v1 binary/source compatibility. Full solution builds green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
452 lines
16 KiB
C#
452 lines
16 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.App.UI;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
|
|
/// <c><icon></c>, <c><button icon></c>, and <c><list icons></c>
|
|
/// markup, backed by a fake <see cref="IMarkupIconResolver"/> that records
|
|
/// every call so the resolver-dispatch (did/spell/item, normalized vs raw)
|
|
/// is directly assertable, plus draw-level pins (via the same
|
|
/// <see cref="RecordingGpuDevice"/>/<see cref="TextRenderer"/> apparatus
|
|
/// <c>UiAncestorClipTests</c>/<c>UiRenderContextDrawStringDatOutlineTests</c>
|
|
/// use) for "draws nothing when unresolvable" and "button/list icon shifts
|
|
/// the caption/text column".
|
|
/// </summary>
|
|
public sealed class MarkupIconTests
|
|
{
|
|
private sealed class FakeIconResolver : IMarkupIconResolver
|
|
{
|
|
public readonly List<(string Method, uint Id)> Calls = new();
|
|
public uint DidTexture = 5u;
|
|
public uint SpellTexture = 6u;
|
|
public uint ItemTexture = 7u;
|
|
|
|
public (uint tex, int w, int h) ResolveDid(uint did)
|
|
{
|
|
Calls.Add(("did", did));
|
|
return did == 0u ? (0u, 0, 0) : (DidTexture, 32, 32);
|
|
}
|
|
|
|
public (uint tex, int w, int h) ResolveSpell(uint spellId)
|
|
{
|
|
Calls.Add(("spell", spellId));
|
|
return spellId == 0u ? (0u, 0, 0) : (SpellTexture, 32, 32);
|
|
}
|
|
|
|
public (uint tex, int w, int h) ResolveItem(uint objectId)
|
|
{
|
|
Calls.Add(("item", objectId));
|
|
return objectId == 0u ? (0u, 0, 0) : (ItemTexture, 32, 32);
|
|
}
|
|
}
|
|
|
|
private sealed class IconBinding
|
|
{
|
|
public uint IconDid { get; set; } = 0x06001234u;
|
|
public uint SpellId { get; set; } = 42u;
|
|
public uint ObjectId { get; set; } = 0x50000001u;
|
|
public Action Go => () => { };
|
|
}
|
|
|
|
private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
|
|
|
|
// ── <icon>: source dispatch + the Build-time contract ────────────────────
|
|
|
|
[Fact]
|
|
public void Icon_LiteralBareIndex_NormalizesBeforeResolving()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
|
|
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
(uint tex, int w, int h) = icon.IconSource();
|
|
|
|
Assert.Equal(resolver.DidTexture, tex);
|
|
Assert.Equal(32, w);
|
|
Assert.Equal(32, h);
|
|
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]); // 0x06000000 + 7735
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_LiteralHexDid_AlreadyInRangeIsUnchangedByNormalize()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"0x06002D14\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
|
|
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
icon.IconSource();
|
|
|
|
Assert.Equal(("did", 0x06002D14u), resolver.Calls[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_BoundDid_ReReadsTheBindingEveryFrame()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"{IconDid}\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new IconBinding();
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
|
|
icon.IconSource();
|
|
Assert.Equal(("did", binding.IconDid), resolver.Calls[^1]);
|
|
|
|
binding.IconDid = 0x06005678u;
|
|
icon.IconSource();
|
|
Assert.Equal(("did", 0x06005678u), resolver.Calls[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_Spell_RoutesToResolveSpellWithTheBoundIdUnnormalized()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" spell=\"{SpellId}\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new IconBinding();
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
(uint tex, _, _) = icon.IconSource();
|
|
|
|
Assert.Equal(resolver.SpellTexture, tex);
|
|
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_Item_RoutesToResolveItemWithTheBoundId()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" item=\"{ObjectId}\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new IconBinding();
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
(uint tex, _, _) = icon.IconSource();
|
|
|
|
Assert.Equal(resolver.ItemTexture, tex);
|
|
Assert.Equal(("item", binding.ObjectId), resolver.Calls[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_ZeroId_ResolvesToNothing()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"0\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
|
|
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
(uint tex, int w, int h) = icon.IconSource();
|
|
|
|
Assert.Equal(0u, tex);
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_NoResolverWired_ResolvesToNothingRatherThanThrowing()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
|
|
"</panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, new object(), Sprite);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
(uint tex, int w, int h) = icon.IconSource();
|
|
|
|
Assert.Equal(0u, tex);
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_TwoSources_ThrowsFormatExceptionAtBuild()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"7735\" spell=\"{SpellId}\"/>" +
|
|
"</panel>";
|
|
|
|
Assert.Throws<FormatException>(
|
|
() => MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: new FakeIconResolver()));
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_NoSources_ThrowsFormatExceptionAtBuild()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\"/>" +
|
|
"</panel>";
|
|
|
|
Assert.Throws<FormatException>(
|
|
() => MarkupDocument.Build(xml, new object(), Sprite));
|
|
}
|
|
|
|
[Fact]
|
|
public void Icon_WidthHeightDefaultTo32WhenOmitted()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<icon x=\"0\" y=\"0\" did=\"1\"/>" +
|
|
"</panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, new object(), Sprite);
|
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
|
|
|
Assert.Equal(32f, icon.Width);
|
|
Assert.Equal(32f, icon.Height);
|
|
}
|
|
|
|
// ── <button icon>: resolver dispatch + draw-level "text shifts right" ────
|
|
|
|
[Fact]
|
|
public void ButtonIcon_DefaultsToDidKind_AndDrawsThroughTheResolver()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"7735\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
|
|
var panel = MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: resolver);
|
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
|
|
|
Assert.NotNull(button.IconSource);
|
|
(uint tex, _, _) = button.IconSource!();
|
|
Assert.Equal(resolver.DidTexture, tex);
|
|
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ButtonIcon_KindSpell_RoutesToResolveSpell()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Buff\" " +
|
|
"icon=\"{SpellId}\" iconkind=\"spell\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new IconBinding();
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
|
button.IconSource!();
|
|
|
|
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ButtonWithoutIconAttribute_HasNoIconSource()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
|
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\"/>" +
|
|
"</panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, new object(), Sprite);
|
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
|
|
|
Assert.Null(button.IconSource);
|
|
}
|
|
|
|
// ── <list icons>: resolver dispatch + column reservation ──────────────────
|
|
|
|
private sealed class ListIconBinding
|
|
{
|
|
public IReadOnlyList<string> Items { get; } = new[] { "First", "Second" };
|
|
// Row 0 resolves; row 1's id is 0 (unresolvable) — "missing entries
|
|
// draw no icon" per the plan.
|
|
public IReadOnlyList<uint> IconIds { get; } = new[] { 7735u, 0u };
|
|
public int Selected { get; set; } = -1;
|
|
}
|
|
|
|
[Fact]
|
|
public void ListIcons_ReservesTheColumn_AndResolvesEachRowThroughTheSharedResolver()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
|
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
|
|
"items=\"{Items}\" icons=\"{IconIds}\" iconkind=\"did\" selected=\"{Selected}\"/>" +
|
|
"</panel>";
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new ListIconBinding();
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
|
|
Assert.NotNull(list.IconIdsSource);
|
|
Assert.Equal(binding.IconIds, list.IconIdsSource!());
|
|
Assert.NotNull(list.IconResolve);
|
|
|
|
(uint tex, _, _) = list.IconResolve!(7735u);
|
|
Assert.Equal(resolver.DidTexture, tex);
|
|
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
|
|
|
|
(uint missTex, _, _) = list.IconResolve!(0u);
|
|
Assert.Equal(0u, missTex);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListWithoutIconsAttribute_HasNoIconColumn()
|
|
{
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
|
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" selected=\"{Selected}\"/>" +
|
|
"</panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, new ListIconBinding(), Sprite);
|
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
|
|
Assert.Null(list.IconIdsSource);
|
|
}
|
|
|
|
// ── Draw-level: "draws nothing" / "shifts text" pinned against real quads ─
|
|
|
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
|
{
|
|
public IGpuFrame? CurrentFrame => null;
|
|
}
|
|
|
|
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
|
renderer.Begin(new Vector2(w, h));
|
|
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
|
|
return (renderer, ctx);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiMarkupIcon_ResolvedTexture_DrawsASpriteAspectPreservedAndCentered()
|
|
{
|
|
// 32x16 source in a 32x32 box: scale = min(32/32, 32/16) = 1, so the
|
|
// drawn quad stays 32x16 (never stretched to fill the box) and is
|
|
// vertically centered: y = (32-16)/2 = 8.
|
|
var icon = new UiMarkupIcon
|
|
{
|
|
Width = 32f,
|
|
Height = 32f,
|
|
IconSource = () => (9u, 32, 16),
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
icon.DrawSelfAndChildren(ctx);
|
|
|
|
var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
// First vertex = top-left (x,y); AppendQuad's V0..V5 layout, 8 floats/vertex.
|
|
Assert.Equal(0f, seg.Verts[0], 3);
|
|
Assert.Equal(8f, seg.Verts[1], 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiMarkupIcon_UnresolvedTexture_DrawsNothing()
|
|
{
|
|
var icon = new UiMarkupIcon
|
|
{
|
|
Width = 32f,
|
|
Height = 32f,
|
|
IconSource = () => (0u, 0, 0),
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
icon.DrawSelfAndChildren(ctx);
|
|
|
|
Assert.Empty(renderer.DebugSpriteSegmentVerts);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiSimpleButton_WithIcon_DrawsTheIconSprite_AndShiftsTheCaptionRight()
|
|
{
|
|
var glyphs = new Dictionary<char, FontCharDesc>
|
|
{
|
|
['G'] = new FontCharDesc { Unicode = 'G', Width = 8, Height = 8 },
|
|
};
|
|
var font = new UiDatFont(
|
|
fgTex: 1u, fgW: 32, fgH: 32,
|
|
bgTex: 0, bgW: 0, bgH: 0,
|
|
lineHeight: 16f, baselineOffset: 12f,
|
|
glyphs);
|
|
|
|
var withoutIcon = new UiSimpleButton
|
|
{
|
|
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
|
|
BackgroundColor = default, BorderColor = default,
|
|
};
|
|
var withIcon = new UiSimpleButton
|
|
{
|
|
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
|
|
IconSource = () => (9u, 32, 32),
|
|
BackgroundColor = default, BorderColor = default,
|
|
};
|
|
|
|
var (rendererWithout, ctxWithout) = MakeContext(200f, 200f);
|
|
withoutIcon.DrawSelfAndChildren(ctxWithout);
|
|
var (rendererWith, ctxWith) = MakeContext(200f, 200f);
|
|
withIcon.DrawSelfAndChildren(ctxWith);
|
|
|
|
// The icon sprite itself drew.
|
|
Assert.Contains(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
Assert.DoesNotContain(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
|
|
// Both buttons draw exactly one glyph quad (font texture 1u); the
|
|
// icon-bearing button's caption starts strictly to the right of the
|
|
// icon-less button's, because its centering region was shifted by
|
|
// the reserved icon column.
|
|
var textWithout = Assert.Single(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 1u);
|
|
var textWith = Assert.Single(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 1u);
|
|
Assert.True(
|
|
textWith.Verts[0] > textWithout.Verts[0],
|
|
$"expected the icon-bearing button's caption ({textWith.Verts[0]}) to start right of the icon-less caption ({textWithout.Verts[0]})");
|
|
}
|
|
|
|
[Fact]
|
|
public void UiMarkupList_IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne()
|
|
{
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 180f, Height = 60f, RowHeight = 18f,
|
|
ItemsSource = () => new[] { "First", "Second" },
|
|
IconIdsSource = () => new uint[] { 9u, 0u },
|
|
IconResolve = id => id == 0u ? (0u, 0, 0) : (id, 16, 16),
|
|
BackgroundColor = default, BorderColor = default,
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// Row 0's icon (id 9u) drew; row 1 has no icon id (0u -> unresolvable)
|
|
// and therefore emits no sprite for that row's column.
|
|
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
// The icon column is RowHeight - 2 wide; the drawn quad's right edge
|
|
// (the second AppendQuad vertex's x, at float offset 8 — see
|
|
// UiRenderContextDrawStringDatOutlineTests.DecodeQuads for the same
|
|
// 8-floats/vertex, 6-vertices/quad layout) must sit entirely inside
|
|
// it, never overlapping where row text starts.
|
|
Assert.True(iconQuad.Verts[8] <= list.RowHeight - 2f + 0.01f);
|
|
}
|
|
}
|