632 lines
39 KiB
Markdown
632 lines
39 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 = 0x06002C41, // 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 to reach for 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 —
|
||
`<panel resizable="true">`, see "Resizable panels and anchors" below), 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>` **or** `IEnumerable<int>` (shared `BindUintList`) |
|
||
| `list icons` (Slice B) | Throws if present but mistyped; omitting it entirely means no icon column at all. A negative `int` element is **silent**: it maps to `0u` (no icon for that row), matching the scalar `did`/`spell`/`item` row above | `IEnumerable<uint>` **or** `IEnumerable<int>` |
|
||
| `<icon>`/`<button icon>` `did`/`spell`/`item` bindings (Slice B) | **Build-time:** throws only for a missing bound property (or, for a literal, one that isn't valid hex/decimal) — the binder never checks the property's static CLR type. **Draw-time:** a resolved value that is negative or above `uint.MaxValue` is **silent** (maps to `0u`, draws nothing); a resolved value that cannot convert to a number at all throws `InvalidCastException`/`FormatException` from the draw, not from `Build` | 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>` |
|
||
| `column items` (`type="text"`) | Throws — REQUIRED, unlike the single-column list's own `items` sugar it mirrors | `IReadOnlyList<string>` |
|
||
| `column colors` (`type="text"`) | **Silent** if omitted (no per-row override, same rule as `list colors`); throws if present but mistyped | `IReadOnlyList<uint>` **or** `IReadOnlyList<int>` |
|
||
| `column onclick` (`type="text"`) | **Silent** if omitted (keeps the original select-the-row behavior); throws if present but mistyped | `Action<int>` (row index) |
|
||
| `column values` (`type="check"`) | Throws — REQUIRED (there is no "no check column" fallback the way `list icons` has "no icon column") | `IReadOnlyList<bool>` |
|
||
| `column values` (`type="icon"`) | Throws — REQUIRED | `IReadOnlyList<uint>` **or** `IReadOnlyList<int>` |
|
||
| `column onchange` (`type="check"`) | Throws — REQUIRED (unlike the list's own optional `onchange`) | `Action<int>` (row index) |
|
||
| `column onclick` (`type="icon"`) | Throws — REQUIRED | `Action<int>` (row index) |
|
||
| `column width` (any type, NOT the list's last column) | Throws if missing, unparseable, or `<= 0` — UNLESS it is the literal `"*"` | `float`, or the literal `"*"` for auto |
|
||
| `column width` (the list's LAST column) | **Silent** — never validated, never used for layout (it always absorbs the remainder), UNLESS it is the literal `"*"` (then it joins the auto-sharing group instead of taking 100% of the remainder alone) | `float`, or the literal `"*"` |
|
||
|
||
The icon-id row is the one binding here whose failure mode depends on WHEN you look: a typo'd property name is caught immediately at `Build`, but a property that exists yet holds the wrong kind of value at runtime is only ever discovered later, from inside a live draw.
|
||
|
||
## 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 resizable minw minh visible` |
|
||
| `group` | Transparent layout container | `x y w h background border visible anchor` |
|
||
| `label` | Static or bound text | `x y text color anchor` |
|
||
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind anchor` |
|
||
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip anchor` |
|
||
| `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 anchor` |
|
||
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color anchor` |
|
||
| `slider` | Horizontal scalar | `x y w h value onchange anchor` |
|
||
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background anchor` |
|
||
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style anchor` |
|
||
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
|
||
|
||
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
|
||
art read as an out-of-place "big yellow button" next to a plugin's own dark
|
||
list boxes (owner live-client report, 2026-09-07), so a plugin `<menu>` now
|
||
draws the flat VTank/Decal `HudCombo` box (list-matching fill/border, a
|
||
left-aligned value, and a small ▾) by default; `style="retail"` opts back
|
||
into the gold face for a panel that genuinely wants it. Any other value
|
||
throws `FormatException` at `Build`. The plain style covers the WHOLE menu,
|
||
closed and open: a follow-up owner report (still 2026-09-07 — "Drop down
|
||
menus look horrible, there is also a checkmark on the text there") found the
|
||
OPEN popup still drew retail's tan/orange gradient panel, its ornate gold
|
||
scrollbar, and a baked checkmark glyph on the current entry even with
|
||
`style="plain"`. The open popup now matches `<list>`'s own chrome too: a
|
||
flat fill + 1px border, one row per entry in the list text color, the
|
||
current entry filled like a list selection, the hovered entry a slightly
|
||
lighter fill, and no checkmark. A `<menu>` popup always scrolls a single
|
||
column (rather than wrapping into more grid columns) once its item count
|
||
exceeds `rows`; a further owner directive (still 2026-09-07 — "For
|
||
scrollable dropdown or the meta window we use the same assets as we do in
|
||
for example chat or inventory window") moved that overflow scrollbar to
|
||
retail's own chrome — the exact sprites the chat window's transcript and
|
||
the inventory list already use — while the rows themselves stay the flat
|
||
style described above; a menu with too few items to overflow shows no bar
|
||
at all. `style="retail"` keeps the sprite popup rows (gradient panel,
|
||
checkmark-bearing row art) exactly as before, unchanged — only the
|
||
scrollbar chrome is shared between the two styles.
|
||
|
||
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
||
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
||
`enabled` (same rule), `tooltip` (a literal string or `{Binding}` shown
|
||
through retail's own runtime tooltip popup, empty/whitespace treated as no
|
||
tooltip), and `anchor` (which edges of the element's PARENT it keeps a fixed
|
||
margin to on resize — see "Resizable panels and anchors" below). The root
|
||
`<panel>` is the one exception: it does **not** go through `ApplyCommon` (no
|
||
`name`/`enabled`/`tooltip`/`anchor` — a top-level window is never anchored to
|
||
its own parent, only dragged/resized directly), 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).
|
||
|
||
Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
|
||
with `<column>` children is no longer limited to one padded text column. A
|
||
`<list>` with no `<column>` children stays exactly the older single-column
|
||
form (`items`/`colors`/`icons`/`iconkind` on the element itself); the two
|
||
forms are mutually exclusive on one element.
|
||
|
||
`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.
|
||
|
||
## Resizable panels and anchors
|
||
|
||
A plugin panel is **fixed-size by default** — this matches every panel
|
||
shipped before 2026-09-07 (e.g. `mosstank.xml`'s `resize="none"`). A window
|
||
opts into real user drag-resize with `<panel resizable="true">`, and every
|
||
non-root element opts its OWN geometry into following that resize with
|
||
`anchor="..."`. The two attributes are independent: a resizable panel whose
|
||
children have no `anchor` just gets bigger/smaller with empty space at the
|
||
bottom-right (today's default placement, `Left|Top`); a panel with anchored
|
||
children but `resizable` left at its default `false` never actually resizes,
|
||
so the anchors never have anything to react to.
|
||
|
||
| Attribute | Element | Meaning |
|
||
|---|---|---|
|
||
| `resizable` | `panel` (root) | `"true"` arms the window for user drag-resize on both axes (edges + corners, same mechanism chat windows use); default `false` — fixed size, exactly as before this attribute existed |
|
||
| `minw` / `minh` | `panel` (root) | The floor a drag-resize (and a persisted-layout restore) will not shrink below. Default: the panel's own authored `w`/`h` — a resizable panel never shrinks past the layout its author actually tested. Only meaningful when `resizable="true"` |
|
||
| `resize` | `panel` (root) | Pre-existing per-axis lock (`x`/`y`/`both`/`none`) that narrows `resizable="true"` to one axis; has no effect on its own now that `resizable` (default `false`) is the master switch |
|
||
| `anchor` | `group` `list` `menu` `field` `label` `button` `icon` (and `meter`/`tab`/`toggle`/`slider`) | Space-separated subset of `left top right bottom` (case-insensitive), naming which edges of the element's **direct parent** it keeps a fixed margin to as that parent resizes. Default (attribute absent) is `left top` — today's fixed placement, unchanged |
|
||
|
||
`anchor` semantics are exactly `AcDream.App.UI.UiElement.Anchors`/
|
||
`AnchorEdges`/`ApplyAnchor` (already used by every retail-imported window):
|
||
|
||
- `left top` (the default) — pinned top-left at a fixed size; never stretches.
|
||
- `left right` — stretches WIDTH to track the parent (both side margins stay
|
||
fixed).
|
||
- `top bottom` — stretches HEIGHT the same way, vertically.
|
||
- `left top right bottom` — stretches on both axes.
|
||
- `right` alone (no `left`) — pins to the parent's right edge at a FIXED
|
||
width, moving as the parent resizes rather than stretching. `bottom` alone
|
||
is the same, vertically.
|
||
|
||
An element's parent is whatever markup element directly contains it — for a
|
||
`<group>`'s children, that is the GROUP, not the panel. This is how a group
|
||
propagates resize to its own contents: give the group
|
||
`anchor="left top right bottom"` so it stretches with the panel, and give a
|
||
`<list>` inside it `anchor="left right"` so the list stretches with the
|
||
GROUP's width in turn. An unrecognized token (a typo like
|
||
`anchor="left rihgt"`) throws `FormatException` at `Build`, naming the
|
||
offending element by its `name`/`id` — the same "malformed markup throws"
|
||
rule every other attribute in this grammar follows.
|
||
|
||
No other markup or host wiring is needed to make a panel resizable: once
|
||
`resizable="true"` sets the window's `Resizable`/`ResizeX`/`ResizeY`/
|
||
`MinWidth`/`MinHeight`, the SAME drag-resize, persistence (save/restore
|
||
across sessions, clamped to `minw`/`minh`), and UI-lock behavior every other
|
||
retained window already has just applies.
|
||
|
||
```xml
|
||
<panel x="0" y="0" w="420" h="320" title="MossTank" resizable="true" minw="360" minh="260">
|
||
<group anchor="left top right bottom" x="8" y="8" w="404" h="304" border="#FF4A3A14">
|
||
<label x="4" y="4" text="Monsters"/>
|
||
<list anchor="left right top bottom" x="4" y="24" w="396" h="276"
|
||
items="{MonsterNames}" selected="{SelectedMonster}" onchange="{SelectMonster}"/>
|
||
</group>
|
||
</panel>
|
||
```
|
||
|
||
Here the outer `<group>` stretches with the panel on every edge, and the
|
||
`<list>` inside it stretches with the GROUP on every edge in turn — dragging
|
||
the window's corner grows the whole list, not just empty panel background.
|
||
|
||
## 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:69-83` 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.
|
||
|
||
A `did` icon is **not blitted raw**. It is drawn the way retail draws every
|
||
icon it composites (`IconData::RenderIcons` with no overlay and no effects):
|
||
the art's pure-white pixels are the DAT's "recolor me" key and are replaced
|
||
with the solid-black fallback tile, exactly as a mundane item in the inventory.
|
||
Raw art shows a white ring around the icon (Decal's `HudPictureBox` draws it
|
||
that way); acdream does not. Art without any pure-white pixel is unaffected.
|
||
|
||
### `<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).
|
||
|
||
`<icon>` derives its kind from WHICH of `did`/`spell`/`item` is set — unlike
|
||
`<button>`/`<list>`, it has no per-element `iconkind` to disambiguate.
|
||
Putting `iconkind` on an `<icon>` throws `FormatException` at `Build`
|
||
("`iconkind applies to button and list; icon derives its kind from
|
||
did/spell/item`") rather than silently ignoring 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:
|
||
|
||
```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`.)
|
||
|
||
## Columns (Campaign VT slice 1 Part B)
|
||
|
||
VVS's `HudList` (the VirindiViewService list control VTank's own `mainView.xml`
|
||
uses) supports N independently-typed columns per row — text, checkbox, and
|
||
icon cells side by side in one scrolling grid, each with its own `Click(row,
|
||
col)`. acdream's `<list>` matches this by letting a `<list>` declare
|
||
`<column>` children instead of the single-column `items`/`colors`/`icons`
|
||
attributes:
|
||
|
||
```xml
|
||
<list x="8" y="24" w="256" h="120" rowheight="18"
|
||
selected="{SelectedMonster}" onchange="{SelectMonster}">
|
||
<column type="check" width="20" values="{MonsterFester}" onchange="{ToggleFester}"/>
|
||
<column type="text" width="127" items="{MonsterNames}" onclick="{PingMonster}"/>
|
||
<column type="icon" width="*" iconkind="did" values="{MonsterIcons}" onclick="{MoveMonsterUp}"/>
|
||
</list>
|
||
```
|
||
|
||
This mirrors VTank's own Monsters tab (several boolean flag columns, a name
|
||
column, and icon-button columns) — see
|
||
`docs/research/vtank-kb/08-ui-views.md` §3's "Multi-column lists with typed
|
||
columns" gap and its proposed extension, which this implements verbatim. The
|
||
`rowheight="18"` above is the widget's own default (`UiMarkupList.RowHeight`),
|
||
chosen to match VVS's own row pitch exactly: `Padding*2 + ControlHeight` =
|
||
`1*2 + 16` = `18` (`docs/research/vtank-kb/08-ui-views.md`'s `HudList` row:
|
||
`Padding=1px`, `ControlHeight=16px`) — an author who omits `rowheight`
|
||
entirely already gets VVS's pitch for free.
|
||
|
||
### `<column>` attribute grammar
|
||
|
||
| Attribute | Applies to | Required | Meaning |
|
||
|---|---|---|---|
|
||
| `type` | every column | yes | `text`, `check`, or `icon` — any other value throws `FormatException` at `Build` |
|
||
| `width` | every column | see below | Column width in px, or `"*"` for auto. See "Width semantics" below — the rules differ for the LAST column in a `<list>` vs. every other column |
|
||
| `items` | `type="text"` | yes | `{IReadOnlyList<string>}` — one row of text per index |
|
||
| `colors` | `type="text"` | no | `{IReadOnlyList<uint>}`, `0xRRGGBB` per row (same grammar as the single-column list's own `colors`); omitted rows (or the whole attribute) fall back to the list's `TextColor` |
|
||
| `onclick` | `type="text"` | no | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell INSTEAD of selecting the row. Omitted (the default) keeps the original select-the-row behavior; present but malformed throws `FormatException` at `Build`. None of VTank's eight lists actually relies on row selection — every real text cell in `mainView.xml` is wired as an action target — so a new column is usually written WITH an `onclick` |
|
||
| `values` | `type="check"` | yes | `{IReadOnlyList<bool>}` — the checked state per row |
|
||
| `onchange` | `type="check"` | yes | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell; the plugin flips its own bool, the column never mutates `values`' backing collection itself |
|
||
| `values` | `type="icon"` | yes | `{IReadOnlyList<uint>}` (or `IReadOnlyList<int>`) — one icon id per row, same id-space rules as `list icons` |
|
||
| `iconkind` | `type="icon"` | no (defaults `"did"`) | `did`/`spell`/`item`, same three-source dispatch as `<list icons iconkind>` above — one kind per column, not per row |
|
||
| `onclick` | `type="icon"` | yes | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell |
|
||
|
||
Unlike the single-column list's optional `icons`/`onchange`, a `check`/`icon`
|
||
column's own `values` and `onchange`/`onclick` are **required** — a column
|
||
that can never fire anything, or has nothing to draw, is a Build-time author
|
||
error, not a silently-inert control. A `<column>` with an unrecognized `type`,
|
||
a missing required binding for its type, or any `<list>` child element that
|
||
isn't `<column>` at all, throws `FormatException` at `Build`. A `<list>` with
|
||
`<column>` children cannot ALSO use the single-column `items`/`colors`/`icons`
|
||
attributes on the `<list>` element itself — pick one form per list. Every
|
||
column-attribute throw message identifies the offending column by position
|
||
and declared type — `column[2] type="check" values`, not just `"column
|
||
values"` — so a list with several columns of the same type still points at
|
||
the right one.
|
||
|
||
### Width semantics
|
||
|
||
`width="*"` means AUTO: this column shares the list's remaining width
|
||
EQUALLY with every other auto column, VVS's own "0-width column auto-sizes"
|
||
rule (`docs/research/vtank-kb/08-ui-views.md`'s `HudList` row: "a 0-width
|
||
text/button/edit/list/fixedlayout/notebook column auto-sizes... share the
|
||
remaining width equally"). `width="*"` is legal on ANY column, including the
|
||
last.
|
||
|
||
The LAST column in a `<list>` is special: it is ALWAYS treated as auto —
|
||
sharing the remaining width like every other auto column when one or more
|
||
earlier columns also declare `width="*"`, or absorbing 100% of the remainder
|
||
by itself when no other column does (the original, still-default behavior).
|
||
Its own declared `width` (or omitting `width` entirely) is never validated
|
||
and never used for layout — only an explicit `width="*"` on the last column
|
||
actually changes anything (it makes the last column share evenly with
|
||
earlier auto columns instead of taking the whole remainder alone). When two
|
||
or more columns end up sharing, integer-division remainder goes to the LAST
|
||
one — e.g. three columns sharing 100px split 33/33/34, not 33/33/33 with 1px
|
||
unaccounted for.
|
||
|
||
Every OTHER (non-last) column's declared `width` is validated at `Build`: **a
|
||
missing, unparseable, or non-positive `width` throws `FormatException`**
|
||
naming the column's index and declared type (`column[0] type="text" width
|
||
must be a positive number or "*", got (missing)`) — UNLESS it is `width="*"`.
|
||
|
||
At layout time (recomputed every frame off the list's live width, so a
|
||
resizable list re-flows like every other retained widget), a declared width
|
||
that would overflow the list's total width is CLAMPED to whatever room is
|
||
actually left, walked left to right — every column after the overflow point
|
||
gets `0` width and draws nothing (a `<=0`-width cell is skipped entirely, the
|
||
same as today).
|
||
|
||
### Row count, selection, and clicks
|
||
|
||
Row count is the longest bound column (a text column with 20 rows next to a
|
||
check column with only 5 simply draws 15 rows past its own data — see "Short
|
||
columns past their own row count" below for what each column kind does
|
||
there). The list's own `selected`/`onchange` attributes keep exactly their
|
||
single-column meaning: a click in a **text** column without its own
|
||
`onclick` selects that row (and fires the list's `onchange` with the row
|
||
index, same as today). A click in a **check** or **icon** column, or a
|
||
**text** column that DOES declare its own `onclick`, instead fires that
|
||
column's own `onchange`/`onclick` and does **not** change the list's
|
||
selection — VVS's per-cell `Click(row, col)` folded into a per-column
|
||
callback, since acdream's binding model is per-attribute rather than
|
||
per-cell. A click landing on a row past that SPECIFIC column's own bound
|
||
data (even though the row is valid for the list overall, because some OTHER
|
||
column has more rows) fires nothing — no callback, no crash. Scrolling works
|
||
exactly as the single-column list already does.
|
||
|
||
### No header row
|
||
|
||
VVS's `HudList` has no built-in header row either — the column-caption
|
||
glyphs seen in VTank's own `mainView.xml` (e.g. the Monsters tab's single-letter
|
||
"F"/"B"/"G"/"I"/… flag headers) are ordinary `StaticText` controls placed
|
||
manually above the list. acdream matches this for free: put a `<label>` (or
|
||
several, one per column, hand-positioned) directly above the `<list>` — there
|
||
is no dedicated header markup to learn.
|
||
|
||
### Check-column glyph
|
||
|
||
A `type="check"` cell draws with the exact same five-band lamp glyph as
|
||
`<toggle>` (both now share one `UiCheckLamp` primitive — its checked/
|
||
unchecked colors and `Draw` method), so a column checkbox reads identically
|
||
to every other checkbox in the client rather than a bespoke box-and-tick.
|
||
The glyph is centered horizontally in its cell (matching how an icon cell
|
||
already centers its sprite) — a check column is routinely declared wider
|
||
than the glyph itself under the PITCH convention below.
|
||
|
||
### Short columns past their own row count
|
||
|
||
A **text** or **icon** cell past its own column's row count draws nothing —
|
||
there is no sensible default string or icon to show. A **check** cell past
|
||
its own column's row count still draws the lamp, UNCHECKED — VVS
|
||
materializes every cell in a row regardless of which columns actually have
|
||
data for it, and there is always a sensible default for a boolean (false).
|
||
|
||
### The PITCH convention for transcribing a VTank column table
|
||
|
||
VVS's own `HudList` reserves geometry acdream's column model doesn't have a
|
||
separate concept for: `WPaddingOuter=3px` (the list's own left/right
|
||
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
|
||
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
|
||
also forces every `CheckColumn` to a fixed 13px regardless of its declared
|
||
`fixedwidth`. acdream's column model has no separate gap/forced-width
|
||
concept — every column's declared `width` is its full cell width, columns
|
||
sit directly adjacent with no gap, and a check column uses whatever `width`
|
||
it's given like any other column. The 16px scrollbar column IS now
|
||
automatic (owner directive, 2026-09-07 — see "Scrollbar" below): a plugin
|
||
author never reserves it by hand.
|
||
|
||
To transcribe a real VTank column table (as in
|
||
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
|
||
**PITCH** instead of its raw `fixedwidth`: `pitch = fixedwidth + 7` (baking
|
||
VVS's inter-column `WPadding` into the cell width itself, since acdream has
|
||
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
|
||
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
|
||
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Do NOT also fold VVS's 16px
|
||
scrollbar width into the last column's pitch or the list's total `w` — the
|
||
list reserves that width itself, automatically, only while its rows
|
||
actually overflow (see "Scrollbar" below); doing both would double-reserve
|
||
it and starve the last column once the list has few enough rows to hide
|
||
the bar.
|
||
|
||
### Scrollbar
|
||
|
||
Once a `<list>`'s rows overflow its own height (either the single-column or
|
||
the `<column>` form), it reserves a 16px column at its right edge — VVS's
|
||
own `VScrollBarButtonSize` placement — and draws retail's scrollbar chrome
|
||
there: the same sprite ids the chat window's transcript and the inventory
|
||
list already draw through (owner live-client report 2026-09-07: "For
|
||
scrollable dropdown or the meta window we use the same assets as we do in
|
||
for example chat or inventory window"). Mouse wheel keeps working as
|
||
before; the bar itself is also fully interactive (up/down arrow clicks,
|
||
track paging, and thumb drag). A list whose rows all fit reserves no width
|
||
and draws no bar at all — the reservation and the chrome both come and go
|
||
together with actual overflow, never present "just in case."
|
||
|
||
### Backward compatibility
|
||
|
||
A `<list>` with no `<column>` children is byte-for-byte the original
|
||
single-text-column widget — every existing panel (including every current
|
||
MossTank tab) keeps working unchanged; `<column>` is additive, not a
|
||
migration.
|
||
|
||
## 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`.
|
||
`MarkupListColumnsTests` covers the Columns extension above: per-column
|
||
binding-type validation (every throw naming its column by index and type),
|
||
width semantics (`"*"` sharing, the last-column-always-auto rule, the
|
||
overflow clamp), the per-column row-bound click guard, draw-level
|
||
column-offset/clipping/check-glyph pins against the same recording-renderer
|
||
apparatus, hit-test routing (text selects unless it has its own `onclick`;
|
||
check/icon/onclick-text fire their own callback and never touch selection),
|
||
a backward-compatibility proof that a column-less `<list>` is unaffected,
|
||
and two full `MarkupDocument.Build` end-to-end tests transcribing VTank's
|
||
real Monsters- and Meta-tab column shapes. `MarkupResizableAnchorTests`
|
||
covers `resizable`/`minw`/`minh` parsing, the `anchor` grammar (default,
|
||
every token combination, the unknown-token throw) across every element
|
||
listed above, live re-layout against the same recording-renderer apparatus
|
||
(a stretching list, a right-anchored button that moves, a group whose resize
|
||
propagates to its own anchored children), and a golden proving a panel with
|
||
none of these attributes draws byte-identically to itself across repeated
|
||
builds. `RetailWindowManagerTests`/`RetailWindowLayoutPersistenceTests`
|
||
cover a resizable markup panel through the real `ResizeTo`/save-restore
|
||
paths (accepts within `minw`/`minh`, a fixed panel refuses, a restored size
|
||
below the CURRENT floor clamps up to it).
|