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." <menu> markup wiring (MarkupDocument.cs): a plugin <menu> is now always Scrollable (single-column, VTank HudCombo shape) instead of wrapping overflow into more grid columns, with PopupScrollbarHideWhenDisabled=true so the bar is entirely absent while the item count fits the "rows" window. RetailScrollbarChrome.ApplyToMenuPopup wires the same chrome ids the previous commit taught DrawScrollablePopupPlain to draw, for both style="plain" and style="retail" markup menus. <list> markup (UiMarkupList.cs / MarkupDocument.cs): a plugin <list> (single-column or <column> multi-column) that overflows its own row viewport now draws the retail scrollbar chrome at its right edge (VVS's own placement, 16px wide) instead of being wheel-scroll-only with no visible bar. The reserved 16px column only exists while rows actually overflow, in both column-layout modes (ComputeColumnLayout receives the already-shrunk width so the last/auto column absorbs the remainder correctly); the bar is fully interactive (up/down arrows, track paging, thumb drag) via a small UiScrollable projection kept in sync with the list's own _topRow, which stays the single source of truth. Wheel scrolling and a no-resolver hand-built list (draws nothing, no crash) are unchanged. Mutation shown to fail first: new UiMarkupListScrollbarTests/MarkupDocumentTests cases were written against pre-change UiMarkupList/MarkupDocument and failed (no scrollbar sprites ever emitted since UiMarkupList had no SpriteResolve property at all, and <menu> markup never set Scrollable) before the implementation landed; after: SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge and Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly pin sprite ids + exact reserved-width geometry, *_ContentFits_DrawsNo(Scrollbar|ReservationLastColumnKeepsFullRemainder) pin the no-overflow/no-bar case, *_UpArrowClick_ScrollsUpByOneRow and ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick pin interactivity via a following row click resolving to the moved position (mirroring MarkupListColumnsTests' own wheel-scroll pin), and the four new MarkupDocumentTests menu cases pin Scrollable/PopupScrollbarHideWhenDisabled/ the six chrome-id properties plus an end-to-end open-popup draw for both the overflowing (draws chrome) and non-overflowing (draws none) cases. Every pre-existing MarkupListColumnsTests/MarkupDocumentTests case stays green unchanged (none of their fixtures overflow their own viewport). docs/plugin-ui-markup.md updated: the <menu> style paragraph and a new <list> "Scrollbar" section describe the new chrome + auto-reservation, and the PITCH-transcription guidance is corrected to say the 16px scrollbar column is now automatic (no more manual fold-in/double-reservation advice). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
557 lines
34 KiB
Markdown
557 lines
34 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),
|
||
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 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 style` |
|
||
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight` + 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), 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).
|
||
|
||
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.
|
||
|
||
## 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.
|