diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 160fb201..9b490892 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -106,7 +106,7 @@ 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 min max` | +| `slider` | Horizontal scalar | `x y w h value onchange min max style` | | `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 scroll 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 `` children (see "Columns" below) — never both | @@ -130,6 +130,14 @@ lighter fill, and no checkmark; more entries than the row cap show a plain `style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing row art, ornate scrollbar) exactly as before, unchanged. +`slider style` is the same `plain`/`retail` grammar (fix round B item 11, +2026-09-07): `plain` (the default) draws a flat dark track, a 1px border, +and a small flat nub — no DAT scrollbar art, no `SpriteResolve` dependency +at all — via `UiScrollbar.RetailArt=false`'s `DrawPlainScalar`; `style= +"retail"` keeps `RetailScrollbarChrome.ApplyHorizontal`'s sprite track/thumb +exactly as before. Any other value throws `FormatException` at `Build`, +same rule as `menu style`. + 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 diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 8b015e95..60fe928f 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -397,6 +397,12 @@ public static class MarkupDocument (string?)el.Attribute("value"), binding); + // Fix round B item 11: mirrors — plain (default) draws a flat track/nub with no + // DAT art at all; style="retail" opts into the sprite chrome + // RetailScrollbarChrome.ApplyHorizontal applies below. + bool sliderRetailArt = ValidateArtStyle("slider", (string?)el.Attribute("style")); + var slider = new UiScrollbar { Left = F(el, "x"), @@ -405,6 +411,7 @@ public static class MarkupDocument Height = F(el, "h"), Horizontal = true, SpriteResolve = resolve, + RetailArt = sliderRetailArt, ScalarPositionSource = () => sliderValueSource() is { } declaredValue ? Math.Clamp( @@ -414,7 +421,8 @@ public static class MarkupDocument ? null : normalized => changed(sliderMin + normalized * sliderRange), }; - RetailScrollbarChrome.ApplyHorizontal(slider); + if (sliderRetailArt) + RetailScrollbarChrome.ApplyHorizontal(slider); ApplyCommon(slider, el, binding); parent.AddChild(slider); break; @@ -481,7 +489,7 @@ public static class MarkupDocument Func menuSelected = BindString( (string?)el.Attribute("selected"), binding); - bool menuRetailButtonArt = ValidateMenuStyle((string?)el.Attribute("style")); + bool menuRetailButtonArt = ValidateArtStyle("menu", (string?)el.Attribute("style")); var menu = new UiMenu { Left = F(el, "x"), @@ -672,21 +680,22 @@ public static class MarkupDocument /// /// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS - /// to go. That is not how vtank looks."): validates <menu - /// style="..."> and returns the - /// value it selects. Default (attribute absent, or explicit - /// style="plain") is the flat VTank/Decal HudCombo box - /// (false) — retail's gold pushbutton art is now an explicit - /// style="retail" opt-in for a plugin panel that genuinely wants - /// it. Any other value is a Build-time author error, same rule as - /// . + /// to go. That is not how vtank looks."): validates style="..." on + /// <menu> (returning 's + /// value) and, since fix round B item 11, <slider> (returning + /// 's value) — the same grammar, the + /// same default. Default (attribute absent, or explicit + /// style="plain") is the flat VTank/Decal look (false) — + /// retail's gold/sprite art is an explicit style="retail" opt-in + /// for a plugin panel that genuinely wants it. Any other value is a + /// Build-time author error, same rule as . /// - private static bool ValidateMenuStyle(string? style) => style switch + private static bool ValidateArtStyle(string elementName, string? style) => style switch { null or "plain" => false, "retail" => true, var other => throw new FormatException( - $" must be plain or retail"), + $"<{elementName} style=\"{other}\"> must be plain or retail"), }; /// diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 8f8fa702..0ac0d4ca 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -45,6 +45,25 @@ public sealed class UiScrollbar : UiElement public Func? ScalarPositionSource { get; set; } public bool Horizontal { get; set; } + /// + /// Fix round B item 11: mirrors — + /// true (the default, preserving every existing retail scrollbar/slider + /// byte-for-byte) draws the sprite chrome below; false draws a plain + /// track/nub instead (), with no + /// dependency at all. Only plugin markup's + /// <slider> (MarkupDocument) ever sets this false by + /// default — every other caller of this widget (retail LayoutDesc + /// import, the chat opacity sliders, etc.) leaves it at the default + /// true and is completely unaffected. + /// + public bool RetailArt { get; set; } = true; + + // ── Plain scalar chrome (RetailArt = false). Colors mirror UiMenu's own + // Plain* palette for a consistent "no DAT art" look across widgets. ── + public Vector4 PlainTrackColor { get; set; } = new(0f, 0f, 0f, 0.6f); + public Vector4 PlainBorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f); + public Vector4 PlainNubColor { get; set; } = new(0.72f, 0.62f, 0.34f, 1f); + /// True while a thumb drag is in progress (between a thumb-hit /// MouseDown/drag-start and the matching MouseUp). OP5 review /// fix S1, 2026-08-11: lets a consumer distinguish a per-tick drag edit @@ -274,6 +293,11 @@ public sealed class UiScrollbar : UiElement protected override void OnDraw(UiRenderContext ctx) { if (!IsPresentationVisible) return; + if (!RetailArt) + { + DrawPlainScalar(ctx); + return; + } if (SpriteResolve is not { } resolve) return; if (Horizontal) { @@ -470,6 +494,40 @@ public sealed class UiScrollbar : UiElement DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight); } + /// + /// Fix round B item 11: = false counterpart of + /// the horizontal/vertical scalar sprite paths above — a flat + /// fill, a 1px + /// outline, and a small flat nub at the + /// current . No + /// dependency at all — a plain slider with no icon resolver wired up + /// still draws. Only handles the scalar shape plugin markup's + /// <slider> actually builds (Horizontal true or false, + /// set); the paged -driven + /// scrollbar shape is unreached by <slider> and keeps its + /// existing sprite-only rendering regardless of this flag. + /// + private void DrawPlainScalar(UiRenderContext ctx) + { + ctx.DrawFill(0f, 0f, Width, Height, PlainTrackColor); + ctx.DrawRectOutline(0f, 0f, Width, Height, PlainBorderColor, 1f); + + if (Horizontal) + { + float nubWidth = MathF.Min(6f, Width); + float travel = MathF.Max(0f, Width - nubWidth); + float x = travel * ScalarPosition; + ctx.DrawFill(x, 0f, nubWidth, Height, PlainNubColor); + } + else + { + float nubHeight = MathF.Min(6f, Height); + float travel = MathF.Max(0f, Height - nubHeight); + float y = travel * ScalarPosition; + ctx.DrawFill(0f, y, Width, nubHeight, PlainNubColor); + } + } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 1286dad5..7113d250 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -377,19 +377,26 @@ public class MarkupDocumentTests Assert.Equal(120f, binding.Value, 3); } + // Fix round B item 11: now defaults to the PLAIN style + // (RetailArt=false), so a markup slider with no style attribute never + // reaches RetailScrollbarChrome.ApplyHorizontal's sprite thumb any more + // — it draws UiScrollbar.DrawPlainScalar's flat nub instead. This test + // now opts INTO style="retail" to keep exercising the retail sprite + // path; the plain default gets its own sibling below. [Fact] public void Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition() { const string xml = "" + "" + + "value=\"{Value}\" style=\"retail\"/>" + ""; var binding = new RangeBinding(); UiNineSlicePanel panel = MarkupDocument.Build( xml, binding, id => (id, 16, 16)); var slider = Assert.IsType(panel.Children[0]); + Assert.True(slider.RetailArt); var device = new RecordingGpuDevice(); var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); @@ -417,6 +424,58 @@ public class MarkupDocumentTests $"expected the thumb offset right of the origin at 25%, got x={thumbMinX}"); } + [Fact] + public void Slider_NoStyleAttribute_DrawsAPlainFlatNubAtTheRescaledNormalizedPosition() + { + const string xml = + "" + + "" + + ""; + var binding = new RangeBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, id => (id, 16, 16)); + var slider = Assert.IsType(panel.Children[0]); + Assert.False(slider.RetailArt); + + 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)); + + slider.TickSelfAndChildren(0); + Assert.Equal(0.25f, slider.ScalarPosition, 3); + + slider.DrawSelfAndChildren(ctx); + + // An untextured segment batches every DrawFill call in submission + // order together (background track + nub both use texture=0), so + // scan per-quad (6 verts x 8 floats = 48 floats) for one whose + // color is PlainNubColor and whose left edge sits right of the + // origin (proof the 25% position, not 0 or 1.0, drove the nub). + const int floatsPerQuad = 6 * 8; + bool foundOffsetNub = renderer.DebugSpriteSegmentVerts.Any(s => + { + if (s.Texture != 0u) return false; + for (int q = 0; q + floatsPerQuad <= s.Verts.Count; q += floatsPerQuad) + { + float xMin = float.MaxValue; + for (int v = 0; v < 6; v++) + xMin = MathF.Min(xMin, s.Verts[q + v * 8]); + float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7]; + bool isNubColor = MathF.Abs(r - slider.PlainNubColor.X) < 0.01f + && MathF.Abs(g - slider.PlainNubColor.Y) < 0.01f + && MathF.Abs(b - slider.PlainNubColor.Z) < 0.01f + && MathF.Abs(a - slider.PlainNubColor.W) < 0.01f; + if (isNubColor && xMin > 0f) + return true; + } + return false; + }); + Assert.True(foundOffsetNub, "expected a plain flat nub offset right of the origin at 25%"); + } + // ── Campaign VT slice 7 S7.2: (KB 08 §3 gap) ──── [Fact]