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>
483 lines
22 KiB
C#
483 lines
22 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
|
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the chat panel. Reads the tail of <see cref="ChatLog"/>
|
|
/// and formats each <see cref="ChatEntry"/> into a single display line.
|
|
///
|
|
/// <para>
|
|
/// Formatting lives here (not in the panel) so the same rendering logic
|
|
/// survives the Phase D.2b backend swap — under the custom retail-look
|
|
/// toolkit we'll want different per-<see cref="ChatKind"/> styling, but
|
|
/// the plain-text form is the fallback and the starting point.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retained UI consumers can key formatted-layout caches from
|
|
/// <see cref="Revision"/>. The revision advances on append and clear, so an
|
|
/// unchanged transcript does not require a queue snapshot each frame.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|
{
|
|
/// <summary>Default number of tail entries rendered.</summary>
|
|
public const int DefaultDisplayLimit = 20;
|
|
|
|
private readonly ChatLog _log;
|
|
private readonly ChatCommandTargetState _commandTargets;
|
|
private readonly bool _ownsCommandTargets;
|
|
private readonly int _displayLimit;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Sender name of the most recent INCOMING Tell. Drives the
|
|
/// <c>/r</c> reply slash command in <see cref="ChatInputParser"/>.
|
|
/// Null until the first Tell arrives. Outgoing self-sent Tell
|
|
/// echoes (which run through <see cref="ChatLog.OnSelfSent"/>) do
|
|
/// NOT update this — we discriminate by <c>SenderGuid != 0</c>;
|
|
/// only real inbound tells from <see cref="ChatLog.OnTellReceived"/>
|
|
/// carry a non-zero guid. Mirrors holtburger
|
|
/// <c>chat.rs::ChatState::last_incoming_tell_sender</c> (line 74 +
|
|
/// the assignment at line 152).
|
|
/// </summary>
|
|
public string? LastIncomingTellSender =>
|
|
_commandTargets.LastIncomingTellSender;
|
|
|
|
/// <summary>
|
|
/// Target of the most recent OUTGOING Tell (the player's own
|
|
/// <c>/tell <name> …</c>). Drives the <c>/retell <msg></c>
|
|
/// (or <c>@retell</c>) slash command, which resends to the same
|
|
/// target. Mirrors retail's <c>@retell</c>. Self-sent echoes flow
|
|
/// through <see cref="ChatLog.OnSelfSent"/> with
|
|
/// <c>SenderGuid == 0</c> and the target name in <c>Sender</c> —
|
|
/// that's the discriminator we capture here.
|
|
/// </summary>
|
|
public string? LastOutgoingTellTarget =>
|
|
_commandTargets.LastOutgoingTellTarget;
|
|
|
|
/// <summary>
|
|
/// Optional callback exposing the live framerate. Wired by
|
|
/// <c>GameWindow</c> at construction so the client-side
|
|
/// <c>/framerate</c> command can print "Framerate: 144.2 FPS"
|
|
/// into chat without the panel knowing about the render-loop.
|
|
/// </summary>
|
|
public Func<float>? FpsProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional callback exposing the local player's world position.
|
|
/// Used by <c>/loc</c> to print
|
|
/// "Location: (123.4, 567.8, 60.0)". Wired by <c>GameWindow</c>.
|
|
/// </summary>
|
|
public Func<Vector3>? PositionProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional hook routing retail-<c>0x1A</c> (<see
|
|
/// cref="RetailLogTextType.ClientLocal"/>) interface text — command
|
|
/// refusals and bad-argument usage lines — to the SpewBox instead of
|
|
/// the chat transcript. <c>AcDream.UI.Abstractions</c> must stay
|
|
/// Runtime-independent (Code Structure Rules), so it cannot call
|
|
/// <c>RuntimeCommunicationState.AddText</c> directly; the App-layer
|
|
/// composition host wires this the same way it wires
|
|
/// <see cref="FpsProvider"/>/<see cref="PositionProvider"/>. Closes
|
|
/// ISSUES.md #367 / register row AP-186 — <see cref="ChatCommandRouter"/>
|
|
/// no longer has to render every <c>0x1A</c> refusal through the chat
|
|
/// scroll.
|
|
/// </summary>
|
|
public Action<string>? OnInterfaceText { get; init; }
|
|
|
|
/// <summary>Monotonic revision of the underlying transcript content.</summary>
|
|
public long Revision => _log.Revision;
|
|
|
|
/// <summary>
|
|
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
|
|
/// </summary>
|
|
/// <param name="log">Live chat log. Never null.</param>
|
|
/// <param name="displayLimit">
|
|
/// Maximum number of tail entries to surface per
|
|
/// <see cref="RecentLines"/> call. Must be >= 1. Defaults to
|
|
/// <see cref="DefaultDisplayLimit"/>.
|
|
/// </param>
|
|
public ChatVM(
|
|
ChatLog log,
|
|
int displayLimit = DefaultDisplayLimit,
|
|
ChatCommandTargetState? commandTargets = null)
|
|
{
|
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
|
if (displayLimit < 1)
|
|
throw new ArgumentOutOfRangeException(nameof(displayLimit), displayLimit, "must be >= 1");
|
|
_displayLimit = displayLimit;
|
|
_commandTargets = commandTargets ?? new ChatCommandTargetState(_log);
|
|
_ownsCommandTargets = commandTargets is null;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
if (_ownsCommandTargets)
|
|
_commandTargets.Dispose();
|
|
_disposed = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append a client-side system line to the chat log. Used by
|
|
/// client-handled commands (/help, /clear, future) to surface
|
|
/// local feedback without round-tripping the server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// LogTextType <c>0x00</c> Default, NOT <c>0x1A</c> (corrected 2026-08-09,
|
|
/// Opus review of 172c6f9a). This sink is <c>ClientCommandController</c>'s
|
|
/// general-purpose output — @version, /loc, friends list, usage lines —
|
|
/// and retail types the great majority of that informational command
|
|
/// output <c>0x00</c>, reserving <c>0x1A</c> (bright red) for genuine
|
|
/// refusals/errors.
|
|
/// </remarks>
|
|
/// <remarks>
|
|
/// <b>Comment corrected 2026-08-09, CH2 REJECT-review rework (NIT 2,
|
|
/// docs/research/2026-08-09-ch2-review-findings.md):</b> the earlier
|
|
/// wording claimed the refusal-vs-informational split "lands with CH2's
|
|
/// producer rewiring" — it did not. CH2's SpewBox routing covers
|
|
/// <c>WeenieError</c>/<c>WeenieErrorWithString</c> ids, which carry their
|
|
/// own resolved <c>RetailLogTextType</c>; this sink takes plain
|
|
/// pre-formatted TEXT with no error code attached, so
|
|
/// <c>WeenieErrorMessages</c> has nothing to classify here. Per-call-site
|
|
/// classification of THIS sink's callers (which specific
|
|
/// <c>ClientCommandController</c> lines are genuine refusals retail
|
|
/// would type <c>0x1A</c>) remains unstarted, and even a classified
|
|
/// caller would still need retail's <c>windowId</c> dual-destination
|
|
/// echo (see register row AP-180) to land in both the SpewBox and the
|
|
/// command's originating chat window — out of scope for CH4/CH5, not
|
|
/// CH2.
|
|
/// </remarks>
|
|
public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x00u);
|
|
|
|
/// <summary>
|
|
/// Route a retail-<c>0x1A</c> (<see cref="RetailLogTextType.ClientLocal"/>)
|
|
/// command refusal / usage line to the SpewBox — retail's
|
|
/// <c>ClientSystem::AddTextToScroll(text, 0x1A, 1, windowId) @0x00563C50</c>
|
|
/// destination for this text type is the SpewBox exclusively, never a
|
|
/// chat window (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c>
|
|
/// §2.1/§2.2).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Prefers <see cref="OnInterfaceText"/> when the App-layer host wired
|
|
/// it (the production graphical client). When unwired — headless, the
|
|
/// automation probe runner, or a test fixture that only exercises the
|
|
/// pure UI.Abstractions layer — the text still needs to reach the
|
|
/// player somewhere, so it falls back to the ordinary chat transcript
|
|
/// tagged with the real <see cref="RetailLogTextType.ClientLocal"/>
|
|
/// color rather than being silently dropped. That fallback lands in
|
|
/// the wrong PANEL (chat instead of SpewBox) but keeps the right TYPE
|
|
/// and never loses the line — the safe default issue #363 requires.
|
|
/// </remarks>
|
|
public void ShowInterfaceText(string text)
|
|
{
|
|
if (OnInterfaceText is { } hook)
|
|
hook(text);
|
|
else
|
|
_log.OnSystemMessage(text, chatType: (uint)RetailLogTextType.ClientLocal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drain the chat log. Used by the /clear client-side command.
|
|
/// </summary>
|
|
public void Clear() => _log.Clear();
|
|
|
|
/// <summary>
|
|
/// Forget per-session reply/retell destinations while retaining the shared
|
|
/// transcript. Old character names must not become command targets after a
|
|
/// reconnect.
|
|
/// </summary>
|
|
public void ResetSessionTargets()
|
|
{
|
|
_commandTargets.ResetSession();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Print the current framerate into chat. Used by
|
|
/// <c>/framerate</c> / <c>@framerate</c>. Falls back to a
|
|
/// helpful diagnostic line if no <see cref="FpsProvider"/>
|
|
/// is wired (test / pre-live-session scenarios).
|
|
/// </summary>
|
|
public void ShowFps()
|
|
{
|
|
var fps = FpsProvider?.Invoke();
|
|
ShowSystemMessage(fps is null
|
|
? "Framerate: (provider unavailable)"
|
|
: string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"Framerate: {fps.Value:F1} FPS"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Print the local player's world position into chat. Used by
|
|
/// <c>/loc</c> / <c>@loc</c>. Falls back to a helpful
|
|
/// diagnostic line if no <see cref="PositionProvider"/> is
|
|
/// wired (pre-EnterWorld / tests).
|
|
/// </summary>
|
|
public void ShowLocation()
|
|
{
|
|
var pos = PositionProvider?.Invoke();
|
|
ShowSystemMessage(pos is null
|
|
? "Location: (provider unavailable)"
|
|
: string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshot the tail of the chat log, formatted as display strings,
|
|
/// oldest-first. Never returns null; returns an empty array if the
|
|
/// log is empty.
|
|
/// </summary>
|
|
public IReadOnlyList<string> RecentLines()
|
|
{
|
|
var snap = _log.Snapshot();
|
|
int start = Math.Max(0, snap.Length - _displayLimit);
|
|
int count = snap.Length - start;
|
|
if (count <= 0) return Array.Empty<string>();
|
|
|
|
// OP4 re-review R1: read the option once per snapshot so every line
|
|
// in one frame renders consistently.
|
|
bool timestamps = _log.DisplayTimestampsSource?.Invoke() == true;
|
|
var lines = new string[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var entry = snap[start + i];
|
|
lines[i] = timestamps
|
|
? ChatLog.FormatTimestampPrefix(entry.Received) + FormatEntry(entry)
|
|
: FormatEntry(entry);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Format a single <see cref="ChatEntry"/> for display. Public so tests
|
|
/// can assert the per-kind formatting without touching a full log.
|
|
/// </summary>
|
|
public static string FormatEntry(ChatEntry entry)
|
|
=> FormatEntry(entry, static sender => sender);
|
|
|
|
/// <summary>
|
|
/// The lowest and highest object ids retail treats as a player, and
|
|
/// therefore the only senders it makes clickable.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// AC1's dynamic/player id range, read off the guard in
|
|
/// <c>Handle_Communication__HearSpeech @0x005712A0</c>: outside it, retail
|
|
/// emits the sender's name as plain text with no tag at all. Monsters and
|
|
/// NPCs therefore never become clickable, which is the behaviour we want
|
|
/// and would not get from a "has a name" test.
|
|
/// </remarks>
|
|
private const uint FirstPlayerObjectId = 0x50000001u;
|
|
private const uint LastPlayerObjectId = 0x6FFFFFFFu;
|
|
|
|
/// <summary>
|
|
/// Formats an entry with retail's tag markup around the sender's name,
|
|
/// when that sender is a player.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Shares its format strings with <see cref="FormatEntry(ChatEntry)"/>
|
|
/// through the sender decorator, deliberately: two copies of retail's
|
|
/// wording would be two things to keep in step, and the plain and tagged
|
|
/// renderings of a line MUST show the same characters — the transcript
|
|
/// selects and hit-tests against the flat text.
|
|
/// </remarks>
|
|
public static string FormatEntryTagged(ChatEntry entry)
|
|
=> ShouldTagSender(entry)
|
|
? FormatEntry(
|
|
entry,
|
|
sender =>
|
|
$"<Tell:IIDString:{entry.SenderGuid}:{sender}>{sender}<\\Tell>")
|
|
: FormatEntry(entry);
|
|
|
|
/// <summary>Whether this entry's sender is a clickable player.</summary>
|
|
/// <remarks>
|
|
/// A name containing a markup delimiter is deliberately NOT tagged. The
|
|
/// markup has no escape mechanism — retail's has none either, because AC
|
|
/// name validation makes the case unreachable there — so a name like
|
|
/// <c>Od<d</c> would be re-parsed as a marker and SWALLOW characters
|
|
/// out of the visible line. Sender names are server data, so the guard
|
|
/// stays: the line renders plain, exactly as it does for any other
|
|
/// untaggable sender, instead of rendering corrupted.
|
|
/// </remarks>
|
|
internal static bool ShouldTagSender(ChatEntry entry)
|
|
=> entry.SenderGuid >= FirstPlayerObjectId
|
|
&& entry.SenderGuid <= LastPlayerObjectId
|
|
&& !string.IsNullOrEmpty(entry.Sender)
|
|
&& entry.Sender.IndexOf('<') < 0
|
|
&& entry.Sender.IndexOf('>') < 0
|
|
&& !IsOwnSpeaker(entry.Sender)
|
|
&& entry.Kind is ChatKind.LocalSpeech
|
|
or ChatKind.RangedSpeech
|
|
or ChatKind.Channel
|
|
or ChatKind.Tell;
|
|
|
|
private static string FormatEntry(
|
|
ChatEntry entry, Func<string, string> decorateSender) => entry.Kind switch
|
|
{
|
|
// Retail style: "Name says, \"text\"" (incoming) /
|
|
// "You say, \"text\"" (own echo). Sender is "" for an
|
|
// OnSelfSent echo; OnLocalSpeech substitutes "You" when the
|
|
// server sends an empty sender (own-shout echoes). Both forms
|
|
// collapse to the singular "You say" verb here.
|
|
ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender)
|
|
? $"You say, \"{entry.Text}\""
|
|
: $"{decorateSender(entry.Sender)} says, \"{entry.Text}\"",
|
|
ChatKind.RangedSpeech => IsOwnSpeaker(entry.Sender)
|
|
? $"You shout, \"{entry.Text}\""
|
|
: $"{decorateSender(entry.Sender)} shouts, \"{entry.Text}\"",
|
|
// Channel: "[ChannelName] Sender says, \"text\"". ChannelName
|
|
// is populated by callers that know the friendly name (the
|
|
// TurbineChat inbound dispatch and OnSelfSent for Channel
|
|
// kinds); falls back to "ch {ChannelId}" if not set.
|
|
// Empty/"You" sender → "[Channel] You say, ..." for our own
|
|
// optimistic echo on legacy ChatChannel and self-broadcast on
|
|
// turbine channels (server's EventSendToRoom carries the
|
|
// sender name; OnSelfSent for legacy channels leaves it
|
|
// empty so the formatter substitutes here).
|
|
ChatKind.Channel => IsOwnSpeaker(entry.Sender)
|
|
? $"[{ChannelLabel(entry)}] You say, \"{entry.Text}\""
|
|
: $"[{ChannelLabel(entry)}] {decorateSender(entry.Sender)} says, \"{entry.Text}\"",
|
|
// Tell: SenderGuid != 0 means an incoming whisper; == 0 is the
|
|
// OnSelfSent echo where Sender carries the target name. Retail
|
|
// wording: "You tell Caith, \"hi\"" / "Caith tells you, \"hi\"".
|
|
ChatKind.Tell => entry.SenderGuid != 0
|
|
? $"{decorateSender(entry.Sender)} tells you, \"{entry.Text}\""
|
|
: $"You tell {entry.Sender}, \"{entry.Text}\"",
|
|
// Campaign CH user-gate round 1 (item B): retail prints system text
|
|
// bare, with no "[System]" prefix — that prefix was acdream's own
|
|
// invention. [Popup] stays (AP-175, a deliberate divergent
|
|
// presentation marker for a different kind).
|
|
ChatKind.System => entry.Text,
|
|
ChatKind.Popup => $"[Popup] {entry.Text}",
|
|
// Phase I.5: emote rendering matches retail's leading-asterisk
|
|
// convention ("* Caith waves at you"). SoulEmote uses the same
|
|
// prefix; the difference between Emote and SoulEmote is which
|
|
// animation pairs with the chat line (handled by the renderer,
|
|
// not the formatter).
|
|
ChatKind.Emote => $"* {entry.Sender} {entry.Text}",
|
|
ChatKind.SoulEmote => $"* {entry.Sender} {entry.Text}",
|
|
// Phase I.7: combat-line entries are pre-formatted by
|
|
// CombatChatTranslator using holtburger templates verbatim
|
|
// (chat.rs:221-308). The translator owns the wording; the VM
|
|
// just passes through. The panel uses TextColored based on
|
|
// entry.CombatKind.
|
|
ChatKind.Combat => entry.Text,
|
|
_ => entry.Text,
|
|
};
|
|
|
|
/// <summary>
|
|
/// True when a chat entry's <c>Sender</c> denotes the local player
|
|
/// — i.e. the entry came from <c>OnSelfSent</c> (empty sender) or
|
|
/// <c>OnLocalSpeech</c> with the empty-sender substitution kicked
|
|
/// in (sender == "You"). Used by the formatter to pick the
|
|
/// singular "You say" verb over the third-person "Name says".
|
|
/// </summary>
|
|
private static bool IsOwnSpeaker(string sender) =>
|
|
string.IsNullOrEmpty(sender) || sender == "You";
|
|
|
|
/// <summary>
|
|
/// Friendly channel label for a Channel entry. Prefers the entry's
|
|
/// <c>ChannelName</c> (set by callers that know the room name)
|
|
/// and falls back to "ch {ChannelId}" so legacy paths still
|
|
/// produce a readable line.
|
|
/// </summary>
|
|
private static string ChannelLabel(ChatEntry entry) =>
|
|
string.IsNullOrEmpty(entry.ChannelName)
|
|
? $"ch {entry.ChannelId}"
|
|
: entry.ChannelName;
|
|
|
|
/// <summary>
|
|
/// Phase I.7: snapshot of the chat tail with kind metadata so
|
|
/// <see cref="ChatPanel"/> can pick the right rendering primitive
|
|
/// per entry (plain <c>Text</c> for most kinds; <c>TextColored</c>
|
|
/// for combat lines, with the rgba chosen from
|
|
/// <see cref="ChatEntry.CombatKind"/>). Campaign CH slice CH1 also
|
|
/// carries <see cref="ChatEntry.LogTextType"/> through — the retail
|
|
/// color key, keyed independently of <see cref="ChatKind"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<FormattedLine> RecentLinesDetailed()
|
|
{
|
|
var snap = _log.Snapshot();
|
|
int start = Math.Max(0, snap.Length - _displayLimit);
|
|
int count = snap.Length - start;
|
|
if (count <= 0) return Array.Empty<FormattedLine>();
|
|
|
|
// OP4 re-review R1: retail prepends the timestamp to the COMPOSED
|
|
// display line (a separate leading string — fprintf("%ls%ls", ts,
|
|
// text) @0x00563e5b), never to the message body, so tells/says render
|
|
// '13:05:09 Alice says, "hi"' and not 'Alice says, "13:05:09 hi"'.
|
|
bool timestamps = _log.DisplayTimestampsSource?.Invoke() == true;
|
|
var lines = new FormattedLine[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var entry = snap[start + i];
|
|
|
|
// Compose with retail's tag markup, then split it. Text stays the
|
|
// VISIBLE line (markup consumed), so every existing consumer —
|
|
// wrapping, selection, hit-testing, the caret — is unaffected;
|
|
// 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 = tagged
|
|
? ChatTagMarkup.Parse(markup)
|
|
: null;
|
|
string text = spans is null
|
|
? markup
|
|
: string.Concat(spans.Select(span => span.Text));
|
|
|
|
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;
|
|
}
|
|
|
|
lines[i] = new FormattedLine(
|
|
Text: text,
|
|
Kind: entry.Kind,
|
|
CombatKind: entry.CombatKind,
|
|
LogTextType: entry.LogTextType,
|
|
Spans: spans);
|
|
}
|
|
return lines;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase I.7: formatted chat line with kind metadata. The
|
|
/// <see cref="ChatPanel"/> switches on <see cref="Kind"/> +
|
|
/// <see cref="CombatKind"/> to pick a rendering primitive
|
|
/// (<c>Text</c> vs <c>TextColored(rgba)</c>).
|
|
/// </summary>
|
|
/// <param name="LogTextType">
|
|
/// Campaign CH slice CH1: the retail wire <c>LogTextType</c> that keys
|
|
/// <see cref="RetailChatColorTable"/> — see <see cref="ChatEntry.LogTextType"/>.
|
|
/// </param>
|
|
/// <param name="Spans">
|
|
/// Campaign CT slice A3: the line split into stretches, where a stretch may
|
|
/// carry a retail text tag (a clickable speaker name). <see langword="null"/>
|
|
/// for the ordinary single-colour line, which is most of them.
|
|
/// <b>Invariant:</b> concatenating the span text reproduces
|
|
/// <paramref name="Text"/> exactly — the transcript wraps, selects and
|
|
/// hit-tests against that flat string.
|
|
/// </param>
|
|
public readonly record struct FormattedLine(
|
|
string Text,
|
|
ChatKind Kind,
|
|
CombatLineKind? CombatKind,
|
|
uint LogTextType,
|
|
IReadOnlyList<ChatTextSpan>? Spans = null);
|