feat(chat): CT-C2 Escape leaves the entry; CT-C3 the timestamp is grey

Campaign CT slices C2 and C3.

**C2 — Escape in the chat input did nothing at all.** Not "did the wrong
thing": nothing. Two independent facts had to hold for that. UiField has no
Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip
its own fallback and the input dispatcher withhold game actions — so the player
had no way out of the bar except the mouse.

Retail maps Escape to input action 0x0B, which runs
ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then
Deactivate. It does NOT clear the field. That is worth stating because the
obvious guess — "Escape clears the input" — is wrong and would have looked
perfectly reasonable; a half-written message survives stepping away from the
bar, and the test pins that rather than just pinning "handled".

**C3 — the timestamp took the message's colour.** Retail appends it as its own
run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0
fills with colorGrey) rather than the line's, so it stays grey whether the
message is red combat text or white speech.

Most of C3 was already done and stayed untouched: the DisplayTimeStamps option
is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ".
Only the colour was wrong, and it was only fixable now because A1/A4 made a
line able to carry more than one colour.

The stamp is a span ROLE rather than a second tag type: it is not clickable and
carries no payload, so modelling it as a tag would have made it hit-testable
for no reason. Its colour comes from the same runtime table every message
colour comes from, unlike the tagged-name colour, which is authored per element
(0x1D) and deliberately lives elsewhere.

One consequence worth naming: a timestamped line now needs runs even when its
sender is not tagged, because the stamp alone is reason enough. Before this,
only tagged lines got runs.

Also verified and NOT changed, having checked rather than assumed: C1's
auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples
"was at the end" BEFORE applying new extents and only re-sticks if so, which is
exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces
to the unread indicator, which does not exist yet.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 08:28:45 +02:00
parent 2f97052e4f
commit 9f6d79b7e0
6 changed files with 167 additions and 10 deletions

View file

@ -65,6 +65,23 @@ internal static class ChatTranscriptRenderer
/// unchanged" rule — see <see cref="RetailChatColorTable"/>'s own doc).
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
/// </param>
/// <summary>
/// The colour retail gives the timestamp prefix — chat colour index
/// <c>0x0C</c>, which
/// <c>ChatInterface::BuildChatColorLookupTable @0x004F31C0</c> fills with
/// <c>colorGrey</c>.
/// </summary>
/// <remarks>
/// Read from the same table every message colour comes from rather than
/// hard-coded, so it cannot drift from the rest of the palette. This one
/// IS a table index, unlike the tagged-name colour, which is authored per
/// element (property <c>0x1D</c>) and deliberately lives elsewhere.
/// </remarks>
private static Vector4 TimestampColor =>
RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey)
? grey
: new Vector4(0.5f, 0.5f, 0.5f, 1f);
/// <summary>
/// Retail's transcript character budget:
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>
@ -156,11 +173,23 @@ internal static class ChatTranscriptRenderer
if (to <= from)
continue;
// Retail appends the timestamp at a FIXED colour index (0x0C)
// rather than the message's, so it stays grey whatever colour the
// line is — RecvNotice_DisplayFinalStringInfo @0x004F4640 passes
// 0xc for that run and the line's own type for the body.
bool tagged = span.Tag is not null;
sawTag |= tagged;
bool stamped = span.Role == ChatSpanRole.Timestamp;
sawTag |= tagged || stamped;
Vector4 color = tagged
? tagColor
: stamped
? TimestampColor
: lineColor;
runs.Add(new UiText.TextRun(
span.Text.Substring(from - spanStart, to - from),
tagged ? tagColor : lineColor));
color));
}
return sawTag ? runs : null;

View file

@ -815,6 +815,21 @@ public sealed class UiField : UiElement
bool shift = Selectable && ShiftHeld();
switch (key)
{
// Campaign CT slice C2. Retail maps Escape to input action
// 0x0B, which runs ChatInterface::DeactivateChatEntry
// @0x004F2FC0: RelinquishFocus, then Deactivate. It does
// NOT clear the field — whatever you had typed is still
// there when you come back.
//
// Without this the key was a complete no-op: there is no
// Escape case, and a focused field also reports
// IsEditControl, which makes UiRoot skip its own fallback
// and the input dispatcher withhold game actions. So the
// player had no way out except the mouse.
case Silk.NET.Input.Key.Escape:
FindRoot()?.SetKeyboardFocus(null);
return true;
case Silk.NET.Input.Key.Enter:
case Silk.NET.Input.Key.KeypadEnter:
if (!OneLine)

View file

@ -51,8 +51,26 @@ public readonly record struct ChatTextTag(string Type, string Format, string Dat
}
}
/// <summary>What a span IS, for colouring purposes.</summary>
public enum ChatSpanRole
{
/// <summary>Ordinary message text; takes the line's own colour.</summary>
Body,
/// <summary>
/// The leading timestamp. Retail appends it as its own run at a FIXED
/// colour index (<c>0x0C</c>) rather than the line's
/// (<c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>),
/// so it stays grey whatever colour the message is.
/// </summary>
Timestamp,
}
/// <summary>One stretch of chat text, and the tag covering it (if any).</summary>
public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag);
public readonly record struct ChatTextSpan(
string Text,
ChatTextTag? Tag,
ChatSpanRole Role = ChatSpanRole.Body);
/// <summary>
/// Parses retail's inline chat tag markup into spans.

View file

@ -424,8 +424,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
// Spans is the sidecar that remembers which stretch was the
// speaker's name. Campaign CT slice A3: this is the point where
// sender identity used to die.
bool tagged = ShouldTagSender(entry);
string markup = FormatEntryTagged(entry);
IReadOnlyList<ChatTextSpan>? spans = ShouldTagSender(entry)
IReadOnlyList<ChatTextSpan>? spans = tagged
? ChatTagMarkup.Parse(markup)
: null;
string text = spans is null
@ -435,13 +436,14 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
if (timestamps)
{
string prefix = ChatLog.FormatTimestampPrefix(entry.Received);
// The stamp is its own run: retail appends it at a FIXED
// colour index rather than the message's, so it stays grey
// whatever colour the line is. That means a timestamped line
// needs spans even when its sender is not tagged.
spans = new[] { new ChatTextSpan(prefix, null, ChatSpanRole.Timestamp) }
.Concat(spans ?? new[] { new ChatTextSpan(text, null) })
.ToArray();
text = prefix + text;
// The prefix is its own untagged stretch in front, so the
// spans keep lining up with the visible text.
if (spans is not null)
spans = new[] { new ChatTextSpan(prefix, null) }
.Concat(spans)
.ToArray();
}
lines[i] = new FormattedLine(

View file

@ -228,4 +228,68 @@ public sealed class ChatTranscriptRunsTests
[Fact]
public void TheBudgetIsRetailsOwnNumber()
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);
// ── CT-C3: the timestamp is its own colour ──────────────────────────
[Fact]
public void TheTimestampIsGreyWhateverColourTheMessageIs()
{
// Retail appends the stamp at a FIXED colour index (0x0C, colorGrey)
// rather than the message's, so a red combat line and a white say line
// carry the same grey stamp.
var spans = new[]
{
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
new ChatTextSpan("Dww says, \"hi\"", null),
};
IReadOnlyList<UiText.TextRun>? runs = ChatTranscriptRenderer.RunsForFragment(
spans,
fragmentStart: 0,
fragmentLength: spans[0].Text.Length + spans[1].Text.Length,
LineColor,
TagGreen);
Assert.NotNull(runs);
Assert.Equal(2, runs!.Count);
Assert.Equal("13:05:09 ", runs[0].Text);
Assert.NotEqual(LineColor, runs[0].Color); // NOT the message colour
Assert.Equal(LineColor, runs[1].Color); // the body still is
}
[Fact]
public void ATimestampedLineGetsRunsEvenWithNoTaggedSender()
{
// The stamp alone is reason enough to need runs — before CT-C3 a line
// only got them when its sender was tagged, so an untagged line's
// stamp took the message colour.
var spans = new[]
{
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
new ChatTextSpan("Welcome.", null),
};
Assert.NotNull(ChatTranscriptRenderer.RunsForFragment(
spans, 0, spans[0].Text.Length + spans[1].Text.Length,
LineColor, TagGreen));
}
[Fact]
public void ATimestampAndATaggedNameKeepSeparateColours()
{
var spans = new[]
{
new ChatTextSpan("13:05:09 ", null, ChatSpanRole.Timestamp),
new ChatTextSpan("Dww", new ChatTextTag("Tell", "IIDString", "1:Dww")),
new ChatTextSpan(" tells you", null),
};
IReadOnlyList<UiText.TextRun> runs = ChatTranscriptRenderer.RunsForFragment(
spans, 0, 9 + 3 + 10, LineColor, TagGreen)!;
Assert.Equal(3, runs.Count);
Assert.Equal(TagGreen, runs[1].Color);
Assert.NotEqual(TagGreen, runs[0].Color);
Assert.NotEqual(runs[0].Color, runs[2].Color);
}
}

View file

@ -258,4 +258,33 @@ public class UiFieldTests
Assert.Equal("hi ", input.Text);
}
// ── CT-C2: Escape leaves the chat entry ─────────────────────────────
[Fact]
public void EscapeIsHandledAndKeepsWhatWasTyped()
{
// Retail's DeactivateChatEntry @0x004F2FC0 relinquishes focus and
// deactivates; it does NOT clear the field, so a half-written message
// survives stepping away from the bar.
var input = new UiField();
input.SetText("half written");
bool handled = input.OnEvent(new UiEvent(
0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape));
Assert.True(handled);
Assert.Equal("half written", input.Text);
}
[Fact]
public void EscapeIsIgnoredWhenTheFieldIsNotEditable()
{
// A read-only field returns early before the key switch, so Escape
// must not acquire behaviour there.
var input = new UiField { Editable = false };
Assert.True(input.OnEvent(new UiEvent(
0, input, UiEventType.KeyDown, Data0: (int)Silk.NET.Input.Key.Escape)));
}
}