fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:
- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
draws its own authored DirectState media (a synthetic parameterless
grip still draws nothing, preserving existing resize-drag tests).
DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
live grips on the main chat window now resolve a non-zero sprite,
restoring the visible borders/corners CH6a silently dropped.
- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
distinct from every real window id (0-4), fixing the bug where the
main window's explicit-addressing branch coincided with the broadcast
check (both were literal 0). SetFilter's main-window no-op is dropped
— the main window's filter is now genuinely settable. ChatWindowController
.Bind takes a ChatWindowState (the same canonical instance the floating
windows already share) and GetTranscriptLines builds a real accept
predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
never reaches ChatLog (AddText routes it to the SpewBox and returns),
so nothing observable regresses.
- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
indicator buttons (DAT property 0x0B=true, no retail click handler)
from flipping their own Selected mirror on a stray click.
- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
the real installed dats; added the permanent RetailLayoutFixtureGenerator
entry. All three flagged FloatingChatWindowController assumptions
(input field, title bar, close button) are confirmed correct against
real data — no controller code changes needed. New finding: unlike the
main window, ALL EIGHT floaty border/corner elements are live Type-9
grips (the floaty's own title bar is its move handle), so a floaty
window resizes from every edge and corner.
- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
200-line-tail vs retail's per-window 10,000-line scrollback depth gap.
- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
asymmetry and the reconnect-preserves-filters intent; corrected the
research doc's modifier-mask mislabel and the "ONLY function" false
superlative; moved WrapText off ChatWindowController onto
ChatTranscriptRenderer, closing the circular dependency.
Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
56b84deeab
commit
1aa7709988
23 changed files with 6284 additions and 248 deletions
|
|
@ -19,6 +19,13 @@ namespace AcDream.App.UI.Layout;
|
|||
/// (revision/wrap-width/font keyed), matching the caching each controller
|
||||
/// already had before this extraction.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="WrapText"/> also lives here (CH6a/b REJECT-review NIT 5 — it
|
||||
/// used to live on <see cref="ChatWindowController"/>, which made
|
||||
/// <see cref="BuildLines"/> call BACK into its own caller's class, a circular
|
||||
/// dependency between this "shared" module and one of its two consumers).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ChatTranscriptRenderer
|
||||
{
|
||||
|
|
@ -28,12 +35,15 @@ internal static class ChatTranscriptRenderer
|
|||
/// <param name="accept">
|
||||
/// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c>
|
||||
/// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null
|
||||
/// accepts every line (the main window has no user filter — color-table
|
||||
/// research doc §4). A line that fails the filter is dropped from this
|
||||
/// window's view WITHOUT advancing the carried-forward color, matching
|
||||
/// retail's <c>m_curFontColor</c> only advancing for lines actually
|
||||
/// appended to THIS window's own scroll (<c>AppendStringInfoWithFont</c>
|
||||
/// only runs for displayed lines).
|
||||
/// accepts every line. CH6a/b REJECT-review SHOULD-FIX 2: the main
|
||||
/// window now ALWAYS passes a real predicate too (its own retail default
|
||||
/// 0xFBFFFFFF filter via <c>ChatWindowState</c>) — null remains supported
|
||||
/// for callers with no filter concept at all (there are none in
|
||||
/// production today, but the shape stays general). A line that fails the
|
||||
/// filter is dropped from this window's view WITHOUT advancing the
|
||||
/// carried-forward color, matching retail's <c>m_curFontColor</c> only
|
||||
/// advancing for lines actually appended to THIS window's own scroll
|
||||
/// (<c>AppendStringInfoWithFont</c> only runs for displayed lines).
|
||||
/// </param>
|
||||
public static List<UiText.Line> BuildLines(
|
||||
IReadOnlyList<FormattedLine> detailed,
|
||||
|
|
@ -57,9 +67,89 @@ internal static class ChatTranscriptRenderer
|
|||
continue;
|
||||
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
|
||||
currentColor = resolved;
|
||||
foreach (string frag in ChatWindowController.WrapText(d.Text, maxW, measure))
|
||||
foreach (string frag in WrapText(d.Text, maxW, measure))
|
||||
result.Add(new UiText.Line(frag, currentColor));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
||||
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
||||
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
||||
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
||||
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
||||
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
||||
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
||||
/// </summary>
|
||||
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
yield return string.Empty;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
|
||||
// reply) carries embedded '\n's. This function used to hand the
|
||||
// WHOLE blob — newlines and all — to the single early-out below,
|
||||
// rendering multi-line text as one UiText.Line with literal newline
|
||||
// characters in it instead of one rendered line per segment. Split
|
||||
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
|
||||
// word-wrap each segment independently; the early-out is now scoped
|
||||
// to one already-newline-free segment, so it only ever collapses a
|
||||
// single-segment text to one line, never a multi-line one.
|
||||
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||
foreach (string segment in normalized.Split('\n'))
|
||||
{
|
||||
foreach (string frag in WrapSingleLine(segment, maxW, measure))
|
||||
yield return frag;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word-wrap for a single, already newline-free line. Split out of
|
||||
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
|
||||
/// multi-segment split there can call this once per '\n'-delimited
|
||||
/// segment without re-deriving the per-line wrap algorithm.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
|
||||
{
|
||||
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
|
||||
{
|
||||
yield return text;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var line = new System.Text.StringBuilder();
|
||||
foreach (var word in text.Split(' '))
|
||||
{
|
||||
string sep = line.Length > 0 ? " " : string.Empty;
|
||||
if (measure(line.ToString() + sep + word) <= maxW)
|
||||
{
|
||||
line.Append(sep).Append(word); // fits on the current line
|
||||
continue;
|
||||
}
|
||||
if (line.Length > 0 && measure(word) <= maxW)
|
||||
{
|
||||
yield return line.ToString(); // word fits alone → push to a new line
|
||||
line.Clear();
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
// Word too long for any single line: char-wrap it, packing onto the current
|
||||
// line's remaining space first (keeps the prefix with the message start).
|
||||
if (line.Length > 0) line.Append(' ');
|
||||
foreach (char ch in word)
|
||||
{
|
||||
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
|
||||
{
|
||||
yield return line.ToString();
|
||||
line.Clear();
|
||||
}
|
||||
line.Append(ch);
|
||||
}
|
||||
}
|
||||
if (line.Length > 0) yield return line.ToString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue