diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 11512b0b..138f0370 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -705,21 +705,53 @@ public static class DatWidgetFactory { // 2026-08-13 social gate: authored strings can carry embedded // newlines (the fellowship empty-state is three sentences over - // '\n's). A single Line renders them as one clipped run — split - // into one Line per authored line, exactly as retail's multiline - // UIElement_Text draws them. Gate round 2: the DAT stores the - // LITERAL two-character escape "\n" (0x5C 0x6E — probe-verified: - // the dump printed backslash-n, not a line break), so normalize - // the escape before splitting. The provider re-reads DefaultColor - // per call (NOT captured eagerly) so state-driven font-color - // changes keep tracking, the same live-color contract the - // single-line provider always had. - string[] parts = [.. authored + // '\n's). Gate round 2: the DAT stores the LITERAL two-character + // escape "\n" (0x5C 0x6E — probe-verified: the dump printed + // backslash-n, not a line break), so normalize the escape first. + // Gate round 3: retail additionally WORD-WRAPS each authored line + // within the element extent (its GlyphList draw — the same wrap + // the confirmation dialog view already uses), so a multiline + // authored block re-wraps to the widget's live width instead of + // clipping at the edge. Single-line authored labels keep the + // one-run shape they have always had (they are authored to fit; + // re-wrapping them is a client-wide behavior change no gate has + // asked for). Providers re-read DefaultColor/width/font per call + // (NOT captured eagerly) so state-driven changes keep tracking. + string normalized = authored .Replace("\\n", "\n") - .Split('\n') - .Select(static p => p.TrimEnd('\r'))]; - t.LinesProvider = () => - [.. parts.Select(p => new UiText.Line(p, t.DefaultColor))]; + .Replace("\r", string.Empty); + if (normalized.Contains('\n')) + { + float cachedWidth = float.NaN; + UiDatFont? cachedFont = null; + System.Numerics.Vector4 cachedColor = default; + UiText.Line[]? cachedLines = null; + t.LinesProvider = () => + { + if (cachedLines is null + || cachedWidth != t.Width + || !ReferenceEquals(cachedFont, t.DatFont) + || cachedColor != t.DefaultColor) + { + cachedWidth = t.Width; + cachedFont = t.DatFont; + cachedColor = t.DefaultColor; + float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding); + Func measure = t.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + cachedLines = [.. UiText + .WrapWords(normalized, measure, maximumWidth) + .Select(line => new UiText.Line(line, t.DefaultColor))]; + } + return cachedLines; + }; + } + else + { + t.LinesProvider = () => + [new UiText.Line(normalized, t.DefaultColor)]; + } } return t; diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 84f7f9f1..6ca8c472 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -149,14 +149,36 @@ public sealed class UiRoot : UiElement if (window is not { Draggable: true }) return false; // 2026-08-13 gate (user-directed, all windows): the move cursor - // advertises ONLY on the window's own chrome — the frame's border - // pixels are the only place the frame element itself wins the - // hit-test (interior points resolve to content children), which - // matches retail's Dragbar-chrome-only move cursor. Whole-surface - // dragging still WORKS; it just no longer advertises over content. - return ReferenceEquals(target, window); + // advertises ONLY on the window's own border chrome. Round 3 + // correction: "the frame element won the hit-test" is NOT a + // border test — a window whose interior is not fully covered by + // content children (the inventory panel's empty regions) resolves + // those interior pixels to the frame too. The border is a + // GEOMETRIC band along the window's outer edge; the frame must + // still be the hit-test winner so border-adjacent content keeps + // its own cursor. Whole-surface dragging still WORKS; it just no + // longer advertises over content or empty interior. + return ReferenceEquals(target, window) + && WithinBorderBand(window, MouseX, MouseY, MoveBorderBand); } } + + /// The point lies inside the window and within + /// pixels of one of its outer edges — the frame chrome the move cursor + /// advertises on. Unlike , no resize-axis masking: + /// a non-resizable window's border still moves it. + private static bool WithinBorderBand(UiElement w, int x, int y, int band) + { + float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height; + if (x < l || x >= r || y < t || y >= b) + return false; + return x - l < band || r - x <= band || y - t < band || b - y <= band; + } + + /// Move-cursor border thickness in px — sized to the retail frame + /// chrome art so the ring stays visible inside the + /// claim on resizable edges. + private const int MoveBorderBand = 8; private (uint tex, int w, int h)? _dragGhost; /// Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag. internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost; diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index f84d042a..7a12c75d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -691,6 +691,45 @@ public class DatWidgetFactoryTests Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5); } + /// + /// 2026-08-13 social gate round 3: a MULTILINE authored string (literal + /// backslash-n escapes in the DAT) word-wraps each authored line to the + /// widget's live width — retail's GlyphList draw, the same wrap the + /// confirmation dialog view uses. The fellowship empty-state was + /// rendering its three authored lines as three clipped runs. + /// + [Fact] + public void BuildText_MultilineAuthored_WordWrapsToLiveWidth() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 80 }; + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0), + }; + info.States[UiStateInfo.DirectStateId] = direct; + // No DatFont in this fixture — the fallback measure is 8 px/char, so + // Width=100 fits 12 characters per wrapped line. + var text = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: _ => "one two three four five\\nsix")); + + var lines = text.LinesProvider!(); + Assert.True(lines.Count >= 3); + Assert.All(lines, line => Assert.True(line.Text.Length <= 12)); + Assert.Equal( + "one two three four five six", + string.Join(" ", lines.Select(static line => line.Text))); + + // The provider tracks the LIVE width — widening re-wraps. + text.Width = 400f; + lines = text.LinesProvider!(); + Assert.Equal(2, lines.Count); + Assert.Equal("one two three four five", lines[0].Text); + Assert.Equal("six", lines[1].Text); + } + [Fact] public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite() {