diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index ee6019023..8e8c7c642 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -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 `` 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; `` is additive, not a migration. +## Slider range and scrollable menus (Campaign VT slice 7) + +Two small ``/`` attributes, both closing gaps identified in +`docs/research/vtank-kb/08-ui-views.md` §3 while porting VTank's own Vitals +and Profiles tabs. + +### `` + +VVS's `HudHSlider`/`LinearPositionControl` expose an arbitrary `Min`/`Max` +range (VTank's own nine Vitals sliders are `minimum="0" maximum="100"`). +acdream's `` 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 + +``` + +`HealPercent`/`SetHealPercent` read and write a plain `0..100` value — no +`/100f` scaling shim in the plugin's own ViewModel. Omitting both attributes +(every `` 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. + +### `` + +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 `` 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 + +``` + +Omitting `scroll` (every `` 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 diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index c152a043a..6045c091b 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -381,6 +381,22 @@ public static class MarkupDocument + $"Action 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 historically only ever + // bound a fixed 0.0-1.0 value. Omitting both attributes keeps + // that exact identity range so every pre-existing + // (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 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 instead + // wraps overflow into extra columns unless the author opts + // into UiMenu.Scrollable. Default false keeps every existing + // (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, diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index bb3cb9b11..a168ee972 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -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: (KB 08 §3 gap) ──────── + + private sealed class RangeBinding + { + public float Value { get; private set; } = 50f; + public Action SetValue => value => Value = value; + } + + [Fact] + public void Build_SliderWithNoMinMax_KeepsTheHistoricZeroToOneIdentityRange() + { + const string xml = + "" + + "" + + ""; + // Value defaults to 50 in RangeBinding, but with no declared range a + // pre-existing 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(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 = + "" + + "" + + ""; + var binding = new RangeBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 16, 16)); + var slider = Assert.IsType(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. + slider.ScalarChanged!(0.6f); + Assert.Equal(120f, binding.Value, 3); + } + + [Fact] + public void Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition() + { + const string xml = + "" + + "" + + ""; + var binding = new RangeBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, id => (id, 16, 16)); + var slider = Assert.IsType(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: (KB 08 §3 gap) ──── + + [Fact] + public void Build_MenuWithNoScrollAttribute_KeepsScrollableFalse() + { + const string xml = """ + + + + """; + var binding = new EditorBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 32, 32)); + var menu = Assert.IsType(panel.Children[0]); + + Assert.False(menu.Scrollable); + } + + [Fact] + public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites() + { + const string xml = """ + + + + """; + var binding = new EditorBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 32, 32)); + var menu = Assert.IsType(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 = """ + + + + """; + var binding = new ManyChoicesBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, id => (id, 16, 16)); + var menu = Assert.IsType(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 ManyChoices { get; } = + Enumerable.Range(0, 18).Select(i => $"Item {i}").ToArray(); + public Action SelectChoice => value => Selected = value; + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } }