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:
parent
fbdbbc7f90
commit
e72a64a311
3 changed files with 283 additions and 6 deletions
|
|
@ -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.0–1.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.0–1.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.0–1.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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue