diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs index 90060320b..300c0b1f4 100644 --- a/src/AcDream.App/UI/PluginSidePanel.cs +++ b/src/AcDream.App/UI/PluginSidePanel.cs @@ -75,10 +75,37 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState private const float ButtonGap = 4f; private const float DefaultTop = 116f; - /// Height of the top drag-grip band. Also the collapsed shelf's - /// total height (only the grip remains when collapsed). + /// + /// Minimum/bitmap-fallback height of the top drag-grip band while + /// EXPANDED. Owner-reported defect (2026-09-06): with this literal + /// hard-coded regardless of font, the retail 16px-tall dat-font glyph + /// line box () overhangs a 12px band — + /// measured against the real DAT (font 0x40000000): the outline + /// (background/shadow) plane's inflated quad is clipped by 1px at the + /// top and 2px at the bottom of a 12px band, even though the FILL glyph + /// itself happens to fit (measured '<'/'>': OffsetY=4 Height=7 + /// VerticalOffsetBefore=5 — the fill quad lands at local y=[3,10], + /// inside [0,12]; the border-inflated outline quad would span + /// y=[-1,14], clipped to [0,12]). + /// now derives the real band from the font's own metrics so neither + /// plane clips for any font, and this constant remains only as the + /// bitmap-font (no ) fallback. private const float GripHeight = 12f; + /// + /// The actual EXPANDED grip/toggle band height for the current font: the + /// bitmap fallback () when there is no + /// , else max(GripHeight, font.LineHeight + 2) + /// so the whole-line-height glyph box (fill AND the border-inflated + /// outline plane) always fits with margin to spare. internal so + /// tests can derive expected coordinates from the real value instead of + /// re-hard-coding the constant this replaces (the shelf's own EXPANDED + /// band height is always this value, never + /// directly, once a dat font is supplied). + /// + internal float ExpandedGripBandHeight => + _font is { } f ? MathF.Max(GripHeight, f.LineHeight + 2f) : GripHeight; + /// Width of the collapse-toggle button, anchored to the grip's /// right end. private const float ToggleWidth = 16f; @@ -179,7 +206,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState _font = font; Width = ButtonExtent + OuterPadding * 2f; - Height = GripHeight + OuterPadding * 2f; + Height = ExpandedGripBandHeight + OuterPadding * 2f; Top = DefaultTop; Anchors = AnchorEdges.None; // See the class doc for why this stays false: FindDragHandleWindow does @@ -325,7 +352,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState // same as before Slice A. float availableHeight = MathF.Max( ButtonExtent + OuterPadding * 2f, - parent.Height - Top - GripHeight - OuterPadding); + parent.Height - Top - ExpandedGripBandHeight - OuterPadding); if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f) { _lastLayoutHeight = availableHeight; @@ -580,18 +607,39 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState /// Repositions the grip/toggle children to span the current /// Width — called whenever changes it (entries added/ /// removed, collapse toggled, row-wrap) so the toggle always sits flush - /// against the shelf's right edge and the grip fills the rest of the band. + /// against the shelf's right edge and the grip fills the rest of the band. + /// + /// + /// Owner-reported defect fix (2026-09-06, "I dont see the < after I + /// minimize the window"): while COLLAPSED the band is + /// tall (matching an ordinary entry button) instead of the 12px grip band — + /// direct evidence (a real-DAT draw probe, see + /// PluginSidePanelToggleGlyphClipTests) showed the toggle glyph's FILL + /// plane was never actually erased by the 12px band's self-clip (it measured + /// well inside it), so a bare clip fix would not have addressed the report; + /// the real problem is that the collapsed shelf shrinks to a barely-visible + /// 24x12 near-black sliver at the screen edge — several times smaller than + /// every other clickable affordance in the UI. Growing the collapsed tab to + /// button size makes it findable again, and the toggle glyph fills that + /// taller band (vertically centered by the unchanged (Height - + /// dat.LineHeight) * 0.5f formula in ). + /// While EXPANDED the band uses (derived + /// from the font, not the bitmap-fallback constant) so neither the fill nor + /// the border-inflated outline glyph plane clips for any font metrics. + /// + /// private void LayoutChrome() { + float bandHeight = _collapsed ? ButtonExtent : ExpandedGripBandHeight; _grip.Left = 0f; _grip.Top = 0f; _grip.Width = MathF.Max(0f, Width - ToggleWidth); - _grip.Height = GripHeight; + _grip.Height = bandHeight; _toggle.Left = Width - ToggleWidth; _toggle.Top = 0f; _toggle.Width = ToggleWidth; - _toggle.Height = GripHeight; + _toggle.Height = bandHeight; } /// @@ -636,7 +684,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState int row = index % maximumRows; entry.Button.Left = OuterPadding + column * (ButtonExtent + ButtonGap); - entry.Button.Top = GripHeight + OuterPadding + entry.Button.Top = ExpandedGripBandHeight + OuterPadding + row * (ButtonExtent + ButtonGap); entry.Button.Visible = !_collapsed; index++; @@ -647,15 +695,18 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState if (_collapsed) { + // Findability fix: the collapsed tab is exactly one entry + // button's height, not the thin grip band — see LayoutChrome's + // doc comment for the owner-reported symptom this addresses. Width = CollapsedWidth; - Height = GripHeight; + Height = ButtonExtent; } else { Width = OuterPadding * 2f + columns * ButtonExtent + Math.Max(0, columns - 1) * ButtonGap; - Height = GripHeight + Height = ExpandedGripBandHeight + OuterPadding * 2f + rows * ButtonExtent + Math.Max(0, rows - 1) * ButtonGap; diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs index 9105c07b5..579c8fe31 100644 --- a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs +++ b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs @@ -249,7 +249,7 @@ public sealed class PluginSidePanelTests // NEW-1 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md // Slice A): pins the actual Draggable:true -> false change 4fada238e made. // A press on the shelf's own PADDING — left of the first entry button's - // Left=4 (OuterPadding), below the 12px grip band — must never promote + // Left=4 (OuterPadding), below the grip band — must never promote // to a whole-window drag now that Draggable is false; only the grip's // WindowMoveHandle subtree can start a move (see the class doc). Proven // to fail against the PRE-4fada238e shelf: temporarily flipping the @@ -274,7 +274,12 @@ public sealed class PluginSidePanelTests root.Tick(0.016d, 16L); // establishes the initial right-edge dock int pressX = (int)shelf.Left + 2; // left of button.Left (4) - int pressY = (int)shelf.Top + 20; // below the grip band (12) + // Below the grip band: derived from the shelf's own EXPANDED band + // height (owner-reported-defect fix, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md) + // rather than a re-hard-coded literal — with font: null this is + // still the 12px bitmap-fallback band, so the numeric press point is + // unchanged, but the intent now survives a font change. + int pressY = (int)shelf.Top + (int)shelf.ExpandedGripBandHeight + 8; float leftBefore = shelf.Left; float topBefore = shelf.Top; @@ -370,6 +375,46 @@ public sealed class PluginSidePanelTests Assert.False(shelf.CaptureWindowState().Collapsed); } + [Fact] + public void CollapseThenExpand_WhileStillDocked_ReturnsToTheIdenticalLeftAndTop() + { + // Owner-reported defect fix (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md): + // the collapsed tab's HEIGHT changed (button-sized, not the thin grip + // band) — confirms that change alone does not disturb the docked + // anchor math: collapsing shifts Left to preserve the right edge (a + // width change), and expanding back must return to the EXACT same + // Left/Top, not merely an equal Width. + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + root.Tick(0.016d, 16L); // establishes the initial right-edge dock + + float leftBefore = shelf.Left; + float topBefore = shelf.Top; + + shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true)); + // Width shrank while still docked, so Left shifted right to keep the + // right edge fixed; Top never moves on a collapse/expand. + Assert.NotEqual(leftBefore, shelf.Left); + Assert.Equal(topBefore, shelf.Top); + + shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false)); + + Assert.Equal(leftBefore, shelf.Left, precision: 3); + Assert.Equal(topBefore, shelf.Top, precision: 3); + } + [Fact] public void ToggleVisibility_ShowHideShow_AndStaysHiddenWhenANewWindowRegisters() { diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs new file mode 100644 index 000000000..fee36108a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using AcDream.Content; +using AcDream.Plugin.Abstractions; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +namespace AcDream.App.Tests.UI; + +/// +/// Owner-reported defect (2026-09-06): "I dont see the < after I minimize +/// the window" — after collapsing the plugin shelf with the > +/// toggle, the < (expand) glyph was not visible. +/// +/// +/// Reproduction findings (a real-DAT draw probe against font 0x40000000, the +/// shelf's default font): '<' and '>' share IDENTICAL glyph metrics +/// (OffsetY=4, Width=5, Height=7, VerticalOffsetBefore=5 — symmetric +/// characters, as expected), so there is no per-glyph asymmetry to explain +/// "I see one but not the other." Against the PRE-FIX 12px grip band +/// (GripHeight), the FILL glyph plane measured fully INSIDE the band +/// (local y=[3,10] of [0,12]) in both collapsed and expanded states — the +/// text's "16px line box overhangs a 12px band" theory does not, on its own, +/// erase the glyph. What IS measurably true: the border-inflated OUTLINE +/// (background/shadow) plane, which retail draws first +/// (UIElement_Text::DrawSelf), spans y=[-1,14] before clipping and was +/// cropped by the 12px band's self-clip (UiElement.ClipsChildren) to +/// exactly [0,12] — a real, if minor (1px top / 2px bottom), defect. The +/// PRIMARY explanation for the report is discoverability, not erasure: the +/// collapsed shelf shrank to a bare 24x12 near-black sliver at the screen +/// edge, several times smaller than any other clickable affordance in the +/// UI, easy to overlook even though its pixels were, in fact, being drawn. +/// +/// +/// +/// Fix: derives the +/// EXPANDED band from the real font metrics +/// (max(12, font.LineHeight + 2)) so neither the fill nor the +/// border-inflated outline plane can ever clip, for any font. The COLLAPSED +/// tab is now ButtonExtent (28px) tall — the same size as an ordinary +/// entry button — instead of the 12px grip band, making it findable. This +/// file pins both: full containment of the measured ink (fill AND outline) +/// inside the toggle's own clip rect in BOTH states against the REAL +/// installed DAT font, and the collapsed tab's button-sized geometry. +/// +/// +public sealed class PluginSidePanelToggleGlyphClipTests +{ + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static string? ResolveDatDir() + { + string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + + string defaultDir = Path.Combine( + SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(defaultDir) ? defaultDir : null; + } + + /// Decodes (x0,y0)/(x1,y1) of every quad (6 verts / 8 floats each) + /// in a recorded sprite segment — same 8-floats-per-vertex layout every + /// other draw-level test in this tree decodes + /// (UiRenderContextDrawStringDatOutlineTests.DecodeQuads). + private static IEnumerable<(float y0, float y1)> DecodeVerticalSpans(IReadOnlyList verts) + { + const int floatsPerVertex = 8; + const int floatsPerQuad = floatsPerVertex * 6; + for (int i = 0; i + floatsPerQuad <= verts.Count; i += floatsPerQuad) + { + float y0 = verts[i + 1]; + float y1 = verts[i + 8 + 1]; + yield return (y0, y1); + } + } + + /// + /// Independently computes the FULL, unclipped vertical ink span (fill + /// plane union the border-inflated outline plane) that + /// UiRenderContext.DrawStringDatPass/DrawOutlineGlyph would + /// produce for a single centered glyph drawn at + /// (Height - font.LineHeight) * 0.5f (exactly + /// 's formula) in a box of the given + /// — mirroring the production math + /// (UiRenderContext.cs's baseY/gy/outline-inflation + /// lines) independently, so this test can tell a genuinely unclipped draw + /// apart from one whose recorded (already-clipped) geometry merely + /// happens to fill the band. + /// + private static (float top, float bottom) ComputeUnclippedInkSpan( + UiDatFont font, char glyph, float bandHeight) + { + Assert.True(font.TryGetGlyph(glyph, out FontCharDesc g), $"font is missing glyph '{glyph}'"); + + float y = (bandHeight - font.LineHeight) * 0.5f; + float baseY = MathF.Floor(y + 0.5f); + float gy = baseY + g.VerticalOffsetBefore; + float gh = g.Height; + + float top = gy; + float bottom = gy + gh; + if (font.HasBackground) + { + float iy = gy - font.BorderY; + float ih = gh + 2f * font.BorderY; + top = MathF.Min(top, iy); + bottom = MathF.Max(bottom, iy + ih); + } + return (top, bottom); + } + + [Fact] + [Trait("Lane", "InstalledDat")] + public void ToggleGlyph_InkFullyFitsInsideTheGripBand_ExpandedAndCollapsed() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var device = new RecordingGpuDevice(); + var cache = new TextureCache(device, adapter); + UiDatFont? font = UiDatFont.Load(adapter, cache); + Assert.NotNull(font); + + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font); + root.AddChild(shelf); + root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + + root.Tick(0.016d, 16L); + + UiSimpleButton toggle = Assert.Single( + shelf.Children, + c => c is UiSimpleButton && c is not PluginSidePanel.PluginShelfButton) as UiSimpleButton + ?? throw new InvalidOperationException("toggle button not found"); + + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + // ── Expanded state ('>') ──────────────────────────────────────── + Assert.False(shelf.CaptureWindowState().Collapsed); + AssertGlyphInkFullyContained(font!, '>', toggle, renderer, ctx); + + // ── Collapse via the REAL toggle click (production input path, + // not the state controller directly) ─────────────────────────── + int toggleX = (int)shelf.Left + (int)shelf.Width - 8; + int toggleY = (int)shelf.Top + 4; + root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY); + root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY); + Assert.True(shelf.CaptureWindowState().Collapsed); + root.Tick(0.016d, 16L); + + AssertGlyphInkFullyContained(font!, '<', toggle, renderer, ctx); + } + + private static void AssertGlyphInkFullyContained( + UiDatFont font, char glyph, UiSimpleButton toggle, TextRenderer renderer, UiRenderContext ctx) + { + (float expectedTop, float expectedBottom) = ComputeUnclippedInkSpan(font, glyph, toggle.Height); + + Assert.True( + expectedTop >= -0.01f && expectedBottom <= toggle.Height + 0.01f, + $"'{glyph}': computed unclipped ink span [{expectedTop},{expectedBottom}] " + + $"does not fit inside the {toggle.Height}px band — the band is too short for this font's metrics " + + $"(LineHeight={font.LineHeight}, BorderY={font.BorderY})."); + + // Draw the toggle IN ISOLATION (its own DrawSelfAndChildren call, no + // ancestor transform) so the recorded quads are in the toggle's own + // local [0,Width]x[0,Height] space and reflect only its own self-clip + // (UiElement.ClipsChildren's [0,Width]x[0,Height] push). + renderer.Begin(new Vector2(800f, 600f)); + toggle.DrawSelfAndChildren(ctx); + + float observedMinY = float.MaxValue, observedMaxY = float.MinValue; + int quadCount = 0; + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + foreach ((float y0, float y1) in DecodeVerticalSpans(seg.Verts)) + { + observedMinY = MathF.Min(observedMinY, MathF.Min(y0, y1)); + observedMaxY = MathF.Max(observedMaxY, MathF.Max(y0, y1)); + quadCount++; + } + } + + Assert.True(quadCount > 0, $"toggle drew no glyph quads at all for '{glyph}'"); + + // The recorded (already self-clipped) geometry must match the + // INDEPENDENTLY computed unclipped span within a pixel — if the band + // were too short, the recorded span would be narrower than the + // computed one (clipped away), not equal to it. + Assert.Equal(expectedTop, observedMinY, 1); + Assert.Equal(expectedBottom, observedMaxY, 1); + + // And, restated directly in terms of the toggle's own clip rect (the + // form the investigation asked for): every recorded quad lies fully + // inside [0, toggle.Height]. + Assert.True(observedMinY >= -0.01f, $"'{glyph}': ink top ({observedMinY}) is above the band (0)"); + Assert.True( + observedMaxY <= toggle.Height + 0.01f, + $"'{glyph}': ink bottom ({observedMaxY}) overflows the {toggle.Height}px band"); + } + + [Fact] + [Trait("Lane", "InstalledDat")] + public void CollapsedTab_IsButtonSized_ForFindability() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var device = new RecordingGpuDevice(); + var cache = new TextureCache(device, adapter); + UiDatFont? font = UiDatFont.Load(adapter, cache); + Assert.NotNull(font); + + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font); + root.AddChild(shelf); + root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + root.Tick(0.016d, 16L); + + float expandedHeight = shelf.Height; + + shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true)); + + // The collapsed tab is button-sized, not the thin 12px grip band — + // this is the actual owner-reported fix (the tab is findable), not + // merely "the glyph is unclipped". + Assert.True(shelf.Height > 20f, $"collapsed shelf height ({shelf.Height}) is not button-sized"); + Assert.NotEqual(expandedHeight, shelf.Height); + } +}