From 35abbe1d0d0354fe95cf22b901aba929a13da381 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 18:44:28 +0200 Subject: [PATCH] fix #430: tooltip wrap and draw honor the text child's authored margins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third owner-screenshot round: acdream fit one more word per line than retail and drew glyphs flush against the popup's right border. Retail's InqSizewMargins @0x00469660 wraps the glyph list at (bound - m_margL - m_margR) and adds the margins back into the measured width; the popup skins' shared text child 0x10000396 authors margins L=2/R=2 (U=2/D=2 on three of the four skins — live-DAT probed). The presenter now subtracts the horizontal margins from both wrap passes, re-adds them into the measured width used for root sizing, and counts the vertical margins in the measured/re-wrapped heights; the widget's own draw already insets by all four margins (UiText ContentOffsetX + the top/bottom inset), so the right-side spacing returns for free. TooltipSkinLiveDatTests pins the authored margins per skin alongside the P0x3D=256 wrap bound; a new presenter test proves margins shrink the wrap bound and survive onto the widget. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/RetailTooltipPresenter.cs | 31 +++++++--- .../UI/Layout/RetailTooltipPresenterTests.cs | 56 ++++++++++++++++++- .../UI/Layout/TooltipSkinLiveDatTests.cs | 16 +++++- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index 4982e10c..8f54e684 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -660,12 +660,25 @@ public sealed class RetailTooltipPresenter : IDisposable text.Centered = false; text.RightAligned = false; - float measureWrapWidth = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth + // (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on + // the popup skins) participate exactly as InqSizewMargins does: + // GlyphList::Recalculate wraps at (width − margL − margR) and + // the measured result adds the margins back + // (@0x00469762/@0x004697..: `Recalculate(..., w − margL − margR)` + // then `*out += margR + margL`). Without this, line one fit one + // more word than retail (256 vs retail's 252 wrap) and the text + // drew flush against the parchment's right border. + float marginsX = text.MarginLeft + text.MarginRight; + float marginsY = text.MarginTop + text.MarginBottom; + + float wrapBound = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth ? MathF.Max(1f, authoredMaxTextWidth) : MathF.Max(1f, _host.EffectiveCanvasSize.X); + float measureWrapWidth = MathF.Max(1f, wrapBound - marginsX); var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth); - float measuredWidth = measured.Count == 0 ? 0f : measured.Max(measure); - float measuredHeight = measured.Count * lineHeight; + float measuredWidth = + (measured.Count == 0 ? 0f : measured.Max(measure)) + marginsX; + float measuredHeight = measured.Count * lineHeight + marginsY; float requestedWidth = root.Width + (measuredWidth - authoredTextWidth); float requestedHeight = root.Height + (measuredHeight - authoredTextHeight); @@ -697,13 +710,17 @@ public sealed class RetailTooltipPresenter : IDisposable float textFinalHeight = authoredTextHeight + (requestedHeight - authoredRootHeight); - // Pass 3: re-wrap at the final clamped width (RecalculateGlyphList). - var wrapped = UiText.WrapWords(tooltipText, measure, textFinalWidth); + // Pass 3: re-wrap at the final clamped width, inside the margins + // (RecalculateGlyphList wraps glyphs at width − margL − margR). + var wrapped = UiText.WrapWords( + tooltipText, measure, MathF.Max(1f, textFinalWidth - marginsX)); text.LinesProvider = () => wrapped .Select(line => new UiText.Line(line, text.DefaultColor)) .ToArray(); - text.Width = wrapped.Count == 0 ? 0f : MathF.Min(textFinalWidth, wrapped.Max(measure)); - float rewrappedHeight = wrapped.Count * lineHeight; + text.Width = wrapped.Count == 0 + ? 0f + : MathF.Min(textFinalWidth, wrapped.Max(measure) + marginsX); + float rewrappedHeight = wrapped.Count * lineHeight + marginsY; // Pass 4: grow the root's height by the re-wrap's overflow beyond // the text child's post-resize height (width unchanged), through diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 30050e50..3e6d008b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -510,7 +510,52 @@ public sealed class RetailTooltipPresenterTests $"popup height {popup.Height} does not fit {lines.Count} lines"); } - private static void HoverAndDwellWithClampedPopup(UiRoot root, int maxWidth) + [Fact] + public void AuthoredMargins_ShrinkTheWrapBound_AndInsetTheMeasuredWidth() + { + // The real skins' text child (0x10000396) authors margins L=2/R=2 + // (U=2/D=2 on three of four skins). Retail's InqSizewMargins + // @0x00469660 wraps at (bound − margL − margR) and adds the margins + // back into the measured width; without this, line one fit one more + // word than retail and the glyphs drew flush against the popup's + // right border (CA5 owner screenshot pair, 2026-08-24). + var (root, _, _) = CreateHarness(); + string longText = string.Join( + " ", Enumerable.Repeat("description", 40)); + var target = new HoverTarget + { + Left = 100, Top = 100, Width = 40, Height = 20, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = longText, + AuthoredTooltipRootElementId = PopupRootId, + AuthoredTooltipLayoutDid = PopupLayoutDid, + }; + root.AddChild(target); + + // Wide horizontal margins make the difference unmissable at the + // 8px-per-char test measure: the wrap bound is 200−80=120. + HoverAndDwellWithClampedPopup(root, maxWidth: 200, marginX: 40, marginY: 8); + + var text = FindText(root.Children[^1]); + Assert.NotNull(text); + var lines = text!.LinesProvider(); + Assert.True(lines.Count >= 3, $"expected >=3 wrapped lines, got {lines.Count}"); + // Glyphs wrap INSIDE the margins... + Assert.All(lines, l => Assert.True( + l.Text.Length * 8f <= 200f - 80f + 8f, + $"line ignored the margins: {l.Text}")); + // ...and the widget keeps the margins so the draw insets by them + // (the right-side spacing the owner's retail screenshot shows). + Assert.Equal(40f, text.MarginLeft); + Assert.Equal(40f, text.MarginRight); + // Height accounts for the vertical margins on top of the lines. + float lineHeight = 14f; + Assert.True(root.Children[^1].Height >= lines.Count * lineHeight + 16f, + $"popup height {root.Children[^1].Height} lost the vertical margins"); + } + + private static void HoverAndDwellWithClampedPopup( + UiRoot root, int maxWidth, int marginX = 0, int marginY = 0) { // The REAL skins bound the wrap on the TEXT CHILD: 0x10000396 // authors P0x3D=256 on all four popup skins (live-DAT probed @@ -520,7 +565,16 @@ public sealed class RetailTooltipPresenterTests { ImportedLayout layout = BuildPopup(); if (layout.FindElement(TextChildId) is { } textChild) + { textChild.AuthoredResizeMaxWidth = maxWidth; + if (textChild is UiText t) + { + t.MarginLeft = marginX; + t.MarginRight = marginX; + t.MarginTop = marginY; + t.MarginBottom = marginY; + } + } return layout; }); HoverAndDwell(root); diff --git a/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs index 764ef742..ae067638 100644 --- a/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs @@ -29,16 +29,26 @@ public sealed class TooltipSkinLiveDatTests Assert.NotNull(tree); int textChildren = 0; - void Walk(ElementInfo e) + void Walk(ElementInfo e, uint parentId) { if (e.Id == 0x10000396u && e.Type == 12) { textChildren++; Assert.Equal(256, e.MaxWidth); + // Retail wraps at (256 - margL - margR) and draws inset by + // the margins — the CA5 owner-screenshot delta (one extra + // word on line one + text flush against the right border). + // All four skins author L=2/R=2; skin 0x10000398 alone + // authors no vertical margins (live-DAT probe 2026-08-24). + Assert.Equal(2, e.MarginLeft); + Assert.Equal(2, e.MarginRight); + int vertical = parentId == 0x10000398u ? 0 : 2; + Assert.Equal(vertical, e.MarginTop); + Assert.Equal(vertical, e.MarginBottom); } - foreach (var c in e.Children) Walk(c); + foreach (var c in e.Children) Walk(c, e.Id); } - Walk(tree!); + Walk(tree!, 0); Assert.True(textChildren >= 4, $"expected the text child under all four popup skins, found {textChildren}"); }