feat(ui-markup): slice 7 step 1 — <slider min max> and <menu scroll>

Campaign VT slice 7 needs to transcribe VTank's own Vitals sliders
(minimum=0/maximum=100) and its long Profiles/Route named-item combos
control-for-control (docs/research/vtank-kb/08-ui-views.md §3's two
markup gaps). acdream's <slider> was hardwired to a 0.0-1.0 scalar and
<menu> always wrapped overflow into extra columns instead of VVS's
single scrolling column.

<slider min max>: optional literal attributes declaring the range the
bound value/onchange speak in (e.g. min="0" max="100" for a percent),
while UiScrollbar itself keeps its existing 0.0-1.0 internal math
untouched — MarkupDocument rescales at the binding boundary. Omitting
both (every pre-existing <slider>) keeps the exact historical identity
range.

<menu scroll="true">: wires UiMenu.Scrollable plus the same
track/thumb/up/down chrome sprites ConfigOptionsPageController and
VendorUiController already apply to their own Scrollable menus,
previously only reachable from C#. Omitting scroll keeps the
historical column-wrapping default.

Both are additive — no existing <slider>/<menu> markup changes
behavior. New pins in MarkupDocumentTests.cs shown to fail against the
prior MarkupDocument.cs (5 failures: Build_SliderWithNoMinMax_*,
Build_SliderWithMinMax_*, Slider_MinMax_Draws*,
Build_MenuWithScrollAttribute_*, Menu_Scroll_Draws*) before this
change, all green after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 07:07:42 +02:00
parent fbdbbc7f90
commit e72a64a311
3 changed files with 283 additions and 6 deletions

View file

