From da42c1fce669bfc9716a4e1a143ecbdc470ea797 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 13:09:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20slice=207=20fix=20round=20B=20item?= =?UTF-8?q?=2011=20=E2=80=94=20plain=20=20style,=20mirroring=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin markup's always drew RetailScrollbarChrome's sprite track/thumb, the same "big gold DAT art next to a plain plugin panel" mismatch already fixed for dropdowns. now uses the identical plain/retail grammar (ValidateArtStyle, renamed and generalized from the menu-only ValidateMenuStyle): plain (the default) draws a flat dark track, 1px border, and a small flat nub via new UiScrollbar.RetailArt=false + DrawPlainScalar — no SpriteResolve dependency at all; style="retail" keeps RetailScrollbarChrome.ApplyHorizontal exactly as before. RetailArt defaults to true on UiScrollbar itself, so every non-plugin caller of this widget (retail LayoutDesc import, chat opacity sliders, etc.) is byte-for-byte unaffected — only 's own MarkupDocument case sets it false by default. Every existing MossTank (Vitals' nine sliders, Buffs, Items' Refill Worn Mana) has no style attribute, so they all switch to the plain look automatically — consistent with the whole campaign's "no gold art" direction, no XML changes needed. Fixed a red pin this change created: Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition asserted the retail sprite thumb on a slider with no style attribute, which now builds plain by default — opted it into style="retail" (same fix shape as item 1's menu-scroll pin) and added a plain sibling, Slider_NoStyleAttribute_DrawsAPlainFlatNubAtTheRescaledNormalizedPosition. Mutation check: hardcoding DrawPlainScalar's horizontal nub x to 0 turned the new plain test red ("expected a plain flat nub offset right of the origin at 25%"); restoring the real ScalarPosition-driven x turns it green. Documented in docs/plugin-ui-markup.md, mirroring the existing paragraph. tests/AcDream.Plugins.MossTank.Tests: 671/671 (unchanged — pure App-layer rendering change, MossTank markup only sets no/default style). tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 278/3 skipped/281 (was 277/3/280, +1 new test). Co-Authored-By: Claude Fable 5.1 --- docs/plugin-ui-markup.md | 10 ++- src/AcDream.App/UI/MarkupDocument.cs | 33 ++++++---- src/AcDream.App/UI/UiScrollbar.cs | 58 ++++++++++++++++++ .../UI/MarkupDocumentTests.cs | 61 ++++++++++++++++++- 4 files changed, 148 insertions(+), 14 deletions(-) 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]