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

@ -397,6 +397,12 @@ public static class MarkupDocument
(string?)el.Attribute("value"),
binding);
// Fix round B item 11: <slider style="..."> mirrors <menu
// style> — 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<string?> 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
/// <summary>
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): validates <c>&lt;menu
/// style="..."&gt;</c> and returns the <see cref="UiMenu.RetailButtonArt"/>
/// value it selects. Default (attribute absent, or explicit
/// <c>style="plain"</c>) is the flat VTank/Decal <c>HudCombo</c> box
/// (<c>false</c>) — retail's gold pushbutton art is now an explicit
/// <c>style="retail"</c> opt-in for a plugin panel that genuinely wants
/// it. Any other value is a Build-time author error, same rule as
/// <see cref="ValidateIconKind"/>.
/// to go. That is not how vtank looks."): validates <c>style="..."</c> on
/// <c>&lt;menu&gt;</c> (returning <see cref="UiMenu.RetailButtonArt"/>'s
/// value) and, since fix round B item 11, <c>&lt;slider&gt;</c> (returning
/// <see cref="UiScrollbar.RetailArt"/>'s value) — the same grammar, the
/// same default. Default (attribute absent, or explicit
/// <c>style="plain"</c>) is the flat VTank/Decal look (<c>false</c>) —
/// retail's gold/sprite art is an explicit <c>style="retail"</c> opt-in
/// for a plugin panel that genuinely wants it. Any other value is a
/// Build-time author error, same rule as <see cref="ValidateIconKind"/>.
/// </summary>
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(
$"<menu style=\"{other}\"> must be plain or retail"),
$"<{elementName} style=\"{other}\"> must be plain or retail"),
};
/// <summary>

View file

@ -45,6 +45,25 @@ public sealed class UiScrollbar : UiElement
public Func<float?>? ScalarPositionSource { get; set; }
public bool Horizontal { get; set; }
/// <summary>
/// Fix round B item 11: mirrors <see cref="UiMenu.RetailButtonArt"/> —
/// true (the default, preserving every existing retail scrollbar/slider
/// byte-for-byte) draws the sprite chrome below; false draws a plain
/// track/nub instead (<see cref="DrawPlainScalar"/>), with no
/// <see cref="SpriteResolve"/> dependency at all. Only plugin markup's
/// <c>&lt;slider&gt;</c> (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.
/// </summary>
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);
/// <summary>True while a thumb drag is in progress (between a thumb-hit
/// <c>MouseDown</c>/drag-start and the matching <c>MouseUp</c>). 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);
}
/// <summary>
/// Fix round B item 11: <see cref="RetailArt"/> = false counterpart of
/// the horizontal/vertical scalar sprite paths above — a flat
/// <see cref="PlainTrackColor"/> fill, a 1px <see cref="PlainBorderColor"/>
/// outline, and a small flat <see cref="PlainNubColor"/> nub at the
/// current <see cref="ScalarPosition"/>. No <see cref="SpriteResolve"/>
/// dependency at all — a plain slider with no icon resolver wired up
/// still draws. Only handles the scalar shape plugin markup's
/// <c>&lt;slider&gt;</c> actually builds (Horizontal true or false,
/// <see cref="ScalarChanged"/> set); the paged <see cref="Model"/>-driven
/// scrollbar shape is unreached by <c>&lt;slider&gt;</c> and keeps its
/// existing sprite-only rendering regardless of this flag.
/// </summary>
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);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)