@ -106,9 +106,9 @@ vanishing from the built tree.
| `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` |
| `slider` | Horizontal scalar | `x y w h value onchange min max` |
| `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` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll` |
| `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 |
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
@ -466,6 +466,55 @@ single-text-column widget — every existing panel (including every current
MossTank tab) keeps working unchanged; `<column>` is additive, not a
migration.
## Slider range and scrollable menus (Campaign VT slice 7)
Two small `<slider>`/`<menu>` attributes, both closing gaps identified in
`docs/research/vtank-kb/08-ui-views.md` §3 while porting VTank's own Vitals
and Profiles tabs.
### `<slider min max>`
VVS's `HudHSlider`/`LinearPositionControl` expose an arbitrary `Min`/`Max`
range (VTank's own nine Vitals sliders are `minimum="0" maximum="100"`).
acdream's `<slider>` always bound a fixed 0.01.0 scalar; `min`/`max` are
now optional literal attributes that declare the range the BOUND `value`/
`onchange` speak in, while the widget itself keeps working internally in
0.01.0 (drag math, click-to-jump, mouse-wheel are all unchanged):
```xml
<slider x="96" y="0" w="144" h="16" min="0" max="100"
value="{HealPercent}" onchange="{SetHealPercent}"/>
```
`HealPercent`/`SetHealPercent` read and write a plain `0..100` value — no
`/100f` scaling shim in the plugin's own ViewModel. Omitting both attributes
(every `<slider>` written before this slice) keeps the exact historical
0.01.0 identity range — `min`/`max` default to `0`/`1`, so `(value-0)/(1-0)`
and `0+t*(1-0)` are both no-ops. A declared `min == max` falls back to a
range of `1` rather than dividing by zero.
### `<menu scroll="true">`
VVS's `HudCombo` popup is always exactly one scrolling column, at most 10
rows visible before a scrollbar appears (`HudCombo.cs:35,102-146`).
acdream's `<menu>` instead wraps overflow into extra columns unless the
markup opts into `UiMenu.Scrollable` — previously only reachable from C#
(`ConfigOptionsPageController`, `VendorUiController`). `scroll="true"` wires
`Scrollable` plus the same track/thumb/up/down chrome sprites those two
controllers already apply, so a VTank `Choice` with many entries (the
27-option recall menu, a long named-profile list) keeps VVS's one-column
look instead of fanning out sideways:
```xml
<menu x="188" y="64" w="120" h="22" items="{RouteProfileNames}"
selected="{SelectedRouteProfile}" onchange="{SelectRouteProfile}"
rows="7" scroll="true"/>
```
Omitting `scroll` (every `<menu>` written before this slice) keeps
`Scrollable` at its historical `false` default — the column-wrapping
behavior is unchanged.
## The plugin shelf (Slice A)
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of

View file

@ -381,6 +381,22 @@ public static class MarkupDocument
+ $"Action<float> property on {binding.GetType().Name}");
}
// KB 08 §3 gap: VVS's HudHSlider exposes an arbitrary Min/Max
// range (VTank's own Vitals sliders are minimum="0"
// maximum="100"); acdream's <slider> historically only ever
// bound a fixed 0.0-1.0 value. Omitting both attributes keeps
// that exact identity range so every pre-existing <slider>
// (which never sets min/max) is byte-for-byte unaffected.
float sliderMin = FOr(el, "min", 0f);
float sliderMax = FOr(el, "max", 1f);
float sliderRange = sliderMax - sliderMin;
if (sliderRange == 0f)
sliderRange = 1f;
Func<float?> sliderValueSource = BindFloat(
(string?)el.Attribute("value"),
binding);
var slider = new UiScrollbar
{
Left = F(el, "x"),
@ -389,10 +405,14 @@ public static class MarkupDocument
Height = F(el, "h"),
Horizontal = true,
SpriteResolve = resolve,
ScalarPositionSource = BindFloat(
(string?)el.Attribute("value"),
binding),
ScalarChanged = changed,
ScalarPositionSource = () =>
sliderValueSource() is { } declaredValue
? Math.Clamp(
(declaredValue - sliderMin) / sliderRange, 0f, 1f)
: (float?)null,
ScalarChanged = changed is null
? null
: normalized => changed(sliderMin + normalized * sliderRange),
};
RetailScrollbarChrome.ApplyHorizontal(slider);
ApplyCommon(slider, el, binding);
@ -473,6 +493,21 @@ public static class MarkupDocument
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", false),
// KB 08 §3 gap: VVS's HudCombo is always a single scrolling
// column (HudCombo.cs:35,102-146); acdream's <menu> instead
// wraps overflow into extra columns unless the author opts
// into UiMenu.Scrollable. Default false keeps every existing
// <menu> (none of which set scroll=) wrapping exactly as
// before.
Scrollable = B(el, "scroll", false),
// Same track/thumb/arrow chrome ConfigOptionsPageController
// and VendorUiController already apply to their own
// Scrollable menus — harmless to set unconditionally since
// a non-scrollable menu never reads these.
ScrollTrackSprite = 0x06004C5Fu,
ScrollThumbSprite = 0x06004C63u,
ScrollUpSprite = RetailScrollbarChrome.UpNormal,
ScrollDownSprite = RetailScrollbarChrome.DownNormal,
TextIndent = 6f,
ButtonTextIndent = 6f,
NormalSprite = 0x06004D65u,

View file

