merge(vt): resizable plugin panels (resizable/minw/minh) and anchor markup (owner: larger default, resizable)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 15:45:06 +02:00
commit 2e63391cc4
5 changed files with 605 additions and 27 deletions

View file

@ -36,8 +36,9 @@ 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
(`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`.
@ -98,18 +99,18 @@ 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` |
| `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` |
| `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 |
| `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
@ -139,12 +140,15 @@ 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
`enabled` (same rule), `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).
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
@ -161,6 +165,67 @@ 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:
@ -554,4 +619,14 @@ 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.
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).