fix: social gate round 3 - authored multiline word wrap + geometric
move-cursor border band - Empty-state text (round 3): the literal-\n split was correct but each authored LINE rendered as one clipped run. Retail word-wraps each authored line within the element extent (its GlyphList draw - the same wrap RetailConfirmationDialogView already uses). Multiline authored text now wraps through UiText.WrapWords against the widget's LIVE width/font/color (cached per width+font+color, re-read per call). Single-line authored labels keep their one-run shape - re-wrapping every label is a client-wide change no gate asked for. - Move cursor (round 3): "the frame won the hit-test" is not a border test - windows whose interior is not fully covered by children (the inventory panel's empty regions) resolve those pixels to the frame too. The border is now a geometric 8 px band along the window's outer edge, AND the frame must win the hit-test so border-adjacent content keeps its own cursor. Resize-edge claim still takes precedence; whole-surface dragging unchanged. App suite 4,984/3 skips (new BuildText_MultilineAuthored wrap test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
67fe754dd6
commit
4943484eb9
3 changed files with 113 additions and 20 deletions
|
|
@ -705,21 +705,53 @@ public static class DatWidgetFactory
|
||||||
{
|
{
|
||||||
// 2026-08-13 social gate: authored strings can carry embedded
|
// 2026-08-13 social gate: authored strings can carry embedded
|
||||||
// newlines (the fellowship empty-state is three sentences over
|
// newlines (the fellowship empty-state is three sentences over
|
||||||
// '\n's). A single Line renders them as one clipped run — split
|
// '\n's). Gate round 2: the DAT stores the LITERAL two-character
|
||||||
// into one Line per authored line, exactly as retail's multiline
|
// escape "\n" (0x5C 0x6E — probe-verified: the dump printed
|
||||||
// UIElement_Text draws them. Gate round 2: the DAT stores the
|
// backslash-n, not a line break), so normalize the escape first.
|
||||||
// LITERAL two-character escape "\n" (0x5C 0x6E — probe-verified:
|
// Gate round 3: retail additionally WORD-WRAPS each authored line
|
||||||
// the dump printed backslash-n, not a line break), so normalize
|
// within the element extent (its GlyphList draw — the same wrap
|
||||||
// the escape before splitting. The provider re-reads DefaultColor
|
// the confirmation dialog view already uses), so a multiline
|
||||||
// per call (NOT captured eagerly) so state-driven font-color
|
// authored block re-wraps to the widget's live width instead of
|
||||||
// changes keep tracking, the same live-color contract the
|
// clipping at the edge. Single-line authored labels keep the
|
||||||
// single-line provider always had.
|
// one-run shape they have always had (they are authored to fit;
|
||||||
string[] parts = [.. authored
|
// 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")
|
.Replace("\\n", "\n")
|
||||||
.Split('\n')
|
.Replace("\r", string.Empty);
|
||||||
.Select(static p => p.TrimEnd('\r'))];
|
if (normalized.Contains('\n'))
|
||||||
t.LinesProvider = () =>
|
{
|
||||||
[.. parts.Select(p => new UiText.Line(p, t.DefaultColor))];
|
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<string, float> 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;
|
return t;
|
||||||
|
|
|
||||||
|
|
@ -149,14 +149,36 @@ public sealed class UiRoot : UiElement
|
||||||
if (window is not { Draggable: true })
|
if (window is not { Draggable: true })
|
||||||
return false;
|
return false;
|
||||||
// 2026-08-13 gate (user-directed, all windows): the move cursor
|
// 2026-08-13 gate (user-directed, all windows): the move cursor
|
||||||
// advertises ONLY on the window's own chrome — the frame's border
|
// advertises ONLY on the window's own border chrome. Round 3
|
||||||
// pixels are the only place the frame element itself wins the
|
// correction: "the frame element won the hit-test" is NOT a
|
||||||
// hit-test (interior points resolve to content children), which
|
// border test — a window whose interior is not fully covered by
|
||||||
// matches retail's Dragbar-chrome-only move cursor. Whole-surface
|
// content children (the inventory panel's empty regions) resolves
|
||||||
// dragging still WORKS; it just no longer advertises over content.
|
// those interior pixels to the frame too. The border is a
|
||||||
return ReferenceEquals(target, window);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The point lies inside the window and within <paramref name="band"/>
|
||||||
|
/// pixels of one of its outer edges — the frame chrome the move cursor
|
||||||
|
/// advertises on. Unlike <see cref="HitEdges"/>, no resize-axis masking:
|
||||||
|
/// a non-resizable window's border still moves it.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Move-cursor border thickness in px — sized to the retail frame
|
||||||
|
/// chrome art so the ring stays visible inside the <see cref="ResizeGrip"/>
|
||||||
|
/// claim on resizable edges.</summary>
|
||||||
|
private const int MoveBorderBand = 8;
|
||||||
private (uint tex, int w, int h)? _dragGhost;
|
private (uint tex, int w, int h)? _dragGhost;
|
||||||
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
|
/// <summary>Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag.</summary>
|
||||||
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
|
internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;
|
||||||
|
|
|
||||||
|
|
@ -691,6 +691,45 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
|
Assert.Equal(204f / 255f, text.LinesProvider()[0].Color.X, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<UiText>(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]
|
[Fact]
|
||||||
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue