feat(app): slice 7 fix round B item 11 — plain <slider> style, mirroring <menu style>

Plugin markup's <slider> always drew RetailScrollbarChrome's sprite
track/thumb, the same "big gold DAT art next to a plain plugin panel"
mismatch <menu style> already fixed for dropdowns. <slider style="..."> 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 <slider>'s own
MarkupDocument case sets it false by default.

Every existing MossTank <slider> (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 <slider style> in docs/plugin-ui-markup.md, mirroring the
existing <menu style> 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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 13:09:33 +02:00
parent ed5378d9b0
commit da42c1fce6
4 changed files with 148 additions and 14 deletions

View file

@ -377,19 +377,26 @@ public class MarkupDocumentTests
Assert.Equal(120f, binding.Value, 3);
}
// Fix round B item 11: <slider> 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 =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"0\" max=\"200\" " +
"value=\"{Value}\"/>" +
"value=\"{Value}\" style=\"retail\"/>" +
"</panel>";
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var slider = Assert.IsType<UiScrollbar>(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 =
"<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]);
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: <menu scroll="true"> (KB 08 §3 gap) ────
[Fact]