@ -1,3 +1,8 @@
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
@ -314,4 +319,192 @@ public class MarkupDocumentTests
Assert.Equal(0.78f, binding.AttackPower);
Assert.Equal(0.78f, slider.ScalarPositionSource!());
}
// ── Campaign VT slice 7 S7.2: <slider min max> (KB 08 §3 gap) ────────
private sealed class RangeBinding
{
public float Value { get; private set; } = 50f;
public Action<float> SetValue => value => Value = value;
}
[Fact]
public void Build_SliderWithNoMinMax_KeepsTheHistoricZeroToOneIdentityRange()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" value=\"{Value}\" " +
"onchange=\"{SetValue}\"/>" +
"</panel>";
// Value defaults to 50 in RangeBinding, but with no declared range a
// pre-existing <slider> must keep treating it as an already-normalized
// 0-1 scalar (unchanged from before this attribute existed) — clamped,
// not rescaled.
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 16, 16));
var slider = Assert.IsType<UiScrollbar>(panel.Children[0]);
Assert.Equal(1f, slider.ScalarPositionSource!());
slider.ScalarChanged!(0.6f);
Assert.Equal(0.6f, binding.Value, 3);
}
[Fact]
public void Build_SliderWithMinMax_RescalesTheDeclaredRangeToAndFromTheInternalZeroToOnePosition()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"0\" max=\"200\" " +
"value=\"{Value}\" onchange=\"{SetValue}\"/>" +
"</panel>";
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 16, 16));
var slider = Assert.IsType<UiScrollbar>(panel.Children[0]);
// 50 out of a declared 0-200 range normalizes to 0.25 internally —
// this is the value UiScrollbar's own drag/click math operates on.
Assert.Equal(0.25f, slider.ScalarPositionSource!()!.Value, 3);
// The reverse direction: an internal drag position of 0.6 (60%) must
// be rescaled back up into the declared 0-200 range before it ever
// reaches the plugin's bound Action<float>.
slider.ScalarChanged!(0.6f);
Assert.Equal(120f, binding.Value, 3);
}
[Fact]
public void Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"0\" max=\"200\" " +
"value=\"{Value}\"/>" +
"</panel>";
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var slider = Assert.IsType<UiScrollbar>(panel.Children[0]);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
// TickSelfAndChildren pulls ScalarPositionSource into ScalarPosition —
// the same per-frame step the real host performs before drawing.
slider.TickSelfAndChildren(0);
Assert.Equal(0.25f, slider.ScalarPosition, 3);
slider.DrawSelfAndChildren(ctx);
// The horizontal thumb sprite (RetailScrollbarChrome.ApplyHorizontal's
// HThumbMidNormal) must be drawn strictly right of the left edge —
// proof the 0.25 normalized position (not the raw declared value 50,
// and not a full 1.0) reached the actual draw call.
var thumb = Assert.Single(
renderer.DebugSpriteSegmentVerts,
s => s.Texture == RetailScrollbarChrome.HThumbMidNormal);
float thumbMinX = Enumerable.Range(0, thumb.Verts.Count / 8)
.Min(i => thumb.Verts[i * 8]);
Assert.True(
thumbMinX > 0f,
$"expected the thumb offset right of the origin at 25%, got x={thumbMinX}");
}
// ── Campaign VT slice 7 S7.2: <menu scroll="true"> (KB 08 §3 gap) ────
[Fact]
public void Build_MenuWithNoScrollAttribute_KeepsScrollableFalse()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="20" items="{Choices}"
selected="{Selected}" onchange="{SelectChoice}" />
</panel>
""";
var binding = new EditorBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.False(menu.Scrollable);
}
[Fact]
public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="20" items="{Choices}"
selected="{Selected}" onchange="{SelectChoice}"
scroll="true" />
</panel>
""";
var binding = new EditorBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.True(menu.Scrollable);
Assert.NotEqual(0u, menu.ScrollTrackSprite);
Assert.NotEqual(0u, menu.ScrollThumbSprite);
Assert.NotEqual(0u, menu.ScrollUpSprite);
Assert.NotEqual(0u, menu.ScrollDownSprite);
}
[Fact]
public void Menu_Scroll_DrawsAScrollbarWhenTheMarkupItemCountOverflowsTheVisibleRows()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="18" items="{ManyChoices}"
selected="{Selected}" onchange="{SelectChoice}"
rows="6" rowheight="18" scroll="true" />
</panel>
""";
var binding = new ManyChoicesBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
// Open the popup, then a zero-delta wheel configures PopupScroll from
// Items.Count/RowsPerColumn/RowHeight — the same "configure right
// before use" step UiMenuTests' own Scrollable coverage relies on.
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5));
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
Assert.True(menu.PopupScroll.HasOverflow);
menu.DrawOverlays(ctx);
Assert.Contains(
renderer.DebugSpriteSegmentVerts,
s => s.Texture == menu.ScrollThumbSprite);
}
private sealed class ManyChoicesBinding
{
public string Selected { get; private set; } = "Item 0";
public IReadOnlyList<string> ManyChoices { get; } =
Enumerable.Range(0, 18).Select(i => $"Item {i}").ToArray();
public Action<string> SelectChoice => value => Selected = value;
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
}