diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index fa430f7c..21e9071d 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -65,6 +65,23 @@ internal static class ChatTranscriptRenderer /// unchanged" rule — see 's own doc). /// Callers pass their transcript's . /// + /// + /// The colour retail gives the timestamp prefix — chat colour index + /// 0x0C, which + /// ChatInterface::BuildChatColorLookupTable @0x004F31C0 fills with + /// colorGrey. + /// + /// + /// 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 0x1D) and deliberately lives elsewhere. + /// + private static Vector4 TimestampColor => + RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey) + ? grey + : new Vector4(0.5f, 0.5f, 0.5f, 1f); + /// /// Retail's transcript character budget: /// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640 @@ -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; diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 7f025c5f..60636d11 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -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) diff --git a/src/AcDream.Core/Chat/ChatTagMarkup.cs b/src/AcDream.Core/Chat/ChatTagMarkup.cs index edf77892..cd136601 100644 --- a/src/AcDream.Core/Chat/ChatTagMarkup.cs +++ b/src/AcDream.Core/Chat/ChatTagMarkup.cs @@ -51,8 +51,26 @@ public readonly record struct ChatTextTag(string Type, string Format, string Dat } } +/// What a span IS, for colouring purposes. +public enum ChatSpanRole +{ + /// Ordinary message text; takes the line's own colour. + Body, + + /// + /// The leading timestamp. Retail appends it as its own run at a FIXED + /// colour index (0x0C) rather than the line's + /// (ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640), + /// so it stays grey whatever colour the message is. + /// + Timestamp, +} + /// One stretch of chat text, and the tag covering it (if any). -public readonly record struct ChatTextSpan(string Text, ChatTextTag? Tag); +public readonly record struct ChatTextSpan( + string Text, + ChatTextTag? Tag, + ChatSpanRole Role = ChatSpanRole.Body); /// /// Parses retail's inline chat tag markup into spans. diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 5c17d437..30081845 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -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? spans = ShouldTagSender(entry) + IReadOnlyList? 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( diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs index 664c3a6c..8e705d6d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -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? 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 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); + } } diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs index 3184f7d9..3141e1f1 100644 --- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs +++ b/tests/AcDream.App.Tests/UI/UiFieldTests.cs @@ -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))); + } }