feat(D.2b): UiNineSlicePanel — 8-piece retail window frame + geometry test

Implements the retail floating-window bevel as a UiPanel subclass using
RetailChromeSprites: 4 tiled edges + 4 stretched corners + tiled center fill,
matching the 8-piece border layout confirmed by the D.2b Step-0 prove-out.
Resolver delegate keeps GL out of unit tests. Geometry verified by
ComputeFrameRects_PlacesCornersEdgesAndCenter (1/1 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 16:36:11 +02:00
parent 8e91805206
commit 0bf790c8bf
2 changed files with 112 additions and 0 deletions

View file

@ -0,0 +1,85 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A <see cref="UiPanel"/> whose background is the retail 8-piece window bevel
/// (<see cref="RetailChromeSprites"/>): 4 corners + 4 edges around a tiled
/// center fill. Retires the flat translucent rect (divergence row TS-30).
/// Sprites resolve to (GL handle, width, height) via an injected delegate so
/// the widget is testable without GL. In production:
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
/// </summary>
public sealed class UiNineSlicePanel : UiPanel
{
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
public readonly record struct Rect(float X, float Y, float W, float H);
/// <summary>The nine destination rects for an 8-piece border + center.</summary>
public readonly record struct FrameRects(
Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right,
Rect TL, Rect TR, Rect BL, Rect BR);
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
{
_resolve = resolve;
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
BorderColor = Vector4.Zero;
}
/// <summary>
/// Destination rects (local px) for a frame of (<paramref name="w"/>,
/// <paramref name="h"/>) with border thickness <paramref name="b"/>:
/// b×b corners, top/bottom edges spanning the interior width at height b,
/// left/right edges spanning the interior height at width b, center fills
/// the interior.
/// </summary>
public static FrameRects ComputeFrameRects(float w, float h, int b)
{
float innerW = w - 2 * b;
float innerH = h - 2 * b;
return new FrameRects(
Center: new Rect(b, b, innerW, innerH),
Top: new Rect(b, 0, innerW, b),
Bottom: new Rect(b, h - b, innerW, b),
Left: new Rect(0, b, b, innerH),
Right: new Rect(w - b, b, b, innerH),
TL: new Rect(0, 0, b, b),
TR: new Rect(w - b, 0, b, b),
BL: new Rect(0, h - b, b, b),
BR: new Rect(w - b, h - b, b, b));
}
protected override void OnDraw(UiRenderContext ctx)
{
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
// center + edges tile (UV repeat); corners stretch 1:1.
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top);
DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom);
DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left);
DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right);
DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL);
DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR);
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
}
private void DrawTiled(UiRenderContext ctx, uint id, Rect d)
{
if (d.W <= 0 || d.H <= 0) return;
var (tex, tw, th) = _resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, d.W / tw, d.H / th, Vector4.One);
}
private void DrawStretched(UiRenderContext ctx, uint id, Rect d)
{
if (d.W <= 0 || d.H <= 0) return;
var (tex, _, _) = _resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, 1, 1, Vector4.One);
}
}

View file

@ -0,0 +1,27 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public class UiNineSlicePanelTests
{
[Fact]
public void ComputeFrameRects_PlacesCornersEdgesAndCenter()
{
var r = UiNineSlicePanel.ComputeFrameRects(100, 80, 5);
// 5x5 corners at the four corners
Assert.Equal(new UiNineSlicePanel.Rect(0, 0, 5, 5), r.TL);
Assert.Equal(new UiNineSlicePanel.Rect(95, 0, 5, 5), r.TR);
Assert.Equal(new UiNineSlicePanel.Rect(0, 75, 5, 5), r.BL);
Assert.Equal(new UiNineSlicePanel.Rect(95, 75, 5, 5), r.BR);
// edges span the interior (100-2*5 = 90 wide, 80-2*5 = 70 tall)
Assert.Equal(new UiNineSlicePanel.Rect(5, 0, 90, 5), r.Top);
Assert.Equal(new UiNineSlicePanel.Rect(5, 75, 90, 5), r.Bottom);
Assert.Equal(new UiNineSlicePanel.Rect(0, 5, 5, 70), r.Left);
Assert.Equal(new UiNineSlicePanel.Rect(95, 5, 5, 70), r.Right);
// center fills the interior
Assert.Equal(new UiNineSlicePanel.Rect(5, 5, 90, 70), r.Center);
}
}