Two Opus reviews of commit 8217a349e (Slice B: DAT icons in plugin
markup) found one BLOCKING defect and 14 SHOULD-FIX findings. All 15
fixed here in one commit per the review contract.
BLOCKING (finding 1): an unresolvable did painted a magenta square.
TextureCache.GetOrUploadRenderSurface's 1x1 magenta placeholder for a
missing RenderSurface is load-bearing for authored chrome, but
RetailMarkupIconResolver.ResolveDid only short-circuited did==0, so any
other unresolvable id fell through to that placeholder and got scaled
up by UiMarkupIcon/UiMarkupList/UiSimpleButton -- the classic
resolve(0)-style footgun (claude-memory/feedback_ui_resolve_zero_magenta.md),
just triggered by a missing id instead of a literal 0. Fixed by probing
Portal/HighRes existence via IDatReaderWriter.TryGet<RenderSurface>
BEFORE ever calling GetOrUploadRenderSurface -- that TryGet already
serializes concurrent DAT access internally (DatDatabaseWrapper's own
_databaseLock), the same synchronization IconComposer.TryDecode relies
on, so no additional lock was needed. RetailMarkupIconResolver now
takes IDatReaderWriter + TextureCache directly (RetailUiAssets gained a
TextureCache field, its one construction site in
InteractionRetainedUiComposition.cs updated) instead of the old
resolveSprite delegate, since it also needs the nearest-sampled upload
path for finding 6 below.
Finding 2 -- Smoke panel wiring bugs: its list fed iconkind="spell"
raw art DIDs (PluginSpellInfo.IconId) instead of spell ids, so
IMarkupIconResolver.ResolveSpell composited the wrong (or no) badge
every row. SmokeIconPanel.Binding.SpellIds now yields SpellId (the
printed text still shows IconId alongside). The bare-index demo and the
descriptor both moved from the unverified literal 7735 to 0x165 --
retail's real Melee Defense skill icon (SampleData.cs:64,
0x06000165) -- so the owner's visual gate proves real art, not a guess.
StartVisible flipped true, and a character with no self-buffs known
falls back to spell 1's real catalog entry (or an honest "no spells
known" row with icon 0 if even that fails) rather than fabricating art.
Finding 3 -- PluginIcons.Normalize's threshold was silently rewritten
from the contract's 0x01000000 to 0x06000000 during Slice B. Restored
to 0x01000000; the class/method XML docs now state the number directly
(no cref to the private const); the test table adds 0x02000000 (a value
that only distinguishes the two thresholds) and 0x01000000 itself
(passes through unchanged).
Finding 4 -- an unknown iconkind (e.g. "spel") only threw when a
resolver happened to be wired, because BuildIconSource/
BuildRowIconResolve validated inside their own null-icons early return.
A new ValidateIconKind helper runs UNCONDITIONALLY before that branch,
so a malformed iconkind is a Build-time author error on every host.
Finding 5 -- BindUintLiteralOrBinding required an exact uint property
type, rejecting the int-typed bindings Decal-facing code commonly uses
(MosswartMassacre's HudPictureBox.Image is int end to end). It now
matches BindUint's existing leniency: any property, converted via
Convert.ToUInt32 at read time. BindUintList likewise now accepts
IEnumerable<int> alongside IEnumerable<uint> (unchecked per-element
reinterpret -- icon ids never go negative in practice).
Finding 6 -- TextureCache._renderSurfaceGpuTextures was keyed by id
alone, so whichever caller asked for a given RenderSurface id FIRST won
the sampler for every later caller of the same id -- UiDatFont's glyph
atlases already request nearest:true while ResolveChrome's background
art requests nearest:false, so this was a real, reachable collision,
not hypothetical. Rekeyed to (id, nearest); RetailMarkupIconResolver.
ResolveDid now requests nearest:true (pixel-exact 32x32 icon art);
ResolveChrome is untouched (still nearest:false/linear). Audited every
other _renderSurfaceGpuTextures use site (TryGetValue/set/Dispose
iteration+Clear) plus the separate _nearestUiTextureSources/
_linearUiTwinHandles/_uploadMetadata dictionaries (all keyed by handle
or accounting name, unaffected) -- no other eviction/accounting path
assumed id-only keying.
Finding 7 -- column-reservation semantics, per the DECIDED shape:
MarkupDocument now sets button.IconSource / list.IconIdsSource +
IconResolve ONLY when a resolver (icons parameter) is actually wired --
previously button.IconSource was always assigned (even to an
always-empty func on an icons:null host); combined with this finding's
other half -- UiSimpleButton.OnDraw now reserves its icon column
whenever IconSource is non-null, regardless of a per-frame resolve miss,
so a bound id that goes briefly to 0 no longer slides the caption back
and forth -- would have permanently reserved a blank column on such a
host. UiMarkupList already reserved its column whenever IconIdsSource
was set; no draw-side change needed there.
Finding 8 -- added a with/without-icons comparison test for
UiMarkupList (mirroring the existing UiSimpleButton one): asserts the
row text quad's x is strictly greater with an icon column present, and
the icon quad itself has non-zero width.
Finding 9 -- <icon tooltip=""> (empty string) was still treated as
"has a tooltip" by a bare attribute-presence check, making the icon
swallow clicks with no visible tooltip ever appearing. Now uses
!string.IsNullOrWhiteSpace, matching ApplyCommon's own predicate for
every other element's tooltip.
Finding 10 -- PluginShelfButton.OnDraw drew nothing when a non-zero
descriptor icon id resolved to no texture (a bad Decal index, a DAT id
from a different install), rather than falling back to Initials the
way a zero id already did. Now decides once, on the first draw
(memoized, so Initials' string work never repeats every frame): a
failed resolve permanently switches Text to the initials fallback,
computed and assigned BEFORE base.OnDraw actually paints the caption.
Finding 11 -- MarkupDocument.AddElement's switch had no default arm, so
an unknown or miscased element name (<Icon>, <butotn>) silently
vanished from the built tree instead of failing loudly like every
other malformed-markup case. Added a default arm that throws
FormatException. Ran AcDream.Plugins.MossTank.Tests (337/337,
unchanged) and the full App markup suite to confirm no existing markup
relies on an unknown element.
Finding 12 -- PluginPanelDescriptor.IconSurfaceId's XML doc now states
that a bare Decal index is accepted and normalized, citing
PluginIcons.Normalize.
Finding 13 -- docs/plugin-ui-markup.md: replaced the blanket "wrong
type/missing property throws at Build" sentence with the per-attribute
truth table the review produced (which attributes are silent at
runtime vs. throw at Build, and each one's bound CLR/delegate type);
restated the icon-id boundary as 0x01000000; added the "do NOT add
0x06000000 to the four already-full IconId records" warning (citing
SkillBase._iconID / UIRegion::SetImageByDID @0x004f150e); documented
that 0x-prefixed hex is required (an unprefixed all-digit literal
parses as decimal); noted unknown element names now throw; called out
list colors (0xRRGGBB) vs. color=/background=/border= (#AARRGGBB) as
non-interchangeable grammars; documented the root <panel visible>
binding-only exception; corrected the shelf's collapse toggle glyphs
(</>, not the old doc's arrows) and the 28px collapsed-tab size; added
the IconId record-equality API-v1 note; and called out iconkind as
per-<list> (mixed id spaces need pre-normalized DIDs; the composited
spell badge has no did-space escape hatch) plus the existing
one-text-column LIMITATION being deferred to MossTank.
Coverage added for finding 14: a PluginSidePanelTests case proving the
shelf button normalizes a bare descriptor index before resolving, and a
reflection-based unit on AppAutomationSurface.ProjectWorldObject (its
public callers gate on IsAvailable, which needs a fully connected
session heavier than this mapping needs -- the plan's own documented
fallback) proving PluginWorldObject.IconId carries ClientObject.IconId
through unchanged; PluginInventoryItem.IconId uses the identical
one-line pattern inline in CaptureOwnedItems, reviewed by inspection.
Finding 15: recorded a "Review ledger" section in the plan doc with
both slices' commits, both review verdicts, and the two items
explicitly deferred to the MossTank plugin work (multi-column list,
root literal visible).
Verification: full solution builds green. Targeted filter
(Markup|PluginSidePanel|PluginIcons|AppAutomation|TextureCache|
UiDatFont) passes 131/131, including the two InstalledDat-lane tests
(RetailMarkupIconResolverInstalledDatTests,
AppAutomationSurfaceIconInstalledDatTests) actually resolving against
the real installed DAT, not skipping. AcDream.Plugins.MossTank.Tests
passes 337/337 unchanged. Full AcDream.App.Tests suite: 7351 passed /
97 skipped / 36 failed -- identical failure set/count to the
7334/97/36 baseline (the +17 passes are exactly the new/expanded
tests: 2 new PluginIconsTests.Normalize theory rows, 10 new
MarkupIconTests cases, 2 new PluginSidePanelTests cases, 1 new
AppAutomationSurfaceTests case, and the 2 new standalone test files).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
317 lines
17 KiB
Markdown
317 lines
17 KiB
Markdown
# Plugin UI markup
|
||
|
||
SSOT for `AcDream.Plugin.Abstractions.IUiRegistry`'s markup vocabulary — every
|
||
element and attribute a plugin can put in the KSML-style XML it hands the host
|
||
via `AddPanel`/`RegisterPanel`/`RegisterPanelContent`, the `{Binding}` rules
|
||
those attributes follow, the DAT-icon grammar (Slice B), and the movable
|
||
plugin shelf (Slice A). Both slices are recorded in
|
||
[`plans/2026-09-06-plugin-shelf-and-dat-icons.md`](plans/2026-09-06-plugin-shelf-and-dat-icons.md);
|
||
this page is the day-to-day reference for writing a panel, that plan is the
|
||
design record.
|
||
|
||
Plugins stay BCL-only: nothing in `AcDream.Plugin.Abstractions` references
|
||
App/UI or Core.Items types. A plugin hands the host raw ids (spell ids,
|
||
object guids, DAT indices); the host owns every texture, every composited
|
||
icon, and the parser that turns markup into a live `UiElement` tree
|
||
(`AcDream.App.UI.MarkupDocument`).
|
||
|
||
## Registering a panel
|
||
|
||
```csharp
|
||
host.Ui.AddPanel(
|
||
new PluginPanelDescriptor("main", "MossTank")
|
||
{
|
||
IconText = "MT", // fallback initials if IconSurfaceId is 0
|
||
IconSurfaceId = 7735, // Decal-style bare index OR a full DID — both normalize
|
||
StartVisible = true,
|
||
ShowInSidePanel = true,
|
||
},
|
||
Path.Combine(pluginDirectory, "mosstank.xml"),
|
||
binding);
|
||
```
|
||
|
||
`RegisterPanel` (same signature, returns `IDisposable`) removes the window
|
||
independently of the plugin's own lifetime. `RegisterPanelContent` takes an
|
||
in-memory KSML string instead of a file path — the route `AcDream.Plugins.Smoke`
|
||
uses for its icon-surface proof panel (`SmokeIconPanel.cs`), when a panel is
|
||
small enough not to need its own shipped `.xml` asset.
|
||
|
||
Every registered window gets a stable persisted key
|
||
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in),
|
||
the global UI lock, and a button in the shared plugin shelf
|
||
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
|
||
disables the plugin or pauses its `Tick`.
|
||
|
||
## The `{Binding}` rule
|
||
|
||
Every attribute that isn't a plain literal is either:
|
||
|
||
- a **literal** — a number, color, or string typed directly in the markup, or
|
||
- a **binding** — `{PropertyName}`, resolved once at `Build` time against the
|
||
binding object's public properties/`Action`/`Action<T>` members via
|
||
reflection, then **re-read every frame** through a `Func<T>` (or invoked
|
||
live for actions). A plugin updates its panel by assigning a property; it
|
||
never touches `UiElement` objects directly, and never from a thread other
|
||
than the one that calls `Tick`.
|
||
|
||
A binding failure's severity is per-attribute, not one blanket rule — see the
|
||
table below. An unrecognized `{Prop}` that resolves loudly (any row marked
|
||
"Throws") throws `FormatException` **at `Build`**, the same moment any other
|
||
malformed attribute throws, never silently at draw time. The attributes
|
||
marked "Silent" instead fall back to something visible-but-harmless at
|
||
*runtime* (the literal text, `null`, or `0`) — a plugin author who typos one
|
||
of those sees a wrong-looking value on screen rather than a crash, so double
|
||
check those four against the markup by eye.
|
||
|
||
| Attribute(s) | On a missing/mistyped `{Prop}` | Bound CLR type |
|
||
|---|---|---|
|
||
| `label text`, `field text`, `menu selected`, `tooltip` (any element) | **Silent** — `BindString` falls back to the literal attribute text itself (a typo'd `{Typo}` renders as the literal string `{Typo}`) | `string` (via `.ToString()`) |
|
||
| `meter cur`, `meter max` | **Silent** — `BindUint` returns `null` (the meter shows no cur/max) | `uint?` (accepts any integral type) |
|
||
| `meter fill`, `slider value` | **Silent** — `BindFloat` returns `0` | `float?`/`float` |
|
||
| `list items`, `menu items` | Throws | `IEnumerable<string>` |
|
||
| `list colors` | **Silent** if omitted (no color override); throws if present but mistyped | `IEnumerable<uint>` |
|
||
| `list icons` (Slice B) | Throws if present but mistyped; omitting it entirely means no icon column at all | `IEnumerable<uint>` **or** `IEnumerable<int>` |
|
||
| `<icon>`/`<button icon>` `did`/`spell`/`item` bindings (Slice B) | Throws | any integral type (`uint`, `int`, `long`, `ushort`, a nullable of one, …) via `Convert.ToUInt32` |
|
||
| `list selected` | Throws (required int reader) | `int` |
|
||
| `tab selected`, `toggle checked` | Throws (required bool reader) | `bool` |
|
||
| root `panel visible` | Throws (required bool reader; see the root-only note below) | `bool` |
|
||
| `onclick` (button/tab/toggle) | Throws | `Action` |
|
||
| `slider onchange` | Throws | `Action<float>` |
|
||
| `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action<string>` |
|
||
| `list onchange` | Throws | `Action<int>` |
|
||
|
||
## Elements
|
||
|
||
Every element name is validated at `Build`: an unknown or miscased tag (e.g.
|
||
`<Icon>`, `<butotn>`) throws `FormatException` rather than silently
|
||
vanishing from the built tree.
|
||
|
||
| Element | Purpose | Key attributes |
|
||
|---|---|---|
|
||
| `panel` (root) | The window itself | `x y w h title resize visible` |
|
||
| `group` | Transparent layout container | `x y w h background border visible` |
|
||
| `label` | Static or bound text | `x y text color` |
|
||
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind` |
|
||
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip` |
|
||
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
|
||
| `tab` | Selectable tab button | `x y w h text selected onclick` |
|
||
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` |
|
||
| `slider` | Horizontal scalar | `x y w h value onchange` |
|
||
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
|
||
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward` |
|
||
| `list` | Scrollable row list (+ Slice B icon column) | `x y w h items colors selected onchange rowheight icons iconkind` |
|
||
|
||
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
||
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
||
`enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown
|
||
through retail's own runtime tooltip popup, empty/whitespace treated as no
|
||
tooltip). The root `<panel>` is the one exception: it does **not** go
|
||
through `ApplyCommon` (no `name`/`enabled`/`tooltip`), and its `visible`
|
||
attribute accepts a `{Binding}` only — a literal `visible="true"` on the
|
||
root is not parsed (unlike every child element, where a literal is fine).
|
||
|
||
**LIMITATION:** `<list>` has exactly one text column (plus the optional
|
||
Slice B icon column) — there is no multi-column list yet. A plugin that
|
||
needs tabular rows today pads its own fixed-width text (`$"{name,-16}{value,6}"`).
|
||
Real multi-column support is deferred to the MossTank plugin work.
|
||
|
||
`list colors`' values are `0xRRGGBB` (opaque, no alpha channel), while every
|
||
`color=`/`background=`/`border=` attribute elsewhere is `#AARRGGBB` (alpha
|
||
first) — the two grammars look similar but are not interchangeable.
|
||
|
||
Every hex literal (`did`, `0x` id bindings, `list colors` entries) requires
|
||
the `0x` prefix to parse as hex; an all-digit string with no prefix
|
||
(`did="165"`) parses as **decimal**, not hex — `did="165"` and `did="0x165"`
|
||
are different ids.
|
||
|
||
## The icon-id grammar (Slice B)
|
||
|
||
Decal/VirindiViewService plugins (the reference usage this ported:
|
||
MosswartMassacre's `HudPictureBox.Image` assignments, fed from Decal's
|
||
`FileService.SpellTable`/`SkillTable` icon columns) hand out **bare portal.dat
|
||
indices** — small integers, not full `0x06xxxxxx` RenderSurface DIDs. acdream's
|
||
host normalizes every icon id through one function so both styles work
|
||
everywhere an icon id is accepted:
|
||
|
||
```csharp
|
||
// AcDream.Plugin.Abstractions.PluginIcons
|
||
static uint Normalize(uint idOrIndex);
|
||
// 0 -> 0 (no icon)
|
||
// 7735 -> 0x06001E37 (bare index -> RenderSurface DID)
|
||
// 0x00FFFFFF -> 0x06FFFFFF (largest bare index, just below the boundary)
|
||
// 0x01000000 -> 0x01000000 (AT the boundary -> already a DID, unchanged)
|
||
// 0x06002D14 -> 0x06002D14 (already a DID, unchanged)
|
||
```
|
||
|
||
The boundary is `0x01000000`: any value below it is treated as a bare
|
||
Decal-style index and gets the `0x06000000` RenderSurface block prefix added;
|
||
any value at or above it (including `0x01000000` itself) is assumed to
|
||
already be a resolvable DID and passes through unchanged.
|
||
|
||
The host applies `Normalize` at **every** `did`-shaped sink: the descriptor's
|
||
`IconSurfaceId` (drawn on the plugin shelf button), and every `<icon did>` /
|
||
`<button icon>` (`iconkind="did"`) / `<list icons>` (`iconkind="did"`) value —
|
||
literal or bound, re-normalized every frame for a bound value. A plugin never
|
||
needs to call `Normalize` itself; handing the host either a Decal-style index
|
||
or a full DID produces the same drawn icon.
|
||
|
||
Plugin-facing records that already carry full retail RenderSurface DIDs
|
||
(`PluginSpellInfo.IconId`, `PluginSkillInfo.IconId`,
|
||
`PluginInventoryItem.IconId`, `PluginWorldObject.IconId`) are **not**
|
||
re-normalized — they are already in DID space, read straight from the
|
||
client's SpellTable/SkillTable/object state. `Normalize` only matters at a
|
||
markup `did` sink, where a plugin author might type a bare index by hand.
|
||
|
||
**Do NOT add `0x06000000` to any of the four `IconId` records above by
|
||
hand** — they are already full DIDs, not bare indices. `PluginSkillInfo.IconId`
|
||
in particular comes straight from `SkillBase.IconId`, and retail's own
|
||
`UIRegion::SetImageByDID(SkillBase._iconID)` (`@0x004f150e`) draws that field
|
||
directly as a DID with no `+0x06000000` step of its own — adding the block
|
||
prefix again would double-normalize it and resolve nothing (see
|
||
`src/AcDream.App/UI/Layout/SampleData.cs:64-82` for the real values: Melee
|
||
Defense is `0x06000165`, never `7735`/`0x165`, in that field).
|
||
|
||
**API-v1 note:** `IconId` is a positional/`init` member on each of the four
|
||
records above, so it participates in record equality (`Equals`/`GetHashCode`)
|
||
along with every other field. Plugin code that compares two
|
||
`PluginSpellInfo`/`PluginSkillInfo`/`PluginInventoryItem`/`PluginWorldObject`
|
||
values for equality now also compares their `IconId` — harmless for code
|
||
built against the new host (both sides fill it identically), but worth
|
||
knowing if you see an equality check that used to succeed start failing
|
||
against a host that populates `IconId` where an older one left it `0`.
|
||
|
||
## The three icon sources
|
||
|
||
Every icon-bearing attribute (`<icon>`'s `did`/`spell`/`item`, `<button icon>`,
|
||
`<list icons>`) resolves through one of three sources, selected by which
|
||
attribute is set (`<icon>`) or by `iconkind` (`<button>`/`<list>`, default
|
||
`"did"`):
|
||
|
||
| Source | What it draws | Backing API |
|
||
|---|---|---|
|
||
| `did` | The raw RenderSurface art at that DID, nothing composited on top | `IMarkupIconResolver.ResolveDid` (a plain sprite resolve, after `PluginIcons.Normalize`) |
|
||
| `spell` | Retail's **composited** spell icon: power-level backing + spell art + reversed/normal tint + self/fellow-targeted overlay | `IconComposer.GetSpellIcon` (retail `ClientMagicSystem::CompositeSpellIcon`) |
|
||
| `item` | Retail's **composited** item icon for a *live* object id: type-default underlay + custom underlay + base icon + custom overlay + effect recolor | `IconComposer.GetIcon`, reading the id's fields from the same `ClientObjectTable` the inventory UI already uses |
|
||
|
||
`did` accepts a literal (`did="7735"` decimal, or `did="0x06002D14"` hex) or a
|
||
binding (`did="{IconDid}"`, a `uint` property re-read every frame). `spell`
|
||
and `item` are almost always bindings (`spell="{SpellId}"`,
|
||
`item="{ObjectId}"`) but accept the same literal grammar. Any of the three
|
||
resolving to 0, or the resolver returning no texture, draws nothing — never a
|
||
placeholder, never a throw.
|
||
|
||
### `<icon>`
|
||
|
||
```xml
|
||
<icon x="8" y="8" w="32" h="32" did="7735" tooltip="Decal-style index"/>
|
||
<icon x="48" y="8" w="32" h="32" did="0x06002D14"/>
|
||
<icon x="88" y="8" w="32" h="32" spell="{SpellId}" tooltip="{SpellName}"/>
|
||
```
|
||
|
||
Exactly one of `did`/`spell`/`item` must be present — two sources on one
|
||
`<icon>` throws `FormatException` at `Build`. `w`/`h` default to 32 (retail's
|
||
standard icon size) when omitted. The sprite is drawn nearest-filtered,
|
||
aspect-preserved, and centered inside the `w`×`h` box — a non-square source
|
||
never stretches. A `tooltip` attribute makes the icon a real hit-test target
|
||
(it is click-through otherwise, so it never steals clicks meant for something
|
||
underneath it).
|
||
|
||
### `<button icon="..." iconkind="did|spell|item">`
|
||
|
||
```xml
|
||
<button x="12" y="68" w="120" h="24" text="Report"
|
||
icon="0x06002D14" onclick="{Report}"/>
|
||
```
|
||
|
||
The icon draws flush left inside the button; the caption's centering region
|
||
shifts right to make room. `text` may be empty for an icon-only button.
|
||
`iconkind` defaults to `"did"`.
|
||
|
||
### `<list icons="{IconIds}" iconkind="did|spell|item">`
|
||
|
||
```xml
|
||
<list x="12" y="100" w="256" h="108"
|
||
items="{SpellRows}" icons="{SpellIds}" iconkind="spell"
|
||
selected="{SelectedIndex}"/>
|
||
```
|
||
|
||
`icons` is an `IEnumerable<uint>` (or `IEnumerable<int>`) binding parallel to
|
||
`items` — Decal's `IconColumn` convention: a leading square column,
|
||
`RowHeight - 2` pixels wide, one icon per row. A row past the end of the
|
||
icons list, or an id that resolves to nothing, draws no icon for that row
|
||
(the text still draws, just without an icon). Omitting `icons` entirely
|
||
keeps the list exactly as it was before Slice B (full-width text, no
|
||
column).
|
||
|
||
`iconkind` is **per-`<list>`**, not per-row: every id in one list's `icons`
|
||
binding is resolved the same way (all `did`, all `spell`, or all `item`).
|
||
There is no way to mix kinds within a single list. Two consequences:
|
||
|
||
- If a plugin's data genuinely mixes id spaces (some rows are raw DIDs, some
|
||
are spell ids needing a composited badge), it must pre-normalize/pre-resolve
|
||
outside the markup and expose ONE consistent `IEnumerable<uint>` of
|
||
`did`-space ids — `iconkind="did"` on the list.
|
||
- retail's composited spell badge (power-level backing + tint + self/fellow
|
||
overlay) can **only** be reached through `iconkind="spell"` with real spell
|
||
ids — there is no DID that already IS the composited result, so a list
|
||
that wants the badge look has no `did`-space escape hatch.
|
||
|
||
MosswartMassacre-style example — a list column composited from spell ids,
|
||
with the spell's own raw art DID printed alongside the name for comparison
|
||
(`AcDream.Plugins.Smoke`'s own proof panel, `SmokeIconPanel.cs`, is exactly
|
||
this pattern):
|
||
|
||
```csharp
|
||
// iconkind="spell": the values MUST be spell ids (what ResolveSpell composites
|
||
// a badge from), NOT the spell's raw IconId — those are different id spaces.
|
||
public IEnumerable<uint> SpellIds =>
|
||
host.Automation.Spells.KnownSelfBuffs.Select(s => s.SpellId);
|
||
public IEnumerable<string> SpellRows =>
|
||
host.Automation.Spells.KnownSelfBuffs.Select(
|
||
s => $"{s.Name} (icon 0x{s.IconId:X8})");
|
||
```
|
||
|
||
```xml
|
||
<list items="{SpellRows}" icons="{SpellIds}" iconkind="spell" .../>
|
||
```
|
||
|
||
(A list backed by `PluginSpellInfo.IconId` directly — the spell's own raw art
|
||
tile, no composited badge — uses `iconkind="did"` instead, with `icons`
|
||
yielding `IconId` rather than `SpellId`.)
|
||
|
||
## The plugin shelf (Slice A)
|
||
|
||
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of
|
||
per-plugin-window buttons. It is a real retained window
|
||
(`RetailWindowManager` key `plugin-shelf`), so it gets drag, the global UI
|
||
lock, and persisted position/visibility/collapsed state for free, exactly
|
||
like every other window.
|
||
|
||
- **Drag**: a grip strip across its top (three short dashes) is the move
|
||
handle. Dragging elsewhere on the shelf (the buttons themselves, the
|
||
padding between them) does not move the window.
|
||
- **Collapse**: a small `>`/`<` toggle at the grip's right end (expanded
|
||
shows `>`, collapsed shows `<`) shrinks the shelf to a 28px-tall,
|
||
button-sized tab (deliberately findable-sized, not a thin sliver) rather
|
||
than just the grip band; button entries stay laid out underneath so
|
||
expanding is instant. Persists through the same window-state channel as
|
||
position/visibility.
|
||
- **Hide/show**: `Shift+Ctrl+F1` (retail's plugin-manager chord,
|
||
`InputAction.TogglePluginManager` — acdream has no separate plugin manager,
|
||
so this is its honest home). Hiding the shelf never disables a plugin or
|
||
touches any individual plugin window's own visibility; a new plugin window
|
||
registering while the shelf is hidden does not un-hide it. If no plugin has
|
||
registered a shelf entry yet, the chord reports "No plugin windows are
|
||
registered." instead.
|
||
- **Default dock**: with no saved layout, the shelf sits at the right screen
|
||
edge, top 116px — until the user drags it (or a saved layout restores a
|
||
different position), after which it stays put and growth preserves whatever
|
||
corner it's anchored from.
|
||
|
||
## Testing conventions
|
||
|
||
`MarkupDocumentTests`/`MarkupIconTests` build panels with a fake
|
||
`resolve`/`IMarkupIconResolver` (`_ => (1u, 32, 32)` for sprites; a small
|
||
in-test class recording which id/kind it was asked to resolve) rather than a
|
||
live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises
|
||
the shelf's drag/collapse/hide/persistence behavior against a bare `UiRoot`.
|